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/__test__/settings/engine-pull.test.ts b/apps/dokploy/__test__/settings/engine-pull.test.ts new file mode 100644 index 00000000000..00382fe43a8 --- /dev/null +++ b/apps/dokploy/__test__/settings/engine-pull.test.ts @@ -0,0 +1,136 @@ +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("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"), + }); + + 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 new file mode 100644 index 00000000000..86f0f1e18a0 --- /dev/null +++ b/apps/dokploy/__test__/settings/pull-progress.test.ts @@ -0,0 +1,255 @@ +import { + addByteSample, + type ByteSample, + downloadRate, + estimateRemainingSeconds, + isDownloadFinished, + isLayerId, + type LayerDownload, + parseDownloadProgress, + parseEngineDownloadProgress, + 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, + parseDownloadProgress(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"), + ).toBeNull(); + 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", + parseDownloadProgress("Downloading [=====> ] 4MB/10MB"), + ); + recordLayerDownload(downloads, "4ac6ba6b019e", "Download complete", null); + + 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", + parseDownloadProgress("Downloading 4MB"), + ); + recordLayerDownload(downloads, "9d3c1e7a2b40", "Download complete", null); + + expect(downloads.get("9d3c1e7a2b40")).toEqual({ + current: 4_000_000, + total: null, + }); + }); + + 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"); + + 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-engine-pull.test.ts b/apps/dokploy/__test__/settings/update-engine-pull.test.ts new file mode 100644 index 00000000000..fe0b622fb7d --- /dev/null +++ b/apps/dokploy/__test__/settings/update-engine-pull.test.ts @@ -0,0 +1,336 @@ +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 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) => { + 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(() => {})); + // 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 () => { + 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("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); + + expect(pullImageThroughEngine).not.toHaveBeenCalled(); + expect(spawnAsync).toHaveBeenCalledWith( + "sh", + ["-c", COMMAND], + expect.any(Function), + ); + }); +}); 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/update-status-panel.test.ts b/apps/dokploy/__test__/settings/update-status-panel.test.ts new file mode 100644 index 00000000000..865891eac20 --- /dev/null +++ b/apps/dokploy/__test__/settings/update-status-panel.test.ts @@ -0,0 +1,92 @@ +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("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 })); + + expect(html).not.toContain("% downloaded"); + expect(html).toContain("Image downloaded, waiting for Dokploy to stop"); + }); +}); 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..fdf3531c07a 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: 84, + 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/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..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 @@ -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,35 @@ const StateIcon = ({ state }: { state: UpdateStepState }) => { } }; +const DownloadProgress = ({ + percent, + bytesPerSecond, + remainingSeconds, +}: { + percent: number; + bytesPerSecond: number | null; + remainingSeconds: number | null; +}) => ( +
+ +
+ {percent}% downloaded + {percent >= 100 ? ( + Finishing + ) : ( + <> + Speed {formatSpeed(bytesPerSecond)} + Time left {formatRemaining(remainingSeconds)} + + )} +
+
+); + const getDownloadDetails = ( progress: UpdateProgress, state: UpdateStepState, @@ -78,6 +110,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 +207,7 @@ export const UpdateStatusPanel = ({ aria-current={state === "active" ? "step" : undefined} > -
+
{details.map((detail, index) => ( - +
{detail} - +
))}
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 ( + `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); diff --git a/packages/server/src/services/web-server-update.ts b/packages/server/src/services/web-server-update.ts index 9e3ba895de0..149c4fe58c3 100644 --- a/packages/server/src/services/web-server-update.ts +++ b/packages/server/src/services/web-server-update.ts @@ -1,3 +1,19 @@ +import { pullImageThroughEngine } from "../utils/docker/engine-pull"; +import { + addByteSample, + type ByteSample, + downloadedBytes, + downloadRate, + type EnginePullEvent, + estimateRemainingSeconds, + isLayerId, + type LayerDownload, + parseDownloadProgress, + parseEngineDownloadProgress, + 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 +35,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 +59,9 @@ const createIdleStatus = (): ServerUpdateStatus => ({ layersTotal: 0, layersDownloaded: 0, layersExtracted: 0, + downloadPercent: null, + downloadBytesPerSecond: null, + downloadRemainingSeconds: null, diskFull: false, output: [], }); @@ -45,23 +70,49 @@ 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 downloadPercentHighWater: number | null = null; +let downloadSamples: readonly ByteSample[] = []; let pendingOutput = ""; +const getDownloadEstimates = (now: number) => { + 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; + 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(); + downloadPercentHighWater = null; + 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", @@ -72,6 +123,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) { @@ -90,17 +165,13 @@ 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]; - // 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); - } - 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 = [ @@ -118,6 +189,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); @@ -132,20 +236,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"; - status.startedAt = Date.now(); - +const runUpdateCommand = (command: string) => { spawnAsync("sh", ["-c", command], handleOutput).then( () => finish(null), (error: unknown) => { @@ -164,6 +255,42 @@ export const startServerUpdate = (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 + * `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. The + * engine pull is skipped when a Docker host is configured through the environment. + */ +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 && !hasRemoteDockerHost()) { + 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..1303080155c --- /dev/null +++ b/packages/server/src/utils/docker/engine-pull.ts @@ -0,0 +1,79 @@ +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: { + 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.", + ); +}; + +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) => { + if (error) { + reject(error); + return; + } + resolve(output); + }, + (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 new file mode 100644 index 00000000000..42a9b762e0f --- /dev/null +++ b/packages/server/src/utils/docker/pull-progress.ts @@ -0,0 +1,236 @@ +export interface LayerDownload { + current: number; + total: number | null; +} + +export interface ByteSample { + at: number; + bytes: number; +} + +export interface DownloadSummary { + percent: number | null; + 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 = { + 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 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; +}; + +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) }; +}; + +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"); + +export const recordLayerDownload = ( + downloads: Map, + id: string, + status: string, + progress: LayerDownload | null, +) => { + const previous = downloads.get(id); + 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; +}; + +// 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; + 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: keepHighWater( + Math.floor((doneBytes / totalBytes) * 100), + highWaterPercent, + ), + remainingBytes: totalBytes - doneBytes, + }; + } + return { + percent: keepHighWater( + layerStates.size > 0 + ? Math.floor((finishedLayers / layerStates.size) * 100) + : null, + highWaterPercent, + ), + 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; +};