Files
peardock/client/pullProgress.js
T
2026-07-16 03:55:43 -04:00

1184 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 “whats 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 dont thrash */
const EXTRACT_STICKY_MS = 750
/** @type {Map<string, LayerPhase>} */
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} 01 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 dont 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<string, LayerState>} */
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
* dont 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 dont 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 doesnt 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 dont 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<string, ActivePull>} */
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,
}