forked from snxraven/peardock
1184 lines
37 KiB
JavaScript
1184 lines
37 KiB
JavaScript
/**
|
||
* 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<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} 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<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
|
||
* 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<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,
|
||
}
|