Add container image update indicators.
Release rolling / release (push) Has been cancelled

Compare local RepoDigests to remote registry manifest digests, show status in the containers table Updates column, and support force recheck with vault credentials for private registries.
This commit is contained in:
Raven Scott
2026-07-15 15:05:04 -04:00
parent e51de4ad1d
commit a6925d337e
12 changed files with 899 additions and 6 deletions
+178 -5
View File
@@ -2946,6 +2946,16 @@ Object.defineProperty(window, 'allImages', {
},
configurable: true,
});
/**
* Image update status by image ref and container id (Portainer-style digest check).
* @type {Map<string, { status: string, localDigest?: string|null, remoteDigest?: string|null, error?: string|null, checkedAt?: number }>}
*/
const imageUpdateByImage = new Map();
/** @type {Map<string, { status: string, image?: string, localDigest?: string|null, remoteDigest?: string|null, error?: string|null }>} */
const imageUpdateByContainer = new Map();
let imageUpdateCheckInFlight = false;
let imageUpdateCheckTimer = null;
let currentImageFilter = 'all'; // Current filter: 'all', 'used', 'unused'
function loadImages() {
@@ -8569,6 +8579,9 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('registry-refresh-btn')?.addEventListener('click', () => {
if (typeof window.refreshRegistryPanel === 'function') window.refreshRegistryPanel();
});
document.getElementById('check-image-updates-btn')?.addEventListener('click', () => {
scheduleImageUpdateCheck({ force: true, clearCache: true, immediate: true });
});
// Prefer smart network modal for create buttons
document.querySelectorAll('[data-bs-target="#createNetworkModal"]').forEach((btn) => {
@@ -9542,10 +9555,12 @@ function patchContainerRow(row, container) {
const nameLink = row.querySelector('.container-name-link');
if (nameLink && nameLink.textContent !== name) nameLink.textContent = name;
// Image is 3rd cell (index 2)
const imageTd = row.children[2];
// Image cell
const imageTd = row.querySelector('.container-image-cell') || row.children[2];
if (imageTd && imageTd.textContent !== image) imageTd.textContent = image;
applyImageUpdateToRow(row, container);
const badge = row.querySelector('td .badge');
if (badge) {
if (badge.textContent !== state) badge.textContent = state;
@@ -9577,6 +9592,159 @@ function patchContainerRow(row, container) {
seedStatsIntoRow(row, container.Id);
}
/**
* @param {string} status
* @param {{ image?: string, localDigest?: string|null, remoteDigest?: string|null, error?: string|null }} [meta]
*/
function imageUpdateIndicatorHtml(status, meta = {}) {
const st = status || 'unknown';
const titleParts = [];
if (st === 'updated') titleParts.push('Image is up to date with the registry');
else if (st === 'outdated') titleParts.push('Newer image available for this tag at the registry');
else if (st === 'checking') titleParts.push('Checking registry…');
else if (st === 'skipped') titleParts.push('Skipped (local / digests not applicable)');
else titleParts.push('Could not determine update status');
if (meta.image) titleParts.push(`Image: ${meta.image}`);
if (meta.localDigest) titleParts.push(`Local: ${String(meta.localDigest).slice(0, 19)}`);
if (meta.remoteDigest) titleParts.push(`Remote: ${String(meta.remoteDigest).slice(0, 19)}`);
if (meta.error) titleParts.push(meta.error);
const title = titleParts.join('\n').replace(/"/g, '&quot;');
let icon = 'fa-minus';
let cls = 'is-unknown';
if (st === 'updated') {
icon = 'fa-check';
cls = 'is-updated';
} else if (st === 'outdated') {
icon = 'fa-arrow-up';
cls = 'is-outdated';
} else if (st === 'checking') {
icon = 'fa-circle-notch fa-spin';
cls = 'is-checking';
} else if (st === 'skipped') {
icon = 'fa-minus';
cls = 'is-skipped';
}
return `<button type="button" class="image-update-indicator ${cls}" data-update-status="${st}" title="${title}" aria-label="Image update status: ${st}">
<i class="fas ${icon}"></i>
</button>`;
}
function getImageUpdateMeta(container) {
const byC = imageUpdateByContainer.get(container.Id);
if (byC) return byC;
const img = container.Image || '';
const byI = imageUpdateByImage.get(img);
if (byI) return { ...byI, image: img };
return { status: 'unknown', image: img };
}
function applyImageUpdateToRow(row, container) {
const cell = row.querySelector('.image-update-cell');
if (!cell) return;
const meta = getImageUpdateMeta(container || row._pdContainer || {});
cell.innerHTML = imageUpdateIndicatorHtml(meta.status, meta);
const btn = cell.querySelector('.image-update-indicator');
if (btn && meta.status === 'outdated') {
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const image = meta.image || container?.Image;
if (!image) return;
if (typeof pullImageWithAuth === 'function') {
pullImageWithAuth({ image }).then(() => {
scheduleImageUpdateCheck({ force: true });
}).catch(() => {});
} else {
sendCommand('pullImage', { image });
}
});
}
}
function scheduleImageUpdateCheck(opts = {}) {
if (imageUpdateCheckTimer) clearTimeout(imageUpdateCheckTimer);
const delay = opts.immediate ? 0 : 400;
imageUpdateCheckTimer = setTimeout(() => {
imageUpdateCheckTimer = null;
runImageUpdateCheck(opts);
}, delay);
}
async function runImageUpdateCheck(opts = {}) {
if (!manager.active?.connected) return;
if (currentView && currentView !== 'containers' && !opts.force) return;
if (imageUpdateCheckInFlight && !opts.force) return;
imageUpdateCheckInFlight = true;
const btn = document.getElementById('check-image-updates-btn');
const line = document.getElementById('image-update-status-line');
if (btn) {
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-sync-alt fa-spin me-1"></i>Checking…';
}
if (line) line.textContent = 'Checking image digests against registries…';
// Mark visible rows as checking when no cached status
const listElement = domCache.containerList || containerList;
if (listElement && opts.force) {
listElement.querySelectorAll('tr[data-container-id]').forEach((row) => {
const c = row._pdContainer;
if (!c) return;
const existing = imageUpdateByContainer.get(c.Id) || imageUpdateByImage.get(c.Image);
if (!existing || opts.force) {
imageUpdateByContainer.set(c.Id, { status: 'checking', image: c.Image });
applyImageUpdateToRow(row, c);
}
});
}
try {
const res = await manager.request(Methods.checkImageUpdates, {
force: Boolean(opts.force),
all: true,
clearCache: Boolean(opts.clearCache),
});
imageUpdateByImage.clear();
imageUpdateByContainer.clear();
const byImage = res?.byImage || {};
const byContainer = res?.byContainer || {};
for (const [img, info] of Object.entries(byImage)) {
imageUpdateByImage.set(img, info);
}
for (const [id, info] of Object.entries(byContainer)) {
imageUpdateByContainer.set(id, info);
}
// Paint all visible rows
if (listElement) {
listElement.querySelectorAll('tr[data-container-id]').forEach((row) => {
applyImageUpdateToRow(row, row._pdContainer);
});
}
const statuses = Object.values(byImage);
const outdated = statuses.filter((s) => s.status === 'outdated').length;
const updated = statuses.filter((s) => s.status === 'updated').length;
const unknown = statuses.filter((s) => s.status === 'unknown' || s.status === 'skipped').length;
if (line) {
line.textContent =
outdated > 0
? `${outdated} image(s) have updates · ${updated} up to date · ${unknown} unknown/skipped`
: statuses.length
? `All checked images up to date (${updated}) · ${unknown} unknown/skipped`
: 'No images to check';
}
} catch (err) {
if (line) line.textContent = err?.message || 'Update check failed';
console.warn('[image-updates]', err);
} finally {
imageUpdateCheckInFlight = false;
if (btn) {
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-sync-alt me-1"></i>Check updates';
}
}
}
window.scheduleImageUpdateCheck = scheduleImageUpdateCheck;
window.runImageUpdateCheck = runImageUpdateCheck;
function buildContainerRow(container) {
const name = container.Names[0]?.replace(/^\//, '') || 'Unknown';
const image = formatImageName(container.Image || '-');
@@ -9596,6 +9764,7 @@ function buildContainerRow(container) {
const memWidth = prior
? memoryBarPercent(prior.memory, prior.memoryLimit)
: 0;
const updateMeta = getImageUpdateMeta(container);
row.innerHTML = `
<td>
@@ -9607,7 +9776,8 @@ function buildContainerRow(container) {
<a href="#" class="container-name-link d-none" data-container-id="${containerId}">${name}</a>
</div>
</td>
<td>${image}</td>
<td class="container-image-cell">${image}</td>
<td class="image-update-cell">${imageUpdateIndicatorHtml(updateMeta.status, updateMeta)}</td>
<td><span class="badge ${statusClass}">${state}</span></td>
<td class="cpu">
<div class="stats-container">
@@ -9673,6 +9843,7 @@ function buildContainerRow(container) {
openContainerActionsModal(container);
});
}
applyImageUpdateToRow(row, container);
addActionListeners(row, container);
return row;
}
@@ -10113,7 +10284,7 @@ function renderContainers(containers, topicId) {
return;
}
listElement.innerHTML = emptyTableRow(
8,
9,
'No containers yet',
'Deploy a container from the Deploy view.'
);
@@ -10139,7 +10310,7 @@ function renderContainers(containers, topicId) {
if (listElement.dataset.structFp === emptyFp) return;
listElement.dataset.structFp = emptyFp;
listElement.innerHTML = emptyTableRow(
8,
9,
'No matching containers',
'Clear filters or search to see more.'
);
@@ -10177,6 +10348,8 @@ function renderContainers(containers, topicId) {
// In-place patch only — no tbody clear, no replaceChildren
reconcileContainerRows(listElement, filteredContainers);
applyRoleUI();
// Digest check (cached server-side; cheap when warm)
scheduleImageUpdateCheck({ force: false });
}
+8
View File
@@ -45,6 +45,14 @@ export const api = {
return connOrActive(connection).request(Methods.inspectContainer, { id })
},
checkImageUpdates(opts = {}, connection) {
if (opts && typeof opts.request === 'function') {
connection = opts
opts = {}
}
return connOrActive(connection).request(Methods.checkImageUpdates, opts)
},
startContainer(id, connection) {
return connOrActive(connection).request(Methods.startContainer, { id })
},
+1
View File
@@ -49,6 +49,7 @@ const METHOD_TIMEOUT_MS = {
deployContainer: Math.max(OP_TIMEOUT_MS, 300000),
pullImage: 600000,
pushImage: 600000,
checkImageUpdates: 120000,
buildImage: 600000,
deployStack: 600000,
removeStack: OP_TIMEOUT_MS,
+2
View File
@@ -42,6 +42,8 @@ What PearDock can do today, mapped to code and protocol surfaces.
**UI:** Containers view + detail pane + **Add container** page (header button).
**RPC:** list/inspect/start/stop/restart/kill/pause/unpause/remove/recreate/rename/update/deploy/bulk/top/stats/logs/exec/attach/commit/export/archive/duplicate/prune.
**Image update indicators** (Portainer-style): **Updates** column compares each containers local image `RepoDigest` to the remote registry manifest digest for the same tag. Green check = up to date, orange up-arrow = update available (click to pull), grey dash = unknown/skipped. **Check updates** forces a recheck. Server caches digests (~5m). Uses vault credentials for private registries. RPC: `checkImageUpdates`.
**Add container** (`#add-container-view`, `libs/addContainer.js`) is a blank create form separate from **Deploy** templates: name, image, **Always pull the image**, ports (manual + publish-all-exposed), auto-remove, and advanced sections (command, volumes, network, env, labels, restart, runtime/resources). Submit uses `deployContainer` via the job tray.
**Duplicate / Edit** (`#duplicateModal`, `libs/templateDeploy.js`) reuses the same deploy path with a full config editor (name, image, **Always pull the image**, ports, volumes, env, resources, security, health, etc.). When always-pull is off, the server uses a local image if present.
+12 -1
View File
@@ -1043,8 +1043,16 @@
</div>
</div>
</div>
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
<p class="small text-muted mb-0" id="image-update-status-line">
Image updates: indicators compare local digests to the registry (like Portainer).
</p>
<button type="button" class="btn btn-sm btn-outline-info" id="check-image-updates-btn" title="Recheck registry digests for all containers">
<i class="fas fa-sync-alt me-1"></i>Check updates
</button>
</div>
<div class="table-responsive">
<table class="table table-dark table-striped">
<table class="table table-dark table-striped" id="containers-table">
<thead>
<tr>
<th style="width: 40px;">
@@ -1052,6 +1060,9 @@
</th>
<th>Name</th>
<th>Image</th>
<th class="text-center" style="width: 4.5rem;" title="Local image digest vs registry (tag)">
Updates
</th>
<th>Status</th>
<th>CPU (%)</th>
<th>Memory (MB)</th>
+28
View File
@@ -9,6 +9,11 @@ import { peers } from '../core/peer-registry.js'
import { getHistory } from '../services/stats-history.js'
import { destroyStatsForContainer } from '../services/stats.js'
import { validateCreateOptions } from '../utils/engine-capabilities.js'
import {
checkContainerImageUpdates,
checkImageUpdates,
clearUpdateCache,
} from '../services/image-updates.js'
import logger from '../utils/logger.js'
/**
@@ -174,6 +179,29 @@ export function registerContainerHandlers(session) {
return { type: 'containerConfig', data: config }
})
/**
* Portainer-style image update check:
* compare local RepoDigest(s) to remote registry manifest digest for the same tag.
*/
session.respond('checkImageUpdates', async (args = {}) => {
if (args.clearCache) clearUpdateCache()
if (Array.isArray(args.images) && args.images.length) {
const byImage = await checkImageUpdates(args.images, { force: Boolean(args.force) })
return {
success: true,
type: 'imageUpdates',
byImage,
byContainer: {},
checkedAt: Date.now(),
}
}
const result = await checkContainerImageUpdates({
force: Boolean(args.force),
all: args.all !== false,
})
return { success: true, type: 'imageUpdates', ...result }
})
session.respond('startContainer', async (args) => {
// Raw unix-socket POST with Content-Length:0 (dockerode/bare-http can send a body)
const id = args.id || args.containerId || args.name
+564
View File
@@ -0,0 +1,564 @@
/**
* Image update detection (Portainer-style).
*
* Compares the first local RepoDigest for an image:tag with the remote
* registry manifest digest. Differing digests → update available.
*
* Remote digests are fetched via Registry HTTP API V2 (no full pull).
* Results are cached to avoid hammering registries on every list refresh.
*/
import https from 'https'
import http from 'http'
import { docker } from './docker.js'
import * as vault from '../core/registry-vault.js'
import logger from '../utils/logger.js'
/** @typedef {'updated'|'outdated'|'unknown'|'skipped'} UpdateStatus */
const CACHE_TTL_MS = Math.max(
30_000,
Number(process.env.PEARDOCK_UPDATE_CACHE_MS) || 5 * 60_000
)
const CHECK_TIMEOUT_MS = Math.min(
30_000,
Number(process.env.PEARDOCK_UPDATE_TIMEOUT_MS) || 12_000
)
const MAX_CONCURRENT = Math.min(8, Number(process.env.PEARDOCK_UPDATE_CONCURRENCY) || 4)
/** @type {Map<string, { status: UpdateStatus, localDigest?: string|null, remoteDigest?: string|null, error?: string|null, checkedAt: number, image: string }>} */
const cache = new Map()
const MANIFEST_ACCEPT = [
'application/vnd.oci.image.index.v1+json',
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.docker.distribution.manifest.v2+json',
'application/vnd.docker.distribution.manifest.v1+json',
].join(', ')
/**
* Parse a Docker image reference into registry / repository / tag.
* @param {string} raw
* @returns {{
* raw: string,
* registry: string,
* host: string,
* repository: string,
* tag: string,
* digest: string|null,
* isOfficial: boolean,
* isLocal: boolean,
* isPinned: boolean,
* }|null}
*/
export function parseImageRef(raw) {
if (!raw || typeof raw !== 'string') return null
let s = raw.trim()
if (!s || s === '<none>' || s.startsWith('sha256:')) return null
// Drop algorithm-only short ids (1264 hex)
if (/^[a-f0-9]{12,64}$/i.test(s)) return null
let digest = null
if (s.includes('@')) {
const at = s.lastIndexOf('@')
digest = s.slice(at + 1)
s = s.slice(0, at)
}
let tag = 'latest'
// Tag is after last ":" that is not part of host:port
const lastColon = s.lastIndexOf(':')
const lastSlash = s.lastIndexOf('/')
if (lastColon > lastSlash && lastColon > 0) {
tag = s.slice(lastColon + 1)
s = s.slice(0, lastColon)
}
const parts = s.split('/')
let registry = 'registry-1.docker.io'
let host = 'docker.io'
let repository = s
let isOfficial = false
const first = parts[0]
const firstIsRegistry =
first.includes('.') ||
first.includes(':') ||
first === 'localhost' ||
first === 'registry-1.docker.io'
if (parts.length === 1) {
// nginx → library/nginx on Docker Hub
repository = `library/${parts[0]}`
isOfficial = true
} else if (!firstIsRegistry && parts.length === 2) {
// user/app on Docker Hub
repository = s
} else if (firstIsRegistry) {
host = first.split(':')[0]
registry =
first === 'docker.io' || first === 'index.docker.io'
? 'registry-1.docker.io'
: first
repository = parts.slice(1).join('/')
if (registry === 'registry-1.docker.io' && !repository.includes('/')) {
repository = `library/${repository}`
isOfficial = true
}
} else {
repository = s
}
// Local-only names (no registry path that could resolve)
const isLocal =
host === 'localhost' ||
registry.startsWith('localhost') ||
/^127\./.test(host)
return {
raw,
registry,
host,
repository,
tag: digest ? digest : tag,
digest,
isOfficial,
isLocal,
isPinned: Boolean(digest),
}
}
/**
* @param {string} digest
*/
function normalizeDigest(digest) {
if (!digest) return null
const d = String(digest).trim()
if (!d) return null
return d.startsWith('sha256:') ? d : `sha256:${d}`
}
/**
* @param {string} url
* @param {{ method?: string, headers?: Record<string,string>, timeoutMs?: number }} opts
* @returns {Promise<{ status: number, headers: Record<string,string>, body: string }>}
*/
function httpRequest(url, opts = {}) {
const timeoutMs = opts.timeoutMs ?? CHECK_TIMEOUT_MS
return new Promise((resolve, reject) => {
let u
try {
u = new URL(url)
} catch (err) {
reject(err)
return
}
const lib = u.protocol === 'http:' ? http : https
const req = lib.request(
{
protocol: u.protocol,
hostname: u.hostname,
port: u.port || (u.protocol === 'http:' ? 80 : 443),
path: u.pathname + u.search,
method: opts.method || 'GET',
headers: opts.headers || {},
timeout: timeoutMs,
},
(res) => {
const chunks = []
res.on('data', (c) => chunks.push(c))
res.on('end', () => {
const headers = {}
for (const [k, v] of Object.entries(res.headers)) {
headers[k.toLowerCase()] = Array.isArray(v) ? v.join(', ') : String(v ?? '')
}
resolve({
status: res.statusCode || 0,
headers,
body: Buffer.concat(chunks).toString('utf8'),
})
})
}
)
req.on('timeout', () => {
req.destroy(new Error('Registry request timed out'))
})
req.on('error', reject)
req.end()
})
}
/**
* Parse WWW-Authenticate: Bearer realm="...",service="...",scope="..."
* @param {string} header
*/
function parseWwwAuthenticate(header) {
if (!header || !/bearer/i.test(header)) return null
const params = {}
for (const m of header.matchAll(/(\w+)="([^"]*)"/g)) {
params[m[1].toLowerCase()] = m[2]
}
if (!params.realm) return null
return params
}
/**
* @param {object} parsed
* @param {{ username?: string, password?: string }|null} auth
*/
async function getBearerToken(parsed, auth, wwwAuthHeader) {
const params = parseWwwAuthenticate(wwwAuthHeader) || {}
let realm = params.realm
let service = params.service
let scope = params.scope
// Docker Hub anonymous token fallback
if (!realm && (parsed.host === 'docker.io' || parsed.registry === 'registry-1.docker.io')) {
realm = 'https://auth.docker.io/token'
service = 'registry.docker.io'
scope = `repository:${parsed.repository}:pull`
}
if (!realm) return null
const u = new URL(realm)
if (service) u.searchParams.set('service', service)
if (scope) u.searchParams.set('scope', scope)
else u.searchParams.set('scope', `repository:${parsed.repository}:pull`)
const headers = { Accept: 'application/json' }
if (auth?.username && auth?.password) {
headers.Authorization =
'Basic ' + Buffer.from(`${auth.username}:${auth.password}`).toString('base64')
}
const res = await httpRequest(u.toString(), { headers })
if (res.status < 200 || res.status >= 300) {
throw new Error(`Token request failed (${res.status})`)
}
let json
try {
json = JSON.parse(res.body)
} catch {
throw new Error('Invalid token response')
}
return json.token || json.access_token || null
}
/**
* Fetch remote manifest digest(s) for image:tag.
* Returns the index/list digest plus any platform child digests (multi-arch).
* @param {ReturnType<typeof parseImageRef>} parsed
* @param {{ username?: string, password?: string }|null} auth
* @returns {Promise<{ digest: string, related: string[] }>}
*/
export async function fetchRemoteDigest(parsed, auth = null) {
if (!parsed) throw new Error('Invalid image ref')
if (parsed.isPinned && parsed.digest) {
const d = normalizeDigest(parsed.digest)
return { digest: d, related: [d] }
}
const registryHost = parsed.registry.includes('://')
? parsed.registry
: `https://${parsed.registry}`
// Prefer https; some local registries use http — try https first
const base = registryHost.startsWith('http') ? registryHost : `https://${registryHost}`
const path = `/v2/${parsed.repository}/manifests/${encodeURIComponent(parsed.tag)}`
const url = new URL(path, base.endsWith('/') ? base : base + '/').toString()
const headers = {
Accept: MANIFEST_ACCEPT,
}
if (auth?.username && auth?.password) {
headers.Authorization =
'Basic ' + Buffer.from(`${auth.username}:${auth.password}`).toString('base64')
}
let res = await httpRequest(url, { method: 'GET', headers })
if (res.status === 401 || res.status === 403) {
const token = await getBearerToken(parsed, auth, res.headers['www-authenticate'])
if (token) {
headers.Authorization = `Bearer ${token}`
res = await httpRequest(url, { method: 'GET', headers })
}
}
// Some registries only allow HEAD for digest
if (res.status === 405) {
let head = await httpRequest(url, { method: 'HEAD', headers })
if ((head.status === 401 || head.status === 403) && !headers.Authorization?.startsWith('Bearer')) {
const token = await getBearerToken(parsed, auth, head.headers['www-authenticate'])
if (token) {
headers.Authorization = `Bearer ${token}`
head = await httpRequest(url, { method: 'HEAD', headers })
}
}
if (head.status >= 200 && head.status < 300) {
const d =
head.headers['docker-content-digest'] ||
head.headers['oci-content-digest']
if (d) {
const n = normalizeDigest(d)
return { digest: n, related: [n] }
}
}
}
if (res.status < 200 || res.status >= 300) {
throw new Error(`Registry returned ${res.status} for ${parsed.repository}:${parsed.tag}`)
}
let digest =
normalizeDigest(
res.headers['docker-content-digest'] || res.headers['oci-content-digest']
) || null
/** @type {string[]} */
const related = []
if (digest) related.push(digest)
// Parse manifest list / index for platform digests (local RepoDigests often point at these)
try {
const body = JSON.parse(res.body)
if (Array.isArray(body.manifests)) {
for (const m of body.manifests) {
const cd = normalizeDigest(m.digest)
if (cd && !related.includes(cd)) related.push(cd)
}
}
// Single-platform manifest config digest is image ID territory — skip
} catch {
// not JSON
}
if (!digest) {
const crypto = await import('crypto')
const hash = crypto.createHash('sha256').update(res.body).digest('hex')
digest = `sha256:${hash}`
related.unshift(digest)
}
return { digest, related }
}
/**
* Local digests from image inspect RepoDigests.
* @param {string} image
* @returns {Promise<{ id: string|null, digests: string[] }>}
*/
export async function getLocalImageDigests(image) {
try {
const inspect = await docker.getImage(image).inspect()
const digests = []
for (const rd of inspect.RepoDigests || []) {
const d = String(rd).split('@')[1]
const n = normalizeDigest(d)
if (n && !digests.includes(n)) digests.push(n)
}
return { id: inspect.Id || null, digests }
} catch (err) {
// Image may only exist as container ImageID
try {
const inspect = await docker.getImage(image).inspect()
return { id: inspect.Id || null, digests: [] }
} catch {
throw err
}
}
}
/**
* @param {string} image
* @param {{ force?: boolean, auth?: { username: string, password: string }|null }} [opts]
* @returns {Promise<{ image: string, status: UpdateStatus, localDigest: string|null, remoteDigest: string|null, error: string|null, checkedAt: number }>}
*/
export async function checkImageUpdate(image, opts = {}) {
const key = String(image || '').trim()
if (!key) {
return {
image: key,
status: 'unknown',
localDigest: null,
remoteDigest: null,
error: 'empty image',
checkedAt: Date.now(),
}
}
const cached = cache.get(key)
if (!opts.force && cached && Date.now() - cached.checkedAt < CACHE_TTL_MS) {
return { ...cached, image: key }
}
const parsed = parseImageRef(key)
if (!parsed) {
const result = {
image: key,
status: /** @type {UpdateStatus} */ ('skipped'),
localDigest: null,
remoteDigest: null,
error: 'Not a registry image reference',
checkedAt: Date.now(),
}
cache.set(key, result)
return result
}
if (parsed.isPinned) {
const result = {
image: key,
status: /** @type {UpdateStatus} */ ('updated'),
localDigest: normalizeDigest(parsed.digest),
remoteDigest: normalizeDigest(parsed.digest),
error: null,
checkedAt: Date.now(),
}
cache.set(key, result)
return result
}
if (parsed.isLocal) {
const result = {
image: key,
status: /** @type {UpdateStatus} */ ('skipped'),
localDigest: null,
remoteDigest: null,
error: 'Local registry skipped',
checkedAt: Date.now(),
}
cache.set(key, result)
return result
}
let localDigest = null
let localDigests = []
try {
const local = await getLocalImageDigests(key)
localDigests = local.digests
localDigest = localDigests[0] || null
} catch (err) {
logger.debug('local digest inspect failed', { image: key, error: err.message })
}
// Resolve auth: explicit → vault match by host
let auth = opts.auth || null
if (!auth) {
const found = vault.findCredentialForServer(parsed.host) ||
vault.findCredentialForServer(parsed.registry) ||
vault.findCredentialForServer(
parsed.host === 'docker.io' ? 'https://index.docker.io/v1/' : parsed.host
)
if (found) {
auth = { username: found.username, password: found.password }
}
}
try {
const remote = await fetchRemoteDigest(parsed, auth)
const remoteDigest = remote.digest
const remoteSet = new Set(remote.related || [remoteDigest])
let status = /** @type {UpdateStatus} */ ('unknown')
if (!localDigest && localDigests.length === 0) {
// No RepoDigests — image may never have been pulled with digest tracking
status = 'unknown'
} else if (localDigests.some((d) => remoteSet.has(d))) {
status = 'updated'
} else if (localDigests.length && remoteDigest) {
status = 'outdated'
} else {
status = 'unknown'
}
const result = {
image: key,
status,
localDigest,
remoteDigest,
error: null,
checkedAt: Date.now(),
}
cache.set(key, result)
return result
} catch (err) {
const result = {
image: key,
status: /** @type {UpdateStatus} */ ('unknown'),
localDigest,
remoteDigest: null,
error: err.message || 'Registry check failed',
checkedAt: Date.now(),
}
cache.set(key, result)
logger.debug('image update check failed', { image: key, error: err.message })
return result
}
}
/**
* Run checks with limited concurrency.
* @param {string[]} images
* @param {{ force?: boolean, auth?: object|null }} [opts]
*/
export async function checkImageUpdates(images, opts = {}) {
const unique = [...new Set((images || []).map((i) => String(i || '').trim()).filter(Boolean))]
/** @type {Record<string, object>} */
const results = {}
let i = 0
async function worker() {
while (i < unique.length) {
const idx = i++
const img = unique[idx]
results[img] = await checkImageUpdate(img, opts)
}
}
const n = Math.min(MAX_CONCURRENT, Math.max(1, unique.length))
await Promise.all(Array.from({ length: n }, () => worker()))
return results
}
/**
* Collect unique image refs from running/all containers and check.
* @param {{ force?: boolean, all?: boolean }} [opts]
*/
export async function checkContainerImageUpdates(opts = {}) {
const containers = await docker.listContainers({ all: opts.all !== false })
const images = containers.map((c) => c.Image).filter(Boolean)
const byImage = await checkImageUpdates(images, { force: opts.force })
/** @type {Record<string, object>} */
const byContainer = {}
for (const c of containers) {
const img = c.Image
const r = byImage[img]
if (r) {
byContainer[c.Id] = {
containerId: c.Id,
image: img,
status: r.status,
localDigest: r.localDigest,
remoteDigest: r.remoteDigest,
error: r.error,
checkedAt: r.checkedAt,
}
}
}
return {
byImage,
byContainer,
checkedAt: Date.now(),
cacheTtlMs: CACHE_TTL_MS,
}
}
export function clearUpdateCache() {
cache.clear()
}
export function getUpdateCacheSize() {
return cache.size
}
+2
View File
@@ -24,6 +24,7 @@ export const MethodRoles = Object.freeze({
ping: Roles.viewer,
listContainers: Roles.viewer,
inspectContainer: Roles.viewer,
checkImageUpdates: Roles.viewer,
listImages: Roles.viewer,
binaryStreamOpen: Roles.operator,
binaryStreamChunk: Roles.operator,
@@ -209,6 +210,7 @@ export const Methods = Object.freeze({
// Containers
listContainers: 'listContainers',
inspectContainer: 'inspectContainer',
checkImageUpdates: 'checkImageUpdates',
createContainer: 'createContainer',
startContainer: 'startContainer',
stopContainer: 'stopContainer',
+6
View File
@@ -122,6 +122,12 @@ export const MethodSchemas = Object.freeze({
name: { type: 'string', required: false, maxLen: 128 },
filters: { type: 'object', required: false },
},
checkImageUpdates: {
force: { type: 'boolean', required: false },
all: { type: 'boolean', required: false },
clearCache: { type: 'boolean', required: false },
images: { type: 'array', required: false },
},
listImages: {
all: { type: 'boolean', required: false },
limit: { type: 'number', required: false, min: 1, max: 1000 },
+47
View File
@@ -0,0 +1,47 @@
import test from 'brittle'
import { parseImageRef } from '../server/services/image-updates.js'
test('parseImageRef handles Docker Hub short names', (t) => {
const r = parseImageRef('nginx:alpine')
t.ok(r)
t.is(r.registry, 'registry-1.docker.io')
t.is(r.repository, 'library/nginx')
t.is(r.tag, 'alpine')
t.ok(r.isOfficial)
t.absent(r.isPinned)
})
test('parseImageRef handles user/repo on Hub', (t) => {
const r = parseImageRef('library/redis:7')
t.is(r.repository, 'library/redis')
t.is(r.tag, '7')
t.is(r.host, 'docker.io')
})
test('parseImageRef handles ghcr and custom registries', (t) => {
const g = parseImageRef('ghcr.io/org/app:1.2.3')
t.is(g.registry, 'ghcr.io')
t.is(g.repository, 'org/app')
t.is(g.tag, '1.2.3')
const c = parseImageRef('registry.example.com:5000/ns/img:v2')
t.is(c.registry, 'registry.example.com:5000')
t.is(c.repository, 'ns/img')
t.is(c.tag, 'v2')
})
test('parseImageRef digests and bare ids', (t) => {
const pinned = parseImageRef('nginx@sha256:abc123')
t.ok(pinned.isPinned)
t.is(pinned.digest, 'sha256:abc123')
t.is(parseImageRef('sha256:deadbeef'), null)
t.is(parseImageRef('a1b2c3d4e5f6'), null)
t.is(parseImageRef(''), null)
})
test('parseImageRef defaults tag to latest', (t) => {
const r = parseImageRef('postgres')
t.is(r.tag, 'latest')
t.is(r.repository, 'library/postgres')
})
+4
View File
@@ -27,6 +27,8 @@ const REQUIRED_IDS = [
'registry-store-form',
'pushImageModal',
'pull-image-credential',
'check-image-updates-btn',
'containers-table',
'deploy-view',
'tunnels-view',
'swarm-view',
@@ -56,6 +58,8 @@ const REQUIRED_SNIPPETS = [
'Always pull the image',
'Add container',
'Deploy the container',
'Updates',
'Check updates',
]
test('index.html retains critical view landmarks', (t) => {
+47
View File
@@ -3533,6 +3533,53 @@ select.bg-dark {
color: var(--text-muted);
}
/* Container image update indicators (Portainer-style) */
.image-update-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.65rem;
height: 1.65rem;
border-radius: 999px;
border: 1.5px solid transparent;
font-size: 0.75rem;
line-height: 1;
cursor: default;
vertical-align: middle;
}
.image-update-indicator.is-updated {
color: #34d399;
border-color: rgba(52, 211, 153, 0.55);
background: rgba(52, 211, 153, 0.12);
}
.image-update-indicator.is-outdated {
color: #fb923c;
border-color: rgba(251, 146, 60, 0.65);
background: rgba(251, 146, 60, 0.14);
cursor: pointer;
}
.image-update-indicator.is-outdated:hover {
background: rgba(251, 146, 60, 0.28);
}
.image-update-indicator.is-unknown,
.image-update-indicator.is-skipped,
.image-update-indicator.is-checking {
color: var(--text-muted, #94a3b8);
border-color: rgba(148, 163, 184, 0.35);
background: rgba(148, 163, 184, 0.08);
}
.image-update-indicator.is-checking {
animation: image-update-pulse 1.1s ease-in-out infinite;
}
@keyframes image-update-pulse {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
td.image-update-cell {
text-align: center;
vertical-align: middle;
}
/* Tabs — dark content tabs (not sidebar) */
.nav-tabs {
border-bottom-color: var(--border-color) !important;