3cf36d436b
`hf` gibt im Nicht-TTY-Modus keinen Fortschritt aus (am Bosgame verifiziert: 0 CR-Frames). Stattdessen pollt jobengine.attach_download_progress die wachsende <local-dir>/.cache/huggingface/download/*.incomplete-Datei gegen die Gesamtgroesse aus der HF-Tree-API (cookbook.hf_file_size) -> exaktes %. - attach_download_progress an /api/download, install-recipe, install-model - Frontend (Aktivitaet + Server-Karte): nutzt job.progress bevorzugt, Log-%-Parsing bleibt Fallback fuer Tools, die selbst Prozente ausgeben Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
127 lines
6.8 KiB
JavaScript
127 lines
6.8 KiB
JavaScript
// jobs.js — Aktivität (v3): Live-System-Metriken + Hintergrund-Jobs mit Log.
|
||
// Exportiert track(id), damit andere Panels einen Job auto-aufklappen.
|
||
|
||
import { api } from "../core/api.js";
|
||
import { $, esc, fmtBytes, toast } from "../core/ui.js";
|
||
|
||
const tracked = new Set();
|
||
let JOBS = [];
|
||
let SYS = null;
|
||
|
||
export function track(id) { tracked.add(id); renderJobs(); }
|
||
|
||
const hist = { cpu: [], ram: [], gpu: [] };
|
||
const MAX_HIST = 60;
|
||
|
||
function statusBadge(s) { return s === "done" ? '<span class="badge b-run">fertig</span>' : s === "failed" ? '<span class="badge b-err">fehler</span>' : s === "canceled" ? '<span class="badge">abgebrochen</span>' : '<span class="badge b-load">läuft…</span>'; }
|
||
function dotClass(s) { return s === "done" ? "on" : (s === "failed" || s === "canceled") ? "" : "load"; }
|
||
|
||
// Fortschritt in %: bevorzugt der exakte Wert vom Backend (Download-Datei-Polling),
|
||
// sonst Fallback auf eine %-Angabe in der letzten Log-Zeile.
|
||
function jobPct(j) {
|
||
if (j.state !== "running") return null;
|
||
if (typeof j.progress === "number") return Math.min(100, j.progress);
|
||
const last = (j.log || [])[j.log.length - 1] || "";
|
||
const m = last.match(/(\d+(?:\.\d+)?)\s*%/);
|
||
return m ? Math.min(100, parseFloat(m[1])) : null;
|
||
}
|
||
|
||
async function cancelJob(id) {
|
||
try { await api(`/api/jobs/${id}/cancel`, { method: "POST" }); toast("Abbruch angefordert…"); renderJobs(); }
|
||
catch (e) { toast(e.message, true); }
|
||
}
|
||
|
||
function tile(label, value, sub) {
|
||
return `<div class="tile"><div class="t-l">${label}</div><div class="t-v">${value}</div><div class="t-s">${sub}</div></div>`;
|
||
}
|
||
function meter(label, pct) {
|
||
const p = Math.max(0, Math.min(100, pct || 0));
|
||
const cls = p >= 90 ? "bad" : p >= 75 ? "warn" : "";
|
||
return `<div class="meter"><div class="meter-h"><span class="mk">${label}</span><span class="mv">${Math.round(p)} %</span></div><div class="bar ${cls}"><i style="width:${Math.max(2, p)}%"></i></div></div>`;
|
||
}
|
||
function spark(arr, varName) {
|
||
return '<div style="display:flex;align-items:flex-end;gap:2px;height:38px;margin-top:10px">' +
|
||
arr.map(v => `<div style="width:4px;background:var(${varName});opacity:.55;height:${Math.max(2, v)}%;border-radius:2px"></div>`).join("") + "</div>";
|
||
}
|
||
|
||
function gpuPct() {
|
||
const g = SYS?.gpu;
|
||
if (g && (g.vram.total + g.gtt.total) > 0) return ((g.vram.used + g.gtt.used) / (g.vram.total + g.gtt.total)) * 100;
|
||
return 0;
|
||
}
|
||
|
||
function renderSys() {
|
||
if (!SYS) return;
|
||
hist.cpu.push(SYS.cpu.percent); hist.ram.push(SYS.ram.percent); hist.gpu.push(gpuPct());
|
||
for (const k of ["cpu", "ram", "gpu"]) if (hist[k].length > MAX_HIST) hist[k].shift();
|
||
|
||
const k = $("#act-kpis");
|
||
if (k) k.innerHTML =
|
||
tile("Prozessor (CPU)", `${Math.round(SYS.cpu.percent)}<small> %</small>`, SYS.cpu.temp != null ? `${Math.round(SYS.cpu.temp)}° CPU-Temp` : "Auslastung") +
|
||
tile("Arbeitsspeicher", `${Math.round(SYS.ram.percent)}<small> %</small>`, `${fmtBytes(SYS.ram.used)} / ${fmtBytes(SYS.ram.total)}`) +
|
||
tile("Grafikspeicher", `${Math.round(gpuPct())}<small> %</small>`, SYS.gpu_temp != null ? `${Math.round(SYS.gpu_temp)}° GPU-Temp` : "VRAM + GTT");
|
||
|
||
const g = SYS.gpu;
|
||
const gpuStr = g && (g.vram.total + g.gtt.total) > 0 ? `${fmtBytes(g.vram.used + g.gtt.used)} / ${fmtBytes(g.vram.total + g.gtt.total)}` : "–";
|
||
const s = $("#act-sys");
|
||
if (s) s.innerHTML = `
|
||
<div class="card-h"><h3>System-Metriken (Bosgame)</h3></div>
|
||
<div class="card-sub">Live-Auslastung deines Mini-PCs, alle 0,5 Sekunden.</div>
|
||
<div class="kv" style="margin-bottom:6px">
|
||
<div class="kv-row"><span class="kv-k">Arbeitsspeicher (RAM)</span><span class="kv-v">${fmtBytes(SYS.ram.used)} / ${fmtBytes(SYS.ram.total)}</span></div>
|
||
<div class="kv-row"><span class="kv-k">Grafikspeicher (VRAM + GTT)</span><span class="kv-v">${gpuStr}</span></div>
|
||
<div class="kv-row"><span class="kv-k">Speicherplatz (Disk)</span><span class="kv-v">${Math.round(SYS.disk.percent)} % belegt</span></div>
|
||
<div class="kv-row"><span class="kv-k">Temperatur (GPU / CPU)</span><span class="kv-v">${SYS.gpu_temp != null ? Math.round(SYS.gpu_temp) + "°" : "–"} / ${SYS.cpu.temp != null ? Math.round(SYS.cpu.temp) + "°" : "–"}</span></div>
|
||
</div>
|
||
<div class="grid grid-3" style="margin-top:14px">
|
||
<div><div class="meta text-xs">CPU-Verlauf</div>${spark(hist.cpu, "--accent")}</div>
|
||
<div><div class="meta text-xs">RAM-Verlauf</div>${spark(hist.ram, "--purple")}</div>
|
||
<div><div class="meta text-xs">VRAM-Verlauf</div>${spark(hist.gpu, "--on")}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function mount() {
|
||
$("#act-head").innerHTML = `<div class="pagehead"><div>
|
||
<h1>Aktivität</h1>
|
||
<div class="sub">Live-Auslastung und laufende Aufgaben (Downloads, Updates) mit Protokoll.</div></div></div>`;
|
||
|
||
$("#v-activity").innerHTML = `
|
||
<div class="card-h"><h3>Hintergrund-Aufgaben</h3><span class="meta" id="job-count"></span></div>
|
||
<div class="card-sub">Downloads & Updates erscheinen hier mit Live-Protokoll — zum Aufklappen klicken.</div>
|
||
<div id="jobs"></div>
|
||
<div id="jobs-empty" class="empty-c"><div class="e-t">Gerade nichts los.</div><div class="e-s">Alles ruhig — keine laufenden Aufgaben.</div></div>`;
|
||
|
||
$("#v-activity").addEventListener("click", e => {
|
||
const cancel = e.target.closest("[data-cancel]");
|
||
if (cancel) { e.stopPropagation(); cancelJob(cancel.getAttribute("data-cancel")); return; }
|
||
const h = e.target.closest(".job-h"); if (!h) return;
|
||
const id = h.getAttribute("data-id");
|
||
tracked.has(id) ? tracked.delete(id) : tracked.add(id);
|
||
renderJobs();
|
||
});
|
||
if (SYS) renderSys();
|
||
}
|
||
|
||
function renderJobs() {
|
||
const c = $("#jobs"); if (!c) return;
|
||
$("#jobs-empty").style.display = JOBS.length ? "none" : "flex";
|
||
const failed = JOBS.filter(j => j.state === "failed").length;
|
||
$("#job-count").textContent = JOBS.length ? (failed ? failed + " Fehler" : JOBS.length + " gesamt") : "";
|
||
c.innerHTML = JOBS.map(j => {
|
||
const log = tracked.has(j.id) ? `<div class="log">${esc((j.log || []).join("\n"))}</div>` : "";
|
||
const p = jobPct(j);
|
||
const prog = p != null ? `<div class="bar" style="margin-top:8px"><i style="width:${p}%"></i></div>` : "";
|
||
const pctTxt = p != null ? `<span class="mono-sm" style="margin-left:auto">${Math.round(p)} %</span>` : "";
|
||
const cancelBtn = j.state === "running"
|
||
? `<button class="ghost" data-cancel="${esc(j.id)}" style="${p != null ? "" : "margin-left:auto;"}margin-right:2px;padding:2px 9px;font-size:12px">Abbrechen</button>`
|
||
: "";
|
||
return `<div class="job"><div class="job-h" data-id="${esc(j.id)}">
|
||
<span class="li-dot ${dotClass(j.state)}"></span><span class="mid">${esc(j.label)}</span>${pctTxt}${cancelBtn}${statusBadge(j.state)}</div>${prog}${log}</div>`;
|
||
}).join("");
|
||
}
|
||
|
||
function onJobs(jobs) { JOBS = jobs || []; renderJobs(); }
|
||
function onSystem(sys) { SYS = sys; if ($("#act-sys")) renderSys(); }
|
||
|
||
export default { id: "jobs", mount, onJobs, onSystem };
|