Merge wartung/phase1d-frontend: Oberflaeche Box-Wart 1.0 (Updates, Modelle, Start, Strom, 320 px, Zustaende)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+11
-15
@@ -31,22 +31,18 @@ export default tseslint.config(
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
|
||||
// Die Regeln aus der React-Compiler-Generation (Plugin v7) finden im Bestand
|
||||
// 18 echte Stellen: setState direkt im Effekt, Ref-Zugriff waehrend des Renderns,
|
||||
// Komponenten im Render erzeugt. Das sind keine Fehlalarme — aber sie quer durch
|
||||
// 10 000 Zeilen zu beheben ist die Arbeit von Etappe P5 (Feature-Slices), nicht
|
||||
// von P1. Als Fehler waere das Gate ab Tag eins rot, und ein dauerhaft rotes Gate
|
||||
// ist kein Gate mehr ("Rot ist ein Ergebnis, kein Aergernis").
|
||||
// Darum: sichtbare Warn-Liste statt stumm abgeschaltet. Beim Zerlegen eines
|
||||
// Bereichs wird sein Anteil mit erledigt; ist der Zaehler bei 0, kommen sie
|
||||
// hier auf "error" und der Rueckfall ist verbaut.
|
||||
"react-hooks/set-state-in-effect": "warn",
|
||||
"react-hooks/refs": "warn",
|
||||
"react-hooks/immutability": "warn",
|
||||
"react-hooks/static-components": "warn",
|
||||
// Die Regeln aus der React-Compiler-Generation (Plugin v7): setState direkt im Effekt,
|
||||
// Ref-Zugriff waehrend des Renderns, Komponenten im Render erzeugt. Im alten Bestand
|
||||
// (10 000 Zeilen) fanden sie 18 Stellen und liefen darum als Warnung, mit der Abmachung:
|
||||
// Ist der Zaehler bei 0, kommen sie auf "error" und der Rueckfall ist verbaut.
|
||||
// Mit dem Box-Wart-Umbau (09/2026) stand der Zaehler bei 0 — also jetzt "error".
|
||||
"react-hooks/set-state-in-effect": "error",
|
||||
"react-hooks/refs": "error",
|
||||
"react-hooks/immutability": "error",
|
||||
"react-hooks/static-components": "error",
|
||||
|
||||
// `any` ist in diesem Projekt an einigen Stellen bewusst gesetzt (Recharts-Rueckrufe,
|
||||
// Fehlerobjekte in catch). Warnen statt blockieren — sonst wird die Regel abgeschaltet.
|
||||
// `any` kommt im Bestand nicht mehr vor (Stand 09/2026). Warnen statt blockieren, damit
|
||||
// eine bewusst gesetzte Ausnahme (z. B. ein Fehlerobjekt in catch) das Gate nicht rot macht.
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
|
||||
// Ungenutzte Variablen sind fast immer ein Rest von etwas Halbfertigem.
|
||||
|
||||
Generated
+321
-17
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,6 @@
|
||||
"radix-ui": "^1.6.7",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"shadcn": "^4.21.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -34,7 +33,6 @@
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@testing-library/user-event": "^14.6.6",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/react": "^19.2.18",
|
||||
@@ -47,6 +45,7 @@
|
||||
"globals": "^17.11.0",
|
||||
"jsdom": "^30.0.1",
|
||||
"prettier": "^3.9.6",
|
||||
"shadcn": "^4.21.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript-eslint": "^8.68.0",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { ExternalLink, ScrollText, Settings } from "lucide-react"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
|
||||
/** Handy-Menü „Mehr“: Hermes, Dienste, Einstellungen. Eigene Datei, damit es erst beim Öffnen lädt. */
|
||||
export function MehrMenue({ verbindung, onDienste, onEinstellungen, onSchliessen }: {
|
||||
verbindung: ReactNode
|
||||
/**
|
||||
* Handy-Menü „Mehr“: Hermes, Dienste, Einstellungen. Eigene Datei, damit es erst beim Öffnen lädt.
|
||||
* Die Verbindungsanzeige steht seit 09/2026 auf jeder Breite in der Kopfzeile, nicht mehr hier.
|
||||
*/
|
||||
export function MehrMenue({ onDienste, onEinstellungen, onSchliessen }: {
|
||||
onDienste: () => void
|
||||
onEinstellungen: () => void
|
||||
onSchliessen: () => void
|
||||
@@ -17,7 +18,6 @@ export function MehrMenue({ verbindung, onDienste, onEinstellungen, onSchliessen
|
||||
<SheetDescription className="sr-only">Weitere Bereiche</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col gap-2 px-4">
|
||||
{verbindung}
|
||||
<a href="/hermes-ui/" target="_blank" rel="noopener" className="flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base">
|
||||
<ExternalLink className="size-5" aria-hidden /> Hermes-Dashboard öffnen
|
||||
</a>
|
||||
|
||||
@@ -8,13 +8,13 @@ const FARBE = {
|
||||
info: "border-cyan-rand bg-cyan-grund text-cyan-text",
|
||||
}
|
||||
|
||||
/** Rückmeldungen unten rechts (am Handy über der Tab-Leiste), für Screenreader angesagt. */
|
||||
/** Rückmeldungen unten rechts (unter 1024 px über der Tab-Leiste), für Screenreader angesagt. */
|
||||
export function Meldungen() {
|
||||
const liste = useMeldungen()
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="pointer-events-none fixed right-4 bottom-24 left-4 z-50 flex flex-col items-end gap-2 md:bottom-6 md:left-auto md:w-[420px]"
|
||||
className="pointer-events-none fixed right-4 bottom-24 left-4 z-50 flex flex-col items-end gap-2 md:left-auto md:w-[420px] lg:bottom-6"
|
||||
>
|
||||
{liste.map((m) => (
|
||||
<div
|
||||
|
||||
+21
-14
@@ -34,15 +34,23 @@ function Uhr() {
|
||||
)
|
||||
}
|
||||
|
||||
/** „Box online“ / „Getrennt“ — auf jeder Breite sichtbar, am Handy etwas kleiner. */
|
||||
function Verbindung() {
|
||||
const lage = useStromLage()
|
||||
const live = lage === "live"
|
||||
return (
|
||||
<div className="schild flex items-center gap-2 text-[15px] text-text-3" aria-live="polite">
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
"schild flex shrink-0 items-center gap-1.5 text-[13px] tracking-[0.1em] sm:gap-2 sm:text-[15px] sm:tracking-[0.16em]",
|
||||
live ? "text-text-3" : "text-bernstein",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn("size-2 rounded-full", live ? "bg-gruen shadow-[0_0_8px_rgba(61,220,138,0.7)]" : "bg-bernstein")}
|
||||
aria-hidden
|
||||
className={cn("size-2 shrink-0 rounded-full", live ? "bg-gruen shadow-[0_0_8px_rgba(61,220,138,0.7)]" : "bg-bernstein")}
|
||||
/>
|
||||
<span>{live ? "Box online" : lage === "verbinde" ? "Verbinde" : "Getrennt"}</span>
|
||||
<span className="whitespace-nowrap">{live ? "Box online" : lage === "verbinde" ? "Verbinde" : "Getrennt"}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -69,10 +77,12 @@ export function Shell() {
|
||||
const zu = () => setSchublade(null)
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-dvh w-full max-w-[1440px] flex-col gap-5 px-4 pt-4 pb-28 sm:px-6 md:gap-6 md:px-10 md:pt-8 md:pb-10">
|
||||
<header className="flex items-center justify-between gap-4 md:gap-6">
|
||||
<div className="mx-auto flex min-h-dvh w-full max-w-[1440px] flex-col gap-5 px-4 pt-4 pb-28 sm:px-6 md:gap-6 md:px-10 md:pt-8 lg:pb-10">
|
||||
{/* Obere Navigation erst ab 1024 px: darunter passen Logo, Navigation, drei Knöpfe, Verbindung
|
||||
und Uhr nicht nebeneinander — Tablets bekommen die Leiste unten wie das Handy. */}
|
||||
<header className="flex items-center justify-between gap-4 lg:gap-6">
|
||||
<Marke />
|
||||
<nav aria-label="Hauptnavigation" className="hidden gap-1 rounded-xl border border-linie bg-[#121518] p-1 md:flex">
|
||||
<nav aria-label="Hauptnavigation" className="hidden gap-1 rounded-xl border border-linie bg-[#121518] p-1 lg:flex">
|
||||
{NAV.map(({ to, label }) => (
|
||||
<Link
|
||||
key={to}
|
||||
@@ -85,11 +95,9 @@ export function Shell() {
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="flex items-center gap-4 md:gap-5">
|
||||
<div className="hidden lg:block">
|
||||
<Verbindung />
|
||||
</div>
|
||||
<div className="hidden items-center gap-1 md:flex">
|
||||
<div className="flex items-center gap-3 lg:gap-5">
|
||||
<Verbindung />
|
||||
<div className="hidden items-center gap-1 lg:flex">
|
||||
<a
|
||||
href="/hermes-ui/"
|
||||
target="_blank"
|
||||
@@ -127,10 +135,10 @@ export function Shell() {
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Handy: Leiste unten, mit Abstand zur Wisch-Leiste des Telefons. */}
|
||||
{/* Handy und Tablet: Leiste unten, mit Abstand zur Wisch-Leiste des Telefons. */}
|
||||
<nav
|
||||
aria-label="Hauptnavigation"
|
||||
className="fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 gap-1 border-t border-linie bg-[#0e1119]/95 px-2 pt-2 pb-[calc(0.75rem+env(safe-area-inset-bottom))] backdrop-blur md:hidden"
|
||||
className="fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 gap-1 border-t border-linie bg-[#0e1119]/95 px-2 pt-2 pb-[calc(0.75rem+env(safe-area-inset-bottom))] backdrop-blur lg:hidden"
|
||||
>
|
||||
{NAV.map(({ to, label, Icon }) => (
|
||||
<Link
|
||||
@@ -157,7 +165,6 @@ export function Shell() {
|
||||
<Suspense fallback={null}>
|
||||
{schublade === "mehr" && (
|
||||
<MehrMenue
|
||||
verbindung={<Verbindung />}
|
||||
onDienste={() => setSchublade("dienste")}
|
||||
onEinstellungen={() => setSchublade("einstellungen")}
|
||||
onSchliessen={zu}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useJobAbbrechen, useJobs } from "@/lib/abfragen"
|
||||
import { jobTitel, sichtbareJobs } from "@/lib/wartung"
|
||||
import { JobKarte } from "./JobKarte"
|
||||
import { Panel } from "./Panel"
|
||||
|
||||
/**
|
||||
* Laufende und zuletzt beendete Aufträge — egal, woher sie kommen (Update, Modell-Download, Suche
|
||||
* nach Neuem). Solange nichts läuft und nichts frisch beendet ist, zeigt sie gar nichts.
|
||||
*/
|
||||
export function Auftraege({ alsPanel = false }: { alsPanel?: boolean }) {
|
||||
const jobs = useJobs()
|
||||
const abbrechen = useJobAbbrechen()
|
||||
const liste = sichtbareJobs(jobs.data?.jobs ?? [])
|
||||
|
||||
let inhalt
|
||||
if (liste.length > 0) {
|
||||
inhalt = (
|
||||
<div className="grid items-start gap-2.5 lg:grid-cols-2">
|
||||
{liste.map((j) => (
|
||||
<JobKarte
|
||||
key={j.id}
|
||||
job={j}
|
||||
ebene={alsPanel ? 3 : 4}
|
||||
abbrechenGesperrt={abbrechen.isPending}
|
||||
onAbbrechen={(job) => abbrechen.mutate({ id: job.id, titel: jobTitel(job) })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
} else if (jobs.isError && !jobs.data) {
|
||||
inhalt = <p role="alert" className="text-sm text-rot-text">Die Aufträge lassen sich gerade nicht lesen: {jobs.error.message}</p>
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
|
||||
if (alsPanel) return <Panel titel="Aufträge">{inhalt}</Panel>
|
||||
return (
|
||||
<section aria-label="Aufträge" className="flex flex-col gap-2.5">
|
||||
<h3 className="schild m-0 text-sm text-text-3">Aufträge</h3>
|
||||
{inhalt}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -4,30 +4,46 @@ import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
|
||||
/** Rückfrage vor Schritten, die die Box umbauen (alles aktualisieren, zurückspielen, neu starten). */
|
||||
export function Bestaetigen({ offen, titel, text, knopf, gefahr, onJa, onNein, children }: {
|
||||
/**
|
||||
* Rückfrage vor Schritten, die die Box verändern (Updates, Freigeben, Zurückspielen, Neustarts,
|
||||
* Installieren, Abbrechen). `gesperrt` hält den Ja-Knopf fest, solange die Aktion schon unterwegs
|
||||
* ist — ein Doppelklick startet sie so nicht zweimal.
|
||||
*/
|
||||
export function Bestaetigen({ offen, titel, text, knopf, neinKnopf = "Abbrechen", gefahr, gesperrt, onJa, onNein, children }: {
|
||||
offen: boolean
|
||||
titel: string
|
||||
text: string
|
||||
knopf: string
|
||||
neinKnopf?: string
|
||||
gefahr?: boolean
|
||||
gesperrt?: boolean
|
||||
onJa: () => void
|
||||
onNein: () => void
|
||||
children?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={offen} onOpenChange={(o) => !o && onNein()}>
|
||||
<DialogContent className="border-linie bg-panel sm:max-w-lg">
|
||||
{/* Nie höher als der Bildschirm: Auf kleinen Handys (320 × 568) lagen die Knöpfe sonst außerhalb
|
||||
und ließen sich nicht erreichen. Der Text scrollt, die Knöpfe bleiben unten stehen — bündig,
|
||||
darum ohne Bodenpolster (sticky rechnet das Polster ein, darunter schien sonst Text durch). */}
|
||||
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto border-linie bg-panel pb-0 sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="schild text-xl">{titel}</DialogTitle>
|
||||
<DialogTitle className="schild pr-10 text-xl break-words">{titel}</DialogTitle>
|
||||
<DialogDescription className="text-[15px] leading-relaxed text-text-2">{text}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{children}
|
||||
<DialogFooter className="gap-2 sm:gap-2">
|
||||
<DialogFooter className="sticky bottom-0 mb-0 gap-2 bg-erhaben sm:gap-2">
|
||||
<Button variant="outline" onClick={onNein}>
|
||||
Abbrechen
|
||||
{neinKnopf}
|
||||
</Button>
|
||||
<Button variant={gefahr ? "gefahr" : "default"} onClick={onJa}>
|
||||
<Button
|
||||
variant={gefahr ? "gefahr" : "default"}
|
||||
disabled={gesperrt}
|
||||
aria-busy={gesperrt || undefined}
|
||||
onClick={() => {
|
||||
if (!gesperrt) onJa()
|
||||
}}
|
||||
>
|
||||
{knopf}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -2,31 +2,43 @@ import { lazy, Suspense, useState } from "react"
|
||||
import { cn } from "cn"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useHinweisAktion } from "@/lib/abfragen"
|
||||
import { hinweisRueckfrage } from "@/lib/anzeige"
|
||||
import { post } from "@/lib/api"
|
||||
import { melden } from "@/lib/meldungen"
|
||||
import type { AktionsErgebnis, Hinweis, VerlaufEintrag } from "@/lib/typen"
|
||||
import type { AktionsErgebnis, Hinweis, HinweisAktion, VerlaufEintrag } from "@/lib/typen"
|
||||
import { seit, vor } from "@/lib/zeit"
|
||||
|
||||
// Erst laden, wenn jemand ein Protokoll öffnet (Radix-Dialog gehört nicht ins Start-Bündel).
|
||||
// Erst laden, wenn jemand ein Protokoll öffnet oder eine Rückfrage kommt — die Radix-Dialog-Kette
|
||||
// gehört nicht ins Start-Bündel.
|
||||
const Protokollfenster = lazy(() => import("./Protokollfenster").then((m) => ({ default: m.Protokollfenster })))
|
||||
const Bestaetigen = lazy(() => import("./Bestaetigen").then((m) => ({ default: m.Bestaetigen })))
|
||||
|
||||
/** Ein offener Punkt: Titel · Leitpunkte · PRÜFEN/JETZT, darunter Text und Knöpfe. */
|
||||
function Punkt({ h }: { h: Hinweis }) {
|
||||
const aktion = useHinweisAktion()
|
||||
const [protokoll, setProtokoll] = useState<{ titel: string; text: string } | null>(null)
|
||||
const [frage, setFrage] = useState<HinweisAktion | null>(null)
|
||||
const rot = h.stufe === "rot"
|
||||
const rueckfrage = frage ? hinweisRueckfrage(h, frage) : null
|
||||
|
||||
async function ausloesen(aid: string, label: string) {
|
||||
if (aid === "protokoll") {
|
||||
try {
|
||||
const e = await post<AktionsErgebnis>(`/api/hinweise/${encodeURIComponent(h.id)}/aktion/protokoll`)
|
||||
setProtokoll({ titel: h.titel, text: e.text || e.out || e.err || "Kein Protokoll vorhanden." })
|
||||
} catch (f) {
|
||||
melden("fehler", (f as Error).message)
|
||||
}
|
||||
return
|
||||
async function protokollZeigen() {
|
||||
try {
|
||||
const e = await post<AktionsErgebnis>(`/api/hinweise/${encodeURIComponent(h.id)}/aktion/protokoll`)
|
||||
setProtokoll({ titel: h.titel, text: e.text || e.out || e.err || "Kein Protokoll vorhanden." })
|
||||
} catch (f) {
|
||||
melden("fehler", (f as Error).message)
|
||||
}
|
||||
aktion.mutate({ hinweis: h.id, aktion: aid, label })
|
||||
}
|
||||
|
||||
function ausfuehren(a: HinweisAktion) {
|
||||
aktion.mutate({ hinweis: h.id, aktion: a.id, label: a.label })
|
||||
}
|
||||
|
||||
function klick(a: HinweisAktion) {
|
||||
if (a.id === "protokoll") protokollZeigen()
|
||||
// Was etwas neu startet, erneut laufen lässt oder freigibt, fragt vorher nach.
|
||||
else if (hinweisRueckfrage(h, a)) setFrage(a)
|
||||
else ausfuehren(a)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -50,7 +62,7 @@ function Punkt({ h }: { h: Hinweis }) {
|
||||
variant={i === 0 && a.id !== "protokoll" ? "default" : "outline"}
|
||||
size="sm"
|
||||
disabled={aktion.isPending}
|
||||
onClick={() => ausloesen(a.id, a.label)}
|
||||
onClick={() => klick(a)}
|
||||
>
|
||||
{a.label}
|
||||
</Button>
|
||||
@@ -62,6 +74,22 @@ function Punkt({ h }: { h: Hinweis }) {
|
||||
<Protokollfenster offen titel={protokoll.titel} text={protokoll.text} onSchliessen={() => setProtokoll(null)} />
|
||||
</Suspense>
|
||||
)}
|
||||
{frage && rueckfrage && (
|
||||
<Suspense fallback={null}>
|
||||
<Bestaetigen
|
||||
offen
|
||||
titel={rueckfrage.titel}
|
||||
text={rueckfrage.text}
|
||||
knopf={rueckfrage.knopf}
|
||||
gesperrt={aktion.isPending}
|
||||
onNein={() => setFrage(null)}
|
||||
onJa={() => {
|
||||
ausfuehren(frage)
|
||||
setFrage(null)
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,18 +8,21 @@ const STATUS: Record<FlugplanEintrag["status"], { text: string; farbe: string }>
|
||||
geplant: { text: "Geplant", farbe: "text-text-3" },
|
||||
}
|
||||
|
||||
// Die Liste richtet sich nach der Breite ihres Feldes, nicht des Bildschirms (@container): Schmal —
|
||||
// am Handy und in der rechten Spalte bei 1024 px — stehen Zeit und Zustand oben, der Text darunter
|
||||
// über die volle Breite. Vorher bekam der Text dort ~50 px und lief in den Zustand hinein.
|
||||
function Zeile({ e }: { e: FlugplanEintrag }) {
|
||||
const s = STATUS[e.status] ?? STATUS.geplant
|
||||
// Updates sind das Wichtigste im Plan — sie stehen in Cyan.
|
||||
const farbe = e.status === "geplant" && /update/i.test(e.titel) ? "text-cyan" : s.farbe
|
||||
return (
|
||||
<li className="grid grid-cols-[112px_minmax(0,1fr)_auto] items-center gap-3 border-t border-linie py-2.5">
|
||||
<span className="ziffern text-sm text-text-3">{flugplanZeit(e.zeit)}</span>
|
||||
<span className="min-w-0 text-base">
|
||||
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 border-t border-linie py-2.5 @sm:grid-cols-[112px_minmax(0,1fr)_auto]">
|
||||
<span className="ziffern col-start-1 row-start-1 text-sm text-text-3">{flugplanZeit(e.zeit)}</span>
|
||||
<span className="col-span-2 row-start-2 min-w-0 text-base break-words @sm:col-span-1 @sm:col-start-2 @sm:row-start-1">
|
||||
{e.titel}
|
||||
{e.text && <span className="text-text-3">, {e.text}</span>}
|
||||
</span>
|
||||
<span className={cn("schild text-[13px]", farbe)}>{s.text}</span>
|
||||
<span className={cn("schild col-start-2 row-start-1 justify-self-end text-[13px] @sm:col-start-3", farbe)}>{s.text}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -27,5 +30,5 @@ function Zeile({ e }: { e: FlugplanEintrag }) {
|
||||
export function Flugplan({ gelaufen, geplant }: { gelaufen: FlugplanEintrag[]; geplant: FlugplanEintrag[] }) {
|
||||
const alle = [...gelaufen, ...geplant.slice(0, 5)]
|
||||
if (alle.length === 0) return <p className="text-[15px] text-text-2">Heute ist noch nichts gelaufen, und nichts ist geplant.</p>
|
||||
return <ol className="m-0 flex list-none flex-col p-0">{alle.map((e) => <Zeile key={`${e.titel}-${e.zeit}`} e={e} />)}</ol>
|
||||
return <ol className="@container m-0 flex list-none flex-col p-0">{alle.map((e) => <Zeile key={`${e.titel}-${e.zeit}`} e={e} />)}</ol>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react"
|
||||
import { jobTitel, sichtbareJobs } from "@/lib/wartung"
|
||||
import type { Job } from "@/lib/typen"
|
||||
import { JobKarte } from "./JobKarte"
|
||||
|
||||
const JETZT = 1_790_000_000 // Unix-Sekunden, fester Bezugspunkt
|
||||
|
||||
function job(teil: Partial<Job>): Job {
|
||||
return {
|
||||
id: "j1", label: "Nach Updates suchen", state: "running", group: null,
|
||||
log: ["$ bash -c 'sudo apt-get update'"], returncode: null,
|
||||
started_at: JETZT - 120, finished_at: null, ...teil,
|
||||
}
|
||||
}
|
||||
|
||||
describe("JobKarte", () => {
|
||||
it("zeigt bei einem Download Fortschritt und fragt vor dem Abbrechen nach", () => {
|
||||
const onAbbrechen = vi.fn()
|
||||
render(
|
||||
<JobKarte
|
||||
job={job({
|
||||
label: "download unsloth/Qwen3.8-27B-GGUF", progress: 42,
|
||||
done_bytes: 12 * 1024 ** 3, total_bytes: 29 * 1024 ** 3, eta_s: 360,
|
||||
})}
|
||||
onAbbrechen={onAbbrechen}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText("Download unsloth/Qwen3.8-27B-GGUF")).toBeInTheDocument()
|
||||
expect(screen.getByRole("progressbar")).toHaveAttribute("aria-valuenow", "42")
|
||||
expect(screen.getByText("42 % · 12,0 GB von 29,0 GB · noch etwa 6 min")).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Abbrechen" }))
|
||||
expect(screen.getByRole("dialog", { name: "Auftrag abbrechen?" })).toBeInTheDocument()
|
||||
expect(onAbbrechen).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Ja, abbrechen" }))
|
||||
expect(onAbbrechen).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("nennt einen abgebrochenen Auftrag „Abgebrochen“ und nicht „Läuft“", () => {
|
||||
render(<JobKarte job={job({ state: "canceled", finished_at: JETZT })} onAbbrechen={() => {}} />)
|
||||
expect(screen.getByText("Abgebrochen")).toBeInTheDocument()
|
||||
expect(screen.queryByText("Läuft")).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole("button", { name: "Abbrechen" })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("bietet bei Updates und der Suche kein Abbrechen an und zeigt bei Fehlern die letzte Meldung", () => {
|
||||
const { rerender } = render(
|
||||
<JobKarte job={job({ label: "Hermes-Agent-Update", group: "maintenance" })} onAbbrechen={() => {}} />,
|
||||
)
|
||||
expect(screen.getByText("Update Hermes")).toBeInTheDocument()
|
||||
expect(screen.queryByRole("button", { name: "Abbrechen" })).not.toBeInTheDocument()
|
||||
|
||||
// Die Suche läuft als Shell-Kette wie die Updates: kein Abbrechen, das womöglich nichts stoppt.
|
||||
rerender(<JobKarte job={job({ label: "Nach Updates suchen" })} onAbbrechen={() => {}} />)
|
||||
expect(screen.queryByRole("button", { name: "Abbrechen" })).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<JobKarte
|
||||
job={job({
|
||||
label: "Hermes-Agent-Update", group: "maintenance", state: "failed", finished_at: JETZT,
|
||||
log: ["$ bash -c …", "✗ HERMES-UPDATE FEHLGESCHLAGEN (Dienste laufen wieder, aber alter Stand)", ""],
|
||||
})}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText("Fehler")).toBeInTheDocument()
|
||||
expect(screen.getByText(/Letzte Meldung: ✗ HERMES-UPDATE FEHLGESCHLAGEN/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe("jobTitel", () => {
|
||||
it("nennt die Bausteine wie die Oberfläche und lässt Unbekanntes stehen", () => {
|
||||
expect(jobTitel({ label: "Engine-Update (llama.cpp Vulkan)" })).toBe("Update Motor (llama.cpp)")
|
||||
expect(jobTitel({ label: "Router-Update (llama-swap)" })).toBe("Update llama-swap")
|
||||
expect(jobTitel({ label: "OS-Update (apt)" })).toBe("Update Betriebssystem")
|
||||
expect(jobTitel({ label: "Alle aktualisieren (Engine (llama.cpp) → Router (llama-swap) → Hermes-Agent → OS (apt))" }))
|
||||
.toBe("Alle aktualisieren (Motor → llama-swap → Hermes → Betriebssystem)")
|
||||
expect(jobTitel({ label: "download unsloth/Qwen3.8-27B-GGUF" })).toBe("Download unsloth/Qwen3.8-27B-GGUF")
|
||||
expect(jobTitel({ label: "Nach Updates suchen" })).toBe("Nach Updates suchen")
|
||||
})
|
||||
})
|
||||
|
||||
describe("sichtbareJobs", () => {
|
||||
it("zeigt alle laufenden Aufträge jeder Gruppe und die drei zuletzt beendeten", () => {
|
||||
const liste = sichtbareJobs(
|
||||
[
|
||||
job({ id: "alt", state: "done", finished_at: JETZT - 2 * 86_400 }),
|
||||
job({ id: "f1", state: "done", finished_at: JETZT - 50 }),
|
||||
job({ id: "f2", state: "failed", finished_at: JETZT - 40 }),
|
||||
job({ id: "f3", state: "canceled", finished_at: JETZT - 30 }),
|
||||
job({ id: "f4", state: "done", finished_at: JETZT - 20 }),
|
||||
job({ id: "download", label: "download a/b", state: "running", group: null }),
|
||||
job({ id: "update", group: "maintenance", state: "queued", started_at: JETZT - 10 }),
|
||||
],
|
||||
JETZT,
|
||||
)
|
||||
expect(liste.map((j) => j.id)).toEqual(["download", "update", "f4", "f3", "f2"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { cn } from "cn"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { laeuftNoch } from "@/lib/anzeige"
|
||||
import { abbrechbar, fortschrittText, jobDownload, jobTitel, laufText, letzteMeldung } from "@/lib/wartung"
|
||||
import type { Job, JobZustand } from "@/lib/typen"
|
||||
import { flugplanZeit } from "@/lib/zeit"
|
||||
import { Bestaetigen } from "./Bestaetigen"
|
||||
import { Chip, type ChipArt } from "./Chip"
|
||||
|
||||
const ZUSTAND: Record<JobZustand, { text: string; art: ChipArt }> = {
|
||||
queued: { text: "Wartet", art: "cyan" },
|
||||
running: { text: "Läuft", art: "cyan" },
|
||||
done: { text: "Fertig", art: "gruen" },
|
||||
failed: { text: "Fehler", art: "rot" },
|
||||
canceled: { text: "Abgebrochen", art: "grau" },
|
||||
}
|
||||
|
||||
const BALKEN: Record<JobZustand, string> = {
|
||||
queued: "bg-cyan", running: "bg-cyan", done: "bg-gruen", failed: "bg-rot", canceled: "bg-text-3",
|
||||
}
|
||||
|
||||
const zeitVon = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString())
|
||||
|
||||
/**
|
||||
* Die Uhrzeit in Sekunden, alle 30 s neu, solange `aktiv`. Ohne sie bliebe „Läuft seit 5 min“ stehen,
|
||||
* wenn ein Auftrag eine Weile nichts schreibt: Die Karte zeichnet sonst nur neu, wenn sich der Auftrag ändert.
|
||||
*/
|
||||
function useJetzt(aktiv: boolean) {
|
||||
const [jetzt, setJetzt] = useState(() => Date.now() / 1000)
|
||||
useEffect(() => {
|
||||
if (!aktiv) return
|
||||
const t = window.setInterval(() => setJetzt(Date.now() / 1000), 30_000)
|
||||
return () => window.clearInterval(t)
|
||||
}, [aktiv])
|
||||
return jetzt
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Hintergrund-Auftrag als Karte: Titel und Zustand, bei Downloads der Fortschritt, bei Fehlern
|
||||
* 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).
|
||||
*/
|
||||
export function JobKarte({ job, onAbbrechen, abbrechenGesperrt, ebene = 4 }: {
|
||||
job: Job
|
||||
onAbbrechen?: (job: Job) => void
|
||||
abbrechenGesperrt?: boolean
|
||||
ebene?: 3 | 4
|
||||
}) {
|
||||
const [frage, setFrage] = useState(false)
|
||||
const [protokoll, setProtokoll] = useState(false)
|
||||
const zustand = ZUSTAND[job.state] ?? { text: job.state, art: "grau" as const }
|
||||
const titel = jobTitel(job)
|
||||
const fortschritt = fortschrittText(job)
|
||||
const meldung = letzteMeldung(job)
|
||||
const laeuft = laeuftNoch(job)
|
||||
const jetzt = useJetzt(laeuft)
|
||||
const darfAbbrechen = !!onAbbrechen && abbrechbar(job)
|
||||
const protokollZeilen = (job.log ?? []).slice(-12)
|
||||
const Titel = ebene === 3 ? "h3" : "h4"
|
||||
|
||||
return (
|
||||
<article aria-label={`Auftrag: ${titel}`} className="flex flex-col gap-2.5 rounded-xl border border-linie bg-erhaben/50 p-3.5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<Titel className="m-0 min-w-0 text-[15px] leading-snug font-semibold break-words">{titel}</Titel>
|
||||
<Chip art={zustand.art}>{zustand.text}</Chip>
|
||||
</div>
|
||||
|
||||
{job.progress != null && (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={`Fortschritt: ${titel}`}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={Math.round(job.progress)}
|
||||
className="h-2 overflow-hidden rounded-full bg-linie"
|
||||
>
|
||||
<div
|
||||
className={cn("h-full", BALKEN[job.state] ?? "bg-cyan")}
|
||||
style={{ width: `${Math.max(2, Math.min(100, job.progress))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="ziffern m-0 text-sm text-text-2">
|
||||
{laeuft
|
||||
? fortschritt ?? laufText(job, jetzt)
|
||||
: `${zustand.text} · ${zeitVon(job.finished_at ?? job.started_at)}`}
|
||||
</p>
|
||||
|
||||
{/* Bei Fehlern steht der Grund meist in der letzten Zeile; ohne Fortschrittsbalken zeigt sie, woran der Auftrag gerade ist. */}
|
||||
{meldung && (job.state === "failed" || (laeuft && job.progress == null)) && (
|
||||
<p className={job.state === "failed" ? "m-0 text-sm break-words text-rot-text" : "ziffern m-0 line-clamp-2 text-xs break-all text-text-3"}>
|
||||
{job.state === "failed" ? `Letzte Meldung: ${meldung}` : meldung}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(darfAbbrechen || protokollZeilen.length > 1) && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{darfAbbrechen && (
|
||||
<Button variant="outline" size="sm" disabled={abbrechenGesperrt} onClick={() => setFrage(true)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
)}
|
||||
{protokollZeilen.length > 1 && (
|
||||
<Button variant="ghost" size="sm" aria-expanded={protokoll} onClick={() => setProtokoll(!protokoll)}>
|
||||
{protokoll ? "Protokoll zu" : "Protokoll"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{protokoll && (
|
||||
<pre className="ziffern m-0 max-h-56 overflow-auto rounded-md bg-[#0b0d0f] p-2 text-xs whitespace-pre-wrap break-all text-text-2">
|
||||
{protokollZeilen.join("\n")}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{darfAbbrechen && (
|
||||
<Bestaetigen
|
||||
offen={frage}
|
||||
gefahr
|
||||
titel="Auftrag abbrechen?"
|
||||
text={jobDownload(job)
|
||||
? `Der ${titel} stoppt. Du kannst ihn später über „Modelle selbst suchen“ neu starten.`
|
||||
: `„${titel}“ wird abgebrochen.`}
|
||||
knopf="Ja, abbrechen"
|
||||
neinKnopf="Weiterlaufen lassen"
|
||||
onNein={() => setFrage(false)}
|
||||
onJa={() => {
|
||||
if (!frage) return // zweiter Klick eines Doppelklicks
|
||||
setFrage(false)
|
||||
onAbbrechen?.(job)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export function Protokollfenster({ offen, titel, text, onSchliessen }: {
|
||||
}) {
|
||||
return (
|
||||
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
|
||||
<SheetContent side="right" className="w-full border-linie bg-panel sm:max-w-2xl">
|
||||
<SheetContent side="right" className="border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="schild text-lg">Protokoll</SheetTitle>
|
||||
<SheetDescription className="text-text-2">{titel}</SheetDescription>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"
|
||||
import { hinweisRueckfrage } from "@/lib/anzeige"
|
||||
import { post } from "@/lib/api"
|
||||
import type { Hinweis } from "@/lib/typen"
|
||||
import { installierenUnterbrechung, rollenErsatzText } from "@/lib/wartung"
|
||||
import { Bestaetigen } from "./Bestaetigen"
|
||||
import { Checkliste } from "./Checkliste"
|
||||
|
||||
vi.mock("@/lib/api", async (original) => ({ ...(await original<typeof import("@/lib/api")>()), post: vi.fn() }))
|
||||
|
||||
const abgestuerzt: Hinweis = {
|
||||
id: "dienst:hermes-gateway", stufe: "rot", titel: "Hermes-Gateway ist abgestürzt", text: "failed",
|
||||
quelle: "hermes-gateway", seit: 1_790_000_000, zuletzt: 1_790_000_060,
|
||||
aktionen: [
|
||||
{ id: "neustart", label: "Neu starten", dienst: "hermes-gateway" },
|
||||
{ id: "protokoll", label: "Protokoll", dienst: "hermes-gateway" },
|
||||
],
|
||||
}
|
||||
|
||||
describe("hinweisRueckfrage", () => {
|
||||
it("fragt vor Neustart, Wiederholen und Freigeben, nie vor dem Protokoll", () => {
|
||||
expect(hinweisRueckfrage(abgestuerzt, { id: "protokoll", label: "Protokoll" })).toBeNull()
|
||||
expect(hinweisRueckfrage(abgestuerzt, { id: "neustart", label: "Neu starten" })?.text)
|
||||
.toBe("„Hermes-Gateway ist abgestürzt“: Der Dienst wird neu gestartet und ist dabei kurz nicht erreichbar.")
|
||||
expect(hinweisRueckfrage({ id: "dienst:x", titel: "X ist gestoppt" }, { id: "neustart", label: "Starten" })?.text)
|
||||
.toContain("wird gestartet")
|
||||
expect(hinweisRueckfrage({ id: "timer:mc2-backup", titel: "Sicherung scheitert" }, { id: "neustart", label: "Jetzt erneut laufen lassen" })?.text)
|
||||
.toContain("läuft sofort noch einmal")
|
||||
expect(hinweisRueckfrage({ id: "job:1", titel: "Job „News“" }, { id: "job-wiederholen", label: "Erneut ausführen" })?.titel)
|
||||
.toBe("Job erneut ausführen?")
|
||||
expect(hinweisRueckfrage({ id: "pin:engine", titel: "Motor bekommt keine Updates" }, { id: "freigeben", label: "Freigeben" })?.text)
|
||||
.toContain("Sonntags-Lauf")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Checkliste", () => {
|
||||
it("startet einen Dienst erst neu, wenn die Rückfrage bestätigt ist", async () => {
|
||||
vi.mocked(post).mockResolvedValue({ ok: true })
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<Checkliste hinweise={[abgestuerzt]} verlauf={[]} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole("button", { name: "Neu starten" }))
|
||||
const dialog = await screen.findByRole("dialog", { name: "Neu starten?" })
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Neu starten" }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalledWith("/api/hinweise/dienst%3Ahermes-gateway/aktion/neustart"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("Bestaetigen", () => {
|
||||
it("lässt den Ja-Knopf nicht los, solange die Aktion schon unterwegs ist", () => {
|
||||
const onJa = vi.fn()
|
||||
render(<Bestaetigen offen titel="Installieren?" text="…" knopf="Installieren" gesperrt onJa={onJa} onNein={() => {}} />)
|
||||
const knopf = screen.getByRole("button", { name: "Installieren" })
|
||||
expect(knopf).toBeDisabled()
|
||||
fireEvent.click(knopf)
|
||||
expect(onJa).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("rollenErsatzText", () => {
|
||||
it("sagt deutlich, welches Modell die Rolle verliert und was es behält", () => {
|
||||
expect(rollenErsatzText("org/Neu-GGUF", "Coder", { name: "Qwen3.8-27B-Heretic", zwilling: true, weitere: ["heavy"] })).toBe(
|
||||
"Achtung: Nach dem Download übernimmt org/Neu-GGUF die Rolle „Coder“ und ersetzt damit Qwen3.8-27B-Heretic. " +
|
||||
"Das bisherige Modell bleibt auf der Platte. Die Rolle „heavy“ behält es. " +
|
||||
"Bilder liest vorerst weiter sein Bild-Zwilling, also noch das bisherige Modell.",
|
||||
)
|
||||
expect(rollenErsatzText("org/Neu-GGUF", "Coder", undefined)).toBe("Nach dem Download übernimmt org/Neu-GGUF die Rolle „Coder“.")
|
||||
})
|
||||
|
||||
it("warnt vor der Unterbrechung durch das Neuladen von llama-swap", () => {
|
||||
expect(installierenUnterbrechung(false)).toContain("bricht ab")
|
||||
expect(installierenUnterbrechung(false)).not.toContain("noch einmal")
|
||||
expect(installierenUnterbrechung(true)).toContain("Mit Rolle passiert das nach dem Download noch einmal.")
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,11 @@
|
||||
import { laufzeit } from "@/lib/zeit"
|
||||
|
||||
// Alle Maße hängen an der Schriftgröße der Zeile (em), und die wächst mit der Breite des Feldes
|
||||
// (cqw): So passt „TT T SS H“ auch in die schmale Hälfte eines 320-px-Handys, und am PC bleibt
|
||||
// es bei den alten 38 × 56 px je Ziffer.
|
||||
function Ziffer({ z }: { z: string }) {
|
||||
return (
|
||||
<span className="ziffern inline-flex h-14 w-[38px] items-center justify-center rounded-md border border-[#2a3036] bg-[#0b0d0f] text-[32px] font-medium">
|
||||
<span className="ziffern inline-flex h-[1.75em] w-[1.1875em] shrink-0 items-center justify-center rounded-md border border-[#2a3036] bg-[#0b0d0f] font-medium">
|
||||
{z}
|
||||
</span>
|
||||
)
|
||||
@@ -15,14 +18,19 @@ export function Zaehlwerk({ sekunden }: { sekunden: number | null | undefined })
|
||||
const std = String(lz?.[1] ?? 0).padStart(2, "0")
|
||||
const text = lz ? `Laufzeit ohne Neustart: ${lz[0]} Tage, ${lz[1]} Stunden` : "Laufzeit unbekannt"
|
||||
return (
|
||||
<figure className="m-0 flex min-w-0 flex-col items-center justify-end gap-1">
|
||||
<div aria-label={text} role="img" className="flex h-[150px] max-w-full items-center gap-1.5 pb-5">
|
||||
<figure className="@container m-0 flex min-w-0 flex-col items-center justify-end gap-1">
|
||||
{/* Höhe wie die Rundinstrumente daneben (200 × 150, also ¾ der Breite). */}
|
||||
<div
|
||||
aria-label={text}
|
||||
role="img"
|
||||
className="flex h-[min(75cqw,150px)] items-center gap-[0.1875em] pb-[min(10cqw,20px)] text-[length:clamp(14px,14.5cqw,32px)]"
|
||||
>
|
||||
<Ziffer z={lz ? tage[0] : "–"} />
|
||||
<Ziffer z={lz ? tage[1] : "–"} />
|
||||
<span className="schild mr-2 ml-0.5 text-lg font-bold text-text-3">T</span>
|
||||
<span className="schild mr-[0.444em] ml-[0.111em] text-[0.5625em] font-bold text-text-3">T</span>
|
||||
<Ziffer z={lz ? std[0] : "–"} />
|
||||
<Ziffer z={lz ? std[1] : "–"} />
|
||||
<span className="schild ml-0.5 text-lg font-bold text-text-3">H</span>
|
||||
<span className="schild ml-[0.111em] text-[0.5625em] font-bold text-text-3">H</span>
|
||||
</div>
|
||||
<figcaption className="schild text-[15px] text-text-3">Ohne Neustart</figcaption>
|
||||
</figure>
|
||||
|
||||
@@ -11,24 +11,12 @@ function Dialog({
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
@@ -152,15 +140,12 @@ function DialogDescription({
|
||||
)
|
||||
}
|
||||
|
||||
// Nur was die Oberfläche nutzt (Rückfrage „Bestaetigen“); Trigger/Close von shadcn sind raus.
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
@@ -7,22 +5,14 @@ import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
// Seitenschublade (shadcn). Breite am Handy/PC setzen die Ansichten selbst — und zwar mit derselben
|
||||
// Varianten-Kette (`data-[side=right]:w-full`, `data-[side=right]:sm:max-w-xl`), sonst schlagen die
|
||||
// Grundklassen unten die eigenen: `w-full` allein verlor gegen `data-[side=right]:w-3/4`.
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
@@ -96,16 +86,6 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
@@ -135,13 +115,11 @@ function SheetDescription({
|
||||
)
|
||||
}
|
||||
|
||||
// Nur was die Oberfläche nutzt; Trigger/Close/Footer von shadcn sind raus.
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
|
||||
@@ -3,16 +3,38 @@
|
||||
// Takte: Steht der Live-Strom, meldet er Änderungen selbst — die Abfragen sind dann nur
|
||||
// Sicherheitsnetz und laufen fünfmal seltener.
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { type QueryClient, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { api, post } from "./api"
|
||||
import { fehlerText, laeuftNoch } from "./anzeige"
|
||||
import { melden } from "./meldungen"
|
||||
import { stromVerbunden } from "./strom"
|
||||
import type {
|
||||
AktionsErgebnis, AufraeumKandidat, Job, Modell, Nutzung, Radar, Sicherung, Start,
|
||||
AktionsErgebnis, AufraeumKandidat, Job, Modell, Nutzung, Radar, Sicherung, Start, UpdateDetails,
|
||||
} from "./typen"
|
||||
import { gbText } from "./zeit"
|
||||
|
||||
const takt = (ms: number) => () => (stromVerbunden() ? ms * 5 : ms)
|
||||
|
||||
/**
|
||||
* Ist seit der letzten Abfrage ein Auftrag fertig geworden, lesen die betroffenen Ansichten neu:
|
||||
* nach einem Download die Modelle und die Platte, nach Updates und der Suche die Versionen, der
|
||||
* Verlauf und die Sicherungen. Gezielt, weil die Update-Prüfung teuer ist (GitHub, apt, git fetch).
|
||||
*/
|
||||
function auftragsEndeMelden(qc: QueryClient, neu: Job[]) {
|
||||
const alt = qc.getQueryData<{ jobs: Job[] }>(["jobs"])?.jobs ?? []
|
||||
const ebenFertig = neu.filter((j) => !laeuftNoch(j) && alt.some((a) => a.id === j.id && laeuftNoch(a)))
|
||||
if (ebenFertig.length === 0) return
|
||||
const schluessel = new Set(["start"])
|
||||
for (const j of ebenFertig) {
|
||||
// Wie wartung.jobDownload — hier ausgeschrieben, damit wartung.ts nicht ins Start-Bündel wandert.
|
||||
const download = j.group === "download" || /^download\s/i.test(j.label)
|
||||
for (const k of download ? ["models", "aufraeumen"] : ["update-details", "updates-verlauf", "sicherungen"]) {
|
||||
schluessel.add(k)
|
||||
}
|
||||
}
|
||||
for (const k of schluessel) qc.invalidateQueries({ queryKey: [k] })
|
||||
}
|
||||
|
||||
export const useStart = () =>
|
||||
useQuery({
|
||||
queryKey: ["start"],
|
||||
@@ -34,14 +56,19 @@ export const useNutzung = () =>
|
||||
export const useRadar = () =>
|
||||
useQuery({ queryKey: ["radar"], queryFn: () => api<Radar>("/api/radar"), retry: false, refetchInterval: 300_000 })
|
||||
|
||||
export const useJobs = () =>
|
||||
useQuery({
|
||||
export const useJobs = () => {
|
||||
const qc = useQueryClient()
|
||||
return useQuery({
|
||||
queryKey: ["jobs"],
|
||||
queryFn: () => api<{ jobs: Job[] }>("/api/jobs"),
|
||||
queryFn: async () => {
|
||||
const daten = await api<{ jobs: Job[] }>("/api/jobs")
|
||||
auftragsEndeMelden(qc, daten.jobs)
|
||||
return daten
|
||||
},
|
||||
// Läuft etwas, wollen wir den Fortschritt sehen; sonst reicht der Strom.
|
||||
refetchInterval: (q) =>
|
||||
q.state.data?.jobs.some((j) => j.state === "running" || j.state === "queued") ? 2000 : takt(30_000)(),
|
||||
refetchInterval: (q) => (q.state.data?.jobs.some(laeuftNoch) ? 2000 : takt(30_000)()),
|
||||
})
|
||||
}
|
||||
|
||||
export const useSicherungen = () =>
|
||||
useQuery({
|
||||
@@ -49,27 +76,18 @@ export const useSicherungen = () =>
|
||||
queryFn: () => api<{ available: boolean; backups: Sicherung[] }>("/api/zeitmaschine"),
|
||||
})
|
||||
|
||||
export interface UpdateDetails {
|
||||
kind: string
|
||||
installed_version?: string | null
|
||||
behind?: number
|
||||
installed_build?: number | null
|
||||
latest_build?: number | null
|
||||
latest_tag?: string | null
|
||||
count?: number
|
||||
packages?: { name: string; current: string; candidate: string }[]
|
||||
summary?: string
|
||||
action_needed?: boolean | null
|
||||
action_text?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const useUpdateDetails = (art: string | null) =>
|
||||
useQuery({
|
||||
queryKey: ["update-details", art],
|
||||
queryFn: () => api<UpdateDetails>(`/api/maintenance/update-details?kind=${art}`),
|
||||
enabled: !!art,
|
||||
staleTime: 600_000,
|
||||
/**
|
||||
* Was die Updates der Bausteine bringen würden — je Baustein eine eigene Abfrage, damit ein langsamer
|
||||
* (Hermes: git fetch bis 25 s) die anderen nicht aufhält. Die Prüfung fragt GitHub, apt und git,
|
||||
* darum gilt ein Ergebnis zehn Minuten.
|
||||
*/
|
||||
export const useUpdateDetails = (arten: readonly string[]) =>
|
||||
useQueries({
|
||||
queries: arten.map((art) => ({
|
||||
queryKey: ["update-details", art],
|
||||
queryFn: () => api<UpdateDetails>(`/api/maintenance/update-details?kind=${encodeURIComponent(art)}`),
|
||||
staleTime: 600_000,
|
||||
})),
|
||||
})
|
||||
|
||||
export interface UpdateLauf {
|
||||
@@ -108,7 +126,12 @@ export const useDienste = () =>
|
||||
|
||||
function useAktion<V, E extends AktionsErgebnis | Record<string, unknown>>(
|
||||
fn: (v: V) => Promise<E>,
|
||||
opt: { erfolg: (v: V, e: E) => string; neuLaden: string[] },
|
||||
opt: {
|
||||
erfolg: (v: V, e: E) => string
|
||||
/** Eigener Text, wenn die Box mit ok: false antwortet (sonst der Grund aus der Antwort). */
|
||||
fehler?: (v: V, e: AktionsErgebnis) => string
|
||||
neuLaden: string[]
|
||||
},
|
||||
) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
@@ -116,7 +139,7 @@ function useAktion<V, E extends AktionsErgebnis | Record<string, unknown>>(
|
||||
onSuccess: (e, v) => {
|
||||
const ergebnis = e as AktionsErgebnis
|
||||
if (ergebnis && ergebnis.ok === false) {
|
||||
melden("fehler", ergebnis.detail || ergebnis.err || "Das hat nicht geklappt.")
|
||||
melden("fehler", opt.fehler?.(v, ergebnis) ?? fehlerText(ergebnis))
|
||||
} else {
|
||||
melden("erfolg", opt.erfolg(v, e))
|
||||
}
|
||||
@@ -130,7 +153,11 @@ export const useHinweisAktion = () =>
|
||||
useAktion(
|
||||
({ hinweis, aktion }: { hinweis: string; aktion: string; label: string }) =>
|
||||
post<AktionsErgebnis>(`/api/hinweise/${encodeURIComponent(hinweis)}/aktion/${aktion}`),
|
||||
{ erfolg: (v) => `${v.label}: erledigt. Der Wächter prüft in einer Minute nach.`, neuLaden: ["start"] },
|
||||
{
|
||||
erfolg: (v, e) =>
|
||||
v.aktion === "freigeben" && e.text ? e.text : `${v.label}: erledigt. Der Wächter prüft in einer Minute nach.`,
|
||||
neuLaden: ["start"],
|
||||
},
|
||||
)
|
||||
|
||||
const UPDATE_PFADE: Record<string, string> = {
|
||||
@@ -144,10 +171,34 @@ const UPDATE_PFADE: Record<string, string> = {
|
||||
|
||||
export const useUpdateStarten = () =>
|
||||
useAktion((art: string) => post<AktionsErgebnis>(UPDATE_PFADE[art]), {
|
||||
erfolg: (art) => (art === "pruefen" ? "Suche nach Neuem läuft." : "Update läuft. Den Fortschritt siehst du unten."),
|
||||
erfolg: (art) =>
|
||||
art === "pruefen"
|
||||
? "Die Suche nach Neuem läuft. Den Fortschritt siehst du unten bei den Aufträgen."
|
||||
: "Das Update läuft. Den Fortschritt siehst du unten bei den Aufträgen.",
|
||||
neuLaden: ["jobs", "start"],
|
||||
})
|
||||
|
||||
/** Einen laufenden Auftrag abbrechen (nur Downloads und die Suche, siehe `abbrechbar`). */
|
||||
export const useJobAbbrechen = () =>
|
||||
useAktion(({ id }: { id: string; titel: string }) => post<AktionsErgebnis>(`/api/jobs/${encodeURIComponent(id)}/cancel`), {
|
||||
erfolg: (v) => `${v.titel}: wird abgebrochen.`,
|
||||
fehler: (v) => `${v.titel} lief schon nicht mehr.`,
|
||||
neuLaden: ["jobs"],
|
||||
})
|
||||
|
||||
/** Ein Modell von Hugging Face holen (Hintergrund-Auftrag, mit optionaler Rolle nach dem Download). */
|
||||
export const useInstallieren = () =>
|
||||
useAktion(
|
||||
({ repo, quant, rolle }: { repo: string; quant: string; rolle: string | null }) =>
|
||||
post<AktionsErgebnis & { total_bytes?: number }>("/api/models/install", { repo, quant, role: rolle }),
|
||||
{
|
||||
erfolg: (v, e) =>
|
||||
`${v.repo} (${v.quant}) wird heruntergeladen${e.total_bytes ? `, ${gbText(e.total_bytes, 1)}` : ""}. ` +
|
||||
"Den Fortschritt siehst du unter Aufträge.",
|
||||
neuLaden: ["jobs", "models"],
|
||||
},
|
||||
)
|
||||
|
||||
export const useAufraeumen = () =>
|
||||
useQuery({
|
||||
queryKey: ["aufraeumen"],
|
||||
@@ -168,8 +219,10 @@ export const useFreigeben = () =>
|
||||
)
|
||||
|
||||
export const useJetztSichern = () =>
|
||||
useAktion(() => post<AktionsErgebnis>("/api/system/backup"), {
|
||||
erfolg: () => "Sicherung erstellt.",
|
||||
useAktion(() => post<AktionsErgebnis & { size_mb?: number }>("/api/system/backup"), {
|
||||
erfolg: (_, e) =>
|
||||
e.size_mb != null ? `Sicherung erstellt (${e.size_mb.toLocaleString("de-DE")} MB).` : "Sicherung erstellt.",
|
||||
fehler: (_, e) => `Die Sicherung ging nicht: ${fehlerText(e)}`,
|
||||
neuLaden: ["sicherungen", "start"],
|
||||
})
|
||||
|
||||
|
||||
+60
-18
@@ -1,8 +1,7 @@
|
||||
// Reine Anzeige-Logik der Cockpit-Seiten — getrennt von den Komponenten, damit sie ohne
|
||||
// DOM testbar bleibt und Vites Hot-Reload die Komponenten-Dateien sauber tauschen kann.
|
||||
|
||||
import type { UpdateDetails } from "./abfragen"
|
||||
import type { Modell, Start } from "./typen"
|
||||
import type { AktionsErgebnis, Hinweis, HinweisAktion, Job, Modell, Start } from "./typen"
|
||||
|
||||
// --- Rundinstrument -----------------------------------------------------------------
|
||||
// 240°-Bogen wie ein Zeigerinstrument, Mittelpunkt (100|95) in einer 200×150-Zeichnung.
|
||||
@@ -55,25 +54,68 @@ export function hauptleuchte(z: Start["zustand"]) {
|
||||
: { art: "gelb" as const, oben: "Achtung", mitte, unten: "Drücken zum Ansehen" }
|
||||
}
|
||||
|
||||
// --- Updates ------------------------------------------------------------------------
|
||||
// --- Checkliste ---------------------------------------------------------------------
|
||||
|
||||
/** „Läuft“ und „Neu“ aus den Update-Details lesbar machen. */
|
||||
export function versionen(id: string, d: UpdateDetails | undefined): { laeuft: string; neu: string | null } {
|
||||
if (!d) return { laeuft: "…", neu: null }
|
||||
if (id === "os") return { laeuft: "Ubuntu", neu: d.count ? `${d.count} Pakete` : null }
|
||||
// Hermes kommt aus git: „Neu“ ist die Zahl der Änderungen seit dem eingespielten Stand.
|
||||
if (id === "hermes") {
|
||||
return {
|
||||
laeuft: d.installed_version ? `v${d.installed_version}` : "–",
|
||||
neu: d.behind ? `+${d.behind.toLocaleString("de-DE")} Änderungen` : null,
|
||||
}
|
||||
}
|
||||
const pre = id === "engine" ? "b" : "v"
|
||||
const laeuft = d.installed_build != null ? `${pre}${d.installed_build}` : "–"
|
||||
const neuer = d.latest_build != null && d.installed_build != null && d.latest_build > d.installed_build
|
||||
return { laeuft, neu: neuer ? `${pre}${d.latest_build}` : null }
|
||||
export interface Rueckfrage {
|
||||
titel: string
|
||||
text: string
|
||||
knopf: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Braucht ein Knopf eines Hinweises eine Rückfrage? Ja bei allem, was etwas neu startet, erneut
|
||||
* laufen lässt oder freigibt (services/waechter.fuehre_aktion_aus). Nur das Protokoll öffnet sofort.
|
||||
*/
|
||||
export function hinweisRueckfrage(
|
||||
h: Pick<Hinweis, "id" | "titel">,
|
||||
a: Pick<HinweisAktion, "id" | "label">,
|
||||
): Rueckfrage | null {
|
||||
const betrifft = `„${h.titel}“:`
|
||||
switch (a.id) {
|
||||
case "protokoll":
|
||||
return null
|
||||
case "neustart":
|
||||
if (h.id.startsWith("timer:")) {
|
||||
return { titel: `${a.label}?`, text: `${betrifft} Der Dienst läuft sofort noch einmal.`, knopf: a.label }
|
||||
}
|
||||
return {
|
||||
titel: `${a.label}?`,
|
||||
text: `${betrifft} Der Dienst wird ${/^starten/i.test(a.label) ? "gestartet" : "neu gestartet"} und ist dabei kurz nicht erreichbar.`,
|
||||
knopf: a.label,
|
||||
}
|
||||
case "job-wiederholen":
|
||||
return {
|
||||
titel: "Job erneut ausführen?",
|
||||
text: `${betrifft} Hermes führt den Job sofort noch einmal aus, mit allem, was er sonst auch tut — etwa Nachrichten verschicken.`,
|
||||
knopf: a.label,
|
||||
}
|
||||
case "freigeben":
|
||||
return {
|
||||
titel: "Update freigeben?",
|
||||
text: `${betrifft} Nach dem Freigeben versucht der nächste Sonntags-Lauf das Update wieder.`,
|
||||
knopf: a.label,
|
||||
}
|
||||
default:
|
||||
// Ein Knopf, den der Wächter neu gelernt hat: lieber einmal zu oft fragen als einmal zu wenig.
|
||||
return { titel: `${a.label}?`, text: `${betrifft} ${a.label}.`, knopf: a.label }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Aktionen und Aufträge (auch im Start-Bündel gebraucht) ----------------------------
|
||||
|
||||
/** Warum eine Aktion nicht geklappt hat — die Endpunkte legen den Grund in verschiedene Felder. */
|
||||
export function fehlerText(e: AktionsErgebnis | null | undefined): string {
|
||||
if (e?.status === "busy") {
|
||||
return e.running
|
||||
? `Es läuft schon ein Update („${e.running}“). Warte, bis es fertig ist.`
|
||||
: "Es läuft schon ein Update. Warte, bis es fertig ist."
|
||||
}
|
||||
return e?.detail || e?.err || e?.error || e?.text || "Das hat nicht geklappt."
|
||||
}
|
||||
|
||||
/** Wartet oder läuft der Auftrag noch? */
|
||||
export const laeuftNoch = (j: Pick<Job, "state">) => j.state === "running" || j.state === "queued"
|
||||
|
||||
// --- Modelle ------------------------------------------------------------------------
|
||||
|
||||
/** Rolle eines Modells aus seinen llama-swap-Aliasen. */
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { act, render, screen } from "@testing-library/react"
|
||||
import { STILLE_MS, useStrom, useStromLage } from "./strom"
|
||||
|
||||
/** EventSource-Attrappe: Der Test entscheidet, wann geöffnet wird und wann sie aufgibt. */
|
||||
class FalscheQuelle {
|
||||
static CONNECTING = 0
|
||||
static OPEN = 1
|
||||
static CLOSED = 2
|
||||
static alle: FalscheQuelle[] = []
|
||||
readyState = FalscheQuelle.CONNECTING
|
||||
onopen: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
constructor(public url: string) {
|
||||
FalscheQuelle.alle.push(this)
|
||||
}
|
||||
addEventListener() {}
|
||||
close() {
|
||||
this.readyState = FalscheQuelle.CLOSED
|
||||
}
|
||||
oeffnen() {
|
||||
this.readyState = FalscheQuelle.OPEN
|
||||
this.onopen?.()
|
||||
}
|
||||
aufgeben() {
|
||||
this.readyState = FalscheQuelle.CLOSED
|
||||
this.onerror?.()
|
||||
}
|
||||
}
|
||||
|
||||
function Anzeige() {
|
||||
useStrom()
|
||||
return <span>{useStromLage()}</span>
|
||||
}
|
||||
|
||||
describe("useStrom", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
FalscheQuelle.alle = []
|
||||
vi.stubGlobal("EventSource", FalscheQuelle)
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it("baut den Strom selbst neu auf, wenn EventSource aufgibt oder die Leitung verstummt", () => {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<Anzeige />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
const erste = FalscheQuelle.alle[0]
|
||||
act(() => erste.oeffnen())
|
||||
expect(screen.getByText("live")).toBeInTheDocument()
|
||||
|
||||
// 503 vom Proxy, während MC2 neu startet: EventSource gibt für immer auf.
|
||||
act(() => erste.aufgeben())
|
||||
expect(screen.getByText("getrennt")).toBeInTheDocument()
|
||||
act(() => vi.advanceTimersByTime(2_000))
|
||||
expect(FalscheQuelle.alle).toHaveLength(2)
|
||||
|
||||
// Leitung offen, aber es kommt nichts mehr (Handy im Standby): nach STILLE_MS neu.
|
||||
const zweite = FalscheQuelle.alle[1]
|
||||
act(() => zweite.oeffnen())
|
||||
act(() => vi.advanceTimersByTime(STILLE_MS))
|
||||
expect(screen.getByText("getrennt")).toBeInTheDocument()
|
||||
expect(zweite.readyState).toBe(FalscheQuelle.CLOSED)
|
||||
act(() => vi.advanceTimersByTime(2_000))
|
||||
expect(FalscheQuelle.alle).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
+93
-26
@@ -7,6 +7,13 @@
|
||||
//
|
||||
// Der Messpunkt liegt bewusst AUSSERHALB von React (useSyncExternalStore): So zeichnen
|
||||
// nur die Instrumente jede Sekunde neu, nicht die ganze Seite.
|
||||
//
|
||||
// Wiederaufbau: Bei einem Netzfehler versucht der Browser es von selbst erneut. Antwortet der
|
||||
// Server aber mit einem Fehler (502/503 vom Proxy, während MC2 neu startet), gibt EventSource
|
||||
// für immer auf — dann bauen wir ihn selbst neu auf, mit wachsender Pause (2 s bis 30 s) und
|
||||
// sofort, wenn das Netz oder der Tab zurück ist. Dazu ein Stille-Wächter: Der Strom schickt jede
|
||||
// Sekunde einen Messpunkt. Kommt 20 s lang nichts, ist die Leitung tot, ohne dass der Browser es
|
||||
// merkt (Handy im Standby, WLAN gewechselt, Proxy hält die Verbindung offen) — auch dann neu.
|
||||
|
||||
import { useEffect, useSyncExternalStore } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
@@ -27,6 +34,11 @@ export interface MetrikPunkt {
|
||||
|
||||
export type StromLage = "verbinde" | "live" | "getrennt"
|
||||
|
||||
const PAUSE_ERST_MS = 2_000
|
||||
const PAUSE_HOECHST_MS = 30_000
|
||||
/** So lange darf der Strom schweigen, bevor er als tot gilt (er schickt jede Sekunde einen Messpunkt). */
|
||||
export const STILLE_MS = 20_000
|
||||
|
||||
let lage: StromLage = "verbinde"
|
||||
let letzte: MetrikPunkt | null = null
|
||||
const hoerer = new Set<() => void>()
|
||||
@@ -43,12 +55,6 @@ export const stromVerbunden = () => lage === "live"
|
||||
export const useStromLage = () => useSyncExternalStore(abonniere, () => lage)
|
||||
export const useLetzteMetrik = () => useSyncExternalStore(abonniere, () => letzte)
|
||||
|
||||
/** Nur für Tests: Messpunkt direkt einspeisen. */
|
||||
export function metrikEinspeisen(m: MetrikPunkt | null) {
|
||||
letzte = m
|
||||
melde()
|
||||
}
|
||||
|
||||
function setzeLage(neu: StromLage) {
|
||||
if (neu !== lage) {
|
||||
lage = neu
|
||||
@@ -59,30 +65,91 @@ function setzeLage(neu: StromLage) {
|
||||
export function useStrom() {
|
||||
const qc = useQueryClient()
|
||||
useEffect(() => {
|
||||
const es = new EventSource("/api/stream")
|
||||
es.onopen = () => {
|
||||
setzeLage("live")
|
||||
// Was während einer Trennung passiert ist, hat der Strom nicht gemeldet.
|
||||
qc.invalidateQueries()
|
||||
let quelle: EventSource | null = null
|
||||
let wiederTimer: number | undefined
|
||||
let stilleTimer: number | undefined
|
||||
let letzteRegung = Date.now()
|
||||
let pause = PAUSE_ERST_MS
|
||||
let beendet = false
|
||||
|
||||
function spaeterNeu() {
|
||||
if (beendet) return
|
||||
window.clearTimeout(wiederTimer)
|
||||
wiederTimer = window.setTimeout(verbinden, pause)
|
||||
pause = Math.min(pause * 2, PAUSE_HOECHST_MS)
|
||||
}
|
||||
es.onerror = () => setzeLage("getrennt")
|
||||
es.addEventListener("invalidate", (e) => {
|
||||
try {
|
||||
const keys: string[] = JSON.parse((e as MessageEvent).data)?.keys ?? []
|
||||
for (const k of keys) qc.invalidateQueries({ queryKey: [k] })
|
||||
} catch {
|
||||
/* ein kaputtes Ereignis reißt den Strom nicht */
|
||||
|
||||
// Jedes Ereignis beweist, dass die Leitung lebt; bleibt es still, gilt sie als tot.
|
||||
function lebt() {
|
||||
letzteRegung = Date.now()
|
||||
window.clearTimeout(stilleTimer)
|
||||
stilleTimer = window.setTimeout(() => {
|
||||
quelle?.close()
|
||||
setzeLage("getrennt")
|
||||
spaeterNeu()
|
||||
}, STILLE_MS)
|
||||
}
|
||||
|
||||
function verbinden() {
|
||||
window.clearTimeout(wiederTimer)
|
||||
window.clearTimeout(stilleTimer)
|
||||
quelle?.close()
|
||||
const es = new EventSource("/api/stream")
|
||||
quelle = es
|
||||
es.onopen = () => {
|
||||
pause = PAUSE_ERST_MS
|
||||
setzeLage("live")
|
||||
lebt()
|
||||
// Was während einer Trennung passiert ist, hat der Strom nicht gemeldet.
|
||||
qc.invalidateQueries()
|
||||
}
|
||||
})
|
||||
es.addEventListener("metrik", (e) => {
|
||||
try {
|
||||
metrikEinspeisen(JSON.parse((e as MessageEvent).data) as MetrikPunkt)
|
||||
} catch {
|
||||
/* ein kaputter Messpunkt reißt den Strom nicht */
|
||||
es.onerror = () => {
|
||||
setzeLage("getrennt")
|
||||
if (es.readyState === EventSource.CLOSED) {
|
||||
window.clearTimeout(stilleTimer)
|
||||
spaeterNeu()
|
||||
}
|
||||
}
|
||||
})
|
||||
es.addEventListener("invalidate", (e) => {
|
||||
lebt()
|
||||
try {
|
||||
const keys: string[] = JSON.parse((e as MessageEvent).data)?.keys ?? []
|
||||
for (const k of keys) qc.invalidateQueries({ queryKey: [k] })
|
||||
} catch {
|
||||
/* ein kaputtes Ereignis reißt den Strom nicht */
|
||||
}
|
||||
})
|
||||
es.addEventListener("metrik", (e) => {
|
||||
lebt()
|
||||
try {
|
||||
letzte = JSON.parse((e as MessageEvent).data) as MetrikPunkt
|
||||
melde()
|
||||
} catch {
|
||||
/* ein kaputter Messpunkt reißt den Strom nicht */
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Netz wieder da oder Tab wieder vorn (Handys kappen Verbindungen im Hintergrund): nicht auf
|
||||
// Pause oder Stille-Wächter warten, wenn der Strom aufgegeben hat oder seit Sekunden schweigt.
|
||||
const wiederDa = () => {
|
||||
if (beendet || document.visibilityState !== "visible") return
|
||||
if (quelle?.readyState === EventSource.CLOSED || Date.now() - letzteRegung > 5_000) {
|
||||
pause = PAUSE_ERST_MS
|
||||
verbinden()
|
||||
}
|
||||
}
|
||||
|
||||
verbinden()
|
||||
window.addEventListener("online", wiederDa)
|
||||
document.addEventListener("visibilitychange", wiederDa)
|
||||
return () => {
|
||||
es.close()
|
||||
beendet = true
|
||||
window.clearTimeout(wiederTimer)
|
||||
window.clearTimeout(stilleTimer)
|
||||
window.removeEventListener("online", wiederDa)
|
||||
document.removeEventListener("visibilitychange", wiederDa)
|
||||
quelle?.close()
|
||||
setzeLage("getrennt")
|
||||
}
|
||||
}, [qc])
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Datenformen der Box-Wart-Schnittstellen — Gegenstück zu backend/routers/boxwart.py,
|
||||
// services/waechter.py, modell_nutzung.py, zeitplan.py und routers/radar.py.
|
||||
// services/waechter.py, modell_nutzung.py, zeitplan.py, routers/radar.py sowie
|
||||
// services/jobengine.py (Aufträge) und services/maintenance.py (Update-Details).
|
||||
|
||||
export type Stufe = "rot" | "gelb"
|
||||
export type LampenZustand = "ok" | "aus" | "warn" | "info" | "fehler"
|
||||
@@ -90,12 +91,48 @@ export interface AufraeumKandidat {
|
||||
hinweis: string
|
||||
}
|
||||
|
||||
/** Antwort einer Aktion. Je nach Endpunkt steht der Fehler in einem anderen Feld. */
|
||||
export interface AktionsErgebnis {
|
||||
ok: boolean
|
||||
text?: string
|
||||
out?: string
|
||||
err?: string
|
||||
detail?: string
|
||||
/** „Jetzt sichern“ (/api/system/backup) legt seinen Fehler hier ab. */
|
||||
error?: string
|
||||
/** "busy" (es läuft schon ein Update), "nothing", "password_required" … */
|
||||
status?: string
|
||||
/** Bei status "busy": welcher Auftrag gerade läuft. */
|
||||
running?: string
|
||||
job_id?: string
|
||||
}
|
||||
|
||||
/** Was ein Update eines Bausteins bringen würde (GET /api/maintenance/update-details?kind=…). */
|
||||
export interface UpdateDetails {
|
||||
kind: string
|
||||
/** Die Prüfung selbst ist gescheitert, z. B. „git fetch“ lief in eine Zeitüberschreitung. */
|
||||
error?: string
|
||||
// Betriebssystem
|
||||
count?: number
|
||||
packages?: { name: string; current: string; candidate: string }[]
|
||||
/** Pakete, die Ubuntu noch zurückhält: "phasing" (schrittweise Verteilung) oder "kept_back". */
|
||||
held_back?: { name: string; reason: string }[]
|
||||
// Motor und llama-swap
|
||||
installed_build?: number | null
|
||||
latest_build?: number | null
|
||||
latest_tag?: string | null
|
||||
name?: string | null
|
||||
url?: string | null
|
||||
// Hermes
|
||||
branch?: string | null
|
||||
installed_version?: string | null
|
||||
behind?: number
|
||||
commits?: { hash: string; subject: string; when: string }[]
|
||||
// Zusammenfassung durchs Hirn (Motor, llama-swap, Hermes) — nur, wenn es Neues gibt
|
||||
summary?: string
|
||||
/** Musst du etwas tun? false = nein, true = ja (siehe action_text), null = unbekannt. */
|
||||
action_needed?: boolean | null
|
||||
action_text?: string
|
||||
}
|
||||
|
||||
export interface Nutzung {
|
||||
@@ -120,14 +157,28 @@ export interface Modell {
|
||||
capabilities?: { vision?: boolean; tools?: boolean; params_b?: number } & Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Zustände eines Auftrags, wie services/jobengine.py sie schreibt („canceled“ mit einem l). */
|
||||
export type JobZustand = "queued" | "running" | "done" | "failed" | "canceled"
|
||||
|
||||
/** Ein Hintergrund-Auftrag (Update, Modell-Download, Suche) aus services/jobengine.py. */
|
||||
export interface Job {
|
||||
id: string
|
||||
label: string
|
||||
state: "running" | "done" | "failed" | "cancelled" | string
|
||||
state: JobZustand
|
||||
/** "maintenance" für Updates (davon läuft immer nur eins). Downloads und die Suche haben keine. */
|
||||
group: string | null
|
||||
log: string[]
|
||||
returncode: number | null
|
||||
/** Unix-Sekunden */
|
||||
started_at: number
|
||||
finished_at: number | null
|
||||
canceled?: boolean
|
||||
/** Nur bei Downloads: Fortschritt 0…100 und die Bytes dazu. */
|
||||
progress?: number | null
|
||||
log?: string[]
|
||||
started?: number
|
||||
finished?: number | null
|
||||
total_bytes?: number
|
||||
done_bytes?: number
|
||||
rate_bps?: number
|
||||
eta_s?: number
|
||||
}
|
||||
|
||||
export interface Sicherung {
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
// Nur für die shadcn-CLI: components.json nennt „@/lib/utils“ als Ort von cn(), und neu
|
||||
// hinzugefügte Bausteine importieren es von hier. Die Oberfläche selbst importiert direkt aus „cn“.
|
||||
export { cn } from "cn"
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// Anzeige-Logik der Wartungs-Ansichten (Updates, Aufträge, Installieren). Eigene Datei, weil
|
||||
// anzeige.ts im Start-Bündel liegt — was nur die nachgeladenen Seiten brauchen, gehört nicht dorthin.
|
||||
|
||||
import { laeuftNoch } from "./anzeige"
|
||||
import type { Job, UpdateDetails } from "./typen"
|
||||
import { gbText } from "./zeit"
|
||||
|
||||
// --- Updates ------------------------------------------------------------------------
|
||||
|
||||
/** „Läuft“ und „Neu“ aus den Update-Details lesbar machen (die Details sind schon geladen). */
|
||||
export function versionen(id: string, d: UpdateDetails): { laeuft: string; neu: string | null } {
|
||||
if (id === "os") return { laeuft: "Ubuntu", neu: d.count ? `${d.count} ${d.count === 1 ? "Paket" : "Pakete"}` : null }
|
||||
// Hermes kommt aus git: „Neu“ ist die Zahl der Änderungen seit dem eingespielten Stand.
|
||||
if (id === "hermes") {
|
||||
return {
|
||||
laeuft: d.installed_version ? `v${d.installed_version}` : "–",
|
||||
neu: d.behind ? `+${d.behind.toLocaleString("de-DE")} Änderungen` : null,
|
||||
}
|
||||
}
|
||||
const pre = id === "engine" ? "b" : "v"
|
||||
const laeuft = d.installed_build != null ? `${pre}${d.installed_build}` : "–"
|
||||
const neuer = d.latest_build != null && d.installed_build != null && d.latest_build > d.installed_build
|
||||
return { laeuft, neu: neuer ? `${pre}${d.latest_build}` : null }
|
||||
}
|
||||
|
||||
/** Sagen die Details, dass es Neues gibt? null = weiß man nicht (Prüfung gescheitert oder unvollständig). */
|
||||
export function detailsHabenNeues(id: string, d: UpdateDetails | undefined): boolean | null {
|
||||
if (!d || d.error) return null
|
||||
if (id === "os") return typeof d.count === "number" ? d.count > 0 : null
|
||||
if (id === "hermes") return typeof d.behind === "number" ? d.behind > 0 : null
|
||||
if (d.latest_build == null || d.installed_build == null) return null
|
||||
return d.latest_build > d.installed_build
|
||||
}
|
||||
|
||||
/** Eine gescheiterte Update-Prüfung in Worten („Konnte nicht prüfen: …“), ohne Befehlszeilen-Kauderwelsch. */
|
||||
export function pruefFehlerText(fehler: string): string {
|
||||
const f = fehler.trim()
|
||||
if (/timed out|timeout|zeitüberschreitung/i.test(f)) {
|
||||
return "Konnte nicht prüfen: Die Gegenstelle hat nicht rechtzeitig geantwortet. Später klappt es meist wieder."
|
||||
}
|
||||
if (/name resolution|name or service not known|network is unreachable|connection (refused|reset)|nicht erreichbar/i.test(f)) {
|
||||
return "Konnte nicht prüfen: Die Box kommt gerade nicht ins Internet."
|
||||
}
|
||||
return `Konnte nicht prüfen: ${f.length > 160 ? `${f.slice(0, 157)}…` : f}`
|
||||
}
|
||||
|
||||
// --- Aufträge (services/jobengine.py) ---------------------------------------------------
|
||||
|
||||
/** Ist der Auftrag ein Modell-Download? (Bis das Backend eine Gruppe setzt, verrät es nur die Beschriftung.) */
|
||||
export const jobDownload = (j: Pick<Job, "label" | "group">) => j.group === "download" || /^download\s/i.test(j.label)
|
||||
|
||||
/** Ist der Auftrag die Suche nach Updates (apt-get update)? Auch sie hat im Backend noch keine Gruppe. */
|
||||
export const jobSuche = (j: Pick<Job, "label" | "group">) => j.group === "pruefen" || j.label === "Nach Updates suchen"
|
||||
|
||||
/**
|
||||
* Darf man den Auftrag abbrechen? Nur Downloads: Sie laufen als eigener Prozess (`hf download`), den
|
||||
* der Abbruch wirklich beendet. Updates und die Suche laufen als Shell-Kette (`bash -c …`), und ob
|
||||
* ein Abbruch dort alles stoppt, ist nicht sicher — bei Updates liefen apt und die Update-Skripte
|
||||
* weiter, während die Karte „Abgebrochen“ zeigte. Scheitert ein Update, rollen Motor und llama-swap
|
||||
* selbst zurück.
|
||||
*/
|
||||
export const abbrechbar = (j: Pick<Job, "state" | "label" | "group">) => laeuftNoch(j) && jobDownload(j)
|
||||
|
||||
// Die Beschriftungen setzt das Backend (services/maintenance.py, routers/models.py) mit technischen
|
||||
// Namen; die Oberfläche sagt Motor, llama-swap, Hermes und Betriebssystem.
|
||||
const TITEL: [RegExp, string][] = [
|
||||
[/^download\s+(.+)$/i, "Download $1"],
|
||||
[/^OS-Update \(apt\)$/, "Update Betriebssystem"],
|
||||
[/^Engine-Update \(llama\.cpp Vulkan\)$/, "Update Motor (llama.cpp)"],
|
||||
[/^Router-Update \(llama-swap\)$/, "Update llama-swap"],
|
||||
[/^Hermes-Agent-Update$/, "Update Hermes"],
|
||||
]
|
||||
const TEILE: [string, string][] = [
|
||||
["Engine (llama.cpp)", "Motor"],
|
||||
["Router (llama-swap)", "llama-swap"],
|
||||
["Hermes-Agent", "Hermes"],
|
||||
["OS (apt)", "Betriebssystem"],
|
||||
]
|
||||
|
||||
/** Die Beschriftung eines Auftrags in den Worten der Oberfläche. Unbekanntes bleibt, wie es ist. */
|
||||
export function jobTitel(j: Pick<Job, "label">): string {
|
||||
for (const [muster, ersatz] of TITEL) if (muster.test(j.label)) return j.label.replace(muster, ersatz)
|
||||
// „Alle aktualisieren (Engine (llama.cpp) → Router (llama-swap) → Hermes-Agent → OS (apt))“
|
||||
if (j.label.startsWith("Alle aktualisieren")) return TEILE.reduce((t, [alt, neu]) => t.split(alt).join(neu), j.label)
|
||||
return j.label
|
||||
}
|
||||
|
||||
/** „noch etwa 6 min“ aus der geschätzten Restzeit in Sekunden. */
|
||||
export function restzeit(s: number): string {
|
||||
if (s < 60) return "noch unter einer Minute"
|
||||
const min = Math.round(s / 60)
|
||||
if (min < 60) return `noch etwa ${min} min`
|
||||
const h = Math.floor(min / 60)
|
||||
const rest = min % 60
|
||||
return rest ? `noch etwa ${h} h ${rest} min` : `noch etwa ${h} h`
|
||||
}
|
||||
|
||||
/** „Läuft seit 12 min“ / „Wartet seit ein paar Sekunden“ für Aufträge ohne Fortschrittsanzeige. */
|
||||
export function laufText(j: Pick<Job, "state" | "started_at">, jetzt = Date.now() / 1000): string {
|
||||
const s = Math.max(0, jetzt - j.started_at)
|
||||
const min = Math.floor(s / 60)
|
||||
const dauer = s < 60 ? "ein paar Sekunden" : min < 60 ? `${min} min` : `${Math.floor(min / 60)} h ${min % 60} min`
|
||||
return `${j.state === "queued" ? "Wartet" : "Läuft"} seit ${dauer}`
|
||||
}
|
||||
|
||||
/** „42 % · 12,3 GB von 29,1 GB · noch etwa 6 min“ — oder null, wenn der Auftrag keinen Fortschritt kennt. */
|
||||
export function fortschrittText(j: Job): string | null {
|
||||
if (j.progress == null) return null
|
||||
const teile = [`${Math.round(j.progress)} %`]
|
||||
if (j.total_bytes) teile.push(`${gbText(j.done_bytes ?? 0, 1)} von ${gbText(j.total_bytes, 1)}`)
|
||||
if (j.eta_s != null && laeuftNoch(j)) teile.push(restzeit(j.eta_s))
|
||||
return teile.join(" · ")
|
||||
}
|
||||
|
||||
/** Letzte aussagekräftige Zeile des Protokolls (nicht die Befehlszeile „$ …“, die der Auftrag zuerst schreibt). */
|
||||
export function letzteMeldung(j: Pick<Job, "log">): string | null {
|
||||
for (let i = (j.log?.length ?? 0) - 1; i >= 0; i--) {
|
||||
const z = j.log[i].trim()
|
||||
if (z && !z.startsWith("$ ")) return z
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Aufträge die Oberfläche zeigt: alle laufenden und wartenden (egal aus welcher Gruppe),
|
||||
* dazu die drei zuletzt beendeten der letzten 24 Stunden — damit man sieht, wie es ausging.
|
||||
*/
|
||||
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 beendet = jobs
|
||||
.filter((j) => !laeuftNoch(j) && jetzt - (j.finished_at ?? j.started_at) < 86_400)
|
||||
.sort((a, b) => (b.finished_at ?? b.started_at) - (a.finished_at ?? a.started_at))
|
||||
.slice(0, 3)
|
||||
return [...laufend, ...beendet]
|
||||
}
|
||||
|
||||
// --- Installieren -------------------------------------------------------------------
|
||||
|
||||
/** Wer eine Rolle heute innehat — für den Hinweis vor dem Installieren. */
|
||||
export interface RollenInhaber {
|
||||
name: string
|
||||
/** Hängt ein Bild-Zwilling an seinen Gewichten (vision, coder-bild)? */
|
||||
zwilling: boolean
|
||||
/** Rollen, die das Modell außerdem trägt (der Coder auch „heavy“) — die behält es. */
|
||||
weitere: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Hinweis vor dem Installieren mit Rolle: Wen ersetzt das neue Modell? Nach dem Download wandert
|
||||
* nur dieser eine Rollen-Alias um (services/llamaswap.set_role_alias): Das bisherige Modell bleibt
|
||||
* auf der Platte, behält seine übrigen Rollen, und sein Bild-Zwilling zeigt weiter auf dessen Gewichte.
|
||||
*/
|
||||
export function rollenErsatzText(neu: string, rolle: string, bisher: RollenInhaber | undefined): string {
|
||||
if (!bisher) return `Nach dem Download übernimmt ${neu} die Rolle „${rolle}“.`
|
||||
const behaelt = bisher.weitere.length
|
||||
? ` Die Rolle${bisher.weitere.length === 1 ? "" : "n"} „${bisher.weitere.join("“, „")}“ behält es.`
|
||||
: ""
|
||||
return (
|
||||
`Achtung: Nach dem Download übernimmt ${neu} die Rolle „${rolle}“ und ersetzt damit ${bisher.name}. ` +
|
||||
`Das bisherige Modell bleibt auf der Platte.${behaelt}` +
|
||||
(bisher.zwilling ? " Bilder liest vorerst weiter sein Bild-Zwilling, also noch das bisherige Modell." : "")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Was das Eintragen in llama-swap unterwegs anrichtet: llama-swap läuft mit `--watch-config` und lädt
|
||||
* bei jeder Änderung sofort neu (services/warmer.py, deploy/coder-vollkontext.sh) — laufende Antworten
|
||||
* brechen ab, geladene Modelle fliegen raus. Mit Rolle schreibt das Backend nach dem Download noch einmal.
|
||||
*/
|
||||
export function installierenUnterbrechung(mitRolle: boolean): string {
|
||||
return (
|
||||
"Kurze Unterbrechung: Beim Eintragen lädt llama-swap sofort neu. Was Lucy oder OpenChamber gerade " +
|
||||
"schreiben, bricht ab, und die Modelle werden neu geladen." +
|
||||
(mitRolle ? " Mit Rolle passiert das nach dem Download noch einmal." : "")
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { flugplanZeit, gbText, laufzeit, seit, vor } from "./zeit"
|
||||
import { rolleVon, temperaturFarbe, versionen } from "./anzeige"
|
||||
import { rolleVon, temperaturFarbe } from "./anzeige"
|
||||
import { versionen } from "./wartung"
|
||||
import type { Modell } from "./typen"
|
||||
|
||||
// Fester Bezugspunkt: Mittwoch, 23.09.2026, 20:45 Uhr Berliner Zeit.
|
||||
|
||||
@@ -9,44 +9,6 @@ afterEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
// jsdom rechnet kein Layout: JEDES Element ist 0x0, und es gibt keinen ResizeObserver.
|
||||
// Messende Komponenten (Recharts' ResponsiveContainer, der Kraftgraph) zeichnen daraufhin
|
||||
// gar nichts und warnen "width(0) and height(0)".
|
||||
//
|
||||
// Diese Attrappe meldet den Beobachtern EINMAL eine plausible Groesse, damit solche
|
||||
// Komponenten ueberhaupt rendern. Sie ist bewusst konstant: Tests hier pruefen Logik und
|
||||
// Struktur, nie gemessene Pixel — auf echte Layout-Werte darf sich kein Test stuetzen.
|
||||
const TEST_BREITE = 800
|
||||
const TEST_HOEHE = 400
|
||||
|
||||
class TestResizeObserver implements ResizeObserver {
|
||||
constructor(private rueckruf: ResizeObserverCallback) {}
|
||||
|
||||
observe(ziel: Element) {
|
||||
const rect = {
|
||||
width: TEST_BREITE, height: TEST_HOEHE, top: 0, left: 0,
|
||||
right: TEST_BREITE, bottom: TEST_HOEHE, x: 0, y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRectReadOnly
|
||||
const eintrag = {
|
||||
target: ziel,
|
||||
contentRect: rect,
|
||||
borderBoxSize: [{ inlineSize: TEST_BREITE, blockSize: TEST_HOEHE }],
|
||||
contentBoxSize: [{ inlineSize: TEST_BREITE, blockSize: TEST_HOEHE }],
|
||||
devicePixelContentBoxSize: [{ inlineSize: TEST_BREITE, blockSize: TEST_HOEHE }],
|
||||
} as unknown as ResizeObserverEntry
|
||||
// Asynchron wie das Original — ein synchroner Rueckruf im Konstruktor-Pfad wuerde
|
||||
// React mitten im Rendern aktualisieren.
|
||||
queueMicrotask(() => this.rueckruf([eintrag], this))
|
||||
}
|
||||
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = TestResizeObserver as unknown as typeof ResizeObserver
|
||||
|
||||
// Denselben Dienst fuer Komponenten, die direkt messen statt zu beobachten.
|
||||
for (const [name, wert] of [["offsetWidth", TEST_BREITE], ["offsetHeight", TEST_HOEHE]] as const) {
|
||||
Object.defineProperty(HTMLElement.prototype, name, { configurable: true, value: wert })
|
||||
}
|
||||
// Bis 09/2026 stand hier eine ResizeObserver-Attrappe fuer Recharts und den Kraftgraph. Beide
|
||||
// sind mit dem Box-Wart-Umbau entfallen, und Radix' Dialog misst nichts. Braucht ein kuenftiger
|
||||
// Baustein wieder Layout-Masse, meldet jsdom das deutlich ("ResizeObserver is not defined").
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import { useState } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { cn } from "cn"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import { useDienste, useProtokoll } from "@/lib/abfragen"
|
||||
import { type Dienst, useDienste, useProtokoll } from "@/lib/abfragen"
|
||||
import { fehlerText } from "@/lib/anzeige"
|
||||
import { post } from "@/lib/api"
|
||||
import { melden } from "@/lib/meldungen"
|
||||
import { cn } from "cn"
|
||||
import type { AktionsErgebnis } from "@/lib/typen"
|
||||
|
||||
/** Das Protokoll eines Dienstes, direkt unter seiner Zeile — am Handy sonst weit unten außer Sicht. */
|
||||
function Protokoll({ unit }: { unit: string }) {
|
||||
const protokoll = useProtokoll(unit)
|
||||
let text: string
|
||||
if (protokoll.isPending) text = "Wird gelesen …"
|
||||
else if (protokoll.isError) text = `Das Protokoll lässt sich gerade nicht lesen: ${protokoll.error.message}`
|
||||
else if (protokoll.data.ok === false) text = protokoll.data.err || protokoll.data.text || "Das Protokoll lässt sich nicht lesen."
|
||||
else text = protokoll.data.text?.trim() || "Noch keine Einträge."
|
||||
return (
|
||||
<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">
|
||||
{text}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
/** Dienste der Box mit Protokoll und Neustart — die „Log-Ansicht der Dienste“. */
|
||||
export function Dienste({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) {
|
||||
@@ -13,64 +30,87 @@ export function Dienste({ offen, onSchliessen }: { offen: boolean; onSchliessen:
|
||||
const dienste = useDienste()
|
||||
const [gewaehlt, setGewaehlt] = useState<string | null>(null)
|
||||
const [bestaetigen, setBestaetigen] = useState<string | null>(null)
|
||||
const protokoll = useProtokoll(gewaehlt)
|
||||
const [startet, setStartet] = useState<string | null>(null)
|
||||
|
||||
async function neustarten(unit: string) {
|
||||
async function neustarten(d: Dienst) {
|
||||
if (startet) return
|
||||
setBestaetigen(null)
|
||||
setStartet(d.unit)
|
||||
try {
|
||||
const e = await post<{ ok: boolean; err?: string }>("/api/maintenance/restart", { service: unit })
|
||||
if (e.ok) melden("erfolg", `${unit} startet neu.`)
|
||||
else melden("fehler", e.err || `${unit} ließ sich nicht neu starten.`)
|
||||
const e = await post<AktionsErgebnis>("/api/maintenance/restart", { service: d.unit })
|
||||
if (e.ok) melden("erfolg", `${d.name} ${d.schlaeft ? "wird geweckt" : "startet neu"}.`)
|
||||
else melden("fehler", `${d.name} ließ sich nicht starten: ${fehlerText(e)}`)
|
||||
qc.invalidateQueries({ queryKey: ["dienste"] })
|
||||
} catch (f) {
|
||||
melden("fehler", (f as Error).message)
|
||||
} finally {
|
||||
setStartet(null)
|
||||
}
|
||||
}
|
||||
|
||||
let inhalt
|
||||
if (dienste.isPending) {
|
||||
inhalt = <p role="status" className="px-4 text-[15px] text-text-2">Dienste werden gelesen …</p>
|
||||
} else if (dienste.isError && !dienste.data) {
|
||||
inhalt = <p role="alert" className="px-4 text-[15px] text-rot-text">Die Dienste lassen sich gerade nicht lesen: {dienste.error.message}</p>
|
||||
} else if (!dienste.data?.services.length) {
|
||||
inhalt = <p className="px-4 text-[15px] text-text-2">Keine Dienste gefunden.</p>
|
||||
} else {
|
||||
inhalt = (
|
||||
<ul className="m-0 flex list-none flex-col px-4 pb-4">
|
||||
{dienste.data.services.map((d) => (
|
||||
<li key={d.unit} className="flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5">
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
className={cn("size-2.5 shrink-0 rounded-full", d.ok ? "bg-gruen" : d.schlaeft ? "bg-text-3" : "bg-rot")}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[15px]">{d.name}</span>
|
||||
<span className="ziffern text-xs text-text-3">
|
||||
{d.unit} · {d.ok ? "läuft" : d.schlaeft ? "schläft (abgeschaltet)" : "antwortet nicht"}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={gewaehlt === d.unit ? "info" : "ghost"}
|
||||
size="sm"
|
||||
aria-expanded={gewaehlt === d.unit}
|
||||
onClick={() => setGewaehlt(gewaehlt === d.unit ? null : d.unit)}
|
||||
>
|
||||
Protokoll
|
||||
</Button>
|
||||
{bestaetigen === d.unit ? (
|
||||
<>
|
||||
<Button variant="gefahr" size="sm" onClick={() => neustarten(d)}>
|
||||
{d.schlaeft ? "Wirklich wecken?" : "Wirklich neu starten?"}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setBestaetigen(null)}>
|
||||
Nein
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" disabled={startet !== null} onClick={() => setBestaetigen(d.unit)}>
|
||||
{startet === d.unit ? "Startet …" : d.schlaeft ? "Wecken" : "Neu starten"}
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
{gewaehlt === d.unit && <Protokoll unit={d.unit} />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
|
||||
<SheetContent side="right" className="w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-2xl">
|
||||
<SheetContent side="right" className="gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="schild text-lg">Dienste und Protokolle</SheetTitle>
|
||||
<SheetDescription className="text-text-2">Welcher Dienst läuft, was er zuletzt geschrieben hat.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<ul className="m-0 flex list-none flex-col px-4 pb-2">
|
||||
{(dienste.data?.services ?? []).map((d) => (
|
||||
<li key={d.unit} className="flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5">
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
<span
|
||||
className={cn("size-2.5 shrink-0 rounded-full", d.ok ? "bg-gruen" : d.schlaeft ? "bg-text-3" : "bg-rot")}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[15px]">{d.name}</span>
|
||||
<span className="ziffern text-xs text-text-3">
|
||||
{d.unit} · {d.ok ? "läuft" : d.schlaeft ? "schläft (abgeschaltet)" : "antwortet nicht"}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex gap-2">
|
||||
<Button variant={gewaehlt === d.unit ? "info" : "ghost"} size="sm" onClick={() => setGewaehlt(d.unit)}>
|
||||
Protokoll
|
||||
</Button>
|
||||
{bestaetigen === d.unit ? (
|
||||
<Button variant="gefahr" size="sm" onClick={() => neustarten(d.unit)}>
|
||||
{d.schlaeft ? "Wirklich wecken?" : "Wirklich neu starten?"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => setBestaetigen(d.unit)}>
|
||||
{d.schlaeft ? "Wecken" : "Neu starten"}
|
||||
</Button>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{gewaehlt && (
|
||||
<pre className="ziffern mx-4 mb-4 max-h-[50vh] overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2">
|
||||
{protokoll.isFetching ? "Wird gelesen …" : protokoll.data?.text || protokoll.data?.err || "Kein Protokoll."}
|
||||
</pre>
|
||||
)}
|
||||
{inhalt}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
|
||||
@@ -20,8 +20,13 @@ export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchli
|
||||
enabled: offen,
|
||||
})
|
||||
const [token, setToken] = useState("")
|
||||
const [speichert, setSpeichert] = useState(false)
|
||||
const [loeschFrage, setLoeschFrage] = useState(false)
|
||||
|
||||
async function speichern(wert: string | null) {
|
||||
if (speichert) return
|
||||
setSpeichert(true)
|
||||
setLoeschFrage(false)
|
||||
try {
|
||||
await post("/api/maintenance/geheimnisse", { schluessel: "hf_token", wert })
|
||||
melden("erfolg", wert ? "Hugging-Face-Zugang gespeichert." : "Hugging-Face-Zugang gelöscht.")
|
||||
@@ -29,23 +34,34 @@ export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchli
|
||||
qc.invalidateQueries({ queryKey: ["geheimnisse"] })
|
||||
} catch (f) {
|
||||
melden("fehler", (f as Error).message)
|
||||
} finally {
|
||||
setSpeichert(false)
|
||||
}
|
||||
}
|
||||
|
||||
const s = stand.data
|
||||
// Solange der Stand lädt, weiß die Oberfläche nicht, ob ein Zugang hinterlegt ist — also nicht „Fehlt.“ sagen.
|
||||
let standText: string
|
||||
if (stand.isPending) standText = "Wird gelesen …"
|
||||
else if (!s) standText = `Lässt sich gerade nicht lesen${stand.error ? `: ${stand.error.message}` : "."}`
|
||||
else standText = s.hf_token_gesetzt ? "Ist hinterlegt." : "Fehlt."
|
||||
return (
|
||||
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
|
||||
<SheetContent side="right" className="w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-lg">
|
||||
<SheetContent side="right" className="gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-lg">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="schild text-lg">Einstellungen</SheetTitle>
|
||||
<SheetDescription className="text-text-2">Was die Box sich merkt.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<section className="flex flex-col gap-3 border-t border-linie px-4 pt-4">
|
||||
<h3 className="schild text-sm text-text-3">Hugging-Face-Zugang</h3>
|
||||
<p className="text-sm text-text-2">
|
||||
{s?.hf_token_gesetzt ? "Ist hinterlegt." : "Fehlt."} Nötig für gesperrte Modelle und schnellere Downloads.
|
||||
<p className="text-sm text-text-2" aria-live="polite">
|
||||
<span className={!s && !stand.isPending ? "text-rot-text" : undefined}>{standText}</span> Nötig für gesperrte Modelle und
|
||||
schnellere Downloads.
|
||||
{s?.hf_token_aus_env ? " Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen." : ""}
|
||||
</p>
|
||||
{s?.schreibbar === false && (
|
||||
<p className="text-sm text-bernstein">Die Box kann den Zugang gerade nicht speichern: Ihre Ablage ist nicht beschreibbar.</p>
|
||||
)}
|
||||
<form
|
||||
className="flex gap-2"
|
||||
onSubmit={(e) => {
|
||||
@@ -63,10 +79,21 @@ export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchli
|
||||
placeholder="hf_…"
|
||||
className="h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan"
|
||||
/>
|
||||
<Button type="submit" disabled={!token.trim() || s?.schreibbar === false}>Speichern</Button>
|
||||
<Button type="submit" disabled={!token.trim() || s?.schreibbar === false || speichert}>
|
||||
{speichert ? "Speichert …" : "Speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
{s?.hf_token_gesetzt && !s.hf_token_aus_env && (
|
||||
<Button variant="ghost" size="sm" className="self-start" onClick={() => speichern(null)}>Zugang löschen</Button>
|
||||
loeschFrage ? (
|
||||
<span className="flex flex-wrap gap-2">
|
||||
<Button variant="gefahr" size="sm" disabled={speichert} onClick={() => speichern(null)}>Wirklich löschen?</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setLoeschFrage(false)}>Nein</Button>
|
||||
</span>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="self-start" disabled={speichert} onClick={() => setLoeschFrage(true)}>
|
||||
Zugang löschen
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
<section className="flex flex-col gap-2 border-t border-linie px-4 pt-4">
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
import { useState } from "react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useRef, useState } from "react"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import { api, post } from "@/lib/api"
|
||||
import { melden } from "@/lib/meldungen"
|
||||
import { zahl } from "@/lib/zeit"
|
||||
import { useInstallieren } from "@/lib/abfragen"
|
||||
import { api } from "@/lib/api"
|
||||
import { installierenUnterbrechung, type RollenInhaber, rollenErsatzText } from "@/lib/wartung"
|
||||
import { gbText, zahl } from "@/lib/zeit"
|
||||
|
||||
interface Treffer { repo: string; downloads: number; likes: number }
|
||||
/** /api/hf/quants liefert heute nur die Namen. Kommen Größen dazu, zeigen Liste und Rückfrage sie an. */
|
||||
interface Quant { quant?: string; name?: string; size_bytes?: number; total_bytes?: number }
|
||||
|
||||
/**
|
||||
* Rollen, die sich beim Installieren gleich vergeben lassen (Rollen-Alias → Name in der Oberfläche).
|
||||
* Das Hirn steht hier (wie bisher) nicht zur Wahl; Hirn-Kandidaten testet das Radar nachts, bevor man sie übernimmt.
|
||||
*/
|
||||
const ROLLEN: Record<string, string> = { coder: "Coder" }
|
||||
|
||||
/** Wer die Rollen heute innehat (Schlüssel = Rollen-Alias). */
|
||||
export type RollenStand = Record<string, RollenInhaber | undefined>
|
||||
|
||||
/** Modelle selbst auf Hugging Face suchen und installieren — zusätzlich zum Radar. */
|
||||
export function ModellSuche({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) {
|
||||
const qc = useQueryClient()
|
||||
export function ModellSuche({ offen, onSchliessen, rollenJetzt }: {
|
||||
offen: boolean
|
||||
onSchliessen: () => void
|
||||
rollenJetzt: RollenStand
|
||||
}) {
|
||||
const installieren = useInstallieren()
|
||||
const [eingabe, setEingabe] = useState("")
|
||||
const [begriff, setBegriff] = useState("")
|
||||
const [repo, setRepo] = useState<string | null>(null)
|
||||
const [rolle, setRolle] = useState("")
|
||||
const [auswahl, setAuswahl] = useState<{ quant: string; bytes?: number } | null>(null)
|
||||
const unterwegs = useRef(false)
|
||||
|
||||
const treffer = useQuery({
|
||||
queryKey: ["hf-suche", begriff],
|
||||
@@ -28,24 +46,33 @@ export function ModellSuche({ offen, onSchliessen }: { offen: boolean; onSchlies
|
||||
enabled: !!repo,
|
||||
})
|
||||
|
||||
async function installieren(quant: string) {
|
||||
if (!repo) return
|
||||
try {
|
||||
await post("/api/models/install", { repo, quant, role: rolle || null })
|
||||
melden("erfolg", `${repo} (${quant}) wird heruntergeladen. Den Fortschritt siehst du unter Updates.`)
|
||||
qc.invalidateQueries({ queryKey: ["jobs"] })
|
||||
qc.invalidateQueries({ queryKey: ["models"] })
|
||||
onSchliessen()
|
||||
} catch (f) {
|
||||
melden("fehler", (f as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const quantListe = (quants.data?.quants ?? []).map((q) => (typeof q === "string" ? { quant: q } : q))
|
||||
const rollenName = rolle ? ROLLEN[rolle] ?? rolle : null
|
||||
const ersatz = rollenName && repo ? rollenErsatzText(repo, rollenName, rollenJetzt[rolle]) : null
|
||||
|
||||
function starten() {
|
||||
// Die Sperre gegen den Doppelklick: Solange ein Start unterwegs ist, geht kein zweiter los. Der
|
||||
// Ref greift sofort — der Zustand von useMutation erst nach dem nächsten Rendern.
|
||||
if (!repo || !auswahl || unterwegs.current) return
|
||||
unterwegs.current = true
|
||||
installieren.mutate(
|
||||
{ repo, quant: auswahl.quant, rolle: rolle || null },
|
||||
{
|
||||
onSuccess: (e) => {
|
||||
setAuswahl(null)
|
||||
if (e.ok !== false) onSchliessen()
|
||||
},
|
||||
onError: () => setAuswahl(null),
|
||||
onSettled: () => {
|
||||
unterwegs.current = false
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
|
||||
<SheetContent side="right" className="w-full gap-3 overflow-y-auto border-linie bg-panel sm:max-w-xl">
|
||||
<SheetContent side="right" className="gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="schild text-lg">Modelle selbst suchen</SheetTitle>
|
||||
<SheetDescription className="text-text-2">Auf Hugging Face suchen, Quantisierung wählen, installieren.</SheetDescription>
|
||||
@@ -69,32 +96,61 @@ export function ModellSuche({ offen, onSchliessen }: { offen: boolean; onSchlies
|
||||
<Button type="submit">Suchen</Button>
|
||||
</form>
|
||||
<div className="flex flex-col px-4 pb-4">
|
||||
{treffer.isFetching && <p className="py-3 text-sm text-text-2">Suche läuft …</p>}
|
||||
{!repo && treffer.data?.results.map((t) => (
|
||||
<button
|
||||
key={t.repo}
|
||||
type="button"
|
||||
onClick={() => setRepo(t.repo)}
|
||||
className="flex min-h-11 items-center justify-between gap-3 border-t border-linie py-2 text-left hover:text-cyan"
|
||||
>
|
||||
<span className="ziffern min-w-0 text-sm break-all">{t.repo}</span>
|
||||
<span className="ziffern shrink-0 text-xs text-text-3">{zahl(t.downloads)} ↓</span>
|
||||
</button>
|
||||
))}
|
||||
{!repo && (
|
||||
<>
|
||||
{treffer.isFetching && <p role="status" className="py-3 text-sm text-text-2">Suche läuft …</p>}
|
||||
{!treffer.isFetching && treffer.isError && (
|
||||
<p role="alert" className="py-3 text-sm text-rot-text">Die Suche ging nicht: {treffer.error.message}</p>
|
||||
)}
|
||||
{!treffer.isFetching && treffer.data?.results.length === 0 && (
|
||||
<p className="py-3 text-sm text-text-2">
|
||||
Keine Treffer. Versuch einen anderen Suchbegriff — oder Hugging Face ist gerade nicht erreichbar.
|
||||
</p>
|
||||
)}
|
||||
{treffer.data?.results.map((t) => (
|
||||
<button
|
||||
key={t.repo}
|
||||
type="button"
|
||||
onClick={() => setRepo(t.repo)}
|
||||
className="flex min-h-11 items-center justify-between gap-3 border-t border-linie py-2 text-left hover:text-cyan"
|
||||
>
|
||||
<span className="ziffern min-w-0 text-sm break-all">{t.repo}</span>
|
||||
<span className="ziffern shrink-0 text-xs text-text-3">{zahl(t.downloads)} ↓</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{repo && (
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="ziffern text-sm break-all">{repo}</span>
|
||||
<span className="ziffern min-w-0 text-sm break-all">{repo}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => setRepo(null)}>Zurück</Button>
|
||||
</div>
|
||||
<label className="flex items-center gap-3 text-sm text-text-2">
|
||||
<label className="flex flex-wrap items-center gap-3 text-sm text-text-2">
|
||||
Rolle
|
||||
<select value={rolle} onChange={(e) => setRolle(e.target.value)} className="h-11 rounded-lg border border-linie-stark bg-erhaben px-3 text-foreground">
|
||||
<select
|
||||
value={rolle}
|
||||
onChange={(e) => setRolle(e.target.value)}
|
||||
className="h-11 min-w-0 rounded-lg border border-linie-stark bg-erhaben px-3 text-foreground"
|
||||
>
|
||||
<option value="">ohne Rolle (nur installieren)</option>
|
||||
<option value="coder">Coder</option>
|
||||
{Object.entries(ROLLEN).map(([wert, name]) => (
|
||||
<option key={wert} value={wert}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{quants.isFetching && <p className="text-sm text-text-2">Quantisierungen werden gelesen …</p>}
|
||||
{ersatz && (
|
||||
<p className="m-0 rounded-lg border border-bernstein/60 bg-bernstein-grund px-3 py-2.5 text-sm leading-relaxed text-bernstein">
|
||||
{ersatz}
|
||||
</p>
|
||||
)}
|
||||
{quants.isFetching && <p role="status" className="text-sm text-text-2">Quantisierungen werden gelesen …</p>}
|
||||
{!quants.isFetching && quants.isError && (
|
||||
<p role="alert" className="text-sm text-rot-text">Die Dateien dieses Modells ließen sich nicht lesen: {quants.error.message}</p>
|
||||
)}
|
||||
{!quants.isFetching && quants.data && quantListe.length === 0 && (
|
||||
<p className="text-sm text-text-2">Hier gibt es keine GGUF-Dateien, die sich installieren lassen.</p>
|
||||
)}
|
||||
{quantListe.map((q) => {
|
||||
const name = q.quant ?? q.name ?? "?"
|
||||
const bytes = q.total_bytes ?? q.size_bytes
|
||||
@@ -102,8 +158,15 @@ export function ModellSuche({ offen, onSchliessen }: { offen: boolean; onSchlies
|
||||
<div key={name} className="flex items-center justify-between gap-3 border-t border-linie py-2">
|
||||
<span className="ziffern text-sm">{name}</span>
|
||||
<span className="flex items-center gap-3">
|
||||
{bytes ? <span className="ziffern text-xs text-text-3">{(bytes / 1024 ** 3).toFixed(1).replace(".", ",")} GB</span> : null}
|
||||
<Button variant="outline" size="sm" onClick={() => installieren(name)}>Installieren</Button>
|
||||
{bytes ? <span className="ziffern text-xs text-text-3">{gbText(bytes, 1)}</span> : null}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={installieren.isPending}
|
||||
onClick={() => setAuswahl({ quant: name, bytes })}
|
||||
>
|
||||
Installieren
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
@@ -111,6 +174,25 @@ export function ModellSuche({ offen, onSchliessen }: { offen: boolean; onSchlies
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Bestaetigen
|
||||
offen={auswahl !== null}
|
||||
titel="Modell installieren?"
|
||||
text={auswahl && repo
|
||||
? `${repo} (${auswahl.quant}${auswahl.bytes ? `, ${gbText(auswahl.bytes, 1)}` : ""}) wird heruntergeladen und in llama-swap eingetragen.` +
|
||||
(auswahl.bytes ? "" : " Die Größe siehst du beim Auftrag, sobald der Download läuft.")
|
||||
: ""}
|
||||
knopf={installieren.isPending ? "Wird gestartet …" : "Installieren"}
|
||||
gesperrt={installieren.isPending}
|
||||
onNein={() => setAuswahl(null)}
|
||||
onJa={starten}
|
||||
>
|
||||
{ersatz && (
|
||||
<p className="m-0 rounded-lg border border-bernstein/60 bg-bernstein-grund px-3 py-2.5 text-[15px] leading-relaxed text-bernstein">
|
||||
{ersatz}
|
||||
</p>
|
||||
)}
|
||||
<p className="m-0 text-[15px] leading-relaxed text-text-2">{installierenUnterbrechung(!!rolle)}</p>
|
||||
</Bestaetigen>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useLocation } from "@tanstack/react-router"
|
||||
import { Search } from "lucide-react"
|
||||
import { Auftraege } from "@/components/cockpit/Auftraege"
|
||||
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
useAufraeumen, useAufraeumenLoeschen, useModellLaden, useModelle, useNutzung, useRadar, useRadarAktion,
|
||||
useAufraeumen, useAufraeumenLoeschen, useJobs, useModellLaden, useModelle, useNutzung, useRadar, useRadarAktion,
|
||||
useRadarSuche, useStart,
|
||||
} from "@/lib/abfragen"
|
||||
import { messwert, RADAR_STATUS_TEXT, rolleVon } from "@/lib/anzeige"
|
||||
import { ApiFehler } from "@/lib/api"
|
||||
import { useLetzteMetrik } from "@/lib/strom"
|
||||
import type { AufraeumKandidat, Modell, Nutzung, RadarKandidat, RadarTest } from "@/lib/typen"
|
||||
import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit"
|
||||
@@ -98,8 +101,18 @@ function Rolle({ titel, modell, zwilling, laeuft, nutzer }: {
|
||||
)
|
||||
}
|
||||
|
||||
function NutzungsPanel({ nutzung }: { nutzung: Nutzung | undefined }) {
|
||||
if (!nutzung) return null
|
||||
function NutzungsPanel({ nutzung, laedt, fehler }: { nutzung: Nutzung | undefined; laedt: boolean; fehler: string | null }) {
|
||||
if (!nutzung) {
|
||||
return (
|
||||
<Panel titel="Wer nutzt die Modelle">
|
||||
{laedt ? (
|
||||
<p className="text-[15px] text-text-2">Wird gelesen …</p>
|
||||
) : (
|
||||
<p role="alert" className="text-[15px] text-rot-text">Die Nutzung lässt sich gerade nicht lesen{fehler ? `: ${fehler}` : "."}</p>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
const max = Math.max(1, ...nutzung.absender.map((a) => a.anfragen))
|
||||
const stunden = nutzung.letzte_24h.map((s) => Object.values(s.je_absender).reduce((a, b) => a + b, 0))
|
||||
const maxStunde = Math.max(1, ...stunden)
|
||||
@@ -201,8 +214,10 @@ function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | unde
|
||||
? `${k.name} wird Lucys Hirn${heute ? ` und löst ${heute} ab` : ""}. NerdQuiz und OpenChamber nutzen ab dann ebenfalls das neue Modell. Hermes startet dafür kurz neu, Lucy ist etwa eine Minute nicht erreichbar. Das alte Modell bleibt auf der Platte.`
|
||||
: `${k.name} wird der Coder${heute ? ` und löst ${heute} ab` : ""}. OpenChamber plant und baut ab dann damit. Das alte Modell bleibt auf der Platte.`}
|
||||
knopf="Übernehmen"
|
||||
gesperrt={aktion.isPending}
|
||||
onNein={() => setFrage(null)}
|
||||
onJa={() => {
|
||||
if (frage !== "uebernehmen") return // zweiter Klick eines Doppelklicks, der Dialog schließt schon
|
||||
aktion.mutate({ id: k.id, aktion: "uebernehmen", name: k.name })
|
||||
setFrage(null)
|
||||
}}
|
||||
@@ -213,8 +228,10 @@ function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | unde
|
||||
titel={`${k.name} verwerfen?`}
|
||||
text="Die heruntergeladenen Dateien werden gelöscht, und das Radar testet dieses Modell nicht noch einmal."
|
||||
knopf="Verwerfen"
|
||||
gesperrt={aktion.isPending}
|
||||
onNein={() => setFrage(null)}
|
||||
onJa={() => {
|
||||
if (frage !== "verwerfen") return
|
||||
aktion.mutate({ id: k.id, aktion: "verwerfen", name: k.name })
|
||||
setFrage(null)
|
||||
}}
|
||||
@@ -226,10 +243,16 @@ function Kandidat({ k, test, heute }: { k: RadarKandidat; test: RadarTest | unde
|
||||
function RadarPanel({ hirn, coder }: { hirn: string | undefined; coder: string | undefined }) {
|
||||
const radar = useRadar()
|
||||
const suche = useRadarSuche()
|
||||
if (radar.isError) {
|
||||
if (radar.isError && !radar.data) {
|
||||
// 404 heißt: Diese Box hat kein Radar. Alles andere ist ein Fehler, kein fehlendes Radar.
|
||||
const fehlt = radar.error instanceof ApiFehler && radar.error.status === 404
|
||||
return (
|
||||
<Panel titel="Modell-Radar" id="radar">
|
||||
<p className="text-[15px] text-text-2">Das Radar ist auf dieser Box noch nicht eingerichtet.</p>
|
||||
<Panel titel="Modell-Radar" id="radar" className="scroll-mt-4">
|
||||
{fehlt ? (
|
||||
<p className="text-[15px] text-text-2">Das Radar ist auf dieser Box noch nicht eingerichtet.</p>
|
||||
) : (
|
||||
<p role="alert" className="text-[15px] text-rot-text">Das Radar antwortet gerade nicht: {radar.error.message}</p>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -238,7 +261,12 @@ function RadarPanel({ hirn, coder }: { hirn: string | undefined; coder: string |
|
||||
<Panel
|
||||
titel="Modell-Radar"
|
||||
id="radar"
|
||||
rechts={<Button variant="info" size="sm" onClick={() => suche.mutate(undefined)} disabled={suche.isPending}>Jetzt suchen</Button>}
|
||||
className="scroll-mt-4"
|
||||
rechts={
|
||||
<Button variant="info" size="sm" onClick={() => suche.mutate(undefined)} disabled={suche.isPending}>
|
||||
{suche.isPending ? "Sucht …" : "Jetzt suchen"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<p className="-mt-2 text-sm text-text-2">
|
||||
Testet nachts zwischen 00:30 und 02:30 selbst, höchstens einen Kandidaten pro Woche.
|
||||
@@ -281,7 +309,13 @@ function AufraeumenPanel() {
|
||||
const [frage, setFrage] = useState<AufraeumKandidat | null>(null)
|
||||
const kandidaten = liste.data?.kandidaten ?? []
|
||||
const summe = kandidaten.reduce((s, k) => s + k.groesse_gb, 0)
|
||||
if (liste.isError) return null
|
||||
if (liste.isError && !liste.data) {
|
||||
return (
|
||||
<Panel titel="Aufräumen">
|
||||
<p role="alert" className="text-[15px] text-rot-text">Was sich aufräumen ließe, lässt sich gerade nicht lesen: {liste.error.message}</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Panel
|
||||
titel="Aufräumen"
|
||||
@@ -323,6 +357,7 @@ function AufraeumenPanel() {
|
||||
titel={`${frage?.name ?? ""} löschen?`}
|
||||
text={`${frage?.hinweis ?? ""} Es werden ${frage?.groesse_gb.toLocaleString("de-DE") ?? ""} GB frei. Das lässt sich nicht rückgängig machen — zurück ginge es nur mit einem neuen Download.`}
|
||||
knopf="Endgültig löschen"
|
||||
gesperrt={loeschen.isPending}
|
||||
onNein={() => setFrage(null)}
|
||||
onJa={() => {
|
||||
if (frage) loeschen.mutate(frage)
|
||||
@@ -336,10 +371,22 @@ function AufraeumenPanel() {
|
||||
export function ModelleSeite() {
|
||||
const modelle = useModelle()
|
||||
const nutzung = useNutzung()
|
||||
const radar = useRadar()
|
||||
const jobs = useJobs()
|
||||
const [suche, setSuche] = useState(false)
|
||||
|
||||
// „Ansehen“ im Radar-Kasten der Startseite führt nach /modelle#radar. Der Router springt beim
|
||||
// Rendern selbst zum Anker — da steht hier aber noch „wird gelesen“. Also springen wir, sobald
|
||||
// alles über dem Radar geladen ist und die Seite nicht mehr wächst.
|
||||
const hash = useLocation({ select: (l) => l.hash })
|
||||
const bereit = !!modelle.data && !nutzung.isPending && !radar.isPending && !jobs.isPending
|
||||
useEffect(() => {
|
||||
if (bereit && hash === "radar") document.getElementById("radar")?.scrollIntoView({ block: "start" })
|
||||
}, [bereit, hash])
|
||||
|
||||
if (modelle.isPending) return <Zustandsfeld text="Modelle werden gelesen …" />
|
||||
if (modelle.error || !modelle.data) return <Zustandsfeld fehler text={`Die Modell-Liste fehlt: ${modelle.error?.message ?? "keine Daten"}`} />
|
||||
// Scheitert nur ein späteres Nachladen, bleibt der letzte Stand stehen.
|
||||
if (!modelle.data) return <Zustandsfeld fehler text={`Die Modell-Liste fehlt: ${modelle.error?.message ?? "keine Daten"}`} />
|
||||
|
||||
const liste = modelle.data.models
|
||||
const laufend = modelle.data.running
|
||||
@@ -356,19 +403,21 @@ export function ModelleSeite() {
|
||||
return (
|
||||
<>
|
||||
<Speicher modelle={liste} laufend={laufend} />
|
||||
<Auftraege alsPanel />
|
||||
<section aria-label="Rollen" className="grid gap-5 md:grid-cols-3 md:gap-6">
|
||||
<Rolle titel="Hirn" modell={hirn} zwilling={hirnZwilling} laeuft={!!hirn && laufend.includes(hirn.name)} nutzer="Lucy, NerdQuiz und OpenChamber für Kleinkram" />
|
||||
<Rolle titel="Coder" modell={coder} zwilling={coderZwilling} laeuft={!!coder && laufend.includes(coder.name)} nutzer="OpenChamber zum Planen und Bauen und Lucys Delegation" />
|
||||
<Rolle titel="Dritte Rolle" laeuft={false} nutzer="" />
|
||||
</section>
|
||||
<div className="grid gap-5 md:gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.3fr)]">
|
||||
<NutzungsPanel nutzung={nutzung.data} />
|
||||
<NutzungsPanel nutzung={nutzung.data} laedt={nutzung.isPending} fehler={nutzung.error?.message ?? null} />
|
||||
<RadarPanel hirn={hirn?.name} coder={coder?.name} />
|
||||
</div>
|
||||
<Panel
|
||||
titel="Weitere Einträge"
|
||||
rechts={<Button variant="outline" size="sm" onClick={() => setSuche(true)}><Search aria-hidden /> Modelle selbst suchen</Button>}
|
||||
>
|
||||
{weitere.length === 0 && <p className="text-[15px] text-text-2">Keine weiteren Einträge.</p>}
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{weitere.map((m) => (
|
||||
<li key={m.name} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-t border-linie py-2.5 sm:grid-cols-[minmax(0,1fr)_120px_160px]">
|
||||
@@ -385,7 +434,15 @@ export function ModelleSeite() {
|
||||
</ul>
|
||||
</Panel>
|
||||
<AufraeumenPanel />
|
||||
<ModellSuche offen={suche} onSchliessen={() => setSuche(false)} />
|
||||
<ModellSuche
|
||||
offen={suche}
|
||||
onSchliessen={() => setSuche(false)}
|
||||
rollenJetzt={{
|
||||
coder: coder
|
||||
? { name: coder.name, zwilling: !!coderZwilling, weitere: coder.aliases.filter((a) => a.toLowerCase() !== "coder") }
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,9 @@ export function StartSeite() {
|
||||
const radar = useRadar()
|
||||
|
||||
if (isPending) return <Zustandsfeld text="Die Box meldet sich gleich …" />
|
||||
if (error || !start) return <Zustandsfeld fehler text={`MC2 antwortet nicht: ${error?.message ?? "keine Daten"}`} />
|
||||
// Scheitert nur ein späteres Nachladen, bleibt der letzte Stand stehen — die Verbindungsanzeige
|
||||
// oben sagt dann „Getrennt“.
|
||||
if (!start) return <Zustandsfeld fehler text={`MC2 antwortet nicht: ${error?.message ?? "keine Daten"}`} />
|
||||
|
||||
const offen = start.hinweise.length
|
||||
return (
|
||||
|
||||
+324
-124
@@ -1,5 +1,8 @@
|
||||
import { useState } from "react"
|
||||
import type { UseQueryResult } from "@tanstack/react-query"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
import { cn } from "cn"
|
||||
import { Auftraege } from "@/components/cockpit/Auftraege"
|
||||
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
@@ -8,8 +11,9 @@ import {
|
||||
type UpdateLauf, useFreigeben, useJetztSichern, useJobs, useSicherungen, useStart, useUpdateDetails,
|
||||
useUpdateStarten, useUpdateVerlauf, useZurueckspielen,
|
||||
} from "@/lib/abfragen"
|
||||
import { versionen } from "@/lib/anzeige"
|
||||
import type { Festgehalten, Job, Sicherung } from "@/lib/typen"
|
||||
import { laeuftNoch } from "@/lib/anzeige"
|
||||
import { detailsHabenNeues, jobSuche, pruefFehlerText, versionen } from "@/lib/wartung"
|
||||
import type { Festgehalten, Sicherung, UpdateDetails } from "@/lib/typen"
|
||||
import { flugplanZeit } from "@/lib/zeit"
|
||||
|
||||
const BAUSTEINE = [
|
||||
@@ -19,56 +23,183 @@ const BAUSTEINE = [
|
||||
{ id: "hermes", name: "Hermes" },
|
||||
] as const
|
||||
|
||||
function BausteinZeile({ id, name, gelesen, hatUpdate, fest, onStart, onFreigeben, laeuftGerade }: {
|
||||
/** Was ein einzelnes Update tut — steht in der Rückfrage, bevor es losgeht. */
|
||||
const EINZEL_TEXT: Record<string, string> = {
|
||||
os: "Die Ubuntu-Pakete werden eingespielt, danach prüft die Box, ob die Modelle noch antworten. Das kann einige Minuten dauern.",
|
||||
engine: "Der Motor wird ersetzt, llama-swap startet dafür neu und die Modelle sind kurz nicht erreichbar. Klappt die Prüfung danach nicht, holt die Box den alten Stand von selbst zurück.",
|
||||
swap: "llama-swap wird ersetzt und startet neu, die Modelle sind kurz nicht erreichbar. Klappt die Prüfung danach nicht, holt die Box den alten Stand von selbst zurück.",
|
||||
hermes: "Vorher wird gesichert. Dann wird Hermes aktualisiert und startet neu — Lucy ist dabei ein paar Minuten nicht erreichbar.",
|
||||
}
|
||||
|
||||
/** Platzhalter, solange die Box noch nachsieht — statt „…“ oder eines voreiligen „neu“. */
|
||||
function Skelett({ breit = "w-16" }: { breit?: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center">
|
||||
<span aria-hidden className={cn("inline-block h-3.5 rounded bg-linie motion-safe:animate-pulse", breit)} />
|
||||
<span className="sr-only">wird geprüft</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Die Zusammenfassung des Hirns: drei Zeilen sichtbar, der Rest auf Knopfdruck. */
|
||||
function Zusammenfassung({ text }: { text: string }) {
|
||||
const [offen, setOffen] = useState(false)
|
||||
const lang = text.split("\n").length > 3 || text.length > 240
|
||||
return (
|
||||
<div className="flex flex-col items-start">
|
||||
<p className={cn("m-0 whitespace-pre-line", lang && !offen && "line-clamp-3")}>{text}</p>
|
||||
{lang && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={offen}
|
||||
onClick={() => setOffen(!offen)}
|
||||
className="-mb-2 inline-flex h-11 items-center text-sm text-cyan hover:underline"
|
||||
>
|
||||
{offen ? "Weniger zeigen" : "Ganze Zusammenfassung zeigen"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Die Paketliste des Betriebssystems, aufklappbar — dazu, was Ubuntu noch zurückhält. */
|
||||
function Paketliste({ d }: { d: UpdateDetails }) {
|
||||
const pakete = d.packages ?? []
|
||||
const zurueck = d.held_back ?? []
|
||||
if (pakete.length === 0 && zurueck.length === 0) return null
|
||||
return (
|
||||
<details className="group">
|
||||
<summary className="inline-flex h-11 cursor-pointer items-center gap-1 text-sm text-cyan hover:underline">
|
||||
<ChevronRight aria-hidden className="size-4 transition-transform group-open:rotate-90" />
|
||||
{pakete.length === 1 ? "Das Paket ansehen" : `Alle ${pakete.length} Pakete ansehen`}
|
||||
</summary>
|
||||
<ul className="ziffern m-0 flex max-h-64 list-none flex-col gap-1 overflow-auto rounded-md border border-linie bg-[#0b0d0f] p-2.5 text-xs">
|
||||
{pakete.map((p) => (
|
||||
<li key={p.name} className="break-all">
|
||||
<span className="text-foreground">{p.name}</span>{" "}
|
||||
<span className="text-text-3">{p.current} → {p.candidate}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{zurueck.length > 0 && (
|
||||
<p className="mt-2 text-xs text-text-3">
|
||||
Dazu {zurueck.length === 1 ? "kommt ein Paket" : `kommen ${zurueck.length} Pakete`}, die Ubuntu noch
|
||||
zurückhält ({zurueck.map((p) => p.name).join(", ")}). Die kommen von selbst, wenn Ubuntu sie freigibt.
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
)
|
||||
}
|
||||
|
||||
/** Das Urteil des Hirns aus den Versionshinweisen: Muss der Besitzer etwas tun? */
|
||||
function Urteil({ d }: { d: UpdateDetails }) {
|
||||
if (d.action_needed === false) return <Chip art="gruen">Nichts zu tun</Chip>
|
||||
if (d.action_needed !== true) return null
|
||||
return (
|
||||
<span className="flex flex-col items-start gap-1.5">
|
||||
<Chip art="bernstein">Du musst etwas tun</Chip>
|
||||
{d.action_text && <span className="text-bernstein">{d.action_text}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function zusammenfassung(summary: string | undefined): string | null {
|
||||
const s = summary?.trim()
|
||||
if (!s) return null
|
||||
// Das Backend meldet ein nicht antwortendes Hirn als „(Zusammenfassung nicht verfügbar: <Fehler>)“.
|
||||
if (s.startsWith("(Zusammenfassung nicht verfügbar")) return "Die Zusammenfassung ist gerade nicht verfügbar."
|
||||
return s
|
||||
}
|
||||
|
||||
function BausteinZeile({ id, name, details, hatUpdate, neuLautStart, fest, gesperrt, onStart, onFreigeben }: {
|
||||
id: string
|
||||
name: string
|
||||
gelesen: boolean
|
||||
details: UseQueryResult<UpdateDetails, Error>
|
||||
/** Gibt es Neues? Frische Details schlagen den gecachten Stand der Startseite (siehe UpdatesSeite). */
|
||||
hatUpdate: boolean
|
||||
/** Was /api/start über diesen Baustein sagt („3 Pakete“, „neuer Build“), undefined = nichts Neues. */
|
||||
neuLautStart: string | undefined
|
||||
fest: Festgehalten | undefined
|
||||
gesperrt: boolean
|
||||
onStart: () => void
|
||||
onFreigeben: () => void
|
||||
laeuftGerade: boolean
|
||||
}) {
|
||||
const { data, isPending } = useUpdateDetails(id)
|
||||
const v = versionen(id, data)
|
||||
const neu = hatUpdate ? v.neu ?? "neu" : null
|
||||
const was = !gelesen
|
||||
? "Wird gerade geprüft …"
|
||||
: !hatUpdate
|
||||
? "Aktuell."
|
||||
: data?.summary?.trim() || (id === "os" && data?.count ? "Paketliste unten in den Details." : isPending ? "Wird gelesen …" : "")
|
||||
const neuText = neu ? <span className="text-cyan">{neu}</span> : <span className="text-text-3">–</span>
|
||||
// Am PC eine Tabellenzeile, am Handy ein Block: Name und Versionen oben, darunter was sich
|
||||
// ändert, darunter die Knöpfe — ohne seitliches Scrollen.
|
||||
const d = details.data
|
||||
const laedt = details.isPending
|
||||
const fehler = details.isError ? details.error.message : d?.error
|
||||
const v = d ? versionen(id, d) : null
|
||||
const neu = hatUpdate ? v?.neu ?? neuLautStart ?? null : null
|
||||
const summary = zusammenfassung(d?.summary)
|
||||
|
||||
// Scheitert die Prüfung, kennt das Backend oft nicht einmal die laufende Version (Hermes: git fetch).
|
||||
const laeuftText = v && v.laeuft !== "–" ? v.laeuft : fehler ? "unbekannt" : "–"
|
||||
const laeuftZelle = laedt ? <Skelett /> : <>{laeuftText}</>
|
||||
const neuZelle = laedt ? (
|
||||
<Skelett breit="w-20" />
|
||||
) : neu ? (
|
||||
<span className="text-cyan">{neu}</span>
|
||||
) : fehler ? (
|
||||
<span className="text-bernstein">nicht geprüft</span>
|
||||
) : (
|
||||
<span className="text-text-3">–</span>
|
||||
)
|
||||
|
||||
let was
|
||||
if (laedt) {
|
||||
was = <span className="text-text-3">Wird geprüft …</span>
|
||||
} else if (fehler && !hatUpdate) {
|
||||
was = <span className="text-bernstein">{pruefFehlerText(fehler)}</span>
|
||||
} else if (!hatUpdate) {
|
||||
was = "Aktuell."
|
||||
} else {
|
||||
was = (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
{fehler && <span className="text-bernstein">{pruefFehlerText(fehler)}</span>}
|
||||
{d && <Urteil d={d} />}
|
||||
{id === "os" ? (
|
||||
<>
|
||||
<span>{d?.count ? `${d.count} ${d.count === 1 ? "Paket" : "Pakete"} aus Ubuntu.` : "Neue Pakete aus Ubuntu."}</span>
|
||||
{d && <Paketliste d={d} />}
|
||||
</>
|
||||
) : summary ? (
|
||||
<Zusammenfassung text={summary} />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Am PC eine Tabellenzeile, am Handy ein Block: Name, darunter die Versionen, darunter was sich
|
||||
// ändert, darunter die Knöpfe — ohne seitliches Scrollen und ohne dass Name und Version aneinanderstoßen.
|
||||
return (
|
||||
<div
|
||||
role="row"
|
||||
className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-4 gap-y-2 border-t border-linie py-3.5 md:grid-cols-[170px_100px_170px_minmax(0,1fr)_auto] md:items-start"
|
||||
aria-busy={laedt || undefined}
|
||||
className="grid grid-cols-1 gap-x-4 gap-y-2 border-t border-linie py-3.5 md:grid-cols-[170px_100px_170px_minmax(0,1fr)_auto] md:items-start"
|
||||
>
|
||||
<span role="rowheader" className="text-[15px] font-semibold">{name}</span>
|
||||
<span role="cell" className="ziffern text-right text-sm text-text-2 md:hidden">
|
||||
{v.laeuft}
|
||||
{neu && <> → {neuText}</>}
|
||||
<span role="cell" className="ziffern -mt-1 text-sm text-text-2 md:hidden">
|
||||
{laeuftZelle}
|
||||
{!laedt && (neu || fehler) && <> {neu ? "→" : "·"} {neuZelle}</>}
|
||||
</span>
|
||||
<span role="cell" className="ziffern hidden text-sm text-text-2 md:block">{v.laeuft}</span>
|
||||
<span role="cell" className="ziffern hidden text-sm md:block">{neuText}</span>
|
||||
<span role="cell" className="col-span-2 text-sm leading-relaxed text-text-2 md:col-span-1">
|
||||
<span role="cell" className="ziffern hidden text-sm text-text-2 md:block">{laeuftZelle}</span>
|
||||
<span role="cell" className="ziffern hidden text-sm md:block">{neuZelle}</span>
|
||||
<div role="cell" className="text-sm leading-relaxed text-text-2">
|
||||
{fest && (
|
||||
<span className="mb-1.5 block text-bernstein">
|
||||
Festgehalten seit {fest.seit} auf {fest.version}: {fest.grund}. Der Sonntags-Lauf überspringt {name}.
|
||||
</span>
|
||||
)}
|
||||
<span className="line-clamp-3 whitespace-pre-line">{was}</span>
|
||||
</span>
|
||||
{was}
|
||||
</div>
|
||||
{(fest || hatUpdate) && (
|
||||
<span role="cell" className="col-span-2 flex flex-wrap gap-2 md:col-span-1 md:flex-col md:items-end">
|
||||
<span role="cell" className="flex flex-wrap gap-2 md:flex-col md:items-end">
|
||||
{fest && (
|
||||
<Button variant="outline" size="sm" onClick={onFreigeben}>
|
||||
Freigeben
|
||||
</Button>
|
||||
)}
|
||||
{hatUpdate && (
|
||||
<Button variant="outline" size="sm" onClick={onStart} disabled={laeuftGerade}>
|
||||
<Button variant="outline" size="sm" onClick={onStart} disabled={gesperrt}>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
)}
|
||||
@@ -89,17 +220,22 @@ const PUNKT: Record<UpdateLauf["stufe"], string> = {
|
||||
function LaufEintrag({ lauf }: { lauf: UpdateLauf }) {
|
||||
return (
|
||||
<li className="flex flex-col gap-2 border-t border-linie py-3">
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 md:grid-cols-[112px_minmax(0,1fr)_auto]">
|
||||
<span className="ziffern text-sm text-text-3">{flugplanZeit(lauf.start)}</span>
|
||||
<span className="min-w-0 text-[15px]">{lauf.anlass}</span>
|
||||
<Chip art={LAUF_ART[lauf.stufe]}>{lauf.titel}</Chip>
|
||||
{/* Schmales Feld: Zeit und Ergebnis oben, der Anlass darunter über die volle Breite. */}
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-3 gap-y-1 @sm:grid-cols-[112px_minmax(0,1fr)_auto]">
|
||||
<span className="ziffern col-start-1 row-start-1 text-sm text-text-3">{flugplanZeit(lauf.start)}</span>
|
||||
<span className="col-span-2 row-start-2 min-w-0 text-[15px] break-words @sm:col-span-1 @sm:col-start-2 @sm:row-start-1">
|
||||
{lauf.anlass}
|
||||
</span>
|
||||
<span className="col-start-2 row-start-1 justify-self-end @sm:col-start-3">
|
||||
<Chip art={LAUF_ART[lauf.stufe]}>{lauf.titel}</Chip>
|
||||
</span>
|
||||
</div>
|
||||
{lauf.stufe !== "grau" && lauf.zeilen.length > 0 && (
|
||||
<ul className="m-0 flex list-none flex-col gap-1 p-0 md:pl-[124px]">
|
||||
<ul className="m-0 flex list-none flex-col gap-1 p-0 @sm:pl-[124px]">
|
||||
{lauf.zeilen.map((z) => (
|
||||
<li key={`${z.baustein}-${z.text}`} className="flex items-baseline gap-2 text-sm leading-snug text-text-2">
|
||||
<span aria-hidden className={cn("size-1.5 shrink-0 -translate-y-px rounded-full", PUNKT[z.stufe])} />
|
||||
<span className="min-w-0">
|
||||
<span className="min-w-0 break-words">
|
||||
<span className="text-foreground">{z.baustein}</span> · {z.text}
|
||||
</span>
|
||||
</li>
|
||||
@@ -110,28 +246,17 @@ function LaufEintrag({ lauf }: { lauf: UpdateLauf }) {
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type Frage =
|
||||
| null
|
||||
| { art: "alle" }
|
||||
| { art: "einzeln"; id: string; name: string }
|
||||
| { art: "freigeben"; id: string; name: string; grund: string }
|
||||
| { art: "zurueck"; datei: string; zeit: string }
|
||||
|
||||
export function UpdatesSeite() {
|
||||
const start = useStart()
|
||||
const jobs = useJobs()
|
||||
@@ -141,12 +266,106 @@ export function UpdatesSeite() {
|
||||
const freigeben = useFreigeben()
|
||||
const sichern = useJetztSichern()
|
||||
const zurueck = useZurueckspielen()
|
||||
const [frage, setFrage] = useState<null | { art: "alle" } | { art: "zurueck"; datei: string; zeit: string }>(null)
|
||||
const [frage, setFrage] = useState<Frage>(null)
|
||||
|
||||
const mitUpdate = new Set(start.data?.updates.bausteine.map((b) => b.id) ?? [])
|
||||
const neuLautStart = new Map((start.data?.updates.bausteine ?? []).map((b) => [b.id, b.neu]))
|
||||
const festgehalten = new Map((start.data?.updates.festgehalten ?? []).map((f) => [f.baustein, f]))
|
||||
const wartungsJobs = (jobs.data?.jobs ?? []).filter((j) => (j as { group?: string }).group === "maintenance")
|
||||
const laeuft = wartungsJobs.some((j) => j.state === "running" || j.state === "queued")
|
||||
// /api/start kennt nur einen bis zu zehn Minuten alten Stand. Die Details prüfen frisch — sagen
|
||||
// sie etwas, gilt das (für die Zeilen UND für „Alles jetzt aktualisieren“), sonst der Start.
|
||||
const details = useUpdateDetails(BAUSTEINE.map((b) => b.id))
|
||||
const hatNeues = BAUSTEINE.map((b, i) => detailsHabenNeues(b.id, details[i].data) ?? neuLautStart.has(b.id))
|
||||
const alleJobs = jobs.data?.jobs ?? []
|
||||
// Nur ein Update gleichzeitig (Riegel im Backend) — auch der Sonntags-Lauf zählt.
|
||||
const updateLaeuft =
|
||||
alleJobs.some((j) => j.group === "maintenance" && laeuftNoch(j)) || (start.data?.zustand.update_laeuft ?? false)
|
||||
// Zwei Suchen gleichzeitig stolpern über die apt-Sperre — solange eine läuft, bleibt der Knopf zu.
|
||||
const suchtGerade = alleJobs.some((j) => jobSuche(j) && laeuftNoch(j))
|
||||
const gesperrt = updateLaeuft || updateStarten.isPending
|
||||
const zurueckMoeglich = sicherungen.data?.available ?? false
|
||||
|
||||
let tabelle
|
||||
if (start.isPending) {
|
||||
tabelle = <Zustandsfeld text="Updates werden gelesen …" />
|
||||
} else if (start.isError && !start.data) {
|
||||
tabelle = <Zustandsfeld fehler text={`Die Box antwortet nicht: ${start.error.message}`} />
|
||||
} else {
|
||||
tabelle = (
|
||||
<div role="table" aria-label="Bausteine und ihre Updates" className="flex flex-col">
|
||||
<div
|
||||
role="row"
|
||||
className="schild hidden grid-cols-[170px_100px_170px_minmax(0,1fr)_auto] gap-x-4 pb-2 text-xs font-semibold text-text-3 md:grid"
|
||||
>
|
||||
<span role="columnheader">Baustein</span>
|
||||
<span role="columnheader">Läuft</span>
|
||||
<span role="columnheader">Neu</span>
|
||||
<span role="columnheader">Was sich ändert</span>
|
||||
<span role="columnheader" className="sr-only">Aktion</span>
|
||||
</div>
|
||||
{BAUSTEINE.map((b, i) => (
|
||||
<BausteinZeile
|
||||
key={b.id}
|
||||
id={b.id}
|
||||
name={b.name}
|
||||
details={details[i]}
|
||||
hatUpdate={hatNeues[i]}
|
||||
neuLautStart={neuLautStart.get(b.id)}
|
||||
fest={festgehalten.get(b.id)}
|
||||
gesperrt={gesperrt}
|
||||
onStart={() => setFrage({ art: "einzeln", id: b.id, name: b.name })}
|
||||
onFreigeben={() => {
|
||||
const f = festgehalten.get(b.id)
|
||||
if (f) setFrage({ art: "freigeben", id: b.id, name: b.name, grund: f.grund })
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
let verlaufInhalt
|
||||
if (verlauf.isPending) {
|
||||
verlaufInhalt = <p className="text-[15px] text-text-2">Wird gelesen …</p>
|
||||
} else if (verlauf.isError && !verlauf.data) {
|
||||
verlaufInhalt = <p role="alert" className="text-[15px] text-rot-text">Der Verlauf lässt sich gerade nicht lesen: {verlauf.error.message}</p>
|
||||
} else if (!verlauf.data?.laeufe.length) {
|
||||
verlaufInhalt = <p className="text-[15px] text-text-2">Noch keine Update-Läufe aufgezeichnet.</p>
|
||||
} else {
|
||||
verlaufInhalt = (
|
||||
<ol className="@container m-0 flex list-none flex-col p-0">
|
||||
{verlauf.data.laeufe.map((l) => <LaufEintrag key={l.start} lauf={l} />)}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
let sicherungsInhalt
|
||||
if (sicherungen.isPending) {
|
||||
sicherungsInhalt = <p className="text-[15px] text-text-2">Wird gelesen …</p>
|
||||
} else if (sicherungen.isError && !sicherungen.data) {
|
||||
sicherungsInhalt = <p role="alert" className="text-[15px] text-rot-text">Die Sicherungen lassen sich gerade nicht lesen: {sicherungen.error.message}</p>
|
||||
} else if (!sicherungen.data?.backups.length) {
|
||||
sicherungsInhalt = <p className="text-[15px] text-text-2">Noch keine Sicherung vorhanden.</p>
|
||||
} else {
|
||||
sicherungsInhalt = (
|
||||
<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={!zurueckMoeglich || zurueck.isPending}
|
||||
onClick={() => setFrage({ art: "zurueck", datei: s.file, zeit: sicherungsZeit(s) })}
|
||||
>
|
||||
Zurückspielen
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -154,10 +373,14 @@ export function UpdatesSeite() {
|
||||
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
|
||||
variant="outline"
|
||||
onClick={() => updateStarten.mutate("pruefen")}
|
||||
disabled={updateStarten.isPending || suchtGerade}
|
||||
>
|
||||
{suchtGerade ? "Sucht …" : "Nach Neuem suchen"}
|
||||
</Button>
|
||||
<Button onClick={() => setFrage({ art: "alle" })} disabled={laeuft || mitUpdate.size === 0}>
|
||||
<Button onClick={() => setFrage({ art: "alle" })} disabled={gesperrt || !hatNeues.some(Boolean)}>
|
||||
Alles jetzt aktualisieren
|
||||
</Button>
|
||||
</div>
|
||||
@@ -166,94 +389,70 @@ export function UpdatesSeite() {
|
||||
<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 role="table" aria-label="Bausteine und ihre Updates" className="flex flex-col">
|
||||
<div
|
||||
role="row"
|
||||
className="schild hidden grid-cols-[170px_100px_170px_minmax(0,1fr)_auto] gap-x-4 pb-2 text-xs font-semibold text-text-3 md:grid"
|
||||
>
|
||||
<span role="columnheader">Baustein</span>
|
||||
<span role="columnheader">Läuft</span>
|
||||
<span role="columnheader">Neu</span>
|
||||
<span role="columnheader">Was sich ändert</span>
|
||||
<span role="columnheader" className="sr-only">Aktion</span>
|
||||
</div>
|
||||
{BAUSTEINE.map((b) => (
|
||||
<BausteinZeile
|
||||
key={b.id}
|
||||
id={b.id}
|
||||
name={b.name}
|
||||
gelesen={start.data?.updates.gelesen ?? false}
|
||||
hatUpdate={mitUpdate.has(b.id)}
|
||||
fest={festgehalten.get(b.id)}
|
||||
laeuftGerade={laeuft}
|
||||
onStart={() => updateStarten.mutate(b.id)}
|
||||
onFreigeben={() => freigeben.mutate(b.id)}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
{updateLaeuft && (
|
||||
<p role="status" className="text-[15px] text-cyan">
|
||||
Gerade läuft ein Update. Weitere lassen sich erst starten, wenn es fertig ist.
|
||||
</p>
|
||||
)}
|
||||
{tabelle}
|
||||
<Auftraege />
|
||||
</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) => <LaufEintrag key={l.start} lauf={l} />)}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="text-[15px] text-text-2">
|
||||
{verlauf.isPending ? "Wird gelesen …" : "Noch keine Update-Läufe aufgezeichnet."}
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel titel="Verlauf">{verlaufInhalt}</Panel>
|
||||
|
||||
<Panel
|
||||
titel="Sicherungen"
|
||||
rechts={
|
||||
<Button variant="outline" size="sm" onClick={() => sichern.mutate(undefined)} disabled={sichern.isPending}>
|
||||
Jetzt sichern
|
||||
{sichern.isPending ? "Sichert …" : "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>
|
||||
{sicherungen.data && !zurueckMoeglich && (
|
||||
<p className="text-sm text-text-3">Zurückspielen geht nur auf der Box selbst.</p>
|
||||
)}
|
||||
{sicherungsInhalt}
|
||||
</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."
|
||||
text="Motor, llama-swap, Hermes und Betriebssystem werden nacheinander aktualisiert, soweit es Neues gibt, und danach geprüft. Motor und llama-swap holen bei Problemen ihren alten Stand von selbst zurück, vor Hermes wird gesichert. Lucy ist dabei ein paar Minuten nicht erreichbar."
|
||||
knopf="Aktualisieren"
|
||||
gesperrt={gesperrt}
|
||||
onNein={() => setFrage(null)}
|
||||
onJa={() => {
|
||||
updateStarten.mutate("alle")
|
||||
// Die Abfrage auf `frage` fängt den zweiten Klick eines Doppelklicks: Dann ist sie schon null.
|
||||
if (frage?.art === "alle") updateStarten.mutate("alle")
|
||||
setFrage(null)
|
||||
}}
|
||||
/>
|
||||
<Bestaetigen
|
||||
offen={frage?.art === "einzeln"}
|
||||
titel={frage?.art === "einzeln" ? `${frage.name} aktualisieren?` : ""}
|
||||
text={frage?.art === "einzeln" ? EINZEL_TEXT[frage.id] ?? "" : ""}
|
||||
knopf="Aktualisieren"
|
||||
gesperrt={gesperrt}
|
||||
onNein={() => setFrage(null)}
|
||||
onJa={() => {
|
||||
if (frage?.art === "einzeln") updateStarten.mutate(frage.id)
|
||||
setFrage(null)
|
||||
}}
|
||||
/>
|
||||
<Bestaetigen
|
||||
offen={frage?.art === "freigeben"}
|
||||
titel={frage?.art === "freigeben" ? `${frage.name} freigeben?` : ""}
|
||||
text={frage?.art === "freigeben"
|
||||
? `Festgehalten wurde ${frage.name}, weil: ${frage.grund}. Nach dem Freigeben versucht der nächste Sonntags-Lauf das Update wieder.`
|
||||
: ""}
|
||||
knopf="Freigeben"
|
||||
gesperrt={freigeben.isPending}
|
||||
onNein={() => setFrage(null)}
|
||||
onJa={() => {
|
||||
if (frage?.art === "freigeben") freigeben.mutate(frage.id)
|
||||
setFrage(null)
|
||||
}}
|
||||
/>
|
||||
@@ -263,6 +462,7 @@ export function UpdatesSeite() {
|
||||
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"
|
||||
gesperrt={zurueck.isPending}
|
||||
onNein={() => setFrage(null)}
|
||||
onJa={() => {
|
||||
if (frage?.art === "zurueck") zurueck.mutate(frage.datei)
|
||||
|
||||
Reference in New Issue
Block a user