diff --git a/client/src/components/UpdateCard.jsx b/client/src/components/UpdateCard.jsx index 7874180..8a5df48 100644 --- a/client/src/components/UpdateCard.jsx +++ b/client/src/components/UpdateCard.jsx @@ -260,9 +260,9 @@ export default function UpdateCard({ container, onSettled, onPinChange, register )} - {showUpdateAvailable && ( + {link && ( )} diff --git a/server/src/checker.js b/server/src/checker.js index b90a148..845677c 100644 --- a/server/src/checker.js +++ b/server/src/checker.js @@ -38,6 +38,21 @@ async function resolveAvailableVersion(c) { return labelVersion || null; } +/** + * The running image's latest release tag, when its own version label is junk + * but it declares a GitHub source. Cached. Used for up-to-date images, where + * "running" == the latest release. + * + * @param {{ sourceUrl?: string|null }} c + * @returns {Promise} + */ +async function releaseTagForSource(c) { + const gh = parseGitHubRepo(c.sourceUrl); + if (!gh) return null; + const tag = await getLatestReleaseTag(gh.owner, gh.repo); + return isMeaningfulVersion(tag) ? tag : null; +} + /** * @returns {Promise<{ total: number, checked: number, updatesFound: number, errors: number }>} * @throws if the Docker daemon can't be reached (caller maps to 503). @@ -70,6 +85,13 @@ export async function runCheck() { if (c.currentDigest && digestsEqual(remote, c.currentDigest)) { // Up to date — clear any stale unresolved event. db.resolveEventsForRef(c.normalizedRef); + // The running image IS the latest. If its own version label is junk + // (e.g. homarr's `main`), remember the source repo's latest release + // tag for this digest so the dashboard can show a real number. + if (!isMeaningfulVersion(c.currentVersion)) { + const tag = await releaseTagForSource(c); + if (tag) db.setImageVersion(c.currentDigest, tag); + } continue; } @@ -85,6 +107,7 @@ export async function runCheck() { const better = await resolveAvailableVersion(c); if (isMeaningfulVersion(better)) { db.updateEventAvailableVersion(c.normalizedRef, remote, better); + db.setImageVersion(remote, better); } } continue; @@ -101,6 +124,11 @@ export async function runCheck() { available_version: availableVersion, raw_json: JSON.stringify({ source: 'check' }), }); + // Remember versions per digest: the available one keyed by the remote + // digest (so it shows instantly once the user updates), and the running + // one if its own label is usable. + if (isMeaningfulVersion(availableVersion)) db.setImageVersion(remote, availableVersion); + if (isMeaningfulVersion(c.currentVersion)) db.setImageVersion(c.currentDigest, c.currentVersion); updatesFound += 1; } catch (err) { errors += 1; diff --git a/server/src/containers-service.js b/server/src/containers-service.js index 4a06126..a2b1b44 100644 --- a/server/src/containers-service.js +++ b/server/src/containers-service.js @@ -10,6 +10,7 @@ */ import { isUpdateAvailable, digestsEqual } from './reconcile.js'; +import { isMeaningfulVersion } from './version.js'; /** * @param {object} params @@ -22,12 +23,15 @@ import { isUpdateAvailable, digestsEqual } from './reconcile.js'; * - returns the latest unresolved event row for a normalized ref, or * undefined if there is none. * @param {(normalizedRef: string) => boolean} params.isPinned + * @param {(digest: string|null) => (string|null)} [params.lookupVersion] + * - returns a remembered human version for an image digest, or null. Lets the + * dashboard show a real version even when the image's own labels are junk. * @returns {{ * items: Array, * refsToResolve: string[] * }} */ -export function buildContainerItems({ containers, lookupEvent, isPinned }) { +export function buildContainerItems({ containers, lookupEvent, isPinned, lookupVersion = () => null }) { const items = []; const refsToResolve = []; @@ -52,13 +56,22 @@ export function buildContainerItems({ containers, lookupEvent, isPinned }) { availableVersion = updateAvailable ? (event?.available_version ?? null) : null; } + // Prefer the image's own meaningful version label; otherwise fall back to a + // version we remembered for this digest from a prior check. + const currentVersion = isMeaningfulVersion(c.currentVersion) + ? c.currentVersion + : lookupVersion(c.currentDigest) ?? c.currentVersion ?? null; + if (updateAvailable && !isMeaningfulVersion(availableVersion)) { + availableVersion = lookupVersion(availableDigest) ?? availableVersion ?? null; + } + items.push({ name: c.name, project: c.project, service: c.service, image: c.image, tag: c.tag ?? null, - currentVersion: c.currentVersion ?? null, + currentVersion, sourceUrl: c.sourceUrl ?? null, currentDigest: c.currentDigest, updateAvailable, diff --git a/server/src/db.js b/server/src/db.js index dd271c3..7d91445 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -40,6 +40,11 @@ CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT ); +CREATE TABLE IF NOT EXISTS image_versions ( + digest TEXT PRIMARY KEY, + version TEXT NOT NULL, + updated_at TEXT DEFAULT (datetime('now')) +); CREATE INDEX IF NOT EXISTS idx_events_ref ON update_events(normalized_ref, resolved); CREATE INDEX IF NOT EXISTS idx_history_created ON update_history(created_at DESC); `); @@ -75,6 +80,14 @@ const stmts = { UPDATE update_events SET available_version = ? WHERE normalized_ref = ? AND digest = ? AND resolved = 0 `), + setImageVersion: db.prepare(` + INSERT INTO image_versions (digest, version, updated_at) + VALUES (?, ?, datetime('now')) + ON CONFLICT(digest) DO UPDATE SET version = excluded.version, updated_at = excluded.updated_at + `), + getImageVersion: db.prepare(` + SELECT version FROM image_versions WHERE digest = ? LIMIT 1 + `), recordUpdate: db.prepare(` INSERT INTO update_history (container_name, image, old_digest, new_digest, status, message) VALUES (@container_name, @image, @old_digest, @new_digest, @status, @message) @@ -147,6 +160,22 @@ export function updateEventAvailableVersion(normalized_ref, digest, available_ve return stmts.updateEventAvailableVersion.run(available_version ?? null, normalized_ref, digest); } +/** + * Remember a human-readable version for a specific image digest, so the + * dashboard can show a real version number even for images whose own labels + * are junk (e.g. `:latest` + `org.opencontainers.image.version=main`). + */ +export function setImageVersion(digest, version) { + if (!digest || !version) return undefined; + return stmts.setImageVersion.run(digest, version); +} + +export function getImageVersion(digest) { + if (!digest) return null; + const row = stmts.getImageVersion.get(digest); + return row ? row.version : null; +} + export function recordUpdate({ container_name, image, old_digest, new_digest, status, message }) { return stmts.recordUpdate.run({ container_name, diff --git a/server/src/routes/api.js b/server/src/routes/api.js index adb4b37..4f92b9e 100644 --- a/server/src/routes/api.js +++ b/server/src/routes/api.js @@ -50,6 +50,7 @@ apiRouter.get('/api/containers', async (req, res) => { containers, lookupEvent: db.latestUnresolvedEventForRef, isPinned: (ref) => db.isPinned(ref), + lookupVersion: (digest) => db.getImageVersion(digest), }); for (const ref of refsToResolve) { diff --git a/server/test/containers-service.test.js b/server/test/containers-service.test.js index 8f40956..d91ffe2 100644 --- a/server/test/containers-service.test.js +++ b/server/test/containers-service.test.js @@ -78,6 +78,43 @@ describe('buildContainerItems', () => { assert.deepEqual(refsToResolve, ['docker.io/library/nginx:latest']); }); + test('junk currentVersion label -> falls back to remembered version for the digest', () => { + const containers = [makeContainer({ currentVersion: 'main', currentDigest: 'sha256:run' })]; + const lookupVersion = (digest) => (digest === 'sha256:run' ? 'v1.68.1' : null); + const { items } = buildContainerItems({ + containers, + lookupEvent: () => undefined, + isPinned: () => false, + lookupVersion, + }); + assert.equal(items[0].currentVersion, 'v1.68.1'); + }); + + test('meaningful currentVersion label is kept over the remembered version', () => { + const containers = [makeContainer({ currentVersion: '1.27.3', currentDigest: 'sha256:run' })]; + const lookupVersion = () => 'should-not-be-used'; + const { items } = buildContainerItems({ + containers, + lookupEvent: () => undefined, + isPinned: () => false, + lookupVersion, + }); + assert.equal(items[0].currentVersion, '1.27.3'); + }); + + test('junk available_version on the event -> falls back to remembered version for availableDigest', () => { + const containers = [makeContainer({ currentDigest: 'sha256:aaa' })]; + const lookupEvent = () => ({ digest: 'sha256:bbb', available_version: 'main' }); + const lookupVersion = (digest) => (digest === 'sha256:bbb' ? 'v2.0.0' : null); + const { items } = buildContainerItems({ + containers, + lookupEvent, + isPinned: () => false, + lookupVersion, + }); + assert.equal(items[0].availableVersion, 'v2.0.0'); + }); + test('pinned ref -> pinned true in the item', () => { const containers = [makeContainer()]; const lookupEvent = () => undefined;