umbau(boxwart): neue Oberflaeche im Cockpit-Stil (Richtung A), Werkzeuge angehoben

Drei Seiten statt zehn: Start, Updates, Modelle — am PC mit Kopfnavigation, am Handy
mit Leiste unten. Umsetzung von Mockup A („Cockpit“), vom User am 23.09. gewaehlt.

- Start: Hauptwarnleuchte + 8 Warnlampen, Rundinstrumente mit Live-Werten aus dem
  Strom (Speicher, Temperatur, Platte) und Laufzeit-Zaehlwerk, Checkliste der
  Waechter-Hinweise mit ihren Knoepfen, Flugplan (heute gelaufen / geplant), Radar-Kasten.
- Updates: Bausteine mit „Laeuft → Neu“ und Zusammenfassung, laufende Auftraege,
  Verlauf der Sonntagslaeufe (neu: GET /api/updates/verlauf), Sicherungen samt
  Zurueckspielen mit Rueckfrage.
- Modelle: Speicherbalken, Rollen Hirn/Coder/Dritte Rolle, wer die Modelle nutzt
  (7 Tage + 24 h je Stunde), Modell-Radar, weitere Eintraege, Modelle selbst suchen.
- Schubladen: Dienste mit Protokoll und Neustart, Einstellungen (HF-Zugang), Hermes-Link.
- Werkzeuge: Vite 8, React 19.3 mit React Compiler 1.0 (Babel), vitest 5, Tailwind 4.3,
  shadcn 4 (Radix) fuer Dialog/Schublade/Knopf, Schriften Barlow/Barlow Condensed/
  JetBrains Mono. Entfernt: recharts, cmdk, Kraftgraph, zustand, Inter, Space Grotesk.
