einstellungen: Zeitfenster, Radar-Schalter, Telegram-Test, Software-Stand und Altreste
Einstellungen, die etwas einstellen (services/einstellungen.py, /api/einstellungen): - Update-Zeitfenster: Wochentag und Uhrzeit des Update-Laufs als Drop-in mc2-autoupdate.timer.d/zeitfenster.conf. Gegen die Nachhol-Falle von Persistent=true (24.09.): Timer vor dem daemon-reload stoppen, Stempel auf jetzt, dann starten. Bei Fehler zurück auf das alte Drop-in. Während eines Update-Laufs abgelehnt. - Neustart nur im eingestellten Fenster (deploy/wartungsfenster.sh, drei Stunden ab der vollen Stunde des Beginns; Standard So 04:00-06:59). - Radar an/aus: disable --now bzw. enable + Stempel + start (kein Nachholen). deploy.sh respektiert den Schalter und startet den Radar-Timer nur mit Stempel; das Zeitfenster-Drop-in fasst er nie an. - Wächter: schläft der Timer eines Timer-Dienstes, ist ein alter Fehlschlag kein Befund mehr (der Dienst selbst ist static und schläft nie). - Telegram-Test je Instanz (/api/einstellungen/telegram-test und /api/homelab/einstellungen/telegram-test) über notify.sh -d; Ergebnis und Grund aus dem Melde-Log. notify.sh schreibt jetzt auch den Grund des gescheiterten Zweitwegs in den FALLBACK-Eintrag. - Einstellungsdatei mc2-einstellungen.json im Datenordner. Software-Stand: deploy.sh und homelab/ausrollen.sh schreiben nach dem Umschalten mc2-stand.json (deploy/stand-schreiben.py); /api/instanz liefert den Stand, die Einstellungen zeigen beide Instanzen und warnen bei Abweichung. Ohne Datei aus git, sonst unbekannt. Aufräumen: Altreste neben dem Betrieb aus einer festen, auf der Box geprüften Liste (Größe, Hinweis, nur was da und nicht in Gebrauch ist). Löschen nur per Kennung und Klick mit Rückfrage; Worktrees mit git worktree prune, Alt-Units mit daemon-reload, /opt/llamacpp notfalls mit sudo -n. Tests: pytest mit einem systemd-Abbild samt Nachhol-Regel, echtem notify.sh (Ersatz-Hermes, Tmp-Zugang, lokale Bot-API), Shell-Funktionen des Wartungsfensters und von deploy.sh; vitest für die Rückfragen der Schublade. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
40d2840b16
commit
3d8df1c6fd
@@ -301,3 +301,64 @@ export const useHomelabHinweise = (aktiv: boolean) =>
|
||||
enabled: aktiv,
|
||||
refetchInterval: takt(60_000),
|
||||
})
|
||||
|
||||
// --- Einstellungen, Software-Stand, Altreste (seit 24.09.2026) --------------------------------------------------
|
||||
|
||||
import type { Altrest, EinstellungenStand, InstanzMitStand, TelegramTest } from "./typen"
|
||||
|
||||
/** Update-Zeitfenster und Radar-Schalter der KI-Box, samt nächstem Lauf laut systemd. */
|
||||
export const useEinstellungenStand = (aktiv: boolean) =>
|
||||
useQuery({
|
||||
queryKey: ["einstellungen"],
|
||||
queryFn: () => api<EinstellungenStand>("/api/einstellungen"),
|
||||
enabled: aktiv,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
export const useUpdateFensterSetzen = () =>
|
||||
useAktion(
|
||||
(f: { tag: number; uhrzeit: string }) => post<AktionsErgebnis>("/api/einstellungen/update-fenster", f),
|
||||
{ erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Gespeichert.", neuLaden: ["einstellungen", "start"] },
|
||||
)
|
||||
|
||||
export const useRadarSchalten = () =>
|
||||
useAktion((an: boolean) => post<AktionsErgebnis>("/api/einstellungen/radar", { an }), {
|
||||
erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Geschaltet.",
|
||||
neuLaden: ["einstellungen", "start", "radar"],
|
||||
})
|
||||
|
||||
/** Testmeldung über den echten Meldeweg der KI-Box oder des Homelab-Teils (lib/instanz.ts findet die Instanz). */
|
||||
export const useTelegramTest = () =>
|
||||
useAktion(
|
||||
(ziel: "box" | "homelab") =>
|
||||
post<TelegramTest>(ziel === "homelab" ? "/api/homelab/einstellungen/telegram-test" : "/api/einstellungen/telegram-test"),
|
||||
{ erfolg: (_, e) => e.text ?? "Die Testmeldung ist raus.", neuLaden: [] },
|
||||
)
|
||||
|
||||
/** Software-Stand dieser Instanz und der anderen (über /api/partner/instanz). */
|
||||
export const useSoftwareStand = (aktiv: boolean, mitPartner: boolean) =>
|
||||
useQueries({
|
||||
queries: [
|
||||
{ queryKey: ["stand", "hier"], queryFn: () => api<InstanzMitStand>("/api/instanz"), enabled: aktiv },
|
||||
{
|
||||
queryKey: ["stand", "partner"],
|
||||
queryFn: () => api<InstanzMitStand>("/api/partner/instanz"),
|
||||
enabled: aktiv && mitPartner,
|
||||
retry: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
/** Altreste neben dem Betrieb (Test-Ordner, alte Umgebungen, Worktrees …) — nur, was noch da ist. */
|
||||
export const useAltreste = () =>
|
||||
useQuery({
|
||||
queryKey: ["altreste"],
|
||||
queryFn: () => api<{ altreste: Altrest[] }>("/api/aufraeumen/altreste"),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
export const useAltrestLoeschen = () =>
|
||||
useAktion((a: Altrest) => post<AktionsErgebnis>("/api/aufraeumen/altreste/loeschen", { id: a.id }), {
|
||||
erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Gelöscht.",
|
||||
neuLaden: ["altreste", "start"],
|
||||
})
|
||||
|
||||
@@ -302,3 +302,74 @@ export interface HomelabLauf {
|
||||
text: string | null
|
||||
schritte: string[]
|
||||
}
|
||||
|
||||
// --- Einstellungen, Software-Stand, Altreste (seit 24.09.2026) --------------------------------------------------
|
||||
|
||||
/** Welcher Stand einer Instanz seit wann live ist (services/software_stand.py). */
|
||||
export interface SoftwareStand {
|
||||
/** "deploy" = Stand-Datei des Deploys, "git" = aus dem Checkout gelesen (Entwicklung), sonst "unbekannt". */
|
||||
quelle: "deploy" | "git" | "unbekannt"
|
||||
commit: string | null
|
||||
betreff: string | null
|
||||
/** ISO-Zeit des Commits */
|
||||
commit_datum: string | null
|
||||
/** ISO-Zeit des Deploys, nur mit Stand-Datei */
|
||||
deploy_zeit: string | null
|
||||
}
|
||||
|
||||
/** GET /api/instanz bzw. /api/partner/instanz, seit 24.09.2026 mit dem Software-Stand. */
|
||||
export interface InstanzMitStand extends Instanz {
|
||||
stand?: SoftwareStand
|
||||
}
|
||||
|
||||
/** Einstellungen der KI-Box samt dem, was systemd daraus gemacht hat (GET /api/einstellungen). */
|
||||
export interface EinstellungenStand {
|
||||
/** false, wo es kein systemd gibt (Entwicklung am PC): Dann lässt sich nichts schalten. */
|
||||
schaltbar: boolean
|
||||
update_fenster: {
|
||||
/** 1 = Montag … 7 = Sonntag */
|
||||
tag: number
|
||||
/** "HH:MM" */
|
||||
uhrzeit: string
|
||||
tag_name: string
|
||||
/** Wann die Box nach einem Update neu starten darf, z. B. „Sonntag 04:00–06:59“. */
|
||||
neustart_fenster: string
|
||||
timer_aktiv: boolean
|
||||
naechster_lauf: string | null
|
||||
/** false: Der Timer läuft noch nach einem anderen Plan als eingestellt. */
|
||||
wirksam: boolean
|
||||
}
|
||||
radar: {
|
||||
/** Was eingestellt ist. */
|
||||
an: boolean
|
||||
/** Was systemd sagt: „schlaeft“ = abgeschaltet und nicht im Autostart. */
|
||||
zustand: "an" | "schlaeft" | "aus" | "unbekannt"
|
||||
naechster_lauf: string | null
|
||||
wirksam: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/** Antwort des Telegram-Tests (POST /api/einstellungen/telegram-test bzw. /api/homelab/einstellungen/telegram-test). */
|
||||
export interface TelegramTest extends AktionsErgebnis {
|
||||
gesendet: boolean
|
||||
/** Über Hermes oder direkt an die Telegram-Bot-API. */
|
||||
weg: "hermes" | "direkt" | null
|
||||
/** Warum nicht, aus dem Melde-Log. */
|
||||
grund: string | null
|
||||
instanz: string
|
||||
/** ISO-Zeit des Tests */
|
||||
zeit: string
|
||||
kennung?: string
|
||||
}
|
||||
|
||||
/** Ein Altrest neben dem Betrieb (GET /api/aufraeumen/altreste). */
|
||||
export interface Altrest {
|
||||
/** Kennung aus der festen Liste — gelöscht wird nur darüber, nie über einen Pfad. */
|
||||
id: string
|
||||
name: string
|
||||
pfade: string[]
|
||||
groesse_bytes: number
|
||||
/** fertig lesbar, z. B. „7,7 GB“ */
|
||||
groesse: string
|
||||
hinweis: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"
|
||||
import { api, post } from "@/lib/api"
|
||||
import type { EinstellungenStand, InstanzMitStand } from "@/lib/typen"
|
||||
import { Einstellungen } from "./Einstellungen"
|
||||
|
||||
vi.mock("@/lib/api", async (original) => ({
|
||||
...(await original<typeof import("@/lib/api")>()),
|
||||
api: vi.fn(),
|
||||
post: vi.fn(),
|
||||
}))
|
||||
|
||||
const BOX: EinstellungenStand = {
|
||||
schaltbar: true,
|
||||
update_fenster: {
|
||||
tag: 7, uhrzeit: "04:30", tag_name: "Sonntag", neustart_fenster: "Sonntag 04:00–06:59", timer_aktiv: true,
|
||||
naechster_lauf: "2026-09-27T04:36:00+02:00", wirksam: true,
|
||||
},
|
||||
radar: { an: true, zustand: "an", naechster_lauf: "2026-09-25T00:30:00+02:00", wirksam: true },
|
||||
}
|
||||
|
||||
function instanz(rolle: "box" | "homelab", commit: string): InstanzMitStand {
|
||||
return {
|
||||
rolle, instanz: rolle === "box" ? "Box-Wart" : "Homelab",
|
||||
partner: { eingerichtet: true, name: rolle === "box" ? "Homelab" : "Box-Wart" },
|
||||
stand: {
|
||||
quelle: "deploy", commit, betreff: "doku: Phase 4", commit_datum: "2026-09-24T20:46:03+02:00",
|
||||
deploy_zeit: "2026-09-24T21:10:00+02:00",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function antworten(partnerCommit = "40d2840") {
|
||||
const daten: Record<string, unknown> = {
|
||||
"/api/maintenance/geheimnisse": { hf_token_gesetzt: false, hf_token_aus_env: false, schreibbar: true },
|
||||
"/api/einstellungen": BOX,
|
||||
"/api/instanz": instanz("box", "40d2840"),
|
||||
"/api/partner/instanz": instanz("homelab", partnerCommit),
|
||||
}
|
||||
vi.mocked(api).mockImplementation((async (pfad: string) => {
|
||||
if (pfad in daten) return daten[pfad]
|
||||
throw new Error(`unerwartete Abfrage ${pfad}`)
|
||||
}) as typeof api)
|
||||
}
|
||||
|
||||
function zeigen() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Einstellungen offen onSchliessen={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe("Einstellungen", () => {
|
||||
it("schaltet das Radar erst nach der Rückfrage aus", async () => {
|
||||
antworten()
|
||||
vi.mocked(post).mockResolvedValue({ ok: true, text: "Das Radar ist aus und schläft." })
|
||||
zeigen()
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Ausschalten" }))
|
||||
const dialog = await screen.findByRole("dialog", { name: "Modell-Radar ausschalten?" })
|
||||
expect(dialog).toHaveTextContent("Auch ein Deploy schaltet es nicht wieder ein.")
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Ausschalten" }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalledWith("/api/einstellungen/radar", { an: false }))
|
||||
})
|
||||
|
||||
it("nennt vor dem Speichern des Zeitfensters das neue Neustart-Fenster", async () => {
|
||||
antworten()
|
||||
vi.mocked(post).mockResolvedValue({ ok: true, text: "Gespeichert." })
|
||||
zeigen()
|
||||
const bereich = await screen.findByRole("region", { name: "Update-Zeitfenster" })
|
||||
const speichern = await within(bereich).findByRole("button", { name: "Speichern" })
|
||||
expect(speichern).toBeDisabled() // nichts geändert, der Timer passt
|
||||
fireEvent.change(within(bereich).getByRole("combobox", { name: "Wochentag" }), { target: { value: "6" } })
|
||||
fireEvent.change(within(bereich).getByRole("combobox", { name: "Uhrzeit" }), { target: { value: "23:30" } })
|
||||
fireEvent.click(speichern)
|
||||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Update-Zeitfenster ändern?" })
|
||||
expect(dialog).toHaveTextContent("jeden Samstag um 23:30 Uhr")
|
||||
expect(dialog).toHaveTextContent("Samstag 23:00 – Sonntag 01:59")
|
||||
expect(post).not.toHaveBeenCalled()
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Speichern" }))
|
||||
await waitFor(() =>
|
||||
expect(post).toHaveBeenCalledWith("/api/einstellungen/update-fenster", { tag: 6, uhrzeit: "23:30" }))
|
||||
})
|
||||
|
||||
it("zeigt beim Telegram-Test des Homelab-Teils den Grund aus dem Melde-Log", async () => {
|
||||
antworten()
|
||||
vi.mocked(post).mockResolvedValue({
|
||||
ok: false, gesendet: false, weg: null, grund: "Zweitweg: Telegram lehnt ab, HTTP-Fehler",
|
||||
detail: "Die Testmeldung ging nicht raus.", instanz: "Homelab", zeit: "2026-09-24T21:14:00+02:00",
|
||||
})
|
||||
zeigen()
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Testmeldung von Homelab senden" }))
|
||||
await waitFor(() => expect(post).toHaveBeenCalledWith("/api/homelab/einstellungen/telegram-test"))
|
||||
expect(await screen.findByText("Nicht gesendet: Zweitweg: Telegram lehnt ab, HTTP-Fehler")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("zeigt beide Software-Stände und warnt, wenn sie abweichen", async () => {
|
||||
antworten("37133fd")
|
||||
zeigen()
|
||||
expect(await screen.findByText(/laufen mit verschiedenen Ständen/)).toBeInTheDocument()
|
||||
expect(screen.getByText("40d2840")).toBeInTheDocument()
|
||||
expect(screen.getByText("37133fd")).toBeInTheDocument()
|
||||
expect(screen.getAllByText(/Live seit 24\.09\.2026, 21:10/)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("schweigt bei gleichem Stand, auch wenn ein Hash länger ist", async () => {
|
||||
antworten("40d2840a")
|
||||
zeigen()
|
||||
expect(await screen.findByText("40d2840a")).toBeInTheDocument()
|
||||
expect(screen.queryByText(/laufen mit verschiedenen Ständen/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,17 @@
|
||||
import { useState } from "react"
|
||||
import { type ReactNode, useState } from "react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { cn } from "cn"
|
||||
import { ExternalLink } from "lucide-react"
|
||||
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||
import {
|
||||
useEinstellungenStand, useInstanz, useRadarSchalten, useSoftwareStand, useTelegramTest, useUpdateFensterSetzen,
|
||||
} from "@/lib/abfragen"
|
||||
import { api, post } from "@/lib/api"
|
||||
import type { Rolle } from "@/lib/instanz"
|
||||
import { melden } from "@/lib/meldungen"
|
||||
import type { EinstellungenStand, SoftwareStand } from "@/lib/typen"
|
||||
|
||||
interface GeheimnisStand {
|
||||
hf_token_gesetzt: boolean
|
||||
@@ -12,7 +19,58 @@ interface GeheimnisStand {
|
||||
schreibbar: boolean
|
||||
}
|
||||
|
||||
export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) {
|
||||
const TAGE = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"]
|
||||
/** Halbstündlich — genauer muss ein Update-Beginn nicht sein. */
|
||||
const ZEITEN = Array.from({ length: 48 }, (_, i) => `${String(Math.floor(i / 2)).padStart(2, "0")}:${i % 2 ? "30" : "00"}`)
|
||||
|
||||
const zeitpunkt = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin",
|
||||
})
|
||||
|
||||
/** ISO-Zeit → „27.09.2026, 04:36“ (Box-Zeit). */
|
||||
function wann(iso: string | null | undefined): string {
|
||||
const d = iso ? new Date(iso) : null
|
||||
return d && !Number.isNaN(d.getTime()) ? zeitpunkt.format(d) : "–"
|
||||
}
|
||||
|
||||
/** Wann die Box nach einem Update neu starten darf: drei Stunden ab der vollen Stunde des Beginns — dieselbe Regel
|
||||
* wie deploy/wartungsfenster.sh und services/einstellungen.py. So 04:30 → „Sonntag 04:00–06:59“. */
|
||||
function neustartFenster(tag: number, uhrzeit: string): string {
|
||||
const stunde = Number(uhrzeit.slice(0, 2))
|
||||
let ende = stunde * 60 + 179
|
||||
let tagEnde = tag
|
||||
if (ende >= 1440) {
|
||||
ende -= 1440
|
||||
tagEnde = (tag % 7) + 1
|
||||
}
|
||||
const zwei = (n: number) => String(n).padStart(2, "0")
|
||||
const von = `${zwei(stunde)}:00`
|
||||
const bis = `${zwei(Math.floor(ende / 60))}:${zwei(ende % 60)}`
|
||||
return tagEnde === tag ? `${TAGE[tag - 1]} ${von}–${bis}` : `${TAGE[tag - 1]} ${von} – ${TAGE[tagEnde - 1]} ${bis}`
|
||||
}
|
||||
|
||||
function Abschnitt({ titel, children, zuletzt }: { titel: string; children: ReactNode; zuletzt?: boolean }) {
|
||||
return (
|
||||
<section aria-label={titel} className={cn("flex flex-col gap-3 border-t border-linie px-4 pt-4", zuletzt && "pb-4")}>
|
||||
<h3 className="schild text-sm text-text-3">{titel}</h3>
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Hinweis({ children }: { children: ReactNode }) {
|
||||
return <p className="text-sm text-bernstein">{children}</p>
|
||||
}
|
||||
|
||||
/** Die Box-Einstellungen kommen von der KI-Box — auch dann, wenn der Homelab-Teil die Seite ausliefert. */
|
||||
function Unlesbar({ laedt, fehler }: { laedt: boolean; fehler: string | undefined }) {
|
||||
if (laedt) return <p className="text-sm text-text-2">Wird gelesen …</p>
|
||||
return <p role="alert" className="text-sm text-rot-text">Lässt sich gerade nicht lesen{fehler ? `: ${fehler}` : "."}</p>
|
||||
}
|
||||
|
||||
// --- Hugging Face -----------------------------------------------------------------------------------
|
||||
|
||||
function HfZugang({ offen }: { offen: boolean }) {
|
||||
const qc = useQueryClient()
|
||||
const stand = useQuery({
|
||||
queryKey: ["geheimnisse"],
|
||||
@@ -45,71 +103,304 @@ export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchli
|
||||
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 (
|
||||
<Abschnitt titel="Hugging-Face-Zugang">
|
||||
<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) => {
|
||||
e.preventDefault()
|
||||
if (token.trim()) speichern(token.trim())
|
||||
}}
|
||||
>
|
||||
<label htmlFor="hf-token" className="sr-only">Hugging-Face-Zugang</label>
|
||||
<input
|
||||
id="hf-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
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 || speichert}>
|
||||
{speichert ? "Speichert …" : "Speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
{s?.hf_token_gesetzt && !s.hf_token_aus_env && (
|
||||
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>
|
||||
)
|
||||
)}
|
||||
</Abschnitt>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Meldungen und Telegram-Test ----------------------------------------------------------------------
|
||||
|
||||
function TelegramKnopf({ ziel, name }: { ziel: Rolle; name: string }) {
|
||||
const test = useTelegramTest()
|
||||
const e = test.data
|
||||
let ergebnis: ReactNode = null
|
||||
if (test.isPending) ergebnis = <p role="status" className="text-sm text-text-2">Sendet …</p>
|
||||
else if (e?.gesendet) {
|
||||
ergebnis = (
|
||||
<p role="status" className="text-sm text-gruen">
|
||||
Gesendet {e.weg === "hermes" ? "über Hermes" : "direkt an die Telegram-Bot-API"}
|
||||
{e.kennung ? `, Kennung ${e.kennung}` : ""}. Schau in Telegram nach.
|
||||
</p>
|
||||
)
|
||||
} else if (e) {
|
||||
ergebnis = <p role="alert" className="text-sm text-rot-text">Nicht gesendet: {e.grund ?? e.detail ?? "ohne Angabe eines Grundes."}</p>
|
||||
} else if (test.error) {
|
||||
ergebnis = <p role="alert" className="text-sm text-rot-text">{test.error.message}</p>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-[15px]">{name}</span>
|
||||
<Button variant="outline" size="sm" disabled={test.isPending} onClick={() => test.mutate(ziel)} aria-label={`Testmeldung von ${name} senden`}>
|
||||
{test.isPending ? "Sendet …" : "Test senden"}
|
||||
</Button>
|
||||
</div>
|
||||
{ergebnis}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Meldungen() {
|
||||
const i = useInstanz().data
|
||||
const eigene: Rolle = i?.rolle ?? "box"
|
||||
const andere: Rolle = eigene === "box" ? "homelab" : "box"
|
||||
const ziele = [{ ziel: eigene, name: i?.instanz ?? (eigene === "box" ? "Box-Wart" : "Homelab") }]
|
||||
if (i?.partner.eingerichtet) ziele.push({ ziel: andere, name: i.partner.name })
|
||||
ziele.sort((a, b) => Number(a.ziel !== "box") - Number(b.ziel !== "box"))
|
||||
return (
|
||||
<Abschnitt titel="Meldungen">
|
||||
<p className="text-sm text-text-2">
|
||||
Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier; was nachts anfällt, kommt um
|
||||
07:00 als eine Morgenmeldung. Einfache Probleme wie einen abgestürzten Dienst behebt der Wächter selbst und
|
||||
schreibt es in den Verlauf.
|
||||
</p>
|
||||
<p className="text-sm text-text-2">
|
||||
Der Test schickt eine kurze Meldung über den echten Meldeweg, als dringend, damit sie auch nachts sofort kommt.
|
||||
</p>
|
||||
{ziele.map((z) => <TelegramKnopf key={z.ziel} ziel={z.ziel} name={z.name} />)}
|
||||
</Abschnitt>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Update-Zeitfenster -------------------------------------------------------------------------------
|
||||
|
||||
function Zeitfenster({ e, laedt, fehler }: { e: EinstellungenStand | undefined; laedt: boolean; fehler?: string }) {
|
||||
const setzen = useUpdateFensterSetzen()
|
||||
const [entwurf, setEntwurf] = useState<{ tag: number; uhrzeit: string } | null>(null)
|
||||
const [frage, setFrage] = useState(false)
|
||||
if (!e) {
|
||||
return <Abschnitt titel="Update-Zeitfenster"><Unlesbar laedt={laedt} fehler={fehler} /></Abschnitt>
|
||||
}
|
||||
const f = e.update_fenster
|
||||
const wahl = entwurf ?? { tag: f.tag, uhrzeit: f.uhrzeit }
|
||||
const geaendert = wahl.tag !== f.tag || wahl.uhrzeit !== f.uhrzeit
|
||||
const zeiten = ZEITEN.includes(wahl.uhrzeit) ? ZEITEN : [...ZEITEN, wahl.uhrzeit].sort()
|
||||
const feld = "h-11 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] text-foreground outline-none focus:border-cyan"
|
||||
return (
|
||||
<Abschnitt titel="Update-Zeitfenster">
|
||||
<p className="text-sm text-text-2">
|
||||
Die Box aktualisiert sich jeden {f.tag_name} um {f.uhrzeit} Uhr
|
||||
{f.naechster_lauf ? ` (nächster Lauf ${wann(f.naechster_lauf)})` : ""}. Neu gestartet wird sie danach nur
|
||||
{" "}{f.neustart_fenster}.
|
||||
</p>
|
||||
{!f.timer_aktiv && e.schaltbar && <Hinweis>Der Update-Timer läuft gerade nicht; er übernimmt das Fenster beim nächsten Start.</Hinweis>}
|
||||
{!f.wirksam && <Hinweis>Der Timer läuft noch nach einem anderen Plan als eingestellt. Einmal speichern gleicht es an.</Hinweis>}
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="flex flex-col gap-1 text-xs text-text-3">
|
||||
Tag
|
||||
<select className={feld} value={wahl.tag} disabled={!e.schaltbar} aria-label="Wochentag"
|
||||
onChange={(ev) => setEntwurf({ ...wahl, tag: Number(ev.target.value) })}>
|
||||
{TAGE.map((t, i) => <option key={t} value={i + 1}>{t}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-xs text-text-3">
|
||||
Uhrzeit
|
||||
<select className={feld} value={wahl.uhrzeit} disabled={!e.schaltbar} aria-label="Uhrzeit"
|
||||
onChange={(ev) => setEntwurf({ ...wahl, uhrzeit: ev.target.value })}>
|
||||
{zeiten.map((z) => <option key={z} value={z}>{z}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<Button disabled={!e.schaltbar || setzen.isPending || (!geaendert && f.wirksam)} onClick={() => setFrage(true)}>
|
||||
{setzen.isPending ? "Speichert …" : "Speichern"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-text-3">
|
||||
Am besten nachts nach der Sicherung (03:30) und vor der Morgenmeldung (07:00).
|
||||
{e.schaltbar ? "" : " Einstellen lässt es sich nur auf der Box."}
|
||||
</p>
|
||||
<Bestaetigen
|
||||
offen={frage}
|
||||
titel="Update-Zeitfenster ändern?"
|
||||
text={`Der Update-Lauf startet ab jetzt jeden ${TAGE[wahl.tag - 1]} um ${wahl.uhrzeit} Uhr. Neu gestartet wird die ` +
|
||||
`Box nur ${neustartFenster(wahl.tag, wahl.uhrzeit)}. Ein Termin, der dadurch schon vorbei ist, wird nicht ` +
|
||||
"nachgeholt: Der erste Lauf ist der nächste reguläre."}
|
||||
knopf="Speichern"
|
||||
gesperrt={setzen.isPending}
|
||||
onNein={() => setFrage(false)}
|
||||
onJa={() => {
|
||||
if (!frage) return // zweiter Klick eines Doppelklicks, der Dialog schließt schon
|
||||
setzen.mutate(wahl, { onSuccess: () => setEntwurf(null) })
|
||||
setFrage(false)
|
||||
}}
|
||||
/>
|
||||
</Abschnitt>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Modell-Radar -------------------------------------------------------------------------------------
|
||||
|
||||
function RadarSchalter({ e, laedt, fehler }: { e: EinstellungenStand | undefined; laedt: boolean; fehler?: string }) {
|
||||
const schalten = useRadarSchalten()
|
||||
const [frage, setFrage] = useState<boolean | null>(null)
|
||||
if (!e) {
|
||||
return <Abschnitt titel="Modell-Radar"><Unlesbar laedt={laedt} fehler={fehler} /></Abschnitt>
|
||||
}
|
||||
const r = e.radar
|
||||
// Geschaltet wird, was systemd zeigt; ohne systemd (Entwicklung) gilt die Einstellung.
|
||||
const laeuft = r.zustand === "an" || (r.zustand === "unbekannt" && r.an)
|
||||
const zustand: Record<EinstellungenStand["radar"]["zustand"], string> = {
|
||||
an: `An. Es sucht jede Nacht ab 00:30 nach besseren Modellen${r.naechster_lauf ? ` (nächster Lauf ${wann(r.naechster_lauf)})` : ""}.`,
|
||||
schlaeft: "Schläft. Es sucht und testet keine Modelle, bis du es wieder einschaltest.",
|
||||
aus: "Gestoppt, steht aber noch im Autostart: Nach einem Neustart der Box liefe es wieder.",
|
||||
unbekannt: r.an ? "Eingeschaltet." : "Ausgeschaltet.",
|
||||
}
|
||||
return (
|
||||
<Abschnitt titel="Modell-Radar">
|
||||
<p className="text-sm text-text-2">{zustand[r.zustand]}</p>
|
||||
{!r.wirksam && (
|
||||
<Hinweis>Eingestellt ist „{r.an ? "an" : "aus"}“, systemd zeigt etwas anderes. Noch einmal schalten gleicht es an.</Hinweis>
|
||||
)}
|
||||
<Button variant={laeuft ? "outline" : "default"} size="sm" className="self-start"
|
||||
disabled={!e.schaltbar || schalten.isPending} onClick={() => setFrage(!laeuft)}>
|
||||
{schalten.isPending ? "Schaltet …" : laeuft ? "Ausschalten" : "Einschalten"}
|
||||
</Button>
|
||||
<Bestaetigen
|
||||
offen={frage !== null}
|
||||
titel={frage ? "Modell-Radar einschalten?" : "Modell-Radar ausschalten?"}
|
||||
text={frage
|
||||
? "Das Radar sucht ab heute Nacht um 00:30 wieder nach besseren Modellen und testet höchstens eines pro Woche. " +
|
||||
"Ein verpasster Lauf wird nicht nachgeholt, tagsüber lädt es also nichts."
|
||||
: "Das Radar sucht und testet dann keine neuen Modelle mehr, bis du es wieder einschaltest. Ein Nachttest, der " +
|
||||
"gerade läuft, endet noch. Auch ein Deploy schaltet es nicht wieder ein."}
|
||||
knopf={frage ? "Einschalten" : "Ausschalten"}
|
||||
gesperrt={schalten.isPending}
|
||||
onNein={() => setFrage(null)}
|
||||
onJa={() => {
|
||||
if (frage === null) return
|
||||
schalten.mutate(frage)
|
||||
setFrage(null)
|
||||
}}
|
||||
/>
|
||||
</Abschnitt>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Software-Stand -------------------------------------------------------------------------------------
|
||||
|
||||
function StandZeile({ name, stand, laedt, fehler }: {
|
||||
name: string
|
||||
stand: SoftwareStand | undefined
|
||||
laedt: boolean
|
||||
fehler?: string
|
||||
}) {
|
||||
let inhalt: ReactNode
|
||||
if (laedt) inhalt = <span className="text-sm text-text-2">Wird gelesen …</span>
|
||||
else if (fehler) inhalt = <span className="text-sm text-rot-text">Nicht lesbar: {fehler}</span>
|
||||
else if (!stand?.commit) inhalt = <span className="text-sm text-text-2">Unbekannt: keine Stand-Datei und kein git.</span>
|
||||
else {
|
||||
inhalt = (
|
||||
<>
|
||||
<span className="text-sm text-text-2">
|
||||
<span className="ziffern text-foreground">{stand.commit}</span>{stand.betreff ? ` · ${stand.betreff}` : ""}
|
||||
</span>
|
||||
<span className="text-xs text-text-3">
|
||||
{stand.quelle === "deploy" ? `Live seit ${wann(stand.deploy_zeit)}` : "Aus git gelesen, ohne Deploy"}
|
||||
{stand.commit_datum ? ` · Commit vom ${wann(stand.commit_datum)}` : ""}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-[15px]">{name}</span>
|
||||
{inhalt}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Gleicher Stand? Kurze Hashes können je nach git verschieden lang sein. */
|
||||
function gleicherStand(a: string | null | undefined, b: string | null | undefined): boolean | null {
|
||||
if (!a || !b) return null
|
||||
return a.startsWith(b) || b.startsWith(a)
|
||||
}
|
||||
|
||||
function Stand({ offen }: { offen: boolean }) {
|
||||
const instanz = useInstanz().data
|
||||
const partnerDa = !!instanz?.partner.eingerichtet
|
||||
const [hier, partner] = useSoftwareStand(offen, partnerDa)
|
||||
const gleich = gleicherStand(hier.data?.stand?.commit, partner.data?.stand?.commit)
|
||||
return (
|
||||
<Abschnitt titel="Software-Stand">
|
||||
<StandZeile name={hier.data?.instanz ?? instanz?.instanz ?? "Diese Instanz"} stand={hier.data?.stand}
|
||||
laedt={hier.isPending} fehler={hier.error?.message} />
|
||||
{partnerDa && (
|
||||
<StandZeile name={partner.data?.instanz ?? instanz?.partner.name ?? "Partner"} stand={partner.data?.stand}
|
||||
laedt={partner.isPending} fehler={partner.error?.message} />
|
||||
)}
|
||||
{gleich === false && (
|
||||
<Hinweis>
|
||||
Die beiden Instanzen laufen mit verschiedenen Ständen. Der nächste Deploy der Box zieht den Homelab-Teil mit.
|
||||
</Hinweis>
|
||||
)}
|
||||
</Abschnitt>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Schublade ------------------------------------------------------------------------------------------
|
||||
|
||||
export function Einstellungen({ offen, onSchliessen }: { offen: boolean; onSchliessen: () => void }) {
|
||||
const box = useEinstellungenStand(offen)
|
||||
const boxProps = { e: box.data, laedt: box.isPending, fehler: box.error?.message }
|
||||
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-lg">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="schild text-lg">Einstellungen</SheetTitle>
|
||||
<SheetDescription className="text-text-2">Was die Box sich merkt.</SheetDescription>
|
||||
<SheetDescription className="text-text-2">Was die Box sich merkt und wann sie was tut.</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" 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) => {
|
||||
e.preventDefault()
|
||||
if (token.trim()) speichern(token.trim())
|
||||
}}
|
||||
>
|
||||
<label htmlFor="hf-token" className="sr-only">Hugging-Face-Zugang</label>
|
||||
<input
|
||||
id="hf-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
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 || speichert}>
|
||||
{speichert ? "Speichert …" : "Speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
{s?.hf_token_gesetzt && !s.hf_token_aus_env && (
|
||||
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">
|
||||
<h3 className="schild text-sm text-text-3">Hinweise</h3>
|
||||
<p className="text-sm text-text-2">
|
||||
Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier. Einfache Probleme wie
|
||||
einen abgestürzten Dienst behebt der Wächter selbst und schreibt es in den Verlauf.
|
||||
</p>
|
||||
</section>
|
||||
<section className="flex flex-col gap-2 border-t border-linie px-4 pt-4 pb-4">
|
||||
<h3 className="schild text-sm text-text-3">Hermes</h3>
|
||||
<HfZugang offen={offen} />
|
||||
<Meldungen />
|
||||
<Zeitfenster {...boxProps} />
|
||||
<RadarSchalter {...boxProps} />
|
||||
<Stand offen={offen} />
|
||||
<Abschnitt titel="Hermes" zuletzt>
|
||||
<a href="/hermes-ui/" target="_blank" rel="noopener" className="flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3">
|
||||
<ExternalLink className="size-4" aria-hidden /> Hermes-Dashboard öffnen
|
||||
</a>
|
||||
<p className="text-xs text-text-3">Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.</p>
|
||||
</section>
|
||||
</Abschnitt>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
|
||||
@@ -16,6 +16,9 @@ import { useLetzteMetrik } from "@/lib/strom"
|
||||
import type { AufraeumKandidat, Modell, Nutzung, RadarKandidat, RadarTest } from "@/lib/typen"
|
||||
import { flugplanZeit, gb, gbText, zahl } from "@/lib/zeit"
|
||||
import { ModellSuche } from "./ModellSuche"
|
||||
// Aufräumen: Altreste neben dem Betrieb (seit 24.09.2026)
|
||||
import { useAltreste, useAltrestLoeschen } from "@/lib/abfragen"
|
||||
import type { Altrest } from "@/lib/typen"
|
||||
|
||||
/** Grenze aus der Erfahrung (Memory „llama-swap: nur EINE Gruppe resident“): darüber OOM. */
|
||||
const GRENZE_GB = 115
|
||||
@@ -313,6 +316,7 @@ function AufraeumenPanel() {
|
||||
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>
|
||||
<Altreste />
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
@@ -364,10 +368,82 @@ function AufraeumenPanel() {
|
||||
setFrage(null)
|
||||
}}
|
||||
/>
|
||||
<Altreste />
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
/** Bytes dezimal wie die Modell-Kandidaten: „7,7 GB“, „323 MB“, „44 KB“. */
|
||||
function mengeText(bytes: number): string {
|
||||
if (bytes >= 1e9) return `${(bytes / 1e9).toLocaleString("de-DE", { maximumFractionDigits: 1 })} GB`
|
||||
if (bytes >= 1e6) return `${Math.round(bytes / 1e6)} MB`
|
||||
return bytes >= 1e3 ? `${Math.round(bytes / 1e3)} KB` : "unter 1 KB"
|
||||
}
|
||||
|
||||
/** Altreste neben dem Betrieb: Test-Ordner, alte Umgebungen, Arbeitskopien, Alt-Units — nur aus der festen Liste der
|
||||
* Box (services/aufraeumen.ALTRESTE), nur was noch da ist. Gelöscht wird je Eintrag auf Knopfdruck, mit Rückfrage. */
|
||||
function Altreste() {
|
||||
const liste = useAltreste()
|
||||
const loeschen = useAltrestLoeschen()
|
||||
const [frage, setFrage] = useState<Altrest | null>(null)
|
||||
const reste = liste.data?.altreste ?? []
|
||||
const summe = reste.reduce((s, a) => s + a.groesse_bytes, 0)
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-linie pt-4">
|
||||
<h3 className="schild text-sm text-text-3">Altreste neben dem Betrieb</h3>
|
||||
{reste.length > 0 && <span className="ziffern text-sm text-text-2">zusammen {mengeText(summe)}</span>}
|
||||
</div>
|
||||
<p className="text-sm text-text-2">
|
||||
Test-Ordner, alte Umgebungen und Arbeitskopien, die nichts mehr nutzt. Die Box prüft sie gegen eine feste Liste und
|
||||
löscht auch hier nie von selbst.
|
||||
</p>
|
||||
{liste.isPending ? (
|
||||
<p className="text-[15px] text-text-2">Wird gelesen …</p>
|
||||
) : liste.isError && !liste.data ? (
|
||||
<p role="alert" className="text-[15px] text-rot-text">Die Altreste lassen sich gerade nicht lesen: {liste.error.message}</p>
|
||||
) : reste.length === 0 ? (
|
||||
<p className="text-[15px] text-text-2">Keine Altreste mehr.</p>
|
||||
) : (
|
||||
<ul className="m-0 flex list-none flex-col p-0">
|
||||
{reste.map((a) => (
|
||||
<li key={a.id} className="flex flex-wrap items-center justify-between gap-3 border-t border-linie py-3">
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm">{a.name}</span>
|
||||
<Chip>Altrest</Chip>
|
||||
</span>
|
||||
<span className="ziffern text-xs break-all text-text-3">{a.pfade.join(" · ")}</span>
|
||||
<span className="text-sm text-text-2">{a.hinweis}</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-3">
|
||||
<span className="ziffern text-sm text-text-2">{a.groesse}</span>
|
||||
<Button variant="gefahr" size="sm" disabled={loeschen.isPending} onClick={() => setFrage(a)}>
|
||||
Löschen
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<Bestaetigen
|
||||
offen={frage !== null}
|
||||
gefahr
|
||||
titel={`${frage?.name ?? ""} löschen?`}
|
||||
text={`${frage?.hinweis ?? ""} Gelöscht wird: ${frage?.pfade.join(", ") ?? ""} (${frage?.groesse ?? ""}). ` +
|
||||
"Das lässt sich nicht rückgängig machen."}
|
||||
knopf="Endgültig löschen"
|
||||
gesperrt={loeschen.isPending}
|
||||
onNein={() => setFrage(null)}
|
||||
onJa={() => {
|
||||
if (frage) loeschen.mutate(frage)
|
||||
setFrage(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ModelleSeite() {
|
||||
const modelle = useModelle()
|
||||
const nutzung = useNutzung()
|
||||
|
||||
Reference in New Issue
Block a user