Welle 2 · „Was ist neu?“ vor dem Knopf: Release Notes, Debian-Änderungslisten und Lucys Kurzfassung

Vor einem App-Update sammelt der Orchestrator die Release Notes aller Versionen zwischen installiert und neu (GitHub),
beim Proxmox-Host die Debian-Änderungslisten der wichtigsten Pakete (neue Ausführer-Aktion changelog, nur lesend, nur
für anstehende Pakete). Lucy fasst sie über das Modell fast der KI-Box auf Deutsch zusammen: Handarbeit ganz oben,
höchstens fünf Stichpunkte, Brüche zuerst. Die Update-Tabelle hat „Was ist neu?“ je Gerät, und die Rückfrage vor dem
Update zeigt die Kurzfassung gleich mit. Gemerkt je Version; der Ausführer braucht ausfuehrer-einrichten.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-25 17:47:04 +02:00
co-authored by Claude Opus 5.5
parent 0190c25d5a
commit 45f11bc3dc
11 changed files with 604 additions and 7 deletions
@@ -0,0 +1,46 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { fireEvent, render, screen } from "@testing-library/react"
import { api } from "@/lib/api"
import type { Neuerungen as NeuerungenDaten } from "@/lib/typen"
import { Neuerungen } from "./Neuerungen"
vi.mock("@/lib/api", async (original) => ({
...(await original<typeof import("@/lib/api")>()),
api: vi.fn(),
}))
const GITEA: NeuerungenDaten = {
verfuegbar: true, von: "1.25.4", nach: "1.26.1", quelle_url: "https://github.com/go-gitea/gitea/releases",
versionen: [
{ version: "v1.26.1", titel: "Gitea 1.26.1", datum: null, url: "u1", text: "* Security: fix XSS" },
{ version: "v1.26.0", titel: "Gitea 1.26.0", datum: null, url: "u0", text: "BREAKING: LFS_JWT_SECRET moved" },
],
zusammenfassung: { aktion: true, aktion_text: "In app.ini [lfs] JWT_SECRET setzen", punkte: ["⚠️ LFS-Schlüssel umgezogen"] },
}
function zeigen(daten: NeuerungenDaten, kompakt = false) {
vi.mocked(api).mockResolvedValue(daten)
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(<QueryClientProvider client={qc}><Neuerungen zid="ct-104" baustein="app" kompakt={kompakt} /></QueryClientProvider>)
}
describe("Neuerungen", () => {
it("stellt die Handarbeit an den Anfang und zeigt die Release Notes auf Wunsch", async () => {
zeigen(GITEA)
expect(await screen.findByText("⚠ Handarbeit: In app.ini [lfs] JWT_SECRET setzen")).toBeInTheDocument()
expect(screen.getByText("⚠️ LFS-Schlüssel umgezogen")).toBeInTheDocument()
fireEvent.click(screen.getByText("Release Notes (2 Versionen)"))
expect(screen.getByText("BREAKING: LFS_JWT_SECRET moved")).toBeInTheDocument()
expect(api).toHaveBeenCalledWith("/api/homelab/ziele/ct-104/neuerungen?baustein=app")
})
it("in der Rückfrage nur die Kurzfassung; ohne Notes der Grund", async () => {
const { unmount } = zeigen({ ...GITEA, zusammenfassung: { aktion: false, aktion_text: "", punkte: [] } }, true)
expect(await screen.findByText("Du musst dafür nichts selbst tun.")).toBeInTheDocument()
expect(screen.queryByText(/Release Notes/)).not.toBeInTheDocument()
unmount()
zeigen({ verfuegbar: false, grund: "Zu diesen Versionen stehen auf GitHub keine Release Notes.", quelle_url: "https://x" })
expect(await screen.findByText(/keine Release Notes/)).toBeInTheDocument()
expect(screen.getByRole("link", { name: "Zu den Releases" })).toHaveAttribute("href", "https://x")
})
})
@@ -0,0 +1,88 @@
import { ExternalLink } from "lucide-react"
import { cn } from "cn"
import { useNeuerungen } from "@/lib/abfragen"
import type { Neuerungen as NeuerungenDaten } from "@/lib/typen"
// „Was ist neu?“ vor dem Knopf (Ausbauplan Welle 2, Punkt 9, seit 25.09.2026): Lucys Kurzfassung der Release Notes
// (bzw. der Debian-Änderungslisten beim Proxmox-Host), Handarbeit ganz oben — und darunter die Quellen selbst.
function Kurzfassung({ d }: { d: NeuerungenDaten }) {
const z = d.zusammenfassung
if (!z) return <p className="m-0 text-sm text-text-3">Lucy konnte gerade keine Kurzfassung schreiben; die Quellen stehen unten.</p>
return (
<div className="flex flex-col gap-2">
{z.aktion ? (
<p className="m-0 rounded-xl bg-bernstein/10 px-3 py-2 text-sm text-bernstein ring-1 ring-bernstein/30 ring-inset">
⚠ Handarbeit: {z.aktion_text || "Lies die Hinweise unten, bevor du updatest."}
</p>
) : (
z.aktion === false && <p className="m-0 text-sm text-gruen-text">Du musst dafür nichts selbst tun.</p>
)}
{z.punkte.length > 0 && (
<ul className="m-0 flex list-disc flex-col gap-1 pl-5 text-sm text-text-2">
{z.punkte.map((p) => <li key={p}>{p}</li>)}
</ul>
)}
</div>
)
}
function Quellen({ d }: { d: NeuerungenDaten }) {
const versionen = d.versionen ?? []
const pakete = d.pakete ?? []
if (versionen.length === 0 && pakete.length === 0) return null
return (
<details className="group text-sm">
<summary className="cursor-pointer text-text-3 hover:text-text-2">
{versionen.length ? `Release Notes (${versionen.length} ${versionen.length === 1 ? "Version" : "Versionen"})`
: `Änderungslisten (${pakete.length} Pakete${d.weitere ? `, ${d.weitere} weitere ohne Liste` : ""})`}
</summary>
<div className="mt-2 flex max-h-80 flex-col gap-3 overflow-y-auto pr-1">
{versionen.map((v) => (
<section key={v.version} className="flex flex-col gap-1">
<span className="flex items-baseline justify-between gap-2">
<span className="font-medium">{v.titel}</span>
{v.url && (
<a href={v.url} target="_blank" rel="noopener" className="inline-flex shrink-0 items-center gap-1 text-cyan hover:underline">
GitHub <ExternalLink className="size-3" aria-hidden />
</a>
)}
</span>
<pre className="m-0 font-sans text-[13px] break-words whitespace-pre-wrap text-text-3">{v.text || "–"}</pre>
</section>
))}
{pakete.map((p) => (
<section key={p.name} className="flex flex-col gap-1">
<span className="font-mono text-[13px] font-medium">{p.name}</span>
<pre className="m-0 font-mono text-[12px] break-words whitespace-pre-wrap text-text-3">{p.text}</pre>
</section>
))}
</div>
</details>
)
}
/** Lädt beim ersten Anzeigen; das erste Mal kann es ein paar Sekunden dauern (GitHub, Ausführer, Lucy). */
export function Neuerungen({ zid, baustein, kompakt = false, className }: {
zid: string
baustein: string
/** In der Rückfrage vor dem Update: nur die Kurzfassung, ohne Quellen. */
kompakt?: boolean
className?: string
}) {
const q = useNeuerungen(zid, baustein, true)
let inhalt
if (q.isPending) inhalt = <p className="m-0 text-sm text-text-3" role="status">Lucy liest, was neu ist …</p>
else if (q.error) inhalt = <p className="m-0 text-sm text-text-3">Ließ sich gerade nicht lesen: {q.error.message}</p>
else if (!q.data?.verfuegbar) {
inhalt = (
<p className="m-0 text-sm text-text-3">
{q.data?.grund ?? "Keine Angaben."}
{q.data?.quelle_url && <> <a href={q.data.quelle_url} target="_blank" rel="noopener" className="text-cyan hover:underline">Zu den Releases</a></>}
</p>
)
} else {
inhalt = <><Kurzfassung d={q.data} />{!kompakt && <Quellen d={q.data} />}</>
}
return <div className={cn("flex flex-col gap-2", className)}>{inhalt}</div>
}
@@ -1,9 +1,10 @@
import { useState } from "react"
import { Search } from "lucide-react"
import { ChevronRight, Search } from "lucide-react"
import { cn } from "cn"
import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
import { Chip } from "@/components/cockpit/Chip"
import { Geraetesymbol } from "@/components/cockpit/Geraetesymbol"
import { Neuerungen } from "@/components/homelab/Neuerungen"
import { Button } from "@/components/ui/button"
import { geraetebild } from "@/lib/geraetebild"
import {
@@ -68,6 +69,12 @@ function PaketZelle({ b, onSuchen, gesperrt }: { b: BausteinStand | null; onSuch
const KNOPF_TEXT: Record<string, string> = { app: "App updaten", os: "Pakete einspielen", pakete: "Pakete einspielen" }
/** Wofür es „Was ist neu?“ gibt (seit 25.09.2026): eine neue App-Version, die Pakete des Proxmox-Hosts. */
function mitNeuerungen(ziel: ZielStand, b: BausteinStand | null | undefined): b is BausteinStand {
if (!b || b.zustand !== "neu") return false
return (b.id === "app" && !!b.verfuegbar) || (ziel.art === "proxmox-host" && b.id === "pakete")
}
function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
ziel: ZielStand
laeuft: string | null
@@ -78,6 +85,8 @@ function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
const sys = systemBaustein(ziel)
const knoepfe = ziel.bausteine.filter((b) => b.zustand === "neu" && b.aktion)
const ruhig = updateRang(ziel) >= 2
const neues = ziel.bausteine.find((b) => mitNeuerungen(ziel, b))
const [offen, setOffen] = useState(false)
return (
<div
role="row"
@@ -111,6 +120,16 @@ function Zeile({ ziel, laeuft, gesperrt, onFrage }: {
))
)}
</span>
{neues && (
<div className="col-span-full flex flex-col gap-2">
<button type="button" aria-expanded={offen} onClick={() => setOffen(!offen)}
className="inline-flex min-h-9 items-center gap-1.5 self-start text-sm text-cyan-text hover:underline">
<ChevronRight className={cn("size-4 transition-transform", offen && "rotate-90")} aria-hidden />
Was ist neu?
</button>
{offen && <Neuerungen zid={ziel.id} baustein={neues.id} className="flaeche-innen rounded-xl p-3" />}
</div>
)}
</div>
)
}
@@ -126,6 +145,8 @@ export function UpdateTabelle({ ziele, laufend, gesperrt, onAktion }: {
onAktion: (a: ZielAktion) => void
}) {
const [frage, setFrage] = useState<Frage>(null)
// Zeigt die Rückfrage ein Update mit „Was ist neu?“, steht Lucys Kurzfassung gleich darin (seit 25.09.2026).
const frageBaustein = frage ? frage.ziel.bausteine.find((b) => b.aktion?.pfad === frage.aktion.pfad && mitNeuerungen(frage.ziel, b)) : undefined
const sortiert = [...ziele].sort((a, b) => updateRang(a) - updateRang(b))
const aktuell = ziele.filter((z) => updateRang(z) === 3).length
return (
@@ -155,7 +176,9 @@ export function UpdateTabelle({ ziele, laufend, gesperrt, onAktion }: {
setFrage(null)
}}
onNein={() => setFrage(null)}
/>
>
{frageBaustein && frage && <Neuerungen zid={frage.ziel.id} baustein={frageBaustein.id} kompakt />}
</Bestaetigen>
</>
)
}
+11 -1
View File
@@ -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, NeuerStack, NeuWahl, DockerLage, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, HomelabZertifikate, NeuesGeraet, NeuerStack, NeuWahl, DockerLage, Neuerungen, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
ZielStand, Zeitraum,
} from "./typen"
@@ -492,6 +492,16 @@ export const useHomelabSpeicher = (aktiv: boolean) =>
retry: false,
})
/** „Was ist neu?“ (seit 25.09.2026): Release Notes und Lucys Kurzfassung. Ändert sich nur mit der Version. */
export const useNeuerungen = (zid: string, baustein: string, aktiv: boolean) =>
useQuery({
queryKey: ["homelab-neuerungen", zid, baustein],
queryFn: () => api<Neuerungen>(`/api/homelab/ziele/${zid}/neuerungen?baustein=${encodeURIComponent(baustein)}`),
enabled: aktiv,
staleTime: 10 * 60_000,
retry: false,
})
/** „Neu entdeckt“ (seit 25.09.2026): Geräte, die der Orchestrator noch nicht kennt. */
export const useNeuEntdeckt = (aktiv: boolean) =>
useQuery({
+15
View File
@@ -633,6 +633,21 @@ export interface HomelabSpeicher {
empfehlungen: SpeicherEmpfehlung[]
}
// --- Was ist neu? (GET /api/homelab/ziele/{id}/neuerungen, seit 25.09.2026) --------------------------------------
export interface Neuerungen {
verfuegbar: boolean
grund?: string | null
von?: string
nach?: string
versionen?: { version: string; titel: string; datum: string | null; url: string | null; text: string }[]
pakete?: { name: string; text: string }[]
weitere?: number
quelle_url?: string
/** Lucys Kurzfassung; aktion = ob der User selbst etwas tun muss. */
zusammenfassung?: { aktion: boolean | null; aktion_text: string; punkte: string[] } | null
}
// --- Neu entdeckt (GET /api/homelab/neu, seit 25.09.2026) ------------------------------------------------------
export type NeuWahl = "freigeben" | "beobachten" | "ausschliessen"