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>
)
}