Add image runtime installation and profile deletion controls

This commit is contained in:
Mikei386
2026-09-28 18:12:33 +02:00
parent c584bbcaa6
commit fd797a6c8d
14 changed files with 244 additions and 15 deletions
+25
View File
@@ -105,3 +105,28 @@ CPU-/GPU-Temperaturen, VRAM und Auslastung vorhanden. Hardware-Ausfall im Test
simuliert, ohne Athena oder SSH zu unterbrechen.
Produktive Router-/Modell-/Bild-/Audio-/Netzwerkdienste nicht verändert.
Vorhandene ungesicherte Änderungen im übergeordneten Repository unangetastet.
### Bildlaufzeit installieren und Profile entfernen
Unter **Einstellungen → Laufzeiten → Bildgenerierung** zeigt Deck die Herkunft
und die festgelegten ComfyUI-/GGUF-Versionen. Fehlt die Umgebung, installiert
**Bildlaufzeit installieren** sie ohne Rootrechte in `state/image-runtime`.
Voraussetzungen: Linux, Python mit venv, Git, Internet und 25 GiB freier Speicher;
ein kompatibler NVIDIA-Treiber muss vom Systeminstaller bereitgestellt sein.
Die Installation ist abbrechbar, startet kein Modell und aktiviert die neue
Umgebung erst nach Paketprüfung. Bereits mitgelieferte Umgebungen werden erkannt;
ein Versionsupdate der Bildlaufzeit ist hier noch nicht vorgesehen.
Profile lassen sich in allen Kategorien löschen. Modell- und Komponentendateien
bleiben erhalten. Die API verhindert das Löschen des gerade ausgeführten
Bildprofils und prüft die Profilrevision gegen zwischenzeitliche Änderungen.
Neue sitzungsgeschützte API-Routen: `GET /api/v1/image-runtime`,
`POST /api/v1/image-runtime/install` und `/cancel` mit `{}` sowie
`POST /api/v1/profiles/delete` mit `{ "id": "…", "revision": 1 }`.
POST benötigt wie die übrigen Verwaltungsaktionen `X-Athena-Deck: 1`.
Die Hardware-Seite zeigt GPU-Rechenlast und VRAM-Belegung mit separaten Balken.
Es sind Host-Gesamtwerte einschließlich anderer Dienste. Der Bild-Textencoder
arbeitet auf CPU/System-RAM; GPU-Last kann daher während eines Auftrags null
sein. Nach Abschluss beendet Deck den eigenen Worker und gibt dessen VRAM frei.
+1 -1
View File
@@ -12,7 +12,7 @@ if(['chat','image','audio','video','runtime','runtimes','image-runtime'].include
if(page==='access'){await accessPage();return;}
if(page==='network'){await networkPage();return;}
if(page==='home'){const s=await api('status'),d=s.demo;html=heading('DEIN KONTROLLZENTRUM','Alles beginnt mit einer klaren Basis.','Eine kleine Oberfläche für Status, Hardware und den ersten steuerbaren Dienst.')+`<div class="grid"><section class="card"><div class="label">Anwendung</div><div class="value"><i></i> Bereit</div><p>Athena Deck läuft auf ${esc(s.location)}.</p><div class="note">Laufzeit ${fmt(s.uptime_seconds,' s')} · API v1</div></section><section class="card"><div class="label">Betriebsmodus</div><div class="value">Isoliert</div><p>Ein eigenständiger Prototyp mit lesendem Hardware-Zugriff auf Athena.</p><div class="note">Keine Modell- oder Router-Steuerung angebunden</div></section><section class="card wide"><div class="card-top"><h2>Demo-Dienst</h2><span class="pill">LOKAL / CPU</span></div><p>Ein minimaler HTTP-Prozess zum Testen von Start, Stopp und Erreichbarkeit.</p><div class="metrics">${metric('Prozess',d.state==='running'?'Läuft':'Gestoppt')}${metric('Health-Prüfung',d.reachable?'Erreichbar':'Nicht erreichbar')}${metric('Lokaler Port',d.port??'—')}</div><button id="start" ${busy||d.state==='running'?'disabled':''}>Dienst starten</button><button id="stop" class="secondary" ${busy||d.state!=='running'?'disabled':''}>Dienst stoppen</button><div class="note">Lädt keine Modelle · endet beim Beenden von Athena Deck</div></section></div>`;
}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.');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>${bar(g.percent)}</section>`;if(h.gpus.length<2)html+='<section class="card">Nicht alle zwei GPUs verfügbar.</section>';html+=`</div><p class="note">${esc(h.source)} · Messung ${new Date(h.sampled_at*1000).toLocaleTimeString('de-DE')}<br>${esc(h.errors.join(' '))}</p>`;}
}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 läuft der Textencoder auf der CPU; 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>`;}
if((location.hash.slice(1)||'home')===page){view.innerHTML=html;for(const action of ['start','stop'])document.getElementById(action)?.addEventListener('click',()=>control(action));}
}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;}}
+2 -1
View File
@@ -11,7 +11,8 @@ RUN git clone https://github.com/Comfy-Org/ComfyUI.git /opt/deck-comfy \
&& /opt/deck-image-python/bin/pip install --no-cache-dir -c /tmp/image-requirements.lock -r /opt/deck-comfy/requirements.txt -r /opt/deck-comfy/custom_nodes/ComfyUI-GGUF/requirements.txt \
&& /opt/deck-image-python/bin/pip freeze > /opt/deck-comfy/deck-requirements.lock
WORKDIR /app
COPY image_test.py profiles.py capacity.py server.py runtime.py catalog.py auth.py collect_hardware.py demo.py /app/
COPY deploy/image-requirements.lock /app/deploy/image-requirements.lock
COPY image_runtime.py image_test.py profiles.py capacity.py server.py runtime.py catalog.py auth.py collect_hardware.py demo.py /app/
COPY 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 network/__init__.py network/client.py network/config.py network/rpc.py /app/network/
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 HOME=/tmp \
+2 -2
View File
@@ -14,7 +14,7 @@ import urllib.request
ROOT = Path(__file__).resolve().parent.parent
LABEL = 'de.casaderoll.athena-deck.standalone'
FILES = ['image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','catalog.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','demo.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 = ['image_runtime.py','image_test.py','image-test-ui.js','profiles.py','profiles-ui.js','capacity.py','runtime.py','runtime-ui.js','catalog.py','catalog-ui.js','server.py','auth.py','collect_hardware.py','demo.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):
@@ -186,7 +186,7 @@ def update(base, rollback=False):
backup_name=config['name']+'-previous'
if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.')
stamp=str(time.time_ns())
shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('models','runtime','runtime-verification','image-tests','image-verification'))
shutil.copytree(base/'state',base/'backups'/stamp,ignore=shutil.ignore_patterns('models','runtime','runtime-verification','image-tests','image-verification','image-runtime'))
was_running=previous['State']['Running']
if was_running:run('docker','stop','--time','15',config['name'])
run('docker','rename',config['name'],backup_name)
+7 -2
View File
@@ -1,7 +1,7 @@
window.ImageTestUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
async function api(path,data){const r=await fetch('/api/v1/'+path,data?{method:'POST',headers:{'Content-Type':'application/json','X-Athena-Deck':'1'},body:JSON.stringify(data)}:{});const v=await r.json();if(!r.ok)throw Error(v.error||'Anfrage fehlgeschlagen');return v;}
function html(readonly=false){return `<section id="image-test"><div class="section-heading"><div><h2>${readonly?'Bildlaufzeit':'Bildmodell testen'}</h2><p>Ein Bild mit deinem gespeicherten Profil erzeugen. Der eigene Worker beendet sich nach dem Auftrag.</p></div></div>${readonly?'':`<form id="image-test-form" class="card"><label>Bildprofil<select name="profile_id" id="image-test-profile" required><option value="">Profile werden geladen …</option></select></label><p id="image-profile-info"></p><label>Prompt<textarea name="prompt" maxlength="4000" rows="5" required placeholder="Beschreibe das gewünschte Bild …"></textarea></label><button id="image-generate" disabled>Bild generieren</button><p class="small">Auflösung, Schritte, Guidance und Seed stammen aus dem Profil. Maximal 1024 × 1024 Pixel. Nur eine freie GPU wird genutzt; Deck stoppt keine produktiven Modelle. Der erste Start kann einige Minuten dauern.</p></form>`}<p id="image-test-message" role="status"></p><section id="image-test-state" class="card"></section><section id="image-test-result"></section></section>`;}
function html(readonly=false){return `<section id="image-test"><div class="section-heading"><div><h2>${readonly?'Bildlaufzeit':'Bildmodell testen'}</h2><p>Ein Bild mit deinem gespeicherten Profil erzeugen. Der eigene Worker beendet sich nach dem Auftrag.</p></div></div>${readonly?'':`<form id="image-test-form" class="card"><label>Bildprofil<select name="profile_id" id="image-test-profile" required><option value="">Profile werden geladen …</option></select></label><p id="image-profile-info"></p><label>Prompt<textarea name="prompt" maxlength="4000" rows="5" required placeholder="Beschreibe das gewünschte Bild …"></textarea></label><button id="image-generate" disabled>Bild generieren</button><p class="small">Auflösung, Schritte, Guidance und Seed stammen aus dem Profil. Maximal 1024 × 1024 Pixel. Nur eine freie GPU wird genutzt; Deck stoppt keine produktiven Modelle. Der erste Start kann einige Minuten dauern.</p></form>`}<p class="note">Textencoder: CPU / System-RAM · Diffusionsmodell und VAE: GPU / VRAM. GPU-Last entsteht nicht während aller Verarbeitungsschritte. <a class="link" href="#hardware">Aktuelle Hardware-Messwerte →</a></p><p id="image-test-message" role="status"></p><section id="image-test-state" class="card"></section><section id="image-test-result"></section></section>`;}
function bind(readonly=false){
const root=document.querySelector('#image-test'),el=id=>root.querySelector('#'+id);let profiles=[],status=null,lastJob='',timer,submitting=false;
const message=t=>{if(root.isConnected)el('image-test-message').textContent=t;};
@@ -15,5 +15,10 @@ window.ImageTestUI=(()=>{
}
refresh();
}
return {html,bind};
function bindInstaller(){
const root=document.querySelector('#image-runtime-install');let pending=false;
async function refresh(){try{const s=await api('image-runtime');if(!root.isConnected)return;const running=s.job?.state==='running';root.innerHTML=`<p><strong>${s.installed?'Installiert · '+e(s.origin):'Noch nicht installiert'}</strong></p><p class="small">ComfyUI ${e(s.comfy_revision.slice(0,12))} · GGUF ${e(s.gguf_revision.slice(0,12))}</p><p>${e(s.job?.phase||'Installation richtet eine eigene Python-Umgebung mit ComfyUI, GGUF-Unterstützung und CUDA-PyTorch ein. Mindestens 25 GiB freier Speicher und Internetzugang erforderlich.')}</p><button id="image-runtime-action" ${pending||s.installed?'disabled':''}>${s.installed?'Bereits installiert':running?'Installation abbrechen':'Bildlaufzeit installieren'}</button><p class="small">Systemvoraussetzungen: Python mit venv, Git und kompatibler NVIDIA-Treiber. Die Installation ändert keine Host-Pakete oder anderen Dienste.</p>`;root.querySelector('button').onclick=async()=>{pending=true;root.querySelector('button').disabled=true;try{await api('image-runtime/'+(running?'cancel':'install'),{});}catch(error){alert(error.message);}finally{pending=false;}};}catch(error){if(root.isConnected)root.textContent=error.message;}finally{if(root.isConnected)setTimeout(refresh,2000);}}
refresh();
}
return {html,bind,bindInstaller};
})();
+102
View File
@@ -0,0 +1,102 @@
"""Unprivileged, pinned image-runtime installation in Deck's own state directory."""
import json
import os
from pathlib import Path
import shutil
import signal
import subprocess
import sys
import threading
import time
import uuid
COMFY_REV='8d534945ebd53cff61e8def81757c6a6c1b9cf2d'
GGUF_REV='373048b8403a7820620065210a691263d4da0a61'
BUNDLED=(Path('/opt/deck-image-python/bin/python'),Path('/opt/deck-comfy'))
class ImageRuntime:
def __init__(self,root):
self.root=Path(root);self.lock=threading.RLock();self.cancel=threading.Event();self.process=None;self.worker=None;self.job=None
if (self.root/'installation.json').is_file():
self.job=json.loads((self.root/'installation.json').read_text())
if self.job.get('state')=='running':self.job.update(state='interrupted',phase='Installation unterbrochen; erneut installieren.')
def paths(self):
marker=self.root/'active.json'
if marker.is_file():
ident=json.loads(marker.read_text()).get('id','')
if len(ident)==32 and all(c in '0123456789abcdef' for c in ident):
base=self.root/ident
if (base/'ready').is_file():return base/'python/bin/python',base/'comfy'
return BUNDLED
def status(self):
with self.lock:
python,comfy=self.paths()
installed=python.is_file() and (comfy/'main.py').is_file()
return dict(installed=installed,origin='Deck-Installer' if (python,comfy)==BUNDLED else 'GUI-Installation',job=dict(self.job) if self.job else None,comfy_revision=COMFY_REV,gguf_revision=GGUF_REV,required_disk_gib=25)
def _save(self):
p=self.root/'installation.tmp';p.write_text(json.dumps(self.job));p.replace(self.root/'installation.json')
def _phase(self,text):
with self.lock:self.job['phase']=text;self._save()
def start(self):
with self.lock:
if self.job and self.job['state']=='running':raise ValueError('Eine Installation läuft bereits.')
if self.status()['installed']:raise ValueError('Die Bildlaufzeit ist bereits installiert und einsatzbereit.')
if sys.platform!='linux' or not shutil.which('git'):raise ValueError('Debian/Linux mit Python venv und Git erforderlich. Systemvoraussetzungen über den Deck-Installer bereitstellen.')
self.root.mkdir(parents=True,exist_ok=True,mode=0o700)
if shutil.disk_usage(self.root).free<25*1024**3:raise ValueError('Mindestens 25 GiB freier Speicher für die Bildlaufzeit erforderlich.')
self.cancel.clear();self.job=dict(id=uuid.uuid4().hex,state='running',phase='Installation wird vorbereitet',started_at=time.time())
self._save();self.worker=threading.Thread(target=self._run,daemon=True);self.worker.start();return self.status()
def stop(self):
with self.lock:
self.cancel.set();process=self.process
if process and process.poll() is None:
try:os.killpg(process.pid,signal.SIGTERM)
except ProcessLookupError:pass
if self.worker:self.worker.join(timeout=7)
return {'cancellation_requested':True}
def _command(self,args):
with self.lock:
if self.cancel.is_set():raise InterruptedError()
temporary=self.root/self.job['id']/'tmp';temporary.mkdir(exist_ok=True)
env=dict(os.environ,TMPDIR=str(temporary),PIP_NO_INPUT='1',PIP_DISABLE_PIP_VERSION_CHECK='1',GIT_TERMINAL_PROMPT='0',OMP_NUM_THREADS='2')
self.process=subprocess.Popen([str(a) for a in args],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True,env=env)
process=self.process
deadline=time.monotonic()+3600
while process.poll() is None:
if self.cancel.is_set() or time.monotonic()>deadline:
try:
os.killpg(process.pid,signal.SIGTERM)
try:process.wait(timeout=5)
except subprocess.TimeoutExpired:os.killpg(process.pid,signal.SIGKILL);process.wait()
except ProcessLookupError:pass
if self.cancel.is_set():raise InterruptedError()
raise RuntimeError('Zeitlimit beim Installationsschritt erreicht.')
time.sleep(.2)
if self.cancel.is_set():raise InterruptedError()
if process.returncode:raise RuntimeError('Installationsschritt fehlgeschlagen. Internetzugang, freien Speicher und Python-venv-Voraussetzungen prüfen.')
def _run(self):
base=self.root/self.job['id'];comfy=base/'comfy';python=base/'python/bin/python'
phase='';state='failed'
try:
base.mkdir(mode=0o700)
self._phase('Python-Umgebung erstellen');self._command([sys.executable,'-m','venv',base/'python'])
self._phase('ComfyUI herunterladen')
for url,path,rev in [('https://github.com/Comfy-Org/ComfyUI.git',comfy,COMFY_REV),('https://github.com/leejet/ComfyUI-GGUF.git',comfy/'custom_nodes/ComfyUI-GGUF',GGUF_REV)]:
self._command(['git','init',path]);self._command(['git','-C',path,'fetch','--depth','1',url,rev]);self._command(['git','-C',path,'checkout','--detach','FETCH_HEAD'])
self._phase('PyTorch mit CUDA herunterladen · mehrere GiB, kann einige Minuten dauern')
self._command([python,'-m','pip','install','--no-cache-dir','torch==2.11.0','torchvision==0.26.0','--index-url','https://download.pytorch.org/whl/cu128'])
self._phase('Bildlaufzeit-Abhängigkeiten installieren')
self._command([python,'-m','pip','install','--no-cache-dir','-c',Path(__file__).parent/'deploy/image-requirements.lock','-r',comfy/'requirements.txt','-r',comfy/'custom_nodes/ComfyUI-GGUF/requirements.txt'])
self._phase('Installation prüfen')
self._command([python,'-m','pip','check'])
self._command([python,'-c','import torch, gguf, safetensors, aiohttp; assert torch.version.cuda'])
with self.lock:
if self.cancel.is_set():raise InterruptedError()
(base/'ready').write_text(COMFY_REV)
marker=self.root/'active.tmp';marker.write_text(json.dumps({'id':base.name}));marker.replace(self.root/'active.json')
state='complete';phase='Bildlaufzeit installiert. Es wurde kein Modell gestartet.'
except InterruptedError:state='cancelled';phase='Installation abgebrochen. Erneuter Versuch möglich.'
except Exception as exc:phase=str(exc) if isinstance(exc,RuntimeError) else 'Installation fehlgeschlagen. Systemvoraussetzungen und Speicher prüfen.'
finally:
if state!='complete':shutil.rmtree(base,ignore_errors=True)
with self.lock:self.process=None;self.job.update(state=state,phase=phase,finished_at=time.time());self._save()
+8 -5
View File
@@ -39,7 +39,7 @@ def workflow(prompt,params,seed):
class ImageTests:
def __init__(self,root,profiles):
self.root=Path(root);self.profiles=profiles;self.lock=threading.RLock();self.process=None;self.cancel=threading.Event();self.job=None
self.root=Path(root);self.profiles=profiles;self.runtime=None;self.lock=threading.RLock();self.process=None;self.cancel=threading.Event();self.job=None
if (self.root/'status.json').exists():
self.job=json.loads((self.root/'status.json').read_text())
if self.job.get('state')=='running':self.job.update(state='interrupted',phase='Deck wurde neu gestartet; Test erneut starten.')
@@ -50,7 +50,9 @@ class ImageTests:
with self.lock:
self.job['phase']=text;self._save()
def status(self):
with self.lock:return dict(job=dict(self.job) if self.job else None,runtime_installed=PYTHON.is_file() and (COMFY/'main.py').is_file())
with self.lock:
python,comfy=self.runtime.paths() if self.runtime else (PYTHON,COMFY)
return dict(job=dict(self.job) if self.job else None,runtime_installed=python.is_file() and (comfy/'main.py').is_file())
def stop(self):
self.cancel.set()
with self.lock:process=self.process
@@ -67,6 +69,7 @@ class ImageTests:
if not isinstance(prompt,str) or not 1<=len(prompt.strip())<=4000:raise ValueError('Bitte einen Prompt mit 1–4000 Zeichen eingeben.')
with self.lock:
if self.job and self.job['state']=='running':raise ValueError('Ein Bildtest läuft bereits.')
if self.runtime and (self.runtime.status()['job'] or {}).get('state')=='running':raise ValueError('Die Bildlaufzeit wird gerade installiert.')
if not self.status()['runtime_installed']:raise ValueError('Die eigene ComfyUI-Bildlaufzeit ist noch nicht installiert.')
profile=next((p for p in self.profiles.status()['profiles'] if p['id']==profile_id),None)
if not profile or profile['kind']!='image' or not profile['model']:raise ValueError('Bildprofil nicht verfügbar.')
@@ -104,10 +107,10 @@ class ImageTests:
(directory/'paths.json').write_text(json.dumps(config)) # JSON is valid YAML.
with socket.socket() as sock:sock.bind(('127.0.0.1',0));port=sock.getsockname()[1]
env=dict(os.environ,CUDA_VISIBLE_DEVICES=gpu['uuid'],OMP_NUM_THREADS='2',MKL_NUM_THREADS='2',HOME=str(directory),HF_HUB_OFFLINE='1',TRANSFORMERS_OFFLINE='1',PYTHONDONTWRITEBYTECODE='1')
args=[str(PYTHON),str(COMFY/'main.py'),'--listen','127.0.0.1','--port',str(port),'--disable-auto-launch','--disable-metadata','--lowvram','--reserve-vram','1.5','--extra-model-paths-config',str(directory/'paths.json'),'--output-directory',str(directory/'output'),'--temp-directory',str(directory/'temp'),'--user-directory',str(directory/'user'),'--input-directory',str(directory/'input')]
args=[str(self.runtime.paths()[0] if self.runtime else PYTHON),str((self.runtime.paths()[1] if self.runtime else COMFY)/'main.py'),'--listen','127.0.0.1','--port',str(port),'--disable-auto-launch','--disable-metadata','--lowvram','--reserve-vram','1.5','--extra-model-paths-config',str(directory/'paths.json'),'--output-directory',str(directory/'output'),'--temp-directory',str(directory/'temp'),'--user-directory',str(directory/'user'),'--input-directory',str(directory/'input')]
with self.lock:
if self.cancel.is_set():raise InterruptedError()
process=subprocess.Popen(args,cwd=COMFY,env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True);self.process=process
process=subprocess.Popen(args,cwd=self.runtime.paths()[1] if self.runtime else COMFY,env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True);self.process=process
def request(path,data=None):
req=urllib.request.Request(f'http://127.0.0.1:{port}'+path,data=json.dumps(data).encode() if data is not None else None,headers={'Content-Type':'application/json'})
with urllib.request.urlopen(req,timeout=5) as r:
@@ -129,7 +132,7 @@ class ImageTests:
nodes=request('/object_info')
required={'UnetLoaderGGUF','CLIPLoader','VAELoader','TextEncodeQwenImage21','EmptyLatentImage','KSampler','VAEDecode','SaveImage'}
if not required.issubset(nodes):raise ValueError('Der installierten Bildlaufzeit fehlen erforderliche Qwen/GGUF-Nodes.')
self._phase('Modell und Textencoder laden · Bild wird erzeugt')
self._phase('Auftrag wird verarbeitet · Textencoder auf CPU, Bildberechnung auf GPU')
response=request('/prompt',{'prompt':workflow(prompt,params,seed),'client_id':'deck-'+job_id});prompt_id=response['prompt_id']
deadline=time.monotonic()+1800
while time.monotonic()<deadline:
+2 -1
View File
@@ -7,7 +7,8 @@ window.ProfilesUI=(()=>{
const root=document.querySelector('#live-profiles'),el=id=>root.querySelector('#'+id);let rows=[],models=[],schema={},panelSequence=0;
const message=t=>{if(root.isConnected)el('profile-message').textContent=t;};
async function load(){try{const [p,c]=await Promise.all([api('profiles'),api('catalog')]);if(!root.isConnected)return;rows=p.profiles.filter(x=>x.kind===kind);models=c.entries.filter(x=>x.kind===kind&&x.profile_eligible===true);schema=p.schemas[kind];el('profile-new').disabled=!models.length;
el('profile-list').innerHTML=rows.map(p=>`<article class="card"><span class="pill">GESPEICHERT AUF ATHENA</span><h3>${e(p.name)}</h3><p class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</p><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k])}: ${e(v)}`).join(' · ')}</p><details><summary>Was fehlt zur Ausführung?</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button>${kind==='image'?`<button class="secondary" data-components="${p.id}">Komponenten</button>`:''}</div></article>`).join('')||`<section class="empty"><h3>Noch keine Profile</h3><p>${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}</p></section>`;
el('profile-list').innerHTML=rows.map(p=>`<article class="card"><span class="pill">GESPEICHERT AUF ATHENA</span><h3>${e(p.name)}</h3><p class="hub-filename">${e(p.model?.file||'Modelldatei nicht verfügbar')}</p><p>${Object.entries(p.parameters).map(([k,v])=>`${e(labels[k])}: ${e(v)}`).join(' · ')}</p><details><summary>Was fehlt zur Ausführung?</summary>${p.blockers.map(t=>`<p>${e(t)}</p>`).join('')}</details><div class="card-actions"><button class="secondary" data-edit="${p.id}">Bearbeiten</button><button class="secondary" data-copy="${p.id}">Duplizieren</button><button class="secondary" data-delete="${p.id}">Löschen</button>${kind==='image'?`<button class="secondary" data-components="${p.id}">Komponenten</button>`:''}</div></article>`).join('')||`<section class="empty"><h3>Noch keine Profile</h3><p>${models.length?'Wähle eine heruntergeladene Modelldatei und lege dein erstes Profil an.':'Lade zuerst eine Gewichtsdatei unter Entdecken herunter.'}</p></section>`;
root.querySelectorAll('[data-delete]').forEach(b=>b.onclick=async()=>{const p=rows.find(p=>p.id===b.dataset.delete);if(!confirm(`Profil „${p.name}“ löschen? Modelldateien und Komponenten bleiben in der Bibliothek.`))return;b.disabled=true;try{await api('profiles/delete',{id:p.id,revision:p.revision});panelSequence++;el('profile-editor').replaceChildren();await load();message('Profil gelöscht. Modelldateien und Komponenten bleiben erhalten.');}catch(error){message(error.message);b.disabled=false;}});
root.querySelectorAll('[data-components]').forEach(b=>b.onclick=()=>components(rows.find(p=>p.id===b.dataset.components)));
root.querySelectorAll('[data-edit]').forEach(b=>b.onclick=()=>editor(rows.find(p=>p.id===b.dataset.edit)));
root.querySelectorAll('[data-copy]').forEach(b=>b.onclick=()=>{const p=rows.find(p=>p.id===b.dataset.copy);editor({...p,id:null,revision:0,name:p.name.slice(0,55)+'-kopie'});});
+9
View File
@@ -66,6 +66,15 @@ class Profiles:
temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows
return row
def delete(self,data):
if set(data)!={'id','revision'}:raise ValueError('Ungültige Profilfelder.')
with self.lock:
old=next((p for p in self.rows if p['id']==data['id']),None)
if not old or old['revision']!=data['revision']:raise ValueError('Profil geändert oder nicht gefunden. Ansicht neu laden.')
rows=[p for p in self.rows if p is not old]
temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows
return {'deleted':old['id']}
def _component(self,model,role,ident):
if model['repo']!=QWEN_REPO or role not in QWEN_COMPONENTS:raise ValueError('Für dieses Modell ist kein geprüftes Komponentenrezept hinterlegt.')
item=self.catalog.entry(ident)
+24
View File
@@ -10,6 +10,7 @@ from catalog import Catalog
from profiles import Profiles
from capacity import assess, overview
from runtime import Runtime
from image_runtime import ImageRuntime
from image_test import ImageTests
from urllib.parse import urlsplit, parse_qs
from network.client import NetworkClient
@@ -104,6 +105,8 @@ class Server(ThreadingHTTPServer):
self.runtime = Runtime(Path(state_dir or os.environ.get("DECK_STATE_DIR", ROOT/".state"))/"runtime")
self.profiles = Profiles(self.catalog.root.parent/"profiles.json",self.catalog)
self.image_tests = ImageTests(self.catalog.root.parent/"image-tests",self.profiles)
self.image_runtime = ImageRuntime(self.catalog.root.parent/"image-runtime")
self.image_tests.runtime = self.image_runtime
self.profiles.image_runtime_ready=lambda:self.image_tests.status()["runtime_installed"]
self.demo = DemoService()
self.hardware = HardwareProvider()
@@ -274,6 +277,7 @@ class Handler(BaseHTTPRequestHandler):
return self.respond((ROOT/name).read_bytes(), mime=mime)
if self.path == '/api/v1/status':
return self.respond(dict(name='Athena Deck', version='0.6.0', state='ready', uptime_seconds=round(time.time()-self.server.started), mode='isolated', location=os.environ.get('DECK_LOCATION', 'Athena · Debian-Server'), demo=self.server.demo.status()))
if self.path == '/api/v1/image-runtime':return self.respond(self.server.image_runtime.status())
if self.path == '/api/v1/image-tests':return self.respond(self.server.image_tests.status())
if urlsplit(self.path).path == '/api/v1/image-tests/image':
try:return self.respond(self.server.image_tests.image(parse_qs(urlsplit(self.path).query).get('id',[''])[0]),mime='image/png')
@@ -351,6 +355,16 @@ class Handler(BaseHTTPRequestHandler):
raise ValueError('Ungültige Laufzeitaktion.')
except ValueError as exc:return self.respond({'error':str(exc)},400)
except (OSError, subprocess.SubprocessError):return self.respond({'error':'Laufzeitaktion fehlgeschlagen; Speicher und Werkzeuge prüfen.'},503)
if self.path in ('/api/v1/image-runtime/install','/api/v1/image-runtime/cancel'):
try:
if self.read_json():raise ValueError('Keine Parameter erwartet.')
with self.server.image_tests.lock:
if self.path.endswith('/cancel'):return self.respond(self.server.image_runtime.stop())
job=self.server.image_tests.job
if job and job.get('state')=='running':raise ValueError('Zuerst den laufenden Bildauftrag beenden.')
return self.respond(self.server.image_runtime.start())
except ValueError as exc:return self.respond({'error':str(exc)},400)
except OSError:return self.respond({'error':'Installation konnte nicht vorbereitet werden.'},503)
if self.path in ('/api/v1/image-tests/start','/api/v1/image-tests/cancel'):
try:
data=self.read_json()
@@ -363,6 +377,15 @@ class Handler(BaseHTTPRequestHandler):
try:return self.respond(self.server.profiles.assign(self.read_json()))
except ValueError as exc:return self.respond({'error':str(exc)},400)
except OSError:return self.respond({'error':'Zuordnung konnte nicht gespeichert werden.'},503)
if self.path == '/api/v1/profiles/delete':
try:
data=self.read_json()
with self.server.image_tests.lock:
job=self.server.image_tests.job
if job and job.get('state')=='running' and job.get('profile_id')==data.get('id'):raise ValueError('Dieses Profil wird gerade ausgeführt. Zuerst den Auftrag beenden.')
return self.respond(self.server.profiles.delete(data))
except ValueError as exc:return self.respond({'error':str(exc)},400)
except OSError:return self.respond({'error':'Profil konnte nicht gelöscht werden.'},503)
if self.path == '/api/v1/profiles/save':
try:return self.respond(self.server.profiles.save(self.read_json()))
except ValueError as exc:return self.respond({'error':str(exc)},400)
@@ -416,6 +439,7 @@ def main():
try:
server.serve_forever()
finally:
server.image_runtime.stop()
server.image_tests.stop()
server.runtime.stop()
server.catalog.stop()
+2 -2
View File
@@ -9,7 +9,7 @@ const Studio=(()=>{
const body=page==='runtimes'?runtimeOverview():page==='image-runtime'?imageRuntime():page==='runtime'?RuntimeUI.html():`<div class="kicker">ATHENA / MODELLVERWALTUNG</div><h1>${labels[page]}</h1><p>Modelle entdecken, herunterladen und mit gespeicherten Profilen konfigurieren.</p><div class="studio-tabs" role="tablist" aria-label="Modellverwaltung">${[['discover','Entdecken'],['library','Bibliothek'],['downloads','Downloads'],['profiles','Profile'],...(page==='image'?[['test','Testen']]:[]),['running','Laufend']].map(([id,label])=>`<button role="tab" aria-selected="${section===id}" class="${section===id?'chosen':'secondary'}" data-section="${id}">${label}</button>`).join('')}</div><div role="tabpanel">${section==='test'?ImageTestUI.html():section==='running'&&page==='image'?ImageTestUI.html(true):section==='profiles'?ProfilesUI.html():section==='running'?'<section class="card"><h2>Keine von Deck gestarteten Modelle</h2><p>Profile werden auf Athena gespeichert. Für diesen Bereich ist noch kein Modellworker angebunden. Fehlende Komponenten stehen beim jeweiligen Profil.</p><a class="link" href="#runtime">llama.cpp-Builds verwalten →</a></section>':CatalogUI.html(section==='library',section==='downloads')}</div>`;
document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`;
if(page==='runtime'){RuntimeUI.bind();return;}
if(page==='image-runtime'){ImageTestUI.bind(true);return;}
if(page==='image-runtime'){ImageTestUI.bind(true);ImageTestUI.bindInstaller();return;}
if(page==='runtimes')return;
if(section==='test'||(section==='running'&&page==='image'))ImageTestUI.bind(section==='running');
else if(section==='profiles')ProfilesUI.bind(page,modelId);
@@ -17,7 +17,7 @@ const Studio=(()=>{
document.querySelectorAll('[data-section]').forEach(b=>b.onclick=()=>{section=b.dataset.section;render(page,true);});
}
function runtimeOverview(){return `<div class="kicker">EINSTELLUNGEN</div><h1>Laufzeiten</h1><p>Laufzeiten führen Modelle aus. Textencoder und VAE werden separat in der Modellbibliothek verwaltet und einem Profil zugeordnet.</p><div class="grid"><section class="card"><h2>llama.cpp</h2><p>Sprachmodelle · CPU / CUDA. Versionen herunterladen, bauen und verwalten.</p><a class="link" href="#runtime">llama.cpp öffnen →</a></section><section class="card"><h2>Bildgenerierung</h2><p>Eigene Bildlaufzeit und modellabhängige Zusatzkomponenten.</p><a class="link" href="#image-runtime">Bildlaufzeit öffnen →</a></section></div>`;}
function imageRuntime(){return `<div class="kicker">EINSTELLUNGEN / LAUFZEITEN</div><h1>Bildgenerierung</h1><section class="card"><h2>ComfyUI + ComfyUI-GGUF</h2><p>Eigene Bildlaufzeit für Qwen-Image-2.1 GGUF. Die Umgebung wird mit dem Deck-Installer bereitgestellt. Modelle laden erst bei einem Testauftrag.</p><p>Textencoder läuft auf CPU, Diffusionsmodell und VAE auf einer freien GPU. Pro Test wird ein eigener Prozess gestartet und anschließend beendet.</p><a class="link" href="#image">Bildgenerierung → Testen öffnen</a></section>${ImageTestUI.html(true)}`;}
function imageRuntime(){return `<div class="kicker">EINSTELLUNGEN / LAUFZEITEN</div><h1>Bildgenerierung</h1><section class="card"><h2>ComfyUI + ComfyUI-GGUF</h2><p>Eigene Bildlaufzeit für Qwen-Image-2.1 GGUF. Die Umgebung kann über den Deck-Installer mitgeliefert oder hier separat installiert werden. Modelle laden erst bei einem Testauftrag.</p><p>Textencoder läuft auf CPU, Diffusionsmodell und VAE auf einer freien GPU. Pro Test wird ein eigener Prozess gestartet und anschließend beendet.</p><div id="image-runtime-install"><p>Installationsstatus wird geprüft …</p></div><a class="link" href="#image">Bildgenerierung → Testen öffnen</a></section>${ImageTestUI.html(true)}`;}
function openProfile(page,modelId){render(page,true,modelId);}
return {render,openProfile};
})();
+42
View File
@@ -0,0 +1,42 @@
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from image_runtime import ImageRuntime
class ImageRuntimeTests(unittest.TestCase):
def test_installed_bundle_blocks_reinstall(self):
with tempfile.TemporaryDirectory() as d:
root=Path(d);python=root/'python';python.touch();comfy=root/'comfy';comfy.mkdir();(comfy/'main.py').touch()
with patch('image_runtime.BUNDLED',(python,comfy)):
manager=ImageRuntime(root/'state');self.assertTrue(manager.status()['installed'])
with self.assertRaises(ValueError):manager.start()
def test_failed_install_preserves_active_and_removes_partial(self):
with tempfile.TemporaryDirectory() as d:
root=Path(d);manager=ImageRuntime(root);manager.job=dict(id='a'*32,state='running')
with patch.object(manager,'_command',side_effect=RuntimeError('test failure')):manager._run()
self.assertEqual(manager.status()['job']['state'],'failed');self.assertFalse((root/'active.json').exists());self.assertFalse((root/('a'*32)).exists())
def test_success_activates_only_after_validation(self):
with tempfile.TemporaryDirectory() as d:
root=Path(d);manager=ImageRuntime(root);manager.job=dict(id='b'*32,state='running');commands=[]
def command(args):
self.assertFalse((root/'active.json').exists());commands.append([str(x) for x in args])
with patch.object(manager,'_command',side_effect=command):manager._run()
self.assertEqual(manager.status()['job']['state'],'complete');self.assertTrue((root/('b'*32)/'ready').exists())
self.assertTrue(any(x[-2:]==['pip','check'] for x in commands))
self.assertEqual(manager.paths()[1],root/('b'*32)/'comfy')
def test_cancel_never_activates(self):
with tempfile.TemporaryDirectory() as d:
manager=ImageRuntime(d);manager.job=dict(id='c'*32,state='running');manager.cancel.set();manager._run()
self.assertEqual(manager.status()['job']['state'],'cancelled');self.assertFalse((Path(d)/'active.json').exists())
def test_install_tempfiles_use_owned_disk_not_container_tmpfs(self):
import sys
with tempfile.TemporaryDirectory() as d:
manager=ImageRuntime(d);manager.job=dict(id='d'*32,state='running');base=Path(d)/manager.job['id'];base.mkdir()
manager._command([sys.executable,'-c',"import os; from pathlib import Path; p=Path(os.environ['TMPDIR']); assert p.name=='tmp'; (p/'test').write_text('ok')"])
self.assertEqual((base/'tmp/test').read_text(),'ok')
def test_restart_interrupted_install(self):
with tempfile.TemporaryDirectory() as d:
(Path(d)/'installation.json').write_text(json.dumps(dict(state='running')))
self.assertEqual(ImageRuntime(d).status()['job']['state'],'interrupted')
+7
View File
@@ -29,6 +29,13 @@ class ManagementTests(unittest.TestCase):
with self.subTest(key=key),self.assertRaises(ValueError):self.profiles.save(req)
(self.root/'models'/self.model_id/'model.gguf').unlink()
with self.assertRaises(ValueError):self.profiles.save(self.request())
def test_delete_revision_and_model_preservation(self):
row=self.profiles.save(self.request())
with self.assertRaises(ValueError):self.profiles.delete(dict(id=row['id'],revision=0))
self.assertEqual(len(self.profiles.rows),1)
self.profiles.delete(dict(id=row['id'],revision=row['revision']))
self.assertEqual(Profiles(self.root/'profiles.json',self.catalog).rows,[])
self.assertTrue((self.root/'models'/self.model_id/'model.gguf').exists())
def test_clear_history_keeps_models_and_survives_restart(self):
old=self.catalog.status()['downloads'][0];self.catalog.dismiss(old['id'])
restart=Catalog(self.root/'models');self.assertEqual(restart.status()['downloads'],[]);self.assertEqual(len(restart.status()['entries']),1)
+11 -1
View File
@@ -47,12 +47,22 @@ class Tests(unittest.TestCase):
payload=dict(id=None,revision=0,name='image-test',kind='image',model_id='a'*64,parameters=dict(width=512,height=512,steps=10,seed=-1,guidance=1))
req=urllib.request.Request(self.url+'/api/v1/profiles/save',data=json.dumps(payload).encode(),headers={'Cookie':self.cookie,'X-Athena-Deck':'1','Content-Type':'application/json'})
with urllib.request.urlopen(req) as r:self.assertEqual(r.status,200)
self.assertEqual(self.request('profiles')['profiles'][0]['name'],'image-test')
row=self.request('profiles')['profiles'][0]
self.assertEqual(row['name'],'image-test')
delete=urllib.request.Request(self.url+'/api/v1/profiles/delete',data=json.dumps(dict(id=row['id'],revision=row['revision'])).encode(),headers={'Cookie':self.cookie,'X-Athena-Deck':'1','Content-Type':'application/json'})
self.server.image_tests.job=dict(state='running',profile_id=row['id'])
with self.assertRaises(urllib.error.HTTPError) as busy:urllib.request.urlopen(delete)
self.assertEqual(busy.exception.code,400);busy.exception.close()
self.server.image_tests.job=None
with urllib.request.urlopen(delete) as r:self.assertEqual(r.status,200)
self.assertEqual(self.request('profiles')['profiles'],[])
self.assertTrue((target/'model.gguf').exists())
with self.assertRaises(urllib.error.HTTPError) as error:
urllib.request.urlopen(urllib.request.Request(self.url+'/api/v1/profiles',headers={'Authorization':'Bearer '+self.api_token}))
self.assertEqual(error.exception.code,401);error.exception.close()
def test_image_test_routes_are_admin_only(self):
self.assertIn('installed',self.request('image-runtime'))
self.assertIn('runtime_installed',self.request('image-tests'))
with self.assertRaises(urllib.error.HTTPError) as error:
urllib.request.urlopen(urllib.request.Request(self.url+'/api/v1/image-tests',headers={'Authorization':'Bearer '+self.api_token}))