This repository has been archived on 2026-07-22. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
mission-control/frontend/src/panels/ServerPanel.svelte
T
Hitonabi a93751fb01 feat(dashboard): Dashboard-Redesign + Bugfixes + Pre-load
Bugs behoben:
- Topbar + Server-Panel: 'Kein Modell im VRAM' obwohl geladen → alle
  States (running/ready/loading/starting) werden jetzt erkannt
- Topbar zeigt bei geladenem Modell: Name + ctx-Größe + State mit Farbe

Dashboard-Redesign (OverviewPanel):
- KPI-Kacheln mit Farbe: .kpi.green/.red/.blue/.muted statt .tile
  (farbige Randlinien + farbige Werte wie im Referenz-Design)
- Icons pro Kachel (server, cpu, thermo, database)
- Stack-Liste: zeigt ctx/quant/GB, 'Laden'-Button wenn Modell bereit
  aber nicht geladen, ∞-Chip für immer-aktive Modelle
- 4. Schnellstart-Button: 'Tools verbinden'

Feature: Modell Vorwärmen (Pre-load):
- POST /api/preload?model=<name> — sendet Mini-Request an llama-swap
  damit es das Modell vorab lädt (max_tokens=1, kein Output)
- 'Laden'-Button je Modell in der Stack-Liste

Topbar CSS:
- Aktives Modell: teal-hinterlegter Pill wenn Modell geladen
- Kontext-Größe direkt neben Modellname angezeigt

Cookbook:
- Discover: genaue Zeitanzeige wann Quellen zuletzt befragt wurden
  ('vor X min / h / Tg.') statt nur 'aktuell'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 08:04:40 +02:00

