Skip to content

Commit d2d290f

Browse files
author
strandedturtle
committed
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.
1 parent 319afb1 commit d2d290f

7 files changed

Lines changed: 393 additions & 5 deletions

File tree

client/src/api.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,4 +125,8 @@ export function testNotify(url) {
125125
return post('/notify/test', url ? { url } : {});
126126
}
127127

128+
export function getChangelog(name) {
129+
return get(`/changelog/${encodeURIComponent(name)}`);
130+
}
131+
128132
export { ApiError };

client/src/components/UpdateCard.jsx

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useCallback, useEffect, useState } from 'react';
2-
import { pin, unpin } from '../api.js';
2+
import { pin, unpin, getChangelog } from '../api.js';
33
import { useUpdateRunner } from '../hooks/useUpdateRunner.js';
44
import StatusMessage from './StatusMessage.jsx';
55
import StreamLog from './StreamLog.jsx';
@@ -57,10 +57,61 @@ const ExternalIcon = () => (
5757
</svg>
5858
);
5959

60+
// Renders a resolved changelog payload (GitHub release notes, a link-out, or
61+
// nothing). Release bodies render as plain text (React escapes — no XSS).
62+
function ChangelogContent({ data }) {
63+
if (data.type === 'github') {
64+
if (!data.releases.length) {
65+
return (
66+
<p className="changelog-empty">
67+
No newer release notes found.{' '}
68+
<a href={data.releasesUrl} target="_blank" rel="noopener noreferrer">
69+
View releases
70+
</a>
71+
</p>
72+
);
73+
}
74+
return (
75+
<div className="changelog-releases">
76+
{data.releases.map((r) => (
77+
<div className="changelog-release" key={`${r.tag}-${r.url}`}>
78+
<div className="changelog-release-head">
79+
<a href={r.url} target="_blank" rel="noopener noreferrer">
80+
{r.name || r.tag}
81+
</a>
82+
{r.publishedAt && (
83+
<span className="changelog-date">
84+
{new Date(r.publishedAt).toLocaleDateString()}
85+
</span>
86+
)}
87+
</div>
88+
{r.body && <pre className="changelog-body">{r.body}</pre>}
89+
</div>
90+
))}
91+
<a className="card-link" href={data.releasesUrl} target="_blank" rel="noopener noreferrer">
92+
All releases
93+
<ExternalIcon />
94+
</a>
95+
</div>
96+
);
97+
}
98+
if (data.type === 'link') {
99+
return (
100+
<p className="changelog-empty">
101+
{data.note ? `${data.note} ` : ''}
102+
<a href={data.url} target="_blank" rel="noopener noreferrer">
103+
{data.label || 'Open'}
104+
</a>
105+
</p>
106+
);
107+
}
108+
return <p className="changelog-empty">No changelog source available for this image.</p>;
109+
}
110+
60111
/**
61-
* A single container's card: identity, version, source/changelog link, pin +
62-
* hide controls, update button, and an expandable live log for the in-flight
63-
* (or most recent) update.
112+
* A single container's card: identity, version, source/changelog link, pin
113+
* control, update button, an expandable "What's changed" panel, and an
114+
* expandable live log for the in-flight (or most recent) update.
64115
*
65116
* props:
66117
* - container: item shape from GET /api/containers
@@ -75,6 +126,11 @@ export default function UpdateCard({ container, onSettled, onPinChange, register
75126
const [pinBusy, setPinBusy] = useState(false);
76127
const [actionError, setActionError] = useState('');
77128

129+
const [clOpen, setClOpen] = useState(false);
130+
const [clLoading, setClLoading] = useState(false);
131+
const [clData, setClData] = useState(null);
132+
const [clError, setClError] = useState('');
133+
78134
const { run, busy, startError, status, lines } = useUpdateRunner(name, onSettled);
79135

80136
useEffect(() => {
@@ -106,6 +162,23 @@ export default function UpdateCard({ container, onSettled, onPinChange, register
106162
}
107163
}, [pinned, image, onPinChange]);
108164

165+
const toggleChangelog = useCallback(async () => {
166+
const next = !clOpen;
167+
setClOpen(next);
168+
if (next && !clData && !clLoading) {
169+
setClLoading(true);
170+
setClError('');
171+
try {
172+
const d = await getChangelog(name);
173+
setClData(d);
174+
} catch (err) {
175+
setClError(err.message || 'Failed to load changelog');
176+
} finally {
177+
setClLoading(false);
178+
}
179+
}
180+
}, [clOpen, clData, clLoading, name]);
181+
109182
const showUpdateAvailable = updateAvailable && !pinned;
110183
const link = sourceLink(sourceUrl);
111184

@@ -170,6 +243,11 @@ export default function UpdateCard({ container, onSettled, onPinChange, register
170243
<ExternalIcon />
171244
</a>
172245
)}
246+
{showUpdateAvailable && (
247+
<button type="button" className="btn-ghost" onClick={toggleChangelog} aria-expanded={clOpen}>
248+
{clOpen ? 'Hide changes' : "What's changed"}
249+
</button>
250+
)}
173251
</div>
174252
<button
175253
type="button"
@@ -182,6 +260,18 @@ export default function UpdateCard({ container, onSettled, onPinChange, register
182260
</button>
183261
</div>
184262

263+
{clOpen && (
264+
<div className="changelog-panel">
265+
{clLoading && (
266+
<div className="changelog-loading">
267+
<span className="spinner" aria-hidden="true" /> Loading release notes…
268+
</div>
269+
)}
270+
{!clLoading && clError && <StatusMessage type="error" message={clError} />}
271+
{!clLoading && !clError && clData && <ChangelogContent data={clData} />}
272+
</div>
273+
)}
274+
185275
<StreamLog lines={lines} />
186276
</div>
187277
);

client/src/styles/app.css

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1213,3 +1213,61 @@ a {
12131213
font-size: 0.82rem;
12141214
color: var(--color-text-muted);
12151215
}
1216+
1217+
/* ---------- Changelog panel (Phase 4) ---------- */
1218+
1219+
.changelog-panel {
1220+
margin-top: 10px;
1221+
padding: 10px 12px;
1222+
background: var(--color-bg);
1223+
border: 1px solid var(--color-border);
1224+
border-radius: var(--radius-md);
1225+
}
1226+
1227+
.changelog-loading {
1228+
display: flex;
1229+
align-items: center;
1230+
gap: 8px;
1231+
color: var(--color-text-muted);
1232+
font-size: 0.85rem;
1233+
}
1234+
1235+
.changelog-empty {
1236+
margin: 0;
1237+
font-size: 0.85rem;
1238+
color: var(--color-text-muted);
1239+
}
1240+
1241+
.changelog-releases {
1242+
display: flex;
1243+
flex-direction: column;
1244+
gap: 12px;
1245+
}
1246+
1247+
.changelog-release-head {
1248+
display: flex;
1249+
align-items: baseline;
1250+
justify-content: space-between;
1251+
gap: 8px;
1252+
font-weight: 700;
1253+
font-size: 0.9rem;
1254+
}
1255+
1256+
.changelog-date {
1257+
flex-shrink: 0;
1258+
font-weight: 500;
1259+
font-size: 0.75rem;
1260+
color: var(--color-text-faint);
1261+
}
1262+
1263+
.changelog-body {
1264+
margin: 6px 0 0;
1265+
white-space: pre-wrap;
1266+
word-break: break-word;
1267+
font-family: inherit;
1268+
font-size: 0.82rem;
1269+
line-height: 1.45;
1270+
color: var(--color-text-muted);
1271+
max-height: 240px;
1272+
overflow-y: auto;
1273+
}

