phase2c: Oberflaeche heisst Homelab Orchestrator, Bereich Homelab, Anfragen gehen an die richtige Instanz

- /api/instanz: Rolle der ausliefernden Instanz, ohne Netzabfragen
- lib/instanz.ts: /api/homelab/... an die Homelab-Instanz, alles andere an die Box,
  ueber /api/partner/..., wenn die andere Instanz die Seite ausliefert
- neue Seite Homelab: Stand der zweiten Instanz (noch nicht eingerichtet / verbunden /
  antwortet nicht) und was dazukommt
- Kopfzeile, Titel und Web-Manifest mit neuem Namen; fuenfter Menuepunkt, 320 px geprueft

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 17:59:23 +02:00
co-authored by Claude Opus 5.5
parent ee2bb9d03c
commit 22bb8c672b
41 changed files with 336 additions and 99 deletions
+10 -1
View File
@@ -9,7 +9,8 @@ import { fehlerText, laeuftNoch } from "./anzeige"
import { melden } from "./meldungen"
import { stromVerbunden } from "./strom"
import type {
AktionsErgebnis, AufraeumKandidat, Job, Modell, Nutzung, Radar, Sicherung, Start, UpdateDetails,
AktionsErgebnis, AufraeumKandidat, Instanz, Job, Modell, Nutzung, PartnerStatus, Radar, Sicherung, Start,
UpdateDetails,
} from "./typen"
import { gbText } from "./zeit"
@@ -253,3 +254,11 @@ export const useRadarSuche = () =>
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<Instanz>("/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<PartnerStatus>("/api/partner"), refetchInterval: 30_000 })
+6 -2
View File
@@ -1,5 +1,8 @@
// Dünner Zugriff auf das MC2-Backend. Bewusst ohne Anmelde- oder Geheimnis-Felder:
// Dünner Zugriff auf das Backend. Bewusst ohne Anmelde- oder Geheimnis-Felder:
// Nichts, was der Browser speichert, reist hier mit (Lehre aus v3-Umbau P1).
// Seit Phase 2 gibt es zwei Instanzen; welche eine Anfrage beantwortet, entscheidet lib/instanz.ts.
import { eigeneRolle, zielPfad } from "./instanz"
export class ApiFehler extends Error {
constructor(
@@ -22,9 +25,10 @@ function detailAus(body: unknown, status: number): string {
}
export async function api<T>(pfad: string, init: RequestInit = {}): Promise<T> {
const ziel = zielPfad(pfad, await eigeneRolle())
let antwort: Response
try {
antwort = await fetch(pfad, {
antwort = await fetch(ziel, {
...init,
headers: { "Content-Type": "application/json", Accept: "application/json", ...init.headers },
})
+50
View File
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { eigeneRolle, rolleVergessen, zielPfad } from "./instanz"
describe("zielPfad", () => {
it("die Box beantwortet den Box-Wart selbst und reicht den Homelab-Bereich weiter", () => {
expect(zielPfad("/api/start", "box")).toBe("/api/start")
expect(zielPfad("/api/homelab/ziele?frisch=1", "box")).toBe("/api/partner/homelab/ziele?frisch=1")
expect(zielPfad("/api/homelab", "box")).toBe("/api/partner/homelab")
})
it("der Homelab-Container reicht den Box-Wart weiter", () => {
expect(zielPfad("/api/start", "homelab")).toBe("/api/partner/start")
expect(zielPfad("/api/homelab/ziele", "homelab")).toBe("/api/homelab/ziele")
})
it("Instanz, Partner und Lebenszeichen bleiben immer lokal", () => {
for (const rolle of ["box", "homelab"] as const) {
expect(zielPfad("/api/instanz", rolle)).toBe("/api/instanz")
expect(zielPfad("/api/partner", rolle)).toBe("/api/partner")
expect(zielPfad("/api/partner/health", rolle)).toBe("/api/partner/health")
expect(zielPfad("/api/health", rolle)).toBe("/api/health")
}
// Nur ganze Pfadteile zählen: /api/homelabx gehört nicht zum Homelab-Bereich.
expect(zielPfad("/api/homelabx", "box")).toBe("/api/homelabx")
expect(zielPfad("/hermes-ui/", "homelab")).toBe("/hermes-ui/")
})
})
describe("eigeneRolle", () => {
afterEach(() => {
rolleVergessen()
vi.unstubAllGlobals()
})
it("fragt einmal und merkt sich die Antwort", async () => {
const abruf = vi.fn(async () => new Response(JSON.stringify({ rolle: "homelab" }), { status: 200 }))
vi.stubGlobal("fetch", abruf)
expect(await eigeneRolle()).toBe("homelab")
expect(await eigeneRolle()).toBe("homelab")
expect(abruf).toHaveBeenCalledTimes(1)
})
it("nimmt bei einem Fehler die Box an und fragt beim nächsten Mal neu", async () => {
const abruf = vi.fn(async () => new Response("", { status: 404 }))
vi.stubGlobal("fetch", abruf)
expect(await eigeneRolle()).toBe("box")
expect(await eigeneRolle()).toBe("box")
expect(abruf).toHaveBeenCalledTimes(2)
})
})
+39
View File
@@ -0,0 +1,39 @@
// Zwei Instanzen, eine Oberfläche (User-Entscheid 24.09.2026): Die Seite kommt von der KI-Box
// (Rolle „box“) oder später vom Container auf dem Proxmox-PC (Rolle „homelab“). Der Homelab-Bereich
// fragt unter /api/homelab/…, alles andere gehört zum Box-Wart. Was die ausliefernde Instanz nicht
// selbst beantwortet, geht über /api/partner/… an die andere — das entscheidet zielPfad().
export type Rolle = "box" | "homelab"
/** Beantwortet immer die Instanz, die die Seite ausliefert. */
const IMMER_LOKAL = ["/api/instanz", "/api/partner", "/api/health"]
const unter = (pfad: string, basis: string) =>
pfad === basis || pfad.startsWith(`${basis}/`) || pfad.startsWith(`${basis}?`)
/** Wohin eine Anfrage der Oberfläche geht, je nach Rolle der Instanz, die die Seite ausliefert. */
export function zielPfad(pfad: string, eigene: Rolle): string {
if (!pfad.startsWith("/api/") || IMMER_LOKAL.some((b) => unter(pfad, b))) return pfad
const bereich: Rolle = unter(pfad, "/api/homelab") ? "homelab" : "box"
return bereich === eigene ? pfad : `/api/partner${pfad.slice(4)}`
}
let rolle: Promise<Rolle> | null = null
/** Rolle der ausliefernden Instanz, einmal gefragt. Scheitert die Frage, gilt „box“ (so war es bis
* Phase 2) — und beim nächsten Mal wird neu gefragt. */
export function eigeneRolle(): Promise<Rolle> {
rolle ??= fetch("/api/instanz", { headers: { Accept: "application/json" } })
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then((d: { rolle?: string }) => (d.rolle === "homelab" ? "homelab" : "box") as Rolle)
.catch(() => {
rolle = null
return "box" as Rolle
})
return rolle
}
/** Nur für Tests: vergessene Rolle. */
export function rolleVergessen() {
rolle = null
}
+21
View File
@@ -222,3 +222,24 @@ export interface Radar {
kandidaten: RadarKandidat[]
tests: RadarTest[]
}
/** Wer die Seite ausliefert (GET /api/instanz, seit Phase 2). */
export interface Instanz {
rolle: "box" | "homelab"
instanz: string
partner: { eingerichtet: boolean; name: string }
}
/** Lebenszeichen der anderen Instanz (GET /api/partner). Ohne zweite Instanz nur `eingerichtet: false`. */
export interface PartnerStatus {
eingerichtet: boolean
name?: string
url?: string
erreichbar?: boolean
rolle?: string | null
instanz?: string | null
version?: string | null
fehler?: string | null
/** Unix-Sekunden */
geprueft?: number
}