261 lines
13 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { statusStore } from '../stores/status.svelte.js'
import { jobsStore } from '../stores/jobs.svelte.js'
import { api, getToken } from '@core/api.js'
import { esc, toast, confirmModal, promptModal } from '@core/ui.js'
const status = $derived(statusStore.value)
const jobs = $derived(jobsStore.value)
// Console
let ws: WebSocket | null = null
let consoleLogs = $state('Verbinde…\n')
let selectedService = $state('llama-swap')
let consoleEl: HTMLDivElement | undefined
// Active job tracking
let activeJobId = $state<string | null>(null)
let activeJobLabel = $state('')
const notified = new Set<string>()
const activeJob = $derived(activeJobId ? jobs.find((j: any) => j.id === activeJobId) : null)
// Auto-scroll + job finish notification
$effect(() => {
if (!activeJob) return
const j = activeJob as any
const done = j.state === 'done', failed = j.state === 'failed', canceled = j.state === 'canceled'
if ((done || failed || canceled) && !notified.has(j.id)) {
notified.add(j.id)
toast(done ? `✓ ${activeJobLabel} abgeschlossen.` : canceled ? `${activeJobLabel} abgebrochen.` : `✗ ${activeJobLabel} fehlgeschlagen.`, failed)
if (done) setTimeout(() => document.dispatchEvent(new Event('mc:refresh')), 800)
}
})
function watchJob(id: string, label: string) {
activeJobId = id
activeJobLabel = label
jobsStore.track(id)
}
// Expose track for use from other panels (server panel tracks its own jobs)
onMount(() => { connectConsole() })
onDestroy(() => { ws?.close() })
function connectConsole() {
ws?.close()
const svc = selectedService
consoleLogs = `Verbinde mit ${svc}\n`
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
ws = new WebSocket(`${proto}//${location.host}/api/logs/${svc}?token=${encodeURIComponent(getToken())}`)
ws.onmessage = e => {
consoleLogs += e.data
if (consoleEl) consoleEl.scrollTop = consoleEl.scrollHeight
}
ws.onclose = () => { consoleLogs += '\n— Verbindung getrennt —' }
ws.onerror = () => ws?.close()
}
async function restartService(name: string, title: string, body: string) {
if (!await confirmModal({ title, body, confirmLabel: 'Neustarten' })) return
try {
await api(`/api/service/${name}/restart`, { method: 'POST' })
toast('Neustart ausgelöst: ' + name)
setTimeout(() => document.dispatchEvent(new Event('mc:refresh')), 2000)
} catch (e: any) { toast(e.message, true) }
}
async function selfUpdate() {
if (!await confirmModal({ title: 'Mission Control aktualisieren?', body: 'Holt die neueste Dashboard-Version aus Gitea und startet automatisch neu. Die Seite trennt kurz und verbindet von selbst wieder (~10 Sek).' })) return
try { const r = await api('/api/self-update', { method: 'POST' }); toast('Update läuft — Dashboard startet gleich neu.'); watchJob(r.job_id, 'Mission Control Update') }
catch (e: any) { toast(e.message, true) }
}
async function engineUpdate() {
if (!await confirmModal({ title: 'LLM-Engine aktualisieren?', body: 'Lädt die neueste llama.cpp-ROCm-Version und installiert sie. Die Engine ist während des Updates kurz nicht erreichbar.' })) return
try { const r = await api('/api/update', { method: 'POST' }); toast('LLM-Engine-Update läuft.'); watchJob(r.job_id, 'LLM-Engine-Update') }
catch (e: any) { toast(e.message, true) }
}
async function unloadAll() {
if (!await confirmModal({ title: 'Grafikspeicher leeren?', body: 'Alle geladenen Modelle werden entladen. Sie laden beim nächsten Aufruf automatisch neu — es geht nichts verloren.', confirmLabel: 'Leeren' })) return
try { await api('/api/unload', { method: 'POST' }); toast('Grafikspeicher geleert.'); setTimeout(() => document.dispatchEvent(new Event('mc:refresh')), 600) }
catch (e: any) { toast(e.message, true) }
}
async function osUpdate() {
if (!await confirmModal({ title: 'OS-Updates installieren?', body: 'Führt <code>apt update & upgrade</code> aus. Das kann ein paar Minuten dauern; der Fortschritt erscheint in der Aktivität.' })) return
const pwd = await promptModal({ title: 'sudo-Passwort', body: 'Für die System-Updates wird einmalig dein sudo-Passwort gebraucht.', placeholder: 'sudo-Passwort', password: true, confirmLabel: 'Installieren' })
if (!pwd) return
try { const r = await api('/api/os-update', { method: 'POST', body: JSON.stringify({ password: pwd }) }); toast('OS-Update gestartet.'); watchJob(r.job_id, 'OS-Update') }
catch (e: any) { toast(e.message, true) }
}
async function rebootServer() {
if (!await confirmModal({ title: 'Server wirklich neu starten?', body: 'Der ganze Bosgame startet physisch neu. Alles ist für ~1 Minute offline — auch dieses Dashboard.', confirmLabel: 'Reboot', danger: true })) return
const pwd = await promptModal({ title: 'sudo-Passwort', body: 'Für den Reboot wird einmalig dein sudo-Passwort gebraucht.', placeholder: 'sudo-Passwort', password: true, confirmLabel: 'Jetzt neustarten', danger: true })
if (!pwd) return
try { await api('/api/reboot', { method: 'POST', body: JSON.stringify({ password: pwd }) }); toast('Reboot ausgelöst — bis gleich.') }
catch (e: any) { toast(e.message, true) }
}
async function checkSwap() {
swapCheckResult = 'prüfe…'
try {
const r = await api('/api/integration/test')
swapCheckResult = r.ok ? `✓ Engine antwortet — ${r.models.length} Modell(e): ${r.models.join(', ')}` : `✗ keine Verbindung: ${r.error}`
swapCheckOk = r.ok
} catch (e: any) { swapCheckResult = '✗ ' + e.message; swapCheckOk = false }
}
let swapCheckResult = $state('')
let swapCheckOk = $state(false)
const swapModels = $derived((status?.models || []) as any[])
const LOADED = ['running', 'ready', 'loading', 'starting']
const loadedModel = $derived(swapModels.find((m: any) => LOADED.includes(m.state)))
// Job progress helpers
function jobPct(j: any) {
if (!j || j.state !== 'running') return null
if (typeof j.progress === 'number') return Math.min(100, j.progress)
const last = (j.log || []).at(-1) || ''
const m = last.match(/(\d+(?:\.\d+)?)\s*%/)
return m ? Math.min(100, parseFloat(m[1])) : null
}
function qa(tone: string, title: string, desc: string, fn: () => void, danger = false) {
return { tone, title, desc, fn, danger }
}
</script>
<div class="pagehead">
<div>
<h1>Server &amp; Wartung</h1>
<div class="sub">Dienste steuern, Updates einspielen und live mitlesen — ganz ohne SSH oder Terminal.</div>
</div>
</div>
<!-- LLM-Engine Card -->
<div class="card">
<div class="card-h">
<h3>LLM-Engine (llama-swap)</h3>
<span class="chip" style="margin-left:auto;{status?.swap_ok
? 'background:rgba(63,185,80,.12);border-color:rgba(63,185,80,.25);color:#7ee29a'
: 'background:rgba(248,81,73,.12);border-color:rgba(248,81,73,.3);color:#ff9b95'}">
{status?.swap_ok ? '● erreichbar' : '● offline'}
</span>
</div>
<div class="card-sub">Das Herzstück: lädt bei jeder Anfrage automatisch das passende Modell.</div>
<div class="kv" style="margin-bottom:6px">
<div class="kv-row"><span class="kv-k">Adresse (für deine Tools)</span><span class="kv-v mono-sm">{(status?.swap_url || '—') + '/v1'}</span></div>
<div class="kv-row"><span class="kv-k">Aktuell geladen</span>
<span class="kv-v">{loadedModel ? loadedModel.name + ` (${loadedModel.state})` : 'nichts geladen — lädt bei der nächsten Anfrage'}</span>
</div>
<div class="kv-row"><span class="kv-k">Konfigurierte Modelle</span>
<span class="kv-v">{swapModels.filter((m: any) => !m.incomplete).length || 'keine'}</span>
</div>
</div>
<div class="btn-row" style="display:flex;align-items:center;gap:10px">
<button class="ghost" onclick={checkSwap}>Verbindung prüfen</button>
{#if swapCheckResult}
<span class="mono-sm" style="color:{swapCheckOk ? '#7ee29a' : '#ff9b95'}">{swapCheckResult}</span>
{/if}
</div>
</div>
<!-- Service Actions -->
<div class="card">
<div class="card-h"><h3>Dienste &amp; Applikation</h3></div>
<div class="card-sub">Diese Aktionen sind harmlos und passwortlos — es geht nichts dabei verloren.</div>
<button class="qa" onclick={() => restartService('llama-swap', 'LLM-Engine neustarten?', 'Die Engine startet neu. Geladene Modelle werden kurz entladen (~5 Sekunden), laden danach automatisch wieder.')}>
<span class="qa-ic teal"></span>
<span class="qa-main"><span class="qa-t">LLM-Engine neustarten</span><span class="qa-s">Startet llama-swap neu. Geladene Modelle laden danach automatisch wieder.</span></span>
<span class="qa-arrow"></span>
</button>
<button class="qa" onclick={() => restartService('mission-control', 'Dashboard neustarten?', 'Diese Oberfläche trennt sich kurz und verbindet automatisch wieder.')}>
<span class="qa-ic blue"></span>
<span class="qa-main"><span class="qa-t">Dashboard neustarten</span><span class="qa-s">Startet diese Oberfläche neu (~5 Sek).</span></span>
<span class="qa-arrow"></span>
</button>
<button class="qa" onclick={selfUpdate}>
<span class="qa-ic teal"></span>
<span class="qa-main"><span class="qa-t">Mission Control aktualisieren</span><span class="qa-s">Holt die neueste Version aus Gitea und startet automatisch neu (~10 Sek).</span></span>
<span class="qa-arrow"></span>
</button>
<button class="qa" onclick={engineUpdate}>
<span class="qa-ic blue"></span>
<span class="qa-main"><span class="qa-t">LLM-Engine aktualisieren</span><span class="qa-s">Lädt die neueste llama.cpp-Version (ROCm) für deine GPU.</span></span>
<span class="qa-arrow"></span>
</button>
<button class="qa" onclick={unloadAll}>
<span class="qa-ic amber">🗄</span>
<span class="qa-main"><span class="qa-t">Grafikspeicher leeren</span><span class="qa-s">Wirft alle aktuell geladenen Modelle aus dem VRAM. Fragt vorher nach.</span></span>
<span class="qa-arrow"></span>
</button>
</div>
<!-- OS Actions -->
<div class="card">
<div class="card-h"><h3>Betriebssystem (Bosgame)</h3></div>
<div class="card-sub">Tiefe Eingriffe — das Dashboard fragt einmalig nach deinem sudo-Passwort.</div>
<button class="qa" onclick={osUpdate}>
<span class="qa-ic amber"></span>
<span class="qa-main"><span class="qa-t">OS-Updates installieren</span><span class="qa-s">Führt apt update &amp; upgrade aus. Kann ein paar Minuten dauern.</span></span>
<span class="qa-arrow"></span>
</button>
<button class="qa qa-danger" onclick={rebootServer}>
<span class="qa-ic red"></span>
<span class="qa-main"><span class="qa-t">Server neustarten (Reboot)</span><span class="qa-s">Bootet den ganzen Bosgame neu. Alles ist für ~1 Minute offline.</span></span>
<span class="qa-arrow"></span>
</button>
</div>
<!-- Active Job Card -->
{#if activeJob}
{@const j = activeJob as any}
{@const done = j.state === 'done'}
{@const failed = j.state === 'failed'}
{@const canceled = j.state === 'canceled'}
{@const terminal = done || failed || canceled}
{@const p = jobPct(j)}
<div class="card">
<div class="card-h">
<h3>Aktueller Vorgang</h3>
{#if !terminal}
<button class="ghost" style="margin-left:auto;padding:3px 12px"
onclick={async () => {
if (await confirmModal({ title: activeJobLabel + ' abbrechen?', body: 'Der laufende Vorgang wird gestoppt.', confirmLabel: 'Abbrechen', danger: true }))
api(`/api/jobs/${j.id}/cancel`, { method: 'POST' })
}}>Abbrechen</button>
{/if}
<span style="margin-left:10px">
{#if done}<span class="badge b-run">✓ fertig</span>
{:else if failed}<span class="badge b-err">✗ fehlgeschlagen</span>
{:else if canceled}<span class="badge">abgebrochen</span>
{:else}<span class="badge b-load">läuft…</span>{/if}
</span>
</div>
<div class="card-sub">{activeJobLabel}{terminal ? (done ? ' — erfolgreich.' : canceled ? ' — abgebrochen.' : ' — fehlgeschlagen.') : ' läuft…'}</div>
{#if p != null && !terminal}
<div class="bar" style="margin-bottom:10px"><i style="width:{p}%"></i></div>
{/if}
<div class="log" style="max-height:280px">{(j.log || []).join('\n')}</div>
</div>
{/if}
<!-- Live Console -->
<div class="card">
<div class="card-h">
<h3>Live-Konsole</h3>
<select bind:value={selectedService} onchange={connectConsole} style="margin:0 0 0 auto;width:240px">
<option value="llama-swap">LLM-Engine (llama-swap)</option>
<option value="mission-control">Dashboard (mission-control)</option>
<option value="system">System (ganzer Server)</option>
</select>
</div>
<div class="card-sub">Live mitlesen, was der Dienst gerade tut.</div>
<div class="console" bind:this={consoleEl}>{consoleLogs}</div>
</div>