feat(quick-wins): HF-Link-Suche, Download-ETA, Job-Verlauf loeschbar

- Cookbook-Profisuche: ganze HF-URL oder "owner/repo" direkt einfuegbar
  (parseHfRepo) -> exakter Treffer statt unscharfer Suche; limit 12->40,
  damit auch grosse/seltenere Modelle (z.B. Llama-4-Scout) erscheinen.
- jobengine.attach_download_progress: geglaettete Rate (EMA) + Restzeit
  (eta_s) je Download; Aktivitaet zeigt "noch ~X min · Y MB/s".
- Jobs: erledigte/abgebrochene Eintraege einzeln loeschbar (DELETE
  /api/jobs/{id}) + "Verlauf leeren" (POST /api/jobs/clear); laufende
  bleiben geschuetzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-22 16:35:40 +02:00
parent 81f6861df8
commit 7c7fcf87d1
4 changed files with 108 additions and 13 deletions
+32 -3
View File
@@ -26,11 +26,28 @@ function jobPct(j) {
return m ? Math.min(100, parseFloat(m[1])) : null;
}
// Restzeit menschlich: "noch ~2 min" / "noch ~45 s".
function fmtEta(s) {
if (s == null || s < 0) return "";
if (s >= 90) return `noch ~${Math.round(s / 60)} min`;
return `noch ~${Math.max(1, Math.round(s))} s`;
}
async function cancelJob(id) {
try { await api(`/api/jobs/${id}/cancel`, { method: "POST" }); toast("Abbruch angefordert…"); renderJobs(); }
catch (e) { toast(e.message, true); }
}
async function deleteJob(id) {
try { await api(`/api/jobs/${id}`, { method: "DELETE" }); renderJobs(); }
catch (e) { toast(e.message, true); }
}
async function clearJobs() {
try { const r = await api("/api/jobs/clear", { method: "POST" }); toast(`${r.removed} Einträge entfernt.`); 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>`;
}
@@ -86,14 +103,18 @@ function mount() {
<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-h"><h3>Hintergrund-Aufgaben</h3><span class="meta" id="job-count"></span>
<button class="ghost" id="jobs-clear" style="margin-left:auto;display:none;padding:4px 11px;font-size:12px">Verlauf leeren</button></div>
<div class="card-sub">Downloads &amp; 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 => {
if (e.target.closest("#jobs-clear")) { clearJobs(); return; }
const cancel = e.target.closest("[data-cancel]");
if (cancel) { e.stopPropagation(); cancelJob(cancel.getAttribute("data-cancel")); return; }
const del = e.target.closest("[data-del]");
if (del) { e.stopPropagation(); deleteJob(del.getAttribute("data-del")); 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);
@@ -106,15 +127,23 @@ 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;
const finished = JOBS.filter(j => j.state !== "running" && j.state !== "queued").length;
$("#job-count").textContent = JOBS.length ? (failed ? failed + " Fehler" : JOBS.length + " gesamt") : "";
const clearBtn = $("#jobs-clear"); if (clearBtn) clearBtn.style.display = finished ? "" : "none";
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>` : "";
// bei laufenden Downloads: Prozent + Restzeit + Rate
const eta = j.state === "running" ? fmtEta(j.eta_s) : "";
const rate = j.state === "running" && j.rate_bps ? fmtBytes(j.rate_bps) + "/s" : "";
const extra = [eta, rate].filter(Boolean).join(" · ");
const pctTxt = p != null
? `<span class="mono-sm" style="margin-left:auto">${Math.round(p)} %${extra ? ` · ${extra}` : ""}</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>`
: "";
: `<button class="ghost" data-del="${esc(j.id)}" title="Aus Verlauf entfernen" style="margin-left:auto;margin-right:2px;padding:2px 9px;font-size:14px;line-height:1">×</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("");