Welle 1 · Totmannschalter: Wächter meldet Abläufe, die gar nicht erst laufen; Seite Dienste zeigt die Zeitpläne

services/ablaeufe.py: je systemd-Timer der Box der erwartete Takt plus Karenz
(LastTriggerUSec über --timestamp=unix), Hermes-Cron-Jobs mit verpasstem
next_run_at, der Runner der Gitea-Ampel (Admin-API, Token aus
~/.git-credentials, nie ausgegeben). Überfällig oder gestoppt → Hinweis nach
fünf Takten, die Sonntags-Updates rot. GET /api/system/ablaeufe; Panel
„Zeitpläne“ unter den Diensten. Tests fragen Gitea nie echt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 16:01:21 +02:00
co-authored by Claude Opus 5.5
parent 4b57a12257
commit 34360c9644
9 changed files with 492 additions and 40 deletions
@@ -0,0 +1,76 @@
import { cn } from "cn"
import { Chip } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel"
import { useAblaeufe } from "@/lib/abfragen"
import type { Ablauf } from "@/lib/typen"
import { flugplanZeit, vor } from "@/lib/zeit"
// Die geplanten Abläufe der Box mit letztem und nächstem Lauf — der Totmannschalter (Ausbauplan Welle 1, seit
// 25.09.2026). Was länger schweigt als erlaubt, steht oben und rot; der Wächter macht daraus einen Hinweis.
const STATUS: Record<Ablauf["status"], { wort: string; punkt: string; text: string }> = {
ok: { wort: "pünktlich", punkt: "bg-gruen", text: "text-text-3" },
ueberfaellig: { wort: "überfällig", punkt: "bg-rot shadow-[0_0_8px_var(--rot)]", text: "text-rot-text" },
gestoppt: { wort: "läuft nicht", punkt: "bg-rot shadow-[0_0_8px_var(--rot)]", text: "text-rot-text" },
aus: { wort: "abgeschaltet", punkt: "bg-text-3", text: "text-text-3" },
}
const SPALTEN = "md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,1fr)_110px]"
const zeit = (s: number) => flugplanZeit(new Date(s * 1000).toISOString())
function Zeile({ a }: { a: Ablauf }) {
const s = STATUS[a.status] ?? STATUS.ok
return (
<div role="row" className={cn("grid grid-cols-2 gap-x-4 gap-y-1.5 border-t border-white/[0.06] py-3 md:items-center", SPALTEN)}>
<div role="rowheader" className="col-span-2 flex min-w-0 items-start gap-2.5 md:col-span-1">
<span aria-hidden className={cn("mt-1.5 size-2 shrink-0 rounded-full", s.punkt)} />
<span className="flex min-w-0 flex-col">
<span className="text-[15px] font-semibold break-words">{a.name}</span>
<span className="text-[13px] text-text-3">{a.takt}</span>
</span>
</div>
<span role="cell" className="text-sm text-text-2">
<span className="text-xs text-text-3 md:hidden">Zuletzt </span>
{a.letzter ? <span className="ziffern">{vor(a.letzter)}</span> : <span className="text-text-3">–</span>}
</span>
<span role="cell" className="text-sm text-text-2">
<span className="text-xs text-text-3 md:hidden">Nächster </span>
{a.naechster ? <span className="ziffern">{zeit(a.naechster).toLowerCase()}</span> : <span className="text-text-3">–</span>}
</span>
<span role="cell" className={cn("etikett text-[10.5px] tracking-[0.12em]", s.text)}>{s.wort}</span>
{a.text && a.status !== "ok" && (
<p className={cn("col-span-2 m-0 text-sm md:col-span-4 md:pl-[18px]", s.text)}>{a.text}</p>
)}
</div>
)
}
/** „Zeitpläne“: je Ablauf Takt, letzter und nächster Lauf, Zustand. */
export function Zeitplaene() {
const abfrage = useAblaeufe()
const liste = abfrage.data?.ablaeufe ?? []
const probleme = liste.filter((a) => a.status === "ueberfaellig" || a.status === "gestoppt").length
return (
<Panel titel="Zeitpläne" rechts={abfrage.data ? <Chip art={probleme ? "rot" : "gruen"}>{probleme ? `${probleme} überfällig` : "alle pünktlich"}</Chip> : undefined}>
<p className="-mt-2 text-[15px] text-text-2">
Was die Box regelmäßig von selbst tut. Bleibt ein Lauf länger aus als erwartet, steht er hier oben und der
Wächter meldet es — ein Stillstand wie im September fällt so am ersten Morgen auf.
</p>
{abfrage.isPending ? (
<p className="text-sm text-text-3">Wird gelesen …</p>
) : abfrage.error ? (
<p role="alert" className="text-sm text-rot-text">Die Zeitpläne lassen sich gerade nicht lesen: {abfrage.error.message}</p>
) : (
<div role="table" aria-label="Zeitpläne" className="flex flex-col">
<div role="row" className={cn("etikett hidden gap-x-4 pb-2.5 text-[10.5px] tracking-[0.14em] text-text-3 md:grid", SPALTEN)}>
<span role="columnheader">Ablauf</span>
<span role="columnheader">Zuletzt</span>
<span role="columnheader">Nächster</span>
<span role="columnheader">Zustand</span>
</div>
{liste.map((a) => <Zeile key={a.id} a={a} />)}
</div>
)}
</Panel>
)
}
+9 -1
View File
@@ -9,7 +9,7 @@ import { fehlerText, laeuftNoch } from "./anzeige"
import { melden } from "./meldungen"
import { stromVerbunden } from "./strom"
import type {
AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
Ablauf, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
ZielStand, Zeitraum,
@@ -146,6 +146,14 @@ export interface DiensteStand {
kerne?: number | null
}
/** Die geplanten Abläufe der Box — der Totmannschalter (seit 25.09.2026). */
export const useAblaeufe = () =>
useQuery({
queryKey: ["ablaeufe"],
queryFn: () => api<{ ablaeufe: Ablauf[] }>("/api/system/ablaeufe"),
refetchInterval: 60_000,
})
/** Die Dienste-Seite misst die CPU zwischen zwei Abfragen — deshalb ein fester, kurzer Takt, solange sie offen ist. */
export const useDienste = () =>
useQuery({
+15
View File
@@ -638,3 +638,18 @@ export interface HomelabSnapshots {
/** Das Aufräumen arbeitet gerade seine Liste ab. */
aufraeumen_laeuft?: boolean
}
// --- Totmannschalter (GET /api/system/ablaeufe, seit 25.09.2026) ---------------------------------------------------
/** Ein geplanter Ablauf der Box: systemd-Timer, Hermes-Cron-Job oder der Runner der Gitea-Ampel. */
export interface Ablauf {
id: string
name: string
art: "zeitplan" | "hermes" | "ampel"
takt: string
/** Unix-Sekunden, null = noch nie bzw. unbekannt */
letzter: number | null
naechster: number | null
status: "ok" | "ueberfaellig" | "gestoppt" | "aus"
text: string | null
}
+41 -37
View File
@@ -5,6 +5,7 @@ import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { Panel } from "@/components/cockpit/Panel"
import { Zeitplaene } from "@/components/cockpit/Zeitplaene"
import { Button } from "@/components/ui/button"
import { type Dienst, type DiensteStand, useDienste, useProtokoll } from "@/lib/abfragen"
import { fehlerText } from "@/lib/anzeige"
@@ -276,44 +277,47 @@ export function DiensteSeite() {
const kerne = stand.kerne ? `allen ${stand.kerne} Kernen` : "allen Kernen"
return (
<Panel titel="Dienste" rechts={<Lage dienste={stand.services} stand={dienste.dataUpdatedAt} />}>
<p className="-mt-2 text-[15px] text-text-2">
Die Dienste der KI-Box: ob sie laufen, was sie gerade an CPU und Speicher brauchen, seit wann sie laufen und was
sie zuletzt ins Protokoll geschrieben haben. Probleme stehen oben.
</p>
{stand.services.length === 0 ? (
<p className="text-[15px] text-text-2">Keine Dienste gefunden.</p>
) : (
<Tabelle
stand={stand}
gewaehlt={gewaehlt}
startet={startet}
onProtokoll={(unit) => setGewaehlt(gewaehlt === unit ? null : unit)}
onFrage={(d) => {
setFrage(d)
setFragt(true)
<>
<Panel titel="Dienste" rechts={<Lage dienste={stand.services} stand={dienste.dataUpdatedAt} />}>
<p className="-mt-2 text-[15px] text-text-2">
Die Dienste der KI-Box: ob sie laufen, was sie gerade an CPU und Speicher brauchen, seit wann sie laufen und was
sie zuletzt ins Protokoll geschrieben haben. Probleme stehen oben.
</p>
{stand.services.length === 0 ? (
<p className="text-[15px] text-text-2">Keine Dienste gefunden.</p>
) : (
<Tabelle
stand={stand}
gewaehlt={gewaehlt}
startet={startet}
onProtokoll={(unit) => setGewaehlt(gewaehlt === unit ? null : unit)}
onFrage={(d) => {
setFrage(d)
setFragt(true)
}}
/>
)}
<p className="m-0 text-xs leading-relaxed text-text-3">
CPU: Anteil an {kerne} der Box, gemessen zwischen zwei Abfragen (alle zehn Sekunden). Speicher: was systemd dem
Dienst zurechnet; der Balken zeigt den Anteil an {speicher} der Box. Bei der Engine zählen die geladenen Modelle
mit — sie liegen im Grafikspeicher, den systemd keinem Dienst zurechnet. Neustarts: wie oft systemd den Dienst
nach einem Absturz von selbst wieder gestartet hat. „Schläft“ heißt: mit Absicht abgeschaltet, kein Fehler.
</p>
<Bestaetigen
offen={fragt && !!rueckfrage}
titel={rueckfrage?.titel ?? ""}
text={rueckfrage?.text ?? ""}
knopf={rueckfrage?.knopf ?? ""}
gefahr={rueckfrage?.knopf === "Neu starten"}
gesperrt={startet !== null}
onJa={() => {
setFragt(false)
if (frage) void starten(frage)
}}
onNein={() => setFragt(false)}
/>
)}
<p className="m-0 text-xs leading-relaxed text-text-3">
CPU: Anteil an {kerne} der Box, gemessen zwischen zwei Abfragen (alle zehn Sekunden). Speicher: was systemd dem
Dienst zurechnet; der Balken zeigt den Anteil an {speicher} der Box. Bei der Engine zählen die geladenen Modelle
mit — sie liegen im Grafikspeicher, den systemd keinem Dienst zurechnet. Neustarts: wie oft systemd den Dienst
nach einem Absturz von selbst wieder gestartet hat. „Schläft“ heißt: mit Absicht abgeschaltet, kein Fehler.
</p>
<Bestaetigen
offen={fragt && !!rueckfrage}
titel={rueckfrage?.titel ?? ""}
text={rueckfrage?.text ?? ""}
knopf={rueckfrage?.knopf ?? ""}
gefahr={rueckfrage?.knopf === "Neu starten"}
gesperrt={startet !== null}
onJa={() => {
setFragt(false)
if (frage) void starten(frage)
}}
onNein={() => setFragt(false)}
/>
</Panel>
</Panel>
<Zeitplaene />
</>
)
}