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
+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()
})
})