server/src/changelog.js

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* Best-effort changelog resolver. Given an image's source label + current
3+
* version, fetch GitHub release notes newer than what's running, or fall back
4+
* to a "where to look" link (the source URL, Docker Hub tags, GHCR repo).
5+
*
6+
* The parsing/selection helpers are pure (unit-tested); only fetchGitHubReleases
7+
* touches the network.
8+
*/
9+
10+
import { parseRef } from './reconcile.js';
11+
12+
/**
13+
* Extract {owner, repo} from a GitHub URL, or null.
14+
* @param {string|null} sourceUrl
15+
* @returns {{owner: string, repo: string}|null}
16+
*/
17+
export function parseGitHubRepo(sourceUrl) {
18+
if (typeof sourceUrl !== 'string') return null;
19+
const m = sourceUrl.match(/github\.com[/:]([^/]+)\/([^/#?]+)/i);
20+
if (!m) return null;
21+
const owner = m[1];
22+
const repo = m[2].replace(/\.git$/i, '');
23+
if (!owner || !repo) return null;
24+
return { owner, repo };
25+
}
26+
27+
function normalizeVer(v) {
28+
return String(v || '').trim().replace(/^v/i, '');
29+
}
30+
31+
function truncate(s, n) {
32+
if (typeof s !== 'string') return '';
33+
return s.length > n ? `${s.slice(0, n)}…` : s;
34+
}
35+
36+
/**
37+
* From a newest-first list of releases, pick those newer than currentVersion.
38+
* Heuristic: walk from newest until we hit the release matching the running
39+
* version; if we never match, show the most recent few. Pure + testable.
40+
*
41+
* @param {Array<{tag_name?: string, name?: string}>} releases
42+
* @param {string|null} currentVersion
43+
* @returns {Array<object>}
44+
*/
45+
export function selectNewerReleases(releases, currentVersion) {
46+
if (!Array.isArray(releases)) return [];
47+
if (!currentVersion) return releases.slice(0, 5);
48+
const cur = normalizeVer(currentVersion);
49+
const out = [];
50+
for (const r of releases) {
51+
const tag = normalizeVer(r.tag_name || r.name || '');
52+
if (tag && tag === cur) break; // reached the running version
53+
out.push(r);
54+
if (out.length >= 10) break;
55+
}
56+
return out;
57+
}
58+
59+
/**
60+
* Best-effort "where to look" link for an image with no GitHub source label.
61+
* @param {string} image
62+
* @returns {{url: string, label: string}|null}
63+
*/
64+
export function buildRegistryLink(image) {
65+
let parsed;
66+
try {
67+
parsed = parseRef(image);
68+
} catch {
69+
return null;
70+
}
71+
const { registry, repository } = parsed;
72+
if (registry === 'docker.io') {
73+
if (repository.startsWith('library/')) {
74+
return { url: `https://hub.docker.com/_/${repository.slice('library/'.length)}`, label: 'Docker Hub' };
75+
}
76+
return { url: `https://hub.docker.com/r/${repository}/tags`, label: 'Docker Hub' };
77+
}
78+
if (registry === 'ghcr.io') {
79+
return { url: `https://github.com/${repository}`, label: 'GitHub' };
80+
}
81+
return null;
82+
}
83+
84+
async function fetchGitHubReleases(owner, repo, timeoutMs = 10000) {
85+
const url = `https://api.github.com/repos/${owner}/${repo}/releases?per_page=30`;
86+
const res = await fetch(url, {
87+
headers: {
88+
Accept: 'application/vnd.github+json',
89+
'User-Agent': 'diun-updater',
90+
},
91+
signal: AbortSignal.timeout(timeoutMs),
92+
});
93+
if (!res.ok) throw new Error(`GitHub API ${res.status}`);
94+
return res.json();
95+
}
96+
97+
/**
98+
* Resolve a changelog payload for a container's image.
99+
*
100+
* @param {{ image: string, sourceUrl: string|null, currentVersion: string|null }} meta
101+
* @returns {Promise<object>}
102+
*/
103+
export async function getChangelog({ image, sourceUrl, currentVersion }) {
104+
const gh = parseGitHubRepo(sourceUrl);
105+
if (gh) {
106+
const releasesUrl = `https://github.com/${gh.owner}/${gh.repo}/releases`;
107+
try {
108+
const releases = await fetchGitHubReleases(gh.owner, gh.repo);
109+
const selected = selectNewerReleases(releases, currentVersion);
110+
return {
111+
type: 'github',
112+
repoUrl: `https://github.com/${gh.owner}/${gh.repo}`,
113+
releasesUrl,
114+
currentVersion: currentVersion || null,
115+
releases: selected.map((r) => ({
116+
tag: r.tag_name || r.name || '',
117+
name: r.name || r.tag_name || '',
118+
url: r.html_url,
119+
publishedAt: r.published_at,
120+
body: truncate(r.body || '', 1500),
121+
})),
122+
};
123+
} catch (err) {
124+
return {
125+
type: 'link',
126+
url: releasesUrl,
127+
label: 'Releases',
128+
note: `Couldn't fetch release notes (${err.message}).`,
129+
};
130+
}
131+
}
132+
if (sourceUrl) return { type: 'link', url: sourceUrl, label: 'Source' };
133+
const reg = buildRegistryLink(image);
134+
if (reg) return { type: 'link', url: reg.url, label: reg.label };
135+
return { type: 'none' };
136+
}
137+
138+
export default { parseGitHubRepo, selectNewerReleases, buildRegistryLink, getChangelog };

server/src/docker.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -647,4 +647,19 @@ export async function updateContainer(name, onLine) {
647647
};
648648
}
649649

650+
/**
651+
* Lightweight per-container image metadata for the changelog endpoint:
652+
* the configured image ref plus its OCI version + source labels.
653+
*
654+
* @param {string} name
655+
* @returns {Promise<{ image: string|null, currentVersion: string|null, sourceUrl: string|null }>}
656+
*/
657+
export async function getContainerImageMeta(name) {
658+
const inspectData = await docker.getContainer(name).inspect();
659+
const image = inspectData.Config?.Image || null;
660+
if (!image) return { image: null, currentVersion: null, sourceUrl: null };
661+
const { version, source } = await inspectImageMeta(inspectData.Image, image);
662+
return { image, currentVersion: version, sourceUrl: source };
663+
}
664+
650665
export { docker };

0 commit comments

Comments
 (0)