Skip to content
Merged
8 changes: 4 additions & 4 deletions api/generated-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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!]!
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('DockerTemplateScannerService', () => {
await mkdir(testTemplateDir, { recursive: true });

const mockDockerService = {
getContainers: vi.fn(),
getRawContainers: vi.fn(),
};

const mockDockerConfigService = {
Expand Down Expand Up @@ -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: {},
Expand Down Expand Up @@ -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: {},
Expand All @@ -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: {},
Expand All @@ -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: {},
Expand All @@ -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: {},
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -56,7 +58,7 @@ export class DockerTemplateScannerService {
}
}

async syncMissingContainers(containers: DockerContainer[]): Promise<boolean> {
async syncMissingContainers(containers: RawDockerContainer[]): Promise<boolean> {
const config = this.dockerConfigService.getConfig();
const mappings = config.templateMappings || {};
const skipSet = new Set(config.skipTemplatePaths || []);
Expand Down Expand Up @@ -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 || []);
Expand Down Expand Up @@ -244,7 +246,7 @@ export class DockerTemplateScannerService {
}

private matchContainerToTemplate(
container: DockerContainer,
container: RawDockerContainer,
templates: ParsedTemplate[]
): ParsedTemplate | null {
const containerName = this.normalizeContainerName(container.names[0]);
Expand Down
51 changes: 29 additions & 22 deletions api/src/unraid-api/graph/resolvers/docker/docker.resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,14 @@ describe('DockerResolver', () => {
{
provide: DockerService,
useValue: {
getContainers: vi.fn(),
getRawContainers: vi.fn(),
enrichWithOrphanStatus: vi.fn().mockImplementation((containers) =>
containers.map((c: Record<string, unknown>) => ({
...c,
isOrphaned: false,
templatePath: '/path/to/template.xml',
}))
),
getNetworks: vi.fn(),
getContainerLogSizes: vi.fn(),
getContainerLogs: vi.fn(),
Expand Down Expand Up @@ -122,7 +129,7 @@ describe('DockerResolver', () => {
});

it('should return containers from service', async () => {
const mockContainers: DockerContainer[] = [
const mockRawContainers = [
{
id: '1',
autoStart: false,
Expand All @@ -134,7 +141,6 @@ describe('DockerResolver', () => {
ports: [],
state: ContainerState.EXITED,
status: 'Exited',
isOrphaned: false,
},
{
id: '2',
Expand All @@ -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,
Expand All @@ -177,25 +184,25 @@ 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';
});

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';
});
Expand All @@ -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 () => {
Expand All @@ -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;
Expand All @@ -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';
});
Expand All @@ -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 () => {
Expand Down
14 changes: 8 additions & 6 deletions api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading
Loading