Add Deck dashboard with Athena telemetry history

This commit is contained in:
Mikei386
2026-09-29 22:31:03 +02:00
parent be40869712
commit aaf9f570e9
19 changed files with 1073 additions and 25 deletions
+61
View File
@@ -0,0 +1,61 @@
# Dashboard und übernommene Historie
**Dashboard** steht an erster Stelle der Deck-Navigation. Die frühere
Hardware-Seite entfällt. Die Übersicht für Endpunkt und GPU-Modus bleibt als
separater Bereich erhalten; die alten Router-, Bild- und Modus-Umschalter
werden im Dashboard nicht eingeblendet.
Die Karten und Ansichten ab „Aktives Profil“ übernehmen die Struktur des alten
Athena-Dashboards: CPU, RAM, beide GPUs mit VRAM/Temperatur/Takt/Leistung,
Inferenzraten, Prompt-Cache, Anfragen, Slots, Kontextfenster, MTP,
Tokenzähler, GPU- und Slot-Verlauf, Nutzung nach Profil, Modellwechsel,
Profile, Dienste, Host/Netzwerk, GPU-Prozesse, llama.cpp-Parameter,
GGUF-Dateien und verschlüsselte portable Backups. Die Farbwerte sind an Deck
angepasst. Nicht verfügbare Laufzeitwerte erscheinen als „–“; sie werden nicht
aus historischen Werten geschätzt. Profil und Modell beziehen sich auf Decks
eigenen Worker, nicht auf den alten Router.
## Datenquellen
- CPU, RAM, Host-Uptime und Netzwerk: Host-`/proc`, im Docker-Testbetrieb
ausschließlich lesend nach `/host/proc` eingebunden. Für das Host-Netzwerk
wird `/host/proc/1/net/dev` verwendet; `/host/proc/net/dev` würde die
Container-Namespace messen.
- GPU, VRAM, Temperatur, Leistung, Takt und Prozesse: `nvidia-smi`.
- Inferenz, Slots und Modellparameter: Decks eigener `llama-server`, dessen
lesende `/metrics`, `/slots` und `/props`-Routen; `--metrics` wird beim
Modellstart aktiviert. Deck liest daraus nur Zahlen und Slotzustände.
- Profile und GGUF-Dateien: Deck-Bibliothek plus, sofern vorhanden, das
lesend eingebundene alte `/data/models`.
- Backups: optionales, lesend eingebundenes `/data/emergency-backups`.
Downloads laufen nur über die angemeldete Deck-Oberfläche.
Das Dashboard verwendet die internen, sitzungsgeschützten GET-Routen
`/api/v1/dashboard`, `/api/v1/dashboard/history?range=24h` und
`/api/v1/dashboard/backups`. Mögliche Zeiträume sind 1 Stunde, 24 Stunden,
7 Tage, 21 Tage und Gesamt. Die Live-Ansicht aktualisiert jede Sekunde,
die History alle 15 Sekunden. Die Aufzeichnung schreibt alle 15 Sekunden;
Detailwerte bleiben 21 Tage, ältere GPU- und Slotwerte werden auf
Stundenwerte verdichtet. Prompts, Antworten, Medien und Anwendungslogs
werden nicht in die History geschrieben.
## Bestehende History einmalig übernehmen
Vor dem ersten Start einer Deck-Version mit Dashboard die alte SQLite-Datei
mit der mitgelieferten Importfunktion kopieren:
```sh
python3 deploy/import_dashboard_history.py \
/data/llama-dashboard/history.sqlite3 \
/opt/athena-deck-dev/runtime/state/dashboard-history.sqlite3
```
Der Import nutzt SQLite-Backup und schließt die laufende WAL-Datei ein.
Die Quelle bleibt unverändert; ein vorhandenes Deck-Ziel wird nicht
überschrieben. Im installierten Deck liegt die neue Datenbank allgemein
unter `<Installationsverzeichnis>/state/dashboard-history.sqlite3`.
Auf einem frisch installierten Debian-Server ohne alte Datenbank beginnt
Deck automatisch eine neue History. Historische Tokenzahlen und Modellwechsel
aus dem alten Router bleiben nach dem Import erhalten; neue Messungen werden
als Deck-Messungen fortgeführt. Das alte Dashboard muss dafür nicht gestoppt
werden.
+8
View File
@@ -173,6 +173,14 @@ GPU-Zugriff ist standardmäßig aus. Nur `state/` und ein temporäres tmpfs sind
die Anwendung beschreibbar. Docker erstellt seine normalen Regeln für den neuen die Anwendung beschreibbar. Docker erstellt seine normalen Regeln für den neuen
Container; vorhandene Gateways und ihre Konfiguration werden nicht bearbeitet. Container; vorhandene Gateways und ihre Konfiguration werden nicht bearbeitet.
Für das [Dashboard](DASHBOARD.md) wird Host-`/proc` lesend eingebunden.
Wenn vorhanden, werden außerdem `/data/models` und
`/data/emergency-backups` ausschließlich lesend für Dateiübersicht und
Backup-Downloads eingebunden. Auf einem leeren Debian beginnen die
Dashboard-Messwerte und die History neu. Eine vorhandene alte Dashboard-History
kann vor dem ersten Deck-Dashboard-Start einmalig gemäß
[Importanleitung](DASHBOARD.md#bestehende-history-einmalig-übernehmen) kopiert werden.
Die WireGuard-Einstellungen zeigen in dieser Variante „nicht angebunden“. Es gibt Die WireGuard-Einstellungen zeigen in dieser Variante „nicht angebunden“. Es gibt
keinen SSH-Schlüssel im Container und keine heimliche Fernsteuerung des vorhandenen keinen SSH-Schlüssel im Container und keine heimliche Fernsteuerung des vorhandenen
Athena-Gateways. Der bisherige WireGuard-Installer bleibt ein separater Weg und wird Athena-Gateways. Der bisherige WireGuard-Installer bleibt ein separater Weg und wird
+10 -4
View File
@@ -3,6 +3,10 @@
Eigenständige neue Oberfläche, Python-Standardbibliothek und HTML/CSS/JavaScript. Eigenständige neue Oberfläche, Python-Standardbibliothek und HTML/CSS/JavaScript.
Keine Installation von Python-Paketen oder Frontend-Builds nötig. Python >= 3.10. Keine Installation von Python-Paketen oder Frontend-Builds nötig. Python >= 3.10.
Die Navigation beginnt mit dem [Dashboard](DASHBOARD.md). Es zeigt die
Live-Telemetrie und die aus dem alten Athena-Dashboard übernommene History;
die frühere separate Hardware-Seite wurde entfernt.
## Vorläufige Testinstallation auf Debian ## Vorläufige Testinstallation auf Debian
Zielprodukt: nativer systemd-Dienst auf Debian. Der derzeitige Docker-Installer dient ausschließlich der isolierten Testphase. Zielprodukt: nativer systemd-Dienst auf Debian. Der derzeitige Docker-Installer dient ausschließlich der isolierten Testphase.
@@ -68,12 +72,14 @@ CPU-Temperatur: k10temp/coretemp temp1_input, Grad Celsius.
GPU: nvidia-smi CSV mit Index, UUID, Name, VRAM in MiB, GPU-Auslastung in Prozent, GPU: nvidia-smi CSV mit Index, UUID, Name, VRAM in MiB, GPU-Auslastung in Prozent,
Temperatur in Grad Celsius. Keine Zuordnung nach vermuteter GPU-Reihenfolge. Temperatur in Grad Celsius. Keine Zuordnung nach vermuteter GPU-Reihenfolge.
Die Anzeige rechnet Speicher in GiB um. Fehlende Werte heißen „Nicht verfügbar“. Die Anzeige rechnet Speicher in GiB um. Fehlende Werte heißen „Nicht verfügbar“.
Abfrage bedarfsgesteuert mit fünf Sekunden Cache, GUI-Polling alle fünf Sekunden. Abfrage mit höchstens einer Sekunde Cache; das Dashboard aktualisiert jede
Keine Historie und kein Hintergrund-Collector bei geschlossener Hardware-Seite. Sekunde und zeichnet unabhängig von der geöffneten Seite alle 15 Sekunden auf.
Details zur historischen Datenbank stehen in [DASHBOARD.md](DASHBOARD.md).
## Struktur und Grenzen ## Struktur und Grenzen
- `server.py`: Verwaltungs-API und lesende Hardware-Abfragen. - `server.py`: Verwaltungs-API und lesende Dashboard-/Hardware-Abfragen.
- `dashboard_data.py` und `dashboard_history.py`: Live-Telemetrie und persistente History.
- `endpoint.py`: separater authentifizierter Inferenz-Listener. - `endpoint.py`: separater authentifizierter Inferenz-Listener.
- `inference.py`: native llama.cpp-Prozesse und gemeinsame GPU-Reservierung. - `inference.py`: native llama.cpp-Prozesse und gemeinsame GPU-Reservierung.
- `image_test.py`: eigene ComfyUI-Aufträge, gemeinsam mit dem Router koordiniert. - `image_test.py`: eigene ComfyUI-Aufträge, gemeinsam mit dem Router koordiniert.
@@ -114,7 +120,7 @@ Neue sitzungsgeschützte API-Routen: `GET /api/v1/image-runtime`,
`POST /api/v1/profiles/delete` mit `{ "id": "…", "revision": 1 }`. `POST /api/v1/profiles/delete` mit `{ "id": "…", "revision": 1 }`.
POST benötigt wie die übrigen Verwaltungsaktionen `X-Athena-Deck: 1`. POST benötigt wie die übrigen Verwaltungsaktionen `X-Athena-Deck: 1`.
Die Hardware-Seite zeigt GPU-Rechenlast und VRAM-Belegung mit separaten Balken. Das Dashboard zeigt GPU-Rechenlast und VRAM-Belegung mit separaten Balken.
Es sind Host-Gesamtwerte einschließlich anderer Dienste. Der Bild-Textencoder arbeitet auf der RTX 3060, Bildmodell und VAE auf der Es sind Host-Gesamtwerte einschließlich anderer Dienste. Der Bild-Textencoder arbeitet auf der RTX 3060, Bildmodell und VAE auf der
RTX 5080. CPU/System-RAM bleiben für Laden und Offload beteiligt. Beide GPUs RTX 5080. CPU/System-RAM bleiben für Laden und Offload beteiligt. Beide GPUs
müssen frei sein; es gibt keinen stillen CPU-Fallback. GPU-Last kann während müssen frei sein; es gibt keinen stillen CPU-Fallback. GPU-Last kann während
+3 -3
View File
@@ -7,14 +7,14 @@ async function api(path,method='GET'){const r=await fetch('/api/v1/'+path,{metho
const metric=(title,value)=>`<div><small>${title}</small><strong>${value}</strong></div>`; const metric=(title,value)=>`<div><small>${title}</small><strong>${value}</strong></div>`;
const bar=v=>v==null?'':`<progress max="100" value="${Number(v)}" aria-label="Auslastung"></progress>`; const bar=v=>v==null?'':`<progress max="100" value="${Number(v)}" aria-label="Auslastung"></progress>`;
const placeholders={chat:['Sprachmodelle / Chat','Modellprofile, Laufzeit und Chat','Hier werden später Modellprofile und native llama.cpp-Prozesse verwaltet.'],image:['Bildgenerierung','Worker und Aufträge','Hier entsteht später die Übersicht für Bild-Worker und deren Zustand.'],audio:['Audio','Spracheingabe und Sprachausgabe','Hier werden später getrennte Worker für Erkennung und Ausgabe eingebunden.'],services:['Weitere Dienste','Erweiterbare Dienste','Hier finden später zusätzliche Worker und ihre Schnittstellen Platz.']}; const placeholders={chat:['Sprachmodelle / Chat','Modellprofile, Laufzeit und Chat','Hier werden später Modellprofile und native llama.cpp-Prozesse verwaltet.'],image:['Bildgenerierung','Worker und Aufträge','Hier entsteht später die Übersicht für Bild-Worker und deren Zustand.'],audio:['Audio','Spracheingabe und Sprachausgabe','Hier werden später getrennte Worker für Erkennung und Ausgabe eingebunden.'],services:['Weitere Dienste','Erweiterbare Dienste','Hier finden später zusätzliche Worker und ihre Schnittstellen Platz.']};
async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'home';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html; async function refresh(){if(refreshing)return;refreshing=true;const page=location.hash.slice(1)||'dashboard';document.querySelectorAll('nav a').forEach(a=>a.classList.toggle('active',a.hash==='#'+page));try{error.textContent='';let html;
if(['video','video-runtime'].includes(page)){VideoUI.runtime();return;} if(['video','video-runtime'].includes(page)){VideoUI.runtime();return;}
if(['chat','image','audio','stt','music','voice','video','runtime','runtimes','image-runtime','stt-runtime','tts-runtime','docker-runtime','services'].includes(page)){Studio.render(page);return;} if(['chat','image','audio','stt','music','voice','video','runtime','runtimes','image-runtime','stt-runtime','tts-runtime','docker-runtime','services'].includes(page)){Studio.render(page);return;}
if(page==='access'){await accessPage();return;} if(page==='access'){await accessPage();return;}
if(page==='network'){await networkPage();return;} if(page==='network'){await networkPage();return;}
if(page==='dashboard'){DashboardUI.render();return;}
if(page==='home'||page==='endpoint'){await EndpointUI.render(page==='endpoint');return; if(page==='home'||page==='endpoint'){await EndpointUI.render(page==='endpoint');return;
}else if(page==='hardware'){const h=await api('hardware');html=heading('TELEMETRIE / ATHENA','Hardware im Blick.','Live-Messwerte vom Host 192.168.1.212. Aktualisierung alle fünf Sekunden. GPU-Rechenlast und belegter VRAM werden getrennt dargestellt.');if(!h.available)html+=`<section class="empty">Hardware nicht verfügbar<p>${esc(h.errors.join(' '))}</p></section>`;else{const c=h.cpu,r=h.ram;html+=`<div class="grid"><section class="card"><div class="label">CPU</div><h2>${esc(c.name)}</h2><div class="metrics">${metric('Auslastung',fmt(c.percent,' %'))}${metric('Temperatur',fmt(c.temperature_c,' °C'))}</div>${bar(c.percent)}</section><section class="card"><div class="label">System-RAM</div><h2>Arbeitsspeicher</h2><div class="metrics">${metric('Belegt',fmt(r.used_bytes==null?null:r.used_bytes/2**30,' GiB'))}${metric('Gesamt',fmt(r.total_bytes==null?null:r.total_bytes/2**30,' GiB'))}</div>${bar(r.total_bytes&&r.used_bytes!=null?100*r.used_bytes/r.total_bytes:null)}</section>`;for(const g of h.gpus)html+=`<section class="card"><div class="card-top"><span class="label">GPU ${g.index}</span><span class="tag">${fmt(g.temperature_c,' °C')}</span></div><h2>${esc(g.name)}</h2><div class="metrics">${metric('GPU-Auslastung',fmt(g.percent,' %'))}${metric('VRAM belegt / gesamt',fmt(g.used_mib==null?null:g.used_mib/1024)+' / '+fmt(g.total_mib==null?null:g.total_mib/1024,' GiB'))}</div><small>GPU-Rechenlast</small>${bar(g.percent)}<small>VRAM-Belegung</small>${bar(g.total_mib&&g.used_mib!=null?100*g.used_mib/g.total_mib:null)}</section>`;if(h.gpus.length<2)html+='<section class="card">Nicht alle zwei GPUs verfügbar.</section>';html+=`</div><p class="note">Die Werte gelten für den gesamten Server einschließlich anderer Dienste. Beim Bildtest laufen Textencoder auf der RTX 3060 und Bildmodell auf der RTX 5080; 0 % GPU-Rechenlast bedeutet nicht, dass kein Modell geladen ist. Nach dem Test wird der eigene Worker entladen.<br>${esc(h.source)} · Messung ${new Date(h.sampled_at*1000).toLocaleTimeString('de-DE')}<br>${esc(h.errors.join(' '))}</p>`;}
}else{const p=placeholders[page]||placeholders.services;html=heading('ARBEITSBEREICH / VORBEREITET',p[0],p[1])+`<section class="empty"><div class="symbol">+</div><h2>Platz für den nächsten Schritt.</h2><p>${p[2]}</p><span class="pill">Platzhalter · noch keine Funktionen</span><p class="note">Installation, Downloads, Presets und Modellwechsel sind in dieser Version nicht enthalten.</p></section>`;} }else{const p=placeholders[page]||placeholders.services;html=heading('ARBEITSBEREICH / VORBEREITET',p[0],p[1])+`<section class="empty"><div class="symbol">+</div><h2>Platz für den nächsten Schritt.</h2><p>${p[2]}</p><span class="pill">Platzhalter · noch keine Funktionen</span><p class="note">Installation, Downloads, Presets und Modellwechsel sind in dieser Version nicht enthalten.</p></section>`;}
if((location.hash.slice(1)||'home')===page){view.innerHTML=html;} if((location.hash.slice(1)||'dashboard')===page){view.innerHTML=html;}
}catch(e){error.textContent=e.message;view.innerHTML=heading('VERBINDUNG','Status nicht verfügbar','Die API ist gerade nicht erreichbar. Angezeigte Messwerte wurden verworfen.');}finally{refreshing=false;}} }catch(e){error.textContent=e.message;view.innerHTML=heading('VERBINDUNG','Status nicht verfügbar','Die API ist gerade nicht erreichbar. Angezeigte Messwerte wurden verworfen.');}finally{refreshing=false;}}
window.addEventListener('hashchange',refresh);refresh();setInterval(refresh,5000); window.addEventListener('hashchange',refresh);refresh();setInterval(refresh,5000);
+46 -7
View File
@@ -3,8 +3,14 @@ import csv
import json import json
import time import time
import subprocess import subprocess
import os
import shutil
from pathlib import Path from pathlib import Path
PROC = Path(os.environ.get('DECK_HOST_PROC', '/proc'))
DATA_DISK = Path(os.environ.get('DECK_HOST_DATA_DISK', '/var/lib/deck'))
_network_previous = None
def read(path): def read(path):
try: try:
@@ -21,11 +27,12 @@ def number(value):
def ticks(): def ticks():
values = list(map(int, read('/proc/stat').splitlines()[0].split()[1:9])) values = list(map(int, read(PROC/'stat').splitlines()[0].split()[1:9]))
return sum(values), sum(values[3:5]) return sum(values), sum(values[3:5])
def collect(): def collect():
global _network_previous
errors = [] errors = []
cpu = {'name': None, 'percent': None, 'temperature_c': None} cpu = {'name': None, 'percent': None, 'temperature_c': None}
try: try:
@@ -35,7 +42,7 @@ def collect():
cpu['percent'] = round(100 * (1 - (bi-ai)/(b-a)), 1) if b > a else None cpu['percent'] = round(100 * (1 - (bi-ai)/(b-a)), 1) if b > a else None
except (ValueError, IndexError): except (ValueError, IndexError):
errors.append('CPU-Auslastung nicht verfügbar') errors.append('CPU-Auslastung nicht verfügbar')
for line in read('/proc/cpuinfo').splitlines(): for line in read(PROC/'cpuinfo').splitlines():
if line.startswith('model name'): if line.startswith('model name'):
cpu['name'] = line.split(':', 1)[1].strip() cpu['name'] = line.split(':', 1)[1].strip()
break break
@@ -45,7 +52,7 @@ def collect():
cpu['temperature_c'] = value / 1000 if value is not None else None cpu['temperature_c'] = value / 1000 if value is not None else None
break break
mem = {} mem = {}
for line in read('/proc/meminfo').splitlines(): for line in read(PROC/'meminfo').splitlines():
parts = line.split() parts = line.split()
if parts[0] in ('MemTotal:', 'MemAvailable:'): if parts[0] in ('MemTotal:', 'MemAvailable:'):
mem[parts[0][:-1]] = int(parts[1]) * 1024 mem[parts[0][:-1]] = int(parts[1]) * 1024
@@ -53,13 +60,45 @@ def collect():
ram = {'total_bytes': total, 'used_bytes': total-available if total is not None and available is not None else None} ram = {'total_bytes': total, 'used_bytes': total-available if total is not None and available is not None else None}
gpus = [] gpus = []
try: try:
result = subprocess.run(['nvidia-smi', '--query-gpu=index,name,uuid,memory.total,memory.used,utilization.gpu,temperature.gpu', '--format=csv,noheader,nounits'], capture_output=True, text=True, timeout=4, check=True) result = subprocess.run(['nvidia-smi', '--query-gpu=index,name,uuid,memory.total,memory.used,utilization.gpu,temperature.gpu,utilization.memory,memory.free,power.draw,power.limit,clocks.current.graphics,clocks.current.memory,fan.speed,pstate', '--format=csv,noheader,nounits'], capture_output=True, text=True, timeout=4, check=True)
for row in csv.reader(result.stdout.splitlines(), skipinitialspace=True): for row in csv.reader(result.stdout.splitlines(), skipinitialspace=True):
index, name, uuid, total, used, usage, temp = row if len(row)!=15:continue
gpus.append(dict(index=int(index), name=name, uuid=uuid, total_mib=number(total), used_mib=number(used), percent=number(usage), temperature_c=number(temp))) index, name, uuid, total, used, usage, temp, memory_usage, free, power, power_limit, graphics_clock, memory_clock, fan, pstate = row
gpus.append(dict(index=int(index), name=name, uuid=uuid, total_mib=number(total), used_mib=number(used), percent=number(usage), temperature_c=number(temp),memory_controller_percent=number(memory_usage),free_mib=number(free),power_w=number(power),power_limit_w=number(power_limit),graphics_clock_mhz=number(graphics_clock),memory_clock_mhz=number(memory_clock),fan_percent=number(fan),pstate=pstate))
except (OSError, subprocess.SubprocessError, ValueError): except (OSError, subprocess.SubprocessError, ValueError):
errors.append('GPU-Messwerte nicht verfügbar') errors.append('GPU-Messwerte nicht verfügbar')
return dict(source='Debian-Server · /proc + hwmon + nvidia-smi', sampled_at=time.time(), cpu=cpu, ram=ram, gpus=gpus, errors=errors) gpu_processes=[]
try:
result=subprocess.run(['nvidia-smi','--query-compute-apps=gpu_uuid,pid,process_name,used_memory','--format=csv,noheader,nounits'],capture_output=True,text=True,timeout=4,check=True)
for row in csv.reader(result.stdout.splitlines(),skipinitialspace=True):
if len(row)==4:gpu_processes.append(dict(gpu_uuid=row[0],pid=number(row[1]),name=row[2],memory_mib=number(row[3])))
except (OSError,subprocess.SubprocessError):pass
try:load=[float(x) for x in read(PROC/'loadavg').split()[:3]]
except ValueError:load=[]
try:uptime=float(read(PROC/'uptime').split()[0])
except (ValueError,IndexError):uptime=None
try:
disk=shutil.disk_usage(DATA_DISK)
disk_data=dict(total=disk.total,used=disk.used,free=disk.free)
except OSError:disk_data={}
rx=tx=interfaces=0
# A bind-mounted /proc/net is a self/net symlink and would show the
# container namespace. PID 1 in the host proc mount has the host network.
host_net=PROC/'1/net/dev' if (PROC/'1/net/dev').exists() else PROC/'net/dev'
for line in read(host_net).splitlines()[2:]:
if ':' not in line:continue
name,raw=line.split(':',1)
if name.strip()=='lo':continue
parts=raw.split()
if len(parts)<9:continue
try:rx+=int(parts[0]);tx+=int(parts[8]);interfaces+=1
except ValueError:pass
now=time.monotonic();rates=dict(rx_bytes_per_second=None,tx_bytes_per_second=None)
if _network_previous and now>_network_previous[0]:
elapsed=now-_network_previous[0]
rates=dict(rx_bytes_per_second=round(max(0,rx-_network_previous[1])/elapsed,1),tx_bytes_per_second=round(max(0,tx-_network_previous[2])/elapsed,1))
_network_previous=(now,rx,tx)
return dict(source='Debian-Server · /proc + hwmon + nvidia-smi', sampled_at=time.time(), cpu={**cpu,'logical_cpus':os.cpu_count(),'load':load,'host_uptime_seconds':uptime,'disk_data':disk_data,'network':dict(interfaces=interfaces,rx_bytes=rx,tx_bytes=tx,**rates)}, ram=ram, gpus=gpus,gpu_processes=gpu_processes, errors=errors)
if __name__ == '__main__': if __name__ == '__main__':
+233
View File
@@ -0,0 +1,233 @@
const DashboardUI=(()=>{
let host=null,root=null,timers=[],controller=null;
const esc=v=>String(v??'–').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const pct=n=>n==null?'–':`${Number(n).toFixed(1)}%`;
const gib=b=>b==null?'–':`${(Number(b)/1073741824).toFixed(1)} GiB`;
const dur=s=>{if(s==null)return'–';let d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);return d?`${d}d ${h}h`:`${h}h ${m}m`};
const $=id=>root.getElementById(id);
const imagePhaseLabel=p=>({'running':'Bildauftrag läuft','loading':'Bildmodell lädt','complete':'bereit','failed':'Fehler'})[p]||p||'inaktiv';
function cleanup(){for(const timer of timers)clearInterval(timer);timers=[];controller?.abort();controller=null;host=null;root=null}
function gpuCard(g){let total=g.memory_total_mib||0,used=g.memory_used_mib||0,p=total&&g.memory_used_mib!=null?used/total*100:0,load=Math.max(0,Math.min(100,g.gpu_percent||0)),memoryText=total&&g.memory_used_mib!=null?`${(used/1024).toFixed(1)} / ${(total/1024).toFixed(1)} GiB`:'–';return `<article class="card span6"><div class="gpu-title"><div><div class="label">GPU ${esc(g.index)}</div><div class="value">${esc(g.name)}</div></div><span class="badge">${esc(g.pstate)}</span></div><div class="metrics"><div class="metric"><b>${pct(g.gpu_percent)}</b><span>GPU-Kern</span></div><div class="metric"><b>${memoryText}</b><span>VRAM</span></div><div class="metric"><b>${esc(g.temperature_c)} °C</b><span>Temperatur</span></div><div class="metric"><b>${esc(g.power_w)} / ${esc(g.power_limit_w)} W</b><span>Leistung</span></div><div class="metric"><b>${esc(g.memory_controller_percent)} %</b><span>Speichercontroller</span></div><div class="metric"><b>${esc(g.graphics_clock_mhz)} MHz</b><span>GPU-Takt</span></div><div class="metric"><b>${esc(g.memory_clock_mhz)} MHz</b><span>Speichertakt</span></div><div class="metric"><b>${esc(g.fan_percent)} %</b><span>Lüfter</span></div></div><div class="bar-label"><span>GPU-Auslastung</span><span>${pct(load)}</span></div><div class="bar"><div class="fill gpu-load" style="width:${load}%"></div></div><div class="bar-label"><span>VRAM</span><span>${pct(p)}</span></div><div class="bar"><div class="fill" style="width:${p}%"></div></div></article>`}
function mount(){
controller=new AbortController();
root=host.attachShadow({mode:'open'});
root.innerHTML=`<link rel="stylesheet" href="/dashboard.css"><div class="dashboard-root"><main><div class="top"><div><div class="eyebrow">Athena Deck · Live Telemetry</div><h1>Dashboard</h1></div><div class="live"><span class="dot" id="dot"></span><span id="updated">verbinde …</span></div></div><section class="grid"> <article class="card span3"><div class="label">Aktives Profil</div><div class="value" id="profile">–</div><div class="sub" id="profileSub">Deck wird abgefragt</div></article>
<article class="card span3"><div class="label">Modell</div><div class="value" id="model">–</div><div class="sub" id="modelSub">–</div></article>
<article class="card span3"><div class="label">CPU</div><div class="value" id="cpu">–</div><div class="bar"><div class="fill" id="cpuBar"></div></div><div class="sub" id="load">–</div></article>
<article class="card span3"><div class="label">System-RAM</div><div class="value" id="ram">–</div><div class="bar"><div class="fill" id="ramBar"></div></div><div class="sub" id="ramSub">–</div></article>
<div id="gpuCards" class="span12 grid"></div>
<div class="section-title">Inferenz · Live</div>
<article class="card span3"><div class="label">Generierung</div><div class="value" id="generationRate">–</div><div class="sub" id="generationSub">Token pro Sekunde</div></article>
<article class="card span3"><div class="label">Prompt-Einlesen</div><div class="value" id="promptRate">–</div><div class="sub" id="promptSub">Token pro Sekunde</div></article>
<article class="card span3"><div class="label">Prompt-Cache</div><div class="value" id="cacheHit">–</div><div class="bar"><div class="fill" id="cacheBar"></div></div><div class="sub" id="cacheSub">–</div></article>
<article class="card span3"><div class="label">Anfragen</div><div class="value" id="requestState">–</div><div class="sub" id="requestSub">–</div></article>
<article class="card span12"><div class="row"><div><div class="label">Slots und Kontextfenster</div><div class="sub">Aktuelle Belegung und Verlauf des genutzten Kontextfensters</div></div><div class="history-controls" id="slotHistoryRanges"><button data-range="1h">1 h</button><button data-range="24h" class="active">24 h</button><button data-range="7d">7 Tage</button><button data-range="21d">21 Tage</button><button data-range="all">Gesamt</button></div></div><div class="slot-list" id="slotCards"><div class="sub">Telemetrie wird geladen …</div></div><div class="chart-legend" id="slotLegend"></div><canvas class="chart" id="slotHistoryChart"></canvas><div class="history-note" id="slotHistoryNote">Historie wird geladen …</div></article>
<article class="card span4"><div class="label">MTP / Speculative Decoding</div><div class="value" id="mtpAcceptance">–</div><div class="bar"><div class="fill gpu-load" id="mtpBar"></div></div><div class="metrics"><div class="metric"><b id="mtpDrafted">–</b><span>Draft-Token</span></div><div class="metric"><b id="mtpAccepted">–</b><span>akzeptiert</span></div><div class="metric"><b id="mtpSteps">–</b><span>Prüfschritte</span></div><div class="metric"><b id="mtpDepth">–</b><span>Draft-Tiefe</span></div></div></article>
<article class="card span4"><div class="label">Tokenzähler seit Modellstart</div><div class="metrics" id="counters"></div></article>
<article class="card span4"><div class="label">Modell-Eigenschaften</div><div class="metrics" id="modelDetails"></div></article>
<div class="section-title">Langzeitstatistik</div>
<article class="card span4"><div class="label">Eingabe-Tokens gesamt</div><div class="token-total" id="historyInput">–</div><div class="sub" id="historyInputSub">neu + Prompt-Cache</div></article>
<article class="card span4"><div class="label">Ausgabe-Tokens gesamt</div><div class="token-total" id="historyOutput">–</div><div class="sub" id="historyOutputSub">seit Beginn der Aufzeichnung</div></article>
<article class="card span4"><div class="label">Historie</div><div class="token-total" id="historyStorage">–</div><div class="sub">21 Tage detailliert, danach Stundenwerte</div></article>
<article class="card span12"><div class="row"><div><div class="label">GPU-Verlauf</div><div class="sub">Auslastung und Temperatur beider Karten</div></div><div class="history-controls" id="historyRanges"><button data-range="1h">1 h</button><button data-range="24h" class="active">24 h</button><button data-range="7d">7 Tage</button><button data-range="21d">21 Tage</button><button data-range="all">Gesamt</button></div></div><div class="chart-legend" id="gpuLegend"></div><canvas class="chart" id="gpuHistoryChart"></canvas><div class="history-note" id="historyNote">Historie wird geladen …</div></article>
<article class="card span12"><div class="row"><div><div class="label">Nutzung nach Profil und Modell</div><div class="sub">Prozentanteil im oben gewählten Zeitraum</div></div><div class="history-controls" id="usageModes"><button data-mode="total" class="active">Gesamte Tokenarbeit</button><button data-mode="output">Nur Ausgabe</button></div></div><div class="usage-list" id="profileUsage"><div class="sub">Nutzungsverteilung wird geladen …</div></div></article>
<article class="card span12"><div class="label">Dauerhafte Modellwechsel</div><table><thead><tr><th>Zeit</th><th>Profil</th><th>Modell</th></tr></thead><tbody id="historyEvents"><tr><td colspan="3">Noch keine Wechsel aufgezeichnet</td></tr></tbody></table></article>
<div class="section-title">Deck · Profile · Dienste</div>
<article class="card span6"><div class="label">Inferenz</div><div class="value status" id="availability">–</div><div class="metrics"><div class="metric"><b id="activeChats">–</b><span>aktive Anfragen</span></div><div class="metric"><b id="routerUptime">–</b><span>Deck-Uptime</span></div><div class="metric"><b id="switching">–</b><span>Profilwechsel</span></div><div class="metric"><b id="dataDisk">–</b><span>/data belegt</span></div></div></article>
<article class="card span6"><div class="label">Verfügbare Profile</div><div class="profiles" id="profiles"></div></article>
<article class="card span6"><div class="label">Zusatzdienste</div><div class="metrics" id="services"></div></article>
<article class="card span6"><div class="label">Netzwerk und Host</div><div class="metrics" id="hostMetrics"></div></article>
<div class="section-title">Hardware · Dateien · Laufzeit</div>
<article class="card span6"><div class="label">GPU-Prozesse</div><table><thead><tr><th>GPU</th><th>Prozess</th><th>PID</th><th>VRAM</th></tr></thead><tbody id="processes"><tr><td colspan="4">–</td></tr></tbody></table></article>
<article class="card span6"><div class="label">Ereignisse seit Dashboard-Start</div><div id="events"><div class="sub">Noch keine Zustandsänderung</div></div></article>
<article class="card span12"><div class="label">llama.cpp Laufzeitkonfiguration</div><div class="metrics" id="runtime"><div class="metric"><b>–</b><span>wird gelesen</span></div></div></article>
<article class="card span12"><div class="row"><div><div class="label">Verfügbare GGUF-Dateien</div><div class="sub" id="modelSummary">–</div></div></div><div class="scroll"><table><thead><tr><th>Datei</th><th>Pfad</th><th>Größe</th><th>Geändert</th></tr></thead><tbody id="modelFiles"><tr><td colspan="4">–</td></tr></tbody></table></div></article>
<div class="section-title">Notfall-Backups · herunterladen</div>
<article class="card span12"><div class="row"><div><div class="label">Verschlüsselte portable Sicherungen</div><div class="sub">Unersetzliche Daten ohne erneut ladbare Modellgewichte · maximal fünf Generationen</div></div></div><div class="scroll"><table><thead><tr><th>Erstellt</th><th>Größe</th><th>SHA256</th><th></th></tr></thead><tbody id="backupFiles"><tr><td colspan="4">Backups werden geladen …</td></tr></tbody></table></div><div class="sub" id="backupNote">Zum Wiederherstellen wird der separat verwahrte Age-Schlüssel benötigt.</div></article>
<article class="card span12 error" id="errors" hidden></article>
</section><div class="footer">Aktualisierung jede Sekunde · Steuerung über die Deck-Übersicht</div></main></div>`;
root.querySelector('link').addEventListener('load',()=>{if(host?.isConnected)refreshHistory()},{once:true});
const deNum=n=>n==null?'–':Number(n).toLocaleString('de-DE',{maximumFractionDigits:1});
const bytesRate=n=>n==null?'–':n>=1048576?`${(n/1048576).toFixed(1)} MiB/s`:`${(n/1024).toFixed(1)} KiB/s`;
const metric=(value,label)=>`<div class="metric"><b>${esc(value)}</b><span>${esc(label)}</span></div>`;
const boolLabel=v=>v===true?'ja':v===false?'nein':'–';
const liveRateState={prompt:{tokens:null,seconds:null,value:null,seen:0},generation:{tokens:null,seconds:null,value:null,seen:0}};
function stableLiveRate(kind,tokensTotal,secondsTotal,gauge,active){
const now=Date.now(),state=liveRateState[kind],tokens=Number(tokensTotal),seconds=Number(secondsTotal),raw=Number(gauge);
let measured=Number.isFinite(raw)&&raw>0?raw:null;
if(Number.isFinite(tokens)&&Number.isFinite(seconds)&&state.tokens!=null&&tokens>=state.tokens&&seconds>state.seconds){
const derived=(tokens-state.tokens)/(seconds-state.seconds);
if(Number.isFinite(derived)&&derived>0) measured=measured??derived;
}
if(Number.isFinite(tokens)&&Number.isFinite(seconds)){
state.tokens=tokens;state.seconds=seconds;
}
if(measured!=null&&measured<100000){state.value=measured;state.seen=now;}
if(state.value!=null&&(active||now-state.seen<8000)) return {text:`${deNum(state.value)} tok/s`,fresh:now-state.seen<2500};
return {text:active?'misst …':'–',fresh:false};
}
function sharedSlotPool(slots,unified){
if(!unified||!Array.isArray(slots)||slots.length<2)return null;
const total=Math.max(0,...slots.map(s=>Number(s.n_ctx)||0));
const used=slots.reduce((sum,s)=>sum+Math.max(0,Number(s.context_used)||0),0);
return total?{total,used,free:Math.max(0,total-used)}:null;
}
function slotHtml(s,pool){
let used=Math.max(0,Number(s.context_used)||0),total=Math.max(0,Number(s.n_ctx)||0);
let available=pool?Math.min(total,used+pool.free):total;
let p=available?Math.min(100,used/available*100):0;
let state=s.processing?'arbeitet':'frei';
let capacity=pool?`${deNum(used)} / ${deNum(available)} Token aktuell möglich`:`${deNum(used)} / ${deNum(total)} Token`;
let shared=pool?`<span>Gemeinsamer Pool: ${deNum(pool.free)} Token frei</span>`:'';
return `<div class="slot"><div class="slot-head"><b>Slot ${s.id??'–'} · ${state}</b><span class="badge">${s.speculative?'MTP aktiv':'Standard'}</span></div><div class="bar-label"><span>${capacity}</span><span>${p.toFixed(1)} %</span></div><div class="bar"><div class="fill" style="width:${p}%"></div></div>${shared?`<div class="sub">${shared}</div>`:''}<div class="metrics">${metric(deNum(s.prompt_tokens),'Prompt')}${metric(deNum(s.prompt_cached),'Cache-Token')}${metric(deNum(s.decoded_tokens),'generiert')}${metric(deNum(s.remaining_generation),'Ausgabe übrig')}${metric(s.task_id??'–','Task-ID')}${metric(s.temperature??'–','Temperatur')}${metric(boolLabel(s.stream),'Streaming')}${metric(deNum(s.max_tokens),'Ausgabelimit')}</div></div>`;
}
async function refreshFull(){
try{
let response=await fetch('/api/v1/dashboard',{cache:'no-store'}); if(!response.ok) return;
let d=await response.json(),rt=d.router||{},lt=rt.llama_telemetry||{},met=lt.metrics||{},props=lt.props||{},lr=d.llama_runtime||{},cpu=d.cpu||{},net=cpu.network||{};
let running=Number(met.requests_processing??(rt.qwen||{}).active_chats??0),waiting=Number(met.requests_deferred||0),active=running>0;
let gen=stableLiveRate('generation',met.tokens_predicted_total,met.tokens_predicted_seconds_total,met.predicted_tokens_seconds,active);
let prompt=stableLiveRate('prompt',met.prompt_tokens_total,met.prompt_seconds_total,met.prompt_tokens_seconds,active);
$('generationRate').textContent=gen.text;
let genAvg=met.tokens_predicted_seconds_total?met.tokens_predicted_total/met.tokens_predicted_seconds_total:null;
$('generationSub').textContent=active&&!gen.fresh?'Anfrage läuft · letzter Messwert':(genAvg==null?'Aktuelle llama.cpp-Messung':`Gesamtdurchschnitt ${deNum(genAvg)} tok/s`);
$('promptRate').textContent=prompt.text;
let promptAvg=met.prompt_seconds_total?met.prompt_tokens_total/met.prompt_seconds_total:null;
$('promptSub').textContent=active&&!prompt.fresh?'Prompt wird verarbeitet · letzter Messwert':(promptAvg==null?'Aktuelle llama.cpp-Messung':`Gesamtdurchschnitt ${deNum(promptAvg)} tok/s`);
let cached=Number(met.prompt_tokens_cached_total||0),processed=Number(met.prompt_tokens_total||0),hit=(cached+processed)?cached/(cached+processed)*100:null;
$('cacheHit').textContent=hit==null?'–':`${hit.toFixed(1)} %`;$('cacheBar').style.width=`${hit||0}%`;
$('cacheSub').textContent=hit==null?'Keine Cache-Metrik':`${deNum(cached)} wiederverwendet · ${deNum(processed)} neu`;
$('requestState').textContent=`${running} aktiv · ${waiting} wartet`;
$('requestSub').textContent=`${(lt.slots||[]).filter(s=>!s.processing).length} freie Slots`;
let slots=lt.slots||[],slotPool=sharedSlotPool(slots,lr.kv_unified===true);
$('slotCards').innerHTML=slots.map(s=>slotHtml(s,slotPool)).join('')||'<div class="sub">Slot-Telemetrie momentan nicht verfügbar</div>';
let drafted=Number(met.spec_decode_num_draft_tokens_total||0),accepted=Number(met.spec_decode_num_accepted_tokens_total||0),acceptance=drafted?accepted/drafted*100:null;
$('mtpAcceptance').textContent=acceptance==null?'–':`${acceptance.toFixed(1)} % akzeptiert`;$('mtpBar').style.width=`${acceptance||0}%`;
$('mtpDrafted').textContent=deNum(drafted);$('mtpAccepted').textContent=deNum(accepted);$('mtpSteps').textContent=deNum(met.spec_decode_num_drafts_total);$('mtpDepth').textContent=lr.mtp_draft_tokens??'–';
$('counters').innerHTML=metric(deNum(met.prompt_tokens_total),'Prompt neu')+metric(deNum(met.prompt_tokens_cached_total),'Prompt aus Cache')+metric(deNum(met.tokens_predicted_total),'generierte Token')+metric(deNum(met.n_decode_total),'Decode-Aufrufe')+metric(deNum(met.n_tokens_max),'größte Sequenz')+metric(deNum(met.n_busy_slots_per_decode),'Slots je Decode');
let modalities=Object.entries(props.modalities||{}).filter(([,v])=>v).map(([k])=>k).join(', ')||'–';
$('modelDetails').innerHTML=metric(props.model_ftype||'–','Quantisierung')+metric(props.total_slots??lr.parallel??'–','Slots')+metric(modalities,'Modalitäten')+metric(deNum(props.default_context||lr.context_size),'Kontext')+metric(props.model_alias||lr.alias||'–','Alias')+metric(lr.reasoning_budget??'–','Reasoning-Budget');
$('profiles').innerHTML=Object.entries(rt.profiles||{}).map(([name,ctx])=>`<div class="profile-item ${name===rt.current_profile?'active':''}"><b>${esc(name)}</b><span>${Number(ctx).toLocaleString('de-DE')} Token${name===rt.current_profile?' · aktiv':''}</span></div>`).join('')||'<div class="sub">Keine Profile gemeldet</div>';
let tts=rt.tts||{},stt=rt.stt||{},img=rt.image||{};
$('services').innerHTML=metric(tts.ready?'bereit':'nicht bereit',`TTS · ${tts.engine||'–'}`)+metric(tts.speaker||'–','Stimme')+metric(stt.reachable?'bereit':'aus','STT')+metric(img.worker||'–','Bild-Worker')+metric(img.model||'–','Bildmodell')+metric(img.phase==='idle'?'inaktiv':imagePhaseLabel(img.phase),'Bildstatus')+metric(img.model_loaded?'geladen':'entladen','Modellzustand')+metric(img.last_seconds==null?'–':`${deNum(img.last_seconds)} s`,'letztes Bild');
$('hostMetrics').innerHTML=metric(bytesRate(net.rx_bytes_per_second),'Netzwerk empfangen')+metric(bytesRate(net.tx_bytes_per_second),'Netzwerk gesendet')+metric(dur(cpu.host_uptime_seconds),'Host-Uptime')+metric(dur(d.dashboard_uptime_seconds),'Dashboard-Uptime')+metric((cpu.load||[]).join(' / ')||'–','Load 1/5/15')+metric(net.interfaces??'–','Interfaces');
let summary=d.model_summary||{};$('modelSummary').textContent=`${summary.count??0} Dateien · ${gib(summary.total_size||0)} gesamt`;
$('modelFiles').innerHTML=(d.models||[]).map(f=>`<tr><td>${esc(f.name)}</td><td>${esc(f.relative_path)}</td><td>${gib(f.size)}</td><td>${new Date(f.modified*1000).toLocaleString('de-DE')}</td></tr>`).join('')||'<tr><td colspan="4">Keine GGUF-Dateien im eingebundenen Modellordner</td></tr>';
$('events').innerHTML=(d.events||[]).map(e=>`<div class="event"><time>${new Date(e.timestamp*1000).toLocaleTimeString('de-DE')}</time><b>${esc(e.name)}</b>: ${esc(e.from)} → ${esc(e.to)}</div>`).join('')||'<div class="sub">Noch keine Zustandsänderung</div>';
}catch(_){/* Die bestehende Verbindungsanzeige meldet Fehler bereits sichtbar. */}
}
refreshFull();timers.push(setInterval(()=>{if(host?.isConnected)refreshFull();else cleanup()},1000));
let historyRange='24h',usageMode='total',lastProfileUsage=[];
const historyColors=['#c5edaf','#ffb454','#7fc8b6','#c39bff'];
const hiddenHistorySeries=new Set();let lastHistoryPoints=[];
const hiddenSlotSeries=new Set();let lastSlotPoints=[];
function drawHistory(points){
lastHistoryPoints=points;
const canvas=$('gpuHistoryChart'),rect=canvas.getBoundingClientRect(),ratio=window.devicePixelRatio||1;
canvas.width=Math.max(1,Math.floor(rect.width*ratio));canvas.height=Math.max(1,Math.floor(rect.height*ratio));
const x=canvas.getContext('2d');x.scale(ratio,ratio);const w=rect.width,h=rect.height,pad={l:42,r:44,t:16,b:28};
x.clearRect(0,0,w,h);x.strokeStyle='#213044';x.fillStyle='#8fa1b5';x.font='11px system-ui';x.lineWidth=1;
for(let i=0;i<=4;i++){let y=pad.t+(h-pad.t-pad.b)*i/4;x.beginPath();x.moveTo(pad.l,y);x.lineTo(w-pad.r,y);x.stroke();x.fillText(`${100-i*25}%`,4,y+4);x.fillText(`${100-i*25}°`,w-pad.r+7,y+4)}
if(!points.length){x.fillText('Noch keine historischen Messwerte',pad.l+10,h/2);return}
const min=Math.min(...points.map(p=>p.ts)),max=Math.max(...points.map(p=>p.ts));
const px=t=>pad.l+(t-min)/Math.max(1,max-min)*(w-pad.l-pad.r), py=v=>pad.t+(100-Math.max(0,Math.min(100,v)))/100*(h-pad.t-pad.b);
const span=max-min,ticks=5;
for(let i=0;i<ticks;i++){
const ts=min+span*i/(ticks-1),xx=px(ts),date=new Date(ts*1000);
const label=span<=2*86400
?date.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'})
:span<=45*86400
?date.toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'})
:date.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'2-digit'});
x.strokeStyle='#172538';x.beginPath();x.moveTo(xx,pad.t);x.lineTo(xx,h-pad.b);x.stroke();
x.fillStyle='#8fa1b5';x.textAlign=i===0?'left':i===ticks-1?'right':'center';x.fillText(label,xx,h-7);
}
x.textAlign='start';
const ids=[...new Set(points.map(p=>p.gpu_index))].sort();
$('gpuLegend').innerHTML=ids.flatMap((id,idx)=>[['load',`GPU ${id} Auslastung`,historyColors[idx*2%historyColors.length]],['temp',`GPU ${id} Temperatur`,historyColors[(idx*2+1)%historyColors.length]]].map(([kind,label,color])=>{let key=`${id}:${kind}`;return `<button data-series="${key}" class="${hiddenHistorySeries.has(key)?'off':''}" style="--series:${color}" aria-pressed="${hiddenHistorySeries.has(key)?'false':'true'}">${label}</button>`})).join('');
ids.forEach((id,idx)=>{let rows=points.filter(p=>p.gpu_index===id),load=historyColors[idx*2%historyColors.length],temp=historyColors[(idx*2+1)%historyColors.length];
[[load,'gpu_util','load'],[temp,'temperature_c','temp']].forEach(([color,key,kind])=>{if(hiddenHistorySeries.has(`${id}:${kind}`))return;x.beginPath();x.strokeStyle=color;x.lineWidth=2;let first=true;rows.forEach(p=>{if(p[key]==null)return;let xx=px(p.ts),yy=py(Number(p[key]));first?(x.moveTo(xx,yy),first=false):x.lineTo(xx,yy)});x.stroke()})});
}
function drawSlotHistory(points){
lastSlotPoints=points;
const canvas=$('slotHistoryChart'),rect=canvas.getBoundingClientRect(),ratio=window.devicePixelRatio||1;
canvas.width=Math.max(1,Math.floor(rect.width*ratio));canvas.height=Math.max(1,Math.floor(rect.height*ratio));
const x=canvas.getContext('2d');x.scale(ratio,ratio);const w=rect.width,h=rect.height,pad={l:42,r:18,t:16,b:28};
x.clearRect(0,0,w,h);x.strokeStyle='#213044';x.fillStyle='#8fa1b5';x.font='11px system-ui';x.lineWidth=1;
for(let i=0;i<=4;i++){let y=pad.t+(h-pad.t-pad.b)*i/4;x.beginPath();x.moveTo(pad.l,y);x.lineTo(w-pad.r,y);x.stroke();x.fillText(`${100-i*25}%`,4,y+4)}
if(!points.length){x.fillText('Noch keine historischen Slot-Messwerte',pad.l+10,h/2);$('slotLegend').innerHTML='';return}
const min=Math.min(...points.map(p=>p.ts)),max=Math.max(...points.map(p=>p.ts));
const px=t=>pad.l+(t-min)/Math.max(1,max-min)*(w-pad.l-pad.r),py=v=>pad.t+(100-Math.max(0,Math.min(100,v)))/100*(h-pad.t-pad.b);
const span=max-min,ticks=5;
for(let i=0;i<ticks;i++){
const ts=min+span*i/(ticks-1),xx=px(ts),date=new Date(ts*1000);
const label=span<=2*86400?date.toLocaleTimeString('de-DE',{hour:'2-digit',minute:'2-digit'}):span<=45*86400?date.toLocaleString('de-DE',{day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'}):date.toLocaleDateString('de-DE',{day:'2-digit',month:'2-digit',year:'2-digit'});
x.strokeStyle='#172538';x.beginPath();x.moveTo(xx,pad.t);x.lineTo(xx,h-pad.b);x.stroke();x.fillStyle='#8fa1b5';x.textAlign=i===0?'left':i===ticks-1?'right':'center';x.fillText(label,xx,h-7);
}
x.textAlign='start';const ids=[...new Set(points.map(p=>p.slot_id))].sort((a,b)=>a-b);
$('slotLegend').innerHTML=ids.map((id,idx)=>{let color=historyColors[idx%historyColors.length],key=String(id);return `<button data-slot-series="${key}" class="${hiddenSlotSeries.has(key)?'off':''}" style="--series:${color}" aria-pressed="${hiddenSlotSeries.has(key)?'false':'true'}">Slot ${id} · Kontext</button>`}).join('');
ids.forEach((id,idx)=>{let key=String(id);if(hiddenSlotSeries.has(key))return;let rows=points.filter(p=>p.slot_id===id);x.beginPath();x.strokeStyle=historyColors[idx%historyColors.length];x.lineWidth=2;let first=true;rows.forEach(p=>{if(p.context_percent==null)return;let xx=px(p.ts),yy=py(Number(p.context_percent));first?(x.moveTo(xx,yy),first=false):x.lineTo(xx,yy)});x.stroke()});
}
async function refreshHistory(){try{let r=await fetch(`/api/v1/dashboard/history?range=${historyRange}`,{cache:'no-store'});if(!r.ok)throw Error(`HTTP ${r.status}`);let d=await r.json(),t=d.token_totals||{},input=Number(t.prompt_tokens||0)+Number(t.cached_tokens||0);$('historyInput').textContent=deNum(input);$('historyInputSub').textContent=`${deNum(t.prompt_tokens)} neu · ${deNum(t.cached_tokens)} aus Cache`;$('historyOutput').textContent=deNum(t.output_tokens||0);$('historyStorage').textContent=`${deNum((d.storage||{}).rows||0)} Messpunkte`;drawHistory(d.points||[]);drawSlotHistory(d.slot_points||[]);renderProfileUsage(d.profile_usage||[]);let note=`Bereich ${d.range} · Messung alle ${d.sample_interval_seconds}s · Detaildaten ${d.detail_retention_days} Tage`;$('historyNote').textContent=note;$('slotHistoryNote').textContent=`${note} · ${deNum((d.slot_storage||{}).rows||0)} Slot-Messpunkte`;$('historyEvents').innerHTML=(d.model_events||[]).slice(0,15).map(e=>`<tr><td>${new Date(e.ts*1000).toLocaleString('de-DE')}</td><td>${esc(e.previous_profile||'–')} → ${esc(e.profile||'–')}</td><td>${esc(e.previous_model||'–')} → ${esc(e.model||'–')}</td></tr>`).join('')||'<tr><td colspan="3">Noch keine Wechsel aufgezeichnet</td></tr>'}catch(e){$('historyNote').textContent=`Historie nicht verfügbar: ${e.message}`;$('slotHistoryNote').textContent=`Historie nicht verfügbar: ${e.message}`}}
function renderProfileUsage(rows){lastProfileUsage=rows;let value=r=>usageMode==='output'?Number(r.output_tokens||0):Number(r.prompt_tokens||0)+Number(r.cached_tokens||0)+Number(r.output_tokens||0),sum=rows.reduce((n,r)=>n+value(r),0);$('profileUsage').innerHTML=rows.map((r,i)=>{let v=value(r),p=sum?v/sum*100:0,input=Number(r.prompt_tokens||0)+Number(r.cached_tokens||0);return `<div class="usage-row"><div class="usage-head"><b>${esc(r.profile||'unbekannt')} <span>· ${esc(r.model||'–')}</span></b><strong>${p.toFixed(1)} %</strong></div><div class="bar"><div class="fill" style="width:${p}%;background:${historyColors[i%historyColors.length]}"></div></div><div class="usage-meta"><span>${deNum(input)} Eingabe · ${deNum(r.output_tokens||0)} Ausgabe</span><span>${deNum(v)} gewertet</span></div></div>`}).join('')||'<div class="sub">In diesem Zeitraum wurden noch keine Token aufgezeichnet.</div>'}
$('usageModes').addEventListener('click',e=>{let b=e.target.closest('button[data-mode]');if(!b)return;usageMode=b.dataset.mode;root.querySelectorAll('#usageModes button').forEach(x=>x.classList.toggle('active',x===b));renderProfileUsage(lastProfileUsage)});
$('historyRanges').addEventListener('click',e=>setHistoryRange(e));
$('slotHistoryRanges').addEventListener('click',e=>setHistoryRange(e));
function setHistoryRange(e){let b=e.target.closest('button[data-range]');if(!b)return;historyRange=b.dataset.range;root.querySelectorAll('#historyRanges button,#slotHistoryRanges button').forEach(x=>x.classList.toggle('active',x.dataset.range===historyRange));refreshHistory()}
$('gpuLegend').addEventListener('click',e=>{let b=e.target.closest('button[data-series]');if(!b)return;let key=b.dataset.series;hiddenHistorySeries.has(key)?hiddenHistorySeries.delete(key):hiddenHistorySeries.add(key);drawHistory(lastHistoryPoints)});
$('slotLegend').addEventListener('click',e=>{let b=e.target.closest('button[data-slot-series]');if(!b)return;let key=b.dataset.slotSeries;hiddenSlotSeries.has(key)?hiddenSlotSeries.delete(key):hiddenSlotSeries.add(key);drawSlotHistory(lastSlotPoints)});
window.addEventListener('resize',()=>{if(host?.isConnected){drawHistory(lastHistoryPoints);drawSlotHistory(lastSlotPoints)}},{signal:controller.signal});refreshHistory();timers.push(setInterval(()=>{if(host?.isConnected)refreshHistory();else cleanup()},15000));
async function refreshSummary(){
try{
const response=await fetch('/api/v1/dashboard',{cache:'no-store'});
if(!response.ok)throw Error(`HTTP ${response.status}`);
const d=await response.json(),c=d.cpu||{},m=c.memory||{},rt=d.router||{},up=rt.upstream||{},q=rt.qwen||{},lr=d.llama_runtime||{},img=rt.image||{};
const imageActive=img.phase&&img.phase!=='idle';
$('profile').textContent=imageActive?'Bildgenerierung':(rt.current_profile||'nicht geladen');
$('profileSub').textContent=imageActive?'Bildauftrag läuft':(rt.switching?'Profilwechsel läuft':`Kontext: ${up.ctx?Number(up.ctx).toLocaleString('de-DE'):'–'} Token`);
$('model').textContent=imageActive?(img.model||'Bildmodell'):(up.model||'–');
$('modelSub').textContent=imageActive?(img.model_loaded?'geladen':'wird vorbereitet'):(lr.model_file||(up.reachable?'llama.cpp erreichbar':'llama.cpp nicht geladen'));
$('cpu').textContent=pct(c.usage_percent);$('cpuBar').style.width=`${c.usage_percent||0}%`;
$('load').textContent=`${c.logical_cpus||'–'} Threads · Load ${(c.load||[]).join(' / ')}`;
const rp=m.total?m.used/m.total*100:0;$('ram').textContent=pct(m.total?rp:null);$('ramBar').style.width=`${rp}%`;$('ramSub').textContent=`${gib(m.used)} / ${gib(m.total)}`;
$('gpuCards').innerHTML=(d.gpus||[]).map(gpuCard).join('')||'<article class="card span12 error">Keine GPU-Daten verfügbar</article>';
$('availability').textContent=imageActive?'Bildauftrag läuft':(q.available?'bereit':'nicht geladen');
$('availability').className=`value status ${q.available||imageActive?'':'bad'}`;
$('activeChats').textContent=q.active_chats??'–';$('routerUptime').textContent=dur(rt.uptime_seconds);
$('switching').textContent=rt.switching||'nein';
const disk=c.disk_data||{};$('dataDisk').textContent=pct(disk.total?100*disk.used/disk.total:null);
$('processes').innerHTML=(d.gpu_processes||[]).map(p=>`<tr><td>${esc((d.gpus||[]).find(g=>g.uuid===p.gpu_uuid)?.index)}</td><td>${esc(p.name)}</td><td>${esc(p.pid)}</td><td>${esc(p.memory_mib)} MiB</td></tr>`).join('')||'<tr><td colspan="4">Keine Compute-Prozesse gemeldet</td></tr>';
const runtime=[['Modell-Datei',lr.model_file],['PID',lr.pid],['Kontext',lr.context_size?Number(lr.context_size).toLocaleString('de-DE'):'–'],['Batch / µBatch',`${lr.batch_size??'–'} / ${lr.ubatch_size??'–'}`],['Parallel',lr.parallel],['Threads',`${lr.threads??'–'} / ${lr.threads_batch??'–'}`],['Geräte',lr.device],['Tensor-Split',lr.tensor_split],['KV-Cache',`${lr.cache_k??'–'} / ${lr.cache_v??'–'}`],['Flash Attention',lr.flash_attention==null?'–':lr.flash_attention?'an':'aus'],['Prompt-Cache',lr.prompt_cache==null?'–':lr.prompt_cache?'an':'aus'],['MTP Draft',lr.mtp_draft_tokens]];
$('runtime').innerHTML=runtime.map(([k,v])=>metric(v,k)).join('');
const errors=Object.entries(d.errors||{}).filter(([,v])=>v);
$('errors').hidden=!errors.length;$('errors').textContent=errors.map(([k,v])=>`${k}: ${v}`).join('\n');
$('updated').textContent=`Live · ${new Date(d.timestamp*1000).toLocaleTimeString('de-DE')}`;$('dot').style.background='var(--green)';
}catch(e){if(!host?.isConnected)return;$('updated').textContent=`Verbindung gestört: ${e.message}`;$('dot').style.background='var(--red)'}
}
async function refreshBackups(){
try{
const response=await fetch('/api/v1/dashboard/backups',{cache:'no-store'});
if(!response.ok)throw Error(`HTTP ${response.status}`);
const d=await response.json(),rows=d.backups||[];
$('backupFiles').innerHTML=rows.map(b=>`<tr><td>${new Date(b.modified*1000).toLocaleString('de-DE')}</td><td>${gib(b.size)}</td><td class="hash">${esc(b.sha256||'Prüfsumme fehlt')}</td><td><a class="download" href="${esc(b.download_url)}">Herunterladen</a></td></tr>`).join('')||'<tr><td colspan="4">Keine portablen Backups gefunden</td></tr>';
$('backupNote').textContent=d.available?`${rows.length} von maximal 5 Generationen · verschlüsselt mit Age`:'Backup-Verzeichnis auf diesem Host nicht eingebunden.';
}catch(e){if(host?.isConnected)$('backupNote').textContent=`Backup-Liste nicht verfügbar: ${e.message}`}
}
refreshSummary();refreshBackups();
timers.push(setInterval(()=>{if(host?.isConnected)refreshSummary();else cleanup()},1000));
timers.push(setInterval(()=>{if(host?.isConnected)refreshBackups();else cleanup()},60000));
}
return {render(){
const view=document.getElementById('view');
if(host?.isConnected)return;
if(host)cleanup();
view.innerHTML='<div id="deck-dashboard-host"></div>';
host=view.firstElementChild;
mount();
}};
})();
+11
View File
@@ -0,0 +1,11 @@
:host{color-scheme:dark;--bg:#101416;--panel:#181f21;--panel2:#202a25;--line:#303a37;--text:#e9edea;--muted:#97a79e;--cyan:#c5edaf;--green:#bdeba4;--amber:#ffc65c;--red:#ff8c8c}
*{box-sizing:border-box}.dashboard-root{margin:0;background:radial-gradient(circle at 15% -10%,#183049 0,transparent 35%),var(--bg);font:14px/1.45 Inter,ui-sans-serif,system-ui;color:var(--text)}main{max-width:1450px;margin:auto;padding:0}.top{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:20px}.eyebrow{color:var(--cyan);font-weight:700;letter-spacing:.14em;text-transform:uppercase;font-size:11px}h1{margin:3px 0 0;font-size:30px}.live{display:flex;align-items:center;gap:8px;color:var(--muted)}.dot{width:9px;height:9px;border-radius:50%;background:var(--green);box-shadow:0 0 14px var(--green)}.grid{display:grid;grid-template-columns:repeat(12,1fr);gap:14px}.card{background:linear-gradient(145deg,rgba(17,27,40,.96),rgba(10,16,24,.96));border:1px solid var(--line);border-radius:14px;padding:17px;min-width:0}.span3{grid-column:span 3}.span4{grid-column:span 4}.span6{grid-column:span 6}.span12{grid-column:span 12}.label{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.08em}.value{font-size:26px;font-weight:750;margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.sub{color:var(--muted);margin-top:4px}.bar-label{display:flex;justify-content:space-between;color:var(--muted);font-size:11px;margin-top:13px}.bar{height:9px;background:#070b11;border-radius:99px;overflow:hidden;margin-top:5px}.fill{height:100%;width:0;background:linear-gradient(90deg,var(--cyan),var(--green));transition:width .5s}.fill.gpu-load{background:linear-gradient(90deg,#8b7cff,var(--cyan))}.gpu-title{display:flex;justify-content:space-between;align-items:center;gap:12px}.badge{border:1px solid var(--line);background:#07101a;color:var(--cyan);padding:4px 8px;border-radius:99px;font-size:11px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-top:16px}.metric b{display:block;font-size:18px}.metric span{color:var(--muted);font-size:11px}.status{color:var(--green)}.status.bad{color:var(--red)}table{border-collapse:collapse;width:100%;margin-top:10px}th,td{text-align:left;padding:8px;border-bottom:1px solid var(--line)}th{color:var(--muted);font-weight:500}.error{color:var(--red);white-space:pre-wrap}.footer{color:var(--muted);font-size:12px;text-align:right;margin-top:14px}@media(max-width:900px){.span3,.span4,.span6{grid-column:span 12}.metrics{grid-template-columns:repeat(2,1fr)}.top{align-items:flex-start;flex-direction:column}}
.amber{color:var(--amber)}.section-title{grid-column:span 12;margin:10px 2px -3px;color:var(--cyan);font-size:12px;font-weight:750;letter-spacing:.13em;text-transform:uppercase}.slot-list{display:grid;gap:12px;margin-top:13px}.slot{border:1px solid var(--line);border-radius:11px;padding:13px;background:#141a1c}.slot-head,.row{display:flex;justify-content:space-between;align-items:center;gap:12px}.slot-head b{font-size:16px}.profiles{display:grid;grid-template-columns:repeat(2,1fr);gap:9px;margin-top:13px}.profile-item{border:1px solid var(--line);border-radius:10px;padding:10px}.profile-item.active{border-color:var(--cyan);box-shadow:inset 0 0 0 1px #c5edaf33}.profile-item b{display:block}.profile-item span{color:var(--muted);font-size:11px}.scroll{max-height:330px;overflow:auto}.scroll th{position:sticky;top:0;background:var(--panel)}.event{padding:8px 0;border-bottom:1px solid var(--line)}.event:last-child{border:0}.event time{color:var(--muted);font-size:11px;margin-right:8px}@media(max-width:900px){.profiles{grid-template-columns:1fr}}
.span4 .metrics{grid-template-columns:repeat(2,1fr)}
.history-controls{display:flex;flex-wrap:wrap;gap:7px;margin:10px 0 14px}.history-controls button{border:1px solid var(--line);background:#141a1c;color:var(--muted);padding:6px 10px;border-radius:8px;cursor:pointer}.history-controls button.active{color:var(--cyan);border-color:var(--cyan)}.chart-legend{display:flex;flex-wrap:wrap;gap:8px;margin:2px 0 10px}.chart-legend button{border:1px solid var(--series);background:#141a1c;color:var(--text);padding:6px 10px;border-radius:8px;cursor:pointer}.chart-legend button::before{content:'';display:inline-block;width:10px;height:3px;background:var(--series);margin:0 7px 3px 0}.chart-legend button.off{opacity:.4;text-decoration:line-through}.chart{width:100%;height:250px;display:block}.token-total{font-size:24px;font-weight:750;margin-top:5px}.history-note{color:var(--muted);font-size:11px;margin-top:8px}
.usage-list{display:grid;gap:13px;margin-top:8px}.usage-head{display:flex;justify-content:space-between;gap:14px;align-items:baseline}.usage-head b{font-size:16px}.usage-head span{color:var(--muted)}.usage-meta{display:flex;justify-content:space-between;gap:12px;color:var(--muted);font-size:11px;margin-top:5px}
.mode-row{display:flex;align-items:center;justify-content:space-between;gap:18px;flex-wrap:wrap}.mode-buttons,.mode-buttons span{display:flex;gap:9px;flex-wrap:wrap}.mode-buttons button,.mode-buttons a{border:1px solid var(--line);background:#141a1c;color:var(--text);padding:9px 14px;border-radius:9px;cursor:pointer;text-decoration:none;font:inherit}.mode-buttons button.active{border-color:var(--cyan);color:var(--cyan);box-shadow:inset 0 0 0 1px #c5edaf33}.mode-buttons button:disabled{opacity:.45;cursor:wait}.mode-buttons span[hidden]{display:none}.mode-buttons a.stable{border-color:#66e3a466;color:var(--green)}.mode-buttons a.experimental{border-color:#ffc65c66;color:var(--amber)}.mode-error{color:var(--red)}
.image-controls{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px;margin-top:12px}.image-controls .mode-buttons{margin-top:8px}@media(max-width:900px){.image-controls{grid-template-columns:1fr}}
.download{display:inline-block;border:1px solid #66e3a466;color:var(--green);padding:6px 10px;border-radius:8px;text-decoration:none}.hash{font:11px ui-monospace,SFMono-Regular,monospace;color:var(--muted);word-break:break-all}
+268
View File
@@ -0,0 +1,268 @@
"""Read-only Deck dashboard, continuing the old Athena telemetry history."""
from __future__ import annotations
import http.client
import json
import os
import re
import urllib.parse
from pathlib import Path
import threading
import time
from dashboard_history import HISTORY_INTERVAL, HistoryStore
METRICS = {
'prompt_tokens_total', 'prompt_tokens_cached_total', 'prompt_seconds_total',
'tokens_predicted_total', 'tokens_predicted_seconds_total', 'n_decode_total',
'n_tokens_max', 'spec_decode_num_draft_tokens_total',
'spec_decode_num_accepted_tokens_total', 'spec_decode_num_drafts_total',
'prompt_tokens_seconds', 'predicted_tokens_seconds', 'requests_processing',
'requests_deferred', 'n_busy_slots_per_decode',
}
def metrics(raw):
values = {}
for line in raw.splitlines():
if not line.startswith('llamacpp:') or ' ' not in line:
continue
key, value = line.rsplit(None, 1)
key = key.removeprefix('llamacpp:')
if key not in METRICS or '{' in key:
continue
try:
number = float(value)
if number == number and abs(number) != float('inf'):
values[key] = int(number) if number.is_integer() else number
except ValueError:
pass
return values
def slot_data(raw):
result = []
if not isinstance(raw, list):
return result
for slot in raw:
if not isinstance(slot, dict):
continue
token = (slot.get('next_token') or [{}])[0]
params = slot.get('params') or {}
prompt = int(slot.get('n_prompt_tokens') or 0)
decoded = int(token.get('n_decoded') or 0)
context = int(slot.get('n_ctx') or 0)
result.append(dict(id=slot.get('id'), task_id=slot.get('id_task'),
processing=bool(slot.get('is_processing')),
speculative=bool(slot.get('speculative')), n_ctx=context,
prompt_tokens=prompt,
prompt_processed=int(slot.get('n_prompt_tokens_processed') or 0),
prompt_cached=int(slot.get('n_prompt_tokens_cache') or 0),
decoded_tokens=decoded,
context_used=min(context, prompt+decoded) if context else prompt+decoded,
remaining_generation=token.get('n_remain'),
max_tokens=params.get('max_tokens', params.get('n_predict')),
temperature=params.get('temperature'), stream=params.get('stream')))
return result
class Dashboard:
def __init__(self, server, state_dir):
self.server = server
self.started = time.time()
self.history = HistoryStore(Path(state_dir)/'dashboard-history.sqlite3')
self.lock = threading.Lock()
self.cached = None
self.checked = 0
self.model_checked = 0
self.model_cache = []
self.events = []
self.last_state = None
self.closed = threading.Event()
self.thread = threading.Thread(target=self._collect_loop, name='deck-dashboard-history', daemon=True)
self.thread.start()
def close(self):
self.closed.set()
self.thread.join(timeout=5)
if not self.thread.is_alive():self.history.close()
@staticmethod
def backup_file(name):
if not re.fullmatch(r'athena-portable-[A-Za-z0-9_.-]+\.tar\.zst\.age', name):
raise ValueError('Ungültiger Backupname.')
path=Path('/host/backups')/name
if not path.is_file() or path.is_symlink():
raise ValueError('Backup nicht verfügbar.')
return path
def backups(self):
folder=Path('/host/backups')
try:
paths=sorted(folder.glob('athena-portable-*.tar.zst.age'),key=lambda p:p.stat().st_mtime,reverse=True)[:5]
except OSError:
paths=[]
items=[]
for path in paths:
try:
self.backup_file(path.name)
stat=path.stat()
checksum=(path.parent/(path.name+'.sha256')).read_text().split()[0]
if not re.fullmatch(r'[a-fA-F0-9]{64}',checksum):checksum=None
items.append(dict(name=path.name,size=stat.st_size,modified=stat.st_mtime,
sha256=checksum,download_url='/api/v1/dashboard/backups/download/'+urllib.parse.quote(path.name)))
except (OSError,IndexError,ValueError):
continue
return dict(backups=items,available=folder.is_dir())
def model_files(self):
now=time.monotonic()
with self.lock:
if now-self.model_checked<30:return list(self.model_cache)
files=[]
root=Path('/host/models')
if root.is_dir():
try:
for path in sorted(root.rglob('*.gguf'))[:100]:
if path.is_symlink():continue
stat=path.stat()
files.append(dict(name=path.name,relative_path=str(path.relative_to(root)),
size=stat.st_size,modified=stat.st_mtime))
except OSError:
pass
for entry in self.server.catalog.status()['entries']:
if entry.get('file','').lower().endswith('.gguf'):
relative=f"Deck/{entry.get('repo','Deck')}/{entry['file']}"
if not any(f['relative_path']==relative for f in files):
files.append(dict(name=entry['file'],relative_path=relative,
size=entry.get('size'),modified=entry.get('downloaded_at')))
if not root.is_dir():
for path in Path('/reference-models').glob('*.gguf'):
try:
stat=path.stat()
if not any(f['name']==path.name for f in files):
files.append(dict(name=path.name,relative_path='Referenzmodell/'+path.name,
size=stat.st_size,modified=stat.st_mtime))
except OSError:pass
with self.lock:
self.model_cache=files
self.model_checked=now
return list(files)
def _read_worker(self, path):
conn, key = self.server.worker.connect()
conn.timeout = 2
try:
conn.request('GET', path, headers={'Authorization': 'Bearer '+key})
response = conn.getresponse()
body = response.read(2*1024*1024+1)
if response.status != 200 or len(body) > 2*1024*1024:
raise ValueError(f'{path}: HTTP {response.status}')
return body
finally:
conn.close()
def telemetry(self, ready):
result = dict(available=False, slots=[], metrics={}, props={})
if not ready:
return result
for path, key, parse in (('/slots', 'slots', lambda raw: slot_data(json.loads(raw))),
('/metrics', 'metrics', lambda raw: metrics(raw.decode('utf-8', 'replace'))),
('/props', 'props', lambda raw: json.loads(raw))):
try:
data = parse(self._read_worker(path))
if key == 'props':
data = dict(total_slots=data.get('total_slots'), model_alias=data.get('model_alias'),
model_ftype=data.get('model_ftype'), modalities=data.get('modalities') or {},
default_context=(data.get('default_generation_settings') or {}).get('n_ctx'))
result[key] = data
except (OSError, ValueError, TypeError, KeyError, http.client.HTTPException):
pass
result['available'] = bool(result['slots'] or result['metrics'])
return result
def snapshot(self):
now = time.monotonic()
with self.lock:
if self.cached is not None and now-self.checked < 1:
return self.cached
server = self.server
h = server.hardware.snapshot()
worker = server.worker.status()
scheduler = server.scheduler.status()
ready = worker['state'] == 'ready'
telemetry = self.telemetry(ready)
with server.worker.lock:
profile = server.worker.profile if ready else None
pid = server.worker.process.pid if ready and server.worker.process else None
params = (profile or {}).get('parameters') or {}
model = (profile or {}).get('model') or {}
model_file = model.get('file')
gpu_names = {g['uuid']: f"GPU {g['index']}" for g in h.get('gpus', [])}
runtime = dict(pid=pid, model_file=model_file, alias=(profile or {}).get('name'),
context_size=params.get('context'), batch_size=params.get('batch'),
ubatch_size=params.get('ubatch'), parallel=params.get('slots'),
threads=params.get('threads'), threads_batch=params.get('threads_batch'),
device=', '.join(gpu_names.get(g, g) for g in worker.get('gpus') or []),
tensor_split=params.get('tensor_split'), cache_k='q4_0', cache_v='q4_0',
flash_attention=True if ready else None, prompt_cache=params.get('cache_prompt'),
kv_unified=True if ready else None, mtp_draft_tokens=params.get('mtp_draft_tokens'),
reasoning_budget=params.get('reasoning_budget'))
image = server.image_tests.status().get('job') or {}
image_active = image.get('state') == 'running'
image_model = image.get('profile_name') if image_active else None
profile_name = worker.get('profile_name') if ready else None
profiles = {p['name']: p['parameters'].get('context') for p in server.profiles.status()['profiles'] if p['kind'] == 'chat'}
router = dict(current_profile=profile_name or 'nicht geladen', switching='ja' if scheduler['switching'] else None,
upstream=dict(model=model_file, ctx=params.get('context'), reachable=ready),
qwen=dict(available=ready, active_chats=scheduler['active_requests']),
llama_telemetry=telemetry, profiles=profiles,
uptime_seconds=time.time()-server.started,
image=dict(phase='running' if image_active else 'idle', model=image_model,
worker='Deck-Bildlaufzeit' if image_active else None,
model_loaded=image_active, last_seconds=image.get('duration_seconds')),
tts=dict(ready=server.tts_tests.status().get('loaded',False),engine='Deck-TTS'),
stt=dict(reachable=server.stt.status().get('loaded',False)))
cpu = h.get('cpu') or {}
ram = h.get('ram') or {}
cpu_data = dict(usage_percent=cpu.get('percent'), logical_cpus=cpu.get('logical_cpus'),
load=cpu.get('load') or [], host_uptime_seconds=cpu.get('host_uptime_seconds'),
memory=dict(total=ram.get('total_bytes'), used=ram.get('used_bytes')),
disk_data=cpu.get('disk_data') or {}, network=cpu.get('network') or {})
gpus = [dict(index=g.get('index'), name=g.get('name'), uuid=g.get('uuid'),
gpu_percent=g.get('percent'), memory_total_mib=g.get('total_mib'),
memory_used_mib=g.get('used_mib'), temperature_c=g.get('temperature_c'),
memory_controller_percent=g.get('memory_controller_percent'),
memory_free_mib=g.get('free_mib'), power_w=g.get('power_w'),
power_limit_w=g.get('power_limit_w'),graphics_clock_mhz=g.get('graphics_clock_mhz'),
memory_clock_mhz=g.get('memory_clock_mhz'),fan_percent=g.get('fan_percent'),
pstate=g.get('pstate')) for g in h.get('gpus') or []]
files = self.model_files()
state = (profile_name, model_file, worker['state'])
with self.lock:
if self.last_state is not None and self.last_state != state:
self.events.insert(0, dict(timestamp=time.time(), name='Deck-Modell',
**{'from': self.last_state[0] or self.last_state[2],
'to': profile_name or worker['state']}))
self.events = self.events[:50]
self.last_state = state
result = dict(timestamp=time.time(),dashboard_uptime_seconds=time.time()-self.started,
cpu=cpu_data,gpus=gpus,gpu_processes=h.get('gpu_processes') or [],
llama_runtime=runtime,router=router,models=files,
model_summary=dict(count=len(files),total_size=sum(f.get('size') or 0 for f in files)),
events=list(self.events),errors={'hardware':'; '.join(h.get('errors') or []) or None})
self.cached = result
self.checked = time.monotonic()
return result
def _collect_loop(self):
next_compaction = 0
while not self.closed.is_set():
try:
self.history.record(self.snapshot())
if time.time() >= next_compaction:
self.history.compact()
next_compaction = time.time()+3600
except Exception:
pass # Telemetry must never interrupt inference or expose request contents.
self.closed.wait(HISTORY_INTERVAL)
+273
View File
@@ -0,0 +1,273 @@
"""Dashboard telemetry history schema and queries, adapted from the old Athena dashboard."""
from __future__ import annotations
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any
HISTORY_INTERVAL = 15
DETAIL_RETENTION_DAYS = 21
class HistoryStore:
"""Small persistent telemetry store; never stores prompts or responses."""
TOKEN_KEYS = ("prompt_tokens_total", "prompt_tokens_cached_total", "tokens_predicted_total")
def close(self) -> None:
with self._lock:
self._db.close()
def __init__(self, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
self._db = sqlite3.connect(path, check_same_thread=False)
self._db.row_factory = sqlite3.Row
self._lock = threading.Lock()
with self._db:
self._db.executescript("""
PRAGMA journal_mode=WAL;
PRAGMA synchronous=NORMAL;
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS samples_raw (
ts INTEGER NOT NULL, gpu_index INTEGER NOT NULL,
gpu_util REAL, memory_used_mib REAL, temperature_c REAL, power_w REAL,
profile TEXT, model TEXT
);
CREATE INDEX IF NOT EXISTS idx_samples_raw_ts ON samples_raw(ts);
CREATE TABLE IF NOT EXISTS samples_hourly (
hour_ts INTEGER NOT NULL, gpu_index INTEGER NOT NULL,
gpu_util_sum REAL, gpu_util_max REAL, memory_used_sum REAL, memory_used_max REAL,
temperature_sum REAL, temperature_max REAL, power_sum REAL, samples INTEGER NOT NULL,
PRIMARY KEY(hour_ts, gpu_index)
);
CREATE TABLE IF NOT EXISTS slot_samples_raw (
ts INTEGER NOT NULL, slot_id INTEGER NOT NULL,
context_used REAL, context_total REAL, processing INTEGER NOT NULL DEFAULT 0,
profile TEXT, model TEXT
);
CREATE INDEX IF NOT EXISTS idx_slot_samples_raw_ts ON slot_samples_raw(ts);
CREATE TABLE IF NOT EXISTS slot_samples_hourly (
hour_ts INTEGER NOT NULL, slot_id INTEGER NOT NULL,
context_percent_sum REAL, context_percent_max REAL,
context_used_max REAL, context_total_max REAL,
busy_samples INTEGER NOT NULL, samples INTEGER NOT NULL,
PRIMARY KEY(hour_ts, slot_id)
);
CREATE TABLE IF NOT EXISTS token_totals (
id INTEGER PRIMARY KEY CHECK(id=1), prompt_tokens INTEGER NOT NULL DEFAULT 0,
cached_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO token_totals(id) VALUES(1);
CREATE TABLE IF NOT EXISTS token_hourly (
hour_ts INTEGER NOT NULL, profile TEXT NOT NULL, model TEXT NOT NULL,
prompt_tokens INTEGER NOT NULL DEFAULT 0, cached_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(hour_ts, profile, model)
);
CREATE TABLE IF NOT EXISTS model_events (
ts INTEGER NOT NULL, previous_profile TEXT, profile TEXT,
previous_model TEXT, model TEXT
);
CREATE INDEX IF NOT EXISTS idx_model_events_ts ON model_events(ts);
""")
def _meta(self, key: str) -> str | None:
row = self._db.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
return str(row[0]) if row else None
def _set_meta(self, key: str, value: Any) -> None:
self._db.execute(
"INSERT INTO meta(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
(key, str(value)),
)
def record(self, snapshot: dict[str, Any]) -> None:
ts = int(snapshot.get("timestamp") or time.time())
router = snapshot.get("router") or {}
image = router.get("image") or {}
image_active = image.get("phase") not in (None, "idle")
profile = str("image" if image_active else
(router.get("current_profile") or "unknown"))
model = str((image.get("model") if image_active else
(router.get("upstream") or {}).get("model")) or "unknown")
metrics = ((router.get("llama_telemetry") or {}).get("metrics") or {})
runtime = snapshot.get("llama_runtime") or {}
runtime_id = f"{runtime.get('pid', 'none')}:{runtime.get('model_file') or model}"
hour = ts - ts % 3600
with self._lock, self._db:
for gpu in snapshot.get("gpus") or []:
if gpu.get("index") is None:
continue
self._db.execute(
"INSERT INTO samples_raw VALUES(?,?,?,?,?,?,?,?)",
(ts, int(gpu["index"]), gpu.get("gpu_percent"), gpu.get("memory_used_mib"),
gpu.get("temperature_c"), gpu.get("power_w"), profile, model),
)
slots = ((router.get("llama_telemetry") or {}).get("slots") or [])
for slot in slots:
if slot.get("id") is None:
continue
used = max(0, float(slot.get("context_used") or 0))
total = max(0, float(slot.get("n_ctx") or 0))
self._db.execute(
"INSERT INTO slot_samples_raw VALUES(?,?,?,?,?,?,?)",
(ts, int(slot["id"]), used, total, int(bool(slot.get("processing"))),
profile, model),
)
previous_runtime = self._meta("counter_runtime")
deltas: list[int] = []
for key in self.TOKEN_KEYS:
current = max(0, int(float(metrics.get(key) or 0)))
previous = int(self._meta(f"counter_{key}") or 0)
delta = current - previous if previous_runtime == runtime_id and current >= previous else current
deltas.append(max(0, delta))
self._set_meta(f"counter_{key}", current)
self._set_meta("counter_runtime", runtime_id)
if any(deltas):
self._db.execute(
"UPDATE token_totals SET prompt_tokens=prompt_tokens+?, cached_tokens=cached_tokens+?, output_tokens=output_tokens+? WHERE id=1",
deltas,
)
self._db.execute(
"""INSERT INTO token_hourly VALUES(?,?,?,?,?,?)
ON CONFLICT(hour_ts,profile,model) DO UPDATE SET
prompt_tokens=prompt_tokens+excluded.prompt_tokens,
cached_tokens=cached_tokens+excluded.cached_tokens,
output_tokens=output_tokens+excluded.output_tokens""",
(hour, profile, model, *deltas),
)
previous_profile = self._meta("last_profile")
previous_model = self._meta("last_model")
if previous_profile is not None and (profile != previous_profile or model != previous_model):
self._db.execute(
"INSERT INTO model_events VALUES(?,?,?,?,?)",
(ts, previous_profile, profile, previous_model, model),
)
self._set_meta("last_profile", profile)
self._set_meta("last_model", model)
def compact(self) -> None:
cutoff = int(time.time()) - DETAIL_RETENTION_DAYS * 86400
with self._lock, self._db:
self._db.execute("""
INSERT INTO samples_hourly
SELECT ts-ts%3600, gpu_index, SUM(gpu_util), MAX(gpu_util),
SUM(memory_used_mib), MAX(memory_used_mib), SUM(temperature_c),
MAX(temperature_c), SUM(power_w), COUNT(*)
FROM samples_raw WHERE ts < ? GROUP BY ts-ts%3600, gpu_index
ON CONFLICT(hour_ts,gpu_index) DO UPDATE SET
gpu_util_sum=gpu_util_sum+excluded.gpu_util_sum,
gpu_util_max=MAX(gpu_util_max,excluded.gpu_util_max),
memory_used_sum=memory_used_sum+excluded.memory_used_sum,
memory_used_max=MAX(memory_used_max,excluded.memory_used_max),
temperature_sum=temperature_sum+excluded.temperature_sum,
temperature_max=MAX(temperature_max,excluded.temperature_max),
power_sum=power_sum+excluded.power_sum,
samples=samples+excluded.samples
""", (cutoff,))
self._db.execute("DELETE FROM samples_raw WHERE ts < ?", (cutoff,))
self._db.execute("""
INSERT INTO slot_samples_hourly
SELECT ts-ts%3600, slot_id,
SUM(CASE WHEN context_total>0 THEN 100.0*context_used/context_total ELSE 0 END),
MAX(CASE WHEN context_total>0 THEN 100.0*context_used/context_total ELSE 0 END),
MAX(context_used), MAX(context_total), SUM(processing), COUNT(*)
FROM slot_samples_raw WHERE ts < ? GROUP BY ts-ts%3600, slot_id
ON CONFLICT(hour_ts,slot_id) DO UPDATE SET
context_percent_sum=context_percent_sum+excluded.context_percent_sum,
context_percent_max=MAX(context_percent_max,excluded.context_percent_max),
context_used_max=MAX(context_used_max,excluded.context_used_max),
context_total_max=MAX(context_total_max,excluded.context_total_max),
busy_samples=busy_samples+excluded.busy_samples,
samples=samples+excluded.samples
""", (cutoff,))
self._db.execute("DELETE FROM slot_samples_raw WHERE ts < ?", (cutoff,))
def query(self, range_name: str) -> dict[str, Any]:
ranges = {
"1h": (3600, 60), "24h": (86400, 300), "7d": (7 * 86400, 1800),
"21d": (21 * 86400, 3600), "all": (0, 3600),
}
seconds, bucket = ranges.get(range_name, ranges["24h"])
now = int(time.time())
start = 0 if seconds == 0 else now - seconds
detail_cutoff = now - DETAIL_RETENTION_DAYS * 86400
with self._lock:
points: list[dict[str, Any]] = []
slot_points: list[dict[str, Any]] = []
if start < detail_cutoff:
for row in self._db.execute("""
SELECT hour_ts ts,gpu_index,gpu_util_sum/samples gpu_util,gpu_util_max,
memory_used_sum/samples memory_used_mib,memory_used_max,
temperature_sum/samples temperature_c,temperature_max,
power_sum/samples power_w
FROM samples_hourly WHERE hour_ts>=? ORDER BY hour_ts,gpu_index
""", (start,)):
points.append(dict(row))
raw_start = max(start, detail_cutoff)
for row in self._db.execute(f"""
SELECT (ts/{bucket})*{bucket} ts,gpu_index,AVG(gpu_util) gpu_util,MAX(gpu_util) gpu_util_max,
AVG(memory_used_mib) memory_used_mib,MAX(memory_used_mib) memory_used_max,
AVG(temperature_c) temperature_c,MAX(temperature_c) temperature_max,
AVG(power_w) power_w
FROM samples_raw WHERE ts>=? GROUP BY (ts/{bucket}),gpu_index ORDER BY ts,gpu_index
""", (raw_start,)):
points.append(dict(row))
if start < detail_cutoff:
for row in self._db.execute("""
SELECT hour_ts ts,slot_id,context_percent_sum/samples context_percent,
context_percent_max,context_used_max context_used,
context_total_max context_total,
1.0*busy_samples/samples busy_ratio
FROM slot_samples_hourly WHERE hour_ts>=? ORDER BY hour_ts,slot_id
""", (start,)):
slot_points.append(dict(row))
for row in self._db.execute(f"""
SELECT (ts/{bucket})*{bucket} ts,slot_id,
AVG(CASE WHEN context_total>0 THEN 100.0*context_used/context_total ELSE 0 END) context_percent,
MAX(CASE WHEN context_total>0 THEN 100.0*context_used/context_total ELSE 0 END) context_percent_max,
MAX(context_used) context_used,MAX(context_total) context_total,
AVG(processing) busy_ratio
FROM slot_samples_raw WHERE ts>=?
GROUP BY (ts/{bucket}),slot_id ORDER BY ts,slot_id
""", (raw_start,)):
slot_points.append(dict(row))
totals = dict(self._db.execute("SELECT * FROM token_totals WHERE id=1").fetchone())
range_tokens = dict(self._db.execute(
"SELECT COALESCE(SUM(prompt_tokens),0) prompt_tokens, COALESCE(SUM(cached_tokens),0) cached_tokens, COALESCE(SUM(output_tokens),0) output_tokens FROM token_hourly WHERE hour_ts>=?",
(start,),
).fetchone())
profile_usage = [dict(row) for row in self._db.execute("""
SELECT profile,model,SUM(prompt_tokens) prompt_tokens,
SUM(cached_tokens) cached_tokens,SUM(output_tokens) output_tokens
FROM token_hourly WHERE hour_ts>=? GROUP BY profile,model
ORDER BY SUM(prompt_tokens+cached_tokens+output_tokens) DESC
""", (start,))]
events = [dict(row) for row in self._db.execute(
"SELECT * FROM model_events WHERE ts>=? ORDER BY ts DESC LIMIT 50", (start,)
)]
raw_info = dict(self._db.execute(
"SELECT COUNT(*) rows, MIN(ts) oldest, MAX(ts) newest FROM samples_raw"
).fetchone())
slot_raw_info = dict(self._db.execute(
"SELECT COUNT(*) rows, MIN(ts) oldest, MAX(ts) newest FROM slot_samples_raw"
).fetchone())
points.sort(key=lambda item: (item["ts"], item["gpu_index"]))
slot_points.sort(key=lambda item: (item["ts"], item["slot_id"]))
return {
"range": range_name if range_name in ranges else "24h",
"detail_retention_days": DETAIL_RETENTION_DAYS,
"sample_interval_seconds": HISTORY_INTERVAL,
"points": points,
"slot_points": slot_points,
"token_totals": totals,
"range_tokens": range_tokens,
"profile_usage": profile_usage,
"model_events": events,
"storage": raw_info,
"slot_storage": slot_raw_info,
}
+2 -2
View File
@@ -14,8 +14,8 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-ins
WORKDIR /app WORKDIR /app
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock COPY deploy/tts-requirements.lock /app/deploy/tts-requirements.lock
COPY audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py /app/ COPY audio_policy.py prompt_enhancer.py prompt_enhancer_worker.py api_compat.py stt.py execution_setup.py tts_runtime.py tts_test.py tts_worker.py auto_test.py chat_test.py endpoint.py inference.py docker_support.py image_encoder_node.py image_runtime.py image_test.py image_upload.py profiles.py capacity.py server.py runtime.py video.py video_proxy.py catalog.py hub_auth.py auth.py collect_hardware.py dashboard_data.py dashboard_history.py /app/
COPY video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css login.html login.js access-ui.js network-ui.js /app/ COPY video-ui.js stt-ui.js tts-ui.js auto-test-ui.js chat-test-ui.js endpoint-ui.js docker-ui.js image-test-ui.js profiles-ui.js dashboard-ui.js index.html app.js studio.js runtime-ui.js catalog-ui.js style.css dashboard.css login.html login.js access-ui.js network-ui.js /app/
COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/ COPY network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \ DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Copy old Athena dashboard telemetry once, without reading chats or logs.
Run before the first Deck dashboard start. SQLite's backup API safely includes
the source WAL while the old dashboard is still collecting samples.
"""
import argparse
import os
import sqlite3
from pathlib import Path
REQUIRED = {'meta','samples_raw','samples_hourly','slot_samples_raw',
'slot_samples_hourly','token_totals','token_hourly','model_events'}
def import_history(source, target):
source=Path(source)
target=Path(target)
if not source.is_file() or target.exists():
raise ValueError('Quelldatenbank fehlt oder Deck-Ziel existiert bereits; nichts überschrieben.')
target.parent.mkdir(parents=True,exist_ok=True)
old=sqlite3.connect('file:'+str(source)+'?mode=ro',uri=True)
try:
tables={row[0] for row in old.execute("SELECT name FROM sqlite_master WHERE type='table'")}
if not REQUIRED<=tables:raise ValueError('Unbekanntes Dashboard-History-Schema.')
new=sqlite3.connect(target)
try:
old.backup(new)
if new.execute('PRAGMA integrity_check').fetchone()[0]!='ok':
raise ValueError('Kopierte Datenbank ist beschädigt.')
counts={table:new.execute('SELECT COUNT(*) FROM '+table).fetchone()[0]
for table in ('samples_raw','slot_samples_raw','token_hourly','model_events')}
except Exception:
new.close()
target.unlink(missing_ok=True)
raise
finally:
if new: new.close()
target.chmod(0o600)
if os.geteuid()==0:
owner=target.parent.stat()
os.chown(target,owner.st_uid,owner.st_gid)
return counts
finally:
old.close()
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('source',type=Path)
parser.add_argument('target',type=Path)
args=parser.parse_args()
print(import_history(args.source,args.target))
+6 -2
View File
@@ -14,7 +14,7 @@ import urllib.request
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
LABEL = 'de.casaderoll.athena-deck.standalone' LABEL = 'de.casaderoll.athena-deck.standalone'
FILES = ['prompt_enhancer.py','prompt_enhancer_worker.py','image_upload.py','audio_policy.py','deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile','deploy/image-requirements.lock'] FILES = ['prompt_enhancer.py','prompt_enhancer_worker.py','image_upload.py','audio_policy.py','deploy/tts-requirements.lock','stt.py','stt-ui.js','api_compat.py','execution_setup.py','tts_runtime.py','tts_test.py','tts_worker.py','tts-ui.js','auto_test.py','auto-test-ui.js','chat_test.py','chat-test-ui.js','endpoint.py','inference.py','endpoint-ui.js','docker_support.py','docker-ui.js','deploy/docker_helper.py','deploy/setup_docker_helper.py','image_encoder_node.py','image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','video.py','video_proxy.py','video-ui.js','catalog.py','hub_auth.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','dashboard_data.py','dashboard_history.py','dashboard-ui.js','dashboard.css','deploy/import_dashboard_history.py','index.html','app.js','studio.js','style.css','login.html','login.js','access-ui.js','network-ui.js','network/__init__.py','network/client.py','network/config.py','network/rpc.py','deploy/Dockerfile','deploy/image-requirements.lock']
def run(*args, check=True, interactive=False): def run(*args, check=True, interactive=False):
@@ -94,7 +94,7 @@ def launch(config):
args=['docker','run','-d','--name',config['name'],'--label',LABEL+'='+str(base), args=['docker','run','-d','--name',config['name'],'--label',LABEL+'='+str(base),
'--restart','unless-stopped','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges:true', '--restart','unless-stopped','--read-only','--cap-drop','ALL','--security-opt','no-new-privileges:true',
'--pids-limit','256' if config.get('image_runtime') else '128','--memory','32g' if config.get('image_runtime') else '8g','--cpus','2', '--tmpfs','/tmp:rw,nosuid,nodev,size=256m', '--pids-limit','256' if config.get('image_runtime') else '128','--memory','32g' if config.get('image_runtime') else '8g','--cpus','2', '--tmpfs','/tmp:rw,nosuid,nodev,size=256m',
'-v',str(base/'state')+':/var/lib/deck:rw','-e','DECK_ALLOWED_HOSTS=127.0.0.1:'+str(config['port'])+',localhost:'+str(config['port']), '-v',str(base/'state')+':/var/lib/deck:rw','--mount','type=bind,src=/proc,dst=/host/proc,readonly','-e','DECK_HOST_PROC=/host/proc','-e','DECK_ALLOWED_HOSTS=127.0.0.1:'+str(config['port'])+',localhost:'+str(config['port']),
'-p','127.0.0.1:'+str(config['port'])+':8108', '-p','127.0.0.1:'+str(config['port'])+':8108',
'--health-cmd', 'python3 -c "import urllib.request,json; assert json.load(urllib.request.urlopen(\'http://127.0.0.1:8108/api/v1/auth/status\',timeout=3))[\'initialized\']"', '--health-cmd', 'python3 -c "import urllib.request,json; assert json.load(urllib.request.urlopen(\'http://127.0.0.1:8108/api/v1/auth/status\',timeout=3))[\'initialized\']"',
'--health-interval','30s','--health-timeout','5s','--health-retries','3'] '--health-interval','30s','--health-timeout','5s','--health-retries','3']
@@ -109,6 +109,10 @@ def launch(config):
args += ['--mount','type=bind,src=/run/athena-deck-docker,dst=/run/athena-deck-docker,readonly','-e','DECK_DOCKER_HELPER_SOCKET=/run/athena-deck-docker/control.sock'] args += ['--mount','type=bind,src=/run/athena-deck-docker,dst=/run/athena-deck-docker,readonly','-e','DECK_DOCKER_HELPER_SOCKET=/run/athena-deck-docker/control.sock']
if config['gpu_telemetry']: if config['gpu_telemetry']:
args += ['--gpus','all','-e','NVIDIA_DRIVER_CAPABILITIES=compute,utility'] args += ['--gpus','all','-e','NVIDIA_DRIVER_CAPABILITIES=compute,utility']
if Path('/data/models').is_dir():
args+=['--mount','type=bind,src=/data/models,dst=/host/models,readonly','-e','DECK_HOST_DATA_DISK=/host/models']
if Path('/data/emergency-backups').is_dir():
args+=['--mount','type=bind,src=/data/emergency-backups,dst=/host/backups,readonly']
if config.get('reference_models'): if config.get('reference_models'):
for folder,filename in [('qwen3.8-27b-iq4-mix','Qwen3.8-27B-IQ4-MIX.gguf'),('qwen3.8-27b-iq4-xs-pure','qwen3.8-27b-IQ4_XS-pure.gguf'),('qwen3.8-27b-abliterated','Qwen3.8-27B-ABLITERATED-Q4_K_M.gguf')]: for folder,filename in [('qwen3.8-27b-iq4-mix','Qwen3.8-27B-IQ4-MIX.gguf'),('qwen3.8-27b-iq4-xs-pure','qwen3.8-27b-IQ4_XS-pure.gguf'),('qwen3.8-27b-abliterated','Qwen3.8-27B-ABLITERATED-Q4_K_M.gguf')]:
source=Path('/data/models')/folder/filename source=Path('/data/models')/folder/filename
+1 -1
View File
@@ -1 +1 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/profiles-ui.js" defer></script><script src="/auto-test-ui.js" defer></script><script src="/chat-test-ui.js" defer></script><script src="/image-test-ui.js" defer></script><script src="/docker-ui.js" defer></script><script src="/stt-ui.js" defer></script><script src="/tts-ui.js" defer></script><script src="/studio.js" defer></script><script src="/video-ui.js" defer></script><script src="/endpoint-ui.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#home"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / ATHENA</p><nav aria-label="Hauptnavigation"><a href="#home">Übersicht <span>01</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><p class="nav-heading">AUDIO</p><a class="nav-sub" href="#audio">Text-to-Speech (TTS)</a><a class="nav-sub" href="#stt">Spracherkennung (STT)</a><a class="nav-sub" href="#music">Musik</a><a class="nav-sub" href="#voice">Stimme / Voice</a><a href="#video">Video <span>05</span></a><a href="#services">Weitere Dienste <span>06</span></a><a href="#hardware">Hardware <span>07</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#runtimes">Laufzeiten <span>↗</span></a><a class="nav-sub" href="#runtime">llama.cpp</a><a class="nav-sub" href="#image-runtime">Bildgenerierung</a><a class="nav-sub" href="#stt-runtime">Spracherkennung</a><a class="nav-sub" href="#tts-runtime">Text-to-Speech</a><a class="nav-sub" href="#docker-runtime">Docker</a><a href="#endpoint">Endpunkt &amp; Profile <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang &amp; API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Eigener Modell-Router<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.7 · Router</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html> <!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Athena Deck</title><link rel="stylesheet" href="/style.css"><script src="/network-ui.js" defer></script><script src="/access-ui.js" defer></script><script src="/catalog-ui.js" defer></script><script src="/runtime-ui.js" defer></script><script src="/profiles-ui.js" defer></script><script src="/auto-test-ui.js" defer></script><script src="/chat-test-ui.js" defer></script><script src="/image-test-ui.js" defer></script><script src="/docker-ui.js" defer></script><script src="/stt-ui.js" defer></script><script src="/tts-ui.js" defer></script><script src="/studio.js" defer></script><script src="/video-ui.js" defer></script><script src="/endpoint-ui.js" defer></script><script src="/dashboard-ui.js" defer></script><script src="/app.js" defer></script></head><body><aside><a class="brand" href="#dashboard"><span>Α</span> ATHENA <b>DECK</b></a><p class="eyebrow">WORKSPACE / ATHENA</p><nav aria-label="Hauptnavigation"><a href="#dashboard">Dashboard <span>01</span></a><a href="#home">Übersicht <span>02</span></a><a href="#chat">Sprachmodelle / Chat <span>02</span></a><a href="#image">Bildgenerierung <span>03</span></a><p class="nav-heading">AUDIO</p><a class="nav-sub" href="#audio">Text-to-Speech (TTS)</a><a class="nav-sub" href="#stt">Spracherkennung (STT)</a><a class="nav-sub" href="#music">Musik</a><a class="nav-sub" href="#voice">Stimme / Voice</a><a href="#video">Video <span>05</span></a><a href="#services">Weitere Dienste <span>06</span></a><p class="nav-heading">EINSTELLUNGEN</p><a href="#runtimes">Laufzeiten <span>↗</span></a><a class="nav-sub" href="#runtime">llama.cpp</a><a class="nav-sub" href="#image-runtime">Bildgenerierung</a><a class="nav-sub" href="#stt-runtime">Spracherkennung</a><a class="nav-sub" href="#tts-runtime">Text-to-Speech</a><a class="nav-sub" href="#docker-runtime">Docker</a><a href="#endpoint">Endpunkt &amp; Profile <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang &amp; API <span>↗</span></a></nav><footer><i></i> Isolierte Entwicklungsumgebung<p>Eigener Modell-Router<br>Hardware · nur lesend</p></footer></aside><main><header><span>ATHENA CONTROL SURFACE</span><span class="pill">v0.7 · Router</span></header><div id="view"></div><p id="error" role="alert"></p></main></body></html>
+1 -1
View File
@@ -233,7 +233,7 @@ class LlamaWorker:
if cancel():raise InferenceError('Modellstart abgebrochen.') if cancel():raise InferenceError('Modellstart abgebrochen.')
if plan_only:return if plan_only:return
with self.lock:self.phase='Modell wird geladen' with self.lock:self.phase='Modell wird geladen'
launch=common+extra+vision_args+['--gpu-layers',str(layers),'--fit','off','--kv-unified','--threads',str(params['threads']),'--load-mode','none','--host','127.0.0.1','--alias',profile['name'],'--no-webui','--log-disable'] launch=common+extra+vision_args+['--gpu-layers',str(layers),'--fit','off','--kv-unified','--threads',str(params['threads']),'--load-mode','none','--host','127.0.0.1','--alias',profile['name'],'--metrics','--no-webui','--log-disable']
if mtp:launch+=['--spec-type','draft-mtp','--spec-draft-n-max',str(params.get('mtp_tokens',2)),'--spec-draft-p-min',str(params.get('mtp_min_p',.05)),'--spec-draft-type-k','f16','--spec-draft-type-v','f16'] if mtp:launch+=['--spec-type','draft-mtp','--spec-draft-n-max',str(params.get('mtp_tokens',2)),'--spec-draft-p-min',str(params.get('mtp_min_p',.05)),'--spec-draft-type-k','f16','--spec-draft-type-v','f16']
self.root.mkdir(parents=True,exist_ok=True,mode=0o700) self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
with socket.socket() as sock:sock.bind(('127.0.0.1',0));port=sock.getsockname()[1] with socket.socket() as sock:sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
+25 -4
View File
@@ -24,7 +24,7 @@ from inference import LlamaWorker,Scheduler
from endpoint import Endpoint from endpoint import Endpoint
from chat_test import ChatTests from chat_test import ChatTests
from auto_test import AutoTests from auto_test import AutoTests
from urllib.parse import urlsplit, parse_qs from urllib.parse import urlsplit, parse_qs, unquote
from network.client import NetworkClient from network.client import NetworkClient
from network.config import parse_config, ConfigError from network.config import parse_config, ConfigError
import json import json
@@ -50,12 +50,12 @@ class HardwareProvider:
if os.environ.get('DECK_LOCAL_HARDWARE', '1') == '1': if os.environ.get('DECK_LOCAL_HARDWARE', '1') == '1':
from collect_hardware import collect from collect_hardware import collect
with self.lock: with self.lock:
if self.cached is None or time.monotonic()-self.checked >= 5: if self.cached is None or time.monotonic()-self.checked >= 1:
self.cached = dict(collect(), available=True) self.cached = dict(collect(), available=True)
self.checked = time.monotonic() self.checked = time.monotonic()
return self.cached return self.cached
with self.lock: with self.lock:
if time.monotonic() - self.checked < 5 and self.cached is not None: if time.monotonic() - self.checked < 1 and self.cached is not None:
return self.cached return self.cached
try: try:
result = subprocess.run(['ssh', '-i', str(Path.home()/'.ssh/athena_key'), '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=4', '-o', 'StrictHostKeyChecking=yes', 'root@192.168.1.212', 'python3 -'], input=(ROOT/'collect_hardware.py').read_text(), capture_output=True, text=True, timeout=10, check=True) result = subprocess.run(['ssh', '-i', str(Path.home()/'.ssh/athena_key'), '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=4', '-o', 'StrictHostKeyChecking=yes', 'root@192.168.1.212', 'python3 -'], input=(ROOT/'collect_hardware.py').read_text(), capture_output=True, text=True, timeout=10, check=True)
@@ -113,6 +113,8 @@ class Server(ThreadingHTTPServer):
self.auto_tests.stop();self.chat_tests.stop();self.image_tests.stop();self.prompt_enhancer.stop_preview();self.tts_tests.stop();self.worker.stop() self.auto_tests.stop();self.chat_tests.stop();self.image_tests.stop();self.prompt_enhancer.stop_preview();self.tts_tests.stop();self.worker.stop()
self.video=Video(self.scheduler,stop_gpu_work,self.docker,self.catalog.root.parent/'video') self.video=Video(self.scheduler,stop_gpu_work,self.docker,self.catalog.root.parent/'video')
self.endpoint.video=self.video self.endpoint.video=self.video
from dashboard_data import Dashboard
self.dashboard=Dashboard(self,self.catalog.root.parent)
if self.endpoint.config['autostart']: if self.endpoint.config['autostart']:
try:self.endpoint.start() try:self.endpoint.start()
except ValueError as exc:self.endpoint.error=str(exc) except ValueError as exc:self.endpoint.error=str(exc)
@@ -272,7 +274,7 @@ class Handler(BaseHTTPRequestHandler):
return self.respond({'error':'Anmeldung erforderlich.'},401) return self.respond({'error':'Anmeldung erforderlich.'},401)
if not self.authenticated() and self.path == '/': if not self.authenticated() and self.path == '/':
return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8') return self.respond((ROOT/'login.html').read_bytes(), mime='text/html; charset=utf-8')
routes = {'/video-ui.js':('video-ui.js','text/javascript'),'/stt-ui.js':('stt-ui.js','text/javascript'),'/tts-ui.js':('tts-ui.js','text/javascript'),'/auto-test-ui.js':('auto-test-ui.js','text/javascript'),'/chat-test-ui.js':('chat-test-ui.js','text/javascript'),'/endpoint-ui.js':('endpoint-ui.js','text/javascript'),'/docker-ui.js': ('docker-ui.js','text/javascript'), '/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript'), '/profiles-ui.js': ('profiles-ui.js','text/javascript'), '/image-test-ui.js': ('image-test-ui.js','text/javascript')} routes = {'/dashboard-ui.js':('dashboard-ui.js','text/javascript'),'/dashboard.css':('dashboard.css','text/css'),'/video-ui.js':('video-ui.js','text/javascript'),'/stt-ui.js':('stt-ui.js','text/javascript'),'/tts-ui.js':('tts-ui.js','text/javascript'),'/auto-test-ui.js':('auto-test-ui.js','text/javascript'),'/chat-test-ui.js':('chat-test-ui.js','text/javascript'),'/endpoint-ui.js':('endpoint-ui.js','text/javascript'),'/docker-ui.js': ('docker-ui.js','text/javascript'), '/': ('index.html', 'text/html; charset=utf-8'), '/app.js': ('app.js', 'text/javascript'), '/style.css': ('style.css', 'text/css'), '/network-ui.js': ('network-ui.js', 'text/javascript'), '/access-ui.js': ('access-ui.js', 'text/javascript'), '/studio.js': ('studio.js', 'text/javascript'), '/catalog-ui.js': ('catalog-ui.js','text/javascript'), '/runtime-ui.js': ('runtime-ui.js','text/javascript'), '/profiles-ui.js': ('profiles-ui.js','text/javascript'), '/image-test-ui.js': ('image-test-ui.js','text/javascript')}
if self.path in routes: if self.path in routes:
name, mime = routes[self.path] name, mime = routes[self.path]
return self.respond((ROOT/name).read_bytes(), mime=mime) return self.respond((ROOT/name).read_bytes(), mime=mime)
@@ -308,6 +310,24 @@ class Handler(BaseHTTPRequestHandler):
if self.path == '/api/v1/huggingface':return self.respond(self.server.catalog.hub_auth.status()) if self.path == '/api/v1/huggingface':return self.respond(self.server.catalog.hub_auth.status())
if self.path == '/api/v1/hardware': if self.path == '/api/v1/hardware':
return self.respond(self.server.hardware.snapshot()) return self.respond(self.server.hardware.snapshot())
if self.path == '/api/v1/dashboard':return self.respond(self.server.dashboard.snapshot())
if urlsplit(self.path).path == '/api/v1/dashboard/history':
period=parse_qs(urlsplit(self.path).query).get('range',['24h'])[0]
return self.respond(self.server.dashboard.history.query(period))
if self.path == '/api/v1/dashboard/backups':return self.respond(self.server.dashboard.backups())
if self.path.startswith('/api/v1/dashboard/backups/download/'):
try:path=self.server.dashboard.backup_file(unquote(self.path.rsplit('/',1)[-1]))
except ValueError:return self.respond({'error':'Backup nicht verfügbar.'},404)
self.send_response(200)
self.send_header('Content-Type','application/octet-stream')
self.send_header('Content-Length',str(path.stat().st_size))
self.send_header('Content-Disposition','attachment; filename="'+path.name+'"')
self.send_header('Cache-Control','no-store')
self.send_header('X-Content-Type-Options','nosniff')
self.end_headers()
with path.open('rb') as stream:
while chunk:=stream.read(1024*1024):self.wfile.write(chunk)
return
if self.path.startswith('/api/v1/runtime'): if self.path.startswith('/api/v1/runtime'):
actions={'/api/v1/runtime':self.server.runtime.status,'/api/v1/runtime/prerequisites':self.server.runtime.prerequisites,'/api/v1/runtime/releases':self.server.runtime.releases,'/api/v1/runtime/log':self.server.runtime.log,'/api/v1/runtime/references':self.server.runtime.references} actions={'/api/v1/runtime':self.server.runtime.status,'/api/v1/runtime/prerequisites':self.server.runtime.prerequisites,'/api/v1/runtime/releases':self.server.runtime.releases,'/api/v1/runtime/log':self.server.runtime.log,'/api/v1/runtime/references':self.server.runtime.references}
if self.path in actions: if self.path in actions:
@@ -559,6 +579,7 @@ def main():
try: try:
server.serve_forever() server.serve_forever()
finally: finally:
server.dashboard.close()
server.auto_tests.stop() server.auto_tests.stop()
server.chat_tests.stop() server.chat_tests.stop()
server.video.close() server.video.close()
+18
View File
@@ -0,0 +1,18 @@
import unittest
from dashboard_data import metrics, slot_data
class DashboardDataTests(unittest.TestCase):
def test_only_selected_numeric_metrics_are_returned(self):
raw='''# HELP ignored\nllamacpp:prompt_tokens_total 120\nllamacpp:requests_processing 2\nllamacpp:unknown_metric 8\nllamacpp:prompt_tokens_total{model="x"} 99\n'''
self.assertEqual(metrics(raw),dict(prompt_tokens_total=120,requests_processing=2))
def test_slot_telemetry_excludes_prompt_text(self):
rows=slot_data([dict(id=0,n_ctx=4096,n_prompt_tokens=100,
next_token=[dict(n_decoded=7,n_remain=3)],
params=dict(max_tokens=10),prompt='private input')])
self.assertEqual(rows[0]['context_used'],107)
self.assertNotIn('prompt',rows[0])
if __name__=='__main__':unittest.main()
+41
View File
@@ -0,0 +1,41 @@
import tempfile
import unittest
from pathlib import Path
from dashboard_history import HistoryStore
from deploy.import_dashboard_history import import_history
class DashboardHistoryTests(unittest.TestCase):
def test_import_preserves_samples_tokens_and_events(self):
with tempfile.TemporaryDirectory() as folder:
old=Path(folder)/'old.sqlite3'
store=HistoryStore(old)
snapshot=dict(timestamp=1720000000,gpus=[dict(index=0,gpu_percent=25,
memory_used_mib=4096,temperature_c=55,power_w=170)],
router=dict(current_profile='Medium',upstream=dict(model='model.gguf'),
llama_telemetry=dict(slots=[dict(id=0,context_used=512,n_ctx=4096,processing=True)],
metrics=dict(prompt_tokens_total=12,
prompt_tokens_cached_total=3,
tokens_predicted_total=6))),
llama_runtime=dict(pid=123,model_file='model.gguf'))
store.record(snapshot)
store.record(dict(snapshot,timestamp=1720000015,
router=dict(snapshot['router'],current_profile='Fast')))
target=Path(folder)/'deck.sqlite3'
counts=import_history(old,target)
self.assertEqual(counts['samples_raw'],2)
self.assertEqual(counts['slot_samples_raw'],2)
self.assertEqual(counts['model_events'],1)
imported=HistoryStore(target)
result=imported.query('all')
self.assertEqual(result['token_totals']['prompt_tokens'],12)
self.assertEqual(result['token_totals']['cached_tokens'],3)
self.assertEqual(result['token_totals']['output_tokens'],6)
self.assertEqual(len(result['model_events']),1)
with self.assertRaises(ValueError):import_history(old,target)
imported.close()
store.close()
if __name__=='__main__':unittest.main()
+1 -1
View File
@@ -69,7 +69,7 @@ class EndpointTests(unittest.TestCase):
for effort in ('none','minimal','low','medium','high','xhigh','max','ultra',None): for effort in ('none','minimal','low','medium','high','xhigh','max','ultra',None):
status,_=self.request('/v1/chat/completions',dict(model='alpha',messages=[dict(role='user',content='synthetic')],reasoning_effort=effort)) status,_=self.request('/v1/chat/completions',dict(model='alpha',messages=[dict(role='user',content='synthetic')],reasoning_effort=effort))
self.assertEqual(status,200) self.assertEqual(status,200)
self.assertEqual(self.worker.requests[-1].get('reasoning_effort'),'max' if effort=='ultra' else effort) self.assertEqual(self.worker.requests[-1].get('reasoning_effort'),{'minimal':'low','max':'xhigh','ultra':'xhigh'}.get(effort,effort))
for effort in ([],True,3,'invalid'): for effort in ([],True,3,'invalid'):
self.assertEqual(self.request('/v1/chat/completions',dict(model='alpha',messages=[dict(role='user',content='synthetic')],reasoning_effort=effort))[0],400) self.assertEqual(self.request('/v1/chat/completions',dict(model='alpha',messages=[dict(role='user',content='synthetic')],reasoning_effort=effort))[0],400)
def test_video_profiles_not_published(self): def test_video_profiles_not_published(self):
+12
View File
@@ -23,6 +23,7 @@ class Tests(unittest.TestCase):
self.url=f'http://127.0.0.1:{self.server.server_port}' self.url=f'http://127.0.0.1:{self.server.server_port}'
def tearDown(self): def tearDown(self):
self.server.shutdown() self.server.shutdown()
self.server.dashboard.close()
self.server.endpoint.close() self.server.endpoint.close()
self.server.server_close() self.server.server_close()
self.thread.join() self.thread.join()
@@ -36,6 +37,17 @@ class Tests(unittest.TestCase):
self.assertEqual(state['endpoint']['state'],'stopped') self.assertEqual(state['endpoint']['state'],'stopped')
with self.assertRaises(urllib.error.HTTPError) as exc:self.request('demo') with self.assertRaises(urllib.error.HTTPError) as exc:self.request('demo')
self.assertEqual(exc.exception.code,404);exc.exception.close() self.assertEqual(exc.exception.code,404);exc.exception.close()
def test_dashboard_routes_are_authenticated_and_read_only(self):
state=self.request('dashboard')
self.assertIn('gpus',state)
self.assertIn('llama_runtime',state)
history=self.request('dashboard/history?range=24h')
self.assertEqual(history['range'],'24h')
self.assertIn('token_totals',history)
self.assertIn('backups',self.request('dashboard/backups'))
with self.assertRaises(urllib.error.HTTPError) as exc:
urllib.request.urlopen(self.url+'/api/v1/dashboard')
self.assertEqual(exc.exception.code,401);exc.exception.close()
def test_profile_and_download_routes(self): def test_profile_and_download_routes(self):
from pathlib import Path from pathlib import Path
target=Path(self.state.name)/'models'/('a'*64);target.mkdir(parents=True) target=Path(self.state.name)/'models'/('a'*64);target.mkdir(parents=True)