Welle 2 · Neu entdeckt: neue Geräte mit Vorschlägen, Übernehmen per Klick (Etikett), App-Katalog pflegt sich selbst
Jeder Bericht wird mit einem Register bekannter Geräte verglichen; ein neuer Container oder eine neue VM erscheint im Homelab-Cockpit unter „Neu entdeckt“, und Lucy meldet ihn einmal über Telegram. Übernehmen: „Für Updates freigeben“ (der Ausführer setzt das Etikett watcher — neue Aktion etikett_setzen, sie darf nur watcher und watcher-aus setzen oder entfernen), „Nur beobachten“ oder „Nicht anfassen“ (watcher-aus). Unbekannte Community-Scripts-Kennungen trägt katalog.py aus ihrem Update-Skript nach (Name, GitHub-Projekt, Port, Weg); ein Port, der nicht antwortet, schaltet die Webprüfung nicht ein. Der Ausführer braucht ausfuehrer-einrichten.sh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f2bffc7203
commit
c7c396f56d
@@ -0,0 +1,40 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react"
|
||||
import type { NeuesGeraet } from "@/lib/typen"
|
||||
import { NeuEntdeckt } from "./NeuEntdeckt"
|
||||
|
||||
const jellyfin: NeuesGeraet = {
|
||||
id: "ct-108", vmid: 108, name: "Jellyfin", hostname: "jellyfin", art: "container", status: "running",
|
||||
erstmals: Math.floor(Date.now() / 1000) - 600, etiketten: [], freigegeben: false, ausgeschlossen: false,
|
||||
community_script: false, ip: "192.168.178.170", ohne_sicherung: true,
|
||||
app: { kennung: "jellyfin", name: "Jellyfin", quelle: "jellyfin/jellyfin", weg: "skript", adresse: null },
|
||||
}
|
||||
|
||||
describe("NeuEntdeckt", () => {
|
||||
it("zeigt, was der Orchestrator weiß, und fragt vor dem Freigeben", () => {
|
||||
const onWahl = vi.fn()
|
||||
render(<NeuEntdeckt geraete={[jellyfin]} gesperrt={false} onWahl={onWahl} />)
|
||||
expect(screen.getByText("Jellyfin (GitHub jellyfin/jellyfin)")).toBeInTheDocument()
|
||||
expect(screen.getByText("Weboberfläche nicht gefunden, keine Webprüfung")).toBeInTheDocument()
|
||||
expect(screen.getByText(/In keinem Sicherungsauftrag/)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole("button", { name: "Für Updates freigeben" }))
|
||||
expect(onWahl).not.toHaveBeenCalled()
|
||||
const dialog = screen.getByRole("dialog")
|
||||
expect(within(dialog).getByText(/Etikett „watcher“/)).toBeInTheDocument()
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Freigeben" }))
|
||||
expect(onWahl).toHaveBeenCalledWith("ct-108", "freigeben")
|
||||
})
|
||||
|
||||
it("übernimmt Beobachten ohne Rückfrage; ein schon freigegebenes Gerät hat nur „Übernehmen“", () => {
|
||||
const onWahl = vi.fn()
|
||||
render(<NeuEntdeckt geraete={[{ ...jellyfin, freigegeben: true, community_script: true }]} gesperrt={false} onWahl={onWahl} />)
|
||||
expect(screen.queryByRole("button", { name: "Für Updates freigeben" })).not.toBeInTheDocument()
|
||||
expect(screen.getByText("Schon freigegeben (Etikett community-script).")).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole("button", { name: "Übernehmen" }))
|
||||
expect(onWahl).toHaveBeenCalledWith("ct-108", "beobachten")
|
||||
})
|
||||
|
||||
it("zeigt nichts, wenn nichts neu ist", () => {
|
||||
const { container } = render(<NeuEntdeckt geraete={[]} gesperrt={false} onWahl={() => {}} />)
|
||||
expect(container).toBeEmptyDOMElement()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
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 { 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",
|
||||
},
|
||||
}
|
||||
|
||||
export function NeuEntdeckt({ geraete, gesperrt, onWahl }: {
|
||||
geraete: NeuesGeraet[]
|
||||
gesperrt: boolean
|
||||
onWahl: (id: string, wahl: NeuWahl) => void
|
||||
}) {
|
||||
const [frage, setFrage] = useState<{ g: NeuesGeraet; wahl: NeuWahl } | null>(null)
|
||||
if (geraete.length === 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.
|
||||
</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 }))} />
|
||||
))}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -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, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
|
||||
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuWahl, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
|
||||
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
|
||||
ZielStand, Zeitraum,
|
||||
} from "./typen"
|
||||
@@ -492,6 +492,23 @@ export const useHomelabSpeicher = (aktiv: boolean) =>
|
||||
retry: false,
|
||||
})
|
||||
|
||||
/** „Neu entdeckt“ (seit 25.09.2026): Geräte, die der Orchestrator noch nicht kennt. */
|
||||
export const useNeuEntdeckt = (aktiv: boolean) =>
|
||||
useQuery({
|
||||
queryKey: ["homelab-neu"],
|
||||
queryFn: () => api<{ geraete: NeuesGeraet[] }>("/api/homelab/neu"),
|
||||
enabled: aktiv,
|
||||
refetchInterval: takt(60_000),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
/** Übernehmen: freigeben (Etikett watcher), nur beobachten oder nicht anfassen (watcher-aus). */
|
||||
export const useNeuUebernehmen = () =>
|
||||
useAktion(({ id, wahl }: { id: string; wahl: NeuWahl }) => post<AktionsErgebnis>(`/api/homelab/neu/${id}`, { wahl }), {
|
||||
erfolg: (_, e) => (e as AktionsErgebnis).text ?? "Übernommen.",
|
||||
neuLaden: ["homelab-neu", "homelab"],
|
||||
})
|
||||
|
||||
/** Die https-Zertifikate (Zertifikats-Wache, seit 25.09.2026) — der Ausführer liest sie alle 30 Minuten. */
|
||||
export const useHomelabZertifikate = (aktiv: boolean) =>
|
||||
useQuery({
|
||||
|
||||
@@ -633,6 +633,29 @@ export interface HomelabSpeicher {
|
||||
empfehlungen: SpeicherEmpfehlung[]
|
||||
}
|
||||
|
||||
// --- Neu entdeckt (GET /api/homelab/neu, seit 25.09.2026) ------------------------------------------------------
|
||||
|
||||
export type NeuWahl = "freigeben" | "beobachten" | "ausschliessen"
|
||||
|
||||
export interface NeuesGeraet {
|
||||
id: string
|
||||
vmid: number
|
||||
/** Name der App, sonst der Name in Proxmox. */
|
||||
name: string
|
||||
hostname: string | null
|
||||
art: "container" | "vm"
|
||||
status: string | null
|
||||
erstmals: number | null
|
||||
etiketten: string[]
|
||||
/** Darf der Orchestrator schon (Etikett community-script oder watcher)? */
|
||||
freigegeben: boolean
|
||||
ausgeschlossen: boolean
|
||||
community_script: boolean
|
||||
app: { kennung: string | null; name: string; quelle: string | null; weg: string; adresse: string | null } | null
|
||||
ip: string | null
|
||||
ohne_sicherung: boolean
|
||||
}
|
||||
|
||||
// --- Zertifikate des Homelabs (GET /api/homelab/zertifikate, seit 25.09.2026) -----------------------------------
|
||||
|
||||
export interface Zertifikat {
|
||||
|
||||
@@ -4,13 +4,15 @@ import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
import { Gesundheit } from "@/components/homelab/Gesundheit"
|
||||
import { Lagebild } from "@/components/homelab/Lagebild"
|
||||
import { NeuEntdeckt } from "@/components/homelab/NeuEntdeckt"
|
||||
import { Speicher } from "@/components/homelab/Speicher"
|
||||
import { AlleKnopf, SammellaufFortschritt } from "@/components/homelab/Sammellauf"
|
||||
import { UpdateTabelle } from "@/components/homelab/UpdateTabelle"
|
||||
import { Zertifikate } from "@/components/homelab/Zertifikate"
|
||||
import {
|
||||
useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useHomelabZertifikate,
|
||||
useInstanz, usePartner, useSammellauf, useSammellaufQuittieren, useSicherungsProbe, useZielAktion,
|
||||
useInstanz, useNeuEntdeckt, useNeuUebernehmen, usePartner, useSammellauf, useSammellaufQuittieren, useSicherungsProbe,
|
||||
useZielAktion,
|
||||
} from "@/lib/abfragen"
|
||||
import { lage, offeneUpdates } from "@/lib/homelab"
|
||||
import { istSnapshotLauf } from "@/lib/snapshots"
|
||||
@@ -199,6 +201,8 @@ export function HomelabSeite() {
|
||||
const speicher = useHomelabSpeicher(!!stand)
|
||||
const zertifikate = useHomelabZertifikate(!!stand)
|
||||
const probe = useSicherungsProbe()
|
||||
const neu = useNeuEntdeckt(!!stand)
|
||||
const uebernehmen = useNeuUebernehmen()
|
||||
if (ersatz || !stand) return ersatz
|
||||
|
||||
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
|
||||
@@ -222,6 +226,8 @@ export function HomelabSeite() {
|
||||
/>
|
||||
<AusfuehrerHinweis stand={stand} />
|
||||
{hinweise.length > 0 && <Hinweise hinweise={hinweise} />}
|
||||
<NeuEntdeckt geraete={neu.data?.geraete ?? []} gesperrt={uebernehmen.isPending || !stand.ausfuehrer.verbunden}
|
||||
onWahl={(id, wahl) => uebernehmen.mutate({ id, wahl })} />
|
||||
<Panel
|
||||
titel="Geräte"
|
||||
id="geraete"
|
||||
|
||||
Reference in New Issue
Block a user