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>
This commit is contained in:
Hitonabi
2026-06-23 08:04:40 +02:00
parent f536f454ba
commit a93751fb01
7 changed files with 186 additions and 95 deletions
+9 -4
View File
@@ -37,11 +37,16 @@ function applyStatus(s: any) {
const host = (s.swap_url || '').replace(/^https?:\/\//, '')
dot && (dot.className = 'dot ' + (s.swap_ok ? 'on' : 'off'))
label && (label.textContent = (s.swap_ok ? 'LLM-Engine: Online ' : 'LLM-Engine: Offline ') + host)
const active = (s.models || []).find((m: any) => m.state === 'running')
const LOADED = ['running', 'ready', 'loading', 'starting']
const active = (s.models || []).find((m: any) => LOADED.includes(m.state))
if (activeText) {
activeText.innerHTML = active
? `Geladen: <b style="color:var(--teal)">${active.name}</b>`
: 'Kein Modell im VRAM'
if (active) {
const ctx = active.meta?.ctx ? ` <span style="color:var(--mut);font-size:11.5px">· ${Math.round(active.meta.ctx / 1024)}k ctx</span>` : ''
const stateLabel = active.state === 'loading' || active.state === 'starting' ? ' <span style="color:var(--warn);font-size:11.5px">lädt…</span>' : ''
activeText.innerHTML = `<b style="color:var(--accent)">${active.name}</b>${ctx}${stateLabel}`
} else {
activeText.innerHTML = '<span style="color:var(--mut)">Kein Modell im VRAM</span>'
}
}
if (s.swap_ok) hideAlert()
else showAlert(`LLM-Engine nicht erreichbar unter <b>${host}</b> läuft der llama-swap Dienst?`, true)
+14 -2
View File
@@ -302,12 +302,24 @@
<div class="card">
<div class="card-h">
<h3>Aktuell die besten Modelle für dein System</h3>
<span class="meta" id="cb-disc-when">{discoverData ? 'aktuell' : ''}</span>
<span class="meta" style="margin-left:auto">
{#if discoverData?.updated}
{@const ageMin = Math.round((Date.now() / 1000 - discoverData.updated) / 60)}
{#if ageMin < 60}
Quellen befragt vor {ageMin} min
{:else if ageMin < 1440}
Quellen befragt vor {Math.round(ageMin / 60)} h
{:else}
Quellen befragt vor {Math.round(ageMin / 1440)} Tg.
{/if}
{#if discoverStale}<span style="color:var(--warn)"> · ⚠ Cache veraltet</span>{/if}
{/if}
</span>
<button class="ghost" style="margin-left:8px" disabled={discoverLoading} onclick={() => loadDiscover(true)}>
{discoverLoading ? 'Suche…' : 'Aktualisieren'}
</button>
</div>
<div class="card-sub">Täglich aktualisierte Top-Modelle je Kategorie — Hardware-Fit berechnet für dein System. {discoverStale ? '⚠ Cache veraltet.' : ''}</div>
<div class="card-sub">Top-Modelle je Kategorie von HuggingFace — Hardware-Fit wird live für dein System berechnet.</div>
{#if discoverLoading && !discoverData}
<div class="empty" style="text-align:center;padding:30px">Frage Quellen ab…</div>
{:else if discoverData?.categories}
+110 -63
View File
@@ -3,13 +3,15 @@
import { statusStore } from '../stores/status.svelte.js'
import { systemStore } from '../stores/system.svelte.js'
import { api } from '@core/api.js'
import { confirmModal, icon } from '@core/ui.js'
import { confirmModal, icon, fmtBytes } from '@core/ui.js'
const s = $derived(statusStore.value)
const sys = $derived(systemStore.value)
const allModels = $derived((s?.models || []) as any[])
const swapOk = $derived(s?.swap_ok ?? false)
const activeModel = $derived(allModels.find((m: any) => ['running','ready','loading','starting'].includes(m.state)))
const LOADED = ['running', 'ready', 'loading', 'starting']
const activeModel = $derived(allModels.find((m: any) => LOADED.includes(m.state)))
const configuredModels = $derived(allModels.filter((m: any) => !m.incomplete))
const heroTitle = $derived(
!s ? 'Verbinde…'
@@ -21,98 +23,123 @@
!s ? ''
: !swapOk ? 'Der llama-swap-Dienst antwortet gerade nicht.'
: sys && sys.ram.percent >= 90 ? `Arbeitsspeicher bei ${Math.round(sys.ram.percent)} % — eventuell Speicher freigeben.`
: `${allModels.length} ${allModels.length === 1 ? 'Modell' : 'Modelle'} bereit · ${activeModel ? `„${activeModel.name}" geladen` : 'keins geladen'} · keine Warnungen.`
: `${configuredModels.length} ${configuredModels.length === 1 ? 'Modell' : 'Modelle'} konfiguriert · ${activeModel ? `„${activeModel.name}" geladen` : 'keines geladen'} · keine Warnungen.`
)
const gpuPct = $derived(() => {
const g = sys?.gpu
if (g && (g.vram.total + g.gtt.total) > 0)
return ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100
return 0
})
const tempWord = (t: number | null) =>
t == null ? '' : t < 55 ? 'kühl & gesund' : t < 72 ? 'normal' : 'läuft heiß'
let news = $state<any[]>([])
let preloadingModel = $state<string | null>(null)
onMount(async () => {
try { news = ((await api('/api/news')).items || []).slice(0, 4) } catch {}
})
async function freeMemory() {
const ok = await confirmModal({
if (!await confirmModal({
title: 'Speicher freigeben?',
body: 'Alle aktuell geladenen Modelle werden aus dem Grafikspeicher geworfen. Sie laden beim nächsten Aufruf automatisch neu — es geht nichts verloren.',
body: 'Alle aktuell geladenen Modelle werden aus dem Grafikspeicher geworfen. Sie laden beim nächsten Aufruf automatisch neu.',
confirmLabel: 'Speicher freigeben',
})
if (!ok) return
})) return
await api('/api/unload', { method: 'POST' })
document.dispatchEvent(new Event('mc:refresh'))
refresh()
}
async function preload(modelName: string) {
preloadingModel = modelName
try { await api('/api/preload?model=' + encodeURIComponent(modelName), { method: 'POST' }) }
catch {}
preloadingModel = null
refresh()
}
function refresh() { document.dispatchEvent(new Event('mc:refresh')) }
function go(view: string) {
document.querySelector(`.nav-item[data-view="${view}"]`)?.dispatchEvent(new MouseEvent('click'))
}
function capTagClass(caps: string[]) {
if (caps?.includes('Bild')) return 'img'
if (caps?.includes('Code')) return 'code'
return 'text'
}
function capTagLabel(caps: string[]) {
if (caps?.includes('Bild')) return 'Bild'
if (caps?.includes('Code')) return 'Code'
return 'Text'
}
function ramColor(pct: number) { return pct >= 90 ? 'red' : pct >= 75 ? '' : 'green' }
function tempColor(t: number | null) { return t == null ? '' : t >= 75 ? 'red' : t >= 55 ? '' : 'green' }
function gpuColor(pct: number) { return pct >= 90 ? 'red' : pct >= 70 ? '' : 'green' }
</script>
<div class="pagehead">
<!-- Hero -->
<div class="pagehead" style="margin-bottom:10px">
<div>
<h1>{heroTitle}</h1>
<h1 style="display:flex;align-items:center;gap:10px">
{heroTitle}
{#if swapOk && !activeModel}
<span style="font-size:13px;font-weight:400;color:var(--mut)">— bereit für Anfragen</span>
{/if}
</h1>
<div class="sub">{heroSub}</div>
</div>
</div>
<!-- KPI Tiles -->
<div class="tiles">
<div class="tile">
<div class="t-l">LLM-Engine</div>
<div class="t-v {swapOk ? 'ok' : 'bad'}">{swapOk ? 'Online' : 'Offline'}</div>
<div class="t-s">llama-swap</div>
<div class="kpi {swapOk ? 'green' : 'red'}">
<div class="k-h">
<span class="k-t">LLM-Engine</span>
<span class="k-ic">{@html icon('server')}</span>
</div>
<div class="k-v">{swapOk ? 'Online' : 'Offline'}</div>
<div class="k-s">{s?.swap_url?.replace(/^https?:\/\//, '') ?? '—'}</div>
</div>
<div class="tile">
<div class="t-l">Aktives Modell</div>
<div class="t-v">{activeModel ? activeModel.name : 'Keins'}</div>
<div class="t-s">{activeModel ? 'im Grafikspeicher' : 'nichts geladen'}</div>
<div class="kpi {activeModel ? 'blue' : 'muted'}">
<div class="k-h">
<span class="k-t">Aktives Modell</span>
<span class="k-ic">{@html icon('cpu')}</span>
</div>
<div class="k-v" style="font-size:16px;word-break:break-word">{activeModel ? activeModel.name : 'Keins'}</div>
<div class="k-s">
{#if activeModel}
{activeModel.meta?.ctx ? Math.round(activeModel.meta.ctx / 1024) + 'k ctx' : ''} · {activeModel.state}
{:else}
lädt bei erster Anfrage
{/if}
</div>
</div>
{#if sys}
{@const t = sys.gpu_temp ?? sys.cpu?.temp ?? null}
<div class="tile">
<div class="t-l">GPU-Temperatur</div>
<div class="t-v">{t != null ? `${Math.round(t)}°` : ''}</div>
<div class="t-s {t != null && t < 72 ? 'ok' : ''}">{tempWord(t)}</div>
{@const tCol = tempColor(t)}
<div class="kpi {tCol}">
<div class="k-h"><span class="k-t">Temperatur (GPU)</span><span class="k-ic">{@html icon('thermo')}</span></div>
<div class="k-v">{t != null ? `${Math.round(t)}°C` : ''}</div>
<div class="k-s">{t == null ? 'messe…' : t < 55 ? 'kühl' : t < 75 ? 'normal' : '⚠ heiß'}</div>
</div>
{@const free = sys.ram.total - sys.ram.used}
<div class="tile">
<div class="t-l">Freier Speicher</div>
<div class="t-v">{Math.round(free / 1024 ** 3)}<small> GB</small></div>
<div class="t-s">von {Math.round(sys.ram.total / 1024 ** 3)} GB</div>
{@const freePct = 100 - sys.ram.percent}
<div class="kpi {freePct < 15 ? 'red' : freePct < 30 ? '' : 'green'}">
<div class="k-h"><span class="k-t">Freier Speicher</span><span class="k-ic">{@html icon('database')}</span></div>
<div class="k-v">{Math.round(free / 1024 ** 3)}<small> GB</small></div>
<div class="k-s">von {Math.round(sys.ram.total / 1024 ** 3)} GB · {Math.round(freePct)} % frei</div>
</div>
{:else}
<div class="tile"><div class="t-l">GPU-Temperatur</div><div class="t-v"></div><div class="t-s">messe…</div></div>
<div class="tile"><div class="t-l">Freier Speicher</div><div class="t-v"></div><div class="t-s">messe…</div></div>
<div class="kpi muted"><div class="k-h"><span class="k-t">Temperatur</span></div><div class="k-v"></div><div class="k-s">messe…</div></div>
<div class="kpi muted"><div class="k-h"><span class="k-t">Freier Speicher</span></div><div class="k-v"></div><div class="k-s">messe…</div></div>
{/if}
</div>
<!-- Split: Health + Quickstart -->
<div class="split">
<div class="card">
<div class="card-h"><h3>System-Gesundheit</h3></div>
<div class="card-sub">So ausgelastet ist dein Mini-PC gerade.</div>
<div class="card-sub">Auslastung in Echtzeit.</div>
{#if sys}
{@const gpu = gpuPct()}
{#each [
['Prozessor (CPU)', sys.cpu?.percent ?? 0],
['Arbeitsspeicher (RAM)', sys.ram.percent],
['Grafikspeicher (VRAM)', gpu]
] as [label, pct]}
['Prozessor (CPU)', sys.cpu?.percent ?? 0, ''],
['Arbeitsspeicher (RAM)', sys.ram.percent, ramColor(sys.ram.percent)],
['Grafikspeicher (VRAM)', gpu, gpuColor(gpu)]
] as [label, pct, cls]}
{@const p = Math.max(0, Math.min(100, (pct as number) || 0))}
<div class="meter">
<div class="meter-h"><span class="mk">{label}</span><span class="mv">{Math.round(p)} %</span></div>
@@ -134,7 +161,7 @@
</button>
<button class="qa" onclick={freeMemory}>
<span class="qa-ic amber">{@html icon('refresh')}</span>
<span class="qa-main"><span class="qa-t">Speicher freigeben</span><span class="qa-s">Modelle entladen · fragt vorher nach</span></span>
<span class="qa-main"><span class="qa-t">Speicher freigeben</span><span class="qa-s">Alle Modelle entladen</span></span>
<span class="qa-arrow">{@html icon('chevron')}</span>
</button>
<button class="qa" onclick={() => go('server')}>
@@ -142,38 +169,57 @@
<span class="qa-main"><span class="qa-t">Logs ansehen</span><span class="qa-s">Live mitlesen, was läuft</span></span>
<span class="qa-arrow">{@html icon('chevron')}</span>
</button>
<button class="qa" onclick={() => go('connect')}>
<span class="qa-ic teal">{@html icon('swap')}</span>
<span class="qa-main"><span class="qa-t">Tools verbinden</span><span class="qa-s">Zed, OpenCode, Cline einrichten</span></span>
<span class="qa-arrow">{@html icon('chevron')}</span>
</button>
</div>
</div>
<!-- Stack + News -->
<div class="grid grid-2">
<div class="card">
<div class="card-h">
<h3>Dein Stack</h3>
<span class="meta">{allModels.length ? allModels.length + ' konfiguriert' : ''}</span>
<span class="meta">{configuredModels.length ? configuredModels.length + ' konfiguriert' : ''}</span>
</div>
{#if allModels.length}
{#if configuredModels.length}
<div class="list">
{#each allModels as m}
{#each configuredModels as m}
{@const isLoaded = LOADED.includes(m.state)}
{@const downloading = m.state === 'downloading'}
{@const on = ['running','ready','loading','starting'].includes(m.state)}
{@const dot = (m.state === 'loading' || m.state === 'starting' || downloading) ? 'load' : on ? 'on' : ''}
{@const dot = (m.state === 'loading' || m.state === 'starting' || downloading) ? 'load' : isLoaded ? 'on' : ''}
{@const statusText = downloading
? `↓ Download${m.download_progress != null ? ` ${Math.round(m.download_progress)}%` : ''}`
: on ? (m.state === 'loading' ? 'lädt…' : 'geladen') : 'bereit'}
<div class="li">
? `↓ ${m.download_progress != null ? Math.round(m.download_progress) + '%' : '...'}`
: m.state === 'loading' || m.state === 'starting' ? 'lädt…'
: isLoaded ? 'geladen ✓' : 'bereit'}
<div class="li" style="padding:10px 4px">
<span class="li-dot {dot}"></span>
<div class="li-main">
<div class="flex items-center gap-2">
<span class="li-id">{m.name}</span>
{#if m.meta?.caps?.length}
<span class="tag {capTagClass(m.meta.caps)}">{capTagLabel(m.meta.caps)}</span>
{/if}
<div class="flex items-center gap-2" style="flex-wrap:wrap">
<span class="li-id" style="{isLoaded ? 'color:var(--accent)' : ''}">{m.name}</span>
{#if m.meta?.caps?.includes('Bild')}<span class="tag img">Bild</span>
{:else if m.meta?.caps?.includes('Code')}<span class="tag code">Code</span>
{:else}<span class="tag text">Text</span>{/if}
{#if m.ttl >= 99999}<span class="chip" style="padding:2px 7px;font-size:11px">∞ aktiv</span>{/if}
</div>
{#if m.meta?.filename}
<div class="li-sub mono-sm">{m.meta.filename}</div>
<div class="li-sub mono-sm">
{m.meta?.ctx ? Math.round(m.meta.ctx / 1024) + 'k ctx' : ''}
{m.meta?.quant ? ' · ' + m.meta.quant : ''}
{m.meta?.size_bytes ? ' · ' + (m.meta.size_bytes / 1024 ** 3).toFixed(1) + ' GB' : ''}
</div>
</div>
<div class="flex items-center gap-2">
<span class="li-meta" style="white-space:nowrap;color:{isLoaded ? 'var(--on)' : 'var(--mut)'}">{statusText}</span>
{#if !isLoaded && !downloading && swapOk}
<button class="ghost" style="padding:3px 9px;font-size:12px;opacity:{preloadingModel === m.name ? '.5' : '1'}"
disabled={preloadingModel === m.name}
onclick={() => preload(m.name)}>
{preloadingModel === m.name ? '…' : 'Laden'}
</button>
{/if}
</div>
<span class="li-meta" style="white-space:nowrap">{statusText}</span>
</div>
{/each}
</div>
@@ -181,6 +227,7 @@
<div class="empty-c">
<div class="e-t">Noch keine Modelle</div>
<div class="e-s">Hol dir unter „Cookbook" ein Modell, das auf deine Hardware passt.</div>
<button class="primary" style="margin-top:12px" onclick={() => go('cookbook')}>Zum Cookbook →</button>
</div>
{/if}
</div>
@@ -197,7 +244,7 @@
<a class="li" href={n.link} target="_blank" rel="noopener" style="text-decoration:none;color:inherit">
<div class="li-main">
<div style="font-size:13px;line-height:1.4">{n.title}</div>
<div class="li-sub">{n.source}</div>
<div class="li-sub">{n.source}{n.relevant ? ' · für dich' : ''}</div>
</div>
</a>
{/each}
+2 -1
View File
@@ -113,7 +113,8 @@
let swapCheckOk = $state(false)
const swapModels = $derived((status?.models || []) as any[])
const loadedModel = $derived(swapModels.find((m: any) => m.state === 'running' || m.state === 'loading'))
const LOADED = ['running', 'ready', 'loading', 'starting']
const loadedModel = $derived(swapModels.find((m: any) => LOADED.includes(m.state)))
// Job progress helpers
function jobPct(j: any) {