@@ -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' } berei t · ${ activeModel ? `„$ { activeModel . name } " geladen ` : 'keins geladen' } · keine Warnungen.`
: `${ configuredModels . length } ${ configuredModels . length === 1 ? 'Modell' : 'Modelle' } konfigurier t · ${ activeModel ? `„$ { activeModel . name } " geladen ` : 'keine s 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 }