Welle 2 · Docker-Dienste unter Arcane einzeln: Stacks, Zustand, Gesundheit, Update-Stand; neue Stacks unter „Neu entdeckt“
Statt „Arcane (Docker) · 11 Images“ zeigt das Homelab-Cockpit je Stack jeden Container mit Zustand, Healthcheck und Update-Stand („Update verfügbar“, „lokal gebaut“ für NerdQuiz und Rippy, „aktuell“). Der Wächter meldet gelb, wenn ein Container ungesund ist oder ständig neu startet; beendete Container sind kein Befund. Neue Stacks erscheinen unter „Neu entdeckt“ (eigenes Register, nur „Übernehmen“), Lucy meldet sie einmal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c7c396f56d
commit
6af42bd42c
@@ -0,0 +1,37 @@
|
||||
import { render, screen, within } from "@testing-library/react"
|
||||
import type { DockerContainer, DockerLage } from "@/lib/typen"
|
||||
import { DockerDienste } from "./DockerDienste"
|
||||
|
||||
const c = (name: string, mehr: Partial<DockerContainer> = {}): DockerContainer => ({
|
||||
id: name, name, stack: "rippy", dienst: name.split("-")[1] ?? name, image: `${name}:latest`, zustand: "running",
|
||||
status: "Up 3 hours", gesund: null, update: "aktuell", aktuell: null, neu: null, ...mehr,
|
||||
})
|
||||
|
||||
const lage: DockerLage = {
|
||||
verfuegbar: true, grund: null,
|
||||
stacks: [
|
||||
{ name: "deploy", container: [c("nerdquiz", { stack: "deploy", gesund: true, update: "lokal" })] },
|
||||
{ name: "rippy", container: [
|
||||
c("rippy-postgres-1", { image: "postgres:16-alpine", update: "neu" }),
|
||||
c("rippy-worker-1", { gesund: false }),
|
||||
] },
|
||||
],
|
||||
}
|
||||
|
||||
describe("DockerDienste", () => {
|
||||
it("zeigt je Stack Zustand, Gesundheit und Update-Stand", () => {
|
||||
render(<DockerDienste lage={lage} />)
|
||||
const deploy = screen.getByRole("region", { name: "Stack deploy" })
|
||||
expect(within(deploy).getByText("läuft, gesund")).toBeInTheDocument()
|
||||
expect(within(deploy).getByText("lokal gebaut")).toBeInTheDocument()
|
||||
const rippy = screen.getByRole("region", { name: "Stack rippy" })
|
||||
expect(within(rippy).getByText("2 von 2 laufen · 1 mit Update")).toBeInTheDocument()
|
||||
expect(within(rippy).getByText("Update verfügbar")).toBeInTheDocument()
|
||||
expect(within(rippy).getByText("ungesund")).toHaveClass("text-rot-text")
|
||||
})
|
||||
|
||||
it("zeigt nichts ohne Arcane", () => {
|
||||
const { container } = render(<DockerDienste lage={{ verfuegbar: false, grund: "Arcane antwortet gerade nicht.", stacks: [] }} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { cn } from "cn"
|
||||
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
|
||||
import { geraetebild, type Punktzustand } from "@/lib/geraetebild"
|
||||
import type { DockerContainer, DockerLage } from "@/lib/typen"
|
||||
|
||||
// Docker-Dienste unter Arcane einzeln (Ausbauplan Welle 2, Punkt 8, seit 25.09.2026): je Stack die Container mit
|
||||
// Zustand, Gesundheit und Update-Stand — statt nur „Arcane (Docker) · 11 Images“.
|
||||
|
||||
const UPDATE: Record<DockerContainer["update"], { text: string; klasse: string }> = {
|
||||
neu: { text: "Update verfügbar", klasse: "text-cyan-text" },
|
||||
lokal: { text: "lokal gebaut", klasse: "text-text-3" },
|
||||
aktuell: { text: "aktuell", klasse: "text-text-3" },
|
||||
unbekannt: { text: "Update-Stand unklar", klasse: "text-bernstein" },
|
||||
}
|
||||
|
||||
function punkt(c: DockerContainer): Punktzustand {
|
||||
if (c.zustand === "restarting" || c.gesund === false) return "rot"
|
||||
if (c.zustand === "running") return "ok"
|
||||
return "aus"
|
||||
}
|
||||
|
||||
function zustandText(c: DockerContainer): string {
|
||||
if (c.gesund === false) return "ungesund"
|
||||
if (c.zustand === "restarting") return "startet immer wieder neu"
|
||||
if (c.zustand === "running") return c.gesund ? "läuft, gesund" : "läuft"
|
||||
if (c.zustand === "exited") return "beendet"
|
||||
if (c.zustand === "paused") return "angehalten"
|
||||
return c.zustand
|
||||
}
|
||||
|
||||
/** Aus „ghcr.io/getarcaneapp/manager:latest“ wird „manager“ — für das Logo. */
|
||||
const imageName = (image: string | null) => (image ?? "").split("/").at(-1)?.split(":")[0] ?? ""
|
||||
|
||||
function Zeile({ c }: { c: DockerContainer }) {
|
||||
const u = UPDATE[c.update] ?? UPDATE.unbekannt
|
||||
return (
|
||||
<li className="flex min-w-0 items-center gap-3 border-t border-white/[0.06] py-2.5 first:border-t-0">
|
||||
<Geraetesymbol bild={geraetebild({ art: "container", name: c.dienst ?? imageName(c.image) })} groesse="sm" punkt={punkt(c)} />
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-[14.5px] font-medium">{c.name}</span>
|
||||
<span className="truncate text-[12.5px] text-text-3">{c.image}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 flex-col items-end text-right">
|
||||
<span className={cn("text-[13px]", punkt(c) === "rot" ? "text-rot-text" : "text-text-2")}>{zustandText(c)}</span>
|
||||
<span className={cn("text-[12px]", u.klasse)}>{u.text}</span>
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function DockerDienste({ lage }: { lage: DockerLage }) {
|
||||
if (!lage.verfuegbar || lage.stacks.length === 0) return null
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2 2xl:grid-cols-3">
|
||||
{lage.stacks.map((s) => {
|
||||
const laufen = s.container.filter((c) => c.zustand === "running").length
|
||||
const neu = s.container.filter((c) => c.update === "neu").length
|
||||
return (
|
||||
<section key={s.name} aria-label={`Stack ${s.name}`} className="flaeche-innen flex min-w-0 flex-col rounded-2xl px-4 py-3">
|
||||
<div className="flex items-baseline justify-between gap-3 pb-1">
|
||||
<h3 className="m-0 truncate text-[15px] font-semibold">{s.name}</h3>
|
||||
<span className="shrink-0 text-[12.5px] text-text-3">
|
||||
{laufen} von {s.container.length} laufen{neu ? ` · ${neu} mit Update` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="m-0 list-none p-0">
|
||||
{s.container.map((c) => <Zeile key={c.id} c={c} />)}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { geraetebild } from "@/lib/geraetebild"
|
||||
import type { NeuesGeraet, NeuWahl } from "@/lib/typen"
|
||||
import type { NeuerStack, NeuesGeraet, NeuWahl } from "@/lib/typen"
|
||||
import { flugplanZeit } from "@/lib/zeit"
|
||||
|
||||
// „Neu entdeckt“ (Ausbauplan Welle 2, Punkt 7, seit 25.09.2026): Geräte, die der Orchestrator noch nicht kennt, mit
|
||||
@@ -76,24 +76,47 @@ const FRAGE: Record<NeuWahl, { titel: string; text: string; knopf: string }> = {
|
||||
},
|
||||
}
|
||||
|
||||
export function NeuEntdeckt({ geraete, gesperrt, onWahl }: {
|
||||
/** Ein neuer Docker-Stack: nur „Übernehmen“ — Etiketten gibt es unter Arcane nicht. */
|
||||
function StackKarte({ s, gesperrt, onWahl }: { s: NeuerStack; gesperrt: boolean; onWahl: (w: NeuWahl) => void }) {
|
||||
return (
|
||||
<li className="flaeche-innen flex flex-col gap-3 rounded-2xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Geraetesymbol bild={geraetebild({ art: "container", name: s.name })} groesse="md" punkt="ok" />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<span className="text-[15px] font-semibold break-words">Docker-Stack {s.name}</span>
|
||||
<span className="text-[13px] text-text-3">
|
||||
unter Arcane{s.erstmals ? ` · entdeckt ${flugplanZeit(new Date(s.erstmals * 1000).toISOString()).toLowerCase()}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="m-0 text-sm break-words text-text-2">Container: {s.container.join(", ") || "–"}</p>
|
||||
<Button size="sm" className="self-start" disabled={gesperrt} onClick={() => onWahl("beobachten")}>Übernehmen</Button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
export function NeuEntdeckt({ geraete, stacks = [], gesperrt, onWahl }: {
|
||||
geraete: NeuesGeraet[]
|
||||
/** Neue Docker-Stacks unter Arcane (seit 25.09.2026). */
|
||||
stacks?: NeuerStack[]
|
||||
gesperrt: boolean
|
||||
onWahl: (id: string, wahl: NeuWahl) => void
|
||||
}) {
|
||||
const [frage, setFrage] = useState<{ g: NeuesGeraet; wahl: NeuWahl } | null>(null)
|
||||
if (geraete.length === 0) return null
|
||||
const anzahl = geraete.length + stacks.length
|
||||
if (anzahl === 0) return null
|
||||
return (
|
||||
<Panel titel="Neu entdeckt" id="neu">
|
||||
<p className={cn("-mt-2 text-[15px] text-text-2")}>
|
||||
{geraete.length === 1 ? "Ein Gerät kennt" : `${geraete.length} Geräte kennt`} der Orchestrator noch nicht. Entscheide,
|
||||
ob er {geraete.length === 1 ? "es" : "sie"} nur beobachtet oder auch aktualisieren darf.
|
||||
{anzahl === 1 ? "Eins kennt" : `${anzahl} kennt`} der Orchestrator noch nicht. Entscheide, ob er
|
||||
{anzahl === 1 ? " es" : " sie"} nur beobachtet oder auch aktualisieren darf.
|
||||
</p>
|
||||
<ul className="m-0 grid list-none gap-3 p-0 md:grid-cols-2">
|
||||
{geraete.map((g) => (
|
||||
<Karte key={g.id} g={g} gesperrt={gesperrt}
|
||||
onWahl={(wahl) => (wahl === "beobachten" ? onWahl(g.id, wahl) : setFrage({ g, wahl }))} />
|
||||
))}
|
||||
{stacks.map((s) => <StackKarte key={s.id} s={s} gesperrt={gesperrt} onWahl={(wahl) => onWahl(s.id, wahl)} />)}
|
||||
</ul>
|
||||
<Bestaetigen
|
||||
offen={!!frage}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { melden } from "./meldungen"
|
||||
import { stromVerbunden } from "./strom"
|
||||
import type {
|
||||
Ablauf, AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
|
||||
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuWahl, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
|
||||
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuerStack, NeuWahl, DockerLage, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
|
||||
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
|
||||
ZielStand, Zeitraum,
|
||||
} from "./typen"
|
||||
@@ -496,7 +496,17 @@ export const useHomelabSpeicher = (aktiv: boolean) =>
|
||||
export const useNeuEntdeckt = (aktiv: boolean) =>
|
||||
useQuery({
|
||||
queryKey: ["homelab-neu"],
|
||||
queryFn: () => api<{ geraete: NeuesGeraet[] }>("/api/homelab/neu"),
|
||||
queryFn: () => api<{ geraete: NeuesGeraet[]; docker?: NeuerStack[] }>("/api/homelab/neu"),
|
||||
enabled: aktiv,
|
||||
refetchInterval: takt(60_000),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
/** Die Docker-Dienste unter Arcane je Stack (seit 25.09.2026). Arcane wird höchstens alle zwei Minuten gefragt. */
|
||||
export const useHomelabDocker = (aktiv: boolean) =>
|
||||
useQuery({
|
||||
queryKey: ["homelab-docker"],
|
||||
queryFn: () => api<DockerLage>("/api/homelab/docker"),
|
||||
enabled: aktiv,
|
||||
refetchInterval: takt(60_000),
|
||||
retry: false,
|
||||
|
||||
@@ -656,6 +656,37 @@ export interface NeuesGeraet {
|
||||
ohne_sicherung: boolean
|
||||
}
|
||||
|
||||
/** Ein neuer Docker-Stack unter Arcane (seit 25.09.2026). */
|
||||
export interface NeuerStack {
|
||||
id: string
|
||||
name: string
|
||||
erstmals: number | null
|
||||
container: string[]
|
||||
}
|
||||
|
||||
// --- Docker-Dienste unter Arcane (GET /api/homelab/docker, seit 25.09.2026) --------------------------------------
|
||||
|
||||
export interface DockerContainer {
|
||||
id: string
|
||||
name: string
|
||||
stack: string | null
|
||||
dienst: string | null
|
||||
image: string | null
|
||||
zustand: string
|
||||
status: string | null
|
||||
/** Healthcheck: true gesund, false ungesund, null ohne Healthcheck. */
|
||||
gesund: boolean | null
|
||||
update: "neu" | "lokal" | "aktuell" | "unbekannt"
|
||||
aktuell: string | null
|
||||
neu: string | null
|
||||
}
|
||||
|
||||
export interface DockerLage {
|
||||
verfuegbar: boolean
|
||||
grund: string | null
|
||||
stacks: { name: string; container: DockerContainer[] }[]
|
||||
}
|
||||
|
||||
// --- Zertifikate des Homelabs (GET /api/homelab/zertifikate, seit 25.09.2026) -----------------------------------
|
||||
|
||||
export interface Zertifikat {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Link, useNavigate } from "@tanstack/react-router"
|
||||
import { ArrowRight } from "lucide-react"
|
||||
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
import { DockerDienste } from "@/components/homelab/DockerDienste"
|
||||
import { Gesundheit } from "@/components/homelab/Gesundheit"
|
||||
import { Lagebild } from "@/components/homelab/Lagebild"
|
||||
import { NeuEntdeckt } from "@/components/homelab/NeuEntdeckt"
|
||||
@@ -10,7 +11,8 @@ import { AlleKnopf, SammellaufFortschritt } from "@/components/homelab/Sammellau
|
||||
import { UpdateTabelle } from "@/components/homelab/UpdateTabelle"
|
||||
import { Zertifikate } from "@/components/homelab/Zertifikate"
|
||||
import {
|
||||
useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useHomelabZertifikate,
|
||||
useHomelab, useHomelabDocker, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher,
|
||||
useHomelabZertifikate,
|
||||
useInstanz, useNeuEntdeckt, useNeuUebernehmen, usePartner, useSammellauf, useSammellaufQuittieren, useSicherungsProbe,
|
||||
useZielAktion,
|
||||
} from "@/lib/abfragen"
|
||||
@@ -202,6 +204,7 @@ export function HomelabSeite() {
|
||||
const zertifikate = useHomelabZertifikate(!!stand)
|
||||
const probe = useSicherungsProbe()
|
||||
const neu = useNeuEntdeckt(!!stand)
|
||||
const docker = useHomelabDocker(!!stand)
|
||||
const uebernehmen = useNeuUebernehmen()
|
||||
if (ersatz || !stand) return ersatz
|
||||
|
||||
@@ -226,7 +229,8 @@ export function HomelabSeite() {
|
||||
/>
|
||||
<AusfuehrerHinweis stand={stand} />
|
||||
{hinweise.length > 0 && <Hinweise hinweise={hinweise} />}
|
||||
<NeuEntdeckt geraete={neu.data?.geraete ?? []} gesperrt={uebernehmen.isPending || !stand.ausfuehrer.verbunden}
|
||||
<NeuEntdeckt geraete={neu.data?.geraete ?? []} stacks={neu.data?.docker ?? []}
|
||||
gesperrt={uebernehmen.isPending || !stand.ausfuehrer.verbunden}
|
||||
onWahl={(id, wahl) => uebernehmen.mutate({ id, wahl })} />
|
||||
<Panel
|
||||
titel="Geräte"
|
||||
@@ -245,6 +249,11 @@ export function HomelabSeite() {
|
||||
<p className="text-xs text-text-3">CPU und Speicher erscheinen, sobald der Ausführer Messwerte schickt.</p>
|
||||
)}
|
||||
</Panel>
|
||||
{docker.data?.verfuegbar && docker.data.stacks.length > 0 && (
|
||||
<Panel titel="Docker-Dienste" id="docker">
|
||||
<DockerDienste lage={docker.data} />
|
||||
</Panel>
|
||||
)}
|
||||
{speicher.data?.bericht && (
|
||||
<Panel titel="Speicher und Sicherungen" id="speicher">
|
||||
<Speicher
|
||||
|
||||
Reference in New Issue
Block a user