zustaende: Dienste und Einstellungen sagen, was los ist

- Dienste: Lade-, Fehler- und Leerzustand; das Protokoll erscheint direkt unter dem
  angetippten Dienst (am Handy stand es vorher unter der ganzen Liste ausser Sicht) und
  meldet Lesefehler; "Wirklich neu starten?" hat ein "Nein", waehrend eines Neustarts
  sind die Knoepfe gesperrt, Meldungen nennen den Dienst beim Namen statt der Unit.
- Einstellungen: Solange der Stand laedt, steht "Wird gelesen ..." statt eines falschen
  "Fehlt."; Lesefehler und eine nicht beschreibbare Ablage werden gesagt; Speichern
  sperrt gegen Doppelklick, "Zugang loeschen" fragt vorher nach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 15:38:17 +02:00
co-authored by Claude Opus 5.5
parent a091ccb3b8
commit bd12667231
2 changed files with 115 additions and 48 deletions
+66 -26
View File
@@ -1,11 +1,28 @@
import { useState } from "react" import { useState } from "react"
import { useQueryClient } from "@tanstack/react-query" import { useQueryClient } from "@tanstack/react-query"
import { cn } from "cn"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet" 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 { post } from "@/lib/api"
import { melden } from "@/lib/meldungen" 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“. */ /** Dienste der Box mit Protokoll und Neustart — die „Log-Ansicht der Dienste“. */
export function Dienste({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) { export function Dienste({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) {
@@ -13,29 +30,35 @@ export function Dienste({ offen, onSchliessen }: { offen: boolean; onSchliessen:
const dienste = useDienste() const dienste = useDienste()
const [gewaehlt, setGewaehlt] = useState<string | null>(null) const [gewaehlt, setGewaehlt] = useState<string | null>(null)
const [bestaetigen, setBestaetigen] = 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) setBestaetigen(null)
setStartet(d.unit)
try { try {
const e = await post<{ ok: boolean; err?: string }>("/api/maintenance/restart", { service: unit }) const e = await post<AktionsErgebnis>("/api/maintenance/restart", { service: d.unit })
if (e.ok) melden("erfolg", `${unit} startet neu.`) if (e.ok) melden("erfolg", `${d.name} ${d.schlaeft ? "wird geweckt" : "startet neu"}.`)
else melden("fehler", e.err || `${unit} ließ sich nicht neu starten.`) else melden("fehler", `${d.name} ließ sich nicht starten: ${fehlerText(e)}`)
qc.invalidateQueries({ queryKey: ["dienste"] }) qc.invalidateQueries({ queryKey: ["dienste"] })
} catch (f) { } catch (f) {
melden("fehler", (f as Error).message) melden("fehler", (f as Error).message)
} finally {
setStartet(null)
} }
} }
return ( let inhalt
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}> if (dienste.isPending) {
<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"> inhalt = <p role="status" className="px-4 text-[15px] text-text-2">Dienste werden gelesen </p>
<SheetHeader> } else if (dienste.isError && !dienste.data) {
<SheetTitle className="schild text-lg">Dienste und Protokolle</SheetTitle> inhalt = <p role="alert" className="px-4 text-[15px] text-rot-text">Die Dienste lassen sich gerade nicht lesen: {dienste.error.message}</p>
<SheetDescription className="text-text-2">Welcher Dienst läuft, was er zuletzt geschrieben hat.</SheetDescription> } else if (!dienste.data?.services.length) {
</SheetHeader> inhalt = <p className="px-4 text-[15px] text-text-2">Keine Dienste gefunden.</p>
<ul className="m-0 flex list-none flex-col px-4 pb-2"> } else {
{(dienste.data?.services ?? []).map((d) => ( 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"> <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="flex min-w-0 items-center gap-2.5">
<span <span
@@ -49,28 +72,45 @@ export function Dienste({ offen, onSchliessen }: { offen: boolean; onSchliessen:
</span> </span>
</span> </span>
</span> </span>
<span className="flex gap-2"> <span className="flex flex-wrap gap-2">
<Button variant={gewaehlt === d.unit ? "info" : "ghost"} size="sm" onClick={() => setGewaehlt(d.unit)}> <Button
variant={gewaehlt === d.unit ? "info" : "ghost"}
size="sm"
aria-expanded={gewaehlt === d.unit}
onClick={() => setGewaehlt(gewaehlt === d.unit ? null : d.unit)}
>
Protokoll Protokoll
</Button> </Button>
{bestaetigen === d.unit ? ( {bestaetigen === d.unit ? (
<Button variant="gefahr" size="sm" onClick={() => neustarten(d.unit)}> <>
<Button variant="gefahr" size="sm" onClick={() => neustarten(d)}>
{d.schlaeft ? "Wirklich wecken?" : "Wirklich neu starten?"} {d.schlaeft ? "Wirklich wecken?" : "Wirklich neu starten?"}
</Button> </Button>
<Button variant="ghost" size="sm" onClick={() => setBestaetigen(null)}>
Nein
</Button>
</>
) : ( ) : (
<Button variant="outline" size="sm" onClick={() => setBestaetigen(d.unit)}> <Button variant="outline" size="sm" disabled={startet !== null} onClick={() => setBestaetigen(d.unit)}>
{d.schlaeft ? "Wecken" : "Neu starten"} {startet === d.unit ? "Startet …" : d.schlaeft ? "Wecken" : "Neu starten"}
</Button> </Button>
)} )}
</span> </span>
{gewaehlt === d.unit && <Protokoll unit={d.unit} />}
</li> </li>
))} ))}
</ul> </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> return (
)} <Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
<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>
{inhalt}
</SheetContent> </SheetContent>
</Sheet> </Sheet>
) )
+31 -4
View File
@@ -20,8 +20,13 @@ export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchli
enabled: offen, enabled: offen,
}) })
const [token, setToken] = useState("") const [token, setToken] = useState("")
const [speichert, setSpeichert] = useState(false)
const [loeschFrage, setLoeschFrage] = useState(false)
async function speichern(wert: string | null) { async function speichern(wert: string | null) {
if (speichert) return
setSpeichert(true)
setLoeschFrage(false)
try { try {
await post("/api/maintenance/geheimnisse", { schluessel: "hf_token", wert }) await post("/api/maintenance/geheimnisse", { schluessel: "hf_token", wert })
melden("erfolg", wert ? "Hugging-Face-Zugang gespeichert." : "Hugging-Face-Zugang gelöscht.") melden("erfolg", wert ? "Hugging-Face-Zugang gespeichert." : "Hugging-Face-Zugang gelöscht.")
@@ -29,10 +34,17 @@ export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchli
qc.invalidateQueries({ queryKey: ["geheimnisse"] }) qc.invalidateQueries({ queryKey: ["geheimnisse"] })
} catch (f) { } catch (f) {
melden("fehler", (f as Error).message) melden("fehler", (f as Error).message)
} finally {
setSpeichert(false)
} }
} }
const s = stand.data 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 ( return (
<Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}> <Sheet open={offen} onOpenChange={(o) => !o && onSchliessen()}>
<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"> <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">
@@ -42,10 +54,14 @@ export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchli
</SheetHeader> </SheetHeader>
<section className="flex flex-col gap-3 border-t border-linie px-4 pt-4"> <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> <h3 className="schild text-sm text-text-3">Hugging-Face-Zugang</h3>
<p className="text-sm text-text-2"> <p className="text-sm text-text-2" aria-live="polite">
{s?.hf_token_gesetzt ? "Ist hinterlegt." : "Fehlt."} Nötig für gesperrte Modelle und schnellere Downloads. <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." : ""} {s?.hf_token_aus_env ? " Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen." : ""}
</p> </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 <form
className="flex gap-2" className="flex gap-2"
onSubmit={(e) => { onSubmit={(e) => {
@@ -63,10 +79,21 @@ export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchli
placeholder="hf_…" 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" 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> </form>
{s?.hf_token_gesetzt && !s.hf_token_aus_env && ( {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>
<section className="flex flex-col gap-2 border-t border-linie px-4 pt-4"> <section className="flex flex-col gap-2 border-t border-linie px-4 pt-4">