- Startbuendel 115 KB gzip (Budget 140); Updates/Modelle/Schubladen laden bei Bedarf.
- 16 Oberflaechen-Tests (Instrument-Bogen, Hauptleuchte, Zeitformate, Versionen).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-23 21:39:07 +02:00
co-authored by Claude Opus 5.5
parent 3d2c9549fb
commit 907289d7dc
223 changed files with 8572 additions and 15393 deletions
+225
View File
@@ -0,0 +1,225 @@
import { useState } from "react"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
import { Panel } from "@/components/cockpit/Panel"
import { Button } from "@/components/ui/button"
import {
useJetztSichern, useJobs, useSicherungen, useStart, useUpdateDetails, useUpdateStarten, useUpdateVerlauf,
useZurueckspielen,
} from "@/lib/abfragen"
import { versionen } from "@/lib/anzeige"
import type { Job, Sicherung } from "@/lib/typen"
import { flugplanZeit } from "@/lib/zeit"
const BAUSTEINE = [
{ id: "os", name: "Betriebssystem" },
{ id: "engine", name: "Motor (llama.cpp)" },
{ id: "swap", name: "llama-swap" },
{ id: "hermes", name: "Hermes" },
] as const
function BausteinZeile({ id, name, hatUpdate, onStart, laeuftGerade }: {
id: string
name: string
hatUpdate: boolean
onStart: () => void
laeuftGerade: boolean
}) {
const { data, isPending } = useUpdateDetails(id)
const v = versionen(id, data)
const neu = hatUpdate ? v.neu ?? "neu" : null
const was = !hatUpdate
? "Aktuell."
: data?.summary?.trim() || (id === "os" && data?.count ? "Paketliste unten in den Details." : isPending ? "Wird gelesen …" : "")
return (
<tr className="border-t border-linie align-top">
<th scope="row" className="py-3.5 pr-4 text-left font-sans text-[15px] font-semibold">{name}</th>
<td className="ziffern py-3.5 pr-4 text-sm text-text-2">{v.laeuft}</td>
<td className="ziffern py-3.5 pr-4 text-sm">{neu ? <span className="text-cyan">{neu}</span> : <span className="text-text-3"></span>}</td>
<td className="py-3.5 pr-4 text-sm leading-relaxed text-text-2">
<span className="line-clamp-3 whitespace-pre-line">{was}</span>
</td>
<td className="py-2.5 text-right">
{hatUpdate && (
<Button variant="outline" size="sm" onClick={onStart} disabled={laeuftGerade}>
Aktualisieren
</Button>
)}
</td>
</tr>
)
}
function JobKasten({ job }: { job: Job & { group?: string; log?: string[] } }) {
const art = job.state === "done" ? "gruen" : job.state === "failed" ? "rot" : "cyan"
const text = job.state === "done" ? "Fertig" : job.state === "failed" ? "Fehler" : job.state === "queued" ? "Wartet" : "Läuft"
return (
<div className="flex flex-col gap-2 rounded-xl border border-linie bg-erhaben/50 p-3">
<div className="flex items-center justify-between gap-3">
<span className="text-[15px] font-semibold">{job.label}</span>
<Chip art={art}>{text}</Chip>
</div>
{job.log && job.log.length > 0 && (
<pre className="ziffern max-h-40 overflow-auto rounded-md bg-[#0b0d0f] p-2 text-xs whitespace-pre-wrap text-text-2">
{job.log.slice(-8).join("\n")}
</pre>
)}
</div>
)
}
function sicherungsZeit(s: Sicherung) {
return s.mtime ? flugplanZeit(new Date(s.mtime * 1000).toISOString()) : s.snapshot
}
export function UpdatesSeite() {
const start = useStart()
const jobs = useJobs()
const verlauf = useUpdateVerlauf()
const sicherungen = useSicherungen()
const updateStarten = useUpdateStarten()
const sichern = useJetztSichern()
const zurueck = useZurueckspielen()
const [frage, setFrage] = useState<null | { art: "alle" } | { art: "zurueck"; datei: string; zeit: string }>(null)
const mitUpdate = new Set(start.data?.updates.bausteine.map((b) => b.id) ?? [])
const wartungsJobs = (jobs.data?.jobs ?? []).filter((j) => (j as { group?: string }).group === "maintenance")
const laeuft = wartungsJobs.some((j) => j.state === "running" || j.state === "queued")
return (
<>
<Panel
titel="Updates"
rechts={
<div className="flex flex-wrap gap-2.5">
<Button variant="outline" onClick={() => updateStarten.mutate("pruefen")} disabled={updateStarten.isPending}>
Nach Neuem suchen
</Button>
<Button onClick={() => setFrage({ art: "alle" })} disabled={laeuft || mitUpdate.size === 0}>
Alles jetzt aktualisieren
</Button>
</div>
}
>
<p className="-mt-2 text-[15px] text-text-2">
Jeden Sonntag um 04:30 automatisch. Vorher wird gesichert, danach geprüft.
</p>
{start.isPending ? (
<Zustandsfeld text="Updates werden gelesen …" />
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[680px] border-collapse">
<thead>
<tr className="schild text-left text-xs text-text-3">
<th scope="col" className="pb-2 font-semibold">Baustein</th>
<th scope="col" className="pb-2 font-semibold">Läuft</th>
<th scope="col" className="pb-2 font-semibold">Neu</th>
<th scope="col" className="pb-2 font-semibold">Was sich ändert</th>
<th scope="col" className="pb-2 text-right font-semibold"><span className="sr-only">Aktion</span></th>
</tr>
</thead>
<tbody>
{BAUSTEINE.map((b) => (
<BausteinZeile
key={b.id}
id={b.id}
name={b.name}
hatUpdate={mitUpdate.has(b.id)}
laeuftGerade={laeuft}
onStart={() => updateStarten.mutate(b.id)}
/>
))}
</tbody>
</table>
</div>
)}
{wartungsJobs.length > 0 && (
<div className="flex flex-col gap-2.5">
<h3 className="schild text-sm text-text-3">Laufende und letzte Aufträge</h3>
{wartungsJobs.slice(-3).reverse().map((j) => <JobKasten key={j.id} job={j} />)}
</div>
)}
</Panel>
<div className="grid gap-5 md:gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)]">
<Panel titel="Verlauf">
{verlauf.data?.laeufe.length ? (
<ol className="m-0 flex list-none flex-col p-0">
{verlauf.data.laeufe.map((l) => {
const ok = l.status === "completed"
const art = ok ? "gruen" : l.status === "failed" ? "rot" : "cyan"
return (
<li key={`${l.start}-${l.job}`} className="grid grid-cols-[112px_minmax(0,1fr)_auto] items-start gap-3 border-t border-linie py-3">
<span className="ziffern text-sm text-text-3">{l.start ? flugplanZeit(l.start) : ""}</span>
<span className="min-w-0 text-[15px]">
{l.job}
{l.fehler && <span className="block text-sm text-rot-text">{l.fehler}</span>}
</span>
<Chip art={art}>{ok ? "Durchgelaufen" : l.status === "failed" ? "Fehlgeschlagen" : "Läuft"}</Chip>
</li>
)
})}
</ol>
) : (
<p className="text-[15px] text-text-2">
{verlauf.isPending ? "Wird gelesen …" : "Noch keine Update-Läufe aufgezeichnet."}
</p>
)}
</Panel>
<Panel
titel="Sicherungen"
rechts={
<Button variant="outline" size="sm" onClick={() => sichern.mutate(undefined)} disabled={sichern.isPending}>
Jetzt sichern
</Button>
}
>
<p className="-mt-2 text-[15px] text-text-2">Täglich um 03:35, mit Kopie auf einem zweiten Gerät.</p>
<ul className="m-0 flex list-none flex-col p-0">
{(sicherungen.data?.backups ?? []).slice(0, 5).map((s) => (
<li key={s.file} className="flex items-center justify-between gap-3 border-t border-linie py-2.5">
<span className="ziffern text-sm">{sicherungsZeit(s)}</span>
<span className="flex items-center gap-3">
<span className="ziffern text-xs text-text-3">{s.size_mb.toLocaleString("de-DE")} MB</span>
<Button
variant="ghost"
size="sm"
disabled={!sicherungen.data?.available}
onClick={() => setFrage({ art: "zurueck", datei: s.file, zeit: sicherungsZeit(s) })}
>
Zurückspielen
</Button>
</span>
</li>
))}
</ul>
</Panel>
</div>
<Bestaetigen
offen={frage?.art === "alle"}
titel="Alles jetzt aktualisieren?"
text="Betriebssystem, Motor, llama-swap und Hermes werden nacheinander aktualisiert. Vorher wird gesichert, danach geprüft. Lucy ist dabei ein paar Minuten nicht erreichbar."
knopf="Aktualisieren"
onNein={() => setFrage(null)}
onJa={() => {
updateStarten.mutate("alle")
setFrage(null)
}}
/>
<Bestaetigen
offen={frage?.art === "zurueck"}
gefahr
titel="Sicherung zurückspielen?"
text={`Die Einstellungen von Hermes und llama-swap werden auf den Stand von ${frage?.art === "zurueck" ? frage.zeit : ""} gesetzt. Vorher wird der jetzige Stand gesichert. Die Dienste starten danach neu.`}
knopf="Zurückspielen"
onNein={() => setFrage(null)}
onJa={() => {
if (frage?.art === "zurueck") zurueck.mutate(frage.datei)
setFrage(null)
}}
/>
</>
)
}