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?:\/\//, '') const host = (s.swap_url || '').replace(/^https?:\/\//, '')
dot && (dot.className = 'dot ' + (s.swap_ok ? 'on' : 'off')) dot && (dot.className = 'dot ' + (s.swap_ok ? 'on' : 'off'))
label && (label.textContent = (s.swap_ok ? 'LLM-Engine: Online ' : 'LLM-Engine: Offline ') + host) 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) { if (activeText) {
activeText.innerHTML = active if (active) {
? `Geladen: <b style="color:var(--teal)">${active.name}</b>` const ctx = active.meta?.ctx ? ` <span style="color:var(--mut);font-size:11.5px">· ${Math.round(active.meta.ctx / 1024)}k ctx</span>` : ''
: 'Kein Modell im VRAM' 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() if (s.swap_ok) hideAlert()
else showAlert(`LLM-Engine nicht erreichbar unter <b>${host}</b> läuft der llama-swap Dienst?`, true) 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">
<div class="card-h"> <div class="card-h">
<h3>Aktuell die besten Modelle für dein System</h3> <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)}> <button class="ghost" style="margin-left:8px" disabled={discoverLoading} onclick={() => loadDiscover(true)}>
{discoverLoading ? 'Suche…' : 'Aktualisieren'} {discoverLoading ? 'Suche…' : 'Aktualisieren'}
</button> </button>
</div> </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} {#if discoverLoading && !discoverData}
<div class="empty" style="text-align:center;padding:30px">Frage Quellen ab…</div> <div class="empty" style="text-align:center;padding:30px">Frage Quellen ab…</div>
{:else if discoverData?.categories} {:else if discoverData?.categories}
+110 -63
View File
@@ -3,13 +3,15 @@
import { statusStore } from '../stores/status.svelte.js' import { statusStore } from '../stores/status.svelte.js'
import { systemStore } from '../stores/system.svelte.js' import { systemStore } from '../stores/system.svelte.js'
import { api } from '@core/api.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 s = $derived(statusStore.value)
const sys = $derived(systemStore.value) const sys = $derived(systemStore.value)
const allModels = $derived((s?.models || []) as any[]) const allModels = $derived((s?.models || []) as any[])
const swapOk = $derived(s?.swap_ok ?? false) 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( const heroTitle = $derived(
!s ? 'Verbinde…' !s ? 'Verbinde…'
@@ -21,98 +23,123 @@
!s ? '' !s ? ''
: !swapOk ? 'Der llama-swap-Dienst antwortet gerade nicht.' : !swapOk ? 'Der llama-swap-Dienst antwortet gerade nicht.'
: sys && sys.ram.percent >= 90 ? `Arbeitsspeicher bei ${Math.round(sys.ram.percent)} % — eventuell Speicher freigeben.` : 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 gpuPct = $derived(() => {
const g = sys?.gpu const g = sys?.gpu
if (g && (g.vram.total + g.gtt.total) > 0) if (g && (g.vram.total + g.gtt.total) > 0)
return ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100 return ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100
return 0 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 news = $state<any[]>([])
let preloadingModel = $state<string | null>(null)
onMount(async () => { onMount(async () => {
try { news = ((await api('/api/news')).items || []).slice(0, 4) } catch {} try { news = ((await api('/api/news')).items || []).slice(0, 4) } catch {}
}) })
async function freeMemory() { async function freeMemory() {
const ok = await confirmModal({ if (!await confirmModal({
title: 'Speicher freigeben?', 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', confirmLabel: 'Speicher freigeben',
}) })) return
if (!ok) return
await api('/api/unload', { method: 'POST' }) 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) { function go(view: string) {
document.querySelector(`.nav-item[data-view="${view}"]`)?.dispatchEvent(new MouseEvent('click')) document.querySelector(`.nav-item[data-view="${view}"]`)?.dispatchEvent(new MouseEvent('click'))
} }
function capTagClass(caps: string[]) { function ramColor(pct: number) { return pct >= 90 ? 'red' : pct >= 75 ? '' : 'green' }
if (caps?.includes('Bild')) return 'img' function tempColor(t: number | null) { return t == null ? '' : t >= 75 ? 'red' : t >= 55 ? '' : 'green' }
if (caps?.includes('Code')) return 'code' function gpuColor(pct: number) { return pct >= 90 ? 'red' : pct >= 70 ? '' : 'green' }
return 'text'
}
function capTagLabel(caps: string[]) {
if (caps?.includes('Bild')) return 'Bild'
if (caps?.includes('Code')) return 'Code'
return 'Text'
}
</script> </script>
<div class="pagehead"> <!-- Hero -->
<div class="pagehead" style="margin-bottom:10px">
<div> <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 class="sub">{heroSub}</div>
</div> </div>
</div> </div>
<!-- KPI Tiles -->
<div class="tiles"> <div class="tiles">
<div class="tile"> <div class="kpi {swapOk ? 'green' : 'red'}">
<div class="t-l">LLM-Engine</div> <div class="k-h">
<div class="t-v {swapOk ? 'ok' : 'bad'}">{swapOk ? 'Online' : 'Offline'}</div> <span class="k-t">LLM-Engine</span>
<div class="t-s">llama-swap</div> <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>
<div class="tile">
<div class="t-l">Aktives Modell</div> <div class="kpi {activeModel ? 'blue' : 'muted'}">
<div class="t-v">{activeModel ? activeModel.name : 'Keins'}</div> <div class="k-h">
<div class="t-s">{activeModel ? 'im Grafikspeicher' : 'nichts geladen'}</div> <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> </div>
{#if sys} {#if sys}
{@const t = sys.gpu_temp ?? sys.cpu?.temp ?? null} {@const t = sys.gpu_temp ?? sys.cpu?.temp ?? null}
<div class="tile"> {@const tCol = tempColor(t)}
<div class="t-l">GPU-Temperatur</div> <div class="kpi {tCol}">
<div class="t-v">{t != null ? `${Math.round(t)}°` : ''}</div> <div class="k-h"><span class="k-t">Temperatur (GPU)</span><span class="k-ic">{@html icon('thermo')}</span></div>
<div class="t-s {t != null && t < 72 ? 'ok' : ''}">{tempWord(t)}</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> </div>
{@const free = sys.ram.total - sys.ram.used} {@const free = sys.ram.total - sys.ram.used}
<div class="tile"> {@const freePct = 100 - sys.ram.percent}
<div class="t-l">Freier Speicher</div> <div class="kpi {freePct < 15 ? 'red' : freePct < 30 ? '' : 'green'}">
<div class="t-v">{Math.round(free / 1024 ** 3)}<small> GB</small></div> <div class="k-h"><span class="k-t">Freier Speicher</span><span class="k-ic">{@html icon('database')}</span></div>
<div class="t-s">von {Math.round(sys.ram.total / 1024 ** 3)} GB</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> </div>
{:else} {: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="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="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">Freier Speicher</span></div><div class="k-v"></div><div class="k-s">messe…</div></div>
{/if} {/if}
</div> </div>
<!-- Split: Health + Quickstart -->
<div class="split"> <div class="split">
<div class="card"> <div class="card">
<div class="card-h"><h3>System-Gesundheit</h3></div> <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} {#if sys}
{@const gpu = gpuPct()} {@const gpu = gpuPct()}
{#each [ {#each [
['Prozessor (CPU)', sys.cpu?.percent ?? 0], ['Prozessor (CPU)', sys.cpu?.percent ?? 0, ''],
['Arbeitsspeicher (RAM)', sys.ram.percent], ['Arbeitsspeicher (RAM)', sys.ram.percent, ramColor(sys.ram.percent)],
['Grafikspeicher (VRAM)', gpu] ['Grafikspeicher (VRAM)', gpu, gpuColor(gpu)]
] as [label, pct]} ] as [label, pct, cls]}
{@const p = Math.max(0, Math.min(100, (pct as number) || 0))} {@const p = Math.max(0, Math.min(100, (pct as number) || 0))}
<div class="meter"> <div class="meter">
<div class="meter-h"><span class="mk">{label}</span><span class="mv">{Math.round(p)} %</span></div> <div class="meter-h"><span class="mk">{label}</span><span class="mv">{Math.round(p)} %</span></div>
@@ -134,7 +161,7 @@
</button> </button>
<button class="qa" onclick={freeMemory}> <button class="qa" onclick={freeMemory}>
<span class="qa-ic amber">{@html icon('refresh')}</span> <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> <span class="qa-arrow">{@html icon('chevron')}</span>
</button> </button>
<button class="qa" onclick={() => go('server')}> <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-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> <span class="qa-arrow">{@html icon('chevron')}</span>
</button> </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>
</div> </div>
<!-- Stack + News -->
<div class="grid grid-2"> <div class="grid grid-2">
<div class="card"> <div class="card">
<div class="card-h"> <div class="card-h">
<h3>Dein Stack</h3> <h3>Dein Stack</h3>
<span class="meta">{allModels.length ? allModels.length + ' konfiguriert' : ''}</span> <span class="meta">{configuredModels.length ? configuredModels.length + ' konfiguriert' : ''}</span>
</div> </div>
{#if allModels.length} {#if configuredModels.length}
<div class="list"> <div class="list">
{#each allModels as m} {#each configuredModels as m}
{@const isLoaded = LOADED.includes(m.state)}
{@const downloading = m.state === 'downloading'} {@const downloading = m.state === 'downloading'}
{@const on = ['running','ready','loading','starting'].includes(m.state)} {@const dot = (m.state === 'loading' || m.state === 'starting' || downloading) ? 'load' : isLoaded ? 'on' : ''}
{@const dot = (m.state === 'loading' || m.state === 'starting' || downloading) ? 'load' : on ? 'on' : ''}
{@const statusText = downloading {@const statusText = downloading
? `↓ Download${m.download_progress != null ? ` ${Math.round(m.download_progress)}%` : ''}` ? `↓ ${m.download_progress != null ? Math.round(m.download_progress) + '%' : '...'}`
: on ? (m.state === 'loading' ? 'lädt…' : 'geladen') : 'bereit'} : m.state === 'loading' || m.state === 'starting' ? 'lädt…'
<div class="li"> : isLoaded ? 'geladen ✓' : 'bereit'}
<div class="li" style="padding:10px 4px">
<span class="li-dot {dot}"></span> <span class="li-dot {dot}"></span>
<div class="li-main"> <div class="li-main">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2" style="flex-wrap:wrap">
<span class="li-id">{m.name}</span> <span class="li-id" style="{isLoaded ? 'color:var(--accent)' : ''}">{m.name}</span>
{#if m.meta?.caps?.length} {#if m.meta?.caps?.includes('Bild')}<span class="tag img">Bild</span>
<span class="tag {capTagClass(m.meta.caps)}">{capTagLabel(m.meta.caps)}</span> {:else if m.meta?.caps?.includes('Code')}<span class="tag code">Code</span>
{/if} {: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> </div>
{#if m.meta?.filename} <div class="li-sub mono-sm">
<div class="li-sub mono-sm">{m.meta.filename}</div> {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} {/if}
</div> </div>
<span class="li-meta" style="white-space:nowrap">{statusText}</span>
</div> </div>
{/each} {/each}
</div> </div>
@@ -181,6 +227,7 @@
<div class="empty-c"> <div class="empty-c">
<div class="e-t">Noch keine Modelle</div> <div class="e-t">Noch keine Modelle</div>
<div class="e-s">Hol dir unter „Cookbook" ein Modell, das auf deine Hardware passt.</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> </div>
{/if} {/if}
</div> </div>
@@ -197,7 +244,7 @@
<a class="li" href={n.link} target="_blank" rel="noopener" style="text-decoration:none;color:inherit"> <a class="li" href={n.link} target="_blank" rel="noopener" style="text-decoration:none;color:inherit">
<div class="li-main"> <div class="li-main">
<div style="font-size:13px;line-height:1.4">{n.title}</div> <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> </div>
</a> </a>
{/each} {/each}
+2 -1
View File
@@ -113,7 +113,8 @@
let swapCheckOk = $state(false) let swapCheckOk = $state(false)
const swapModels = $derived((status?.models || []) as any[]) 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 // Job progress helpers
function jobPct(j: any) { function jobPct(j: any) {
+16
View File
@@ -342,6 +342,22 @@ def delete_model(req: DeleteReq):
return {"ok": True, "alias": req.alias, "freed_bytes": freed, "note": note or "Modell entfernt."} return {"ok": True, "alias": req.alias, "freed_bytes": freed, "note": note or "Modell entfernt."}
@router.post("/preload")
def preload(model: str):
"""Modell vorwaermen: sendet eine Minimal-Anfrage damit llama-swap es laedt.
max_tokens=1 haelt die Antwort kurz; der Token wird verworfen."""
try:
with httpx.Client(timeout=60.0) as c:
c.post(f"{LLAMA_SWAP_URL}/v1/chat/completions", json={
"model": model,
"messages": [{"role": "user", "content": "!"}],
"max_tokens": 1,
})
return {"ok": True}
except Exception as exc: # noqa: BLE001
raise HTTPException(502, f"llama-swap nicht erreichbar: {exc}")
@router.post("/unload") @router.post("/unload")
def unload(model: str | None = None): def unload(model: str | None = None):
path = f"/api/models/unload/{model}" if model else "/api/models/unload" path = f"/api/models/unload/{model}" if model else "/api/models/unload"
+10 -2
View File
@@ -91,8 +91,16 @@ a{color:var(--accent);text-decoration:none}
.dot.on{background:var(--on);box-shadow:0 0 0 0 rgba(63,185,80,.5);animation:pulse 2.2s infinite} .dot.on{background:var(--on);box-shadow:0 0 0 0 rgba(63,185,80,.5);animation:pulse 2.2s infinite}
.dot.off{background:var(--err)} .dot.off{background:var(--err)}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(63,185,80,.45)}70%{box-shadow:0 0 0 7px rgba(63,185,80,0)}100%{box-shadow:0 0 0 0 rgba(63,185,80,0)}} @keyframes pulse{0%{box-shadow:0 0 0 0 rgba(63,185,80,.45)}70%{box-shadow:0 0 0 7px rgba(63,185,80,0)}100%{box-shadow:0 0 0 0 rgba(63,185,80,0)}}
.top-stat{font-size:var(--text-sm);color:var(--mut)} .top-stat{font-size:var(--text-sm);color:var(--mut);display:flex;align-items:center;gap:5px}
.top-stat b{color:var(--tx);font-family:var(--mono);font-weight:500;margin-left:4px} .top-stat b{color:var(--accent);font-family:var(--mono);font-weight:600;letter-spacing:.01em}
/* Aktives Modell in Topbar — klarer sichtbar wenn geladen */
#top-active-text{
font-family:var(--mono);font-size:var(--text-sm);
padding:4px 10px;border-radius:999px;
border:1px solid transparent;transition:border-color .3s,background .3s;
}
#top-active-text b{font-weight:600}
#top-active-text:has(b){border-color:rgba(45,212,191,.25);background:rgba(45,212,191,.07)}
.top-clock{font-family:var(--mono);font-size:var(--text-sm);color:var(--accent)} .top-clock{font-family:var(--mono);font-size:var(--text-sm);color:var(--accent)}
/* Security-Chip (zeigt LAN/Token-Status) */ /* Security-Chip (zeigt LAN/Token-Status) */
+25 -23
View File
File diff suppressed because one or more lines are too long