From aaf9f570e90e7061b493dd20df4a27085602befa Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:31:03 +0200 Subject: [PATCH] Add Deck dashboard with Athena telemetry history --- DASHBOARD.md | 61 +++++++ INSTALL.md | 8 + README.md | 14 +- app.js | 6 +- collect_hardware.py | 53 +++++- dashboard-ui.js | 233 ++++++++++++++++++++++++ dashboard.css | 11 ++ dashboard_data.py | 268 ++++++++++++++++++++++++++++ dashboard_history.py | 273 +++++++++++++++++++++++++++++ deploy/Dockerfile | 4 +- deploy/import_dashboard_history.py | 53 ++++++ deploy/install.py | 8 +- index.html | 2 +- inference.py | 2 +- server.py | 29 ++- test_dashboard_data.py | 18 ++ test_dashboard_history.py | 41 +++++ test_endpoint.py | 2 +- test_server.py | 12 ++ 19 files changed, 1073 insertions(+), 25 deletions(-) create mode 100644 DASHBOARD.md create mode 100644 dashboard-ui.js create mode 100644 dashboard.css create mode 100644 dashboard_data.py create mode 100644 dashboard_history.py create mode 100644 deploy/import_dashboard_history.py create mode 100644 test_dashboard_data.py create mode 100644 test_dashboard_history.py diff --git a/DASHBOARD.md b/DASHBOARD.md new file mode 100644 index 0000000..637fabc --- /dev/null +++ b/DASHBOARD.md @@ -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 `/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. diff --git a/INSTALL.md b/INSTALL.md index b797544..97fb4e8 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -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 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 keinen SSH-Schlüssel im Container und keine heimliche Fernsteuerung des vorhandenen Athena-Gateways. Der bisherige WireGuard-Installer bleibt ein separater Weg und wird diff --git a/README.md b/README.md index 4fac0ec..ec2f7aa 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,10 @@ Eigenständige neue Oberfläche, Python-Standardbibliothek und HTML/CSS/JavaScript. 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 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, Temperatur in Grad Celsius. Keine Zuordnung nach vermuteter GPU-Reihenfolge. 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. -Keine Historie und kein Hintergrund-Collector bei geschlossener Hardware-Seite. +Abfrage mit höchstens einer Sekunde Cache; das Dashboard aktualisiert jede +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 -- `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. - `inference.py`: native llama.cpp-Prozesse und gemeinsame GPU-Reservierung. - `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 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 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 diff --git a/app.js b/app.js index 57cc07f..3e6ffe1 100644 --- a/app.js +++ b/app.js @@ -7,14 +7,14 @@ async function api(path,method='GET'){const r=await fetch('/api/v1/'+path,{metho const metric=(title,value)=>`
${title}${value}
`; const bar=v=>v==null?'':``; 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(['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==='network'){await networkPage();return;} +if(page==='dashboard'){DashboardUI.render();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+=`
Hardware nicht verfügbar

${esc(h.errors.join(' '))}

`;else{const c=h.cpu,r=h.ram;html+=`
CPU

${esc(c.name)}

${metric('Auslastung',fmt(c.percent,' %'))}${metric('Temperatur',fmt(c.temperature_c,' °C'))}
${bar(c.percent)}
System-RAM

Arbeitsspeicher

