/** * Aggregate Docker Engine pull/push progress events into a hybrid UI snapshot. * * Docker streams per-layer events (id + status + progressDetail). A single * event's current/total is never overall pull progress — this module tracks * layers and derives phase, overall %, bytes, and a fixed-height layer track. * * Extract events often report a tiny progressDetail that is NOT download size. * Download byte totals are preserved separately so the tray summary never * thrash from megabytes → bytes mid-extract. */ /** @typedef {'waiting'|'downloading'|'downloaded'|'extracting'|'done'|'exists'|'error'} LayerPhase */ /** @typedef {'resolving'|'downloading'|'extracting'|'complete'|'error'} PullPhase */ /** @typedef {'done'|'extract'|'download'|'wait'|'error'|'idle'} LayerPip */ /** * @typedef {object} LayerState * @property {string} id * @property {LayerPhase} phase * @property {number} current progress for the active phase (UI only) * @property {number} total * @property {number} downloadCurrent preserved download bytes * @property {number} downloadTotal * @property {number} extractCurrent * @property {number} extractTotal * @property {string|null} status * @property {string|null} error */ /** * @typedef {object} PullLayerView * @property {string} id * @property {string} shortId * @property {LayerPhase} phase * @property {number|null} percent * @property {number} current * @property {number} total * @property {string} meta */ /** * @typedef {object} PullProgressSnapshot * @property {'image-pull'|'image-push'} kind * @property {string} image * @property {PullPhase} phase * @property {string} phaseLabel * @property {number|null} percent * @property {number} layersTotal * @property {number} layersDone * @property {number} layersActive * @property {number} layersWaiting * @property {number} bytesCurrent * @property {number} bytesTotal * @property {string} summary * @property {string} activity Fixed single-line “what’s happening now” (stable tray height) * @property {PullLayerView[]} activeLayers * @property {LayerPip[]} layerPips Fixed-height layer track (capped) * @property {string|null} error * @property {boolean} complete */ const DOWNLOAD_WEIGHT = 0.7 const EXTRACT_WEIGHT = 0.3 /** Active per-layer rows shown in the tray (fixed slot count in jobs.js) */ const MAX_ACTIVE_LAYERS = 4 /** Max pips in the fixed-height track (overflow summarized in activity) */ const MAX_LAYER_PIPS = 16 /** Hold extract phase after last unpack so short sequential extracts don’t thrash */ const EXTRACT_STICKY_MS = 750 /** @type {Map} */ const STATUS_PHASE = new Map([ ['pulling fs layer', 'waiting'], ['waiting', 'waiting'], ['downloading', 'downloading'], ['verifying checksum', 'downloaded'], ['download complete', 'downloaded'], ['extracting', 'extracting'], ['pull complete', 'done'], ['already exists', 'exists'], ['layer already exists', 'exists'], ['mounting', 'extracting'], ]) /** * @param {number} n * @param {number} [digits=1] */ export function formatPullBytes(n, digits = 1) { const v = Number(n) if (!Number.isFinite(v) || v < 0) return '0 B' if (v < 1024) return `${Math.round(v)} B` const units = ['KB', 'MB', 'GB', 'TB'] let x = v / 1024 let i = 0 while (x >= 1024 && i < units.length - 1) { x /= 1024 i++ } const rounded = x >= 100 ? Math.round(x) : x >= 10 ? Math.round(x * 10) / 10 : Math.round(x * 100) / 100 const text = digits === 0 || rounded >= 100 ? String(Math.round(rounded)) : String(rounded) return `${text} ${units[i]}` } /** * Normalize image ref for map keys (trim, lowercase name portion loosely). * @param {string|null|undefined} image */ export function normalizePullImageKey(image) { return String(image || '') .trim() .toLowerCase() } /** * Map a Docker status string to a layer phase. * @param {string|null|undefined} status * @returns {LayerPhase|null} */ export function mapStatusToPhase(status) { if (!status) return null const s = String(status).trim().toLowerCase() if (STATUS_PHASE.has(s)) return STATUS_PHASE.get(s) if (/already exists/i.test(s)) return 'exists' if (/pull complete/i.test(s)) return 'done' if (/download complete|verifying/i.test(s)) return 'downloaded' if (/extract/i.test(s)) return 'extracting' if (/download/i.test(s)) return 'downloading' if (/waiting|pulling fs/i.test(s)) return 'waiting' return null } /** * @param {string|null|undefined} status */ export function isGlobalPullStatus(status) { if (!status) return false const s = String(status) return ( /^pulling from\b/i.test(s) || /^status:\s*/i.test(s) || /^digest:\s*/i.test(s) || /image is up to date/i.test(s) || /downloaded newer image/i.test(s) ) } /** * @param {LayerPhase} phase */ function layerIsTerminal(phase) { return phase === 'done' || phase === 'exists' } /** * @param {LayerState} layer * @returns {number} 0–1 contribution toward overall progress */ function layerScore(layer) { if (layer.phase === 'done' || layer.phase === 'exists') return 1 if (layer.phase === 'error') return 0 if (layer.phase === 'extracting') { const p = layer.extractTotal > 0 ? Math.min(1, layer.extractCurrent / layer.extractTotal) : 0.5 return DOWNLOAD_WEIGHT + EXTRACT_WEIGHT * p } if (layer.phase === 'downloaded') return DOWNLOAD_WEIGHT if (layer.phase === 'downloading') { const tot = layer.downloadTotal || layer.total const cur = layer.downloadCurrent || layer.current const p = tot > 0 ? Math.min(1, cur / tot) : 0.05 return DOWNLOAD_WEIGHT * p } return 0 } /** * @param {LayerState} layer * @returns {number|null} */ function layerPercent(layer) { if (layerIsTerminal(layer.phase)) return 100 if (layer.phase === 'extracting') { if (layer.extractTotal > 0) { return Math.min(100, Math.round((layer.extractCurrent / layer.extractTotal) * 100)) } return null } if (layer.phase === 'downloaded') return 100 const tot = layer.downloadTotal || layer.total const cur = layer.downloadCurrent || layer.current if (tot > 0) return Math.min(100, Math.round((cur / tot) * 100)) if (layer.phase === 'downloading') return null return 0 } /** * @param {LayerState} layer */ function layerMeta(layer) { if (layer.phase === 'exists') return 'Already exists' if (layer.phase === 'done') return 'Complete' if (layer.phase === 'waiting') { // Docker often announces layers as "Pulling fs layer" / "Waiting" before bytes flow if (/pulling/i.test(layer.status || '')) return 'Pulling…' return 'Waiting…' } if (layer.phase === 'error') return layer.error || 'Error' if (layer.phase === 'downloaded') return 'Download complete' if (layer.phase === 'extracting') { if (layer.extractTotal > 0) { return `${Math.min(100, Math.round((layer.extractCurrent / layer.extractTotal) * 100))}%` } return 'Extracting…' } const tot = layer.downloadTotal || layer.total const cur = layer.downloadCurrent || layer.current if (tot > 0) { return `${formatPullBytes(cur)} / ${formatPullBytes(tot)}` } if (layer.phase === 'downloading') return 'Downloading…' return layer.status || '' } /** * Rank for tray layer rows — in-flight first, then queued, never drop the list mid-pull. * @param {LayerPhase} phase */ function activeLayerRank(phase) { switch (phase) { case 'downloading': return 0 case 'extracting': return 1 case 'error': return 2 case 'downloaded': return 3 case 'waiting': return 4 default: return 5 } } /** * Fixed-slot active layer views for the tray. Includes waiting/pulling layers so the * row UI stays populated from the first "Pulling fs layer" event through download. * @param {LayerState[]} list * @returns {PullLayerView[]} */ function buildActiveLayers(list) { const open = list.filter((l) => !layerIsTerminal(l.phase)) open.sort((a, b) => { const ra = activeLayerRank(a.phase) const rb = activeLayerRank(b.phase) if (ra !== rb) return ra - rb // Prefer larger remaining work among downloads if (a.phase === 'downloading' && b.phase === 'downloading') { const ta = a.downloadTotal || a.total || 0 const tb = b.downloadTotal || b.total || 0 return tb - ta } return String(a.id).localeCompare(String(b.id)) }) return open.slice(0, MAX_ACTIVE_LAYERS).map((l) => ({ id: l.id, shortId: shortLayerId(l.id), phase: l.phase, percent: layerPercent(l), current: l.downloadCurrent || l.current || 0, total: l.downloadTotal || l.total || 0, meta: layerMeta(l), })) } /** * @param {LayerState} layer * @param {PullPhase} overallPhase * @returns {LayerPip} */ function layerToPip(layer, overallPhase) { if (layer.phase === 'error') return 'error' if (layerIsTerminal(layer.phase)) return 'done' if (layer.phase === 'extracting') return 'extract' // Downloaded = fully fetched (teal); only flip to extract when overall unpack starts if (layer.phase === 'downloaded') { return overallPhase === 'extracting' ? 'extract' : 'download' } if (layer.phase === 'downloading') return 'download' // During sticky extract, treat remaining work as unpack-bound so pips don’t thrash if (overallPhase === 'extracting') return 'extract' return 'wait' } /** * Aggregate Docker pull/push progress for one image ref. */ export class PullProgressTracker { /** * @param {string} [image] * @param {{ kind?: 'image-pull'|'image-push' }} [opts] */ constructor(image = '', opts = {}) { this.image = String(image || '') /** @type {'image-pull'|'image-push'} */ this.kind = opts.kind === 'image-push' ? 'image-push' : 'image-pull' /** @type {Map} */ this.layers = new Map() /** @type {string|null} */ this.globalStatus = null /** @type {string|null} */ this.error = null this.complete = false /** @type {string|null} last milestone key for consumers */ this._lastMilestone = null /** * Once extract begins, stay on “extracting” briefly so short unpacks * don’t flip download↔extract and thrash the tray. * @type {boolean} */ this._extractSticky = false /** @type {number} timestamp until which extract phase is held */ this._extractStickyUntil = 0 /** @type {number} monotonic overall % so short extract completions don’t jump the bar backward */ this._maxPercent = 0 /** @type {number} peak download bytes (summary never shrinks mid-pull) */ this._peakBytesCurrent = 0 /** @type {number} */ this._peakBytesTotal = 0 /** @type {string|null} last activity line (stable while only the layer id would change) */ this._stableActivity = null /** @type {PullPhase|null} */ this._stableActivityPhase = null /** @type {number} */ this._stableActivityAt = 0 /** @type {number} last layersDone shown in activity (extract updates only on this) */ this._activityLayersDone = -1 /** @type {string|null} last activity “family” (fetch vs unpack vs queue) */ this._activityFamily = null /** @type {string|null} stable summary during extract */ this._stableSummary = null /** @type {number} */ this._summaryLayersDone = -1 } /** * @param {object} event Docker progress event (or peardock pullProgress push) * @returns {{ snapshot: PullProgressSnapshot, milestone: string|null }} */ update(event) { const prevMilestone = this._lastMilestone if (!event || typeof event !== 'object') { return { snapshot: this.snapshot(), milestone: null } } if (event.image && !this.image) { this.image = String(event.image) } if (event.error) { this.error = String(event.error) const id = event.id ? String(event.id) : null if (id) { const layer = this._ensureLayer(id) layer.phase = 'error' layer.error = this.error layer.status = event.status || layer.status } this._lastMilestone = `error:${this.error}` return { snapshot: this.snapshot(), milestone: this._lastMilestone !== prevMilestone ? this._lastMilestone : null, } } const status = event.status != null ? String(event.status) : null const id = event.id != null && String(event.id).trim() ? String(event.id) : null const detail = event.progressDetail || {} const current = Number(detail.current) const total = Number(detail.total) if (isGlobalPullStatus(status) || (!id && status)) { this.globalStatus = status if (/up to date|downloaded newer|status:\s*downloaded/i.test(status || '')) { this.complete = true for (const layer of this.layers.values()) { if (!layerIsTerminal(layer.phase) && layer.phase !== 'error') { layer.phase = 'done' } } this._lastMilestone = `complete:${status}` } else if (/^pulling from\b/i.test(status || '')) { this._lastMilestone = `resolving:${status}` } return { snapshot: this.snapshot(), milestone: this._lastMilestone !== prevMilestone ? this._lastMilestone : null, } } if (!id) { return { snapshot: this.snapshot(), milestone: null } } const layer = this._ensureLayer(id) const prevPhase = layer.phase if (status) layer.status = status const mapped = mapStatusToPhase(status) const enteringExtract = mapped === 'extracting' || (layer.phase === 'extracting' && mapped == null) || (mapped == null && /extract/i.test(status || '')) // Apply phase first so we know whether progressDetail is download or extract if (mapped) { if (!(layerIsTerminal(layer.phase) && mapped !== 'error')) { layer.phase = mapped } } if (layer.phase === 'extracting' || enteringExtract) { // Extract progressDetail is NOT download size — never clobber download totals if (Number.isFinite(total) && total > 0) { layer.extractTotal = total layer.total = total } if (Number.isFinite(current) && current >= 0) { layer.extractCurrent = current layer.current = current } } else if (!layerIsTerminal(layer.phase)) { if (Number.isFinite(total) && total > 0) { // Never shrink a known download total (Docker sometimes re-sends smaller numbers) if (total >= layer.downloadTotal) { layer.downloadTotal = total layer.total = total } } if (Number.isFinite(current) && current >= 0) { layer.downloadCurrent = Math.max(layer.downloadCurrent, current) layer.current = layer.downloadCurrent } if (layer.phase === 'downloaded' && layer.downloadTotal > 0) { layer.downloadCurrent = layer.downloadTotal layer.current = layer.downloadTotal } } if (layerIsTerminal(layer.phase)) { if (layer.downloadTotal > 0) { layer.downloadCurrent = layer.downloadTotal } if (layer.total > 0) layer.current = layer.total else if (layer.downloadTotal > 0) { layer.total = layer.downloadTotal layer.current = layer.downloadTotal } } // Quiet milestones only — per-layer done/exists spam made the tray log grow and feel noisy. if ( layer.phase === 'extracting' && prevPhase !== 'extracting' && !String(this._lastMilestone || '').startsWith('extracting:') ) { this._lastMilestone = `extracting:${id}` this._extractSticky = true this._extractStickyUntil = Date.now() + EXTRACT_STICKY_MS } else if ( layer.phase === 'downloading' && prevPhase === 'waiting' && !String(this._lastMilestone || '').startsWith('download-start:') && !String(this._lastMilestone || '').startsWith('extracting:') ) { this._lastMilestone = `download-start:${id}` } else if (layer.phase === 'extracting') { // Refresh sticky window while unpacks keep arriving this._extractSticky = true this._extractStickyUntil = Date.now() + EXTRACT_STICKY_MS } return { snapshot: this.snapshot(), milestone: this._lastMilestone !== prevMilestone ? this._lastMilestone : null, } } /** * Mark pull finished successfully (RPC returned). * @returns {PullProgressSnapshot} */ markComplete() { this.complete = true this.error = null this._extractSticky = false this._extractStickyUntil = 0 for (const layer of this.layers.values()) { if (!layerIsTerminal(layer.phase) && layer.phase !== 'error') { layer.phase = 'done' if (layer.downloadTotal > 0) { layer.downloadCurrent = layer.downloadTotal layer.total = layer.downloadTotal layer.current = layer.downloadTotal } else if (layer.total > 0) { layer.current = layer.total } } } this._lastMilestone = 'complete:ok' return this.snapshot() } /** * @param {string} message * @returns {PullProgressSnapshot} */ markError(message) { this.error = String(message || 'Pull failed') this.complete = false this._extractSticky = false this._extractStickyUntil = 0 this._lastMilestone = `error:${this.error}` return this.snapshot() } /** * @returns {PullProgressSnapshot} */ snapshot() { const list = [...this.layers.values()] let layersDone = 0 let layersActive = 0 let layersWaiting = 0 let bytesCurrent = 0 let bytesTotal = 0 let score = 0 for (const layer of list) { score += layerScore(layer) if (layerIsTerminal(layer.phase)) layersDone++ else if (layer.phase === 'waiting') layersWaiting++ else if (layer.phase !== 'error') layersActive++ // Byte accounting uses download sizes only (extract totals are unrelated) const dTotal = layer.downloadTotal if (dTotal > 0) { bytesTotal += dTotal bytesCurrent += Math.min( dTotal, layerIsTerminal(layer.phase) || layer.phase === 'downloaded' || layer.phase === 'extracting' ? dTotal : layer.downloadCurrent || 0 ) } } // Monotonic peaks so summary never shrinks when layers appear mid-stream this._peakBytesTotal = Math.max(this._peakBytesTotal, bytesTotal) this._peakBytesCurrent = Math.max(this._peakBytesCurrent, bytesCurrent) bytesTotal = this._peakBytesTotal bytesCurrent = Math.min(this._peakBytesCurrent, bytesTotal || this._peakBytesCurrent) const layersTotal = list.length const anyExtracting = list.some((l) => l.phase === 'extracting') const anyDownloaded = list.some((l) => l.phase === 'downloaded') const anyDownloading = list.some((l) => l.phase === 'downloading') const now = Date.now() if (anyExtracting) { this._extractSticky = true this._extractStickyUntil = Math.max(this._extractStickyUntil, now + EXTRACT_STICKY_MS) } /** @type {PullPhase} */ let phase = 'resolving' if (this.error) phase = 'error' else if (this.complete || (layersTotal > 0 && layersDone === layersTotal)) { phase = 'complete' this._extractSticky = false this._extractStickyUntil = 0 } else if (anyExtracting || (this._extractSticky && now < this._extractStickyUntil)) { // Hold extract across short gaps between layer unpacks phase = 'extracting' } else if (this._extractSticky && !anyDownloading && (anyDownloaded || layersWaiting > 0)) { // Still unpacking / waiting to unpack even if sticky window expired briefly phase = 'extracting' this._extractStickyUntil = now + EXTRACT_STICKY_MS } else { this._extractSticky = false if (anyDownloading || anyDownloaded) { phase = 'downloading' } else if (layersTotal > 0) { phase = 'downloading' } } let percent = null if (phase === 'complete') { percent = 100 this._maxPercent = 100 } else if (layersTotal > 0) { let raw if (phase === 'extracting') { // Walk the last 30% by completed layers — short extracts often lack useful ratios const extractFrac = layersDone / layersTotal const inFlight = anyExtracting || anyDownloaded ? 0.35 / layersTotal : 0 const extractPart = Math.min(1, extractFrac + inFlight) raw = Math.min( 99, Math.round(DOWNLOAD_WEIGHT * 100 + EXTRACT_WEIGHT * 100 * extractPart) ) // Blend with score so partial download+extract still makes sense const scorePct = Math.min(99, Math.round((score / layersTotal) * 100)) raw = Math.max(raw, scorePct) } else { raw = Math.min(99, Math.round((score / layersTotal) * 100)) } percent = Math.max(this._maxPercent, raw) this._maxPercent = percent } else if (phase === 'resolving') { percent = null } // Tray layer rows: downloading/extracting first, then waiting — always filled when layers exist const activeLayers = buildActiveLayers(list) // Fixed-length pip track — always same height in the UI const layerPips = buildLayerPips(list, phase) const summary = this._stableSummaryLine( buildSummary({ phase, layersTotal, layersDone, layersWaiting, layersActive, bytesCurrent, bytesTotal, error: this.error, globalStatus: this.globalStatus, kind: this.kind, }), phase, layersDone ) const activity = this._stableActivityLine( buildActivity({ phase, list, layersTotal, layersDone, layersActive, layersWaiting, bytesCurrent, bytesTotal, error: this.error, globalStatus: this.globalStatus, kind: this.kind, }), phase, layersDone ) return { kind: this.kind, image: this.image, phase, phaseLabel: phaseLabel(phase, this.kind), percent, layersTotal, layersDone, layersActive, layersWaiting, bytesCurrent, bytesTotal, summary, activity, activeLayers, layerPips, error: this.error, complete: phase === 'complete', } } /** * Hold summary steady during extract so byte text doesn’t thrash. * @param {string} next * @param {PullPhase} phase * @param {number} layersDone */ _stableSummaryLine(next, phase, layersDone) { if (phase === 'complete' || phase === 'error' || phase === 'resolving') { this._stableSummary = next this._summaryLayersDone = layersDone return next } if (phase === 'extracting') { if (layersDone !== this._summaryLayersDone || this._stableSummary == null) { this._stableSummary = next this._summaryLayersDone = layersDone } return this._stableSummary } // Download: always allow byte progress through this._stableSummary = next this._summaryLayersDone = layersDone return next } /** * Hold activity text steady so short extract/download layer swaps don’t thrash the tray. * @param {string} next * @param {PullPhase} phase * @param {number} layersDone */ _stableActivityLine(next, phase, layersDone) { const now = Date.now() const phaseChanged = phase !== this._stableActivityPhase this._stableActivityPhase = phase const family = activityFamily(next) // Phase transitions (and terminal states) always take the new copy immediately if (phaseChanged || phase === 'complete' || phase === 'error') { this._stableActivity = next this._stableActivityAt = now this._activityLayersDone = layersDone this._activityFamily = family return next } // During extract: only refresh when layersDone advances (short unpacks share one line) if (phase === 'extracting') { if (layersDone !== this._activityLayersDone || this._stableActivity == null) { this._stableActivity = next this._stableActivityAt = now this._activityLayersDone = layersDone this._activityFamily = family } return this._stableActivity } // Download: refresh byte progress on a calm cadence; semantic changes (fetch→queued→ready) win immediately const minHoldMs = 350 const semanticChange = family !== this._activityFamily if ( this._stableActivity == null || layersDone !== this._activityLayersDone || semanticChange || now - this._stableActivityAt >= minHoldMs ) { this._stableActivity = next this._stableActivityAt = now this._activityLayersDone = layersDone this._activityFamily = family } return this._stableActivity } /** * Human log line for a milestone key, or null if none. * @param {string|null} milestone * @param {PullProgressSnapshot} [snap] */ formatMilestone(milestone, snap = this.snapshot()) { if (!milestone) return null const verb = this.kind === 'image-push' ? 'Push' : 'Pull' if (milestone.startsWith('error:')) { return `Error: ${milestone.slice(6)}` } if (milestone.startsWith('complete:')) { const rest = milestone.slice(9) if (rest === 'ok') return `${verb} complete` return rest } if (milestone.startsWith('resolving:')) { return milestone.slice(10) } // Skip per-layer noise in the live log (kept only for back-compat keys) if (milestone.startsWith('exists:') || milestone.startsWith('layer-done:')) { return null } if (milestone.startsWith('extracting:')) { return 'Extracting image layers…' } if (milestone.startsWith('download-start:')) { return this.kind === 'image-push' ? 'Uploading image layers…' : 'Downloading image layers…' } return snap.summary } /** * @param {string} id * @returns {LayerState} */ _ensureLayer(id) { let layer = this.layers.get(id) if (!layer) { layer = { id, phase: 'waiting', current: 0, total: 0, downloadCurrent: 0, downloadTotal: 0, extractCurrent: 0, extractTotal: 0, status: null, error: null, } this.layers.set(id, layer) } return layer } } /** * @param {LayerState[]} list * @param {PullPhase} phase * @returns {LayerPip[]} */ function buildLayerPips(list, phase) { if (list.length === 0) { // Placeholder ghosts so the track has a constant footprint from the first paint return ['idle', 'idle', 'idle', 'idle'] } const pips = list.map((l) => layerToPip(l, phase)) if (pips.length <= MAX_LAYER_PIPS) return pips // Prefer showing done + in-flight; collapse middle waiting into samples const done = pips.filter((p) => p === 'done') const active = pips.filter((p) => p === 'download' || p === 'extract' || p === 'error') const wait = pips.filter((p) => p === 'wait') const out = [...done, ...active] const room = MAX_LAYER_PIPS - out.length if (room > 0) { out.push(...wait.slice(0, room)) } return out.slice(0, MAX_LAYER_PIPS) } /** * @param {string} id */ export function shortLayerId(id) { const s = String(id || '') if (s.length <= 12) return s return s.slice(0, 12) } /** * Coarse activity bucket so byte-only updates can be rate-limited without * freezing “queued” after downloads finish. * @param {string} text */ function activityFamily(text) { const s = String(text || '') if (/^Fetching layers ·/.test(s)) return 'fetch-bytes' if (/^Fetching layer ·/.test(s)) return 'fetch-one' if (/^Fetching \d+ layers/.test(s)) return 'fetch-n' if (/preparing to unpack/i.test(s)) return 'prep-unpack' if (/Unpacking/i.test(s)) return 'unpack' if (/queued/i.test(s)) return 'queued' if (/ready on this host|Upload finished/i.test(s)) return 'done' if (/Contacting registry|Looking up/i.test(s)) return 'resolve' return s.slice(0, 24) } /** * @param {PullPhase} phase * @param {'image-pull'|'image-push'} [kind] */ function phaseLabel(phase, kind = 'image-pull') { const push = kind === 'image-push' switch (phase) { case 'resolving': return 'Resolving' case 'downloading': return push ? 'Uploading' : 'Downloading' case 'extracting': return 'Extracting' case 'complete': return 'Complete' case 'error': return 'Failed' default: return push ? 'Pushing' : 'Pulling' } } /** * One-line overall status for the progress meta (layers + bytes). * @param {object} p */ function buildSummary(p) { if (p.error) return p.error if (p.phase === 'complete') { if (p.layersTotal > 0) { const bytes = p.bytesTotal > 0 ? ` · ${formatPullBytes(p.bytesTotal)}` : '' return `${p.layersDone}/${p.layersTotal} layers · ready${bytes}` } return p.globalStatus || 'Image ready' } if (p.phase === 'resolving' && p.layersTotal === 0) { return p.globalStatus || 'Looking up image…' } if (p.phase === 'extracting') { // Layer count only during extract — download bytes are already in; avoid thrash if (p.layersTotal > 0) { const bytes = p.bytesTotal > 0 ? ` · ${formatPullBytes(p.bytesTotal)}` : '' return `${p.layersDone} of ${p.layersTotal} layers unpacked${bytes}` } return 'Unpacking layers…' } const parts = [] if (p.layersTotal > 0) { parts.push(`${p.layersDone} of ${p.layersTotal} layers`) } if (p.bytesTotal > 0) { parts.push(`${formatPullBytes(p.bytesCurrent)} / ${formatPullBytes(p.bytesTotal)}`) } else if (p.layersActive > 0) { parts.push(`${p.layersActive} in progress`) } return parts.length ? parts.join(' · ') : phaseLabel(p.phase, p.kind) } /** * Fixed single-line activity copy — never empty (reserves tray height). * Extract phase deliberately avoids per-layer ids (unpacks are often <200ms). * @param {object} p */ function buildActivity(p) { if (p.error) return p.error if (p.phase === 'complete') { return p.kind === 'image-push' ? 'Upload finished' : 'Image ready on this host' } if (p.phase === 'resolving' || p.layersTotal === 0) { return p.globalStatus || 'Contacting registry and resolving layers…' } // Aggregate-only during extract — short unpacks must not thrash layer ids if (p.phase === 'extracting') { if (p.layersTotal > 0) { return `Unpacking layers · ${p.layersDone} of ${p.layersTotal} ready` } return 'Unpacking image layers…' } const downloading = p.list.filter((l) => l.phase === 'downloading') const n = downloading.length if (n > 0 && p.bytesTotal > 0) { return `Fetching layers · ${formatPullBytes(p.bytesCurrent)} / ${formatPullBytes(p.bytesTotal)}` } if (n > 1) { return `Fetching ${n} layers in parallel…` } if (n === 1) { const top = downloading[0] const tot = top.downloadTotal || top.total const cur = top.downloadCurrent || top.current if (tot > 0) { return `Fetching layer · ${formatPullBytes(cur)} / ${formatPullBytes(tot)}` } return 'Fetching layers…' } if (p.list.some((l) => l.phase === 'downloaded')) { return p.layersTotal > 0 ? `Download complete · preparing to unpack (${p.layersDone} of ${p.layersTotal})` : 'Download complete · preparing to unpack…' } if (p.layersWaiting > 0) { return `${p.layersWaiting} layer${p.layersWaiting === 1 ? '' : 's'} queued` } return p.kind === 'image-push' ? 'Pushing layers…' : 'Working on layers…' } // ── Active pull registry (job tray wiring) ──────────────────────────── /** * @typedef {object} ActivePull * @property {string} jobId * @property {string} stepId * @property {PullProgressTracker} tracker * @property {number} lastEmitAt * @property {string|null} lastSummary * @property {number|null} lastPercent * @property {string|null} lastPhase * @property {string|null} lastActivity * @property {string|null} lastPipsKey * @property {string|null} lastLayersKey */ /** @type {Map} */ const activePulls = new Map() /** Fallback when image key missing on event but only one pull is active */ let soleActiveKey = null /** UI emit cadence — extract events are bursty; use a calmer floor */ const EMIT_MIN_MS = 250 const EMIT_MIN_MS_EXTRACT = 450 /** * @param {string} image * @param {string} jobId * @param {string} [stepId='pull'] * @param {{ kind?: 'image-pull'|'image-push' }} [opts] */ export function beginPullProgress(image, jobId, stepId = 'pull', opts = {}) { const key = normalizePullImageKey(image) || `job:${jobId}` const tracker = new PullProgressTracker(image, opts) activePulls.set(key, { jobId, stepId, tracker, lastEmitAt: 0, lastSummary: null, lastPercent: null, lastPhase: null, lastActivity: null, lastPipsKey: null, lastLayersKey: null, }) soleActiveKey = activePulls.size === 1 ? key : null return tracker } /** * @param {string} [image] * @param {string} [jobId] */ export function endPullProgress(image, jobId) { const key = normalizePullImageKey(image) if (key && activePulls.has(key)) { activePulls.delete(key) } else if (jobId) { for (const [k, v] of activePulls) { if (v.jobId === jobId) activePulls.delete(k) } } else if (soleActiveKey) { activePulls.delete(soleActiveKey) } soleActiveKey = activePulls.size === 1 ? [...activePulls.keys()][0] : null } /** * @param {string} [image] * @returns {ActivePull|null} */ export function getActivePull(image) { const key = normalizePullImageKey(image) if (key && activePulls.has(key)) return activePulls.get(key) if (soleActiveKey && activePulls.has(soleActiveKey)) return activePulls.get(soleActiveKey) if (activePulls.size === 1) return [...activePulls.values()][0] return null } /** * Apply a pullProgress / pushProgress push. Returns UI update payload or null if none. * @param {object} event * @returns {{ jobId: string, stepId: string, snapshot: PullProgressSnapshot, milestoneLine: string|null, force: boolean }|null} */ export function applyPullProgressEvent(event) { const image = event?.image const active = getActivePull(image) if (!active) return null const { snapshot, milestone } = active.tracker.update(event) const milestoneLine = active.tracker.formatMilestone(milestone, snapshot) const now = Date.now() const phaseChanged = snapshot.phase !== active.lastPhase const pipsKey = (snapshot.layerPips || []).join(',') // Include per-layer phase/percent/meta so row bars keep updating even when overall % is flat const layersKey = (snapshot.activeLayers || []) .map((l) => `${l.id}:${l.phase}:${l.percent ?? ''}:${l.meta || ''}`) .join('|') const force = Boolean(milestoneLine) || snapshot.phase === 'complete' || snapshot.phase === 'error' || Boolean(event?.error) || phaseChanged const minMs = snapshot.phase === 'extracting' ? EMIT_MIN_MS_EXTRACT : EMIT_MIN_MS // Cap UI updates; milestones / phase changes always pass through if (!force && now - active.lastEmitAt < minMs) { return null } // Skip no-op frames (same overall + layer row state) if ( !force && snapshot.percent === active.lastPercent && snapshot.summary === active.lastSummary && snapshot.activity === active.lastActivity && pipsKey === active.lastPipsKey && layersKey === active.lastLayersKey ) { return null } // During extract, ignore pure activity-text churn if percent/summary/pips/layers unchanged if ( !force && snapshot.phase === 'extracting' && snapshot.percent === active.lastPercent && snapshot.summary === active.lastSummary && pipsKey === active.lastPipsKey && layersKey === active.lastLayersKey ) { return null } active.lastEmitAt = now active.lastSummary = snapshot.summary active.lastPercent = snapshot.percent active.lastPhase = snapshot.phase active.lastActivity = snapshot.activity active.lastPipsKey = pipsKey active.lastLayersKey = layersKey return { jobId: active.jobId, stepId: active.stepId, snapshot, milestoneLine, force, } } /** * Finalize active pull when RPC completes. * @param {string} image * @param {{ ok: boolean, message?: string }} result */ export function finalizePullProgress(image, result = { ok: true }) { const active = getActivePull(image) if (!active) return null const snapshot = result.ok ? active.tracker.markComplete() : active.tracker.markError(result.message || 'Pull failed') const out = { jobId: active.jobId, stepId: active.stepId, snapshot, milestoneLine: result.ok ? active.tracker.formatMilestone('complete:ok', snapshot) : active.tracker.formatMilestone(`error:${result.message || 'Pull failed'}`, snapshot), force: true, } endPullProgress(image, active.jobId) return out } export function clearAllPullProgress() { activePulls.clear() soleActiveKey = null } export default { PullProgressTracker, beginPullProgress, endPullProgress, applyPullProgressEvent, finalizePullProgress, getActivePull, formatPullBytes, mapStatusToPhase, normalizePullImageKey, }