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:
@@ -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, '"');
|
||||
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 });
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 container’s 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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (12–64 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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user