Skip to content

Commit d40274e

Browse files
Show real versions for all containers + changelog on every card (#17)
Show real versions for all containers + changelog on every card
2 parents a00c11b + 997ca6b commit d40274e

6 files changed

Lines changed: 112 additions & 4 deletions

File tree

client/src/components/UpdateCard.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,9 +260,9 @@ export default function UpdateCard({ container, onSettled, onPinChange, register
260260
<ExternalIcon />
261261
</a>
262262
)}
263-
{showUpdateAvailable && (
263+
{link && (
264264
<button type="button" className="btn-ghost" onClick={toggleChangelog} aria-expanded={clOpen}>
265-
{clOpen ? 'Hide changes' : "What's changed"}
265+
{clOpen ? 'Hide changes' : showUpdateAvailable ? "What's changed" : 'Release notes'}
266266
</button>
267267
)}
268268
</div>

server/src/checker.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,21 @@ async function resolveAvailableVersion(c) {
3838
return labelVersion || null;
3939
}
4040

41+
/**
42+
* The running image's latest release tag, when its own version label is junk
43+
* but it declares a GitHub source. Cached. Used for up-to-date images, where
44+
* "running" == the latest release.
45+
*
46+
* @param {{ sourceUrl?: string|null }} c
47+
* @returns {Promise<string|null>}
48+
*/
49+
async function releaseTagForSource(c) {
50+
const gh = parseGitHubRepo(c.sourceUrl);
51+
if (!gh) return null;
52+
const tag = await getLatestReleaseTag(gh.owner, gh.repo);
53+
return isMeaningfulVersion(tag) ? tag : null;
54+
}
55+
4156
/**
4257
* @returns {Promise<{ total: number, checked: number, updatesFound: number, errors: number }>}
4358
* @throws if the Docker daemon can't be reached (caller maps to 503).
@@ -70,6 +85,13 @@ export async function runCheck() {
7085
if (c.currentDigest && digestsEqual(remote, c.currentDigest)) {
7186
// Up to date — clear any stale unresolved event.
7287
db.resolveEventsForRef(c.normalizedRef);
88+
// The running image IS the latest. If its own version label is junk
89+
// (e.g. homarr's `main`), remember the source repo's latest release
90+
// tag for this digest so the dashboard can show a real number.
91+
if (!isMeaningfulVersion(c.currentVersion)) {
92+
const tag = await releaseTagForSource(c);
93+
if (tag) db.setImageVersion(c.currentDigest, tag);
94+
}
7395
continue;
7496
}
7597

@@ -85,6 +107,7 @@ export async function runCheck() {
85107
const better = await resolveAvailableVersion(c);
86108
if (isMeaningfulVersion(better)) {
87109
db.updateEventAvailableVersion(c.normalizedRef, remote, better);
110+
db.setImageVersion(remote, better);
88111
}
89112
}
90113
continue;
@@ -101,6 +124,11 @@ export async function runCheck() {
101124
available_version: availableVersion,
102125
raw_json: JSON.stringify({ source: 'check' }),
103126
});
127+
// Remember versions per digest: the available one keyed by the remote
128+
// digest (so it shows instantly once the user updates), and the running
129+
// one if its own label is usable.
130+
if (isMeaningfulVersion(availableVersion)) db.setImageVersion(remote, availableVersion);
131+
if (isMeaningfulVersion(c.currentVersion)) db.setImageVersion(c.currentDigest, c.currentVersion);
104132
updatesFound += 1;
105133
} catch (err) {
106134
errors += 1;

server/src/containers-service.js

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
*/
1111

1212
import { isUpdateAvailable, digestsEqual } from './reconcile.js';
13+
import { isMeaningfulVersion } from './version.js';
1314

1415
/**
1516
* @param {object} params
@@ -22,12 +23,15 @@ import { isUpdateAvailable, digestsEqual } from './reconcile.js';
2223
* - returns the latest unresolved event row for a normalized ref, or
2324
* undefined if there is none.
2425
* @param {(normalizedRef: string) => boolean} params.isPinned
26+
* @param {(digest: string|null) => (string|null)} [params.lookupVersion]
27+
* - returns a remembered human version for an image digest, or null. Lets the
28+
* dashboard show a real version even when the image's own labels are junk.
2529
* @returns {{
2630
* items: Array<object>,
2731
* refsToResolve: string[]
2832
* }}
2933
*/
30-
export function buildContainerItems({ containers, lookupEvent, isPinned }) {
34+
export function buildContainerItems({ containers, lookupEvent, isPinned, lookupVersion = () => null }) {
3135
const items = [];
3236
const refsToResolve = [];
3337

@@ -52,13 +56,22 @@ export function buildContainerItems({ containers, lookupEvent, isPinned }) {
5256
availableVersion = updateAvailable ? (event?.available_version ?? null) : null;
5357
}
5458

59+
// Prefer the image's own meaningful version label; otherwise fall back to a
60+
// version we remembered for this digest from a prior check.
61+
const currentVersion = isMeaningfulVersion(c.currentVersion)
62+
? c.currentVersion
63+
: lookupVersion(c.currentDigest) ?? c.currentVersion ?? null;
64+
if (updateAvailable && !isMeaningfulVersion(availableVersion)) {
65+
availableVersion = lookupVersion(availableDigest) ?? availableVersion ?? null;
66+
}
67+
5568
items.push({
5669
name: c.name,
5770
project: c.project,
5871
service: c.service,
5972
image: c.image,
6073
tag: c.tag ?? null,
61-
currentVersion: c.currentVersion ?? null,
74+
currentVersion,
6275
sourceUrl: c.sourceUrl ?? null,
6376
currentDigest: c.currentDigest,
6477
updateAvailable,

server/src/db.js

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ CREATE TABLE IF NOT EXISTS settings (
4040
key TEXT PRIMARY KEY,
4141
value TEXT
4242
);
43+
CREATE TABLE IF NOT EXISTS image_versions (
44+
digest TEXT PRIMARY KEY,
45+
version TEXT NOT NULL,
46+
updated_at TEXT DEFAULT (datetime('now'))
47+
);
4348
CREATE INDEX IF NOT EXISTS idx_events_ref ON update_events(normalized_ref, resolved);
4449
CREATE INDEX IF NOT EXISTS idx_history_created ON update_history(created_at DESC);
4550
`);
@@ -75,6 +80,14 @@ const stmts = {
7580
UPDATE update_events SET available_version = ?
7681
WHERE normalized_ref = ? AND digest = ? AND resolved = 0
7782
`),
83+
setImageVersion: db.prepare(`
84+
INSERT INTO image_versions (digest, version, updated_at)
85+
VALUES (?, ?, datetime('now'))
86+
ON CONFLICT(digest) DO UPDATE SET version = excluded.version, updated_at = excluded.updated_at
87+
`),
88+
getImageVersion: db.prepare(`
89+
SELECT version FROM image_versions WHERE digest = ? LIMIT 1
90+
`),
7891
recordUpdate: db.prepare(`
7992
INSERT INTO update_history (container_name, image, old_digest, new_digest, status, message)
8093
VALUES (@container_name, @image, @old_digest, @new_digest, @status, @message)
@@ -147,6 +160,22 @@ export function updateEventAvailableVersion(normalized_ref, digest, available_ve
147160
return stmts.updateEventAvailableVersion.run(available_version ?? null, normalized_ref, digest);
148161
}
149162

163+
/**
164+
* Remember a human-readable version for a specific image digest, so the
165+
* dashboard can show a real version number even for images whose own labels
166+
* are junk (e.g. `:latest` + `org.opencontainers.image.version=main`).
167+
*/
168+
export function setImageVersion(digest, version) {
169+
if (!digest || !version) return undefined;
170+
return stmts.setImageVersion.run(digest, version);
171+
}
172+
173+
export function getImageVersion(digest) {
174+
if (!digest) return null;
175+
const row = stmts.getImageVersion.get(digest);
176+
return row ? row.version : null;
177+
}
178+
150179
export function recordUpdate({ container_name, image, old_digest, new_digest, status, message }) {
151180
return stmts.recordUpdate.run({
152181
container_name,

server/src/routes/api.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ apiRouter.get('/api/containers', async (req, res) => {
5050
containers,
5151
lookupEvent: db.latestUnresolvedEventForRef,
5252
isPinned: (ref) => db.isPinned(ref),
53+
lookupVersion: (digest) => db.getImageVersion(digest),
5354
});
5455

5556
for (const ref of refsToResolve) {

server/test/containers-service.test.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,43 @@ describe('buildContainerItems', () => {
7878
assert.deepEqual(refsToResolve, ['docker.io/library/nginx:latest']);
7979
});
8080

81+
test('junk currentVersion label -> falls back to remembered version for the digest', () => {
82+
const containers = [makeContainer({ currentVersion: 'main', currentDigest: 'sha256:run' })];
83+
const lookupVersion = (digest) => (digest === 'sha256:run' ? 'v1.68.1' : null);
84+
const { items } = buildContainerItems({
85+
containers,
86+
lookupEvent: () => undefined,
87+
isPinned: () => false,
88+
lookupVersion,
89+
});
90+
assert.equal(items[0].currentVersion, 'v1.68.1');
91+
});
92+
93+
test('meaningful currentVersion label is kept over the remembered version', () => {
94+
const containers = [makeContainer({ currentVersion: '1.27.3', currentDigest: 'sha256:run' })];
95+
const lookupVersion = () => 'should-not-be-used';
96+
const { items } = buildContainerItems({
97+
containers,
98+
lookupEvent: () => undefined,
99+
isPinned: () => false,
100+
lookupVersion,
101+
});
102+
assert.equal(items[0].currentVersion, '1.27.3');
103+
});
104+
105+
test('junk available_version on the event -> falls back to remembered version for availableDigest', () => {
106+
const containers = [makeContainer({ currentDigest: 'sha256:aaa' })];
107+
const lookupEvent = () => ({ digest: 'sha256:bbb', available_version: 'main' });
108+
const lookupVersion = (digest) => (digest === 'sha256:bbb' ? 'v2.0.0' : null);
109+
const { items } = buildContainerItems({
110+
containers,
111+
lookupEvent,
112+
isPinned: () => false,
113+
lookupVersion,
114+
});
115+
assert.equal(items[0].availableVersion, 'v2.0.0');
116+
});
117+
81118
test('pinned ref -> pinned true in the item', () => {
82119
const containers = [makeContainer()];
83120
const lookupEvent = () => undefined;

0 commit comments

Comments
 (0)