Add Deck dashboard with Athena telemetry history
This commit is contained in:
1 parent
be40869712
commit
aaf9f570e9
19 files changed
+1073
-25
No files matched your search
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 `<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();
|
||||||
|
}};
|
||||||
|
})();
|
||||||
@@ -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}
|
||||||
|
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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 \
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 & Profile <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang & 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 & Profile <span>↗</span></a><a href="#network">Netzwerk <span>↗</span></a><a href="#access">Zugang & 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
@@ -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]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user