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>
136 lines
6.5 KiB
TypeScript
136 lines
6.5 KiB
TypeScript
import { useState } from "react"
|
||
import { cn } from "cn"
|
||
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||
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 { 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
|
||
// dem, was er über sie weiß, und drei Wegen, sie zu übernehmen. Freigeben setzt das Etikett watcher — erst danach darf
|
||
// er dort Snapshots anlegen und Updates einspielen; deshalb fragt die Seite vorher nach.
|
||
|
||
const WEG: Record<string, string> = {
|
||
skript: "Updates über das Community-Script",
|
||
"eigene-oberflaeche": "Updates über die eigene Oberfläche der App",
|
||
pakete: "Updates über die Pakete im Gast",
|
||
}
|
||
|
||
function Karte({ g, gesperrt, onWahl }: { g: NeuesGeraet; gesperrt: boolean; onWahl: (w: NeuWahl) => void }) {
|
||
const bild = geraetebild({ art: g.art, name: g.app?.kennung ?? g.hostname ?? g.name })
|
||
const fakten = [
|
||
g.app ? `${g.app.name}${g.app.quelle ? ` (GitHub ${g.app.quelle})` : ""}` : "App unbekannt",
|
||
g.app ? WEG[g.app.weg] ?? g.app.weg : null,
|
||
g.app?.adresse ? `Weboberfläche ${g.app.adresse} wird geprüft` : g.app ? "Weboberfläche nicht gefunden, keine Webprüfung" : null,
|
||
].filter(Boolean)
|
||
return (
|
||
<li className="flaeche-innen flex flex-col gap-3 rounded-2xl p-4">
|
||
<div className="flex items-start gap-3">
|
||
<Geraetesymbol bild={bild} groesse="md" punkt={g.status === "running" ? "ok" : "aus"} />
|
||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||
<span className="text-[15px] font-semibold break-words">{g.name}</span>
|
||
<span className="text-[13px] text-text-3">
|
||
{g.art === "container" ? "Container" : "VM"} {g.vmid}
|
||
{g.hostname && g.hostname !== g.name ? ` · „${g.hostname}“` : ""}
|
||
{g.erstmals ? ` · entdeckt ${flugplanZeit(new Date(g.erstmals * 1000).toISOString()).toLowerCase()}` : ""}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<ul className="m-0 flex list-none flex-col gap-1 p-0 text-sm text-text-2">
|
||
{fakten.map((f) => <li key={f}>{f}</li>)}
|
||
{g.freigegeben && <li>Schon freigegeben (Etikett {g.community_script ? "community-script" : "watcher"}).</li>}
|
||
{g.ohne_sicherung && (
|
||
<li className="text-bernstein">In keinem Sicherungsauftrag — in Proxmox: Rechenzentrum → Backup → den Auftrag bearbeiten.</li>
|
||
)}
|
||
</ul>
|
||
<div className="flex flex-wrap gap-2">
|
||
{!g.freigegeben && !g.ausgeschlossen && (
|
||
<Button size="sm" disabled={gesperrt} onClick={() => onWahl("freigeben")}>Für Updates freigeben</Button>
|
||
)}
|
||
<Button variant={g.freigegeben ? "default" : "outline"} size="sm" disabled={gesperrt} onClick={() => onWahl("beobachten")}>
|
||
{g.freigegeben ? "Übernehmen" : "Nur beobachten"}
|
||
</Button>
|
||
{!g.ausgeschlossen && (
|
||
<Button variant="ghost" size="sm" disabled={gesperrt} onClick={() => onWahl("ausschliessen")}>Nicht anfassen</Button>
|
||
)}
|
||
</div>
|
||
</li>
|
||
)
|
||
}
|
||
|
||
const FRAGE: Record<NeuWahl, { titel: string; text: string; knopf: string }> = {
|
||
freigeben: {
|
||
titel: "Für Updates freigeben?",
|
||
text: "Der Orchestrator setzt in Proxmox das Etikett „watcher“. Danach darf er dort Snapshots anlegen und Updates " +
|
||
"einspielen — immer erst nach deinem Klick, mit Snapshot vorher und Rückweg bei Rot.",
|
||
knopf: "Freigeben",
|
||
},
|
||
beobachten: { titel: "Nur beobachten?", text: "", knopf: "Übernehmen" },
|
||
ausschliessen: {
|
||
titel: "Nicht anfassen?",
|
||
text: "Der Orchestrator setzt in Proxmox das Etikett „watcher-aus“ und lässt das Gerät dann auch bei „Alle " +
|
||
"aktualisieren“ aus.",
|
||
knopf: "Nicht anfassen",
|
||
},
|
||
}
|
||
|
||
/** 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)
|
||
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")}>
|
||
{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}
|
||
titel={frage ? `${frage.g.name}: ${FRAGE[frage.wahl].titel}` : ""}
|
||
text={frage ? FRAGE[frage.wahl].text : ""}
|
||
knopf={frage ? FRAGE[frage.wahl].knopf : ""}
|
||
gesperrt={gesperrt}
|
||
onNein={() => setFrage(null)}
|
||
onJa={() => {
|
||
if (frage) onWahl(frage.g.id, frage.wahl)
|
||
setFrage(null)
|
||
}}
|
||
/>
|
||
</Panel>
|
||
)
|
||
}
|