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:
co-authored by
Claude Opus 5.5
parent
0190c25d5a
commit
45f11bc3dc
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user