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:
Hitonabi
2026-09-24 20:37:30 +02:00
co-authored by Claude Opus 5.5
parent 40d2840b16
commit 3d8df1c6fd
24 changed files with 2640 additions and 85 deletions
+61
View File
@@ -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"],
})
+71
View File
@@ -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
}
+116
View File
@@ -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()
})
})
+348 -57
View File
@@ -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>
)
+76
View File
@@ -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()