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. simuliert, ohne Athena oder SSH zu unterbrechen.
Produktive Router-/Modell-/Bild-/Audio-/Netzwerkdienste nicht verändert. Produktive Router-/Modell-/Bild-/Audio-/Netzwerkdienste nicht verändert.
Vorhandene ungesicherte Änderungen im übergeordneten Repository unangetastet. 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==='access'){await accessPage();return;}
if(page==='network'){await networkPage();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>`; 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>`;} }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));} 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;}} }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 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 && /opt/deck-image-python/bin/pip freeze > /opt/deck-comfy/deck-requirements.lock
WORKDIR /app 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 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/ 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 \
+2 -2
View File
@@ -14,7 +14,7 @@ import urllib.request
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
LABEL = 'de.casaderoll.athena-deck.standalone' LABEL = 'de.casaderoll.athena-deck.standalone'
FILES = ['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): def run(*args, check=True, interactive=False):
@@ -186,7 +186,7 @@ def update(base, rollback=False):
backup_name=config['name']+'-previous' backup_name=config['name']+'-previous'
if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.') if inspect(backup_name):raise RuntimeError('Rückfall-Containername belegt. Keine Änderung ausgeführt.')
stamp=str(time.time_ns()) 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'] was_running=previous['State']['Running']
if was_running:run('docker','stop','--time','15',config['name']) if was_running:run('docker','stop','--time','15',config['name'])
run('docker','rename',config['name'],backup_name) run('docker','rename',config['name'],backup_name)
+7 -2
View File
@@ -1,7 +1,7 @@
window.ImageTestUI=(()=>{ window.ImageTestUI=(()=>{
const e=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); 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;} 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){ function bind(readonly=false){
const root=document.querySelector('#image-test'),el=id=>root.querySelector('#'+id);let profiles=[],status=null,lastJob='',timer,submitting=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;}; const message=t=>{if(root.isConnected)el('image-test-message').textContent=t;};
@@ -15,5 +15,10 @@ window.ImageTestUI=(()=>{
} }
refresh(); 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: class ImageTests:
def __init__(self,root,profiles): 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(): if (self.root/'status.json').exists():
self.job=json.loads((self.root/'status.json').read_text()) 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.') 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: with self.lock:
self.job['phase']=text;self._save() self.job['phase']=text;self._save()
def status(self): 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): def stop(self):
self.cancel.set() self.cancel.set()
with self.lock:process=self.process 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.') 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: with self.lock:
if self.job and self.job['state']=='running':raise ValueError('Ein Bildtest läuft bereits.') 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.') 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) 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.') 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. (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] 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') 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: with self.lock:
if self.cancel.is_set():raise InterruptedError() 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): 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'}) 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: with urllib.request.urlopen(req,timeout=5) as r:
@@ -129,7 +132,7 @@ class ImageTests:
nodes=request('/object_info') nodes=request('/object_info')
required={'UnetLoaderGGUF','CLIPLoader','VAELoader','TextEncodeQwenImage21','EmptyLatentImage','KSampler','VAEDecode','SaveImage'} required={'UnetLoaderGGUF','CLIPLoader','VAELoader','TextEncodeQwenImage21','EmptyLatentImage','KSampler','VAEDecode','SaveImage'}
if not required.issubset(nodes):raise ValueError('Der installierten Bildlaufzeit fehlen erforderliche Qwen/GGUF-Nodes.') 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'] response=request('/prompt',{'prompt':workflow(prompt,params,seed),'client_id':'deck-'+job_id});prompt_id=response['prompt_id']
deadline=time.monotonic()+1800 deadline=time.monotonic()+1800
while time.monotonic()<deadline: 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 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;}; 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; 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-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-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'});}); 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 temp=self.path.with_suffix('.tmp');temp.write_text(json.dumps(rows));temp.replace(self.path);self.rows=rows
return row 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): 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.') 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) item=self.catalog.entry(ident)
+24
View File
@@ -10,6 +10,7 @@ from catalog import Catalog
from profiles import Profiles from profiles import Profiles
from capacity import assess, overview from capacity import assess, overview
from runtime import Runtime from runtime import Runtime
from image_runtime import ImageRuntime
from image_test import ImageTests from image_test import ImageTests
from urllib.parse import urlsplit, parse_qs from urllib.parse import urlsplit, parse_qs
from network.client import NetworkClient 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.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.profiles = Profiles(self.catalog.root.parent/"profiles.json",self.catalog)
self.image_tests = ImageTests(self.catalog.root.parent/"image-tests",self.profiles) 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.profiles.image_runtime_ready=lambda:self.image_tests.status()["runtime_installed"]
self.demo = DemoService() self.demo = DemoService()
self.hardware = HardwareProvider() self.hardware = HardwareProvider()
@@ -274,6 +277,7 @@ class Handler(BaseHTTPRequestHandler):
return self.respond((ROOT/name).read_bytes(), mime=mime) return self.respond((ROOT/name).read_bytes(), mime=mime)
if self.path == '/api/v1/status': 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())) 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 self.path == '/api/v1/image-tests':return self.respond(self.server.image_tests.status())
if urlsplit(self.path).path == '/api/v1/image-tests/image': 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') 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.') raise ValueError('Ungültige Laufzeitaktion.')
except ValueError as exc:return self.respond({'error':str(exc)},400) 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) 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'): if self.path in ('/api/v1/image-tests/start','/api/v1/image-tests/cancel'):
try: try:
data=self.read_json() data=self.read_json()
@@ -363,6 +377,15 @@ class Handler(BaseHTTPRequestHandler):
try:return self.respond(self.server.profiles.assign(self.read_json())) try:return self.respond(self.server.profiles.assign(self.read_json()))
except ValueError as exc:return self.respond({'error':str(exc)},400) except ValueError as exc:return self.respond({'error':str(exc)},400)
except OSError:return self.respond({'error':'Zuordnung konnte nicht gespeichert werden.'},503) 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': if self.path == '/api/v1/profiles/save':
try:return self.respond(self.server.profiles.save(self.read_json())) try:return self.respond(self.server.profiles.save(self.read_json()))
except ValueError as exc:return self.respond({'error':str(exc)},400) except ValueError as exc:return self.respond({'error':str(exc)},400)
@@ -416,6 +439,7 @@ def main():
try: try:
server.serve_forever() server.serve_forever()
finally: finally:
server.image_runtime.stop()
server.image_tests.stop() server.image_tests.stop()
server.runtime.stop() server.runtime.stop()
server.catalog.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>`; 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>`; document.querySelector('#view').innerHTML=`<div id="studio" data-page="${page}">${body}</div>`;
if(page==='runtime'){RuntimeUI.bind();return;} 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(page==='runtimes')return;
if(section==='test'||(section==='running'&&page==='image'))ImageTestUI.bind(section==='running'); if(section==='test'||(section==='running'&&page==='image'))ImageTestUI.bind(section==='running');
else if(section==='profiles')ProfilesUI.bind(page,modelId); 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);}); 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 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);} function openProfile(page,modelId){render(page,true,modelId);}
return {render,openProfile}; 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) with self.subTest(key=key),self.assertRaises(ValueError):self.profiles.save(req)
(self.root/'models'/self.model_id/'model.gguf').unlink() (self.root/'models'/self.model_id/'model.gguf').unlink()
with self.assertRaises(ValueError):self.profiles.save(self.request()) 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): def test_clear_history_keeps_models_and_survives_restart(self):
old=self.catalog.status()['downloads'][0];self.catalog.dismiss(old['id']) 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) 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)) 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'}) 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) 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: 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})) 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() self.assertEqual(error.exception.code,401);error.exception.close()
def test_image_test_routes_are_admin_only(self): def test_image_test_routes_are_admin_only(self):
self.assertIn('installed',self.request('image-runtime'))
self.assertIn('runtime_installed',self.request('image-tests')) self.assertIn('runtime_installed',self.request('image-tests'))
with self.assertRaises(urllib.error.HTTPError) as error: 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})) urllib.request.urlopen(urllib.request.Request(self.url+'/api/v1/image-tests',headers={'Authorization':'Bearer '+self.api_token}))