diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 126a982ad6..8e18b766be 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -1342,16 +1342,16 @@ type TailscaleStatus { type Docker implements Node { id: PrefixedID! - containers(skipCache: Boolean! = false): [DockerContainer!]! - networks(skipCache: Boolean! = false): [DockerNetwork!]! - portConflicts(skipCache: Boolean! = false): DockerPortConflicts! + containers(skipCache: Boolean! = false @deprecated(reason: "Caching has been removed; this parameter is now ignored")): [DockerContainer!]! + networks(skipCache: Boolean! = false @deprecated(reason: "Caching has been removed; this parameter is now ignored")): [DockerNetwork!]! + portConflicts(skipCache: Boolean! = false @deprecated(reason: "Caching has been removed; this parameter is now ignored")): DockerPortConflicts! """ Access container logs. Requires specifying a target container id through resolver arguments. """ logs(id: PrefixedID!, since: DateTime, tail: Int): DockerContainerLogs! container(id: PrefixedID!): DockerContainer - organizer(skipCache: Boolean! = false): ResolvedOrganizerV1! + organizer(skipCache: Boolean! = false @deprecated(reason: "Caching has been removed; this parameter is now ignored")): ResolvedOrganizerV1! containerUpdateStatuses: [ExplicitStatusItem!]! } diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.service.spec.ts index 54e9d8c772..b387886f14 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.service.spec.ts @@ -24,7 +24,7 @@ describe('DockerTemplateScannerService', () => { await mkdir(testTemplateDir, { recursive: true }); const mockDockerService = { - getContainers: vi.fn(), + getRawContainers: vi.fn(), }; const mockDockerConfigService = { @@ -196,7 +196,7 @@ describe('DockerTemplateScannerService', () => { } as DockerContainer, ]; - vi.mocked(dockerService.getContainers).mockResolvedValue(containers); + vi.mocked(dockerService.getRawContainers).mockResolvedValue(containers); vi.mocked(dockerConfigService.getConfig).mockReturnValue({ updateCheckCronSchedule: '0 6 * * *', templateMappings: {}, @@ -236,7 +236,7 @@ describe('DockerTemplateScannerService', () => { } as DockerContainer, ]; - vi.mocked(dockerService.getContainers).mockResolvedValue(containers); + vi.mocked(dockerService.getRawContainers).mockResolvedValue(containers); vi.mocked(dockerConfigService.getConfig).mockReturnValue({ updateCheckCronSchedule: '0 6 * * *', templateMappings: {}, @@ -254,7 +254,7 @@ describe('DockerTemplateScannerService', () => { const containers: DockerContainer[] = []; - vi.mocked(dockerService.getContainers).mockResolvedValue(containers); + vi.mocked(dockerService.getRawContainers).mockResolvedValue(containers); vi.mocked(dockerConfigService.getConfig).mockReturnValue({ updateCheckCronSchedule: '0 6 * * *', templateMappings: {}, @@ -268,7 +268,7 @@ describe('DockerTemplateScannerService', () => { }); it('should handle docker service errors gracefully', async () => { - vi.mocked(dockerService.getContainers).mockRejectedValue(new Error('Docker error')); + vi.mocked(dockerService.getRawContainers).mockRejectedValue(new Error('Docker error')); vi.mocked(dockerConfigService.getConfig).mockReturnValue({ updateCheckCronSchedule: '0 6 * * *', templateMappings: {}, @@ -290,7 +290,7 @@ describe('DockerTemplateScannerService', () => { } as DockerContainer, ]; - vi.mocked(dockerService.getContainers).mockResolvedValue(containers); + vi.mocked(dockerService.getRawContainers).mockResolvedValue(containers); vi.mocked(dockerConfigService.getConfig).mockReturnValue({ updateCheckCronSchedule: '0 6 * * *', templateMappings: {}, @@ -325,7 +325,7 @@ describe('DockerTemplateScannerService', () => { skipTemplatePaths: [], }); - vi.mocked(dockerService.getContainers).mockResolvedValue(containers); + vi.mocked(dockerService.getRawContainers).mockResolvedValue(containers); const scanSpy = vi.spyOn(service, 'scanTemplates').mockResolvedValue({ scanned: 0, diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.service.ts index a3434077c7..deaf96b0c7 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.service.ts @@ -8,8 +8,10 @@ import { XMLParser } from 'fast-xml-parser'; import { ENABLE_NEXT_DOCKER_RELEASE, PATHS_DOCKER_TEMPLATES } from '@app/environment.js'; import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js'; import { DockerTemplateSyncResult } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.model.js'; -import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; -import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; +import { + DockerService, + RawDockerContainer, +} from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; interface ParsedTemplate { filePath: string; @@ -56,7 +58,7 @@ export class DockerTemplateScannerService { } } - async syncMissingContainers(containers: DockerContainer[]): Promise { + async syncMissingContainers(containers: RawDockerContainer[]): Promise { const config = this.dockerConfigService.getConfig(); const mappings = config.templateMappings || {}; const skipSet = new Set(config.skipTemplatePaths || []); @@ -87,7 +89,7 @@ export class DockerTemplateScannerService { const templates = await this.loadAllTemplates(result); try { - const containers = await this.dockerService.getContainers(); + const containers = await this.dockerService.getRawContainers(); const config = this.dockerConfigService.getConfig(); const currentMappings = config.templateMappings || {}; const skipSet = new Set(config.skipTemplatePaths || []); @@ -244,7 +246,7 @@ export class DockerTemplateScannerService { } private matchContainerToTemplate( - container: DockerContainer, + container: RawDockerContainer, templates: ParsedTemplate[] ): ParsedTemplate | null { const containerName = this.normalizeContainerName(container.names[0]); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.spec.ts index 5109fecab8..c2de95759a 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.spec.ts @@ -36,7 +36,14 @@ describe('DockerResolver', () => { { provide: DockerService, useValue: { - getContainers: vi.fn(), + getRawContainers: vi.fn(), + enrichWithOrphanStatus: vi.fn().mockImplementation((containers) => + containers.map((c: Record) => ({ + ...c, + isOrphaned: false, + templatePath: '/path/to/template.xml', + })) + ), getNetworks: vi.fn(), getContainerLogSizes: vi.fn(), getContainerLogs: vi.fn(), @@ -122,7 +129,7 @@ describe('DockerResolver', () => { }); it('should return containers from service', async () => { - const mockContainers: DockerContainer[] = [ + const mockRawContainers = [ { id: '1', autoStart: false, @@ -134,7 +141,6 @@ describe('DockerResolver', () => { ports: [], state: ContainerState.EXITED, status: 'Exited', - isOrphaned: false, }, { id: '2', @@ -147,24 +153,25 @@ describe('DockerResolver', () => { ports: [], state: ContainerState.RUNNING, status: 'Up 2 hours', - isOrphaned: false, }, ]; - vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers); + vi.mocked(dockerService.getRawContainers).mockResolvedValue(mockRawContainers); vi.mocked(GraphQLFieldHelper.isFieldRequested).mockImplementation(() => false); const mockInfo = {} as any; const result = await resolver.containers(false, mockInfo); - expect(result).toEqual(mockContainers); + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({ id: '1', isOrphaned: false }); + expect(result[1]).toMatchObject({ id: '2', isOrphaned: false }); expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeRootFs'); expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeRw'); expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeLog'); - expect(dockerService.getContainers).toHaveBeenCalledWith({ size: false }); + expect(dockerService.getRawContainers).toHaveBeenCalledWith({ size: false }); }); it('should request size when sizeRootFs field is requested', async () => { - const mockContainers: DockerContainer[] = [ + const mockRawContainers = [ { id: '1', autoStart: false, @@ -177,10 +184,9 @@ describe('DockerResolver', () => { sizeRootFs: 1024000, state: ContainerState.EXITED, status: 'Exited', - isOrphaned: false, }, ]; - vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers); + vi.mocked(dockerService.getRawContainers).mockResolvedValue(mockRawContainers); vi.mocked(GraphQLFieldHelper.isFieldRequested).mockImplementation((_, field) => { return field === 'sizeRootFs'; }); @@ -188,14 +194,15 @@ describe('DockerResolver', () => { const mockInfo = {} as any; const result = await resolver.containers(false, mockInfo); - expect(result).toEqual(mockContainers); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ id: '1', sizeRootFs: 1024000, isOrphaned: false }); expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeRootFs'); - expect(dockerService.getContainers).toHaveBeenCalledWith({ size: true }); + expect(dockerService.getRawContainers).toHaveBeenCalledWith({ size: true }); }); it('should request size when sizeRw field is requested', async () => { const mockContainers: DockerContainer[] = []; - vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers); + vi.mocked(dockerService.getRawContainers).mockResolvedValue(mockContainers); vi.mocked(GraphQLFieldHelper.isFieldRequested).mockImplementation((_, field) => { return field === 'sizeRw'; }); @@ -204,7 +211,7 @@ describe('DockerResolver', () => { await resolver.containers(false, mockInfo); expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeRw'); - expect(dockerService.getContainers).toHaveBeenCalledWith({ size: true }); + expect(dockerService.getRawContainers).toHaveBeenCalledWith({ size: true }); }); it('should fetch log sizes when sizeLog field is requested', async () => { @@ -223,7 +230,7 @@ describe('DockerResolver', () => { isOrphaned: false, }, ]; - vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers); + vi.mocked(dockerService.getRawContainers).mockResolvedValue(mockContainers); vi.mocked(GraphQLFieldHelper.isFieldRequested).mockImplementation((_, field) => { if (field === 'sizeLog') return true; return false; @@ -239,12 +246,12 @@ describe('DockerResolver', () => { expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeLog'); expect(dockerService.getContainerLogSizes).toHaveBeenCalledWith(['test-container']); expect(result[0]?.sizeLog).toBe(42); - expect(dockerService.getContainers).toHaveBeenCalledWith({ size: false }); + expect(dockerService.getRawContainers).toHaveBeenCalledWith({ size: false }); }); it('should request size when GraphQLFieldHelper indicates sizeRootFs is requested', async () => { const mockContainers: DockerContainer[] = []; - vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers); + vi.mocked(dockerService.getRawContainers).mockResolvedValue(mockContainers); vi.mocked(GraphQLFieldHelper.isFieldRequested).mockImplementation((_, field) => { return field === 'sizeRootFs'; }); @@ -253,31 +260,31 @@ describe('DockerResolver', () => { await resolver.containers(false, mockInfo); expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeRootFs'); - expect(dockerService.getContainers).toHaveBeenCalledWith({ size: true }); + expect(dockerService.getRawContainers).toHaveBeenCalledWith({ size: true }); }); it('should not request size when GraphQLFieldHelper indicates sizeRootFs is not requested', async () => { const mockContainers: DockerContainer[] = []; - vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers); + vi.mocked(dockerService.getRawContainers).mockResolvedValue(mockContainers); vi.mocked(GraphQLFieldHelper.isFieldRequested).mockImplementation(() => false); const mockInfo = {} as any; await resolver.containers(false, mockInfo); expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeRootFs'); - expect(dockerService.getContainers).toHaveBeenCalledWith({ size: false }); + expect(dockerService.getRawContainers).toHaveBeenCalledWith({ size: false }); }); it('skipCache parameter is deprecated and ignored', async () => { const mockContainers: DockerContainer[] = []; - vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers); + vi.mocked(dockerService.getRawContainers).mockResolvedValue(mockContainers); vi.mocked(GraphQLFieldHelper.isFieldRequested).mockReturnValue(false); const mockInfo = {} as any; // skipCache parameter is now deprecated and ignored await resolver.containers(true, mockInfo); - expect(dockerService.getContainers).toHaveBeenCalledWith({ size: false }); + expect(dockerService.getRawContainers).toHaveBeenCalledWith({ size: false }); }); it('should fetch container logs with provided arguments', async () => { 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 c03ad15462..51c6536cdb 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts @@ -98,27 +98,29 @@ export class DockerResolver { const requestsRootFsSize = GraphQLFieldHelper.isFieldRequested(info, 'sizeRootFs'); const requestsRwSize = GraphQLFieldHelper.isFieldRequested(info, 'sizeRw'); const requestsLogSize = GraphQLFieldHelper.isFieldRequested(info, 'sizeLog'); - const containers = await this.dockerService.getContainers({ + const rawContainers = await this.dockerService.getRawContainers({ size: requestsRootFsSize || requestsRwSize, }); if (requestsLogSize) { const names = Array.from( new Set( - containers + rawContainers .map((container) => container.names?.[0]?.replace(/^\//, '') || null) .filter((name): name is string => Boolean(name)) ) ); const logSizes = await this.dockerService.getContainerLogSizes(names); - containers.forEach((container) => { + rawContainers.forEach((container) => { const normalized = container.names?.[0]?.replace(/^\//, '') || ''; - container.sizeLog = normalized ? (logSizes.get(normalized) ?? 0) : 0; + (container as { sizeLog?: number }).sizeLog = normalized + ? (logSizes.get(normalized) ?? 0) + : 0; }); } - const wasSynced = await this.dockerTemplateScannerService.syncMissingContainers(containers); - return wasSynced ? await this.dockerService.getContainers() : containers; + await this.dockerTemplateScannerService.syncMissingContainers(rawContainers); + return this.dockerService.enrichWithOrphanStatus(rawContainers); } @UsePermissions({ 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 55acc78709..66f5d42b0a 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.ts @@ -24,6 +24,8 @@ import { } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; import { getDockerClient } from '@app/unraid-api/graph/resolvers/docker/utils/docker-client.js'; +export type RawDockerContainer = Omit; + @Injectable() export class DockerService { private client: Docker; @@ -63,7 +65,7 @@ export class DockerService { return this.autostartService.getAutoStarts(); } - public transformContainer(container: Docker.ContainerInfo): Omit { + public transformContainer(container: Docker.ContainerInfo): RawDockerContainer { const sizeValue = (container as Docker.ContainerInfo & { SizeRootFs?: number }).SizeRootFs; const primaryName = this.autostartService.getContainerPrimaryName(container) ?? ''; const autoStartEntry = primaryName @@ -91,7 +93,7 @@ export class DockerService { }; }); - const transformed: Omit = { + const transformed: RawDockerContainer = { id: container.Id, names: container.Names, image: container.Image, @@ -125,11 +127,24 @@ export class DockerService { return transformed; } - public async getContainers({ + public enrichWithOrphanStatus(containers: RawDockerContainer[]): DockerContainer[] { + const config = this.dockerConfigService.getConfig(); + return containers.map((c) => { + const containerName = c.names[0]?.replace(/^\//, '').toLowerCase() ?? ''; + const templatePath = config.templateMappings?.[containerName] || undefined; + return { + ...c, + templatePath, + isOrphaned: !templatePath, + }; + }); + } + + public async getRawContainers({ all = true, size = false, ...listOptions - }: Partial = {}): Promise { + }: Partial = {}): Promise { this.logger.debug(`Fetching docker containers (${size ? 'with' : 'without'} size)`); let rawContainers: Docker.ContainerInfo[] = []; try { @@ -143,20 +158,14 @@ export class DockerService { } await this.autostartService.refreshAutoStartEntries(); - const containers = rawContainers.map((container) => this.transformContainer(container)); - - const config = this.dockerConfigService.getConfig(); - const containersWithTemplatePaths = containers.map((c) => { - const containerName = c.names[0]?.replace(/^\//, '').toLowerCase() ?? ''; - const templatePath = config.templateMappings?.[containerName] || undefined; - return { - ...c, - templatePath, - isOrphaned: !templatePath, - }; - }); + return rawContainers.map((container) => this.transformContainer(container)); + } - return containersWithTemplatePaths; + public async getContainers( + options: Partial = {} + ): Promise { + const raw = await this.getRawContainers(options); + return this.enrichWithOrphanStatus(raw); } public async getPortConflicts(): Promise { diff --git a/api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.spec.ts index 12c80735a4..ee39ea5b5a 100644 --- a/api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.spec.ts @@ -3,6 +3,7 @@ import { Test } from '@nestjs/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; 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 { ContainerPortType, ContainerState, @@ -193,7 +194,7 @@ describe('DockerOrganizerService', () => { { provide: DockerService, useValue: { - getContainers: vi.fn().mockResolvedValue([ + getRawContainers: vi.fn().mockResolvedValue([ { id: 'container1', names: ['container1'], @@ -219,6 +220,13 @@ describe('DockerOrganizerService', () => { autoStart: true, }, ]), + enrichWithOrphanStatus: vi.fn().mockImplementation((containers) => + containers.map((c: Record) => ({ + ...c, + isOrphaned: false, + templatePath: '/path/to/template.xml', + })) + ), }, }, { @@ -227,6 +235,12 @@ describe('DockerOrganizerService', () => { getIconsForContainers: vi.fn().mockResolvedValue(new Map()), }, }, + { + provide: DockerTemplateScannerService, + useValue: { + syncMissingContainers: vi.fn().mockResolvedValue(false), + }, + }, ], }).compile(); @@ -551,7 +565,7 @@ describe('DockerOrganizerService', () => { it('should handle docker service failure gracefully', async () => { const dockerError = new Error('Docker service unavailable'); - (dockerService.getContainers as any).mockRejectedValue(dockerError); + (dockerService.getRawContainers as any).mockRejectedValue(dockerError); await expect( service.deleteEntries({ @@ -685,7 +699,7 @@ describe('DockerOrganizerService', () => { const TO_DELETE = ['entryB', 'entryD']; const EXPECTED_REMAINING = ['entryA', 'entryC']; - // Mock getContainers to return containers matching our test entries + // Mock getRawContainers to return containers matching our test entries const mockContainers = ENTRIES.map((entryId, i) => ({ id: `container-${entryId}`, names: [`/${entryId}`], @@ -698,7 +712,7 @@ describe('DockerOrganizerService', () => { status: 'Up 1 hour', autoStart: true, })); - (dockerService.getContainers as any).mockResolvedValue(mockContainers); + (dockerService.getRawContainers as any).mockResolvedValue(mockContainers); const organizerWithOrdering = createTestOrganizer(); const rootFolder = getRootFolder(organizerWithOrdering); diff --git a/api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.ts b/api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.ts index 7ae6075823..510700b137 100644 --- a/api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.ts @@ -3,6 +3,7 @@ import { Injectable, Logger } from '@nestjs/common'; import type { ContainerListOptions } from 'dockerode'; import { AppError } from '@app/core/errors/app-error.js'; +import { DockerTemplateScannerService } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.service.js'; import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.js'; @@ -51,11 +52,14 @@ export class DockerOrganizerService { private readonly logger = new Logger(DockerOrganizerService.name); constructor( private readonly dockerConfigService: DockerOrganizerConfigService, - private readonly dockerService: DockerService + private readonly dockerService: DockerService, + private readonly dockerTemplateScannerService: DockerTemplateScannerService ) {} async getResources(opts?: Partial): Promise { - const containers = await this.dockerService.getContainers(opts); + const rawContainers = await this.dockerService.getRawContainers(opts); + await this.dockerTemplateScannerService.syncMissingContainers(rawContainers); + const containers = this.dockerService.enrichWithOrphanStatus(rawContainers); return containerListToResourcesObject(containers); } diff --git a/web/src/components/Docker/DockerOrphanedAlert.vue b/web/src/components/Docker/DockerOrphanedAlert.vue index 7d1d8c0d5a..7296a5fc86 100644 --- a/web/src/components/Docker/DockerOrphanedAlert.vue +++ b/web/src/components/Docker/DockerOrphanedAlert.vue @@ -1,4 +1,5 @@