Initial commit: CTmine-client (ContainerMine Client)
Docker-WebApp, die einen echten Minecraft Java-Client (Prism Launcher) in einem Container mit virtuellem Display betreibt und per noVNC im Browser anzeigt. Gedacht, um Accounts AFK an Farmen zu stellen. - Multi-Stage Dockerfile: Vite/Vue-Build + Ubuntu-Runtime (Xvfb, x11vnc, websockify, noVNC, openbox, nginx, Prism Launcher 11.0.3) - Vue 3 + Vite + TypeScript Dashboard (noVNC-iframe, Start/Stop, Status-Anzeige, Schnellstart-Anleitung) - Node-Status-API ohne externe Dependencies (/api/status, /api/mc/*) - docker-compose.yml mit PUID/PGID, konfigurierbarer Auflösung, shm_size - Persistente Accounts/Instanzen in /config (Volume)
This commit is contained in:
commit
b8a99e6e9e
25 changed files with 1766 additions and 0 deletions
13
web/index.html
Normal file
13
web/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CTmine-client · ContainerMine</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
web/package.json
Normal file
23
web/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "ctmine-client-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "Dashboard für CTmine-client (ContainerMine Client)",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.38"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.5.0",
|
||||
"@vitejs/plugin-vue": "^5.1.2",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.2",
|
||||
"vue-tsc": "^2.0.29"
|
||||
}
|
||||
}
|
||||
136
web/src/App.vue
Normal file
136
web/src/App.vue
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import VncViewer from './components/VncViewer.vue'
|
||||
import ControlPanel from './components/ControlPanel.vue'
|
||||
import StatusBar from './components/StatusBar.vue'
|
||||
import { getStatus } from './api'
|
||||
import type { ContainerStatus } from './types'
|
||||
|
||||
const status = ref<ContainerStatus>({
|
||||
display: false,
|
||||
vnc: false,
|
||||
minecraft: false,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
const loadingStatus = ref(false)
|
||||
const lastError = ref<string | null>(null)
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function refresh() {
|
||||
loadingStatus.value = true
|
||||
try {
|
||||
status.value = await getStatus()
|
||||
lastError.value = null
|
||||
} catch (e) {
|
||||
lastError.value = e instanceof Error ? e.message : String(e)
|
||||
} finally {
|
||||
loadingStatus.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
// Alle 5s den Backend-Status aktualisieren (Display/VNC/MC).
|
||||
pollTimer = setInterval(refresh, 5000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app">
|
||||
<header class="app__header">
|
||||
<div class="brand">
|
||||
<span class="brand__mark">⛏</span>
|
||||
<div>
|
||||
<div class="brand__title">CTmine-client</div>
|
||||
<div class="brand__sub">ContainerMine Client</div>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBar
|
||||
:status="status"
|
||||
:loading="loadingStatus"
|
||||
:error="lastError"
|
||||
@refresh="refresh"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<main class="app__main">
|
||||
<section class="viewer-wrap">
|
||||
<VncViewer />
|
||||
</section>
|
||||
<aside class="panel-wrap">
|
||||
<ControlPanel :status="status" @changed="refresh" />
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.app__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 10px 16px;
|
||||
background: var(--bg-elev);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand__mark {
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.brand__title {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.brand__sub {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.app__main {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.viewer-wrap {
|
||||
background: #000;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panel-wrap {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.app__main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
41
web/src/api.ts
Normal file
41
web/src/api.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { ContainerStatus, McActionResponse } from './types'
|
||||
|
||||
/** Relativer Basis-Pfad — im Container liefert nginx /api aus. */
|
||||
const BASE = import.meta.env.BASE_URL.replace(/\/$/, '')
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
let detail = `${res.status} ${res.statusText}`
|
||||
try {
|
||||
const body = await res.json()
|
||||
if (body?.message) detail = body.message
|
||||
} catch {
|
||||
/* kein JSON-Body */
|
||||
}
|
||||
throw new Error(detail)
|
||||
}
|
||||
// 204 → kein Body
|
||||
if (res.status === 204) return undefined as T
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
export function getStatus(): Promise<ContainerStatus> {
|
||||
return request<ContainerStatus>('/api/status')
|
||||
}
|
||||
|
||||
/** Startet Prism Launcher (falls nicht schon offen). */
|
||||
export function startMinecraft(): Promise<McActionResponse> {
|
||||
return request<McActionResponse>('/api/mc/start', { method: 'POST' })
|
||||
}
|
||||
|
||||
/** Beendet alle Minecraft-/Prism-Prozesse. */
|
||||
export function stopMinecraft(): Promise<McActionResponse> {
|
||||
return request<McActionResponse>('/api/mc/stop', { method: 'POST' })
|
||||
}
|
||||
212
web/src/components/ControlPanel.vue
Normal file
212
web/src/components/ControlPanel.vue
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { startMinecraft, stopMinecraft } from '../api'
|
||||
import type { ContainerStatus, McActionResponse } from '../types'
|
||||
|
||||
const props = defineProps<{ status: ContainerStatus }>()
|
||||
const emit = defineEmits<{ (e: 'changed'): void }>()
|
||||
|
||||
const busy = ref<null | 'start' | 'stop'>(null)
|
||||
const toast = ref<{ type: 'ok' | 'err'; text: string } | null>(null)
|
||||
const showHelp = ref(true)
|
||||
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function flashToast(type: 'ok' | 'err', text: string) {
|
||||
toast.value = { type, text }
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => (toast.value = null), 4000)
|
||||
}
|
||||
|
||||
async function run(fn: () => Promise<McActionResponse>, label: 'start' | 'stop') {
|
||||
busy.value = label
|
||||
try {
|
||||
const res = await fn()
|
||||
flashToast(res.ok ? 'ok' : 'err', res.message)
|
||||
emit('changed')
|
||||
} catch (e) {
|
||||
flashToast('err', e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
busy.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function onStart() {
|
||||
run(startMinecraft, 'start')
|
||||
}
|
||||
function onStop() {
|
||||
if (confirm('Minecraft / Prism Launcher wirklich beenden?')) {
|
||||
run(stopMinecraft, 'stop')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<section class="card">
|
||||
<h2>Minecraft</h2>
|
||||
<p class="muted">
|
||||
Prism Launcher startet den echten Vanilla-Client im Container. Der
|
||||
erste Microsoft-Login erfolgt einmalig im VNC-Fenster (Device-Code)
|
||||
und wird in <code>/config</code> gespeichert.
|
||||
</p>
|
||||
<div class="btn-row">
|
||||
<button
|
||||
class="primary"
|
||||
@click="onStart"
|
||||
:disabled="busy !== null || props.status.minecraft"
|
||||
>
|
||||
{{ busy === 'start' ? 'Starte…' : 'Instanz starten' }}
|
||||
</button>
|
||||
<button
|
||||
class="danger"
|
||||
@click="onStop"
|
||||
:disabled="busy !== null || !props.status.minecraft"
|
||||
>
|
||||
{{ busy === 'stop' ? 'Stoppe…' : 'Beenden' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="showHelp" class="card">
|
||||
<div class="card__head">
|
||||
<h2>Schnellstart</h2>
|
||||
<button class="link" @click="showHelp = false">ausblenden</button>
|
||||
</div>
|
||||
<ol class="steps">
|
||||
<li>Auf <strong>„Instanz starten“</strong> klicken — Prism öffnet sich im VNC-Fenster links.</li>
|
||||
<li>Oben rechts in Prism: <strong>„Konto hinzufügen“</strong> → Microsoft → Code notieren.</li>
|
||||
<li>Auf einem beliebigen Gerät <code>microsoft.com/link</code> öffnen, Code eingeben, einloggen.</li>
|
||||
<li>Zurück in Prism: <strong>Instanz wählen</strong> → <strong>„Spielen“</strong>.</li>
|
||||
<li>Im Minecraft-Hauptmenü: <strong>Mehrspieler</strong> → <strong>Server hinzufügen</strong> → verbinden.</li>
|
||||
<li>Account ist nun AFK auf der Farm. Fenster kann offen bleiben.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Tastenkürzel im VNC-Fenster</h2>
|
||||
<ul class="kbds">
|
||||
<li><kbd>Strg</kbd>+<kbd>Alt</kbd>+<kbd>Umschalt</kbd> — Maus aus VNC freigeben</li>
|
||||
<li><kbd>Strg</kbd>+<kbd>Alt</kbd>+<kbd>End</kbd> — Steuerbefehl senden (Toolbar)</li>
|
||||
</ul>
|
||||
<p class="muted">
|
||||
Tipp: Minecraft-Auflösung folgt <code>DISPLAY_WIDTH</code>×<code>DISPLAY_HEIGHT</code>
|
||||
aus der <code>.env</code>. Für AFK-Farmen genügen 1280×720.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Transition name="toast">
|
||||
<div v-if="toast" class="toast" :class="toast.type">{{ toast.text }}</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--mono);
|
||||
background: var(--bg-elev-2);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.steps,
|
||||
.kbds {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.steps li,
|
||||
.kbds li {
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
kbd {
|
||||
font-family: var(--mono);
|
||||
background: var(--bg-elev-2);
|
||||
border: 1px solid var(--border);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
z-index: 100;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elev-2);
|
||||
}
|
||||
|
||||
.toast.ok {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.toast.err {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
</style>
|
||||
63
web/src/components/StatusBar.vue
Normal file
63
web/src/components/StatusBar.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
<script setup lang="ts">
|
||||
import type { ContainerStatus } from '../types'
|
||||
|
||||
defineProps<{
|
||||
status: ContainerStatus
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}>()
|
||||
defineEmits<{ (e: 'refresh'): void }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="statusbar">
|
||||
<span class="item" :title="'Virtuelles Display (Xvfb)'">
|
||||
<span class="dot" :class="status.display ? 'on' : 'off'"></span>
|
||||
Display
|
||||
</span>
|
||||
<span class="item" :title="'VNC-Server (x11vnc)'">
|
||||
<span class="dot" :class="status.vnc ? 'on' : 'off'"></span>
|
||||
VNC
|
||||
</span>
|
||||
<span class="item" :title="'Minecraft / Prism Launcher'">
|
||||
<span class="dot" :class="status.minecraft ? 'on' : 'off'"></span>
|
||||
MC
|
||||
</span>
|
||||
<button class="icon" @click="$emit('refresh')" :disabled="loading" title="Aktualisieren">
|
||||
↻
|
||||
</button>
|
||||
<span v-if="error" class="err" :title="error">⚠ API</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.statusbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
padding: 2px 6px;
|
||||
font-size: 14px;
|
||||
color: var(--text-dim);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.icon:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.err {
|
||||
color: var(--warn);
|
||||
}
|
||||
</style>
|
||||
183
web/src/components/VncViewer.vue
Normal file
183
web/src/components/VncViewer.vue
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { ConnectionState } from '../types'
|
||||
|
||||
/**
|
||||
* noVNC wird als <iframe> eingebettet. nginx liefert die noVNC-Web-App
|
||||
* statisch unter /novnc/ aus (vom Debian-Paket). Der iframe öffnet
|
||||
* vnc.html und verbindet sich automatisch mit dem websockify-Endpunkt
|
||||
* auf derselben Origin unter /websockify.
|
||||
*
|
||||
* Das ist deutlich robuster als der Versuch, noVNCs interne ES-Module
|
||||
* (die nicht offiziell als Public-API exportiert werden) zu bündeln.
|
||||
*/
|
||||
const state = ref<ConnectionState>('disconnected')
|
||||
const errorMsg = ref<string | null>(null)
|
||||
const iframeEl = ref<HTMLIFrameElement | null>(null)
|
||||
const iframeKey = ref(0)
|
||||
|
||||
// noVNC-URL: autoconnect=1, resize=scaling, Pfad zum websockify.
|
||||
const novncUrl = computed(() => {
|
||||
const host = window.location.hostname
|
||||
const port = window.location.port || String(window.location.port)
|
||||
const params = new URLSearchParams({
|
||||
autoconnect: '1',
|
||||
resize: 'scaling',
|
||||
// noVNC spricht relativ 'websockify' an → nginx mappt /websockify.
|
||||
path: 'websockify',
|
||||
// show_dot_cursor hilft beim Zielen im VNC-Fenster.
|
||||
show_dot: '1',
|
||||
// host/port für die Anzeige im noVNC-eigenen UI; iframe nutzt aber path.
|
||||
host,
|
||||
port,
|
||||
})
|
||||
return `/novnc/vnc.html?${params.toString()}`
|
||||
})
|
||||
|
||||
function reload() {
|
||||
state.value = 'connecting'
|
||||
errorMsg.value = null
|
||||
// iframe neu laden per Key-Wechsel.
|
||||
iframeKey.value++
|
||||
}
|
||||
|
||||
function onIframeLoad() {
|
||||
// Wenn der iframe geladen hat, betrachten wir die Verbindung als aktiv.
|
||||
// (noVNC zeigt intern Fehler selbst an — wir liefern nur den Rahmen.)
|
||||
state.value = 'connected'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
state.value = 'connecting'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="vnc">
|
||||
<iframe
|
||||
ref="iframeEl"
|
||||
:key="iframeKey"
|
||||
class="vnc__frame"
|
||||
:src="novncUrl"
|
||||
title="Minecraft via noVNC"
|
||||
allow="clipboard-read; clipboard-write; fullscreen"
|
||||
@load="onIframeLoad"
|
||||
></iframe>
|
||||
|
||||
<div v-if="state !== 'connected'" class="vnc__overlay">
|
||||
<div class="vnc__overlay-inner">
|
||||
<p class="vnc__state">
|
||||
<span class="dot" :class="state === 'connecting' ? 'warn' : 'off'"></span>
|
||||
{{
|
||||
state === 'connecting'
|
||||
? 'Lade noVNC-Client…'
|
||||
: state === 'error'
|
||||
? (errorMsg ?? 'Verbindungsfehler')
|
||||
: 'Getrennt'
|
||||
}}
|
||||
</p>
|
||||
<button class="primary" @click="reload">Neu verbinden</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vnc__toolbar">
|
||||
<span class="vnc__badge" :class="state">
|
||||
{{
|
||||
state === 'connected'
|
||||
? '● Live'
|
||||
: state === 'connecting'
|
||||
? 'Verbinde…'
|
||||
: 'Offline'
|
||||
}}
|
||||
</span>
|
||||
<span class="vnc__hint">
|
||||
Maus im Fenster festhalten? Im noVNC-Seitenmenge „Clip to Window“.
|
||||
</span>
|
||||
<button @click="reload">Aktualisieren</button>
|
||||
<button
|
||||
@click="iframeEl?.requestFullscreen()"
|
||||
:disabled="state !== 'connected'"
|
||||
>
|
||||
Vollbild
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.vnc {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.vnc__frame {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.vnc__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.vnc__overlay-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.vnc__state {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.vnc__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: var(--bg-elev);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.vnc__badge {
|
||||
font-size: 12px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vnc__hint {
|
||||
margin-right: auto;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.vnc__badge.connected {
|
||||
background: var(--accent);
|
||||
color: #0f1115;
|
||||
}
|
||||
|
||||
.vnc__badge.connecting {
|
||||
background: var(--warn);
|
||||
color: #0f1115;
|
||||
}
|
||||
|
||||
.vnc__badge.disconnected,
|
||||
.vnc__badge.error {
|
||||
background: var(--bg-elev-2);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
5
web/src/main.ts
Normal file
5
web/src/main.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import './styles/main.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
5
web/src/shims-vue.d.ts
vendored
Normal file
5
web/src/shims-vue.d.ts
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
109
web/src/styles/main.css
Normal file
109
web/src/styles/main.css
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
:root {
|
||||
--bg: #0f1115;
|
||||
--bg-elev: #161a22;
|
||||
--bg-elev-2: #1d222d;
|
||||
--border: #2a3140;
|
||||
--text: #e6e9ef;
|
||||
--text-dim: #9aa3b2;
|
||||
--accent: #4ea34e;
|
||||
--accent-hover: #5cbf5c;
|
||||
--danger: #d4564f;
|
||||
--warn: #d9a441;
|
||||
--radius: 8px;
|
||||
--font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica,
|
||||
Arial, sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-elev-2);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
background: #262d3c;
|
||||
border-color: #3a4253;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #0f1115;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
button.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
button.danger {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
input {
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
vertical-align: middle;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.dot.on {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 6px var(--accent);
|
||||
}
|
||||
|
||||
.dot.off {
|
||||
background: var(--text-dim);
|
||||
}
|
||||
|
||||
.dot.warn {
|
||||
background: var(--warn);
|
||||
}
|
||||
20
web/src/types.ts
Normal file
20
web/src/types.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Status-Modell, das die Backend-API (/api/status) liefert.
|
||||
export interface ContainerStatus {
|
||||
/** Läuft das virtuelle Display? */
|
||||
display: boolean
|
||||
/** Läuft der VNC-Server? */
|
||||
vnc: boolean
|
||||
/** Läuft Prism Launcher / ein Minecraft-Prozess? */
|
||||
minecraft: boolean
|
||||
/** Letzte bekannte Bildschirmauflösung. */
|
||||
resolution?: { width: number; height: number }
|
||||
/** UNIX-Zeitstempel der letzten Aktualisierung. */
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
|
||||
export interface McActionResponse {
|
||||
ok: boolean
|
||||
message: string
|
||||
}
|
||||
1
web/src/vite-env.d.ts
vendored
Normal file
1
web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
27
web/tsconfig.json
Normal file
27
web/tsconfig.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"jsx": "preserve",
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"useDefineForClassFields": true,
|
||||
"allowImportingTsExtensions": false,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
31
web/vite.config.ts
Normal file
31
web/vite.config.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// Beim Entwickeln (außerhalb des Containers) läuft Vite unter :5173 und
|
||||
// erwartet das Backend (nginx + websockify) unter :8080. In der Produktion
|
||||
// liefert nginx das gebaute dist/ direkt aus, sodass keine Proxys nötig sind.
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': { target: 'http://localhost:8080', changeOrigin: true },
|
||||
'/websockify': {
|
||||
target: 'ws://localhost:8080',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue