From d2d290f9293b81fab03198652ede21fcacfd1eb0 Mon Sep 17 00:00:00 2001 From: strandedturtle Date: Fri, 26 Jun 2026 07:14:10 +0000 Subject: [PATCH] Phase 4: best-effort changelog "what's changed" panel New changelog.js resolves release notes for a container's image: - if the image's OCI source label points at GitHub, fetch recent releases and show those newer than the running version (heuristic walk, capped); - otherwise link out to the source, Docker Hub tags, or GHCR repo. Parsing/selection helpers (parseGitHubRepo, selectNewerReleases, buildRegistryLink) are pure and unit-tested; only the GitHub fetch hits the network. GET /api/changelog/:name resolves the container's image + version + source via a new docker.getContainerImageMeta and caches per image+version (10 min). Each update card gains a lazy "What's changed" panel rendering release notes (as escaped plain text) or a link-out. Server tests 73/73; client builds clean. --- client/src/api.js | 4 + client/src/components/UpdateCard.jsx | 98 ++++++++++++++++++- client/src/styles/app.css | 58 +++++++++++ server/src/changelog.js | 138 +++++++++++++++++++++++++++ server/src/docker.js | 15 +++ server/src/routes/api.js | 32 ++++++- server/test/changelog.test.js | 53 ++++++++++ 7 files changed, 393 insertions(+), 5 deletions(-) create mode 100644 server/src/changelog.js create mode 100644 server/test/changelog.test.js diff --git a/client/src/api.js b/client/src/api.js index cc58bb5..29c9244 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -125,4 +125,8 @@ export function testNotify(url) { return post('/notify/test', url ? { url } : {}); } +export function getChangelog(name) { + return get(`/changelog/${encodeURIComponent(name)}`); +} + export { ApiError }; diff --git a/client/src/components/UpdateCard.jsx b/client/src/components/UpdateCard.jsx index aebdbba..b95ac3d 100644 --- a/client/src/components/UpdateCard.jsx +++ b/client/src/components/UpdateCard.jsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { pin, unpin } from '../api.js'; +import { pin, unpin, getChangelog } from '../api.js'; import { useUpdateRunner } from '../hooks/useUpdateRunner.js'; import StatusMessage from './StatusMessage.jsx'; import StreamLog from './StreamLog.jsx'; @@ -57,10 +57,61 @@ const ExternalIcon = () => ( ); +// Renders a resolved changelog payload (GitHub release notes, a link-out, or +// nothing). Release bodies render as plain text (React escapes — no XSS). +function ChangelogContent({ data }) { + if (data.type === 'github') { + if (!data.releases.length) { + return ( +

+ No newer release notes found.{' '} + + View releases + +

+ ); + } + return ( +
+ {data.releases.map((r) => ( +
+
+ + {r.name || r.tag} + + {r.publishedAt && ( + + {new Date(r.publishedAt).toLocaleDateString()} + + )} +
+ {r.body &&
{r.body}
} +
+ ))} + + All releases + + +
+ ); + } + if (data.type === 'link') { + return ( +

+ {data.note ? `${data.note} ` : ''} + + {data.label || 'Open'} + +

+ ); + } + return

No changelog source available for this image.

; +} + /** - * A single container's card: identity, version, source/changelog link, pin + - * hide controls, update button, and an expandable live log for the in-flight - * (or most recent) update. + * A single container's card: identity, version, source/changelog link, pin + * control, update button, an expandable "What's changed" panel, and an + * expandable live log for the in-flight (or most recent) update. * * props: * - container: item shape from GET /api/containers @@ -75,6 +126,11 @@ export default function UpdateCard({ container, onSettled, onPinChange, register const [pinBusy, setPinBusy] = useState(false); const [actionError, setActionError] = useState(''); + const [clOpen, setClOpen] = useState(false); + const [clLoading, setClLoading] = useState(false); + const [clData, setClData] = useState(null); + const [clError, setClError] = useState(''); + const { run, busy, startError, status, lines } = useUpdateRunner(name, onSettled); useEffect(() => { @@ -106,6 +162,23 @@ export default function UpdateCard({ container, onSettled, onPinChange, register } }, [pinned, image, onPinChange]); + const toggleChangelog = useCallback(async () => { + const next = !clOpen; + setClOpen(next); + if (next && !clData && !clLoading) { + setClLoading(true); + setClError(''); + try { + const d = await getChangelog(name); + setClData(d); + } catch (err) { + setClError(err.message || 'Failed to load changelog'); + } finally { + setClLoading(false); + } + } + }, [clOpen, clData, clLoading, name]); + const showUpdateAvailable = updateAvailable && !pinned; const link = sourceLink(sourceUrl); @@ -170,6 +243,11 @@ export default function UpdateCard({ container, onSettled, onPinChange, register )} + {showUpdateAvailable && ( + + )}