From 764ff0341e0f397dfe014f1f052d8c1fd9364016 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Fri, 9 Jan 2026 15:21:51 -0500 Subject: [PATCH 1/3] feat(docker): add real-time container update progress streaming Implement GraphQL subscription for streaming Docker container update progress in real-time. This replaces the static spinner during container updates with detailed progress information including per-layer download progress. Backend: - Add DockerUpdateProgress GraphQL type with layer progress details - Add DockerUpdateProgressService to parse docker pull output and stream events - Add dockerUpdateProgress subscription to DockerResolver - Integrate progress tracking into DockerService.updateContainer() Frontend: - Add useDockerUpdateProgress composable for subscription management - Add DockerUpdateProgressModal component showing update progress - Integrate progress modal into DockerContainersTable - Add onUpdateStart/onUpdateComplete callbacks to useDockerUpdateActions Co-Authored-By: Claude Opus 4.5 --- api/dev/configs/api.json | 9 - api/generated-schema.graphql | 61 ++++ .../docker/docker-update-progress.model.ts | 71 ++++ .../docker/docker-update-progress.service.ts | 329 ++++++++++++++++++ .../graph/resolvers/docker/docker.module.ts | 2 + .../graph/resolvers/docker/docker.resolver.ts | 14 + .../resolvers/docker/docker.service.spec.ts | 15 + .../graph/resolvers/docker/docker.service.ts | 14 +- .../src/pubsub/graphql.pubsub.ts | 1 + .../Docker/DockerContainersTable.vue | 28 +- .../Docker/DockerUpdateProgressModal.vue | 189 ++++++++++ .../docker-update-progress.subscription.ts | 22 ++ web/src/composables/gql/gql.ts | 6 + web/src/composables/gql/graphql.ts | 57 +++ web/src/composables/useDockerUpdateActions.ts | 27 ++ .../composables/useDockerUpdateProgress.ts | 180 ++++++++++ 16 files changed, 1004 insertions(+), 21 deletions(-) create mode 100644 api/src/unraid-api/graph/resolvers/docker/docker-update-progress.model.ts create mode 100644 api/src/unraid-api/graph/resolvers/docker/docker-update-progress.service.ts create mode 100644 web/src/components/Docker/DockerUpdateProgressModal.vue create mode 100644 web/src/components/Docker/docker-update-progress.subscription.ts create mode 100644 web/src/composables/useDockerUpdateProgress.ts diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index f4f76b8f55..e69de29bb2 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -1,9 +0,0 @@ -{ - "version": "4.29.2", - "extraOrigins": [], - "sandbox": true, - "ssoSubIds": [], - "plugins": [ - "unraid-api-plugin-connect" - ] -} \ No newline at end of file diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 8e18b766be..8274f3c7a7 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1355,6 +1355,64 @@ type Docker implements Node { containerUpdateStatuses: [ExplicitStatusItem!]! } +"""Progress information for a single image layer""" +type DockerLayerProgress { + """Layer ID (short hash)""" + layerId: String! + + """Current status of the layer""" + status: String! + + """Download/extract progress percentage (0-100)""" + progress: Float + + """Bytes downloaded/processed""" + current: Int + + """Total bytes for this layer""" + total: Int +} + +"""Real-time progress update for a Docker container update operation""" +type DockerUpdateProgress { + """Container ID being updated""" + containerId: PrefixedID! + + """Container name being updated""" + containerName: String! + + """Type of progress event""" + type: DockerUpdateEventType! + + """Human-readable message or log line""" + message: String + + """Layer ID for layer-specific events""" + layerId: String + + """Overall progress percentage (0-100) for the current operation""" + overallProgress: Float + + """Per-layer progress details""" + layers: [DockerLayerProgress!] + + """Error message if type is ERROR""" + error: String +} + +"""Type of Docker update progress event""" +enum DockerUpdateEventType { + STARTED + LAYER_DOWNLOADING + LAYER_EXTRACTING + LAYER_COMPLETE + LAYER_ALREADY_EXISTS + PULLING + LOG + COMPLETE + ERROR +} + type DockerTemplateSyncResult { scanned: Int! matched: Int! @@ -2898,6 +2956,9 @@ type Subscription { parityHistorySubscription: ParityCheck! arraySubscription: UnraidArray! dockerContainerStats: DockerContainerStats! + + """Real-time progress updates for Docker container update operations""" + dockerUpdateProgress: DockerUpdateProgress! logFile(path: String!): LogFileContent! systemMetricsCpu: CpuUtilization! systemMetricsCpuTelemetry: CpuPackages! diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-update-progress.model.ts b/api/src/unraid-api/graph/resolvers/docker/docker-update-progress.model.ts new file mode 100644 index 0000000000..7ebe2eb921 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-update-progress.model.ts @@ -0,0 +1,71 @@ +import { Field, Float, Int, ObjectType, registerEnumType } from '@nestjs/graphql'; + +import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; + +export enum DockerUpdateEventType { + STARTED = 'STARTED', + LAYER_DOWNLOADING = 'LAYER_DOWNLOADING', + LAYER_EXTRACTING = 'LAYER_EXTRACTING', + LAYER_COMPLETE = 'LAYER_COMPLETE', + LAYER_ALREADY_EXISTS = 'LAYER_ALREADY_EXISTS', + PULLING = 'PULLING', + LOG = 'LOG', + COMPLETE = 'COMPLETE', + ERROR = 'ERROR', +} + +registerEnumType(DockerUpdateEventType, { + name: 'DockerUpdateEventType', + description: 'Type of Docker update progress event', +}); + +@ObjectType({ description: 'Progress information for a single image layer' }) +export class DockerLayerProgress { + @Field(() => String, { description: 'Layer ID (short hash)' }) + layerId!: string; + + @Field(() => String, { description: 'Current status of the layer' }) + status!: string; + + @Field(() => Float, { nullable: true, description: 'Download/extract progress percentage (0-100)' }) + progress?: number; + + @Field(() => Int, { nullable: true, description: 'Bytes downloaded/processed' }) + current?: number; + + @Field(() => Int, { nullable: true, description: 'Total bytes for this layer' }) + total?: number; +} + +@ObjectType({ description: 'Real-time progress update for a Docker container update operation' }) +export class DockerUpdateProgress { + @Field(() => PrefixedID, { description: 'Container ID being updated' }) + containerId!: string; + + @Field(() => String, { description: 'Container name being updated' }) + containerName!: string; + + @Field(() => DockerUpdateEventType, { description: 'Type of progress event' }) + type!: DockerUpdateEventType; + + @Field(() => String, { nullable: true, description: 'Human-readable message or log line' }) + message?: string; + + @Field(() => String, { nullable: true, description: 'Layer ID for layer-specific events' }) + layerId?: string; + + @Field(() => Float, { + nullable: true, + description: 'Overall progress percentage (0-100) for the current operation', + }) + overallProgress?: number; + + @Field(() => [DockerLayerProgress], { + nullable: true, + description: 'Per-layer progress details', + }) + layers?: DockerLayerProgress[]; + + @Field(() => String, { nullable: true, description: 'Error message if type is ERROR' }) + error?: string; +} diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-update-progress.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-update-progress.service.ts new file mode 100644 index 0000000000..257c931de3 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-update-progress.service.ts @@ -0,0 +1,329 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { createInterface } from 'readline'; + +import type { ResultPromise } from 'execa'; +import { execa } from 'execa'; + +import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { + DockerLayerProgress, + DockerUpdateEventType, + DockerUpdateProgress, +} from '@app/unraid-api/graph/resolvers/docker/docker-update-progress.model.js'; + +interface ActiveUpdate { + containerId: string; + containerName: string; + process: ResultPromise; + layers: Map; +} + +@Injectable() +export class DockerUpdateProgressService { + private readonly logger = new Logger(DockerUpdateProgressService.name); + private activeUpdates = new Map(); + + public async updateContainerWithProgress(containerId: string, containerName: string): Promise { + if (this.activeUpdates.has(containerId)) { + throw new Error(`Container ${containerName} is already being updated`); + } + + this.logger.log(`Starting update with progress for ${containerName} (${containerId})`); + + this.publishProgress({ + containerId, + containerName, + type: DockerUpdateEventType.STARTED, + message: `Starting update for ${containerName}`, + }); + + const updateProcess = execa( + '/usr/local/emhttp/plugins/dynamix.docker.manager/scripts/update_container', + [encodeURIComponent(containerName)], + { + shell: 'bash', + all: true, + reject: false, + env: { + ...process.env, + DOCKER_CLI_FORMAT: 'json', + }, + } + ); + + const activeUpdate: ActiveUpdate = { + containerId, + containerName, + process: updateProcess, + layers: new Map(), + }; + + this.activeUpdates.set(containerId, activeUpdate); + + try { + if (updateProcess.stdout) { + const rl = createInterface({ + input: updateProcess.stdout, + crlfDelay: Infinity, + }); + + rl.on('line', (line) => { + this.processOutputLine(activeUpdate, line); + }); + + rl.on('error', (err) => { + this.logger.error(`Error reading update output for ${containerName}`, err); + }); + } + + if (updateProcess.stderr) { + updateProcess.stderr.on('data', (data: Buffer) => { + const message = data.toString().trim(); + if (message) { + this.logger.debug(`Update stderr for ${containerName}: ${message}`); + } + }); + } + + const result = await updateProcess; + + if (result.failed) { + this.publishProgress({ + containerId, + containerName, + type: DockerUpdateEventType.ERROR, + message: `Update failed for ${containerName}`, + error: result.stderr || result.shortMessage || 'Unknown error', + }); + throw new Error(`Failed to update container ${containerName}: ${result.shortMessage}`); + } + + this.publishProgress({ + containerId, + containerName, + type: DockerUpdateEventType.COMPLETE, + message: `Successfully updated ${containerName}`, + overallProgress: 100, + }); + } finally { + this.activeUpdates.delete(containerId); + } + } + + private processOutputLine(update: ActiveUpdate, line: string): void { + const trimmed = line.trim(); + if (!trimmed) return; + + const { containerId, containerName } = update; + + if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + try { + const json = JSON.parse(trimmed); + this.processDockerJson(update, json); + return; + } catch { + // Not valid JSON, treat as log line + } + } + + const pullMatch = trimmed.match(/^([a-f0-9]+):\s*(.+)$/i); + if (pullMatch) { + const [, layerId, status] = pullMatch; + this.processLayerStatus(update, layerId, status); + return; + } + + const progressMatch = trimmed.match(/^Pulling\s+(.+)$/i); + if (progressMatch) { + this.publishProgress({ + containerId, + containerName, + type: DockerUpdateEventType.PULLING, + message: trimmed, + }); + return; + } + + this.publishProgress({ + containerId, + containerName, + type: DockerUpdateEventType.LOG, + message: trimmed, + }); + } + + private processDockerJson(update: ActiveUpdate, json: Record): void { + const { containerId, containerName } = update; + + if (json.status && typeof json.id === 'string') { + const layerId = json.id as string; + const status = json.status as string; + + const progressDetail = json.progressDetail as + | { current?: number; total?: number } + | undefined; + + const layerProgress: DockerLayerProgress = { + layerId, + status, + current: progressDetail?.current, + total: progressDetail?.total, + progress: + progressDetail?.current && progressDetail?.total + ? Math.round((progressDetail.current / progressDetail.total) * 100) + : undefined, + }; + + update.layers.set(layerId, layerProgress); + + let eventType: DockerUpdateEventType; + if (status.toLowerCase().includes('downloading')) { + eventType = DockerUpdateEventType.LAYER_DOWNLOADING; + } else if (status.toLowerCase().includes('extracting')) { + eventType = DockerUpdateEventType.LAYER_EXTRACTING; + } else if (status.toLowerCase().includes('pull complete')) { + eventType = DockerUpdateEventType.LAYER_COMPLETE; + } else if (status.toLowerCase().includes('already exists')) { + eventType = DockerUpdateEventType.LAYER_ALREADY_EXISTS; + } else { + eventType = DockerUpdateEventType.LOG; + } + + const overallProgress = this.calculateOverallProgress(update); + + this.publishProgress({ + containerId, + containerName, + type: eventType, + layerId, + message: json.progress + ? `${layerId}: ${status} ${json.progress}` + : `${layerId}: ${status}`, + overallProgress, + layers: Array.from(update.layers.values()), + }); + } else if (json.status) { + this.publishProgress({ + containerId, + containerName, + type: DockerUpdateEventType.LOG, + message: json.status as string, + }); + } else if (json.error) { + this.publishProgress({ + containerId, + containerName, + type: DockerUpdateEventType.ERROR, + error: json.error as string, + }); + } + } + + private processLayerStatus(update: ActiveUpdate, layerId: string, status: string): void { + const { containerId, containerName } = update; + + const progressMatch = status.match(/(\d+(?:\.\d+)?)\s*%/); + const progress = progressMatch ? parseFloat(progressMatch[1]) : undefined; + + const bytesMatch = status.match(/(\d+(?:\.\d+)?)\s*([KMGT]?B)/i); + let current: number | undefined; + let total: number | undefined; + + if (bytesMatch) { + const totalMatch = status.match(/of\s+(\d+(?:\.\d+)?)\s*([KMGT]?B)/i); + if (totalMatch) { + total = this.parseBytes(totalMatch[1], totalMatch[2]); + current = this.parseBytes(bytesMatch[1], bytesMatch[2]); + } + } + + const layerProgress: DockerLayerProgress = { + layerId, + status, + progress, + current, + total, + }; + + update.layers.set(layerId, layerProgress); + + let eventType: DockerUpdateEventType; + const statusLower = status.toLowerCase(); + if (statusLower.includes('downloading')) { + eventType = DockerUpdateEventType.LAYER_DOWNLOADING; + } else if (statusLower.includes('extracting')) { + eventType = DockerUpdateEventType.LAYER_EXTRACTING; + } else if (statusLower.includes('complete') || statusLower.includes('done')) { + eventType = DockerUpdateEventType.LAYER_COMPLETE; + } else if (statusLower.includes('already exists')) { + eventType = DockerUpdateEventType.LAYER_ALREADY_EXISTS; + } else { + eventType = DockerUpdateEventType.LOG; + } + + const overallProgress = this.calculateOverallProgress(update); + + this.publishProgress({ + containerId, + containerName, + type: eventType, + layerId, + message: `${layerId}: ${status}`, + overallProgress, + layers: Array.from(update.layers.values()), + }); + } + + private calculateOverallProgress(update: ActiveUpdate): number { + const layers = Array.from(update.layers.values()); + if (layers.length === 0) return 0; + + let totalProgress = 0; + let countedLayers = 0; + + for (const layer of layers) { + if (layer.progress !== undefined) { + totalProgress += layer.progress; + countedLayers++; + } else if ( + layer.status.toLowerCase().includes('complete') || + layer.status.toLowerCase().includes('already exists') + ) { + totalProgress += 100; + countedLayers++; + } + } + + if (countedLayers === 0) return 0; + return Math.round(totalProgress / layers.length); + } + + private parseBytes(value: string, unit: string): number { + const num = parseFloat(value); + const multipliers: Record = { + B: 1, + KB: 1024, + MB: 1024 * 1024, + GB: 1024 * 1024 * 1024, + TB: 1024 * 1024 * 1024 * 1024, + }; + return num * (multipliers[unit.toUpperCase()] || 1); + } + + private publishProgress(progress: DockerUpdateProgress): void { + this.logger.debug( + `Update progress for ${progress.containerName}: ${progress.type} - ${progress.message ?? ''}` + ); + pubsub.publish(PUBSUB_CHANNEL.DOCKER_UPDATE_PROGRESS, { + dockerUpdateProgress: progress, + }); + } + + public isUpdating(containerId: string): boolean { + return this.activeUpdates.has(containerId); + } + + public getActiveUpdates(): string[] { + return Array.from(this.activeUpdates.keys()); + } +} diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.module.ts b/api/src/unraid-api/graph/resolvers/docker/docker.module.ts index 7f4474d014..7a26c874be 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.module.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.module.ts @@ -14,6 +14,7 @@ import { DockerStatsService } from '@app/unraid-api/graph/resolvers/docker/docke import { DockerTailscaleService } from '@app/unraid-api/graph/resolvers/docker/docker-tailscale.service.js'; import { DockerTemplateIconService } from '@app/unraid-api/graph/resolvers/docker/docker-template-icon.service.js'; import { DockerTemplateScannerService } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.service.js'; +import { DockerUpdateProgressService } from '@app/unraid-api/graph/resolvers/docker/docker-update-progress.service.js'; import { DockerMutationsResolver } from '@app/unraid-api/graph/resolvers/docker/docker.mutations.resolver.js'; import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; @@ -37,6 +38,7 @@ import { ServicesModule } from '@app/unraid-api/graph/services/services.module.j DockerTemplateIconService, DockerStatsService, DockerTailscaleService, + DockerUpdateProgressService, DockerLogService, DockerNetworkService, DockerPortService, diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts index 51c6536cdb..c3f9f6f8f1 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts @@ -23,6 +23,7 @@ import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker- import { DockerStatsService } from '@app/unraid-api/graph/resolvers/docker/docker-stats.service.js'; import { DockerTemplateSyncResult } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.model.js'; import { DockerTemplateScannerService } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.service.js'; +import { DockerUpdateProgress } from '@app/unraid-api/graph/resolvers/docker/docker-update-progress.model.js'; import { ExplicitStatusItem } from '@app/unraid-api/graph/resolvers/docker/docker-update-status.model.js'; import { Docker, @@ -385,4 +386,17 @@ export class DockerResolver { public dockerContainerStats() { return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.DOCKER_STATS); } + + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.DOCKER, + }) + @Subscription(() => DockerUpdateProgress, { + description: 'Real-time progress updates for Docker container update operations', + resolve: (payload) => payload.dockerUpdateProgress, + }) + public dockerUpdateProgress() { + return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.DOCKER_UPDATE_PROGRESS); + } } diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts index 48994aee16..1c2d034b3d 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.spec.ts @@ -12,6 +12,7 @@ import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker- import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js'; import { DockerNetworkService } from '@app/unraid-api/graph/resolvers/docker/docker-network.service.js'; import { DockerPortService } from '@app/unraid-api/graph/resolvers/docker/docker-port.service.js'; +import { DockerUpdateProgressService } from '@app/unraid-api/graph/resolvers/docker/docker-update-progress.service.js'; import { ContainerState, DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js'; @@ -167,6 +168,13 @@ const mockDockerPortService = { calculateConflicts: vi.fn().mockReturnValue({ containerPorts: [], lanPorts: [] }), }; +// Mock DockerUpdateProgressService +const mockDockerUpdateProgressService = { + updateContainerWithProgress: vi.fn().mockResolvedValue(undefined), + isUpdating: vi.fn().mockReturnValue(false), + getActiveUpdates: vi.fn().mockReturnValue([]), +}; + describe('DockerService', () => { let service: DockerService; @@ -209,6 +217,9 @@ describe('DockerService', () => { mockDockerPortService.deduplicateContainerPorts.mockClear(); mockDockerPortService.calculateConflicts.mockReset(); + mockDockerUpdateProgressService.updateContainerWithProgress.mockReset(); + mockDockerUpdateProgressService.updateContainerWithProgress.mockResolvedValue(undefined); + const module: TestingModule = await Test.createTestingModule({ providers: [ DockerService, @@ -240,6 +251,10 @@ describe('DockerService', () => { provide: DockerPortService, useValue: mockDockerPortService, }, + { + provide: DockerUpdateProgressService, + useValue: mockDockerUpdateProgressService, + }, ], }).compile(); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker.service.ts index 66f5d42b0a..1d24528433 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.ts @@ -1,7 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; import Docker from 'dockerode'; -import { execa } from 'execa'; import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { catchHandlers } from '@app/core/utils/misc/catch-handlers.js'; @@ -13,6 +12,7 @@ import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker- import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js'; import { DockerNetworkService } from '@app/unraid-api/graph/resolvers/docker/docker-network.service.js'; import { DockerPortService } from '@app/unraid-api/graph/resolvers/docker/docker-port.service.js'; +import { DockerUpdateProgressService } from '@app/unraid-api/graph/resolvers/docker/docker-update-progress.service.js'; import { ContainerPortType, ContainerState, @@ -37,7 +37,9 @@ export class DockerService { private readonly autostartService: DockerAutostartService, private readonly dockerLogService: DockerLogService, private readonly dockerNetworkService: DockerNetworkService, - private readonly dockerPortService: DockerPortService + private readonly dockerPortService: DockerPortService, + @Inject(forwardRef(() => DockerUpdateProgressService)) + private readonly dockerUpdateProgressService: DockerUpdateProgressService ) { this.client = getDockerClient(); } @@ -336,11 +338,7 @@ export class DockerService { this.logger.log(`Updating container ${containerName} (${id})`); try { - await execa( - '/usr/local/emhttp/plugins/dynamix.docker.manager/scripts/update_container', - [encodeURIComponent(containerName)], - { shell: 'bash' } - ); + await this.dockerUpdateProgressService.updateContainerWithProgress(id, containerName); } catch (error) { this.logger.error(`Failed to update container ${containerName}:`, error); throw new Error(`Failed to update container ${containerName}`); diff --git a/packages/unraid-shared/src/pubsub/graphql.pubsub.ts b/packages/unraid-shared/src/pubsub/graphql.pubsub.ts index 2c48757006..7b006775fd 100644 --- a/packages/unraid-shared/src/pubsub/graphql.pubsub.ts +++ b/packages/unraid-shared/src/pubsub/graphql.pubsub.ts @@ -18,6 +18,7 @@ export enum GRAPHQL_PUBSUB_CHANNEL { SERVERS = "SERVERS", VMS = "VMS", DOCKER_STATS = "DOCKER_STATS", + DOCKER_UPDATE_PROGRESS = "DOCKER_UPDATE_PROGRESS", LOG_FILE = "LOG_FILE", PARITY = "PARITY", } diff --git a/web/src/components/Docker/DockerContainersTable.vue b/web/src/components/Docker/DockerContainersTable.vue index 89268aeea6..086066a0c9 100644 --- a/web/src/components/Docker/DockerContainersTable.vue +++ b/web/src/components/Docker/DockerContainersTable.vue @@ -21,6 +21,7 @@ import { STOP_DOCKER_CONTAINER } from '@/components/Docker/docker-stop-container import { GET_CONTAINER_TAILSCALE_STATUS } from '@/components/Docker/docker-tailscale-status.query'; import { UNPAUSE_DOCKER_CONTAINER } from '@/components/Docker/docker-unpause-container.mutation'; import DockerLogViewerModal from '@/components/Docker/DockerLogViewerModal.vue'; +import DockerUpdateProgressModal from '@/components/Docker/DockerUpdateProgressModal.vue'; import RemoveContainerModal from '@/components/Docker/RemoveContainerModal.vue'; import { useContainerActions } from '@/composables/useContainerActions'; import { useContextMenu } from '@/composables/useContextMenu'; @@ -36,6 +37,7 @@ import { useDockerTableColumns, } from '@/composables/useDockerTableColumns'; import { useDockerUpdateActions } from '@/composables/useDockerUpdateActions'; +import { useDockerUpdateProgress } from '@/composables/useDockerUpdateProgress'; import { useEntryReordering } from '@/composables/useEntryReordering'; import { useFolderOperations } from '@/composables/useFolderOperations'; import { useFolderTree } from '@/composables/useFolderTree'; @@ -120,6 +122,7 @@ const containerToRemove = ref | null>(null); const { containerStats } = useDockerContainerStats(); const logs = useDockerLogSessions(); const consoleSessions = useDockerConsoleSessions(); +const updateProgress = useDockerUpdateProgress(); const contextMenu = useContextMenu(); const { client: apolloClient } = useApolloClient(); const { mergeServerPreferences, saveColumnVisibility, columnVisibilityRef } = useDockerViewPreferences(); @@ -202,6 +205,12 @@ const { showToast, showError, getRowById: (id) => getRowById(id, treeData.value), + onUpdateStart: (containerId, containerName) => { + updateProgress.startTracking(containerId, containerName); + }, + onUpdateComplete: () => { + // Progress tracking is handled by subscription events, no need to stop tracking here + }, }); // Container actions @@ -696,13 +705,15 @@ const rowActionDropdownUi = { -
- Updating {{ activeUpdateSummary }}... -
+ Updating {{ activeUpdateSummary }}... + Click for details + @@ -776,5 +787,14 @@ const rowActionDropdownUi = { @update:open="removeContainerModalOpen = $event" @confirm="handleConfirmRemoveContainer" /> + + + diff --git a/web/src/components/Docker/DockerUpdateProgressModal.vue b/web/src/components/Docker/DockerUpdateProgressModal.vue new file mode 100644 index 0000000000..efaa526700 --- /dev/null +++ b/web/src/components/Docker/DockerUpdateProgressModal.vue @@ -0,0 +1,189 @@ + + + diff --git a/web/src/components/Docker/docker-update-progress.subscription.ts b/web/src/components/Docker/docker-update-progress.subscription.ts new file mode 100644 index 0000000000..5b2eb07a7b --- /dev/null +++ b/web/src/components/Docker/docker-update-progress.subscription.ts @@ -0,0 +1,22 @@ +import { gql } from '@apollo/client'; + +export const DOCKER_UPDATE_PROGRESS_SUBSCRIPTION = gql` + subscription DockerUpdateProgress { + dockerUpdateProgress { + containerId + containerName + type + message + layerId + overallProgress + error + layers { + layerId + status + progress + current + total + } + } + } +`; diff --git a/web/src/composables/gql/gql.ts b/web/src/composables/gql/gql.ts index 260eb52f1c..763f5c3216 100644 --- a/web/src/composables/gql/gql.ts +++ b/web/src/composables/gql/gql.ts @@ -50,6 +50,7 @@ type Documents = { "\n mutation UpdateAllDockerContainers {\n docker {\n updateAllContainers {\n id\n names\n state\n isUpdateAvailable\n isRebuildReady\n }\n }\n }\n": typeof types.UpdateAllDockerContainersDocument, "\n mutation UpdateDockerAutostartConfiguration(\n $entries: [DockerAutostartEntryInput!]!\n $persistUserPreferences: Boolean\n ) {\n docker {\n updateAutostartConfiguration(entries: $entries, persistUserPreferences: $persistUserPreferences)\n }\n }\n": typeof types.UpdateDockerAutostartConfigurationDocument, "\n mutation UpdateDockerContainers($ids: [PrefixedID!]!) {\n docker {\n updateContainers(ids: $ids) {\n id\n names\n state\n isUpdateAvailable\n isRebuildReady\n }\n }\n }\n": typeof types.UpdateDockerContainersDocument, + "\n subscription DockerUpdateProgress {\n dockerUpdateProgress {\n containerId\n containerName\n type\n message\n layerId\n overallProgress\n error\n layers {\n layerId\n status\n progress\n current\n total\n }\n }\n }\n": typeof types.DockerUpdateProgressDocument, "\n mutation UpdateDockerViewPreferences($viewId: String, $prefs: JSON!) {\n updateDockerViewPreferences(viewId: $viewId, prefs: $prefs) {\n version\n views {\n id\n name\n rootId\n prefs\n flatEntries {\n id\n type\n name\n parentId\n depth\n position\n path\n hasChildren\n childrenIds\n meta {\n id\n names\n state\n status\n image\n ports {\n privatePort\n publicPort\n type\n }\n autoStart\n hostConfig {\n networkMode\n }\n created\n isUpdateAvailable\n isRebuildReady\n }\n }\n }\n }\n }\n": typeof types.UpdateDockerViewPreferencesDocument, "\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": typeof types.LogFilesDocument, "\n query LogFileContent($path: String!, $lines: Int, $startLine: Int) {\n logFile(path: $path, lines: $lines, startLine: $startLine) {\n path\n content\n totalLines\n startLine\n }\n }\n": typeof types.LogFileContentDocument, @@ -120,6 +121,7 @@ const documents: Documents = { "\n mutation UpdateAllDockerContainers {\n docker {\n updateAllContainers {\n id\n names\n state\n isUpdateAvailable\n isRebuildReady\n }\n }\n }\n": types.UpdateAllDockerContainersDocument, "\n mutation UpdateDockerAutostartConfiguration(\n $entries: [DockerAutostartEntryInput!]!\n $persistUserPreferences: Boolean\n ) {\n docker {\n updateAutostartConfiguration(entries: $entries, persistUserPreferences: $persistUserPreferences)\n }\n }\n": types.UpdateDockerAutostartConfigurationDocument, "\n mutation UpdateDockerContainers($ids: [PrefixedID!]!) {\n docker {\n updateContainers(ids: $ids) {\n id\n names\n state\n isUpdateAvailable\n isRebuildReady\n }\n }\n }\n": types.UpdateDockerContainersDocument, + "\n subscription DockerUpdateProgress {\n dockerUpdateProgress {\n containerId\n containerName\n type\n message\n layerId\n overallProgress\n error\n layers {\n layerId\n status\n progress\n current\n total\n }\n }\n }\n": types.DockerUpdateProgressDocument, "\n mutation UpdateDockerViewPreferences($viewId: String, $prefs: JSON!) {\n updateDockerViewPreferences(viewId: $viewId, prefs: $prefs) {\n version\n views {\n id\n name\n rootId\n prefs\n flatEntries {\n id\n type\n name\n parentId\n depth\n position\n path\n hasChildren\n childrenIds\n meta {\n id\n names\n state\n status\n image\n ports {\n privatePort\n publicPort\n type\n }\n autoStart\n hostConfig {\n networkMode\n }\n created\n isUpdateAvailable\n isRebuildReady\n }\n }\n }\n }\n }\n": types.UpdateDockerViewPreferencesDocument, "\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": types.LogFilesDocument, "\n query LogFileContent($path: String!, $lines: Int, $startLine: Int) {\n logFile(path: $path, lines: $lines, startLine: $startLine) {\n path\n content\n totalLines\n startLine\n }\n }\n": types.LogFileContentDocument, @@ -312,6 +314,10 @@ export function graphql(source: "\n mutation UpdateDockerAutostartConfiguration * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ export function graphql(source: "\n mutation UpdateDockerContainers($ids: [PrefixedID!]!) {\n docker {\n updateContainers(ids: $ids) {\n id\n names\n state\n isUpdateAvailable\n isRebuildReady\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateDockerContainers($ids: [PrefixedID!]!) {\n docker {\n updateContainers(ids: $ids) {\n id\n names\n state\n isUpdateAvailable\n isRebuildReady\n }\n }\n }\n"]; +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n subscription DockerUpdateProgress {\n dockerUpdateProgress {\n containerId\n containerName\n type\n message\n layerId\n overallProgress\n error\n layers {\n layerId\n status\n progress\n current\n total\n }\n }\n }\n"): (typeof documents)["\n subscription DockerUpdateProgress {\n dockerUpdateProgress {\n containerId\n containerName\n type\n message\n layerId\n overallProgress\n error\n layers {\n layerId\n status\n progress\n current\n total\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/web/src/composables/gql/graphql.ts b/web/src/composables/gql/graphql.ts index 4b8fee0d07..5f5488c2f3 100644 --- a/web/src/composables/gql/graphql.ts +++ b/web/src/composables/gql/graphql.ts @@ -857,6 +857,21 @@ export type DockerLanPortConflict = { type: ContainerPortType; }; +/** Progress information for a single image layer */ +export type DockerLayerProgress = { + __typename?: 'DockerLayerProgress'; + /** Bytes downloaded/processed */ + current?: Maybe; + /** Layer ID (short hash) */ + layerId: Scalars['String']['output']; + /** Download/extract progress percentage (0-100) */ + progress?: Maybe; + /** Current status of the layer */ + status: Scalars['String']['output']; + /** Total bytes for this layer */ + total?: Maybe; +}; + export type DockerMutations = { __typename?: 'DockerMutations'; /** Pause (Suspend) a container */ @@ -960,6 +975,40 @@ export type DockerTemplateSyncResult = { skipped: Scalars['Int']['output']; }; +/** Type of Docker update progress event */ +export enum DockerUpdateEventType { + COMPLETE = 'COMPLETE', + ERROR = 'ERROR', + LAYER_ALREADY_EXISTS = 'LAYER_ALREADY_EXISTS', + LAYER_COMPLETE = 'LAYER_COMPLETE', + LAYER_DOWNLOADING = 'LAYER_DOWNLOADING', + LAYER_EXTRACTING = 'LAYER_EXTRACTING', + LOG = 'LOG', + PULLING = 'PULLING', + STARTED = 'STARTED' +} + +/** Real-time progress update for a Docker container update operation */ +export type DockerUpdateProgress = { + __typename?: 'DockerUpdateProgress'; + /** Container ID being updated */ + containerId: Scalars['PrefixedID']['output']; + /** Container name being updated */ + containerName: Scalars['String']['output']; + /** Error message if type is ERROR */ + error?: Maybe; + /** Layer ID for layer-specific events */ + layerId?: Maybe; + /** Per-layer progress details */ + layers?: Maybe>; + /** Human-readable message or log line */ + message?: Maybe; + /** Overall progress percentage (0-100) for the current operation */ + overallProgress?: Maybe; + /** Type of progress event */ + type: DockerUpdateEventType; +}; + export type DynamicRemoteAccessStatus = { __typename?: 'DynamicRemoteAccessStatus'; /** The type of dynamic remote access that is enabled */ @@ -2289,6 +2338,8 @@ export type Subscription = { __typename?: 'Subscription'; arraySubscription: UnraidArray; dockerContainerStats: DockerContainerStats; + /** Real-time progress updates for Docker container update operations */ + dockerUpdateProgress: DockerUpdateProgress; logFile: LogFileContent; notificationAdded: Notification; notificationsOverview: NotificationOverview; @@ -3114,6 +3165,11 @@ export type UpdateDockerContainersMutationVariables = Exact<{ export type UpdateDockerContainersMutation = { __typename?: 'Mutation', docker: { __typename?: 'DockerMutations', updateContainers: Array<{ __typename?: 'DockerContainer', id: string, names: Array, state: ContainerState, isUpdateAvailable?: boolean | null, isRebuildReady?: boolean | null }> } }; +export type DockerUpdateProgressSubscriptionVariables = Exact<{ [key: string]: never; }>; + + +export type DockerUpdateProgressSubscription = { __typename?: 'Subscription', dockerUpdateProgress: { __typename?: 'DockerUpdateProgress', containerId: string, containerName: string, type: DockerUpdateEventType, message?: string | null, layerId?: string | null, overallProgress?: number | null, error?: string | null, layers?: Array<{ __typename?: 'DockerLayerProgress', layerId: string, status: string, progress?: number | null, current?: number | null, total?: number | null }> | null } }; + export type UpdateDockerViewPreferencesMutationVariables = Exact<{ viewId?: InputMaybe; prefs: Scalars['JSON']['input']; @@ -3363,6 +3419,7 @@ export const UnpauseDockerContainerDocument = {"kind":"Document","definitions":[ export const UpdateAllDockerContainersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateAllDockerContainers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"docker"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateAllContainers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"names"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"isUpdateAvailable"}},{"kind":"Field","name":{"kind":"Name","value":"isRebuildReady"}}]}}]}}]}}]} as unknown as DocumentNode; export const UpdateDockerAutostartConfigurationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateDockerAutostartConfiguration"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"entries"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DockerAutostartEntryInput"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"persistUserPreferences"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"docker"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateAutostartConfiguration"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"entries"},"value":{"kind":"Variable","name":{"kind":"Name","value":"entries"}}},{"kind":"Argument","name":{"kind":"Name","value":"persistUserPreferences"},"value":{"kind":"Variable","name":{"kind":"Name","value":"persistUserPreferences"}}}]}]}}]}}]} as unknown as DocumentNode; export const UpdateDockerContainersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateDockerContainers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PrefixedID"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"docker"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateContainers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"names"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"isUpdateAvailable"}},{"kind":"Field","name":{"kind":"Name","value":"isRebuildReady"}}]}}]}}]}}]} as unknown as DocumentNode; +export const DockerUpdateProgressDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"DockerUpdateProgress"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"dockerUpdateProgress"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"containerId"}},{"kind":"Field","name":{"kind":"Name","value":"containerName"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"layerId"}},{"kind":"Field","name":{"kind":"Name","value":"overallProgress"}},{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"layers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"layerId"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"progress"}},{"kind":"Field","name":{"kind":"Name","value":"current"}},{"kind":"Field","name":{"kind":"Name","value":"total"}}]}}]}}]}}]} as unknown as DocumentNode; export const UpdateDockerViewPreferencesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateDockerViewPreferences"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"viewId"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"prefs"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"JSON"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateDockerViewPreferences"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"viewId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"viewId"}}},{"kind":"Argument","name":{"kind":"Name","value":"prefs"},"value":{"kind":"Variable","name":{"kind":"Name","value":"prefs"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"version"}},{"kind":"Field","name":{"kind":"Name","value":"views"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"rootId"}},{"kind":"Field","name":{"kind":"Name","value":"prefs"}},{"kind":"Field","name":{"kind":"Name","value":"flatEntries"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"parentId"}},{"kind":"Field","name":{"kind":"Name","value":"depth"}},{"kind":"Field","name":{"kind":"Name","value":"position"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"hasChildren"}},{"kind":"Field","name":{"kind":"Name","value":"childrenIds"}},{"kind":"Field","name":{"kind":"Name","value":"meta"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"names"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"image"}},{"kind":"Field","name":{"kind":"Name","value":"ports"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"privatePort"}},{"kind":"Field","name":{"kind":"Name","value":"publicPort"}},{"kind":"Field","name":{"kind":"Name","value":"type"}}]}},{"kind":"Field","name":{"kind":"Name","value":"autoStart"}},{"kind":"Field","name":{"kind":"Name","value":"hostConfig"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"networkMode"}}]}},{"kind":"Field","name":{"kind":"Name","value":"created"}},{"kind":"Field","name":{"kind":"Name","value":"isUpdateAvailable"}},{"kind":"Field","name":{"kind":"Name","value":"isRebuildReady"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; export const LogFilesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"LogFiles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFiles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"modifiedAt"}}]}}]}}]} as unknown as DocumentNode; export const LogFileContentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"LogFileContent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"path"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lines"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"startLine"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"path"},"value":{"kind":"Variable","name":{"kind":"Name","value":"path"}}},{"kind":"Argument","name":{"kind":"Name","value":"lines"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lines"}}},{"kind":"Argument","name":{"kind":"Name","value":"startLine"},"value":{"kind":"Variable","name":{"kind":"Name","value":"startLine"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"totalLines"}},{"kind":"Field","name":{"kind":"Name","value":"startLine"}}]}}]}}]} as unknown as DocumentNode; diff --git a/web/src/composables/useDockerUpdateActions.ts b/web/src/composables/useDockerUpdateActions.ts index 914d610d4a..af51f09297 100644 --- a/web/src/composables/useDockerUpdateActions.ts +++ b/web/src/composables/useDockerUpdateActions.ts @@ -14,6 +14,8 @@ interface UpdateActionsOptions { showToast: (message: string) => void; showError: (message: string, options?: { description?: string }) => void; getRowById: (id: string) => TreeRow | undefined; + onUpdateStart?: (containerId: string, containerName: string) => void; + onUpdateComplete?: (containerId: string) => void; } export function useDockerUpdateActions({ @@ -21,6 +23,8 @@ export function useDockerUpdateActions({ showToast, showError, getRowById, + onUpdateStart, + onUpdateComplete, }: UpdateActionsOptions) { const updatingRowIds = ref>(new Set()); @@ -93,6 +97,7 @@ export function useDockerUpdateActions({ setRowsUpdating([row], true); setRowsBusy([row.id], true); + onUpdateStart?.(row.containerId, row.name); try { await updateContainersMutation( @@ -110,6 +115,7 @@ export function useDockerUpdateActions({ } finally { setRowsBusy([row.id], false); setRowsUpdating([row], false); + onUpdateComplete?.(row.containerId); } } @@ -125,6 +131,12 @@ export function useDockerUpdateActions({ setRowsUpdating(rows, true); setRowsBusy(entryIds, true); + for (const row of rows) { + if (row.containerId) { + onUpdateStart?.(row.containerId, row.name); + } + } + try { await updateContainersMutation( { ids: containerIds }, @@ -142,6 +154,11 @@ export function useDockerUpdateActions({ } finally { setRowsBusy(entryIds, false); setRowsUpdating(rows, false); + for (const row of rows) { + if (row.containerId) { + onUpdateComplete?.(row.containerId); + } + } } } @@ -151,6 +168,11 @@ export function useDockerUpdateActions({ if (rows.length) { setRowsUpdating(rows, true); setRowsBusy(entryIds, true); + for (const row of rows) { + if (row.containerId) { + onUpdateStart?.(row.containerId, row.name); + } + } } try { @@ -175,6 +197,11 @@ export function useDockerUpdateActions({ if (rows.length) { setRowsBusy(entryIds, false); setRowsUpdating(rows, false); + for (const row of rows) { + if (row.containerId) { + onUpdateComplete?.(row.containerId); + } + } } } } diff --git a/web/src/composables/useDockerUpdateProgress.ts b/web/src/composables/useDockerUpdateProgress.ts new file mode 100644 index 0000000000..14ec96cd49 --- /dev/null +++ b/web/src/composables/useDockerUpdateProgress.ts @@ -0,0 +1,180 @@ +import { computed, reactive, ref } from 'vue'; +import { useSubscription } from '@vue/apollo-composable'; + +import { DOCKER_UPDATE_PROGRESS_SUBSCRIPTION } from '@/components/Docker/docker-update-progress.subscription'; + +import type { + DockerLayerProgress, + DockerUpdateEventType, + DockerUpdateProgress, +} from '@/composables/gql/graphql'; + +export interface ContainerUpdateState { + containerId: string; + containerName: string; + status: 'pending' | 'in_progress' | 'complete' | 'error'; + overallProgress: number; + message: string; + error?: string; + layers: Map; + events: DockerUpdateProgress[]; +} + +export function useDockerUpdateProgress() { + const containerUpdates = reactive(new Map()); + const isModalOpen = ref(false); + const activeContainerId = ref(null); + + const { onResult: onProgressResult, onError: onProgressError } = useSubscription( + DOCKER_UPDATE_PROGRESS_SUBSCRIPTION, + null, + () => ({ + fetchPolicy: 'network-only', + }) + ); + + onProgressResult((result) => { + const progress = result.data?.dockerUpdateProgress as DockerUpdateProgress | undefined; + if (!progress || !progress.containerId) return; + + const { containerId, containerName, type, message, overallProgress, error, layers } = progress; + + let state = containerUpdates.get(containerId); + if (!state) { + state = { + containerId, + containerName, + status: 'pending', + overallProgress: 0, + message: '', + layers: new Map(), + events: [], + }; + containerUpdates.set(containerId, state); + } + + state.events.push(progress); + + if (message) { + state.message = message; + } + + if (overallProgress !== undefined && overallProgress !== null) { + state.overallProgress = overallProgress; + } + + if (layers) { + for (const layer of layers) { + state.layers.set(layer.layerId, layer); + } + } + + const eventType = type as DockerUpdateEventType; + switch (eventType) { + case 'STARTED': + state.status = 'in_progress'; + break; + case 'COMPLETE': + state.status = 'complete'; + state.overallProgress = 100; + break; + case 'ERROR': + state.status = 'error'; + state.error = error ?? 'Unknown error'; + break; + } + }); + + onProgressError((err) => { + console.error('Docker update progress subscription error:', err); + }); + + function startTracking(containerId: string, containerName: string) { + const state: ContainerUpdateState = { + containerId, + containerName, + status: 'pending', + overallProgress: 0, + message: `Preparing to update ${containerName}...`, + layers: new Map(), + events: [], + }; + containerUpdates.set(containerId, state); + activeContainerId.value = containerId; + isModalOpen.value = true; + } + + function stopTracking(containerId: string) { + containerUpdates.delete(containerId); + if (activeContainerId.value === containerId) { + const remaining = Array.from(containerUpdates.keys()); + activeContainerId.value = remaining.length > 0 ? remaining[0] : null; + if (!activeContainerId.value) { + isModalOpen.value = false; + } + } + } + + function clearCompleted() { + for (const [id, state] of containerUpdates.entries()) { + if (state.status === 'complete' || state.status === 'error') { + containerUpdates.delete(id); + } + } + if (activeContainerId.value && !containerUpdates.has(activeContainerId.value)) { + const remaining = Array.from(containerUpdates.keys()); + activeContainerId.value = remaining.length > 0 ? remaining[0] : null; + } + if (containerUpdates.size === 0) { + isModalOpen.value = false; + } + } + + function closeModal() { + isModalOpen.value = false; + } + + function openModal(containerId?: string) { + if (containerId && containerUpdates.has(containerId)) { + activeContainerId.value = containerId; + } + isModalOpen.value = true; + } + + function setActiveContainer(containerId: string) { + if (containerUpdates.has(containerId)) { + activeContainerId.value = containerId; + } + } + + const activeContainerState = computed(() => { + if (!activeContainerId.value) return null; + return containerUpdates.get(activeContainerId.value) ?? null; + }); + + const hasActiveUpdates = computed(() => { + return Array.from(containerUpdates.values()).some( + (s) => s.status === 'pending' || s.status === 'in_progress' + ); + }); + + const updateCount = computed(() => containerUpdates.size); + + const allContainerStates = computed(() => Array.from(containerUpdates.values())); + + return { + containerUpdates, + isModalOpen, + activeContainerId, + activeContainerState, + hasActiveUpdates, + updateCount, + allContainerStates, + startTracking, + stopTracking, + clearCompleted, + closeModal, + openModal, + setActiveContainer, + }; +} From c739f8b2d38b36951c3334016492bf48c7498c29 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Fri, 9 Jan 2026 15:30:46 -0500 Subject: [PATCH 2/3] chore: restore api config for dev --- api/dev/configs/api.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index e69de29bb2..7b5dceb665 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -0,0 +1,10 @@ +{ + "version": "4.29.2", + "extraOrigins": [], + "sandbox": true, + "ssoSubIds": [], + "plugins": [ + "unraid-api-plugin-connect" + ] +} + From 11f7e82bb903d19f3f9ae27b3566a004a48b7656 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Fri, 9 Jan 2026 15:53:24 -0500 Subject: [PATCH 3/3] fix(docker): add DockerUpdateProgressService to integration test Co-Authored-By: Claude Opus 4.5 --- .../resolvers/docker/docker.service.integration.spec.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.service.integration.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker.service.integration.spec.ts index 942e0a204b..07db4f0780 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.service.integration.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.integration.spec.ts @@ -11,6 +11,7 @@ import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker- import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js'; import { DockerNetworkService } from '@app/unraid-api/graph/resolvers/docker/docker-network.service.js'; import { DockerPortService } from '@app/unraid-api/graph/resolvers/docker/docker-port.service.js'; +import { DockerUpdateProgressService } from '@app/unraid-api/graph/resolvers/docker/docker-update-progress.service.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js'; @@ -28,6 +29,12 @@ const mockDockerManifestService = { isUpdateAvailableCached: vi.fn().mockResolvedValue(false), }; +const mockDockerUpdateProgressService = { + updateContainerWithProgress: vi.fn().mockResolvedValue(undefined), + isUpdating: vi.fn().mockReturnValue(false), + getActiveUpdates: vi.fn().mockReturnValue([]), +}; + // Hoisted mock for paths const { mockPaths } = vi.hoisted(() => ({ mockPaths: { @@ -76,6 +83,7 @@ describe.runIf(dockerAvailable)('DockerService Integration', () => { DockerPortService, { provide: DockerConfigService, useValue: mockDockerConfigService }, { provide: DockerManifestService, useValue: mockDockerManifestService }, + { provide: DockerUpdateProgressService, useValue: mockDockerUpdateProgressService }, { provide: NotificationsService, useValue: mockNotificationsService }, ], }).compile();