${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'))}
${bar(r.total_bytes&&r.used_bytes!=null?100*r.used_bytes/r.total_bytes:null)}
`;for(const g of h.gpus)html+=`
GPU ${g.index}${fmt(g.temperature_c,' °C')}

${esc(g.name)}

${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'))}
GPU-Rechenlast${bar(g.percent)}VRAM-Belegung${bar(g.total_mib&&g.used_mib!=null?100*g.used_mib/g.total_mib:null)}
`;if(h.gpus.length<2)html+='
Nicht alle zwei GPUs verfügbar.
';html+=`

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.
${esc(h.source)} · Messung ${new Date(h.sampled_at*1000).toLocaleTimeString('de-DE')}
${esc(h.errors.join(' '))}

`;} }else{const p=placeholders[page]||placeholders.services;html=heading('ARBEITSBEREICH / VORBEREITET',p[0],p[1])+`
+

Platz für den nächsten Schritt.

${p[2]}

Platzhalter · noch keine Funktionen

Installation, Downloads, Presets und Modellwechsel sind in dieser Version nicht enthalten.

`;} -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;}} window.addEventListener('hashchange',refresh);refresh();setInterval(refresh,5000); diff --git a/collect_hardware.py b/collect_hardware.py index d7f503d..c9137a9 100644 --- a/collect_hardware.py +++ b/collect_hardware.py @@ -3,8 +3,14 @@ import csv import json import time import subprocess +import os +import shutil 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): try: @@ -21,11 +27,12 @@ def number(value): 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]) def collect(): + global _network_previous errors = [] cpu = {'name': None, 'percent': None, 'temperature_c': None} try: @@ -35,7 +42,7 @@ def collect(): cpu['percent'] = round(100 * (1 - (bi-ai)/(b-a)), 1) if b > a else None except (ValueError, IndexError): 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'): cpu['name'] = line.split(':', 1)[1].strip() break @@ -45,7 +52,7 @@ def collect(): cpu['temperature_c'] = value / 1000 if value is not None else None break mem = {} - for line in read('/proc/meminfo').splitlines(): + for line in read(PROC/'meminfo').splitlines(): parts = line.split() if parts[0] in ('MemTotal:', 'MemAvailable:'): 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} gpus = [] 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): - index, name, uuid, total, used, usage, temp = 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))) + if len(row)!=15:continue + 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): 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__': diff --git a/dashboard-ui.js b/dashboard-ui.js new file mode 100644 index 0000000..5d5c8d5 --- /dev/null +++ b/dashboard-ui.js @@ -0,0 +1,233 @@ +const DashboardUI=(()=>{ +let host=null,root=null,timers=[],controller=null; +const esc=v=>String(v??'–').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 `
GPU ${esc(g.index)}
${esc(g.name)}
${esc(g.pstate)}
${pct(g.gpu_percent)}GPU-Kern
${memoryText}VRAM
${esc(g.temperature_c)} °CTemperatur
${esc(g.power_w)} / ${esc(g.power_limit_w)} WLeistung
${esc(g.memory_controller_percent)} %Speichercontroller
${esc(g.graphics_clock_mhz)} MHzGPU-Takt
${esc(g.memory_clock_mhz)} MHzSpeichertakt
${esc(g.fan_percent)} %Lüfter
GPU-Auslastung${pct(load)}
VRAM${pct(p)}
`} +function mount(){ +controller=new AbortController(); +root=host.attachShadow({mode:'open'}); +root.innerHTML=`
Athena Deck · Live Telemetry

Dashboard

verbinde …
Aktives Profil
–
Deck wird abgefragt
+
Modell
–
–
+
CPU
–
–
+
System-RAM
–
–
+
+
Inferenz · Live
+
Generierung
–
Token pro Sekunde
+
Prompt-Einlesen
–
Token pro Sekunde
+
Prompt-Cache
–
–
+
Anfragen
–
–
+
Slots und Kontextfenster
Aktuelle Belegung und Verlauf des genutzten Kontextfensters
Telemetrie wird geladen …
Historie wird geladen …
+
MTP / Speculative Decoding
–
–Draft-Token
–akzeptiert
–Prüfschritte
–Draft-Tiefe
+
Tokenzähler seit Modellstart
+
Modell-Eigenschaften
+
Langzeitstatistik
+
Eingabe-Tokens gesamt
–
neu + Prompt-Cache
+
Ausgabe-Tokens gesamt
–
seit Beginn der Aufzeichnung
+
Historie
–
21 Tage detailliert, danach Stundenwerte
+
GPU-Verlauf
Auslastung und Temperatur beider Karten
Historie wird geladen …
+
Nutzung nach Profil und Modell
Prozentanteil im oben gewählten Zeitraum
Nutzungsverteilung wird geladen …
+
Dauerhafte Modellwechsel
ZeitProfilModell
Noch keine Wechsel aufgezeichnet
+
Deck · Profile · Dienste
+
Inferenz
–
–aktive Anfragen
–Deck-Uptime
–Profilwechsel
–/data belegt
+
Verfügbare Profile
+
Zusatzdienste
+
Netzwerk und Host
+
Hardware · Dateien · Laufzeit
+
GPU-Prozesse
GPUProzessPIDVRAM
–
+
Ereignisse seit Dashboard-Start
Noch keine Zustandsänderung
+
llama.cpp Laufzeitkonfiguration
–wird gelesen
+
Verfügbare GGUF-Dateien
–
DateiPfadGrößeGeändert
–
+
Notfall-Backups · herunterladen
+
Verschlüsselte portable Sicherungen
Unersetzliche Daten ohne erneut ladbare Modellgewichte · maximal fünf Generationen
ErstelltGrößeSHA256
Backups werden geladen …
Zum Wiederherstellen wird der separat verwahrte Age-Schlüssel benötigt.
+ +
`; +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)=>`
${esc(value)}${esc(label)}
`; +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?`Gemeinsamer Pool: ${deNum(pool.free)} Token frei`:''; + return `
Slot ${s.id??'–'} · ${state}${s.speculative?'MTP aktiv':'Standard'}
${capacity}${p.toFixed(1)} %
${shared?`
${shared}
`:''}
${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')}
`; +} +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('')||'
Slot-Telemetrie momentan nicht verfügbar
'; + 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])=>`
${esc(name)}${Number(ctx).toLocaleString('de-DE')} Token${name===rt.current_profile?' · aktiv':''}
`).join('')||'
Keine Profile gemeldet
'; + 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=>`${esc(f.name)}${esc(f.relative_path)}${gib(f.size)}${new Date(f.modified*1000).toLocaleString('de-DE')}`).join('')||'Keine GGUF-Dateien im eingebundenen Modellordner'; + $('events').innerHTML=(d.events||[]).map(e=>`
${esc(e.name)}: ${esc(e.from)} → ${esc(e.to)}
`).join('')||'
Noch keine Zustandsänderung
'; + }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;ip.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 ``})).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;ip.slot_id))].sort((a,b)=>a-b); + $('slotLegend').innerHTML=ids.map((id,idx)=>{let color=historyColors[idx%historyColors.length],key=String(id);return ``}).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=>`${new Date(e.ts*1000).toLocaleString('de-DE')}${esc(e.previous_profile||'–')} → ${esc(e.profile||'–')}${esc(e.previous_model||'–')} → ${esc(e.model||'–')}`).join('')||'Noch keine Wechsel aufgezeichnet'}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 `
${esc(r.profile||'unbekannt')} · ${esc(r.model||'–')}${p.toFixed(1)} %
${deNum(input)} Eingabe · ${deNum(r.output_tokens||0)} Ausgabe${deNum(v)} gewertet
`}).join('')||'
In diesem Zeitraum wurden noch keine Token aufgezeichnet.
'} +$('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('')||'
Keine GPU-Daten verfügbar
'; + $('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=>`${esc((d.gpus||[]).find(g=>g.uuid===p.gpu_uuid)?.index)}${esc(p.name)}${esc(p.pid)}${esc(p.memory_mib)} MiB`).join('')||'Keine Compute-Prozesse gemeldet'; + 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=>`${new Date(b.modified*1000).toLocaleString('de-DE')}${gib(b.size)}${esc(b.sha256||'Prüfsumme fehlt')}Herunterladen`).join('')||'Keine portablen Backups gefunden'; + $('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='
'; + host=view.firstElementChild; + mount(); +}}; +})(); diff --git a/dashboard.css b/dashboard.css new file mode 100644 index 0000000..f436bf6 --- /dev/null +++ b/dashboard.css @@ -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} + diff --git a/dashboard_data.py b/dashboard_data.py new file mode 100644 index 0000000..aad4db2 --- /dev/null +++ b/dashboard_data.py @@ -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) diff --git a/dashboard_history.py b/dashboard_history.py new file mode 100644 index 0000000..d692e95 --- /dev/null +++ b/dashboard_history.py @@ -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, + } diff --git a/deploy/Dockerfile b/deploy/Dockerfile index f073545..843e88b 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -14,8 +14,8 @@ RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-ins WORKDIR /app COPY deploy/image-requirements.lock /app/deploy/image-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 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 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 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/ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \ DECK_BIND_HOST=0.0.0.0 DECK_STATE_DIR=/var/lib/deck \ diff --git a/deploy/import_dashboard_history.py b/deploy/import_dashboard_history.py new file mode 100644 index 0000000..01a76ef --- /dev/null +++ b/deploy/import_dashboard_history.py @@ -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)) diff --git a/deploy/install.py b/deploy/install.py index 6187c2d..0b1691b 100644 --- a/deploy/install.py +++ b/deploy/install.py @@ -14,7 +14,7 @@ import urllib.request ROOT = Path(__file__).resolve().parent.parent 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): @@ -94,7 +94,7 @@ def launch(config): 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', '--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', '--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'] @@ -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'] if config['gpu_telemetry']: 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'): 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 diff --git a/index.html b/index.html index f9bdfe8..f82f53b 100644 --- a/index.html +++ b/index.html @@ -1 +1 @@ -Athena Deck
ATHENA CONTROL SURFACEv0.7 · Router
+Athena Deck
ATHENA CONTROL SURFACEv0.7 · Router
diff --git a/inference.py b/inference.py index 107ef43..c89bf54 100644 --- a/inference.py +++ b/inference.py @@ -233,7 +233,7 @@ class LlamaWorker: if cancel():raise InferenceError('Modellstart abgebrochen.') if plan_only:return 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'] 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] diff --git a/server.py b/server.py index 9c316ee..54eddc2 100644 --- a/server.py +++ b/server.py @@ -24,7 +24,7 @@ from inference import LlamaWorker,Scheduler from endpoint import Endpoint from chat_test import ChatTests 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.config import parse_config, ConfigError import json @@ -50,12 +50,12 @@ class HardwareProvider: if os.environ.get('DECK_LOCAL_HARDWARE', '1') == '1': from collect_hardware import collect 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.checked = time.monotonic() return self.cached 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 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) @@ -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.video=Video(self.scheduler,stop_gpu_work,self.docker,self.catalog.root.parent/'video') self.endpoint.video=self.video + from dashboard_data import Dashboard + self.dashboard=Dashboard(self,self.catalog.root.parent) if self.endpoint.config['autostart']: try:self.endpoint.start() except ValueError as exc:self.endpoint.error=str(exc) @@ -272,7 +274,7 @@ class Handler(BaseHTTPRequestHandler): return self.respond({'error':'Anmeldung erforderlich.'},401) if not self.authenticated() and self.path == '/': 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: name, mime = routes[self.path] 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/hardware': 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'): 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: @@ -559,6 +579,7 @@ def main(): try: server.serve_forever() finally: + server.dashboard.close() server.auto_tests.stop() server.chat_tests.stop() server.video.close() diff --git a/test_dashboard_data.py b/test_dashboard_data.py new file mode 100644 index 0000000..a3f49dc --- /dev/null +++ b/test_dashboard_data.py @@ -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() diff --git a/test_dashboard_history.py b/test_dashboard_history.py new file mode 100644 index 0000000..1f7d543 --- /dev/null +++ b/test_dashboard_history.py @@ -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() diff --git a/test_endpoint.py b/test_endpoint.py index c9d7eca..ceb6b93 100644 --- a/test_endpoint.py +++ b/test_endpoint.py @@ -69,7 +69,7 @@ class EndpointTests(unittest.TestCase): 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)) 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'): 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): diff --git a/test_server.py b/test_server.py index 322640d..2f32712 100644 --- a/test_server.py +++ b/test_server.py @@ -23,6 +23,7 @@ class Tests(unittest.TestCase): self.url=f'http://127.0.0.1:{self.server.server_port}' def tearDown(self): self.server.shutdown() + self.server.dashboard.close() self.server.endpoint.close() self.server.server_close() self.thread.join() @@ -36,6 +37,17 @@ class Tests(unittest.TestCase): self.assertEqual(state['endpoint']['state'],'stopped') with self.assertRaises(urllib.error.HTTPError) as exc:self.request('demo') 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): from pathlib import Path target=Path(self.state.name)/'models'/('a'*64);target.mkdir(parents=True)