From 91cb6c3e411eaab317840152610ee467006728bd Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:53:03 -0400 Subject: [PATCH 1/5] feat(update): track download bytes and rate from docker pull output Parse the per-layer byte counts from docker pull progress lines (current and total, in docker's decimal units) and keep them in the update status. The overall share uses bytes only once every unfinished layer has a known size. Until then it uses the layer share, because a layer that has not started has no size yet and would make the byte share read too high. Download rate is measured over the last five seconds from the byte samples. Time left is (total - done) / rate. Both stay null until there is enough data, and the time left is never negative or infinite. The update command and its flow are unchanged. The update script's output is piped rather than sent to a terminal, so byte counts may not appear there; the layer share is the fallback in that case. Co-Authored-By: Claude Opus 5.5 --- .../__test__/settings/pull-progress.test.ts | 199 ++++++++++++++++++ .../__test__/settings/update-progress.test.ts | 23 ++ .../settings/web-server-update-status.test.ts | 32 +++ .../settings/web-server/update-progress.ts | 11 + .../server/src/services/web-server-update.ts | 55 ++++- .../server/src/utils/docker/pull-progress.ts | 189 +++++++++++++++++ 6 files changed, 505 insertions(+), 4 deletions(-) create mode 100644 apps/dokploy/__test__/settings/pull-progress.test.ts create mode 100644 packages/server/src/utils/docker/pull-progress.ts diff --git a/apps/dokploy/__test__/settings/pull-progress.test.ts b/apps/dokploy/__test__/settings/pull-progress.test.ts new file mode 100644 index 00000000000..15b592944ba --- /dev/null +++ b/apps/dokploy/__test__/settings/pull-progress.test.ts @@ -0,0 +1,199 @@ +import { + addByteSample, + type ByteSample, + downloadRate, + estimateRemainingSeconds, + isDownloadFinished, + type LayerDownload, + parseDownloadProgress, + parsePullLine, + recordLayerDownload, + summarizeDownload, +} from "@dokploy/server/utils/docker/pull-progress"; +import { describe, expect, it } from "vitest"; + +const PULL_OUTPUT = `latest: Pulling from dokploy/dokploy +4ac6ba6b019e: Pulling fs layer +9d3c1e7a2b40: Waiting +f0a1c2d3e4f5: Already exists +1b2c3d4e5f60: Downloading [==================> ] 12.3MB/45.6MB +5e6f7a8b9c0d: Downloading [====================================================>] 9.6MB/9.6MB +5e6f7a8b9c0d: Verifying Checksum +5e6f7a8b9c0d: Download complete +5e6f7a8b9c0d: Extracting [=====> ] 8.1MB/9.6MB +5e6f7a8b9c0d: Pull complete +Digest: sha256:0123456789abcdef +Status: Downloaded newer image for dokploy/dokploy:latest`; + +const track = (lines: string[]) => { + const states = new Map(); + const downloads = new Map(); + for (const line of lines) { + const layer = parsePullLine(line); + if (layer) { + states.set(layer.id, layer.status); + recordLayerDownload(downloads, layer.id, layer.status); + } + } + return summarizeDownload(states, downloads); +}; + +describe("docker pull progress", () => { + it("reads the layer id and state from pull output lines", () => { + expect( + parsePullLine("4ac6ba6b019e: Downloading [=====> ] 12.3MB/45.6MB"), + ).toEqual({ + id: "4ac6ba6b019e", + status: "Downloading [=====> ] 12.3MB/45.6MB", + }); + expect(parsePullLine("4ac6ba6b019e: Pulling fs layer")).toEqual({ + id: "4ac6ba6b019e", + status: "Pulling fs layer", + }); + expect(parsePullLine("latest: Pulling from dokploy/dokploy")).toBeNull(); + expect(parsePullLine("Digest: sha256:0123456789abcdef")).toBeNull(); + }); + + it("reads the current and total size of a downloading layer", () => { + expect( + parseDownloadProgress("Downloading [=====> ] 12.3MB/45.6MB"), + ).toEqual({ current: 12_300_000, total: 45_600_000 }); + expect(parseDownloadProgress("Downloading 12.3MB")).toEqual({ + current: 12_300_000, + total: null, + }); + expect(parseDownloadProgress("Downloading [> ] 0B/1.2kB")).toEqual({ + current: 0, + total: 1_200, + }); + expect(parseDownloadProgress("Extracting [=====> ] 8.1MB/9.6MB")).toBe( + null, + ); + expect(parseDownloadProgress("Waiting")).toBeNull(); + }); + + it("treats a layer as finished only once its download is complete", () => { + expect(isDownloadFinished("Download complete")).toBe(true); + expect(isDownloadFinished("Extracting [=====> ] 8.1MB/9.6MB")).toBe( + true, + ); + expect(isDownloadFinished("Pull complete")).toBe(true); + expect(isDownloadFinished("Already exists")).toBe(true); + expect(isDownloadFinished("Verifying Checksum")).toBe(false); + expect(isDownloadFinished("Downloading 12.3MB")).toBe(false); + expect(isDownloadFinished("Pulling fs layer")).toBe(false); + expect(isDownloadFinished("Waiting")).toBe(false); + }); + + it("fills a finished layer up to its known total", () => { + const downloads = new Map(); + recordLayerDownload( + downloads, + "4ac6ba6b019e", + "Downloading [=====> ] 4MB/10MB", + ); + recordLayerDownload(downloads, "4ac6ba6b019e", "Download complete"); + + expect(downloads.get("4ac6ba6b019e")).toEqual({ + current: 10_000_000, + total: 10_000_000, + }); + }); + + it("keeps a layer without a known total unsized when it completes", () => { + const downloads = new Map(); + recordLayerDownload(downloads, "9d3c1e7a2b40", "Downloading 4MB"); + recordLayerDownload(downloads, "9d3c1e7a2b40", "Download complete"); + + expect(downloads.get("9d3c1e7a2b40")).toEqual({ + current: 4_000_000, + total: null, + }); + }); + + it("uses the layer count while a layer has not announced its size", () => { + const lines = PULL_OUTPUT.split("\n"); + + expect(track(lines)).toEqual({ percent: 40, remainingBytes: null }); + }); + + it("uses the byte share once every unfinished layer has a known size", () => { + expect( + track([ + "4ac6ba6b019e: Download complete", + "9d3c1e7a2b40: Downloading [=====> ] 10MB/40MB", + "1b2c3d4e5f60: Downloading [==> ] 5MB/20MB", + ]), + ).toEqual({ percent: 25, remainingBytes: 45_000_000 }); + }); + + it("does not report 100 percent while a layer is still waiting", () => { + const downloading = [ + "aaaaaaaaaaaa: Downloading [=========>] 40MB/40MB", + "aaaaaaaaaaaa: Download complete", + "bbbbbbbbbbbb: Pulling fs layer", + ]; + + expect(track(downloading)).toEqual({ percent: 50, remainingBytes: null }); + expect(track([...downloading, "bbbbbbbbbbbb: Pull complete"])).toEqual({ + percent: 100, + remainingBytes: 0, + }); + }); + + it("does not count a layer without a size as complete in bytes", () => { + expect( + track([ + "aaaaaaaaaaaa: Downloading 5MB", + "bbbbbbbbbbbb: Download complete", + ]), + ).toEqual({ percent: 50, remainingBytes: null }); + }); + + it("measures the download rate over the last five seconds", () => { + let samples: readonly ByteSample[] = [{ at: 0, bytes: 0 }]; + samples = addByteSample(samples, { at: 1_000, bytes: 10_000_000 }); + samples = addByteSample(samples, { at: 6_000, bytes: 30_000_000 }); + + expect(downloadRate(samples, 6_000)).toBe(4_000_000); + }); + + it("reports the rate as unknown until there is a second of data", () => { + const samples = [ + { at: 0, bytes: 0 }, + { at: 500, bytes: 1_000_000 }, + ]; + + expect(downloadRate(samples, 600)).toBeNull(); + }); + + it("reports a stalled download as a zero rate", () => { + const samples = [ + { at: 0, bytes: 0 }, + { at: 1_000, bytes: 5_000_000 }, + ]; + + expect(downloadRate(samples, 10_000)).toBe(0); + }); + + it("keeps only the samples the rate window needs", () => { + let samples: readonly ByteSample[] = [{ at: 0, bytes: 0 }]; + for (let second = 1; second <= 60; second++) { + samples = addByteSample(samples, { + at: second * 1_000, + bytes: second * 1_000, + }); + } + + expect(samples.length).toBeLessThanOrEqual(6); + expect(downloadRate(samples, 60_000)).toBe(1_000); + }); + + it("estimates the time left without negative or infinite values", () => { + expect(estimateRemainingSeconds(8_000_000, 2_000_000)).toBe(4); + expect(estimateRemainingSeconds(0, null)).toBe(0); + expect(estimateRemainingSeconds(5_000_000, 0)).toBeNull(); + expect(estimateRemainingSeconds(5_000_000, null)).toBeNull(); + expect(estimateRemainingSeconds(null, 1_000_000)).toBeNull(); + }); +}); diff --git a/apps/dokploy/__test__/settings/update-progress.test.ts b/apps/dokploy/__test__/settings/update-progress.test.ts index e4e0f7d5ec4..5eb45d70221 100644 --- a/apps/dokploy/__test__/settings/update-progress.test.ts +++ b/apps/dokploy/__test__/settings/update-progress.test.ts @@ -7,6 +7,8 @@ import { DOWNLOAD_LIMIT_MS, failUpdateProgress, formatElapsed, + formatRemaining, + formatSpeed, getUpdateStepState, RESTART_LIMIT_MS, UPDATE_STEPS, @@ -25,6 +27,9 @@ const serverStatus = ( layersTotal: 0, layersDownloaded: 0, layersExtracted: 0, + downloadPercent: null, + downloadBytesPerSecond: null, + downloadRemainingSeconds: null, diskFull: false, output: [], ...overrides, @@ -240,6 +245,24 @@ describe("update progress", () => { }); }); +describe("download figures", () => { + it("shows the speed in MB/s, or unknown without a measurement", () => { + expect(formatSpeed(12_400_000)).toBe("12.4 MB/s"); + expect(formatSpeed(0)).toBe("0.0 MB/s"); + expect(formatSpeed(null)).toBe("unknown"); + expect(formatSpeed(-1)).toBe("unknown"); + expect(formatSpeed(Number.POSITIVE_INFINITY)).toBe("unknown"); + }); + + it("shows the time left like the elapsed time, or unknown", () => { + expect(formatRemaining(80)).toBe("1 min 20 s"); + expect(formatRemaining(0)).toBe("0 s"); + expect(formatRemaining(null)).toBe("unknown"); + expect(formatRemaining(-3)).toBe("unknown"); + expect(formatRemaining(Number.POSITIVE_INFINITY)).toBe("unknown"); + }); +}); + describe("formatElapsed", () => { it("formats seconds and minutes", () => { expect(formatElapsed(-5)).toBe("0 s"); diff --git a/apps/dokploy/__test__/settings/web-server-update-status.test.ts b/apps/dokploy/__test__/settings/web-server-update-status.test.ts index 5fbda15cbb3..bf0a7d0256f 100644 --- a/apps/dokploy/__test__/settings/web-server-update-status.test.ts +++ b/apps/dokploy/__test__/settings/web-server-update-status.test.ts @@ -223,6 +223,38 @@ describe("web server update status", () => { }); }); + it("reports the download share and rate from layer sizes", () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(10_000); + const script = startFakeScript(); + expect(getServerUpdateStatus()).toMatchObject({ + downloadPercent: null, + downloadBytesPerSecond: null, + }); + + vi.setSystemTime(10_500); + script.write( + "aaaaaaaaaaaa: Downloading [=====> ] 10MB/40MB\nbbbbbbbbbbbb: Downloading [==> ] 2MB/10MB\n", + ); + vi.setSystemTime(12_000); + script.write("aaaaaaaaaaaa: Download complete\n"); + + const status = getServerUpdateStatus(); + expect(status.downloadPercent).toBe(84); + expect(status.downloadBytesPerSecond).toBe(21_000_000); + expect(status.downloadRemainingSeconds).toBeCloseTo(8 / 21, 6); + + script.write("cccccccccccc: Pulling fs layer\n"); + expect(getServerUpdateStatus()).toMatchObject({ + downloadPercent: 33, + downloadRemainingSeconds: null, + }); + } finally { + vi.useRealTimers(); + } + }); + it("returns a copy of the status", () => { const script = startFakeScript(); script.write("line\n"); diff --git a/apps/dokploy/components/dashboard/settings/web-server/update-progress.ts b/apps/dokploy/components/dashboard/settings/web-server/update-progress.ts index 24738c341f3..c3b2c3ab5ad 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/update-progress.ts +++ b/apps/dokploy/components/dashboard/settings/web-server/update-progress.ts @@ -203,3 +203,14 @@ export const formatElapsed = (ms: number) => { const seconds = totalSeconds % 60; return `${minutes} min ${String(seconds).padStart(2, "0")} s`; }; + +const isKnownAmount = (value: number | null): value is number => + value !== null && Number.isFinite(value) && value >= 0; + +export const formatSpeed = (bytesPerSecond: number | null) => + isKnownAmount(bytesPerSecond) + ? `${(bytesPerSecond / 1_000_000).toFixed(1)} MB/s` + : "unknown"; + +export const formatRemaining = (seconds: number | null) => + isKnownAmount(seconds) ? formatElapsed(seconds * 1000) : "unknown"; diff --git a/packages/server/src/services/web-server-update.ts b/packages/server/src/services/web-server-update.ts index 9e3ba895de0..33ca7d7ebc9 100644 --- a/packages/server/src/services/web-server-update.ts +++ b/packages/server/src/services/web-server-update.ts @@ -1,3 +1,14 @@ +import { + addByteSample, + type ByteSample, + downloadedBytes, + downloadRate, + estimateRemainingSeconds, + type LayerDownload, + parsePullLine, + recordLayerDownload, + summarizeDownload, +} from "../utils/docker/pull-progress"; import { spawnAsync } from "../utils/process/spawnAsync"; /** Printed by the update scripts once `docker pull` succeeded. */ @@ -19,6 +30,12 @@ export interface ServerUpdateStatus { layersTotal: number; layersDownloaded: number; layersExtracted: number; + /** Share of the image downloaded: by bytes once every unfinished layer has a known size, otherwise by layers. */ + downloadPercent: number | null; + /** Download rate over the last five seconds; null until there is enough data. */ + downloadBytesPerSecond: number | null; + /** Estimated seconds until the download finishes; null while unknown. */ + downloadRemainingSeconds: number | null; /** Docker reported "no space left on device" while the update ran. */ diskFull: boolean; /** Last lines of the update script output: image refs and layer ids only. */ @@ -37,6 +54,9 @@ const createIdleStatus = (): ServerUpdateStatus => ({ layersTotal: 0, layersDownloaded: 0, layersExtracted: 0, + downloadPercent: null, + downloadBytesPerSecond: null, + downloadRemainingSeconds: null, diskFull: false, output: [], }); @@ -45,23 +65,42 @@ const createIdleStatus = (): ServerUpdateStatus => ({ // process starting from "idle" is how the UI knows the restart happened. let status = createIdleStatus(); let layers = new Map(); +let layerDownloads = new Map(); +let downloadSamples: readonly ByteSample[] = []; let pendingOutput = ""; +const getDownloadEstimates = (now: number) => { + const { percent, remainingBytes } = summarizeDownload(layers, layerDownloads); + // Without any byte counts the rate would read as zero instead of unknown. + const bytesPerSecond = + layerDownloads.size > 0 ? downloadRate(downloadSamples, now) : null; + return { + downloadPercent: percent, + downloadBytesPerSecond: bytesPerSecond, + downloadRemainingSeconds: estimateRemainingSeconds( + remainingBytes, + bytesPerSecond, + ), + }; +}; + export const getServerUpdateStatus = (): ServerUpdateStatus => ({ ...status, output: [...status.output], + ...getDownloadEstimates(Date.now()), }); export const resetServerUpdateStatus = () => { status = createIdleStatus(); layers = new Map(); + layerDownloads = new Map(); + downloadSamples = []; pendingOutput = ""; }; export const isServerUpdateRunning = () => status.phase === "pulling" || status.phase === "updating"; -const LAYER_LINE = /^([0-9a-f]{12,64}): (.+)$/; const DOWNLOADED_LAYER_STATES = new Set([ "Download complete", "Pull complete", @@ -90,13 +129,18 @@ const handleOutputLine = (rawLine: string) => { status.diskFull = true; } - const layer = line.match(LAYER_LINE); + const layer = parsePullLine(line); if (layer && status.phase === "pulling") { - const [, id, state] = layer as unknown as [string, string, string]; + const { id, status: state } = layer; // Docker repeats a layer's earlier states while it is retried, so a // layer that already finished must not go back to "downloading". if (!EXTRACTED_LAYER_STATES.has(layers.get(id) ?? "")) { layers.set(id, state); + recordLayerDownload(layerDownloads, id, state); + downloadSamples = addByteSample(downloadSamples, { + at: Date.now(), + bytes: downloadedBytes(layerDownloads), + }); } status.layersTotal = layers.size; status.layersDownloaded = countLayers(DOWNLOADED_LAYER_STATES); @@ -144,7 +188,10 @@ export const startServerUpdate = (command: string) => { resetServerUpdateStatus(); status.phase = "pulling"; - status.startedAt = Date.now(); + const startedAt = Date.now(); + status.startedAt = startedAt; + // The first progress line is measured against zero bytes at the start. + downloadSamples = [{ at: startedAt, bytes: 0 }]; spawnAsync("sh", ["-c", command], handleOutput).then( () => finish(null), diff --git a/packages/server/src/utils/docker/pull-progress.ts b/packages/server/src/utils/docker/pull-progress.ts new file mode 100644 index 00000000000..27cdaac2ac6 --- /dev/null +++ b/packages/server/src/utils/docker/pull-progress.ts @@ -0,0 +1,189 @@ +export interface LayerDownload { + current: number; + total: number | null; +} + +export interface ByteSample { + at: number; + bytes: number; +} + +export interface DownloadSummary { + percent: number | null; + remainingBytes: number | null; +} + +const RATE_WINDOW_MS = 5_000; +const RATE_MIN_WINDOW_MS = 1_000; + +const PULL_LINE = /^([0-9a-f]{12,64}): (.+)$/; + +// Docker prints decimal units: kB is 1000 bytes, not 1024. +const UNIT_BYTES: Record = { + B: 1, + kB: 1_000, + MB: 1_000_000, + GB: 1_000_000_000, + TB: 1_000_000_000_000, +}; + +const DOWNLOADING_LINE = + /^Downloading\s+(?:\[[^\]]*\]\s*)?(\d+(?:\.\d+)?)(B|kB|MB|GB|TB)(?:\/(\d+(?:\.\d+)?)(B|kB|MB|GB|TB))?$/; + +const FINISHED_STATUSES = new Set([ + "Download complete", + "Pull complete", + "Already exists", +]); + +export const parsePullLine = (line: string) => { + const [, id, status] = PULL_LINE.exec(line) ?? []; + return id !== undefined && status !== undefined ? { id, status } : null; +}; + +const toBytes = (amount: string | undefined, unit: string | undefined) => { + const factor = unit === undefined ? undefined : UNIT_BYTES[unit]; + if (amount === undefined || factor === undefined) { + return null; + } + return Math.round(Number(amount) * factor); +}; + +export const parseDownloadProgress = (status: string): LayerDownload | null => { + const [, currentAmount, currentUnit, totalAmount, totalUnit] = + DOWNLOADING_LINE.exec(status) ?? []; + const current = toBytes(currentAmount, currentUnit); + if (current === null) { + return null; + } + return { current, total: toBytes(totalAmount, totalUnit) }; +}; + +export const isDownloadFinished = (status: string) => + FINISHED_STATUSES.has(status) || status.startsWith("Extracting"); + +export const recordLayerDownload = ( + downloads: Map, + id: string, + status: string, +) => { + const previous = downloads.get(id); + const progress = parseDownloadProgress(status); + if (progress) { + const total = progress.total ?? previous?.total ?? null; + const current = Math.max(previous?.current ?? 0, progress.current); + downloads.set(id, { + current: total === null ? current : Math.min(current, total), + total, + }); + return; + } + const knownTotal = previous?.total ?? null; + if (knownTotal !== null && isDownloadFinished(status)) { + downloads.set(id, { current: knownTotal, total: knownTotal }); + } +}; + +export const downloadedBytes = ( + downloads: ReadonlyMap, +) => { + let bytes = 0; + for (const download of downloads.values()) { + bytes += download.current; + } + return bytes; +}; + +export const summarizeDownload = ( + layerStates: ReadonlyMap, + downloads: ReadonlyMap, +): DownloadSummary => { + let finishedLayers = 0; + let hasUnsizedUnfinishedLayer = false; + let totalBytes = 0; + let doneBytes = 0; + for (const [id, status] of layerStates) { + const finished = isDownloadFinished(status); + if (finished) { + finishedLayers++; + } + const download = downloads.get(id); + if (download && download.total !== null) { + totalBytes += download.total; + doneBytes += download.current; + } else if (!finished) { + // A layer that has not announced its size yet would make the byte share + // read as finished too early, so the layer count is used instead. + hasUnsizedUnfinishedLayer = true; + } + } + if (totalBytes > 0 && !hasUnsizedUnfinishedLayer) { + return { + percent: Math.floor((doneBytes / totalBytes) * 100), + remainingBytes: totalBytes - doneBytes, + }; + } + return { + percent: + layerStates.size > 0 + ? Math.floor((finishedLayers / layerStates.size) * 100) + : null, + remainingBytes: null, + }; +}; + +export const addByteSample = ( + samples: readonly ByteSample[], + sample: ByteSample, +): readonly ByteSample[] => { + if (samples.at(-1)?.bytes === sample.bytes) { + return samples; + } + const windowStart = sample.at - RATE_WINDOW_MS; + let anchor = 0; + for (const [index, entry] of samples.entries()) { + if (entry.at <= windowStart) { + anchor = index; + } + } + return [...samples.slice(anchor), sample]; +}; + +export const downloadRate = ( + samples: readonly ByteSample[], + now: number, +): number | null => { + const first = samples[0]; + const latest = samples.at(-1); + if (!first || !latest) { + return null; + } + const windowStart = Math.max(now - RATE_WINDOW_MS, first.at); + const elapsedMs = now - windowStart; + if (elapsedMs < RATE_MIN_WINDOW_MS) { + return null; + } + let bytesAtStart = first.bytes; + for (const sample of samples) { + if (sample.at <= windowStart) { + bytesAtStart = sample.bytes; + } + } + return Math.max(0, latest.bytes - bytesAtStart) / (elapsedMs / 1000); +}; + +export const estimateRemainingSeconds = ( + remainingBytes: number | null, + bytesPerSecond: number | null, +): number | null => { + if (remainingBytes === null) { + return null; + } + if (remainingBytes <= 0) { + return 0; + } + if (bytesPerSecond === null || bytesPerSecond <= 0) { + return null; + } + return remainingBytes / bytesPerSecond; +}; From 615d46c0e9774e249535de0efd67a3df489ddfeb Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 18:53:07 -0400 Subject: [PATCH 2/5] feat(update): show download progress and figures in the update dialog While the image downloads, the downloading step shows a progress bar with the percentage, the download speed in MB/s and the estimated time left. Speed and time left read unknown until there is enough data. The detail lines are now block elements so the bar can sit between them, and the step content fills the dialog width. Co-Authored-By: Claude Opus 5.5 --- .../settings/update-status-panel.test.ts | 70 +++++++++++++++++++ .../web-server/update-status-panel.tsx | 44 +++++++++++- 2 files changed, 111 insertions(+), 3 deletions(-) create mode 100644 apps/dokploy/__test__/settings/update-status-panel.test.ts diff --git a/apps/dokploy/__test__/settings/update-status-panel.test.ts b/apps/dokploy/__test__/settings/update-status-panel.test.ts new file mode 100644 index 00000000000..8c2e9f8c587 --- /dev/null +++ b/apps/dokploy/__test__/settings/update-status-panel.test.ts @@ -0,0 +1,70 @@ +import type { ServerUpdateStatus } from "@dokploy/server/services/web-server-update"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { + createUpdateProgress, + type UpdateProgress, +} from "@/components/dashboard/settings/web-server/update-progress"; +import { UpdateStatusPanel } from "@/components/dashboard/settings/web-server/update-status-panel"; + +const serverStatus = ( + overrides: Partial = {}, +): ServerUpdateStatus => ({ + phase: "pulling", + startedAt: 1_000, + pulledAt: null, + finishedAt: null, + error: null, + layersTotal: 3, + layersDownloaded: 1, + layersExtracted: 0, + downloadPercent: 36, + downloadBytesPerSecond: 12_400_000, + downloadRemainingSeconds: 80, + diskFull: false, + output: [], + ...overrides, +}); + +const render = (server: ServerUpdateStatus) => { + const progress: UpdateProgress = { + ...createUpdateProgress(0), + server, + }; + return renderToStaticMarkup( + createElement(UpdateStatusPanel, { progress, now: 61_000 }), + ); +}; + +describe("update status panel", () => { + it("shows the download bar with the percentage, speed and time left", () => { + const html = render(serverStatus()); + + expect(html).toContain('aria-label="Image download progress"'); + expect(html).toContain("36% downloaded"); + expect(html).toContain("Speed 12.4 MB/s"); + expect(html).toContain("Time left 1 min 20 s"); + expect(html).toContain("1 of 3 layers downloaded, 0 extracted"); + }); + + it("marks speed and time left as unknown until there is data", () => { + const html = render( + serverStatus({ + downloadBytesPerSecond: null, + downloadRemainingSeconds: null, + }), + ); + + expect(html).toContain("36% downloaded"); + expect(html).toContain("Speed unknown"); + expect(html).toContain("Time left unknown"); + }); + + it("hides the download bar once the image is pulled", () => { + const html = render(serverStatus({ phase: "updating", pulledAt: 5_000 })); + + expect(html).not.toContain("% downloaded"); + expect(html).toContain("Image downloaded, waiting for Dokploy to stop"); + }); +}); diff --git a/apps/dokploy/components/dashboard/settings/web-server/update-status-panel.tsx b/apps/dokploy/components/dashboard/settings/web-server/update-status-panel.tsx index b72666fb591..b5023307420 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/update-status-panel.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/update-status-panel.tsx @@ -11,9 +11,12 @@ import { XCircle, } from "lucide-react"; import type { ReactNode } from "react"; +import { Progress } from "@/components/ui/progress"; import { cn } from "@/lib/utils"; import { formatElapsed, + formatRemaining, + formatSpeed, getUpdateStepState, type UpdatePanelStep, type UpdateProgress, @@ -49,6 +52,29 @@ const StateIcon = ({ state }: { state: UpdateStepState }) => { } }; +const DownloadProgress = ({ + percent, + bytesPerSecond, + remainingSeconds, +}: { + percent: number; + bytesPerSecond: number | null; + remainingSeconds: number | null; +}) => ( +
+ +
+ {percent}% downloaded + Speed {formatSpeed(bytesPerSecond)} + Time left {formatRemaining(remainingSeconds)} +
+
+); + const getDownloadDetails = ( progress: UpdateProgress, state: UpdateStepState, @@ -78,6 +104,18 @@ const getDownloadDetails = ( `${server.layersDownloaded} of ${server.layersTotal} layers downloaded, ${server.layersExtracted} extracted`, ); } + if ( + server?.phase === "pulling" && + typeof server.downloadPercent === "number" + ) { + details.push( + , + ); + } if (server?.phase === "updating" || server?.phase === "done") { details.push("Image downloaded, waiting for Dokploy to stop"); } @@ -163,7 +201,7 @@ export const UpdateStatusPanel = ({ aria-current={state === "active" ? "step" : undefined} > -
+
{details.map((detail, index) => ( - +
{detail} - +
))}
From 6ab7ef74e17d12e700bab27cf60c041752c9c341 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:05:07 -0400 Subject: [PATCH 3/5] feat(update): pull the update image through the Docker Engine API The update script's output is piped, so docker prints no byte counts there. Pull the image through the local Docker client before the script runs, and read the JSON progress events, which carry each layer's bytes and total. The script's own docker pull then finds the image present. If the engine pull fails, the error is logged, the partial layer progress is dropped, and the script pulls the image as before. Error events inside the stream are checked explicitly, since docker-modem reports only stream failures. Co-Authored-By: Claude Opus 5.5 --- .../__test__/settings/engine-pull.test.ts | 98 +++++++ .../__test__/settings/pull-progress.test.ts | 70 ++++- .../settings/update-engine-pull.test.ts | 252 ++++++++++++++++++ .../server/src/services/web-server-update.ts | 129 ++++++--- .../server/src/utils/docker/engine-pull.ts | 54 ++++ .../server/src/utils/docker/pull-progress.ts | 34 ++- 6 files changed, 598 insertions(+), 39 deletions(-) create mode 100644 apps/dokploy/__test__/settings/engine-pull.test.ts create mode 100644 apps/dokploy/__test__/settings/update-engine-pull.test.ts create mode 100644 packages/server/src/utils/docker/engine-pull.ts diff --git a/apps/dokploy/__test__/settings/engine-pull.test.ts b/apps/dokploy/__test__/settings/engine-pull.test.ts new file mode 100644 index 00000000000..2b317309a62 --- /dev/null +++ b/apps/dokploy/__test__/settings/engine-pull.test.ts @@ -0,0 +1,98 @@ +import { Readable } from "node:stream"; +import type { EnginePullEvent } from "@dokploy/server/utils/docker/pull-progress"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@dokploy/server/constants", () => ({ docker: {} })); + +const { pullImageThroughEngine } = await import( + "@dokploy/server/utils/docker/engine-pull" +); + +const IMAGE = "registry.example.com/dokploy:test"; + +const recorded = (lines: string[]) => + lines.map((line) => JSON.parse(line) as EnginePullEvent); + +const RECORDED_PULL = recorded([ + '{"status":"Pulling from example/dokploy","id":"test"}', + '{"status":"Pulling fs layer","progressDetail":{},"id":"4ac6ba6b019e"}', + '{"status":"Downloading","progressDetail":{"current":10000000,"total":40000000},"progress":"[=====> ] 10MB/40MB","id":"4ac6ba6b019e"}', + '{"status":"Download complete","progressDetail":{},"id":"4ac6ba6b019e"}', + '{"status":"Pull complete","progressDetail":{},"id":"4ac6ba6b019e"}', + '{"status":"Digest: sha256:0123456789abcdef"}', + '{"status":"Status: Downloaded newer image for registry.example.com/dokploy:test"}', +]); + +const clientReplaying = ( + events: EnginePullEvent[], + options: { pullError?: Error; streamError?: Error } = {}, +) => ({ + pull: vi.fn(async () => { + if (options.pullError) { + throw options.pullError; + } + return Readable.from([]); + }), + modem: { + followProgress: ( + _stream: NodeJS.ReadableStream, + onFinished: (error: Error | null, output: EnginePullEvent[]) => void, + onProgress: (event: EnginePullEvent) => void, + ) => { + for (const event of events) { + onProgress(event); + } + if (options.streamError) { + onFinished(options.streamError, []); + return; + } + onFinished(null, events); + }, + }, +}); + +describe("pullImageThroughEngine", () => { + it("passes each recorded event on and resolves when the stream ends", async () => { + const onEvent = vi.fn(); + const client = clientReplaying(RECORDED_PULL); + + await expect( + pullImageThroughEngine(IMAGE, onEvent, client), + ).resolves.toBeUndefined(); + + expect(client.pull).toHaveBeenCalledWith(IMAGE); + expect(onEvent).toHaveBeenCalledTimes(RECORDED_PULL.length); + expect(onEvent).toHaveBeenNthCalledWith(3, RECORDED_PULL[2]); + }); + + it("rejects when the stream reports an error event", async () => { + const events = recorded([ + '{"status":"Pulling fs layer","progressDetail":{},"id":"4ac6ba6b019e"}', + '{"errorDetail":{"message":"manifest unknown"},"error":"manifest unknown"}', + ]); + + await expect( + pullImageThroughEngine(IMAGE, vi.fn(), clientReplaying(events)), + ).rejects.toThrow("manifest unknown"); + }); + + it("rejects when the pull request itself fails", async () => { + const client = clientReplaying([], { + pullError: new Error("no such image"), + }); + + await expect( + pullImageThroughEngine(IMAGE, vi.fn(), client), + ).rejects.toThrow("no such image"); + }); + + it("rejects when the stream fails", async () => { + const client = clientReplaying(RECORDED_PULL, { + streamError: new Error("socket hang up"), + }); + + await expect( + pullImageThroughEngine(IMAGE, vi.fn(), client), + ).rejects.toThrow("socket hang up"); + }); +}); diff --git a/apps/dokploy/__test__/settings/pull-progress.test.ts b/apps/dokploy/__test__/settings/pull-progress.test.ts index 15b592944ba..86f0f1e18a0 100644 --- a/apps/dokploy/__test__/settings/pull-progress.test.ts +++ b/apps/dokploy/__test__/settings/pull-progress.test.ts @@ -4,8 +4,10 @@ import { downloadRate, estimateRemainingSeconds, isDownloadFinished, + isLayerId, type LayerDownload, parseDownloadProgress, + parseEngineDownloadProgress, parsePullLine, recordLayerDownload, summarizeDownload, @@ -32,7 +34,12 @@ const track = (lines: string[]) => { const layer = parsePullLine(line); if (layer) { states.set(layer.id, layer.status); - recordLayerDownload(downloads, layer.id, layer.status); + recordLayerDownload( + downloads, + layer.id, + layer.status, + parseDownloadProgress(layer.status), + ); } } return summarizeDownload(states, downloads); @@ -66,9 +73,9 @@ describe("docker pull progress", () => { current: 0, total: 1_200, }); - expect(parseDownloadProgress("Extracting [=====> ] 8.1MB/9.6MB")).toBe( - null, - ); + expect( + parseDownloadProgress("Extracting [=====> ] 8.1MB/9.6MB"), + ).toBeNull(); expect(parseDownloadProgress("Waiting")).toBeNull(); }); @@ -91,8 +98,9 @@ describe("docker pull progress", () => { downloads, "4ac6ba6b019e", "Downloading [=====> ] 4MB/10MB", + parseDownloadProgress("Downloading [=====> ] 4MB/10MB"), ); - recordLayerDownload(downloads, "4ac6ba6b019e", "Download complete"); + recordLayerDownload(downloads, "4ac6ba6b019e", "Download complete", null); expect(downloads.get("4ac6ba6b019e")).toEqual({ current: 10_000_000, @@ -102,8 +110,13 @@ describe("docker pull progress", () => { it("keeps a layer without a known total unsized when it completes", () => { const downloads = new Map(); - recordLayerDownload(downloads, "9d3c1e7a2b40", "Downloading 4MB"); - recordLayerDownload(downloads, "9d3c1e7a2b40", "Download complete"); + recordLayerDownload( + downloads, + "9d3c1e7a2b40", + "Downloading 4MB", + parseDownloadProgress("Downloading 4MB"), + ); + recordLayerDownload(downloads, "9d3c1e7a2b40", "Download complete", null); expect(downloads.get("9d3c1e7a2b40")).toEqual({ current: 4_000_000, @@ -111,6 +124,49 @@ describe("docker pull progress", () => { }); }); + it("reads download bytes from engine events and ignores extraction progress", () => { + expect( + parseEngineDownloadProgress({ + id: "4ac6ba6b019e", + status: "Downloading", + progressDetail: { current: 5_000_000, total: 20_000_000 }, + }), + ).toEqual({ current: 5_000_000, total: 20_000_000 }); + expect( + parseEngineDownloadProgress({ + id: "4ac6ba6b019e", + status: "Downloading", + progressDetail: { current: 5_000_000 }, + }), + ).toEqual({ current: 5_000_000, total: null }); + expect( + parseEngineDownloadProgress({ + id: "4ac6ba6b019e", + status: "Downloading", + progressDetail: { current: 5_000_000, total: 0 }, + }), + ).toEqual({ current: 5_000_000, total: null }); + expect( + parseEngineDownloadProgress({ + id: "4ac6ba6b019e", + status: "Downloading", + progressDetail: {}, + }), + ).toBeNull(); + expect( + parseEngineDownloadProgress({ + id: "4ac6ba6b019e", + status: "Extracting", + progressDetail: { current: 8_100_000, total: 9_600_000 }, + }), + ).toBeNull(); + }); + + it("recognises layer ids and skips other engine events", () => { + expect(isLayerId("4ac6ba6b019e")).toBe(true); + expect(isLayerId("latest")).toBe(false); + }); + it("uses the layer count while a layer has not announced its size", () => { const lines = PULL_OUTPUT.split("\n"); diff --git a/apps/dokploy/__test__/settings/update-engine-pull.test.ts b/apps/dokploy/__test__/settings/update-engine-pull.test.ts new file mode 100644 index 00000000000..c5f7856eb4a --- /dev/null +++ b/apps/dokploy/__test__/settings/update-engine-pull.test.ts @@ -0,0 +1,252 @@ +import type { EnginePullEvent } from "@dokploy/server/utils/docker/pull-progress"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { pullImageThroughEngine, spawnAsync } = vi.hoisted(() => ({ + pullImageThroughEngine: vi.fn(), + spawnAsync: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/docker/engine-pull", () => ({ + pullImageThroughEngine, +})); +vi.mock("@dokploy/server/utils/process/spawnAsync", () => ({ spawnAsync })); + +const { getServerUpdateStatus, resetServerUpdateStatus, startServerUpdate } = + await import("@dokploy/server/services/web-server-update"); + +const COMMAND = "update command"; +const IMAGE = "registry.example.com/dokploy:test"; +const START = 10_000; + +// Each entry is [time the event arrived in ms, the JSON line docker sent]. +type Recorded = [at: number, line: string]; + +const NORMAL_PULL: Recorded[] = [ + [10_100, '{"status":"Pulling from example/dokploy","id":"test"}'], + [ + 10_200, + '{"status":"Pulling fs layer","progressDetail":{},"id":"4ac6ba6b019e"}', + ], + [ + 10_200, + '{"status":"Pulling fs layer","progressDetail":{},"id":"9d3c1e7a2b40"}', + ], + [ + 10_200, + '{"status":"Already exists","progressDetail":{},"id":"f0a1c2d3e4f5"}', + ], + [ + 11_000, + '{"status":"Downloading","progressDetail":{"current":10000000,"total":40000000},"progress":"[=====> ] 10MB/40MB","id":"4ac6ba6b019e"}', + ], + [ + 11_000, + '{"status":"Downloading","progressDetail":{"current":2000000,"total":200000000},"progress":"[> ] 2MB/200MB","id":"9d3c1e7a2b40"}', + ], + [ + 12_000, + '{"status":"Downloading","progressDetail":{"current":30000000,"total":40000000},"progress":"[=========>] 30MB/40MB","id":"4ac6ba6b019e"}', + ], + [ + 12_500, + '{"status":"Download complete","progressDetail":{},"id":"4ac6ba6b019e"}', + ], + [ + 12_500, + '{"status":"Extracting","progressDetail":{"current":40000000,"total":40000000},"progress":"[=====>] 40MB/40MB","id":"4ac6ba6b019e"}', + ], + [ + 13_000, + '{"status":"Downloading","progressDetail":{"current":128000000,"total":200000000},"progress":"[=====> ] 128MB/200MB","id":"9d3c1e7a2b40"}', + ], + [ + 13_000, + '{"status":"Pull complete","progressDetail":{},"id":"4ac6ba6b019e"}', + ], + [13_000, '{"status":"Digest: sha256:0123456789abcdef"}'], + [ + 13_000, + '{"status":"Status: Downloaded newer image for registry.example.com/dokploy:test"}', + ], +]; + +const UNSIZED_LAYER: Recorded[] = [ + [ + 10_200, + '{"status":"Pulling fs layer","progressDetail":{},"id":"4ac6ba6b019e"}', + ], + [ + 10_200, + '{"status":"Pulling fs layer","progressDetail":{},"id":"9d3c1e7a2b40"}', + ], + [ + 11_000, + '{"status":"Downloading","progressDetail":{"current":5000000},"progress":"5MB","id":"4ac6ba6b019e"}', + ], +]; + +const ALREADY_COMPLETE: Recorded[] = [ + [ + 10_200, + '{"status":"Already exists","progressDetail":{},"id":"f0a1c2d3e4f5"}', + ], + [ + 10_300, + '{"status":"Already exists","progressDetail":{},"id":"9d3c1e7a2b40"}', + ], +]; + +const PARTIAL_PULL: Recorded[] = [ + [ + 10_200, + '{"status":"Pulling fs layer","progressDetail":{},"id":"4ac6ba6b019e"}', + ], + [ + 11_000, + '{"status":"Downloading","progressDetail":{"current":10000000,"total":40000000},"progress":"[=====> ] 10MB/40MB","id":"4ac6ba6b019e"}', + ], +]; + +const replayPull = (recording: Recorded[], failure?: Error) => { + pullImageThroughEngine.mockImplementation( + async (_image: string, onEvent: (event: EnginePullEvent) => void) => { + for (const [at, line] of recording) { + vi.setSystemTime(at); + onEvent(JSON.parse(line) as EnginePullEvent); + } + if (failure) { + throw failure; + } + }, + ); +}; + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe("update through the Docker Engine API", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(START); + resetServerUpdateStatus(); + spawnAsync.mockImplementation(() => new Promise(() => {})); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("reports bytes, speed and time left from a recorded pull, then runs the script", async () => { + replayPull(NORMAL_PULL); + + expect(startServerUpdate(COMMAND, IMAGE)).toBe(true); + expect(spawnAsync).not.toHaveBeenCalled(); + await settle(); + + expect(pullImageThroughEngine).toHaveBeenCalledWith( + IMAGE, + expect.any(Function), + ); + expect(spawnAsync).toHaveBeenCalledWith( + "sh", + ["-c", COMMAND], + expect.any(Function), + ); + vi.setSystemTime(13_000); + const status = getServerUpdateStatus(); + expect(status).toMatchObject({ + phase: "pulling", + downloadPercent: 70, + downloadBytesPerSecond: 56_000_000, + layersTotal: 3, + layersDownloaded: 2, + layersExtracted: 2, + }); + expect(status.downloadRemainingSeconds).toBeCloseTo(72 / 56, 6); + }); + + it("shows the layer share and unknown time left while a layer has no total yet", async () => { + replayPull(UNSIZED_LAYER); + + startServerUpdate(COMMAND, IMAGE); + await settle(); + + vi.setSystemTime(13_000); + const status = getServerUpdateStatus(); + expect(status.downloadPercent).toBe(0); + expect(status.downloadRemainingSeconds).toBeNull(); + expect(status.downloadBytesPerSecond).toBeCloseTo(5_000_000 / 3, 0); + }); + + it("counts a layer that already exists as complete without bytes", async () => { + replayPull(ALREADY_COMPLETE); + + startServerUpdate(COMMAND, IMAGE); + await settle(); + + expect(getServerUpdateStatus()).toMatchObject({ + phase: "pulling", + downloadPercent: 100, + downloadBytesPerSecond: null, + downloadRemainingSeconds: null, + layersTotal: 2, + layersDownloaded: 2, + layersExtracted: 2, + }); + }); + + it("falls back to the script's own pull when the engine pull fails", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + replayPull(PARTIAL_PULL, new Error("manifest unknown")); + + startServerUpdate(COMMAND, IMAGE); + await settle(); + + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining(IMAGE), + expect.any(Error), + ); + expect(spawnAsync).toHaveBeenCalledWith( + "sh", + ["-c", COMMAND], + expect.any(Function), + ); + expect(getServerUpdateStatus()).toMatchObject({ + phase: "pulling", + downloadPercent: null, + downloadBytesPerSecond: null, + layersTotal: 0, + }); + + const onScriptOutput = spawnAsync.mock.calls[0]?.[2] as ( + chunk: string, + ) => void; + onScriptOutput("aaaaaaaaaaaa: Pulling fs layer\n"); + expect(getServerUpdateStatus()).toMatchObject({ + downloadPercent: 0, + downloadBytesPerSecond: null, + layersTotal: 1, + }); + + onScriptOutput("aaaaaaaaaaaa: Pull complete\n"); + expect(getServerUpdateStatus()).toMatchObject({ + downloadPercent: 100, + downloadBytesPerSecond: null, + layersTotal: 1, + }); + }); + + it("runs the script directly when no image is given", () => { + startServerUpdate(COMMAND); + + expect(pullImageThroughEngine).not.toHaveBeenCalled(); + expect(spawnAsync).toHaveBeenCalledWith( + "sh", + ["-c", COMMAND], + expect.any(Function), + ); + }); +}); diff --git a/packages/server/src/services/web-server-update.ts b/packages/server/src/services/web-server-update.ts index 33ca7d7ebc9..254b5723f8c 100644 --- a/packages/server/src/services/web-server-update.ts +++ b/packages/server/src/services/web-server-update.ts @@ -1,10 +1,15 @@ +import { pullImageThroughEngine } from "../utils/docker/engine-pull"; import { addByteSample, type ByteSample, downloadedBytes, downloadRate, + type EnginePullEvent, estimateRemainingSeconds, + isLayerId, type LayerDownload, + parseDownloadProgress, + parseEngineDownloadProgress, parsePullLine, recordLayerDownload, summarizeDownload, @@ -111,6 +116,30 @@ const EXTRACTED_LAYER_STATES = new Set(["Pull complete", "Already exists"]); const countLayers = (states: Set) => [...layers.values()].filter((state) => states.has(state)).length; +const updateLayerCounts = () => { + status.layersTotal = layers.size; + status.layersDownloaded = countLayers(DOWNLOADED_LAYER_STATES); + status.layersExtracted = countLayers(EXTRACTED_LAYER_STATES); +}; + +const recordLayerState = ( + id: string, + state: string, + progress: LayerDownload | null, +) => { + // Docker repeats a layer's earlier states while it is retried, so a + // layer that already finished must not go back to "downloading". + if (!EXTRACTED_LAYER_STATES.has(layers.get(id) ?? "")) { + layers.set(id, state); + recordLayerDownload(layerDownloads, id, state, progress); + downloadSamples = addByteSample(downloadSamples, { + at: Date.now(), + bytes: downloadedBytes(layerDownloads), + }); + } + updateLayerCounts(); +}; + const handleOutputLine = (rawLine: string) => { const line = rawLine.trim(); if (!line) { @@ -131,20 +160,11 @@ const handleOutputLine = (rawLine: string) => { const layer = parsePullLine(line); if (layer && status.phase === "pulling") { - const { id, status: state } = layer; - // Docker repeats a layer's earlier states while it is retried, so a - // layer that already finished must not go back to "downloading". - if (!EXTRACTED_LAYER_STATES.has(layers.get(id) ?? "")) { - layers.set(id, state); - recordLayerDownload(layerDownloads, id, state); - downloadSamples = addByteSample(downloadSamples, { - at: Date.now(), - bytes: downloadedBytes(layerDownloads), - }); - } - status.layersTotal = layers.size; - status.layersDownloaded = countLayers(DOWNLOADED_LAYER_STATES); - status.layersExtracted = countLayers(EXTRACTED_LAYER_STATES); + recordLayerState( + layer.id, + layer.status, + parseDownloadProgress(layer.status), + ); } status.output = [ @@ -162,6 +182,39 @@ const handleOutput = (chunk: string) => { } }; +const handleEnginePullEvent = (event: EnginePullEvent) => { + if ( + status.phase !== "pulling" || + !event.id || + !event.status || + !isLayerId(event.id) + ) { + return; + } + recordLayerState(event.id, event.status, parseEngineDownloadProgress(event)); +}; + +// A failed engine pull leaves partial layer states and bytes behind; dropping +// them keeps the layer share to what the update script reports. +const clearLayerProgress = () => { + layers = new Map(); + layerDownloads = new Map(); + downloadSamples = [{ at: status.startedAt ?? Date.now(), bytes: 0 }]; + updateLayerCounts(); +}; + +const pullThroughEngine = async (image: string) => { + try { + await pullImageThroughEngine(image, handleEnginePullEvent); + } catch (error) { + clearLayerProgress(); + console.error( + `Could not pull ${image} through the Docker API; the update script will pull it.`, + error, + ); + } +}; + const finish = (error: string | null) => { if (pendingOutput) { handleOutputLine(pendingOutput); @@ -176,23 +229,7 @@ const finish = (error: string | null) => { } }; -/** - * Runs an update script from `getAgentHitsUpdateCommand` or - * `getOfficialUpdateCommand` in the background and tracks it in - * `getServerUpdateStatus`. Returns false when an update is already running. - */ -export const startServerUpdate = (command: string) => { - if (isServerUpdateRunning()) { - return false; - } - - resetServerUpdateStatus(); - status.phase = "pulling"; - const startedAt = Date.now(); - status.startedAt = startedAt; - // The first progress line is measured against zero bytes at the start. - downloadSamples = [{ at: startedAt, bytes: 0 }]; - +const runUpdateCommand = (command: string) => { spawnAsync("sh", ["-c", command], handleOutput).then( () => finish(null), (error: unknown) => { @@ -211,6 +248,36 @@ export const startServerUpdate = (command: string) => { } }, ); +}; + +/** + * Runs an update script from `getAgentHitsUpdateCommand` or + * `getOfficialUpdateCommand` in the background and tracks it in + * `getServerUpdateStatus`. Returns false when an update is already running. + * + * With `image`, that image is pulled through the Docker Engine API first, since + * the script's piped output carries no byte counts. The script's own pull then + * finds the image present, or pulls it itself if the engine pull failed. + */ +export const startServerUpdate = (command: string, image?: string) => { + if (isServerUpdateRunning()) { + return false; + } + + resetServerUpdateStatus(); + status.phase = "pulling"; + const startedAt = Date.now(); + status.startedAt = startedAt; + // The first progress line is measured against zero bytes at the start. + downloadSamples = [{ at: startedAt, bytes: 0 }]; + + if (image) { + pullThroughEngine(image) + .then(() => runUpdateCommand(command)) + .catch(() => finish("Could not start the update script.")); + } else { + runUpdateCommand(command); + } return true; }; diff --git a/packages/server/src/utils/docker/engine-pull.ts b/packages/server/src/utils/docker/engine-pull.ts new file mode 100644 index 00000000000..847f3806450 --- /dev/null +++ b/packages/server/src/utils/docker/engine-pull.ts @@ -0,0 +1,54 @@ +import { docker } from "../../constants"; +import type { EnginePullEvent } from "./pull-progress"; + +export interface EnginePullClient { + pull(repoTag: string): Promise; + modem: { + followProgress( + stream: NodeJS.ReadableStream, + onFinished: (error: Error | null, events: EnginePullEvent[]) => void, + onProgress: (event: EnginePullEvent) => void, + ): void; + }; +} + +// The modem reports only stream failures, so an error event inside a +// successful stream would otherwise look like a pull that worked. +const findReportedError = (events: EnginePullEvent[]) => { + const failure = events.find( + (event) => event.error !== undefined || event.errorDetail !== undefined, + ); + if (!failure) { + return null; + } + return new Error( + failure.error ?? + failure.errorDetail?.message ?? + "Docker could not pull the image.", + ); +}; + +export const pullImageThroughEngine = async ( + image: string, + onEvent: (event: EnginePullEvent) => void, + client: EnginePullClient = docker, +) => { + const stream = await client.pull(image); + const events = await new Promise((resolve, reject) => { + client.modem.followProgress( + stream, + (error, output) => { + if (error) { + reject(error); + return; + } + resolve(output); + }, + onEvent, + ); + }); + const reported = findReportedError(events); + if (reported) { + throw reported; + } +}; diff --git a/packages/server/src/utils/docker/pull-progress.ts b/packages/server/src/utils/docker/pull-progress.ts index 27cdaac2ac6..d6aa6a3714c 100644 --- a/packages/server/src/utils/docker/pull-progress.ts +++ b/packages/server/src/utils/docker/pull-progress.ts @@ -13,10 +13,19 @@ export interface DownloadSummary { remainingBytes: number | null; } +export interface EnginePullEvent { + id?: string; + status?: string; + progressDetail?: { current?: unknown; total?: unknown }; + error?: string; + errorDetail?: { message?: string }; +} + const RATE_WINDOW_MS = 5_000; const RATE_MIN_WINDOW_MS = 1_000; const PULL_LINE = /^([0-9a-f]{12,64}): (.+)$/; +const LAYER_ID = /^[0-9a-f]{12,64}$/; // Docker prints decimal units: kB is 1000 bytes, not 1024. const UNIT_BYTES: Record = { @@ -36,6 +45,8 @@ const FINISHED_STATUSES = new Set([ "Already exists", ]); +export const isLayerId = (id: string) => LAYER_ID.test(id); + export const parsePullLine = (line: string) => { const [, id, status] = PULL_LINE.exec(line) ?? []; return id !== undefined && status !== undefined ? { id, status } : null; @@ -59,6 +70,27 @@ export const parseDownloadProgress = (status: string): LayerDownload | null => { return { current, total: toBytes(totalAmount, totalUnit) }; }; +const isKnownAmount = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value) && value >= 0; + +// Extracting events carry extraction progress, not download bytes. +export const parseEngineDownloadProgress = ( + event: EnginePullEvent, +): LayerDownload | null => { + if (event.status !== "Downloading") { + return null; + } + const current = event.progressDetail?.current; + const total = event.progressDetail?.total; + if (!isKnownAmount(current)) { + return null; + } + return { + current: Math.round(current), + total: isKnownAmount(total) && total > 0 ? Math.round(total) : null, + }; +}; + export const isDownloadFinished = (status: string) => FINISHED_STATUSES.has(status) || status.startsWith("Extracting"); @@ -66,9 +98,9 @@ export const recordLayerDownload = ( downloads: Map, id: string, status: string, + progress: LayerDownload | null, ) => { const previous = downloads.get(id); - const progress = parseDownloadProgress(status); if (progress) { const total = progress.total ?? previous?.total ?? null; const current = Math.max(previous?.current ?? 0, progress.current); From 39b74a6d4a5d1c99797f6d26f420ad4e813b61e9 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:05:10 -0400 Subject: [PATCH 4/5] feat(update): pass the update image to the update runner The router passes the image of the chosen update source, so the engine pull can run before the update script. The script's command is unchanged; the official image reference moves into a helper that the command reuses. Co-Authored-By: Claude Opus 5.5 --- .../__test__/settings/docker-server-boundary.test.ts | 6 ++++++ apps/dokploy/server/api/routers/settings.ts | 5 +++++ packages/server/src/services/settings.ts | 5 ++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/dokploy/__test__/settings/docker-server-boundary.test.ts b/apps/dokploy/__test__/settings/docker-server-boundary.test.ts index 32ae0e340ef..724f408d15e 100644 --- a/apps/dokploy/__test__/settings/docker-server-boundary.test.ts +++ b/apps/dokploy/__test__/settings/docker-server-boundary.test.ts @@ -24,7 +24,9 @@ const mocks = vi.hoisted(() => ({ generateOpenApiDocument: vi.fn(), getAccessibleServerIds: vi.fn(), getAgentHitsUpdateCommand: vi.fn(), + getAgentHitsUpdateImage: vi.fn(() => "update image"), getOfficialUpdateCommand: vi.fn(), + getOfficialUpdateImage: vi.fn(() => "official image"), getDockerDiskUsage: vi.fn(), getDokployImageTag: vi.fn(), getDokployVersionData: vi.fn(), @@ -97,6 +99,8 @@ vi.mock("@dokploy/server", async () => ({ getAccessibleServerIds: mocks.getAccessibleServerIds, getAgentHitsUpdateCommand: mocks.getAgentHitsUpdateCommand, getOfficialUpdateCommand: mocks.getOfficialUpdateCommand, + getAgentHitsUpdateImage: mocks.getAgentHitsUpdateImage, + getOfficialUpdateImage: mocks.getOfficialUpdateImage, getDockerDiskUsage: mocks.getDockerDiskUsage, getDokployImageTag: mocks.getDokployImageTag, getDokployVersionData: mocks.getDokployVersionData, @@ -473,6 +477,7 @@ describe("settings Docker server boundary", () => { ); expect(mocks.startServerUpdate).toHaveBeenCalledWith( "agenthits update command", + "update image", ); }); @@ -524,6 +529,7 @@ describe("settings Docker server boundary", () => { ); expect(mocks.startServerUpdate).toHaveBeenCalledWith( "official update command", + "official image", ); expect(mocks.getAgentHitsUpdateCommand).not.toHaveBeenCalled(); }); diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index 9c21426da19..2f820c6dd13 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -19,6 +19,7 @@ import { findServerById, getAccessibleServerIds, getAgentHitsUpdateCommand, + getAgentHitsUpdateImage, getDockerDiskUsage, getDokployImageKeepCount, getDokployImages, @@ -26,6 +27,7 @@ import { getDokployVersionData, getLogCleanupStatus, getOfficialUpdateCommand, + getOfficialUpdateImage, getServerUpdateStatus, getUpdateData, getUpdateDiskSpace, @@ -669,6 +671,9 @@ export const settingsRouter = createTRPCRouter({ keepImages, ) : getOfficialUpdateCommand(data.latestVersion ?? "", keepImages), + data.updateSource === "agenthits" + ? getAgentHitsUpdateImage() + : getOfficialUpdateImage(data.latestVersion ?? ""), ); await audit(ctx, { action: "update", diff --git a/packages/server/src/services/settings.ts b/packages/server/src/services/settings.ts index dd5d813d496..291d8f81c4a 100644 --- a/packages/server/src/services/settings.ts +++ b/packages/server/src/services/settings.ts @@ -656,11 +656,14 @@ docker service update --force \\ `; }; +export const getOfficialUpdateImage = (version: string) => + `dokploy/dokploy:${version}`; + export const getOfficialUpdateCommand = ( version: string, keepImages?: number | null, ) => { - const image = quoteShellArg(`dokploy/dokploy:${version}`); + const image = quoteShellArg(getOfficialUpdateImage(version)); const keepImagesArg = keepImages === undefined ? "" : getDokployKeepImagesEnvArg(keepImages); From e0b831ef22c719a047ca885a654b37804b75f467 Mon Sep 17 00:00:00 2001 From: merge-check <140916359+agentHits@users.noreply.github.com> Date: Fri, 9 Oct 2026 02:20:50 -0400 Subject: [PATCH 5/5] fix(update): keep the progress monotonic and stop a stalled pull - The displayed percent no longer goes backwards when a large layer gets its size after smaller ones finished. - The progress bar exposes its value to assistive technology. - An engine pull with no event for 90 seconds is cancelled and the update falls back to the script pull. The timer is cleared on every exit. - The engine pull is skipped when a Docker host is set in the environment, because the script's docker CLI may then use a different daemon. - At 100% the panel shows "Finishing" instead of a decaying speed. Tests cover the monotonic percent, the rendered aria value, the idle timeout with fake timers, and the remote host skip. Co-Authored-By: Claude Sonnet 5.5 --- .../__test__/settings/engine-pull.test.ts | 38 +++++++++ .../settings/update-engine-pull.test.ts | 84 +++++++++++++++++++ .../settings/update-status-panel.test.ts | 22 +++++ .../settings/web-server-update-status.test.ts | 2 +- .../web-server/update-status-panel.tsx | 10 ++- apps/dokploy/components/ui/progress.tsx | 1 + .../server/src/services/web-server-update.ts | 19 ++++- .../server/src/utils/docker/engine-pull.ts | 29 ++++++- .../server/src/utils/docker/pull-progress.ts | 19 ++++- 9 files changed, 214 insertions(+), 10 deletions(-) diff --git a/apps/dokploy/__test__/settings/engine-pull.test.ts b/apps/dokploy/__test__/settings/engine-pull.test.ts index 2b317309a62..00382fe43a8 100644 --- a/apps/dokploy/__test__/settings/engine-pull.test.ts +++ b/apps/dokploy/__test__/settings/engine-pull.test.ts @@ -86,6 +86,44 @@ describe("pullImageThroughEngine", () => { ).rejects.toThrow("no such image"); }); + it("gives up after 90 seconds without an event and destroys the stream", async () => { + vi.useFakeTimers(); + try { + const stream = Readable.from([]); + const destroy = vi.spyOn(stream, "destroy"); + let reportProgress: (event: EnginePullEvent) => void = () => {}; + const client = { + pull: vi.fn(async () => stream), + modem: { + followProgress: ( + _stream: NodeJS.ReadableStream, + _onFinished: unknown, + onProgress: (event: EnginePullEvent) => void, + ) => { + reportProgress = onProgress; + }, + }, + }; + const onEvent = vi.fn(); + const outcome = expect( + pullImageThroughEngine(IMAGE, onEvent, client), + ).rejects.toThrow("no pull progress for 90 seconds"); + + await vi.advanceTimersByTimeAsync(60_000); + reportProgress({ status: "Pulling fs layer" }); + await vi.advanceTimersByTimeAsync(89_999); + expect(destroy).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + + await outcome; + expect(onEvent).toHaveBeenCalledTimes(1); + expect(destroy).toHaveBeenCalledOnce(); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + it("rejects when the stream fails", async () => { const client = clientReplaying(RECORDED_PULL, { streamError: new Error("socket hang up"), diff --git a/apps/dokploy/__test__/settings/update-engine-pull.test.ts b/apps/dokploy/__test__/settings/update-engine-pull.test.ts index c5f7856eb4a..fe0b622fb7d 100644 --- a/apps/dokploy/__test__/settings/update-engine-pull.test.ts +++ b/apps/dokploy/__test__/settings/update-engine-pull.test.ts @@ -107,6 +107,44 @@ const PARTIAL_PULL: Recorded[] = [ ], ]; +const layerEvent = ( + id: string, + status: string, + progressDetail: Record = {}, +) => JSON.stringify({ id, status, progressDetail }); + +const SMALL_LAYERS = [ + "aaaaaaaaaaaa", + "bbbbbbbbbbbb", + "cccccccccccc", + "dddddddddddd", +]; +const LATE_LAYER = "eeeeeeeeeeee"; + +// The byte share drops once the late layer is sized, which the shown percent +// must not follow. +const LATE_SIZE_PULL: Recorded[] = [ + ...SMALL_LAYERS.map( + (id): Recorded => [10_200, layerEvent(id, "Pulling fs layer")], + ), + [10_200, layerEvent(LATE_LAYER, "Pulling fs layer")], + ...SMALL_LAYERS.flatMap((id, index): Recorded[] => [ + [ + 11_000 + index, + layerEvent(id, "Downloading", { current: 1_000_000, total: 1_000_000 }), + ], + [11_100 + index, layerEvent(id, "Pull complete")], + ]), + [12_000, layerEvent(LATE_LAYER, "Downloading", { current: 1_000_000 })], + [ + 12_100, + layerEvent(LATE_LAYER, "Downloading", { + current: 5_000_000, + total: 500_000_000, + }), + ], +]; + const replayPull = (recording: Recorded[], failure?: Error) => { pullImageThroughEngine.mockImplementation( async (_image: string, onEvent: (event: EnginePullEvent) => void) => { @@ -130,11 +168,15 @@ describe("update through the Docker Engine API", () => { vi.setSystemTime(START); resetServerUpdateStatus(); spawnAsync.mockImplementation(() => new Promise(() => {})); + // Keeps a Docker host from the developer's shell from turning off the engine pull. + vi.stubEnv("DOCKER_HOST", ""); + vi.stubEnv("DOKPLOY_DOCKER_HOST", ""); }); afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); + vi.unstubAllEnvs(); }); it("reports bytes, speed and time left from a recorded pull, then runs the script", async () => { @@ -239,6 +281,48 @@ describe("update through the Docker Engine API", () => { }); }); + it("does not step the shown percent back when a late layer gets its size", async () => { + const shown: number[] = []; + pullImageThroughEngine.mockImplementation( + async (_image: string, onEvent: (event: EnginePullEvent) => void) => { + for (const [at, line] of LATE_SIZE_PULL) { + vi.setSystemTime(at); + onEvent(JSON.parse(line) as EnginePullEvent); + const percent = getServerUpdateStatus().downloadPercent; + if (percent !== null) { + shown.push(percent); + } + } + }, + ); + + startServerUpdate(COMMAND, IMAGE); + await settle(); + + expect(shown.at(-1)).toBe(80); + expect([...shown].sort((a, b) => a - b)).toEqual(shown); + }); + + it.each([ + ["DOCKER_HOST", "tcp://docker.example.com:2376"], + ["DOKPLOY_DOCKER_HOST", "docker.example.com"], + ])( + "runs the script without the engine pull when %s is set", + (name, value) => { + vi.stubEnv(name, value); + + startServerUpdate(COMMAND, IMAGE); + + expect(pullImageThroughEngine).not.toHaveBeenCalled(); + expect(spawnAsync).toHaveBeenCalledWith( + "sh", + ["-c", COMMAND], + expect.any(Function), + ); + expect(getServerUpdateStatus().downloadPercent).toBeNull(); + }, + ); + it("runs the script directly when no image is given", () => { startServerUpdate(COMMAND); diff --git a/apps/dokploy/__test__/settings/update-status-panel.test.ts b/apps/dokploy/__test__/settings/update-status-panel.test.ts index 8c2e9f8c587..865891eac20 100644 --- a/apps/dokploy/__test__/settings/update-status-panel.test.ts +++ b/apps/dokploy/__test__/settings/update-status-panel.test.ts @@ -61,6 +61,28 @@ describe("update status panel", () => { expect(html).toContain("Time left unknown"); }); + it("exposes the download percentage as the bar value", () => { + const html = render(serverStatus()); + const bar = html.match(/]*role="progressbar"[^>]*>/)?.[0]; + + expect(bar).toContain('aria-valuenow="36"'); + }); + + it("shows finishing instead of speed and time left at 100 percent", () => { + const html = render( + serverStatus({ + downloadPercent: 100, + downloadBytesPerSecond: 0, + downloadRemainingSeconds: 0, + }), + ); + + expect(html).toContain("100% downloaded"); + expect(html).toContain("Finishing"); + expect(html).not.toContain("Speed"); + expect(html).not.toContain("Time left"); + }); + it("hides the download bar once the image is pulled", () => { const html = render(serverStatus({ phase: "updating", pulledAt: 5_000 })); diff --git a/apps/dokploy/__test__/settings/web-server-update-status.test.ts b/apps/dokploy/__test__/settings/web-server-update-status.test.ts index bf0a7d0256f..fdf3531c07a 100644 --- a/apps/dokploy/__test__/settings/web-server-update-status.test.ts +++ b/apps/dokploy/__test__/settings/web-server-update-status.test.ts @@ -247,7 +247,7 @@ describe("web server update status", () => { script.write("cccccccccccc: Pulling fs layer\n"); expect(getServerUpdateStatus()).toMatchObject({ - downloadPercent: 33, + downloadPercent: 84, downloadRemainingSeconds: null, }); } finally { diff --git a/apps/dokploy/components/dashboard/settings/web-server/update-status-panel.tsx b/apps/dokploy/components/dashboard/settings/web-server/update-status-panel.tsx index b5023307420..47785f0c40d 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/update-status-panel.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/update-status-panel.tsx @@ -69,8 +69,14 @@ const DownloadProgress = ({ />
{percent}% downloaded - Speed {formatSpeed(bytesPerSecond)} - Time left {formatRemaining(remainingSeconds)} + {percent >= 100 ? ( + Finishing + ) : ( + <> + Speed {formatSpeed(bytesPerSecond)} + Time left {formatRemaining(remainingSeconds)} + + )}
); diff --git a/apps/dokploy/components/ui/progress.tsx b/apps/dokploy/components/ui/progress.tsx index 5dc058c759e..3cfbb33fab9 100644 --- a/apps/dokploy/components/ui/progress.tsx +++ b/apps/dokploy/components/ui/progress.tsx @@ -11,6 +11,7 @@ function Progress({ return ( ({ let status = createIdleStatus(); let layers = new Map(); let layerDownloads = new Map(); +let downloadPercentHighWater: number | null = null; let downloadSamples: readonly ByteSample[] = []; let pendingOutput = ""; const getDownloadEstimates = (now: number) => { - const { percent, remainingBytes } = summarizeDownload(layers, layerDownloads); + const { percent, remainingBytes } = summarizeDownload( + layers, + layerDownloads, + downloadPercentHighWater, + ); + downloadPercentHighWater = percent; // Without any byte counts the rate would read as zero instead of unknown. const bytesPerSecond = layerDownloads.size > 0 ? downloadRate(downloadSamples, now) : null; @@ -99,6 +105,7 @@ export const resetServerUpdateStatus = () => { status = createIdleStatus(); layers = new Map(); layerDownloads = new Map(); + downloadPercentHighWater = null; downloadSamples = []; pendingOutput = ""; }; @@ -250,6 +257,11 @@ const runUpdateCommand = (command: string) => { ); }; +// Engine progress only describes the script's pull when both use the local daemon, +// and a configured Docker host may not be that daemon. +const hasRemoteDockerHost = () => + Boolean(process.env.DOKPLOY_DOCKER_HOST || process.env.DOCKER_HOST); + /** * Runs an update script from `getAgentHitsUpdateCommand` or * `getOfficialUpdateCommand` in the background and tracks it in @@ -257,7 +269,8 @@ const runUpdateCommand = (command: string) => { * * With `image`, that image is pulled through the Docker Engine API first, since * the script's piped output carries no byte counts. The script's own pull then - * finds the image present, or pulls it itself if the engine pull failed. + * finds the image present, or pulls it itself if the engine pull failed. The + * engine pull is skipped when a Docker host is configured through the environment. */ export const startServerUpdate = (command: string, image?: string) => { if (isServerUpdateRunning()) { @@ -271,7 +284,7 @@ export const startServerUpdate = (command: string, image?: string) => { // The first progress line is measured against zero bytes at the start. downloadSamples = [{ at: startedAt, bytes: 0 }]; - if (image) { + if (image && !hasRemoteDockerHost()) { pullThroughEngine(image) .then(() => runUpdateCommand(command)) .catch(() => finish("Could not start the update script.")); diff --git a/packages/server/src/utils/docker/engine-pull.ts b/packages/server/src/utils/docker/engine-pull.ts index 847f3806450..1303080155c 100644 --- a/packages/server/src/utils/docker/engine-pull.ts +++ b/packages/server/src/utils/docker/engine-pull.ts @@ -1,6 +1,11 @@ +import type { Readable } from "node:stream"; import { docker } from "../../constants"; import type { EnginePullEvent } from "./pull-progress"; +// Docker reports progress several times a second while it moves bytes, so a +// longer silence means the connection stalled. +const ENGINE_PULL_IDLE_MS = 90_000; + export interface EnginePullClient { pull(repoTag: string): Promise; modem: { @@ -28,13 +33,30 @@ const findReportedError = (events: EnginePullEvent[]) => { ); }; +const destroyStream = (stream: NodeJS.ReadableStream) => { + (stream as Partial).destroy?.(); +}; + export const pullImageThroughEngine = async ( image: string, onEvent: (event: EnginePullEvent) => void, client: EnginePullClient = docker, ) => { const stream = await client.pull(image); + let idleTimer: ReturnType | undefined; const events = await new Promise((resolve, reject) => { + const armIdleTimer = () => { + clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + reject( + new Error( + `Docker sent no pull progress for ${ENGINE_PULL_IDLE_MS / 1000} seconds.`, + ), + ); + destroyStream(stream); + }, ENGINE_PULL_IDLE_MS); + }; + armIdleTimer(); client.modem.followProgress( stream, (error, output) => { @@ -44,9 +66,12 @@ export const pullImageThroughEngine = async ( } resolve(output); }, - onEvent, + (event) => { + armIdleTimer(); + onEvent(event); + }, ); - }); + }).finally(() => clearTimeout(idleTimer)); const reported = findReportedError(events); if (reported) { throw reported; diff --git a/packages/server/src/utils/docker/pull-progress.ts b/packages/server/src/utils/docker/pull-progress.ts index d6aa6a3714c..42a9b762e0f 100644 --- a/packages/server/src/utils/docker/pull-progress.ts +++ b/packages/server/src/utils/docker/pull-progress.ts @@ -126,9 +126,19 @@ export const downloadedBytes = ( return bytes; }; +// The byte share can fall when a layer gets its size, and the bar must not step back. +const keepHighWater = ( + percent: number | null, + highWaterPercent: number | null, +) => + percent === null + ? highWaterPercent + : Math.max(percent, highWaterPercent ?? percent); + export const summarizeDownload = ( layerStates: ReadonlyMap, downloads: ReadonlyMap, + highWaterPercent: number | null = null, ): DownloadSummary => { let finishedLayers = 0; let hasUnsizedUnfinishedLayer = false; @@ -151,15 +161,20 @@ export const summarizeDownload = ( } if (totalBytes > 0 && !hasUnsizedUnfinishedLayer) { return { - percent: Math.floor((doneBytes / totalBytes) * 100), + percent: keepHighWater( + Math.floor((doneBytes / totalBytes) * 100), + highWaterPercent, + ), remainingBytes: totalBytes - doneBytes, }; } return { - percent: + percent: keepHighWater( layerStates.size > 0 ? Math.floor((finishedLayers / layerStates.size) * 100) : null, + highWaterPercent, + ), remainingBytes: null, }; };