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:
co-authored by
Claude Opus 5.5
parent
ee2bb9d03c
commit
22bb8c672b
@@ -1,6 +1,6 @@
|
||||
import { lazy, Suspense, useEffect, useState } from "react"
|
||||
import { Link, Outlet } from "@tanstack/react-router"
|
||||
import { Cpu, Ellipsis, ExternalLink, House, RefreshCw, ScrollText, Settings } from "lucide-react"
|
||||
import { Cpu, Ellipsis, ExternalLink, House, RefreshCw, ScrollText, Server, Settings } from "lucide-react"
|
||||
import { cn } from "cn"
|
||||
import { useStrom, useStromLage } from "@/lib/strom"
|
||||
import { Meldungen } from "./Meldungen"
|
||||
@@ -11,10 +11,12 @@ const Dienste = lazy(() => import("@/views/Dienste").then((m) => ({ default: m.D
|
||||
const Einstellungen = lazy(() => import("@/views/Einstellungen").then((m) => ({ default: m.Einstellungen })))
|
||||
const MehrMenue = lazy(() => import("./MehrMenue").then((m) => ({ default: m.MehrMenue })))
|
||||
|
||||
// Start, Updates und Modelle sind der Box-Wart (KI-Box), „Homelab“ der Bereich des Proxmox-PCs.
|
||||
const NAV = [
|
||||
{ to: "/", label: "Start", Icon: House },
|
||||
{ to: "/updates", label: "Updates", Icon: RefreshCw },
|
||||
{ to: "/modelle", label: "Modelle", Icon: Cpu },
|
||||
{ to: "/homelab", label: "Homelab", Icon: Server },
|
||||
] as const
|
||||
|
||||
const uhrzeit = new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit", timeZone: "Europe/Berlin" })
|
||||
@@ -55,16 +57,19 @@ function Verbindung() {
|
||||
)
|
||||
}
|
||||
|
||||
/** Homelab Orchestrator (vormals MC2, Name seit 24.09.2026). Am Handy reicht das Zeichen: Dort stehen
|
||||
* Verbindung und Uhr daneben, und die Leiste unten sagt, wo man ist. */
|
||||
function Marke() {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" aria-hidden>
|
||||
<svg width="34" height="34" viewBox="0 0 34 34" fill="none" aria-hidden className="shrink-0">
|
||||
<rect x="1.5" y="1.5" width="31" height="31" rx="8" stroke="var(--linie-stark)" strokeWidth="2" />
|
||||
<path d="M7 18h5l3-7 4 13 3-6h5" stroke="var(--bernstein)" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
<span className="font-anzeige text-[28px] leading-none font-bold tracking-[0.06em]">MC2</span>
|
||||
<span className="hidden h-6 w-px bg-linie sm:block" />
|
||||
<span className="schild hidden text-base tracking-[0.22em] text-text-3 sm:block">Box-Wart</span>
|
||||
<span className="sr-only sm:not-sr-only sm:flex sm:flex-col sm:leading-none">
|
||||
<span className="font-anzeige text-[22px] font-bold tracking-[0.06em]">Homelab</span>
|
||||
<span className="schild text-[13px] tracking-[0.22em] text-text-3">Orchestrator</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -87,7 +92,7 @@ export function Shell() {
|
||||
<Link
|
||||
key={to}
|
||||
to={to}
|
||||
className="schild flex h-11 items-center rounded-[9px] border border-transparent px-5 text-base text-text-3 hover:text-foreground"
|
||||
className="schild flex h-11 items-center rounded-[9px] border border-transparent px-4 text-base text-text-3 hover:text-foreground xl:px-5"
|
||||
activeProps={{ className: "!border-linie-stark bg-erhaben !text-foreground" }}
|
||||
activeOptions={{ exact: true }}
|
||||
>
|
||||
@@ -138,7 +143,7 @@ export function Shell() {
|
||||
{/* Handy und Tablet: Leiste unten, mit Abstand zur Wisch-Leiste des Telefons. */}
|
||||
<nav
|
||||
aria-label="Hauptnavigation"
|
||||
className="fixed inset-x-0 bottom-0 z-40 grid grid-cols-4 gap-1 border-t border-linie bg-[#0e1119]/95 px-2 pt-2 pb-[calc(0.75rem+env(safe-area-inset-bottom))] backdrop-blur lg:hidden"
|
||||
className="fixed inset-x-0 bottom-0 z-40 grid grid-cols-5 gap-1 border-t border-linie bg-[#0e1119]/95 px-2 pt-2 pb-[calc(0.75rem+env(safe-area-inset-bottom))] backdrop-blur lg:hidden"
|
||||
>
|
||||
{NAV.map(({ to, label, Icon }) => (
|
||||
<Link
|
||||
|
||||
@@ -23,8 +23,15 @@ const modelle = createRoute({
|
||||
component: lazyRouteComponent(() => import("@/views/Modelle"), "ModelleSeite"),
|
||||
})
|
||||
|
||||
// Der Homelab-Bereich (Phase 2c): heute Stand der zweiten Instanz, ab Phase 3 die Geräte mit Updates.
|
||||
const homelab = createRoute({
|
||||
getParentRoute: () => wurzel,
|
||||
path: "/homelab",
|
||||
component: lazyRouteComponent(() => import("@/views/Homelab"), "HomelabSeite"),
|
||||
})
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree: wurzel.addChildren([start, updates, modelle]),
|
||||
routeTree: wurzel.addChildren([start, updates, modelle, homelab]),
|
||||
defaultPreload: "intent",
|
||||
defaultErrorComponent: Absturz,
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Chip, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
import { useInstanz, usePartner } from "@/lib/abfragen"
|
||||
import type { PartnerStatus } from "@/lib/typen"
|
||||
|
||||
/** Was der Homelab-Teil übernimmt (Bestandsaufnahme vom 24.09.2026; ab Phase 3 kommt die Liste live). */
|
||||
const GERAETE = ["Proxmox-Host", "AdGuard", "NPMplus", "NetBird", "PVE Scripts Local", "Gitea", "PBS", "Arcane mit Docker"]
|
||||
|
||||
function Verbindung({ partner }: { partner: PartnerStatus }) {
|
||||
if (partner.erreichbar) {
|
||||
return (
|
||||
<>
|
||||
<p className="text-[15px] text-text-2">
|
||||
{partner.instanz ?? partner.name} antwortet{partner.version ? ` (Version ${partner.version})` : ""}. Die
|
||||
Übersicht der Geräte mit ihren Updates folgt als Nächstes.
|
||||
</p>
|
||||
<p className="ziffern text-sm text-text-3">{partner.url}</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<p role="alert" className="text-[15px] text-rot-text">
|
||||
Der Homelab-Teil ({partner.url}) ist {partner.fehler ?? "nicht erreichbar"}. Hält das an, meldet der Wächter es
|
||||
nach fünf Minuten auf Telegram.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function NochNichtEingerichtet() {
|
||||
return (
|
||||
<>
|
||||
<p className="text-[15px] text-text-2">
|
||||
Der Homelab-Teil läuft als eigene Instanz in einem kleinen Container auf dem Proxmox-PC. Von dort prüft er
|
||||
auch die KI-Box von außen.
|
||||
</p>
|
||||
<p className="text-[15px] text-text-2">
|
||||
Steht er, zeigt diese Seite alle Geräte mit ihren offenen Updates. Ein Update startest du dann hier mit
|
||||
„Jetzt updaten“: Vorher wird gesichert, danach die Erreichbarkeit geprüft, und ist sie rot, geht es von selbst
|
||||
zurück.
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="schild text-sm tracking-[0.18em] text-text-3">Dazu kommen</h3>
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{GERAETE.map((g) => (
|
||||
<li key={g}>
|
||||
<Chip>{g}</Chip>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<p className="text-sm text-text-3">Der Container wird erst nach deiner Freigabe angelegt.</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function HomelabSeite() {
|
||||
const instanz = useInstanz()
|
||||
const partner = usePartner()
|
||||
|
||||
if (instanz.isPending || partner.isPending) return <Zustandsfeld text="Wird gelesen …" />
|
||||
if (!partner.data) {
|
||||
return <Zustandsfeld fehler text={`Der Stand des Homelab-Teils lässt sich nicht lesen: ${partner.error?.message ?? "keine Daten"}`} />
|
||||
}
|
||||
|
||||
// Liefert der Homelab-Teil die Seite selbst aus, ist die Box sein Partner (ab Phase 3).
|
||||
if (instanz.data?.rolle === "homelab") {
|
||||
return (
|
||||
<Panel titel="Homelab" rechts={<Chip art="gruen">Diese Instanz</Chip>}>
|
||||
<p className="text-[15px] text-text-2">Diese Seite kommt vom Homelab-Teil auf dem Proxmox-PC.</p>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
const p = partner.data
|
||||
const chip = !p.eingerichtet
|
||||
? <Chip>Noch nicht eingerichtet</Chip>
|
||||
: p.erreichbar
|
||||
? <Chip art="gruen">Verbunden</Chip>
|
||||
: <Chip art="rot">Antwortet nicht</Chip>
|
||||
|
||||
return (
|
||||
<Panel titel="Homelab" rechts={chip}>
|
||||
{p.eingerichtet ? <Verbindung partner={p} /> : <NochNichtEingerichtet />}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user