Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions client/src/components/UpdateCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -260,9 +260,9 @@ export default function UpdateCard({ container, onSettled, onPinChange, register
<ExternalIcon />
</a>
)}
{showUpdateAvailable && (
{link && (
<button type="button" className="btn-ghost" onClick={toggleChangelog} aria-expanded={clOpen}>
{clOpen ? 'Hide changes' : "What's changed"}
{clOpen ? 'Hide changes' : showUpdateAvailable ? "What's changed" : 'Release notes'}
</button>
)}
</div>
Expand Down
28 changes: 28 additions & 0 deletions server/src/checker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string|null>}
*/
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).
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
Expand All @@ -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;
Expand Down
17 changes: 15 additions & 2 deletions server/src/containers-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
*/

import { isUpdateAvailable, digestsEqual } from './reconcile.js';
import { isMeaningfulVersion } from './version.js';

/**
* @param {object} params
Expand All @@ -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<object>,
* refsToResolve: string[]
* }}
*/
export function buildContainerItems({ containers, lookupEvent, isPinned }) {
export function buildContainerItems({ containers, lookupEvent, isPinned, lookupVersion = () => null }) {
const items = [];
const refsToResolve = [];

Expand All @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions server/src/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
`);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions server/src/routes/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
37 changes: 37 additions & 0 deletions server/test/containers-service.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading