aufträge und „Alle aktualisieren“: beendete lassen sich ausblenden

User 25.09.: Die Karten waren „immer da“ und ließen sich weder bestätigen noch
wegklicken. Beendete Aufträge der KI-Box und das letzte „Alle aktualisieren“
im Homelab haben jetzt einen Knopf „Ausblenden“ (quittiert wird auf dem Server,
gilt also auf allen Geräten; das Protokoll behält alles).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 12:54:21 +02:00
co-authored by Claude Opus 5.5
parent 0df593c05a
commit 54a3649d5e
39 changed files with 172 additions and 42 deletions
+7
View File
@@ -126,6 +126,13 @@ def sammellauf_stand() -> dict:
return {"sammellauf": sammellauf.aktueller()} return {"sammellauf": sammellauf.aktueller()}
@router.post("/sammellauf/{sl_id}/quittieren")
def sammellauf_quittieren(sl_id: str) -> dict:
"""Beendetes „Alle aktualisieren“ als gesehen markieren (die Update-Seite blendet es aus)."""
from services.homelab import sammellauf
return {"ok": sammellauf.quittieren(sl_id)}
@router.get("/protokoll") @router.get("/protokoll")
def protokoll_lesen(grenze: int = 200, berichte: bool = False) -> dict: def protokoll_lesen(grenze: int = 200, berichte: bool = False) -> dict:
from services.homelab import protokoll from services.homelab import protokoll
+6
View File
@@ -155,6 +155,12 @@ def cancel(job_id: str) -> dict:
return {"ok": jobengine.cancel_job(job_id)} return {"ok": jobengine.cancel_job(job_id)}
@router.post("/jobs/{job_id}/quittieren")
def quittieren(job_id: str) -> dict:
"""Beendeten Auftrag als gesehen markieren (die Oberfläche blendet ihn aus)."""
return {"ok": jobengine.quittieren(job_id)}
class RoleReq(BaseModel): class RoleReq(BaseModel):
role: str | None = None role: str | None = None
+17 -2
View File
@@ -89,9 +89,24 @@ def liste() -> list[dict]:
def aktueller() -> dict | None: def aktueller() -> dict | None:
"""Der laufende oder, wenn keiner läuft, der letzte Sammellauf.""" """Der laufende oder, wenn keiner läuft, der letzte Sammellauf — solange ihn niemand quittiert hat (seit
25.09.2026: vorher stand der letzte für immer auf der Update-Seite)."""
alle = _alle() alle = _alle()
return oeffentlich(next((x for x in reversed(alle) if x.get("status") == "laeuft"), alle[-1] if alle else None)) if laufend := next((x for x in reversed(alle) if x.get("status") == "laeuft"), None):
return oeffentlich(laufend)
letzter = alle[-1] if alle else None
return None if not letzter or letzter.get("quittiert") else oeffentlich(letzter)
def quittieren(sl_id: str) -> bool:
"""Einen beendeten Sammellauf als gesehen markieren; das Protokoll behält ihn."""
with _lock:
sl = next((x for x in _alle() if x["id"] == sl_id), None)
if not sl or sl.get("status") == "laeuft":
return False
sl["quittiert"] = True
_merken(sl)
return True
def laeuft() -> bool: def laeuft() -> bool:
+13
View File
@@ -567,6 +567,19 @@ def cancel_job(job_id: str) -> bool:
return True return True
def quittieren(job_id: str) -> bool:
"""Einen beendeten Auftrag als gesehen markieren — die Oberfläche blendet ihn dann aus (seit 25.09.2026;
vorher blieben beendete Aufträge 24 Stunden stehen, ohne dass man sie wegklicken konnte)."""
with _LOCK:
_laden()
job = JOBS.get(job_id)
if not job or job["state"] not in _ENDE:
return False
job["quittiert"] = True
_speichern(job)
return True
def public_jobs(mit_log: bool = True) -> list[dict]: def public_jobs(mit_log: bool = True) -> list[dict]:
"""Aufträge ohne interne Felder für die API; das Protokoll kommt aus der Datei (Ende).""" """Aufträge ohne interne Felder für die API; das Protokoll kommt aus der Datei (Ende)."""
with _LOCK: with _LOCK:
+11
View File
@@ -386,3 +386,14 @@ def test_schnittstellen(homelab):
fertig = c.get("/api/homelab/sammellauf").json()["sammellauf"] fertig = c.get("/api/homelab/sammellauf").json()["sammellauf"]
assert fertig["status"] == "fertig" and fertig["id"] == antwort.json()["sammellauf"]["id"] assert fertig["status"] == "fertig" and fertig["id"] == antwort.json()["sammellauf"]["id"]
assert "meldung" not in fertig and "dringend" not in fertig assert "meldung" not in fertig and "dringend" not in fertig
def test_quittiert_verschwindet_von_der_update_seite(homelab):
"""25.09.2026: Das fertige „Alle aktualisieren“ ließ sich nicht wegklicken."""
antwort = sammellauf.starten()
assert sammellauf.quittieren(antwort["sammellauf"]["id"]) is False # läuft noch
sl = _auf_ende()
assert sammellauf.aktueller()["id"] == sl["id"]
assert sammellauf.quittieren(sl["id"]) is True
assert sammellauf.aktueller() is None
assert sammellauf.liste()[0]["quittiert"] is True # das Protokoll behält ihn
+13
View File
@@ -194,3 +194,16 @@ def test_systemd_start_scheitert_sauber(motor, monkeypatch):
def test_zeilen_aus_ueberschreibt_mit_wagenruecklauf(): def test_zeilen_aus_ueberschreibt_mit_wagenruecklauf():
assert jobengine.zeilen_aus("a\n 1%\r 50%\r100%\nb\r\n") == ["a", "100%", "b"] assert jobengine.zeilen_aus("a\n 1%\r 50%\r100%\nb\r\n") == ["a", "100%", "b"]
assert jobengine.zeilen_aus("läuft\r 7%") == [" 7%"] assert jobengine.zeilen_aus("läuft\r 7%") == [" 7%"]
def test_quittieren_nur_wenn_beendet(motor):
"""25.09.2026: Beendete Aufträge standen 24 Stunden auf der Update-Seite, ohne dass man sie wegklicken konnte."""
job_id = jobengine.start_job([PY, "-c", "import time; time.sleep(30)"], "Lang")
time.sleep(0.2)
assert jobengine.quittieren(job_id) is False # läuft noch
jobengine.cancel_job(job_id)
_warte(job_id)
assert jobengine.quittieren(job_id) is True and _oeffentlich(job_id)["quittiert"] is True
akte = json.loads((motor / f"{job_id}.json").read_text(encoding="utf-8"))
assert akte["quittiert"] is True
assert jobengine.quittieren("gibt-es-nicht") is False
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{i as e,n as t,r as n,s as r,t as i}from"./dist-D4CVSex9.js";import{Bt as a,Dt as o,I as s,Zt as c,jt as l,nt as u}from"./index-DW9Crlr_.js";import{t as d}from"./button-D8rvET0Z.js";import{a as f,i as p,n as m,r as h,t as g}from"./sheet-ClRIvGlH.js";var _=t(),v=r(e(),1),y=n();function b(e){let t=(0,_.c)(4),{unit:n}=e,r=u(n),i;if(r.isPending)i=`Wird gelesen …`;else if(r.isError)i=`Das Protokoll lässt sich gerade nicht lesen: ${r.error.message}`;else if(r.data.ok===!1)i=r.data.err||r.data.text||`Das Protokoll lässt sich nicht lesen.`;else{let e;t[0]===r.data.text?e=t[1]:(e=r.data.text?.trim()||`Noch keine Einträge.`,t[0]=r.data.text,t[1]=e),i=e}let a;return t[2]===i?a=t[3]:(a=(0,y.jsx)(`pre`,{className:`ziffern m-0 max-h-[50vh] w-full basis-full overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap break-all text-text-2`,children:i}),t[2]=i,t[3]=a),a}function x({offen:e,onSchliessen:t}){let n=c(),r=s(),[u,_]=(0,v.useState)(null),[x,S]=(0,v.useState)(null),[C,w]=(0,v.useState)(null);async function T(e){if(!C){S(null),w(e.unit);try{let t=await a(`/api/maintenance/restart`,{service:e.unit});t.ok?o(`erfolg`,`${e.name} ${e.schlaeft?`wird geweckt`:`startet neu`}.`):o(`fehler`,`${e.name} ließ sich nicht starten: ${l(t)}`),n.invalidateQueries({queryKey:[`dienste`]})}catch(e){o(`fehler`,e.message)}finally{w(null)}}}let E;return E=r.isPending?(0,y.jsx)(`p`,{role:`status`,className:`px-4 text-[15px] text-text-2`,children:`Dienste werden gelesen …`}):r.isError&&!r.data?(0,y.jsxs)(`p`,{role:`alert`,className:`px-4 text-[15px] text-rot-text`,children:[`Die Dienste lassen sich gerade nicht lesen: `,r.error.message]}):r.data?.services.length?(0,y.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-4`,children:r.data.services.map(e=>(0,y.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,y.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,y.jsx)(`span`,{className:i(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:e.schlaeft?`bg-text-3`:`bg-rot`),"aria-hidden":!0}),(0,y.jsxs)(`span`,{className:`min-w-0`,children:[(0,y.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,y.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:e.schlaeft?`schläft (abgeschaltet)`:`antwortet nicht`]})]})]}),(0,y.jsxs)(`span`,{className:`flex flex-wrap gap-2`,children:[(0,y.jsx)(d,{variant:u===e.unit?`info`:`ghost`,size:`sm`,"aria-expanded":u===e.unit,onClick:()=>_(u===e.unit?null:e.unit),children:`Protokoll`}),x===e.unit?(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(d,{variant:`gefahr`,size:`sm`,onClick:()=>T(e),children:e.schlaeft?`Wirklich wecken?`:`Wirklich neu starten?`}),(0,y.jsx)(d,{variant:`ghost`,size:`sm`,onClick:()=>S(null),children:`Nein`})]}):(0,y.jsx)(d,{variant:`outline`,size:`sm`,disabled:C!==null,onClick:()=>S(e.unit),children:C===e.unit?`Startet …`:e.schlaeft?`Wecken`:`Neu starten`})]}),u===e.unit&&(0,y.jsx)(b,{unit:e.unit})]},e.unit))}):(0,y.jsx)(`p`,{className:`px-4 text-[15px] text-text-2`,children:`Keine Dienste gefunden.`}),(0,y.jsx)(g,{open:e,onOpenChange:e=>!e&&t(),children:(0,y.jsxs)(m,{side:`right`,className:`gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[(0,y.jsxs)(p,{children:[(0,y.jsx)(f,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,y.jsx)(h,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),E]})})}export{x as Dienste}; import{i as e,n as t,r as n,s as r,t as i}from"./dist-D4CVSex9.js";import{$t as a,Ht as o,I as s,Nt as c,kt as l,rt as u}from"./index-CHHAAyIc.js";import{t as d}from"./button-D8rvET0Z.js";import{a as f,i as p,n as m,r as h,t as g}from"./sheet-BwqJxN7_.js";var _=t(),v=r(e(),1),y=n();function b(e){let t=(0,_.c)(4),{unit:n}=e,r=u(n),i;if(r.isPending)i=`Wird gelesen …`;else if(r.isError)i=`Das Protokoll lässt sich gerade nicht lesen: ${r.error.message}`;else if(r.data.ok===!1)i=r.data.err||r.data.text||`Das Protokoll lässt sich nicht lesen.`;else{let e;t[0]===r.data.text?e=t[1]:(e=r.data.text?.trim()||`Noch keine Einträge.`,t[0]=r.data.text,t[1]=e),i=e}let a;return t[2]===i?a=t[3]:(a=(0,y.jsx)(`pre`,{className:`ziffern m-0 max-h-[50vh] w-full basis-full overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap break-all text-text-2`,children:i}),t[2]=i,t[3]=a),a}function x({offen:e,onSchliessen:t}){let n=a(),r=s(),[u,_]=(0,v.useState)(null),[x,S]=(0,v.useState)(null),[C,w]=(0,v.useState)(null);async function T(e){if(!C){S(null),w(e.unit);try{let t=await o(`/api/maintenance/restart`,{service:e.unit});t.ok?l(`erfolg`,`${e.name} ${e.schlaeft?`wird geweckt`:`startet neu`}.`):l(`fehler`,`${e.name} ließ sich nicht starten: ${c(t)}`),n.invalidateQueries({queryKey:[`dienste`]})}catch(e){l(`fehler`,e.message)}finally{w(null)}}}let E;return E=r.isPending?(0,y.jsx)(`p`,{role:`status`,className:`px-4 text-[15px] text-text-2`,children:`Dienste werden gelesen …`}):r.isError&&!r.data?(0,y.jsxs)(`p`,{role:`alert`,className:`px-4 text-[15px] text-rot-text`,children:[`Die Dienste lassen sich gerade nicht lesen: `,r.error.message]}):r.data?.services.length?(0,y.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-4`,children:r.data.services.map(e=>(0,y.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,y.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,y.jsx)(`span`,{className:i(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:e.schlaeft?`bg-text-3`:`bg-rot`),"aria-hidden":!0}),(0,y.jsxs)(`span`,{className:`min-w-0`,children:[(0,y.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,y.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:e.schlaeft?`schläft (abgeschaltet)`:`antwortet nicht`]})]})]}),(0,y.jsxs)(`span`,{className:`flex flex-wrap gap-2`,children:[(0,y.jsx)(d,{variant:u===e.unit?`info`:`ghost`,size:`sm`,"aria-expanded":u===e.unit,onClick:()=>_(u===e.unit?null:e.unit),children:`Protokoll`}),x===e.unit?(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(d,{variant:`gefahr`,size:`sm`,onClick:()=>T(e),children:e.schlaeft?`Wirklich wecken?`:`Wirklich neu starten?`}),(0,y.jsx)(d,{variant:`ghost`,size:`sm`,onClick:()=>S(null),children:`Nein`})]}):(0,y.jsx)(d,{variant:`outline`,size:`sm`,disabled:C!==null,onClick:()=>S(e.unit),children:C===e.unit?`Startet …`:e.schlaeft?`Wecken`:`Neu starten`})]}),u===e.unit&&(0,y.jsx)(b,{unit:e.unit})]},e.unit))}):(0,y.jsx)(`p`,{className:`px-4 text-[15px] text-text-2`,children:`Keine Dienste gefunden.`}),(0,y.jsx)(g,{open:e,onOpenChange:e=>!e&&t(),children:(0,y.jsxs)(m,{side:`right`,className:`gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[(0,y.jsxs)(p,{children:[(0,y.jsx)(f,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,y.jsx)(h,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),E]})})}export{x as Dienste};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{n as e,r as t}from"./dist-D4CVSex9.js";import{g as n,h as r}from"./index-DW9Crlr_.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-ClRIvGlH.js";var l=e(),u=t();function d(e){let t=(0,l.c)(9),{onDienste:d,onSchliessen:f}=e,p;t[0]===f?p=t[1]:(p=e=>!e&&f(),t[0]=f,t[1]=p);let m;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(r,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=m):m=t[2];let h;t[3]!==d||t[4]!==f?(h=(0,u.jsxs)(o,{side:`left`,className:`flex flex-col gap-6 border-linie bg-[#0c0f12] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]`,children:[m,(0,u.jsx)(n,{onDienste:d,onGewaehlt:f})]}),t[3]=d,t[4]=f,t[5]=h):h=t[5];let g;return t[6]!==p||t[7]!==h?(g=(0,u.jsx)(c,{open:!0,onOpenChange:p,children:h}),t[6]=p,t[7]=h,t[8]=g):g=t[8],g}export{d as MenueSchublade}; import{n as e,r as t}from"./dist-D4CVSex9.js";import{g as n,h as r}from"./index-CHHAAyIc.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-BwqJxN7_.js";var l=e(),u=t();function d(e){let t=(0,l.c)(9),{onDienste:d,onSchliessen:f}=e,p;t[0]===f?p=t[1]:(p=e=>!e&&f(),t[0]=f,t[1]=p);let m;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(r,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=m):m=t[2];let h;t[3]!==d||t[4]!==f?(h=(0,u.jsxs)(o,{side:`left`,className:`flex flex-col gap-6 border-linie bg-[#0c0f12] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]`,children:[m,(0,u.jsx)(n,{onDienste:d,onGewaehlt:f})]}),t[3]=d,t[4]=f,t[5]=h):h=t[5];let g;return t[6]!==p||t[7]!==h?(g=(0,u.jsx)(c,{open:!0,onOpenChange:p,children:h}),t[6]=p,t[7]=h,t[8]=g):g=t[8],g}export{d as MenueSchublade};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{n as e,r as t}from"./dist-D4CVSex9.js";import{a as n,i as r,n as i,r as a,t as o}from"./sheet-ClRIvGlH.js";var s=e(),c=t();function l(e){let t=(0,s.c)(14),{offen:l,titel:u,text:d,onSchliessen:f}=e,p;t[0]===f?p=t[1]:(p=e=>!e&&f(),t[0]=f,t[1]=p);let m;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,c.jsx)(n,{className:`schild text-lg`,children:`Protokoll`}),t[2]=m):m=t[2];let h;t[3]===u?h=t[4]:(h=(0,c.jsxs)(r,{children:[m,(0,c.jsx)(a,{className:`text-text-2`,children:u})]}),t[3]=u,t[4]=h);let g;t[5]===d?g=t[6]:(g=(0,c.jsx)(`pre`,{className:`ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:d}),t[5]=d,t[6]=g);let _;t[7]!==h||t[8]!==g?(_=(0,c.jsxs)(i,{side:`right`,className:`border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[h,g]}),t[7]=h,t[8]=g,t[9]=_):_=t[9];let v;return t[10]!==l||t[11]!==p||t[12]!==_?(v=(0,c.jsx)(o,{open:l,onOpenChange:p,children:_}),t[10]=l,t[11]=p,t[12]=_,t[13]=v):v=t[13],v}export{l as Protokollfenster}; import{n as e,r as t}from"./dist-D4CVSex9.js";import{a as n,i as r,n as i,r as a,t as o}from"./sheet-BwqJxN7_.js";var s=e(),c=t();function l(e){let t=(0,s.c)(14),{offen:l,titel:u,text:d,onSchliessen:f}=e,p;t[0]===f?p=t[1]:(p=e=>!e&&f(),t[0]=f,t[1]=p);let m;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,c.jsx)(n,{className:`schild text-lg`,children:`Protokoll`}),t[2]=m):m=t[2];let h;t[3]===u?h=t[4]:(h=(0,c.jsxs)(r,{children:[m,(0,c.jsx)(a,{className:`text-text-2`,children:u})]}),t[3]=u,t[4]=h);let g;t[5]===d?g=t[6]:(g=(0,c.jsx)(`pre`,{className:`ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:d}),t[5]=d,t[6]=g);let _;t[7]!==h||t[8]!==g?(_=(0,c.jsxs)(i,{side:`right`,className:`border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[h,g]}),t[7]=h,t[8]=g,t[9]=_):_=t[9];let v;return t[10]!==l||t[11]!==p||t[12]!==_?(v=(0,c.jsx)(o,{open:l,onOpenChange:p,children:_}),t[10]=l,t[11]=p,t[12]=_,t[13]=v):v=t[13],v}export{l as Protokollfenster};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{Kt as e}from"./index-DW9Crlr_.js";var t={name:`chevron-right`,size:24,node:[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]};t.node;var n=e(t);export{n as t}; import{Jt as e}from"./index-CHHAAyIc.js";var t={name:`chevron-right`,size:24,node:[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]};t.node;var n=e(t);export{n as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{Kt as e}from"./index-DW9Crlr_.js";var t={name:`search`,size:24,node:[[`path`,{d:`m21 21-4.34-4.34`,key:`14j7rj`}],[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}]]};t.node;var n=e(t);export{n as t}; import{Jt as e}from"./index-CHHAAyIc.js";var t={name:`search`,size:24,node:[[`path`,{d:`m21 21-4.34-4.34`,key:`14j7rj`}],[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}]]};t.node;var n=e(t);export{n as t};
@@ -1 +1 @@
import{i as e,n as t,r as n,t as r}from"./dist-D4CVSex9.js";import{Vt as i}from"./index-DW9Crlr_.js";import{t as a}from"./button-D8rvET0Z.js";import{a as o,i as s,n as c,o as l,r as u,s as d,t as f}from"./dist-C9MWG2Tl.js";var p=t();e();var m=n();function h(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(f,{"data-slot":`sheet`,...n}),t[2]=n,t[3]=r),r}function g(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(l,{"data-slot":`sheet-portal`,...n}),t[2]=n,t[3]=r),r}function _(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0`,n),t[3]=n,t[4]=a);let s;return t[5]!==i||t[6]!==a?(s=(0,m.jsx)(o,{"data-slot":`sheet-overlay`,className:a,...i}),t[5]=i,t[6]=a,t[7]=s):s=t[7],s}function v(e){let t=(0,p.c)(17),n,o,s,l,d;t[0]===e?(n=t[1],o=t[2],s=t[3],l=t[4],d=t[5]):({className:o,children:n,side:l,showCloseButton:d,...s}=e,t[0]=e,t[1]=n,t[2]=o,t[3]=s,t[4]=l,t[5]=d);let f=l===void 0?`right`:l,h=d===void 0||d,v;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,m.jsx)(_,{}),t[6]=v):v=t[6];let y;t[7]===o?y=t[8]:(y=r(`fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10`,o),t[7]=o,t[8]=y);let b;t[9]===h?b=t[10]:(b=h&&(0,m.jsx)(c,{"data-slot":`sheet-close`,asChild:!0,children:(0,m.jsxs)(a,{variant:`ghost`,className:`absolute top-3 right-3`,size:`icon-sm`,children:[(0,m.jsx)(i,{}),(0,m.jsx)(`span`,{className:`sr-only`,children:`Schließen`})]})}),t[9]=h,t[10]=b);let x;return t[11]!==n||t[12]!==s||t[13]!==f||t[14]!==y||t[15]!==b?(x=(0,m.jsxs)(g,{children:[v,(0,m.jsxs)(u,{"data-slot":`sheet-content`,"data-side":f,className:y,...s,children:[n,b]})]}),t[11]=n,t[12]=s,t[13]=f,t[14]=y,t[15]=b,t[16]=x):x=t[16],x}function y(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`flex flex-col gap-0.5 p-4`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(`div`,{"data-slot":`sheet-header`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function b(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`font-heading text-base font-medium text-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(d,{"data-slot":`sheet-title`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function x(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`text-sm text-muted-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(s,{"data-slot":`sheet-description`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}export{b as a,y as i,v as n,x as r,h as t}; import{i as e,n as t,r as n,t as r}from"./dist-D4CVSex9.js";import{Ut as i}from"./index-CHHAAyIc.js";import{t as a}from"./button-D8rvET0Z.js";import{a as o,i as s,n as c,o as l,r as u,s as d,t as f}from"./dist-BWAe-t7-.js";var p=t();e();var m=n();function h(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(f,{"data-slot":`sheet`,...n}),t[2]=n,t[3]=r),r}function g(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(l,{"data-slot":`sheet-portal`,...n}),t[2]=n,t[3]=r),r}function _(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0`,n),t[3]=n,t[4]=a);let s;return t[5]!==i||t[6]!==a?(s=(0,m.jsx)(o,{"data-slot":`sheet-overlay`,className:a,...i}),t[5]=i,t[6]=a,t[7]=s):s=t[7],s}function v(e){let t=(0,p.c)(17),n,o,s,l,d;t[0]===e?(n=t[1],o=t[2],s=t[3],l=t[4],d=t[5]):({className:o,children:n,side:l,showCloseButton:d,...s}=e,t[0]=e,t[1]=n,t[2]=o,t[3]=s,t[4]=l,t[5]=d);let f=l===void 0?`right`:l,h=d===void 0||d,v;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,m.jsx)(_,{}),t[6]=v):v=t[6];let y;t[7]===o?y=t[8]:(y=r(`fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10`,o),t[7]=o,t[8]=y);let b;t[9]===h?b=t[10]:(b=h&&(0,m.jsx)(c,{"data-slot":`sheet-close`,asChild:!0,children:(0,m.jsxs)(a,{variant:`ghost`,className:`absolute top-3 right-3`,size:`icon-sm`,children:[(0,m.jsx)(i,{}),(0,m.jsx)(`span`,{className:`sr-only`,children:`Schließen`})]})}),t[9]=h,t[10]=b);let x;return t[11]!==n||t[12]!==s||t[13]!==f||t[14]!==y||t[15]!==b?(x=(0,m.jsxs)(g,{children:[v,(0,m.jsxs)(u,{"data-slot":`sheet-content`,"data-side":f,className:y,...s,children:[n,b]})]}),t[11]=n,t[12]=s,t[13]=f,t[14]=y,t[15]=b,t[16]=x):x=t[16],x}function y(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`flex flex-col gap-0.5 p-4`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(`div`,{"data-slot":`sheet-header`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function b(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`font-heading text-base font-medium text-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(d,{"data-slot":`sheet-title`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function x(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`text-sm text-muted-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(s,{"data-slot":`sheet-description`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}export{b as a,y as i,v as n,x as r,h as t};
+1 -1
View File
@@ -9,7 +9,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>Homelab Orchestrator</title> <title>Homelab Orchestrator</title>
<script type="module" crossorigin src="/assets/index-DW9Crlr_.js"></script> <script type="module" crossorigin src="/assets/index-CHHAAyIc.js"></script>
<link rel="modulepreload" crossorigin href="/assets/dist-D4CVSex9.js"> <link rel="modulepreload" crossorigin href="/assets/dist-D4CVSex9.js">
<link rel="modulepreload" crossorigin href="/assets/useSelector-uUwrspuX.js"> <link rel="modulepreload" crossorigin href="/assets/useSelector-uUwrspuX.js">
<link rel="modulepreload" crossorigin href="/assets/useMatch-CiyhmkMV.js"> <link rel="modulepreload" crossorigin href="/assets/useMatch-CiyhmkMV.js">
@@ -1,4 +1,4 @@
import { useJobAbbrechen, useJobs } from "@/lib/abfragen" import { useJobAbbrechen, useJobQuittieren, useJobs } from "@/lib/abfragen"
import { jobTitel, sichtbareJobs } from "@/lib/wartung" import { jobTitel, sichtbareJobs } from "@/lib/wartung"
import { JobKarte } from "./JobKarte" import { JobKarte } from "./JobKarte"
import { Panel } from "./Panel" import { Panel } from "./Panel"
@@ -10,6 +10,7 @@ import { Panel } from "./Panel"
export function Auftraege({ alsPanel = false }: { alsPanel?: boolean }) { export function Auftraege({ alsPanel = false }: { alsPanel?: boolean }) {
const jobs = useJobs() const jobs = useJobs()
const abbrechen = useJobAbbrechen() const abbrechen = useJobAbbrechen()
const quittieren = useJobQuittieren()
const liste = sichtbareJobs(jobs.data?.jobs ?? []) const liste = sichtbareJobs(jobs.data?.jobs ?? [])
let inhalt let inhalt
@@ -23,6 +24,7 @@ export function Auftraege({ alsPanel = false }: { alsPanel?: boolean }) {
ebene={alsPanel ? 3 : 4} ebene={alsPanel ? 3 : 4}
abbrechenGesperrt={abbrechen.isPending} abbrechenGesperrt={abbrechen.isPending}
onAbbrechen={(job) => abbrechen.mutate({ id: job.id, titel: jobTitel(job) })} onAbbrechen={(job) => abbrechen.mutate({ id: job.id, titel: jobTitel(job) })}
onQuittieren={(job) => quittieren.mutate(job.id)}
/> />
))} ))}
</div> </div>
@@ -97,3 +97,16 @@ describe("sichtbareJobs", () => {
expect(liste.map((j) => j.id)).toEqual(["download", "update", "f4", "f3", "f2"]) expect(liste.map((j) => j.id)).toEqual(["download", "update", "f4", "f3", "f2"])
}) })
}) })
describe("Ausblenden (seit 25.09.2026)", () => {
it("bietet es nur für beendete Aufträge an und blendet quittierte aus", () => {
const onQuittieren = vi.fn()
const { rerender } = render(<JobKarte job={job({})} onQuittieren={onQuittieren} />)
expect(screen.queryByRole("button", { name: "Ausblenden" })).not.toBeInTheDocument()
const fertig = job({ state: "failed", returncode: 1, finished_at: JETZT - 60 })
rerender(<JobKarte job={fertig} onQuittieren={onQuittieren} />)
fireEvent.click(screen.getByRole("button", { name: "Ausblenden" }))
expect(onQuittieren).toHaveBeenCalledWith(fertig)
expect(sichtbareJobs([fertig, { ...fertig, id: "j2", quittiert: true }], JETZT).map((j) => j.id)).toEqual(["j1"])
})
})
+9 -2
View File
@@ -41,9 +41,11 @@ function useJetzt(aktiv: boolean) {
* die letzte Meldung, auf Wunsch das Protokoll. Abbrechen nur, wo es wirklich stoppt (`abbrechbar`). * die letzte Meldung, auf Wunsch das Protokoll. Abbrechen nur, wo es wirklich stoppt (`abbrechbar`).
* `ebene` ist die Überschriftenebene (unter einem Panel-Titel h3, unter einer Zwischenüberschrift h4). * `ebene` ist die Überschriftenebene (unter einem Panel-Titel h3, unter einer Zwischenüberschrift h4).
*/ */
export function JobKarte({ job, onAbbrechen, abbrechenGesperrt, ebene = 4 }: { export function JobKarte({ job, onAbbrechen, onQuittieren, abbrechenGesperrt, ebene = 4 }: {
job: Job job: Job
onAbbrechen?: (job: Job) => void onAbbrechen?: (job: Job) => void
/** Beendete Aufträge lassen sich ausblenden. */
onQuittieren?: (job: Job) => void
abbrechenGesperrt?: boolean abbrechenGesperrt?: boolean
ebene?: 3 | 4 ebene?: 3 | 4
}) { }) {
@@ -95,7 +97,7 @@ export function JobKarte({ job, onAbbrechen, abbrechenGesperrt, ebene = 4 }: {
</p> </p>
)} )}
{(darfAbbrechen || protokollZeilen.length > 1) && ( {(darfAbbrechen || protokollZeilen.length > 1 || (!laeuft && onQuittieren)) && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{darfAbbrechen && ( {darfAbbrechen && (
<Button variant="outline" size="sm" disabled={abbrechenGesperrt} onClick={() => setFrage(true)}> <Button variant="outline" size="sm" disabled={abbrechenGesperrt} onClick={() => setFrage(true)}>
@@ -107,6 +109,11 @@ export function JobKarte({ job, onAbbrechen, abbrechenGesperrt, ebene = 4 }: {
{protokoll ? "Protokoll zu" : "Protokoll"} {protokoll ? "Protokoll zu" : "Protokoll"}
</Button> </Button>
)} )}
{!laeuft && onQuittieren && (
<Button variant="outline" size="sm" className="ml-auto" onClick={() => onQuittieren(job)}>
Ausblenden
</Button>
)}
</div> </div>
)} )}
+11 -2
View File
@@ -75,7 +75,7 @@ const LAUF: Record<Sammellauf["status"], { art: ChipArt; text: string }> = {
} }
/** Fortschritt des Sammellaufs: jeder Schritt mit Zustand; fertige Läufe bleiben bis zum nächsten stehen. */ /** Fortschritt des Sammellaufs: jeder Schritt mit Zustand; fertige Läufe bleiben bis zum nächsten stehen. */
export function SammellaufFortschritt({ lauf }: { lauf: Sammellauf }) { export function SammellaufFortschritt({ lauf, onQuittieren }: { lauf: Sammellauf; onQuittieren?: () => void }) {
const fertig = lauf.schritte.filter((s) => !["wartet", "laeuft"].includes(s.status)).length const fertig = lauf.schritte.filter((s) => !["wartet", "laeuft"].includes(s.status)).length
const l = LAUF[lauf.status] const l = LAUF[lauf.status]
return ( return (
@@ -104,7 +104,16 @@ export function SammellaufFortschritt({ lauf }: { lauf: Sammellauf }) {
) )
})} })}
</ol> </ol>
{lauf.text && <p className="text-sm text-text-2">{lauf.text}</p>} {(lauf.text || (lauf.status !== "laeuft" && onQuittieren)) && (
<div className="flex flex-wrap items-center justify-between gap-3">
{lauf.text && <p className="text-sm text-text-2">{lauf.text}</p>}
{lauf.status !== "laeuft" && onQuittieren && (
<Button variant="outline" size="sm" className="ml-auto" onClick={onQuittieren}>
Ausblenden
</Button>
)}
</div>
)}
</section> </section>
) )
} }
@@ -73,3 +73,19 @@ describe("SammellaufFortschritt", () => {
expect(screen.getByText(/118 Pakete · wartet/)).toBeInTheDocument() expect(screen.getByText(/118 Pakete · wartet/)).toBeInTheDocument()
}) })
}) })
describe("SammellaufFortschritt ausblenden", () => {
it("zeigt „Ausblenden“ erst, wenn der Lauf fertig ist", () => {
const fertig: Sammellauf = {
id: "s2", status: "fertig", start: 1_790_000_000, ende: 1_790_001_000, aktuell: null,
text: "8 Updates eingespielt, alle Prüfungen grün.",
schritte: [{ ziel: "ct-104", name: "Gitea", baustein: "os", was: "73 Pakete", status: "eingespielt", lauf: "l1", text: null }],
}
const weg = vi.fn()
const { rerender } = render(<SammellaufFortschritt lauf={{ ...fertig, status: "laeuft", ende: null }} onQuittieren={weg} />)
expect(screen.queryByRole("button", { name: "Ausblenden" })).not.toBeInTheDocument()
rerender(<SammellaufFortschritt lauf={fertig} onQuittieren={weg} />)
fireEvent.click(screen.getByRole("button", { name: "Ausblenden" }))
expect(weg).toHaveBeenCalled()
})
})
+16 -1
View File
@@ -143,7 +143,8 @@ function useAktion<V, E extends AktionsErgebnis | Record<string, unknown>>(
const ergebnis = e as AktionsErgebnis const ergebnis = e as AktionsErgebnis
if (ergebnis && ergebnis.ok === false) { if (ergebnis && ergebnis.ok === false) {
melden("fehler", opt.fehler?.(v, ergebnis) ?? fehlerText(ergebnis)) melden("fehler", opt.fehler?.(v, ergebnis) ?? fehlerText(ergebnis))
} else { } else if (opt.erfolg(v, e)) {
// Leerer Text: keine Erfolgsmeldung (etwa beim Ausblenden — da sieht man es ja).
melden("erfolg", opt.erfolg(v, e)) melden("erfolg", opt.erfolg(v, e))
} }
for (const k of opt.neuLaden) qc.invalidateQueries({ queryKey: [k] }) for (const k of opt.neuLaden) qc.invalidateQueries({ queryKey: [k] })
@@ -189,6 +190,13 @@ export const useJobAbbrechen = () =>
neuLaden: ["jobs"], neuLaden: ["jobs"],
}) })
/** Einen beendeten Auftrag ausblenden (seit 25.09.2026; vorher blieb er 24 Stunden stehen). */
export const useJobQuittieren = () =>
useAktion((id: string) => post<AktionsErgebnis>(`/api/jobs/${encodeURIComponent(id)}/quittieren`), {
erfolg: () => "",
neuLaden: ["jobs"],
})
/** Ein Modell von Hugging Face holen (Hintergrund-Auftrag, mit optionaler Rolle nach dem Download). */ /** Ein Modell von Hugging Face holen (Hintergrund-Auftrag, mit optionaler Rolle nach dem Download). */
export const useInstallieren = () => export const useInstallieren = () =>
useAktion( useAktion(
@@ -414,6 +422,13 @@ export const useAlleAktualisieren = () =>
neuLaden: ["homelab-sammellauf", "homelab-laeufe", "homelab"], neuLaden: ["homelab-sammellauf", "homelab-laeufe", "homelab"],
}) })
/** Ein beendetes „Alle aktualisieren“ ausblenden (das Protokoll behält es). */
export const useSammellaufQuittieren = () =>
useAktion((id: string) => post<AktionsErgebnis>(`/api/homelab/sammellauf/${encodeURIComponent(id)}/quittieren`), {
erfolg: () => "",
neuLaden: ["homelab-sammellauf"],
})
export const useHomelabProtokoll = (aktiv: boolean, berichte: boolean) => export const useHomelabProtokoll = (aktiv: boolean, berichte: boolean) =>
useQuery({ useQuery({
queryKey: ["homelab-protokoll", berichte], queryKey: ["homelab-protokoll", berichte],
+2
View File
@@ -173,6 +173,8 @@ export interface Job {
started_at: number started_at: number
finished_at: number | null finished_at: number | null
canceled?: boolean canceled?: boolean
/** Vom Nutzer ausgeblendet (seit 25.09.2026). */
quittiert?: boolean
/** Nur bei Downloads: Fortschritt 0…100 und die Bytes dazu. */ /** Nur bei Downloads: Fortschritt 0…100 und die Bytes dazu. */
progress?: number | null progress?: number | null
total_bytes?: number total_bytes?: number
+1 -1
View File
@@ -128,7 +128,7 @@ export function letzteMeldung(j: Pick<Job, "log">): string | null {
export function sichtbareJobs(jobs: Job[], jetzt = Date.now() / 1000): Job[] { export function sichtbareJobs(jobs: Job[], jetzt = Date.now() / 1000): Job[] {
const laufend = jobs.filter(laeuftNoch).sort((a, b) => a.started_at - b.started_at) const laufend = jobs.filter(laeuftNoch).sort((a, b) => a.started_at - b.started_at)
const beendet = jobs const beendet = jobs
.filter((j) => !laeuftNoch(j) && jetzt - (j.finished_at ?? j.started_at) < 86_400) .filter((j) => !laeuftNoch(j) && !j.quittiert && jetzt - (j.finished_at ?? j.started_at) < 86_400)
.sort((a, b) => (b.finished_at ?? b.started_at) - (a.finished_at ?? a.started_at)) .sort((a, b) => (b.finished_at ?? b.started_at) - (a.finished_at ?? a.started_at))
.slice(0, 3) .slice(0, 3)
return [...laufend, ...beendet] return [...laufend, ...beendet]
+3 -2
View File
@@ -9,7 +9,7 @@ import { AlleKnopf, SammellaufFortschritt } from "@/components/homelab/Sammellau
import { UpdateTabelle } from "@/components/homelab/UpdateTabelle" import { UpdateTabelle } from "@/components/homelab/UpdateTabelle"
import { import {
useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useInstanz, usePartner, useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useInstanz, usePartner,
useSammellauf, useSammellauf, useSammellaufQuittieren,
useZielAktion, useZielAktion,
} from "@/lib/abfragen" } from "@/lib/abfragen"
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab" import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab"
@@ -242,6 +242,7 @@ export function HomelabSeite() {
export function HomelabUpdatesSeite() { export function HomelabUpdatesSeite() {
const { ersatz, stand, laufend, laeufe, aktion } = useHomelabBereich() const { ersatz, stand, laufend, laeufe, aktion } = useHomelabBereich()
const sammel = useSammellauf(!!stand) const sammel = useSammellauf(!!stand)
const quittieren = useSammellaufQuittieren()
if (ersatz || !stand) return ersatz if (ersatz || !stand) return ersatz
const lauf = sammel.data?.sammellauf ?? null const lauf = sammel.data?.sammellauf ?? null
@@ -259,7 +260,7 @@ export function HomelabUpdatesSeite() {
</p> </p>
<AusfuehrerHinweis stand={stand} /> <AusfuehrerHinweis stand={stand} />
{/* Ein fertiger Lauf bleibt sechs Stunden stehen — gemessen am letzten Bericht, nicht an der Uhr beim Zeichnen. */} {/* Ein fertiger Lauf bleibt sechs Stunden stehen — gemessen am letzten Bericht, nicht an der Uhr beim Zeichnen. */}
{lauf && (sammelLaeuft || (lauf.ende && (stand.bericht?.empfangen ?? 0) - lauf.ende < 6 * 3600)) && <SammellaufFortschritt lauf={lauf} />} {lauf && (sammelLaeuft || (lauf.ende && (stand.bericht?.empfangen ?? 0) - lauf.ende < 6 * 3600)) && <SammellaufFortschritt lauf={lauf} onQuittieren={() => quittieren.mutate(lauf.id)} />}
<UpdateTabelle <UpdateTabelle
ziele={stand.ziele} ziele={stand.ziele}
laufend={laufend} laufend={laufend}