// Alle Abfragen und Aktionen der Oberfläche an einem Ort (TanStack Query). // // Takte: Steht der Live-Strom, meldet er Änderungen selbst — die Abfragen sind dann nur // Sicherheitsnetz und laufen fünfmal seltener. 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, Instanz, Job, Modell, Nutzung, PartnerStatus, 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"], queryFn: () => api("/api/start"), // Sucht MC2 gerade noch nach Updates (nach einem Neustart), bald wieder nachsehen. refetchInterval: (q) => (q.state.data && !q.state.data.updates.gelesen ? 4000 : takt(20_000)()), }) export const useModelle = () => useQuery({ queryKey: ["models"], queryFn: () => api<{ models: Modell[]; running: string[] }>("/api/models"), refetchInterval: takt(30_000), }) export const useNutzung = () => useQuery({ queryKey: ["nutzung"], queryFn: () => api("/api/modelle/nutzung"), refetchInterval: 600_000 }) export const useRadar = () => useQuery({ queryKey: ["radar"], queryFn: () => api("/api/radar"), retry: false, refetchInterval: 300_000 }) export const useJobs = () => { const qc = useQueryClient() return useQuery({ queryKey: ["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(laeuftNoch) ? 2000 : takt(30_000)()), }) } export const useSicherungen = () => useQuery({ queryKey: ["sicherungen"], queryFn: () => api<{ available: boolean; backups: Sicherung[] }>("/api/zeitmaschine"), }) /** * 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(`/api/maintenance/update-details?kind=${encodeURIComponent(art)}`), staleTime: 600_000, })), }) export interface UpdateLauf { start: string ende: string anlass: string stufe: "rot" | "gelb" | "gruen" | "info" | "grau" titel: string zeilen: { baustein: string; ergebnis: string; stufe: UpdateLauf["stufe"]; text: string }[] } export const useUpdateVerlauf = () => useQuery({ queryKey: ["updates-verlauf"], queryFn: () => api<{ laeufe: UpdateLauf[] }>("/api/updates/verlauf") }) export const useProtokoll = (dienst: string | null) => useQuery({ queryKey: ["protokoll", dienst], queryFn: () => api<{ ok: boolean; text?: string; err?: string }>( `/api/maintenance/logs?service=${encodeURIComponent(dienst ?? "")}&lines=200`), enabled: !!dienst, }) export interface Dienst { name: string unit: string ok: boolean scope: string /** Bewusst abgeschaltet (systemctl disable) — kein Fehler, nur geweckt wird per Knopf. */ schlaeft?: boolean } export const useDienste = () => useQuery({ queryKey: ["dienste"], queryFn: () => api<{ services: Dienst[] }>("/api/system/services") }) // --- Aktionen ---------------------------------------------------------------- function useAktion>( fn: (v: V) => Promise, 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({ mutationFn: fn, onSuccess: (e, v) => { const ergebnis = e as AktionsErgebnis if (ergebnis && ergebnis.ok === false) { melden("fehler", opt.fehler?.(v, ergebnis) ?? fehlerText(ergebnis)) } else { melden("erfolg", opt.erfolg(v, e)) } for (const k of opt.neuLaden) qc.invalidateQueries({ queryKey: [k] }) }, onError: (f: Error) => melden("fehler", f.message), }) } export const useHinweisAktion = () => useAktion( ({ hinweis, aktion }: { hinweis: string; aktion: string; label: string }) => post(`/api/hinweise/${encodeURIComponent(hinweis)}/aktion/${aktion}`), { 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 = { alle: "/api/maintenance/update-all", pruefen: "/api/maintenance/check-updates", os: "/api/maintenance/os-update", engine: "/api/maintenance/engine-update", swap: "/api/maintenance/swap-update", hermes: "/api/maintenance/hermes-update", } export const useUpdateStarten = () => useAktion((art: string) => post(UPDATE_PFADE[art]), { 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(`/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("/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"], queryFn: () => api<{ kandidaten: AufraeumKandidat[] }>("/api/modelle/aufraeumen"), staleTime: 60_000, }) export const useAufraeumenLoeschen = () => useAktion( (k: AufraeumKandidat) => post("/api/modelle/aufraeumen/loeschen", { art: k.art, name: k.name }), { erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Gelöscht.", neuLaden: ["aufraeumen", "models", "start"] }, ) export const useFreigeben = () => useAktion( (baustein: string) => post(`/api/updates/festgehalten/${encodeURIComponent(baustein)}/freigeben`), { erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Freigegeben.", neuLaden: ["start"] }, ) export const useJetztSichern = () => useAktion(() => post("/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"], }) export const useZurueckspielen = () => useAktion((datei: string) => post("/api/zeitmaschine/restore", { file: datei }), { erfolg: () => "Zurückspielen läuft. Die Dienste starten gleich neu.", neuLaden: ["sicherungen"], }) export const useModellLaden = () => useAktion((name: string) => post(`/api/models/${encodeURIComponent(name)}/load`), { erfolg: (name) => `${name} wird geladen.`, neuLaden: ["models", "start"], }) export const useRadarAktion = () => useAktion( ({ id, aktion }: { id: string; aktion: "uebernehmen" | "verwerfen"; name: string }) => post(`/api/radar/${encodeURIComponent(id)}/${aktion}`), { erfolg: (v) => (v.aktion === "uebernehmen" ? `${v.name} übernommen.` : `${v.name} verworfen.`), neuLaden: ["radar", "models", "start"], }, ) export const useRadarSuche = () => useAktion(() => post("/api/radar/suche"), { erfolg: () => "Radar sucht nach neuen Modellen.", neuLaden: ["radar"], }) /** Wer liefert die Seite aus, und gibt es die zweite Instanz? (ändert sich nur mit einem Deploy) */ export const useInstanz = () => useQuery({ queryKey: ["instanz"], queryFn: () => api("/api/instanz"), staleTime: Infinity }) /** Lebenszeichen der anderen Instanz — die Box prüft den Homelab-Teil und umgekehrt. */ export const usePartner = () => useQuery({ queryKey: ["partner"], queryFn: () => api("/api/partner"), refetchInterval: 30_000 })