diff --git a/api/.env.development b/api/.env.development index 35a4b30d71..6a22de0ead 100644 --- a/api/.env.development +++ b/api/.env.development @@ -19,6 +19,7 @@ PATHS_LOGS_FILE=./dev/log/graphql-api.log PATHS_CONNECT_STATUS_FILE_PATH=./dev/connectStatus.json # Connect plugin status file PATHS_OIDC_JSON=./dev/configs/oidc.local.json PATHS_LOCAL_SESSION_FILE=./dev/local-session +PATHS_DOCKER_TEMPLATES=./dev/docker-templates ENVIRONMENT="development" NODE_ENV="development" PORT="3001" diff --git a/api/.env.production b/api/.env.production index b7083f3715..7cb5e45373 100644 --- a/api/.env.production +++ b/api/.env.production @@ -3,3 +3,4 @@ NODE_ENV="production" PORT="/var/run/unraid-api.sock" MOTHERSHIP_GRAPHQL_LINK="https://mothership.unraid.net/ws" PATHS_CONFIG_MODULES="/boot/config/plugins/dynamix.my.servers/configs" +ENABLE_NEXT_DOCKER_RELEASE=true diff --git a/api/.env.staging b/api/.env.staging index cb526b0eb7..8a1baef66a 100644 --- a/api/.env.staging +++ b/api/.env.staging @@ -3,3 +3,4 @@ NODE_ENV="production" PORT="/var/run/unraid-api.sock" MOTHERSHIP_GRAPHQL_LINK="https://staging.mothership.unraid.net/ws" PATHS_CONFIG_MODULES="/boot/config/plugins/dynamix.my.servers/configs" +ENABLE_NEXT_DOCKER_RELEASE=true diff --git a/api/.eslintrc.ts b/api/.eslintrc.ts index 5556a7488a..f58333268a 100644 --- a/api/.eslintrc.ts +++ b/api/.eslintrc.ts @@ -8,7 +8,7 @@ export default tseslint.config( eslint.configs.recommended, ...tseslint.configs.recommended, { - ignores: ['src/graphql/generated/client/**/*', 'src/**/**/dummy-process.js'], + ignores: ['src/graphql/generated/client/**/*', 'src/**/**/dummy-process.js', 'dist/**/*'], }, { plugins: { diff --git a/api/.gitignore b/api/.gitignore index 77fdfdbeee..ac324a27ff 100644 --- a/api/.gitignore +++ b/api/.gitignore @@ -83,6 +83,8 @@ deploy/* !**/*.login.* +# Local Development Artifacts + # local api configs - don't need project-wide tracking dev/connectStatus.json dev/configs/* @@ -96,3 +98,7 @@ dev/configs/oidc.local.json # local api keys dev/keys/* +# mock docker templates +dev/docker-templates +# ie unraid notifications +dev/notifications \ No newline at end of file diff --git a/api/.prettierignore b/api/.prettierignore index 6ef230e8a2..ccd169f755 100644 --- a/api/.prettierignore +++ b/api/.prettierignore @@ -5,3 +5,4 @@ src/unraid-api/unraid-file-modifier/modifications/__fixtures__/downloaded/* # Generated Types src/graphql/generated/client/*.ts +dist/ diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index acaf5daa92..e09b0f3f55 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -1,5 +1,5 @@ { - "version": "4.25.3", + "version": "4.27.2", "extraOrigins": [], "sandbox": true, "ssoSubIds": [], diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 0dfe521f9e..9095f829f2 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -862,6 +862,38 @@ type DockerMutations { """Stop a container""" stop(id: PrefixedID!): DockerContainer! + + """Pause (Suspend) a container""" + pause(id: PrefixedID!): DockerContainer! + + """Unpause (Resume) a container""" + unpause(id: PrefixedID!): DockerContainer! + + """Remove a container""" + removeContainer(id: PrefixedID!): Boolean! + + """Update auto-start configuration for Docker containers""" + updateAutostartConfiguration(entries: [DockerAutostartEntryInput!]!, persistUserPreferences: Boolean): Boolean! + + """Update a container to the latest image""" + updateContainer(id: PrefixedID!): DockerContainer! + + """Update multiple containers to the latest images""" + updateContainers(ids: [PrefixedID!]!): [DockerContainer!]! + + """Update all containers that have available updates""" + updateAllContainers: [DockerContainer!]! +} + +input DockerAutostartEntryInput { + """Docker container identifier""" + id: PrefixedID! + + """Whether the container should auto-start""" + autoStart: Boolean! + + """Number of seconds to wait after starting the container""" + wait: Int } type VmMutations { @@ -1080,6 +1112,29 @@ enum ContainerPortType { UDP } +type DockerPortConflictContainer { + id: PrefixedID! + name: String! +} + +type DockerContainerPortConflict { + privatePort: Port! + type: ContainerPortType! + containers: [DockerPortConflictContainer!]! +} + +type DockerLanPortConflict { + lanIpPort: String! + publicPort: Port + type: ContainerPortType! + containers: [DockerPortConflictContainer!]! +} + +type DockerPortConflicts { + containerPorts: [DockerContainerPortConflict!]! + lanPorts: [DockerLanPortConflict!]! +} + type ContainerHostConfig { networkMode: String! } @@ -1093,8 +1148,17 @@ type DockerContainer implements Node { created: Int! ports: [ContainerPort!]! + """List of LAN-accessible host:port values""" + lanIpPorts: [String!] + """Total size of all files in the container (in bytes)""" sizeRootFs: BigInt + + """Size of writable layer (in bytes)""" + sizeRw: BigInt + + """Size of container logs (in bytes)""" + sizeLog: BigInt labels: JSON state: ContainerState! status: String! @@ -1102,12 +1166,35 @@ type DockerContainer implements Node { networkSettings: JSON mounts: [JSON!] autoStart: Boolean! + + """Zero-based order in the auto-start list""" + autoStartOrder: Int + + """Wait time in seconds applied after start""" + autoStartWait: Int + templatePath: String + + """Project/Product homepage URL""" + projectUrl: String + + """Registry/Docker Hub URL""" + registryUrl: String + + """Support page/thread URL""" + supportUrl: String + + """Icon URL""" + iconUrl: String + + """Whether the container is orphaned (no template found)""" + isOrphaned: Boolean! isUpdateAvailable: Boolean isRebuildReady: Boolean } enum ContainerState { RUNNING + PAUSED EXITED } @@ -1129,42 +1216,133 @@ type DockerNetwork implements Node { labels: JSON! } +type DockerContainerLogLine { + timestamp: DateTime! + message: String! +} + +type DockerContainerLogs { + containerId: PrefixedID! + lines: [DockerContainerLogLine!]! + + """ + Cursor that can be passed back through the since argument to continue streaming logs. + """ + cursor: DateTime +} + +type DockerContainerStats { + id: PrefixedID! + + """CPU Usage Percentage""" + cpuPercent: Float! + + """Memory Usage String (e.g. 100MB / 1GB)""" + memUsage: String! + + """Memory Usage Percentage""" + memPercent: Float! + + """Network I/O String (e.g. 100MB / 1GB)""" + netIO: String! + + """Block I/O String (e.g. 100MB / 1GB)""" + blockIO: String! +} + type Docker implements Node { id: PrefixedID! containers(skipCache: Boolean! = false): [DockerContainer!]! networks(skipCache: Boolean! = false): [DockerNetwork!]! - organizer: ResolvedOrganizerV1! + portConflicts(skipCache: Boolean! = false): DockerPortConflicts! + + """ + Access container logs. Requires specifying a target container id through resolver arguments. + """ + logs(id: PrefixedID!, since: DateTime, tail: Int): DockerContainerLogs! + organizer(skipCache: Boolean! = false): ResolvedOrganizerV1! containerUpdateStatuses: [ExplicitStatusItem!]! } -type ResolvedOrganizerView { - id: String! - name: String! - root: ResolvedOrganizerEntry! - prefs: JSON +type DockerContainerOverviewForm { + id: ID! + dataSchema: JSON! + uiSchema: JSON! + data: JSON! } -union ResolvedOrganizerEntry = ResolvedOrganizerFolder | OrganizerContainerResource | OrganizerResource +type NotificationCounts { + info: Int! + warning: Int! + alert: Int! + total: Int! +} -type ResolvedOrganizerFolder { - id: String! - type: String! - name: String! - children: [ResolvedOrganizerEntry!]! +type NotificationOverview { + unread: NotificationCounts! + archive: NotificationCounts! } -type OrganizerContainerResource { - id: String! - type: String! - name: String! - meta: DockerContainer +type Notification implements Node { + id: PrefixedID! + + """Also known as 'event'""" + title: String! + subject: String! + description: String! + importance: NotificationImportance! + link: String + type: NotificationType! + + """ISO Timestamp for when the notification occurred""" + timestamp: String + formattedTimestamp: String } -type OrganizerResource { +enum NotificationImportance { + ALERT + INFO + WARNING +} + +enum NotificationType { + UNREAD + ARCHIVE +} + +type Notifications implements Node { + id: PrefixedID! + + """A cached overview of the notifications in the system & their severity.""" + overview: NotificationOverview! + list(filter: NotificationFilter!): [Notification!]! + + """ + Deduplicated list of unread warning and alert notifications, sorted latest first. + """ + warningsAndAlerts: [Notification!]! +} + +input NotificationFilter { + importance: NotificationImportance + type: NotificationType! + offset: Int! + limit: Int! +} + +type DockerTemplateSyncResult { + scanned: Int! + matched: Int! + skipped: Int! + errors: [String!]! +} + +type ResolvedOrganizerView { id: String! - type: String! name: String! - meta: JSON + rootId: String! + flatEntries: [FlatOrganizerEntry!]! + prefs: JSON } type ResolvedOrganizerV1 { @@ -1172,6 +1350,19 @@ type ResolvedOrganizerV1 { views: [ResolvedOrganizerView!]! } +type FlatOrganizerEntry { + id: String! + type: String! + name: String! + parentId: String + depth: Float! + position: Float! + path: [String!]! + hasChildren: Boolean! + childrenIds: [String!]! + meta: DockerContainer +} + type FlashBackupStatus { """Status message indicating the outcome of the backup initiation.""" status: String! @@ -1772,60 +1963,6 @@ type Metrics implements Node { memory: MemoryUtilization } -type NotificationCounts { - info: Int! - warning: Int! - alert: Int! - total: Int! -} - -type NotificationOverview { - unread: NotificationCounts! - archive: NotificationCounts! -} - -type Notification implements Node { - id: PrefixedID! - - """Also known as 'event'""" - title: String! - subject: String! - description: String! - importance: NotificationImportance! - link: String - type: NotificationType! - - """ISO Timestamp for when the notification occurred""" - timestamp: String - formattedTimestamp: String -} - -enum NotificationImportance { - ALERT - INFO - WARNING -} - -enum NotificationType { - UNREAD - ARCHIVE -} - -type Notifications implements Node { - id: PrefixedID! - - """A cached overview of the notifications in the system & their severity.""" - overview: NotificationOverview! - list(filter: NotificationFilter!): [Notification!]! -} - -input NotificationFilter { - importance: NotificationImportance - type: NotificationType! - offset: Int! - limit: Int! -} - type Owner { username: String! url: String! @@ -2388,6 +2525,7 @@ type Query { publicPartnerInfo: PublicPartnerInfo publicTheme: Theme! docker: Docker! + dockerContainerOverviewForm(skipCache: Boolean! = false): DockerContainerOverviewForm! disks: [Disk!]! disk(id: PrefixedID!): Disk! rclone: RCloneBackupSettings! @@ -2435,6 +2573,11 @@ type Mutation { """Marks a notification as archived.""" archiveNotification(id: PrefixedID!): Notification! archiveNotifications(ids: [PrefixedID!]!): NotificationOverview! + + """ + Creates a notification if an equivalent unread notification does not already exist. + """ + notifyIfUnique(input: NotificationData!): Notification archiveAll(importance: NotificationImportance): NotificationOverview! """Marks a notification as unread.""" @@ -2454,6 +2597,11 @@ type Mutation { setDockerFolderChildren(folderId: String, childrenIds: [String!]!): ResolvedOrganizerV1! deleteDockerEntries(entryIds: [String!]!): ResolvedOrganizerV1! moveDockerEntriesToFolder(sourceEntryIds: [String!]!, destinationFolderId: String!): ResolvedOrganizerV1! + moveDockerItemsToPosition(sourceEntryIds: [String!]!, destinationFolderId: String!, position: Float!): ResolvedOrganizerV1! + renameDockerFolder(folderId: String!, newName: String!): ResolvedOrganizerV1! + createDockerFolderWithItems(name: String!, parentId: String, sourceEntryIds: [String!], position: Float): ResolvedOrganizerV1! + updateDockerViewPreferences(viewId: String = "default", prefs: JSON!): ResolvedOrganizerV1! + syncDockerTemplatePaths: DockerTemplateSyncResult! refreshDockerDigests: Boolean! """Initiates a flash drive backup using a configured remote.""" @@ -2655,10 +2803,12 @@ input AccessUrlInput { type Subscription { notificationAdded: Notification! notificationsOverview: NotificationOverview! + notificationsWarningsAndAlerts: [Notification!]! ownerSubscription: Owner! serversSubscription: Server! parityHistorySubscription: ParityCheck! arraySubscription: UnraidArray! + dockerContainerStats: DockerContainerStats! logFile(path: String!): LogFileContent! systemMetricsCpu: CpuUtilization! systemMetricsCpuTelemetry: CpuPackages! diff --git a/api/justfile b/api/justfile index 2542ccca3a..0b064fdd86 100644 --- a/api/justfile +++ b/api/justfile @@ -12,8 +12,13 @@ default: @deploy remote: ./scripts/deploy-dev.sh {{remote}} +# watches typescript files and restarts dev server on changes +@watch: + watchexec -e ts -r -- pnpm dev + alias b := build alias d := deploy +alias w := watch sync-env server: rsync -avz --progress --stats -e ssh .env* root@{{server}}:/usr/local/unraid-api diff --git a/api/package.json b/api/package.json index 26e51095bf..1df4395745 100644 --- a/api/package.json +++ b/api/package.json @@ -104,6 +104,7 @@ "escape-html": "1.0.3", "execa": "9.6.0", "exit-hook": "4.0.0", + "fast-xml-parser": "^5.3.0", "fastify": "5.5.0", "filenamify": "7.0.0", "fs-extra": "11.3.1", diff --git a/api/src/__test__/store/modules/__snapshots__/paths.test.ts.snap b/api/src/__test__/store/modules/__snapshots__/paths.test.ts.snap index 2bd80788c0..d131c4b49d 100644 --- a/api/src/__test__/store/modules/__snapshots__/paths.test.ts.snap +++ b/api/src/__test__/store/modules/__snapshots__/paths.test.ts.snap @@ -6,6 +6,7 @@ exports[`Returns paths 1`] = ` "unraid-api-base", "unraid-data", "docker-autostart", + "docker-userprefs", "docker-socket", "rclone-socket", "parity-checks", diff --git a/api/src/__test__/store/modules/paths.test.ts b/api/src/__test__/store/modules/paths.test.ts index 0fae0dabcd..2630c34b5f 100644 --- a/api/src/__test__/store/modules/paths.test.ts +++ b/api/src/__test__/store/modules/paths.test.ts @@ -11,6 +11,7 @@ test('Returns paths', async () => { 'unraid-api-base': '/usr/local/unraid-api/', 'unraid-data': expect.stringContaining('api/dev/data'), 'docker-autostart': '/var/lib/docker/unraid-autostart', + 'docker-userprefs': '/boot/config/plugins/dockerMan/userprefs.cfg', 'docker-socket': '/var/run/docker.sock', 'parity-checks': expect.stringContaining('api/dev/states/parity-checks.log'), htpasswd: '/etc/nginx/htpasswd', diff --git a/api/src/core/utils/misc/catch-handlers.ts b/api/src/core/utils/misc/catch-handlers.ts index 48b3341135..48a4cd4ce1 100644 --- a/api/src/core/utils/misc/catch-handlers.ts +++ b/api/src/core/utils/misc/catch-handlers.ts @@ -2,7 +2,7 @@ import { AppError } from '@app/core/errors/app-error.js'; import { getters } from '@app/store/index.js'; interface DockerError extends NodeJS.ErrnoException { - address: string; + address?: string; } /** diff --git a/api/src/core/utils/network.ts b/api/src/core/utils/network.ts new file mode 100644 index 0000000000..98d9a12b0b --- /dev/null +++ b/api/src/core/utils/network.ts @@ -0,0 +1,19 @@ +import { getters } from '@app/store/index.js'; + +/** + * Returns the LAN IPv4 address reported by emhttp, if available. + */ +export function getLanIp(): string { + const emhttp = getters.emhttp(); + const lanFromNetworks = emhttp?.networks?.[0]?.ipaddr?.[0]; + if (lanFromNetworks) { + return lanFromNetworks; + } + + const lanFromNginx = emhttp?.nginx?.lanIp; + if (lanFromNginx) { + return lanFromNginx; + } + + return ''; +} diff --git a/api/src/environment.ts b/api/src/environment.ts index b1d3c2bad3..2cd0b6c4a2 100644 --- a/api/src/environment.ts +++ b/api/src/environment.ts @@ -111,5 +111,10 @@ export const PATHS_CONFIG_MODULES = export const PATHS_LOCAL_SESSION_FILE = process.env.PATHS_LOCAL_SESSION_FILE ?? '/var/run/unraid-api/local-session'; +export const PATHS_DOCKER_TEMPLATES = process.env.PATHS_DOCKER_TEMPLATES?.split(',') ?? [ + '/boot/config/plugins/dockerMan/templates-user', + '/boot/config/plugins/dockerMan/templates', +]; + /** feature flag for the upcoming docker release */ export const ENABLE_NEXT_DOCKER_RELEASE = process.env.ENABLE_NEXT_DOCKER_RELEASE === 'true'; diff --git a/api/src/store/modules/paths.ts b/api/src/store/modules/paths.ts index e42e4d83a8..548dfb777e 100644 --- a/api/src/store/modules/paths.ts +++ b/api/src/store/modules/paths.ts @@ -20,6 +20,7 @@ const initialState = { process.env.PATHS_UNRAID_DATA ?? ('/boot/config/plugins/dynamix.my.servers/data/' as const) ), 'docker-autostart': '/var/lib/docker/unraid-autostart' as const, + 'docker-userprefs': '/boot/config/plugins/dockerMan/userprefs.cfg' as const, 'docker-socket': '/var/run/docker.sock' as const, 'rclone-socket': resolvePath(process.env.PATHS_RCLONE_SOCKET ?? ('/var/run/rclone.socket' as const)), 'parity-checks': resolvePath( diff --git a/api/src/unraid-api/app/__test__/app.module.integration.spec.ts b/api/src/unraid-api/app/__test__/app.module.integration.spec.ts index 7ed7c87d01..8ca743610c 100644 --- a/api/src/unraid-api/app/__test__/app.module.integration.spec.ts +++ b/api/src/unraid-api/app/__test__/app.module.integration.spec.ts @@ -6,102 +6,60 @@ import { AuthZGuard } from 'nest-authz'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { loadDynamixConfig, store } from '@app/store/index.js'; -import { loadStateFiles } from '@app/store/modules/emhttp.js'; import { AppModule } from '@app/unraid-api/app/app.module.js'; import { AuthService } from '@app/unraid-api/auth/auth.service.js'; import { AuthenticationGuard } from '@app/unraid-api/auth/authentication.guard.js'; -import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; - -// Mock external system boundaries that we can't control in tests -vi.mock('dockerode', () => { - return { - default: vi.fn().mockImplementation(() => ({ - listContainers: vi.fn().mockResolvedValue([ - { - Id: 'test-container-1', - Names: ['/test-container'], - State: 'running', - Status: 'Up 5 minutes', - Image: 'test:latest', - Command: 'node server.js', - Created: Date.now() / 1000, - Ports: [ - { - IP: '0.0.0.0', - PrivatePort: 3000, - PublicPort: 3000, - Type: 'tcp', - }, - ], - Labels: {}, - HostConfig: { - NetworkMode: 'bridge', - }, - NetworkSettings: { - Networks: {}, - }, - Mounts: [], - }, - ]), - getContainer: vi.fn().mockImplementation((id) => ({ - inspect: vi.fn().mockResolvedValue({ - Id: id, - Name: '/test-container', - State: { Running: true }, - Config: { Image: 'test:latest' }, - }), - })), - listImages: vi.fn().mockResolvedValue([]), - listNetworks: vi.fn().mockResolvedValue([]), - listVolumes: vi.fn().mockResolvedValue({ Volumes: [] }), - })), - }; -}); -// Mock external command execution -vi.mock('execa', () => ({ - execa: vi.fn().mockImplementation((cmd) => { - if (cmd === 'whoami') { - return Promise.resolve({ stdout: 'testuser' }); - } - return Promise.resolve({ stdout: 'mocked output' }); - }), +// Mock the store before importing it +vi.mock('@app/store/index.js', () => ({ + store: { + dispatch: vi.fn().mockResolvedValue(undefined), + subscribe: vi.fn().mockImplementation(() => vi.fn()), + getState: vi.fn().mockReturnValue({ + emhttp: { + var: { + csrfToken: 'test-csrf-token', + }, + }, + docker: { + containers: [], + autostart: [], + }, + }), + unsubscribe: vi.fn(), + }, + getters: { + emhttp: vi.fn().mockReturnValue({ + var: { + csrfToken: 'test-csrf-token', + }, + }), + docker: vi.fn().mockReturnValue({ + containers: [], + autostart: [], + }), + paths: vi.fn().mockReturnValue({ + 'docker-autostart': '/tmp/docker-autostart', + 'docker-socket': '/var/run/docker.sock', + 'var-run': '/var/run', + 'auth-keys': '/tmp/auth-keys', + activationBase: '/tmp/activation', + 'dynamix-config': ['/tmp/dynamix-config', '/tmp/dynamix-config'], + identConfig: '/tmp/ident.cfg', + }), + dynamix: vi.fn().mockReturnValue({ + notify: { + path: '/tmp/notifications', + }, + }), + }, + loadDynamixConfig: vi.fn(), + loadStateFiles: vi.fn().mockResolvedValue(undefined), })); -// Mock child_process for services that spawn processes -vi.mock('node:child_process', () => ({ - spawn: vi.fn(() => ({ - on: vi.fn(), - kill: vi.fn(), - stdout: { on: vi.fn() }, - stderr: { on: vi.fn() }, - })), -})); - -// Mock file system operations that would fail in test environment -vi.mock('node:fs/promises', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - readFile: vi.fn().mockResolvedValue(''), - writeFile: vi.fn().mockResolvedValue(undefined), - mkdir: vi.fn().mockResolvedValue(undefined), - access: vi.fn().mockResolvedValue(undefined), - stat: vi.fn().mockResolvedValue({ isFile: () => true }), - readdir: vi.fn().mockResolvedValue([]), - rename: vi.fn().mockResolvedValue(undefined), - unlink: vi.fn().mockResolvedValue(undefined), - }; -}); - -// Mock fs module for synchronous operations -vi.mock('node:fs', () => ({ - existsSync: vi.fn().mockReturnValue(false), - readFileSync: vi.fn().mockReturnValue(''), - writeFileSync: vi.fn(), - mkdirSync: vi.fn(), - readdirSync: vi.fn().mockReturnValue([]), +// Mock fs-extra for directory operations +vi.mock('fs-extra', () => ({ + ensureDirSync: vi.fn().mockReturnValue(undefined), })); describe('AppModule Integration Tests', () => { @@ -109,14 +67,6 @@ describe('AppModule Integration Tests', () => { let moduleRef: TestingModule; beforeAll(async () => { - // Initialize the dynamix config and state files before creating the module - await store.dispatch(loadStateFiles()); - loadDynamixConfig(); - - // Debug: Log the CSRF token from the store - const { getters } = await import('@app/store/index.js'); - console.log('CSRF Token from store:', getters.emhttp().var.csrfToken); - moduleRef = await Test.createTestingModule({ imports: [AppModule], }) @@ -149,14 +99,6 @@ describe('AppModule Integration Tests', () => { roles: ['admin'], }), }) - // Override Redis client - .overrideProvider('REDIS_CLIENT') - .useValue({ - get: vi.fn(), - set: vi.fn(), - del: vi.fn(), - connect: vi.fn(), - }) .compile(); app = moduleRef.createNestApplication(new FastifyAdapter()); @@ -177,9 +119,9 @@ describe('AppModule Integration Tests', () => { }); it('should resolve core services', () => { - const dockerService = moduleRef.get(DockerService); + const authService = moduleRef.get(AuthService); - expect(dockerService).toBeDefined(); + expect(authService).toBeDefined(); }); }); @@ -238,18 +180,12 @@ describe('AppModule Integration Tests', () => { }); describe('Service Integration', () => { - it('should have working service-to-service communication', async () => { - const dockerService = moduleRef.get(DockerService); - - // Test that the service can be called and returns expected data structure - const containers = await dockerService.getContainers(); - - expect(containers).toBeInstanceOf(Array); - // The containers might be empty or cached, just verify structure - if (containers.length > 0) { - expect(containers[0]).toHaveProperty('id'); - expect(containers[0]).toHaveProperty('names'); - } + it('should have working service-to-service communication', () => { + // Test that the module can resolve its services without errors + // This validates that dependency injection is working correctly + const authService = moduleRef.get(AuthService); + expect(authService).toBeDefined(); + expect(typeof authService.validateCookiesWithCsrfToken).toBe('function'); }); }); }); diff --git a/api/src/unraid-api/auth/api-key.service.ts b/api/src/unraid-api/auth/api-key.service.ts index 7c0a90e543..cfad48ef1b 100644 --- a/api/src/unraid-api/auth/api-key.service.ts +++ b/api/src/unraid-api/auth/api-key.service.ts @@ -183,6 +183,11 @@ export class ApiKeyService implements OnModuleInit { async loadAllFromDisk(): Promise { const files = await readdir(this.basePath).catch((error) => { + if (error.code === 'ENOENT') { + // Directory doesn't exist, which means no API keys have been created yet + this.logger.error(`API key directory does not exist: ${this.basePath}`); + return []; + } this.logger.error(`Failed to read API key directory: ${error}`); throw new Error('Failed to list API keys'); }); diff --git a/api/src/unraid-api/cli/generated/graphql.ts b/api/src/unraid-api/cli/generated/graphql.ts index 97e116fcbb..b69c6d06a5 100644 --- a/api/src/unraid-api/cli/generated/graphql.ts +++ b/api/src/unraid-api/cli/generated/graphql.ts @@ -525,6 +525,7 @@ export enum ContainerPortType { export enum ContainerState { EXITED = 'EXITED', + PAUSED = 'PAUSED', RUNNING = 'RUNNING' } @@ -559,6 +560,17 @@ export type CpuLoad = { percentUser: Scalars['Float']['output']; }; +export type CpuPackages = Node & { + __typename?: 'CpuPackages'; + id: Scalars['PrefixedID']['output']; + /** Power draw per package (W) */ + power: Array; + /** Temperature per package (°C) */ + temp: Array; + /** Total CPU package power draw (W) */ + totalPower: Scalars['Float']['output']; +}; + export type CpuUtilization = Node & { __typename?: 'CpuUtilization'; /** CPU load for each core */ @@ -681,8 +693,11 @@ export type Docker = Node & { containerUpdateStatuses: Array; containers: Array; id: Scalars['PrefixedID']['output']; + /** Access container logs. Requires specifying a target container id through resolver arguments. */ + logs: DockerContainerLogs; networks: Array; organizer: ResolvedOrganizerV1; + portConflicts: DockerPortConflicts; }; @@ -691,38 +706,161 @@ export type DockerContainersArgs = { }; +export type DockerLogsArgs = { + id: Scalars['PrefixedID']['input']; + since?: InputMaybe; + tail?: InputMaybe; +}; + + export type DockerNetworksArgs = { skipCache?: Scalars['Boolean']['input']; }; + +export type DockerOrganizerArgs = { + skipCache?: Scalars['Boolean']['input']; +}; + + +export type DockerPortConflictsArgs = { + skipCache?: Scalars['Boolean']['input']; +}; + +export type DockerAutostartEntryInput = { + /** Whether the container should auto-start */ + autoStart: Scalars['Boolean']['input']; + /** Docker container identifier */ + id: Scalars['PrefixedID']['input']; + /** Number of seconds to wait after starting the container */ + wait?: InputMaybe; +}; + export type DockerContainer = Node & { __typename?: 'DockerContainer'; autoStart: Scalars['Boolean']['output']; + /** Zero-based order in the auto-start list */ + autoStartOrder?: Maybe; + /** Wait time in seconds applied after start */ + autoStartWait?: Maybe; command: Scalars['String']['output']; created: Scalars['Int']['output']; hostConfig?: Maybe; + /** Icon URL */ + iconUrl?: Maybe; id: Scalars['PrefixedID']['output']; image: Scalars['String']['output']; imageId: Scalars['String']['output']; + /** Whether the container is orphaned (no template found) */ + isOrphaned: Scalars['Boolean']['output']; isRebuildReady?: Maybe; isUpdateAvailable?: Maybe; labels?: Maybe; + /** List of LAN-accessible host:port values */ + lanIpPorts?: Maybe>; mounts?: Maybe>; names: Array; networkSettings?: Maybe; ports: Array; + /** Project/Product homepage URL */ + projectUrl?: Maybe; + /** Registry/Docker Hub URL */ + registryUrl?: Maybe; + /** Size of container logs (in bytes) */ + sizeLog?: Maybe; /** Total size of all files in the container (in bytes) */ sizeRootFs?: Maybe; + /** Size of writable layer (in bytes) */ + sizeRw?: Maybe; state: ContainerState; status: Scalars['String']['output']; + /** Support page/thread URL */ + supportUrl?: Maybe; + templatePath?: Maybe; +}; + +export type DockerContainerLogLine = { + __typename?: 'DockerContainerLogLine'; + message: Scalars['String']['output']; + timestamp: Scalars['DateTime']['output']; +}; + +export type DockerContainerLogs = { + __typename?: 'DockerContainerLogs'; + containerId: Scalars['PrefixedID']['output']; + /** Cursor that can be passed back through the since argument to continue streaming logs. */ + cursor?: Maybe; + lines: Array; +}; + +export type DockerContainerOverviewForm = { + __typename?: 'DockerContainerOverviewForm'; + data: Scalars['JSON']['output']; + dataSchema: Scalars['JSON']['output']; + id: Scalars['ID']['output']; + uiSchema: Scalars['JSON']['output']; +}; + +export type DockerContainerPortConflict = { + __typename?: 'DockerContainerPortConflict'; + containers: Array; + privatePort: Scalars['Port']['output']; + type: ContainerPortType; +}; + +export type DockerContainerStats = { + __typename?: 'DockerContainerStats'; + /** Block I/O String (e.g. 100MB / 1GB) */ + blockIO: Scalars['String']['output']; + /** CPU Usage Percentage */ + cpuPercent: Scalars['Float']['output']; + id: Scalars['PrefixedID']['output']; + /** Memory Usage Percentage */ + memPercent: Scalars['Float']['output']; + /** Memory Usage String (e.g. 100MB / 1GB) */ + memUsage: Scalars['String']['output']; + /** Network I/O String (e.g. 100MB / 1GB) */ + netIO: Scalars['String']['output']; +}; + +export type DockerLanPortConflict = { + __typename?: 'DockerLanPortConflict'; + containers: Array; + lanIpPort: Scalars['String']['output']; + publicPort?: Maybe; + type: ContainerPortType; }; export type DockerMutations = { __typename?: 'DockerMutations'; + /** Pause (Suspend) a container */ + pause: DockerContainer; + /** Remove a container */ + removeContainer: Scalars['Boolean']['output']; /** Start a container */ start: DockerContainer; /** Stop a container */ stop: DockerContainer; + /** Unpause (Resume) a container */ + unpause: DockerContainer; + /** Update all containers that have available updates */ + updateAllContainers: Array; + /** Update auto-start configuration for Docker containers */ + updateAutostartConfiguration: Scalars['Boolean']['output']; + /** Update a container to the latest image */ + updateContainer: DockerContainer; + /** Update multiple containers to the latest images */ + updateContainers: Array; +}; + + +export type DockerMutationsPauseArgs = { + id: Scalars['PrefixedID']['input']; +}; + + +export type DockerMutationsRemoveContainerArgs = { + id: Scalars['PrefixedID']['input']; }; @@ -735,6 +873,27 @@ export type DockerMutationsStopArgs = { id: Scalars['PrefixedID']['input']; }; + +export type DockerMutationsUnpauseArgs = { + id: Scalars['PrefixedID']['input']; +}; + + +export type DockerMutationsUpdateAutostartConfigurationArgs = { + entries: Array; + persistUserPreferences?: InputMaybe; +}; + + +export type DockerMutationsUpdateContainerArgs = { + id: Scalars['PrefixedID']['input']; +}; + + +export type DockerMutationsUpdateContainersArgs = { + ids: Array; +}; + export type DockerNetwork = Node & { __typename?: 'DockerNetwork'; attachable: Scalars['Boolean']['output']; @@ -754,6 +913,26 @@ export type DockerNetwork = Node & { scope: Scalars['String']['output']; }; +export type DockerPortConflictContainer = { + __typename?: 'DockerPortConflictContainer'; + id: Scalars['PrefixedID']['output']; + name: Scalars['String']['output']; +}; + +export type DockerPortConflicts = { + __typename?: 'DockerPortConflicts'; + containerPorts: Array; + lanPorts: Array; +}; + +export type DockerTemplateSyncResult = { + __typename?: 'DockerTemplateSyncResult'; + errors: Array; + matched: Scalars['Int']['output']; + scanned: Scalars['Int']['output']; + skipped: Scalars['Int']['output']; +}; + export type DynamicRemoteAccessStatus = { __typename?: 'DynamicRemoteAccessStatus'; /** The type of dynamic remote access that is enabled */ @@ -799,6 +978,20 @@ export type FlashBackupStatus = { status: Scalars['String']['output']; }; +export type FlatOrganizerEntry = { + __typename?: 'FlatOrganizerEntry'; + childrenIds: Array; + depth: Scalars['Float']['output']; + hasChildren: Scalars['Boolean']['output']; + id: Scalars['String']['output']; + meta?: Maybe; + name: Scalars['String']['output']; + parentId?: Maybe; + path: Array; + position: Scalars['Float']['output']; + type: Scalars['String']['output']; +}; + export type FormSchema = { /** The data schema for the form */ dataSchema: Scalars['JSON']['output']; @@ -869,6 +1062,7 @@ export type InfoCpu = Node & { manufacturer?: Maybe; /** CPU model */ model?: Maybe; + packages: CpuPackages; /** Number of physical processors */ processors?: Maybe; /** CPU revision */ @@ -885,6 +1079,8 @@ export type InfoCpu = Node & { stepping?: Maybe; /** Number of CPU threads */ threads?: Maybe; + /** Per-package array of core/thread pairs, e.g. [[[0,1],[2,3]], [[4,5],[6,7]]] */ + topology: Array>>; /** CPU vendor */ vendor?: Maybe; /** CPU voltage */ @@ -1223,6 +1419,7 @@ export type Mutation = { connectSignIn: Scalars['Boolean']['output']; connectSignOut: Scalars['Boolean']['output']; createDockerFolder: ResolvedOrganizerV1; + createDockerFolderWithItems: ResolvedOrganizerV1; /** Creates a new notification record */ createNotification: Notification; /** Deletes all archived notifications on server. */ @@ -1234,6 +1431,9 @@ export type Mutation = { /** Initiates a flash drive backup using a configured remote. */ initiateFlashBackup: FlashBackupStatus; moveDockerEntriesToFolder: ResolvedOrganizerV1; + moveDockerItemsToPosition: ResolvedOrganizerV1; + /** Creates a notification if an equivalent unread notification does not already exist. */ + notifyIfUnique?: Maybe; parityCheck: ParityCheckMutations; rclone: RCloneMutations; /** Reads each notification to recompute & update the overview. */ @@ -1241,13 +1441,16 @@ export type Mutation = { refreshDockerDigests: Scalars['Boolean']['output']; /** Remove one or more plugins from the API. Returns false if restart was triggered automatically, true if manual restart is required. */ removePlugin: Scalars['Boolean']['output']; + renameDockerFolder: ResolvedOrganizerV1; setDockerFolderChildren: ResolvedOrganizerV1; setupRemoteAccess: Scalars['Boolean']['output']; + syncDockerTemplatePaths: DockerTemplateSyncResult; unarchiveAll: NotificationOverview; unarchiveNotifications: NotificationOverview; /** Marks a notification as unread. */ unreadNotification: Notification; updateApiSettings: ConnectSettingsValues; + updateDockerViewPreferences: ResolvedOrganizerV1; updateSettings: UpdateSettingsResponse; vm: VmMutations; }; @@ -1290,6 +1493,14 @@ export type MutationCreateDockerFolderArgs = { }; +export type MutationCreateDockerFolderWithItemsArgs = { + name: Scalars['String']['input']; + parentId?: InputMaybe; + position?: InputMaybe; + sourceEntryIds?: InputMaybe>; +}; + + export type MutationCreateNotificationArgs = { input: NotificationData; }; @@ -1322,11 +1533,29 @@ export type MutationMoveDockerEntriesToFolderArgs = { }; +export type MutationMoveDockerItemsToPositionArgs = { + destinationFolderId: Scalars['String']['input']; + position: Scalars['Float']['input']; + sourceEntryIds: Array; +}; + + +export type MutationNotifyIfUniqueArgs = { + input: NotificationData; +}; + + export type MutationRemovePluginArgs = { input: PluginManagementInput; }; +export type MutationRenameDockerFolderArgs = { + folderId: Scalars['String']['input']; + newName: Scalars['String']['input']; +}; + + export type MutationSetDockerFolderChildrenArgs = { childrenIds: Array; folderId?: InputMaybe; @@ -1358,6 +1587,12 @@ export type MutationUpdateApiSettingsArgs = { }; +export type MutationUpdateDockerViewPreferencesArgs = { + prefs: Scalars['JSON']['input']; + viewId?: InputMaybe; +}; + + export type MutationUpdateSettingsArgs = { input: Scalars['JSON']['input']; }; @@ -1433,6 +1668,8 @@ export type Notifications = Node & { list: Array; /** A cached overview of the notifications in the system & their severity. */ overview: NotificationOverview; + /** Deduplicated list of unread warning and alert notifications, sorted latest first. */ + warningsAndAlerts: Array; }; @@ -1498,22 +1735,6 @@ export type OidcSessionValidation = { valid: Scalars['Boolean']['output']; }; -export type OrganizerContainerResource = { - __typename?: 'OrganizerContainerResource'; - id: Scalars['String']['output']; - meta?: Maybe; - name: Scalars['String']['output']; - type: Scalars['String']['output']; -}; - -export type OrganizerResource = { - __typename?: 'OrganizerResource'; - id: Scalars['String']['output']; - meta?: Maybe; - name: Scalars['String']['output']; - type: Scalars['String']['output']; -}; - export type Owner = { __typename?: 'Owner'; avatar: Scalars['String']['output']; @@ -1663,6 +1884,7 @@ export type Query = { disk: Disk; disks: Array; docker: Docker; + dockerContainerOverviewForm: DockerContainerOverviewForm; flash: Flash; /** Get JSON Schema for API key creation form */ getApiKeyCreationFormSchema: ApiKeyFormSettings; @@ -1726,6 +1948,11 @@ export type QueryDiskArgs = { }; +export type QueryDockerContainerOverviewFormArgs = { + skipCache?: Scalars['Boolean']['input']; +}; + + export type QueryGetPermissionsForRolesArgs = { roles: Array; }; @@ -1882,16 +2109,6 @@ export type RemoveRoleFromApiKeyInput = { role: Role; }; -export type ResolvedOrganizerEntry = OrganizerContainerResource | OrganizerResource | ResolvedOrganizerFolder; - -export type ResolvedOrganizerFolder = { - __typename?: 'ResolvedOrganizerFolder'; - children: Array; - id: Scalars['String']['output']; - name: Scalars['String']['output']; - type: Scalars['String']['output']; -}; - export type ResolvedOrganizerV1 = { __typename?: 'ResolvedOrganizerV1'; version: Scalars['Float']['output']; @@ -1900,10 +2117,11 @@ export type ResolvedOrganizerV1 = { export type ResolvedOrganizerView = { __typename?: 'ResolvedOrganizerView'; + flatEntries: Array; id: Scalars['String']['output']; name: Scalars['String']['output']; prefs?: Maybe; - root: ResolvedOrganizerEntry; + rootId: Scalars['String']['output']; }; /** Available resources for permissions */ @@ -2046,13 +2264,16 @@ export type SsoSettings = Node & { export type Subscription = { __typename?: 'Subscription'; arraySubscription: UnraidArray; + dockerContainerStats: DockerContainerStats; logFile: LogFileContent; notificationAdded: Notification; notificationsOverview: NotificationOverview; + notificationsWarningsAndAlerts: Array; ownerSubscription: Owner; parityHistorySubscription: ParityCheck; serversSubscription: Server; systemMetricsCpu: CpuUtilization; + systemMetricsCpuTelemetry: CpuPackages; systemMetricsMemory: MemoryUtilization; upsUpdates: UpsDevice; }; diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-autostart.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker-autostart.service.spec.ts new file mode 100644 index 0000000000..adc98d497e --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-autostart.service.spec.ts @@ -0,0 +1,144 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + AutoStartEntry, + DockerAutostartService, +} from '@app/unraid-api/graph/resolvers/docker/docker-autostart.service.js'; +import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; + +// Mock store getters +const mockPaths = { + 'docker-autostart': '/path/to/docker-autostart', + 'docker-userprefs': '/path/to/docker-userprefs', +}; + +vi.mock('@app/store/index.js', () => ({ + getters: { + paths: () => mockPaths, + }, +})); + +// Mock fs/promises +const { readFileMock, writeFileMock, unlinkMock } = vi.hoisted(() => ({ + readFileMock: vi.fn().mockResolvedValue(''), + writeFileMock: vi.fn().mockResolvedValue(undefined), + unlinkMock: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('fs/promises', () => ({ + readFile: readFileMock, + writeFile: writeFileMock, + unlink: unlinkMock, +})); + +describe('DockerAutostartService', () => { + let service: DockerAutostartService; + + beforeEach(async () => { + readFileMock.mockReset(); + writeFileMock.mockReset(); + unlinkMock.mockReset(); + readFileMock.mockResolvedValue(''); + + const module: TestingModule = await Test.createTestingModule({ + providers: [DockerAutostartService], + }).compile(); + + service = module.get(DockerAutostartService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should parse autostart entries correctly', () => { + const content = 'container1 10\ncontainer2\ncontainer3 0'; + const entries = service.parseAutoStartEntries(content); + + expect(entries).toHaveLength(3); + expect(entries[0]).toEqual({ name: 'container1', wait: 10, order: 0 }); + expect(entries[1]).toEqual({ name: 'container2', wait: 0, order: 1 }); + expect(entries[2]).toEqual({ name: 'container3', wait: 0, order: 2 }); + }); + + it('should refresh autostart entries', async () => { + readFileMock.mockResolvedValue('alpha 5'); + await service.refreshAutoStartEntries(); + + const entry = service.getAutoStartEntry('alpha'); + expect(entry).toBeDefined(); + expect(entry?.wait).toBe(5); + }); + + describe('updateAutostartConfiguration', () => { + const mockContainers = [ + { id: 'c1', names: ['/alpha'] }, + { id: 'c2', names: ['/beta'] }, + ] as DockerContainer[]; + + it('should update auto-start configuration and persist waits', async () => { + await service.updateAutostartConfiguration( + [ + { id: 'c1', autoStart: true, wait: 15 }, + { id: 'c2', autoStart: true, wait: 0 }, + ], + mockContainers, + { persistUserPreferences: true } + ); + + expect(writeFileMock).toHaveBeenCalledWith( + mockPaths['docker-autostart'], + 'alpha 15\nbeta\n', + 'utf8' + ); + expect(writeFileMock).toHaveBeenCalledWith( + mockPaths['docker-userprefs'], + '0="alpha"\n1="beta"\n', + 'utf8' + ); + }); + + it('should skip updating user preferences when persist flag is false', async () => { + await service.updateAutostartConfiguration( + [{ id: 'c1', autoStart: true, wait: 5 }], + mockContainers + ); + + expect(writeFileMock).toHaveBeenCalledWith( + mockPaths['docker-autostart'], + 'alpha 5\n', + 'utf8' + ); + expect(writeFileMock).not.toHaveBeenCalledWith( + mockPaths['docker-userprefs'], + expect.any(String), + expect.any(String) + ); + }); + + it('should remove auto-start file when no containers are configured', async () => { + await service.updateAutostartConfiguration( + [{ id: 'c1', autoStart: false, wait: 30 }], + mockContainers, + { persistUserPreferences: true } + ); + + expect(unlinkMock).toHaveBeenCalledWith(mockPaths['docker-autostart']); + expect(writeFileMock).toHaveBeenCalledWith( + mockPaths['docker-userprefs'], + '0="alpha"\n', + 'utf8' + ); + }); + }); + + it('should sanitize autostart wait values', () => { + expect(service.sanitizeAutoStartWait(null)).toBe(0); + expect(service.sanitizeAutoStartWait(undefined)).toBe(0); + expect(service.sanitizeAutoStartWait(10)).toBe(10); + expect(service.sanitizeAutoStartWait(-5)).toBe(0); + expect(service.sanitizeAutoStartWait(NaN)).toBe(0); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-autostart.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-autostart.service.ts new file mode 100644 index 0000000000..d5fb1ae7d3 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-autostart.service.ts @@ -0,0 +1,175 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { readFile, unlink, writeFile } from 'fs/promises'; + +import Docker from 'dockerode'; + +import { getters } from '@app/store/index.js'; +import { + DockerAutostartEntryInput, + DockerContainer, +} from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; + +export interface AutoStartEntry { + name: string; + wait: number; + order: number; +} + +@Injectable() +export class DockerAutostartService { + private readonly logger = new Logger(DockerAutostartService.name); + private autoStartEntries: AutoStartEntry[] = []; + private autoStartEntryByName = new Map(); + + public getAutoStartEntry(name: string): AutoStartEntry | undefined { + return this.autoStartEntryByName.get(name); + } + + public setAutoStartEntries(entries: AutoStartEntry[]) { + this.autoStartEntries = entries; + this.autoStartEntryByName = new Map(entries.map((entry) => [entry.name, entry])); + } + + public parseAutoStartEntries(rawContent: string): AutoStartEntry[] { + const lines = rawContent + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + const seen = new Set(); + const entries: AutoStartEntry[] = []; + + lines.forEach((line, index) => { + const [name, waitRaw] = line.split(/\s+/); + if (!name || seen.has(name)) { + return; + } + const parsedWait = Number.parseInt(waitRaw ?? '', 10); + const wait = Number.isFinite(parsedWait) && parsedWait > 0 ? parsedWait : 0; + entries.push({ + name, + wait, + order: index, + }); + seen.add(name); + }); + + return entries; + } + + public async refreshAutoStartEntries(): Promise { + const autoStartPath = getters.paths()['docker-autostart']; + const raw = await readFile(autoStartPath, 'utf8') + .then((file) => file.toString()) + .catch(() => ''); + const entries = this.parseAutoStartEntries(raw); + this.setAutoStartEntries(entries); + } + + public sanitizeAutoStartWait(wait?: number | null): number { + if (wait === null || wait === undefined) return 0; + const coerced = Number.isInteger(wait) ? wait : Number.parseInt(String(wait), 10); + if (!Number.isFinite(coerced) || coerced < 0) { + return 0; + } + return coerced; + } + + public getContainerPrimaryName(container: Docker.ContainerInfo | DockerContainer): string | null { + const names = + 'Names' in container ? container.Names : 'names' in container ? container.names : undefined; + const firstName = names?.[0] ?? ''; + return firstName ? firstName.replace(/^\//, '') : null; + } + + private buildUserPreferenceLines( + entries: DockerAutostartEntryInput[], + containerById: Map + ): string[] { + const seenNames = new Set(); + const lines: string[] = []; + + for (const entry of entries) { + const container = containerById.get(entry.id); + if (!container) { + continue; + } + const primaryName = this.getContainerPrimaryName(container); + if (!primaryName || seenNames.has(primaryName)) { + continue; + } + lines.push(`${lines.length}="${primaryName}"`); + seenNames.add(primaryName); + } + + return lines; + } + + /** + * Docker auto start file + * + * @note Doesn't exist if array is offline. + * @see https://github.com/limetech/webgui/issues/502#issue-480992547 + */ + public async getAutoStarts(): Promise { + await this.refreshAutoStartEntries(); + return this.autoStartEntries.map((entry) => entry.name); + } + + public async updateAutostartConfiguration( + entries: DockerAutostartEntryInput[], + containers: DockerContainer[], + options?: { persistUserPreferences?: boolean } + ): Promise { + const containerById = new Map(containers.map((container) => [container.id, container])); + const paths = getters.paths(); + const autoStartPath = paths['docker-autostart']; + const userPrefsPath = paths['docker-userprefs']; + const persistUserPreferences = Boolean(options?.persistUserPreferences); + + const lines: string[] = []; + const seenNames = new Set(); + + for (const entry of entries) { + if (!entry.autoStart) { + continue; + } + const container = containerById.get(entry.id); + if (!container) { + continue; + } + const primaryName = this.getContainerPrimaryName(container); + if (!primaryName || seenNames.has(primaryName)) { + continue; + } + const wait = this.sanitizeAutoStartWait(entry.wait); + lines.push(wait > 0 ? `${primaryName} ${wait}` : primaryName); + seenNames.add(primaryName); + } + + if (lines.length) { + await writeFile(autoStartPath, `${lines.join('\n')}\n`, 'utf8'); + } else { + await unlink(autoStartPath)?.catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') { + throw error; + } + }); + } + + if (persistUserPreferences) { + const userPrefsLines = this.buildUserPreferenceLines(entries, containerById); + if (userPrefsLines.length) { + await writeFile(userPrefsPath, `${userPrefsLines.join('\n')}\n`, 'utf8'); + } else { + await unlink(userPrefsPath)?.catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') { + throw error; + } + }); + } + } + + await this.refreshAutoStartEntries(); + } +} diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-config.model.ts b/api/src/unraid-api/graph/resolvers/docker/docker-config.model.ts index e7a47ae660..b023933be0 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-config.model.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-config.model.ts @@ -1,7 +1,22 @@ import { Field, ObjectType } from '@nestjs/graphql'; +import { IsArray, IsObject, IsOptional, IsString } from 'class-validator'; +import { GraphQLJSON } from 'graphql-scalars'; + @ObjectType() export class DockerConfig { @Field(() => String) + @IsString() updateCheckCronSchedule!: string; + + @Field(() => GraphQLJSON, { nullable: true }) + @IsOptional() + @IsObject() + templateMappings?: Record; + + @Field(() => [String], { nullable: true }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + skipTemplatePaths?: string[]; } diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-config.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-config.service.ts index 1ed27212f8..c1c091ddba 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-config.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-config.service.ts @@ -31,6 +31,8 @@ export class DockerConfigService extends ConfigFilePersister { defaultConfig(): DockerConfig { return { updateCheckCronSchedule: CronExpression.EVERY_DAY_AT_6AM, + templateMappings: {}, + skipTemplatePaths: [], }; } @@ -40,6 +42,7 @@ export class DockerConfigService extends ConfigFilePersister { if (!cronExpression.valid) { throw new AppError(`Cron expression not supported: ${dockerConfig.updateCheckCronSchedule}`); } + return dockerConfig; } } diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-container.resolver.ts b/api/src/unraid-api/graph/resolvers/docker/docker-container.resolver.ts index 4528b24658..074477f6c2 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-container.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-container.resolver.ts @@ -7,12 +7,16 @@ import { AuthAction, UsePermissions } from '@unraid/shared/use-permissions.direc import { AppError } from '@app/core/errors/app-error.js'; import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js'; import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.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'; @Resolver(() => DockerContainer) export class DockerContainerResolver { private readonly logger = new Logger(DockerContainerResolver.name); - constructor(private readonly dockerManifestService: DockerManifestService) {} + constructor( + private readonly dockerManifestService: DockerManifestService, + private readonly dockerTemplateScannerService: DockerTemplateScannerService + ) {} @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') @UsePermissions({ @@ -39,6 +43,65 @@ export class DockerContainerResolver { return this.dockerManifestService.isRebuildReady(container.hostConfig?.networkMode); } + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.DOCKER, + }) + @ResolveField(() => String, { nullable: true }) + public async projectUrl(@Parent() container: DockerContainer) { + if (!container.templatePath) return null; + const details = await this.dockerTemplateScannerService.getTemplateDetails( + container.templatePath + ); + return details?.project || null; + } + + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.DOCKER, + }) + @ResolveField(() => String, { nullable: true }) + public async registryUrl(@Parent() container: DockerContainer) { + if (!container.templatePath) return null; + const details = await this.dockerTemplateScannerService.getTemplateDetails( + container.templatePath + ); + return details?.registry || null; + } + + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.DOCKER, + }) + @ResolveField(() => String, { nullable: true }) + public async supportUrl(@Parent() container: DockerContainer) { + if (!container.templatePath) return null; + const details = await this.dockerTemplateScannerService.getTemplateDetails( + container.templatePath + ); + return details?.support || null; + } + + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.DOCKER, + }) + @ResolveField(() => String, { nullable: true }) + public async iconUrl(@Parent() container: DockerContainer) { + if (container.labels?.['net.unraid.docker.icon']) { + return container.labels['net.unraid.docker.icon']; + } + if (!container.templatePath) return null; + const details = await this.dockerTemplateScannerService.getTemplateDetails( + container.templatePath + ); + return details?.icon || null; + } + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') @UsePermissions({ action: AuthAction.UPDATE_ANY, diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-event.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker-event.service.spec.ts index 933100f1bf..84066df280 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-event.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-event.service.spec.ts @@ -27,6 +27,7 @@ vi.mock('@nestjs/common', async () => { debug: vi.fn(), error: vi.fn(), log: vi.fn(), + verbose: vi.fn(), })), }; }); @@ -54,29 +55,33 @@ vi.mock('@app/core/pubsub.js', () => ({ // Mock DockerService vi.mock('./docker.service.js', () => ({ DockerService: vi.fn().mockImplementation(() => ({ - getDockerClient: vi.fn(), clearContainerCache: vi.fn(), getAppInfo: vi.fn().mockResolvedValue({ info: { apps: { installed: 1, running: 1 } } }), })), })); +const { mockDockerClientInstance } = vi.hoisted(() => { + const mock = { + getEvents: vi.fn(), + } as unknown as Docker; + return { mockDockerClientInstance: mock }; +}); + +// Mock the docker client util +vi.mock('@app/unraid-api/graph/resolvers/docker/utils/docker-client.js', () => ({ + getDockerClient: vi.fn().mockReturnValue(mockDockerClientInstance), +})); + describe('DockerEventService', () => { let service: DockerEventService; let dockerService: DockerService; - let mockDockerClient: Docker; let mockEventStream: PassThrough; let mockLogger: Logger; let module: TestingModule; beforeEach(async () => { - // Create a mock Docker client - mockDockerClient = { - getEvents: vi.fn(), - } as unknown as Docker; - // Create a mock Docker service *instance* const mockDockerServiceImpl = { - getDockerClient: vi.fn().mockReturnValue(mockDockerClient), clearContainerCache: vi.fn(), getAppInfo: vi.fn().mockResolvedValue({ info: { apps: { installed: 1, running: 1 } } }), }; @@ -85,7 +90,7 @@ describe('DockerEventService', () => { mockEventStream = new PassThrough(); // Set up the mock Docker client to return our mock event stream - vi.spyOn(mockDockerClient, 'getEvents').mockResolvedValue( + vi.spyOn(mockDockerClientInstance, 'getEvents').mockResolvedValue( mockEventStream as unknown as Readable ); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-event.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-event.service.ts index 8e34166b61..63fdd0d482 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-event.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-event.service.ts @@ -7,6 +7,7 @@ import Docker from 'dockerode'; import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { getters } from '@app/store/index.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; +import { getDockerClient } from '@app/unraid-api/graph/resolvers/docker/utils/docker-client.js'; enum DockerEventAction { DIE = 'die', @@ -66,7 +67,7 @@ export class DockerEventService implements OnModuleDestroy, OnModuleInit { ]; constructor(private readonly dockerService: DockerService) { - this.client = this.dockerService.getDockerClient(); + this.client = getDockerClient(); } async onModuleInit() { diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-form.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-form.service.ts new file mode 100644 index 0000000000..95ef32d304 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-form.service.ts @@ -0,0 +1,110 @@ +import { Injectable } from '@nestjs/common'; + +import { type UISchemaElement } from '@jsonforms/core'; + +import { DockerContainerOverviewForm } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; +import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; +import { DataSlice } from '@app/unraid-api/types/json-forms.js'; + +@Injectable() +export class DockerFormService { + constructor(private readonly dockerService: DockerService) {} + + async getContainerOverviewForm(skipCache = false): Promise { + const containers = await this.dockerService.getContainers({ skipCache }); + + // Transform containers data for table display + const tableData = containers.map((container) => ({ + id: container.id, + name: container.names[0]?.replace(/^\//, '') || 'Unknown', + state: container.state, + status: container.status, + image: container.image, + ports: container.ports + .map((p) => { + if (p.publicPort && p.privatePort) { + return `${p.publicPort}:${p.privatePort}/${p.type}`; + } else if (p.privatePort) { + return `${p.privatePort}/${p.type}`; + } + return ''; + }) + .filter(Boolean) + .join(', '), + autoStart: container.autoStart, + network: container.hostConfig?.networkMode || 'default', + })); + + const dataSchema = this.createDataSchema(); + const uiSchema = this.createUiSchema(); + + return { + id: 'docker-container-overview', + dataSchema: { + type: 'object', + properties: dataSchema, + }, + uiSchema: { + type: 'VerticalLayout', + elements: [uiSchema], + }, + data: tableData, + }; + } + + private createDataSchema(): DataSlice { + return { + containers: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + title: 'ID', + }, + name: { + type: 'string', + title: 'Name', + }, + state: { + type: 'string', + title: 'State', + enum: ['RUNNING', 'EXITED'], + }, + status: { + type: 'string', + title: 'Status', + }, + image: { + type: 'string', + title: 'Image', + }, + ports: { + type: 'string', + title: 'Ports', + }, + autoStart: { + type: 'boolean', + title: 'Auto Start', + }, + network: { + type: 'string', + title: 'Network', + }, + }, + }, + }, + }; + } + + private createUiSchema(): UISchemaElement { + return { + type: 'Control', + scope: '#', + options: { + variant: 'table', + }, + }; + } +} diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-log.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker-log.service.spec.ts new file mode 100644 index 0000000000..2280e8e3d8 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-log.service.spec.ts @@ -0,0 +1,144 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AppError } from '@app/core/errors/app-error.js'; +import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker-log.service.js'; +import { DockerContainerLogs } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; + +// Mock dependencies +const mockExeca = vi.fn(); +vi.mock('execa', () => ({ + execa: (cmd: string, args: string[]) => mockExeca(cmd, args), +})); + +const { mockDockerInstance, mockGetContainer, mockContainer } = vi.hoisted(() => { + const mockContainer = { + inspect: vi.fn(), + }; + const mockGetContainer = vi.fn().mockReturnValue(mockContainer); + const mockDockerInstance = { + getContainer: mockGetContainer, + }; + return { mockDockerInstance, mockGetContainer, mockContainer }; +}); + +vi.mock('@app/unraid-api/graph/resolvers/docker/utils/docker-client.js', () => ({ + getDockerClient: vi.fn().mockReturnValue(mockDockerInstance), +})); + +const { statMock } = vi.hoisted(() => ({ + statMock: vi.fn().mockResolvedValue({ size: 0 }), +})); + +vi.mock('fs/promises', () => ({ + stat: statMock, +})); + +describe('DockerLogService', () => { + let service: DockerLogService; + + beforeEach(async () => { + mockExeca.mockReset(); + mockGetContainer.mockReset(); + mockGetContainer.mockReturnValue(mockContainer); + mockContainer.inspect.mockReset(); + statMock.mockReset(); + statMock.mockResolvedValue({ size: 0 }); + + const module: TestingModule = await Test.createTestingModule({ + providers: [DockerLogService], + }).compile(); + + service = module.get(DockerLogService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getContainerLogSizes', () => { + it('should get container log sizes using dockerode inspect', async () => { + mockContainer.inspect.mockResolvedValue({ + LogPath: '/var/lib/docker/containers/id/id-json.log', + }); + statMock.mockResolvedValue({ size: 1024 }); + + const sizes = await service.getContainerLogSizes(['test-container']); + + expect(mockGetContainer).toHaveBeenCalledWith('test-container'); + expect(mockContainer.inspect).toHaveBeenCalled(); + expect(statMock).toHaveBeenCalledWith('/var/lib/docker/containers/id/id-json.log'); + expect(sizes.get('test-container')).toBe(1024); + }); + + it('should return 0 for missing log path', async () => { + mockContainer.inspect.mockResolvedValue({}); // No LogPath + + const sizes = await service.getContainerLogSizes(['test-container']); + expect(sizes.get('test-container')).toBe(0); + }); + + it('should handle inspect errors gracefully', async () => { + mockContainer.inspect.mockRejectedValue(new Error('Inspect failed')); + + const sizes = await service.getContainerLogSizes(['test-container']); + expect(sizes.get('test-container')).toBe(0); + }); + }); + + describe('getContainerLogs', () => { + it('should fetch logs via docker CLI', async () => { + mockExeca.mockResolvedValue({ stdout: '2023-01-01T00:00:00Z Log message\n' }); + + const result = await service.getContainerLogs('test-id'); + + expect(mockExeca).toHaveBeenCalledWith('docker', [ + 'logs', + '--timestamps', + '--tail', + '200', + 'test-id', + ]); + expect(result.lines).toHaveLength(1); + expect(result.lines[0].message).toBe('Log message'); + }); + + it('should respect tail option', async () => { + mockExeca.mockResolvedValue({ stdout: '' }); + + await service.getContainerLogs('test-id', { tail: 50 }); + + expect(mockExeca).toHaveBeenCalledWith('docker', [ + 'logs', + '--timestamps', + '--tail', + '50', + 'test-id', + ]); + }); + + it('should respect since option', async () => { + mockExeca.mockResolvedValue({ stdout: '' }); + const since = new Date('2023-01-01T00:00:00Z'); + + await service.getContainerLogs('test-id', { since }); + + expect(mockExeca).toHaveBeenCalledWith('docker', [ + 'logs', + '--timestamps', + '--tail', + '200', + '--since', + since.toISOString(), + 'test-id', + ]); + }); + + it('should throw AppError on execa failure', async () => { + mockExeca.mockRejectedValue(new Error('Docker error')); + + await expect(service.getContainerLogs('test-id')).rejects.toThrow(AppError); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-log.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-log.service.ts new file mode 100644 index 0000000000..a667f70a83 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-log.service.ts @@ -0,0 +1,149 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { stat } from 'fs/promises'; + +import type { ExecaError } from 'execa'; +import { execa } from 'execa'; + +import { AppError } from '@app/core/errors/app-error.js'; +import { + DockerContainerLogLine, + DockerContainerLogs, +} from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; +import { getDockerClient } from '@app/unraid-api/graph/resolvers/docker/utils/docker-client.js'; + +@Injectable() +export class DockerLogService { + private readonly logger = new Logger(DockerLogService.name); + private readonly client = getDockerClient(); + + private static readonly DEFAULT_LOG_TAIL = 200; + private static readonly MAX_LOG_TAIL = 2000; + + public async getContainerLogSizes(containerNames: string[]): Promise> { + const logSizes = new Map(); + if (!Array.isArray(containerNames) || containerNames.length === 0) { + return logSizes; + } + + for (const rawName of containerNames) { + const normalized = (rawName ?? '').replace(/^\//, ''); + if (!normalized) { + logSizes.set(normalized, 0); + continue; + } + + try { + const container = this.client.getContainer(normalized); + const info = await container.inspect(); + const logPath = info.LogPath; + + if (!logPath || typeof logPath !== 'string' || !logPath.length) { + logSizes.set(normalized, 0); + continue; + } + + const stats = await stat(logPath).catch(() => null); + logSizes.set(normalized, stats?.size ?? 0); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error ?? 'unknown error'); + this.logger.debug( + `Failed to determine log size for container ${normalized}: ${message}` + ); + logSizes.set(normalized, 0); + } + } + + return logSizes; + } + + public async getContainerLogs( + id: string, + options?: { since?: Date | null; tail?: number | null } + ): Promise { + const normalizedId = (id ?? '').trim(); + if (!normalizedId) { + throw new AppError('Container id is required to fetch logs.', 400); + } + + const tail = this.normalizeLogTail(options?.tail); + const args = ['logs', '--timestamps', '--tail', String(tail)]; + const sinceIso = options?.since instanceof Date ? options.since.toISOString() : null; + if (sinceIso) { + args.push('--since', sinceIso); + } + args.push(normalizedId); + + try { + const { stdout } = await execa('docker', args); + const lines = this.parseDockerLogOutput(stdout); + const cursor = + lines.length > 0 ? lines[lines.length - 1].timestamp : (options?.since ?? null); + + return { + containerId: normalizedId, + lines, + cursor: cursor ?? undefined, + }; + } catch (error: unknown) { + const execaError = error as ExecaError; + const stderr = typeof execaError?.stderr === 'string' ? execaError.stderr.trim() : ''; + const message = stderr || execaError?.message || 'Unknown error'; + this.logger.error( + `Failed to fetch logs for container ${normalizedId}: ${message}`, + execaError + ); + throw new AppError(`Failed to fetch logs for container ${normalizedId}.`); + } + } + + private normalizeLogTail(tail?: number | null): number { + if (typeof tail !== 'number' || Number.isNaN(tail)) { + return DockerLogService.DEFAULT_LOG_TAIL; + } + const coerced = Math.floor(tail); + if (!Number.isFinite(coerced) || coerced <= 0) { + return DockerLogService.DEFAULT_LOG_TAIL; + } + return Math.min(coerced, DockerLogService.MAX_LOG_TAIL); + } + + private parseDockerLogOutput(output: string): DockerContainerLogLine[] { + if (!output) { + return []; + } + return output + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => this.parseDockerLogLine(line)) + .filter((entry): entry is DockerContainerLogLine => Boolean(entry)); + } + + private parseDockerLogLine(line: string): DockerContainerLogLine | null { + const trimmed = line.trim(); + if (!trimmed.length) { + return null; + } + const firstSpaceIndex = trimmed.indexOf(' '); + if (firstSpaceIndex === -1) { + return { + timestamp: new Date(), + message: trimmed, + }; + } + const potentialTimestamp = trimmed.slice(0, firstSpaceIndex); + const message = trimmed.slice(firstSpaceIndex + 1); + const parsedTimestamp = new Date(potentialTimestamp); + if (Number.isNaN(parsedTimestamp.getTime())) { + return { + timestamp: new Date(), + message: trimmed, + }; + } + return { + timestamp: parsedTimestamp, + message, + }; + } +} diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-manifest.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-manifest.service.ts index b14fe8606b..871b32b6b5 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker-manifest.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker-manifest.service.ts @@ -16,6 +16,14 @@ export class DockerManifestService { return this.dockerPhpService.refreshDigestsViaPhp(); }); + /** + * Reads the cached update status file and returns the parsed contents. + * Exposed so other services can reuse the parsed data when evaluating many containers. + */ + async getCachedUpdateStatuses(): Promise> { + return this.dockerPhpService.readCachedUpdateStatus(); + } + /** * Recomputes local/remote docker container digests and writes them to /var/lib/docker/unraid-update-status.json * @param mutex - Optional mutex to use for the operation. If not provided, a default mutex will be used. @@ -41,7 +49,22 @@ export class DockerManifestService { cacheData ??= await this.dockerPhpService.readCachedUpdateStatus(); const containerData = cacheData[taggedRef]; if (!containerData) return null; - return containerData.status?.toLowerCase() === 'true'; + + const normalize = (digest?: string | null) => { + const value = digest?.trim().toLowerCase(); + return value && value !== 'undef' ? value : null; + }; + + const localDigest = normalize(containerData.local); + const remoteDigest = normalize(containerData.remote); + if (localDigest && remoteDigest) { + return localDigest !== remoteDigest; + } + + const status = containerData.status?.toLowerCase(); + if (status === 'true') return true; + if (status === 'false') return false; + return null; } /** diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-network.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker-network.service.spec.ts new file mode 100644 index 0000000000..ca29501437 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-network.service.spec.ts @@ -0,0 +1,89 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Test, TestingModule } from '@nestjs/testing'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DockerNetworkService } from '@app/unraid-api/graph/resolvers/docker/docker-network.service.js'; + +const { mockDockerInstance, mockListNetworks } = vi.hoisted(() => { + const mockListNetworks = vi.fn(); + const mockDockerInstance = { + listNetworks: mockListNetworks, + }; + return { mockDockerInstance, mockListNetworks }; +}); + +vi.mock('@app/unraid-api/graph/resolvers/docker/utils/docker-client.js', () => ({ + getDockerClient: vi.fn().mockReturnValue(mockDockerInstance), +})); + +const mockCacheManager = { + get: vi.fn(), + set: vi.fn(), +}; + +describe('DockerNetworkService', () => { + let service: DockerNetworkService; + + beforeEach(async () => { + mockListNetworks.mockReset(); + mockCacheManager.get.mockReset(); + mockCacheManager.set.mockReset(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DockerNetworkService, + { + provide: CACHE_MANAGER, + useValue: mockCacheManager, + }, + ], + }).compile(); + + service = module.get(DockerNetworkService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('getNetworks', () => { + it('should return cached networks if available and not skipped', async () => { + const cached = [{ id: 'net1', name: 'test-net' }]; + mockCacheManager.get.mockResolvedValue(cached); + + const result = await service.getNetworks({ skipCache: false }); + expect(result).toEqual(cached); + expect(mockListNetworks).not.toHaveBeenCalled(); + }); + + it('should fetch networks from docker if cache skipped', async () => { + const rawNetworks = [ + { + Id: 'net1', + Name: 'test-net', + Driver: 'bridge', + }, + ]; + mockListNetworks.mockResolvedValue(rawNetworks); + + const result = await service.getNetworks({ skipCache: true }); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('net1'); + expect(mockListNetworks).toHaveBeenCalled(); + expect(mockCacheManager.set).toHaveBeenCalledWith( + DockerNetworkService.NETWORK_CACHE_KEY, + expect.anything(), + expect.anything() + ); + }); + + it('should fetch networks from docker if cache miss', async () => { + mockCacheManager.get.mockResolvedValue(undefined); + mockListNetworks.mockResolvedValue([]); + + await service.getNetworks({ skipCache: false }); + expect(mockListNetworks).toHaveBeenCalled(); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-network.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-network.service.ts new file mode 100644 index 0000000000..19ddf80172 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-network.service.ts @@ -0,0 +1,69 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject, Injectable, Logger } from '@nestjs/common'; + +import { type Cache } from 'cache-manager'; + +import { catchHandlers } from '@app/core/utils/misc/catch-handlers.js'; +import { DockerNetwork } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; +import { getDockerClient } from '@app/unraid-api/graph/resolvers/docker/utils/docker-client.js'; + +interface NetworkListingOptions { + skipCache: boolean; +} + +@Injectable() +export class DockerNetworkService { + private readonly logger = new Logger(DockerNetworkService.name); + private readonly client = getDockerClient(); + + public static readonly NETWORK_CACHE_KEY = 'docker_networks'; + private static readonly CACHE_TTL_SECONDS = 60; + + constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {} + + /** + * Get all Docker networks + * @returns All the in/active Docker networks on the system. + */ + public async getNetworks({ skipCache }: NetworkListingOptions): Promise { + if (!skipCache) { + const cachedNetworks = await this.cacheManager.get( + DockerNetworkService.NETWORK_CACHE_KEY + ); + if (cachedNetworks) { + this.logger.debug('Using docker network cache'); + return cachedNetworks; + } + } + + this.logger.debug('Updating docker network cache'); + const rawNetworks = await this.client.listNetworks().catch(catchHandlers.docker); + const networks = rawNetworks.map( + (network) => + ({ + name: network.Name || '', + id: network.Id || '', + created: network.Created || '', + scope: network.Scope || '', + driver: network.Driver || '', + enableIPv6: network.EnableIPv6 || false, + ipam: network.IPAM || {}, + internal: network.Internal || false, + attachable: network.Attachable || false, + ingress: network.Ingress || false, + configFrom: network.ConfigFrom || {}, + configOnly: network.ConfigOnly || false, + containers: network.Containers || {}, + options: network.Options || {}, + labels: network.Labels || {}, + }) as DockerNetwork + ); + + await this.cacheManager.set( + DockerNetworkService.NETWORK_CACHE_KEY, + networks, + DockerNetworkService.CACHE_TTL_SECONDS * 1000 + ); + return networks; + } +} diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-port.service.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker-port.service.spec.ts new file mode 100644 index 0000000000..eab03078e9 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-port.service.spec.ts @@ -0,0 +1,84 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DockerPortService } from '@app/unraid-api/graph/resolvers/docker/docker-port.service.js'; +import { + ContainerPortType, + DockerContainer, +} from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; + +vi.mock('@app/core/utils/network.js', () => ({ + getLanIp: vi.fn().mockReturnValue('192.168.1.100'), +})); + +describe('DockerPortService', () => { + let service: DockerPortService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [DockerPortService], + }).compile(); + + service = module.get(DockerPortService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('deduplicateContainerPorts', () => { + it('should deduplicate ports', () => { + const ports = [ + { PrivatePort: 80, PublicPort: 80, Type: 'tcp' }, + { PrivatePort: 80, PublicPort: 80, Type: 'tcp' }, + { PrivatePort: 443, PublicPort: 443, Type: 'tcp' }, + ]; + // @ts-expect-error - types are loosely mocked + const result = service.deduplicateContainerPorts(ports); + expect(result).toHaveLength(2); + }); + }); + + describe('calculateConflicts', () => { + it('should detect port conflicts', () => { + const containers = [ + { + id: 'c1', + names: ['/web1'], + ports: [{ privatePort: 80, type: ContainerPortType.TCP }], + }, + { + id: 'c2', + names: ['/web2'], + ports: [{ privatePort: 80, type: ContainerPortType.TCP }], + }, + ] as DockerContainer[]; + + const result = service.calculateConflicts(containers); + expect(result.containerPorts).toHaveLength(1); + expect(result.containerPorts[0].privatePort).toBe(80); + expect(result.containerPorts[0].containers).toHaveLength(2); + }); + + it('should detect lan port conflicts', () => { + const containers = [ + { + id: 'c1', + names: ['/web1'], + ports: [{ publicPort: 8080, type: ContainerPortType.TCP }], + }, + { + id: 'c2', + names: ['/web2'], + ports: [{ publicPort: 8080, type: ContainerPortType.TCP }], + }, + ] as DockerContainer[]; + + const result = service.calculateConflicts(containers); + expect(result.lanPorts).toHaveLength(1); + expect(result.lanPorts[0].publicPort).toBe(8080); + expect(result.lanPorts[0].containers).toHaveLength(2); + }); + }); +}); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-port.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-port.service.ts new file mode 100644 index 0000000000..74671b6248 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-port.service.ts @@ -0,0 +1,178 @@ +import { Injectable } from '@nestjs/common'; + +import Docker from 'dockerode'; + +import { getLanIp } from '@app/core/utils/network.js'; +import { + ContainerPortType, + DockerContainer, + DockerContainerPortConflict, + DockerLanPortConflict, + DockerPortConflictContainer, + DockerPortConflicts, +} from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; + +@Injectable() +export class DockerPortService { + public deduplicateContainerPorts( + ports: Docker.ContainerInfo['Ports'] | undefined + ): Docker.ContainerInfo['Ports'] { + if (!Array.isArray(ports)) { + return []; + } + + const seen = new Set(); + const uniquePorts: Docker.ContainerInfo['Ports'] = []; + + for (const port of ports) { + const key = `${port.PrivatePort ?? ''}-${port.PublicPort ?? ''}-${(port.Type ?? '').toLowerCase()}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + uniquePorts.push(port); + } + + return uniquePorts; + } + + public calculateConflicts(containers: DockerContainer[]): DockerPortConflicts { + return { + containerPorts: this.buildContainerPortConflicts(containers), + lanPorts: this.buildLanPortConflicts(containers), + }; + } + + private buildPortConflictContainerRef(container: DockerContainer): DockerPortConflictContainer { + const primaryName = this.getContainerPrimaryName(container); + const fallback = container.names?.[0] ?? container.id; + const normalized = typeof fallback === 'string' ? fallback.replace(/^\//, '') : container.id; + return { + id: container.id, + name: primaryName || normalized, + }; + } + + private getContainerPrimaryName(container: DockerContainer): string | null { + const names = container.names; + const firstName = names?.[0] ?? ''; + return firstName ? firstName.replace(/^\//, '') : null; + } + + private buildContainerPortConflicts(containers: DockerContainer[]): DockerContainerPortConflict[] { + const groups = new Map< + string, + { + privatePort: number; + type: ContainerPortType; + containers: DockerContainer[]; + seen: Set; + } + >(); + + for (const container of containers) { + if (!Array.isArray(container.ports)) { + continue; + } + for (const port of container.ports) { + if (!port || typeof port.privatePort !== 'number') { + continue; + } + const type = port.type ?? ContainerPortType.TCP; + const key = `${port.privatePort}/${type}`; + let group = groups.get(key); + if (!group) { + group = { + privatePort: port.privatePort, + type, + containers: [], + seen: new Set(), + }; + groups.set(key, group); + } + if (group.seen.has(container.id)) { + continue; + } + group.seen.add(container.id); + group.containers.push(container); + } + } + + return Array.from(groups.values()) + .filter((group) => group.containers.length > 1) + .map((group) => ({ + privatePort: group.privatePort, + type: group.type, + containers: group.containers.map((container) => + this.buildPortConflictContainerRef(container) + ), + })) + .sort((a, b) => { + if (a.privatePort !== b.privatePort) { + return a.privatePort - b.privatePort; + } + return a.type.localeCompare(b.type); + }); + } + + private buildLanPortConflicts(containers: DockerContainer[]): DockerLanPortConflict[] { + const lanIp = getLanIp(); + const groups = new Map< + string, + { + lanIpPort: string; + publicPort: number; + type: ContainerPortType; + containers: DockerContainer[]; + seen: Set; + } + >(); + + for (const container of containers) { + if (!Array.isArray(container.ports)) { + continue; + } + for (const port of container.ports) { + if (!port || typeof port.publicPort !== 'number') { + continue; + } + const type = port.type ?? ContainerPortType.TCP; + const lanIpPort = lanIp ? `${lanIp}:${port.publicPort}` : `${port.publicPort}`; + const key = `${lanIpPort}/${type}`; + let group = groups.get(key); + if (!group) { + group = { + lanIpPort, + publicPort: port.publicPort, + type, + containers: [], + seen: new Set(), + }; + groups.set(key, group); + } + if (group.seen.has(container.id)) { + continue; + } + group.seen.add(container.id); + group.containers.push(container); + } + } + + return Array.from(groups.values()) + .filter((group) => group.containers.length > 1) + .map((group) => ({ + lanIpPort: group.lanIpPort, + publicPort: group.publicPort, + type: group.type, + containers: group.containers.map((container) => + this.buildPortConflictContainerRef(container) + ), + })) + .sort((a, b) => { + if ((a.publicPort ?? 0) !== (b.publicPort ?? 0)) { + return (a.publicPort ?? 0) - (b.publicPort ?? 0); + } + return a.type.localeCompare(b.type); + }); + } +} diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-stats.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-stats.service.ts new file mode 100644 index 0000000000..c617e92efa --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-stats.service.ts @@ -0,0 +1,117 @@ +import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; +import { createInterface } from 'readline'; + +import { execa } from 'execa'; + +import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; +import { catchHandlers } from '@app/core/utils/misc/catch-handlers.js'; +import { DockerContainerStats } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; + +@Injectable() +export class DockerStatsService implements OnModuleDestroy { + private readonly logger = new Logger(DockerStatsService.name); + private statsProcess: ReturnType | null = null; + private readonly STATS_FORMAT = + '{{.ID}};{{.CPUPerc}};{{.MemUsage}};{{.MemPerc}};{{.NetIO}};{{.BlockIO}}'; + + onModuleDestroy() { + this.stopStatsStream(); + } + + public startStatsStream() { + if (this.statsProcess) { + return; + } + + this.logger.log('Starting docker stats stream'); + + try { + this.statsProcess = execa('docker', ['stats', '--format', this.STATS_FORMAT, '--no-trunc'], { + all: true, + reject: false, // Don't throw on exit code != 0, handle via parsing/events + }); + + if (this.statsProcess.stdout) { + const rl = createInterface({ + input: this.statsProcess.stdout, + crlfDelay: Infinity, + }); + + rl.on('line', (line) => { + if (!line.trim()) return; + this.processStatsLine(line); + }); + + rl.on('error', (err) => { + this.logger.error('Error reading docker stats stream', err); + }); + } + + if (this.statsProcess.stderr) { + this.statsProcess.stderr.on('data', (data: Buffer) => { + // Log docker stats errors but don't crash + this.logger.debug(`Docker stats stderr: ${data.toString()}`); + }); + } + + // Handle process exit + this.statsProcess + .then((result) => { + if (result.failed && !result.signal) { + this.logger.error('Docker stats process exited with error', result.shortMessage); + this.stopStatsStream(); + } + }) + .catch((err) => { + if (!err.killed) { + this.logger.error('Docker stats process ended unexpectedly', err); + this.stopStatsStream(); + } + }); + } catch (error) { + this.logger.error('Failed to start docker stats', error); + catchHandlers.docker(error as Error); + } + } + + public stopStatsStream() { + if (this.statsProcess) { + this.logger.log('Stopping docker stats stream'); + this.statsProcess.kill(); + this.statsProcess = null; + } + } + + private processStatsLine(line: string) { + try { + // format: ID;CPUPerc;MemUsage;MemPerc;NetIO;BlockIO + // Example: 123abcde;0.00%;10MiB / 100MiB;10.00%;1kB / 2kB;0B / 0B + + // Remove ANSI escape codes if any (docker stats sometimes includes them) + // eslint-disable-next-line no-control-regex + const cleanLine = line.replace(/\x1B\[[0-9;]*[mK]/g, ''); + + const parts = cleanLine.split(';'); + if (parts.length < 6) return; + + const [id, cpuPercStr, memUsage, memPercStr, netIO, blockIO] = parts; + + const stats: DockerContainerStats = { + id, + cpuPercent: this.parsePercentage(cpuPercStr), + memUsage, + memPercent: this.parsePercentage(memPercStr), + netIO, + blockIO, + }; + + pubsub.publish(PUBSUB_CHANNEL.DOCKER_STATS, { dockerContainerStats: stats }); + } catch (error) { + this.logger.debug(`Failed to process stats line: ${line}`, error); + } + } + + private parsePercentage(value: string): number { + return parseFloat(value.replace('%', '')) || 0; + } +} diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-template-icon.service.ts b/api/src/unraid-api/graph/resolvers/docker/docker-template-icon.service.ts new file mode 100644 index 0000000000..ec4fea9e0b --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-template-icon.service.ts @@ -0,0 +1,61 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { readFile } from 'fs/promises'; + +import { XMLParser } from 'fast-xml-parser'; + +@Injectable() +export class DockerTemplateIconService { + private readonly logger = new Logger(DockerTemplateIconService.name); + private readonly xmlParser = new XMLParser({ + ignoreAttributes: false, + parseAttributeValue: true, + trimValues: true, + }); + + async getIconFromTemplate(templatePath: string): Promise { + try { + const content = await readFile(templatePath, 'utf-8'); + const parsed = this.xmlParser.parse(content); + + if (!parsed.Container) { + return null; + } + + return parsed.Container.Icon || null; + } catch (error) { + this.logger.debug( + `Failed to read icon from template ${templatePath}: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + return null; + } + } + + async getIconsForContainers( + containers: Array<{ id: string; templatePath?: string }> + ): Promise> { + const iconMap = new Map(); + + const iconPromises = containers.map(async (container) => { + if (!container.templatePath) { + return null; + } + + const icon = await this.getIconFromTemplate(container.templatePath); + if (icon) { + return { id: container.id, icon }; + } + return null; + }); + + const results = await Promise.all(iconPromises); + + for (const result of results) { + if (result) { + iconMap.set(result.id, result.icon); + } + } + + this.logger.debug(`Loaded ${iconMap.size} icons from ${containers.length} containers`); + return iconMap; + } +} diff --git a/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.model.ts b/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.model.ts new file mode 100644 index 0000000000..275039f46a --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.model.ts @@ -0,0 +1,16 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql'; + +@ObjectType() +export class DockerTemplateSyncResult { + @Field(() => Int) + scanned!: number; + + @Field(() => Int) + matched!: number; + + @Field(() => Int) + skipped!: number; + + @Field(() => [String]) + errors!: string[]; +} 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 new file mode 100644 index 0000000000..54e9d8c772 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.service.spec.ts @@ -0,0 +1,425 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { mkdir, rm, writeFile } from 'fs/promises'; +import { join } from 'path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.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'; + +vi.mock('@app/environment.js', () => ({ + PATHS_DOCKER_TEMPLATES: ['/tmp/test-templates'], + ENABLE_NEXT_DOCKER_RELEASE: true, +})); + +describe('DockerTemplateScannerService', () => { + let service: DockerTemplateScannerService; + let dockerConfigService: DockerConfigService; + let dockerService: DockerService; + const testTemplateDir = '/tmp/test-templates'; + + beforeEach(async () => { + await mkdir(testTemplateDir, { recursive: true }); + + const mockDockerService = { + getContainers: vi.fn(), + }; + + const mockDockerConfigService = { + getConfig: vi.fn(), + replaceConfig: vi.fn(), + validate: vi.fn((config) => Promise.resolve(config)), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DockerTemplateScannerService, + { + provide: DockerConfigService, + useValue: mockDockerConfigService, + }, + { + provide: DockerService, + useValue: mockDockerService, + }, + ], + }).compile(); + + service = module.get(DockerTemplateScannerService); + dockerConfigService = module.get(DockerConfigService); + dockerService = module.get(DockerService); + }); + + afterEach(async () => { + await rm(testTemplateDir, { recursive: true, force: true }); + }); + + describe('parseTemplate', () => { + it('should parse valid XML template', async () => { + const templatePath = join(testTemplateDir, 'test.xml'); + const templateContent = ` + + test-container + test/image +`; + await writeFile(templatePath, templateContent); + + const result = await (service as any).parseTemplate(templatePath); + + expect(result).toEqual({ + filePath: templatePath, + name: 'test-container', + repository: 'test/image', + }); + }); + + it('should handle invalid XML gracefully by returning null', async () => { + const templatePath = join(testTemplateDir, 'invalid.xml'); + await writeFile(templatePath, 'not xml'); + + const result = await (service as any).parseTemplate(templatePath); + expect(result).toBeNull(); + }); + + it('should return null for XML without Container element', async () => { + const templatePath = join(testTemplateDir, 'no-container.xml'); + const templateContent = ``; + await writeFile(templatePath, templateContent); + + const result = await (service as any).parseTemplate(templatePath); + + expect(result).toBeNull(); + }); + }); + + describe('matchContainerToTemplate', () => { + it('should match by container name (exact match)', () => { + const container: DockerContainer = { + id: 'abc123', + names: ['/test-container'], + image: 'different/image:latest', + } as DockerContainer; + + const templates = [ + { filePath: '/path/1', name: 'test-container', repository: 'some/repo' }, + { filePath: '/path/2', name: 'other', repository: 'other/repo' }, + ]; + + const result = (service as any).matchContainerToTemplate(container, templates); + + expect(result).toEqual(templates[0]); + }); + + it('should match by repository when name does not match', () => { + const container: DockerContainer = { + id: 'abc123', + names: ['/my-container'], + image: 'test/image:v1.0', + } as DockerContainer; + + const templates = [ + { filePath: '/path/1', name: 'different', repository: 'other/repo' }, + { filePath: '/path/2', name: 'also-different', repository: 'test/image' }, + ]; + + const result = (service as any).matchContainerToTemplate(container, templates); + + expect(result).toEqual(templates[1]); + }); + + it('should strip tags when matching repository', () => { + const container: DockerContainer = { + id: 'abc123', + names: ['/my-container'], + image: 'test/image:latest', + } as DockerContainer; + + const templates = [ + { filePath: '/path/1', name: 'different', repository: 'test/image:v1.0' }, + ]; + + const result = (service as any).matchContainerToTemplate(container, templates); + + expect(result).toEqual(templates[0]); + }); + + it('should return null when no match found', () => { + const container: DockerContainer = { + id: 'abc123', + names: ['/my-container'], + image: 'test/image:latest', + } as DockerContainer; + + const templates = [{ filePath: '/path/1', name: 'different', repository: 'other/image' }]; + + const result = (service as any).matchContainerToTemplate(container, templates); + + expect(result).toBeNull(); + }); + + it('should be case-insensitive', () => { + const container: DockerContainer = { + id: 'abc123', + names: ['/Test-Container'], + image: 'Test/Image:latest', + } as DockerContainer; + + const templates = [ + { filePath: '/path/1', name: 'test-container', repository: 'test/image' }, + ]; + + const result = (service as any).matchContainerToTemplate(container, templates); + + expect(result).toEqual(templates[0]); + }); + }); + + describe('scanTemplates', () => { + it('should scan templates and create mappings', async () => { + const template1 = join(testTemplateDir, 'redis.xml'); + await writeFile( + template1, + ` + + redis + redis +` + ); + + const containers: DockerContainer[] = [ + { + id: 'container1', + names: ['/redis'], + image: 'redis:latest', + } as DockerContainer, + ]; + + vi.mocked(dockerService.getContainers).mockResolvedValue(containers); + vi.mocked(dockerConfigService.getConfig).mockReturnValue({ + updateCheckCronSchedule: '0 6 * * *', + templateMappings: {}, + skipTemplatePaths: [], + }); + + const result = await service.scanTemplates(); + + expect(result.scanned).toBe(1); + expect(result.matched).toBe(1); + expect(result.errors).toHaveLength(0); + expect(dockerConfigService.replaceConfig).toHaveBeenCalledWith( + expect.objectContaining({ + templateMappings: { + redis: template1, + }, + }) + ); + }); + + it('should skip containers in skipTemplatePaths', async () => { + const template1 = join(testTemplateDir, 'redis.xml'); + await writeFile( + template1, + ` + + redis + redis +` + ); + + const containers: DockerContainer[] = [ + { + id: 'container1', + names: ['/redis'], + image: 'redis:latest', + } as DockerContainer, + ]; + + vi.mocked(dockerService.getContainers).mockResolvedValue(containers); + vi.mocked(dockerConfigService.getConfig).mockReturnValue({ + updateCheckCronSchedule: '0 6 * * *', + templateMappings: {}, + skipTemplatePaths: ['redis'], + }); + + const result = await service.scanTemplates(); + + expect(result.skipped).toBe(1); + expect(result.matched).toBe(0); + }); + + it('should handle missing template directory gracefully', async () => { + await rm(testTemplateDir, { recursive: true, force: true }); + + const containers: DockerContainer[] = []; + + vi.mocked(dockerService.getContainers).mockResolvedValue(containers); + vi.mocked(dockerConfigService.getConfig).mockReturnValue({ + updateCheckCronSchedule: '0 6 * * *', + templateMappings: {}, + skipTemplatePaths: [], + }); + + const result = await service.scanTemplates(); + + expect(result.scanned).toBe(0); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('should handle docker service errors gracefully', async () => { + vi.mocked(dockerService.getContainers).mockRejectedValue(new Error('Docker error')); + vi.mocked(dockerConfigService.getConfig).mockReturnValue({ + updateCheckCronSchedule: '0 6 * * *', + templateMappings: {}, + skipTemplatePaths: [], + }); + + const result = await service.scanTemplates(); + + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors[0]).toContain('Failed to get containers'); + }); + + it('should set null mapping for unmatched containers', async () => { + const containers: DockerContainer[] = [ + { + id: 'container1', + names: ['/unknown'], + image: 'unknown:latest', + } as DockerContainer, + ]; + + vi.mocked(dockerService.getContainers).mockResolvedValue(containers); + vi.mocked(dockerConfigService.getConfig).mockReturnValue({ + updateCheckCronSchedule: '0 6 * * *', + templateMappings: {}, + skipTemplatePaths: [], + }); + + await service.scanTemplates(); + + expect(dockerConfigService.replaceConfig).toHaveBeenCalledWith( + expect.objectContaining({ + templateMappings: { + unknown: null, + }, + }) + ); + }); + }); + + describe('syncMissingContainers', () => { + it('should return true and trigger scan when containers are missing mappings', async () => { + const containers: DockerContainer[] = [ + { + id: 'container1', + names: ['/redis'], + image: 'redis:latest', + } as DockerContainer, + ]; + + vi.mocked(dockerConfigService.getConfig).mockReturnValue({ + updateCheckCronSchedule: '0 6 * * *', + templateMappings: {}, + skipTemplatePaths: [], + }); + + vi.mocked(dockerService.getContainers).mockResolvedValue(containers); + + const scanSpy = vi.spyOn(service, 'scanTemplates').mockResolvedValue({ + scanned: 0, + matched: 0, + skipped: 0, + errors: [], + }); + + const result = await service.syncMissingContainers(containers); + + expect(result).toBe(true); + expect(scanSpy).toHaveBeenCalled(); + }); + + it('should return false when all containers have mappings', async () => { + const containers: DockerContainer[] = [ + { + id: 'container1', + names: ['/redis'], + image: 'redis:latest', + } as DockerContainer, + ]; + + vi.mocked(dockerConfigService.getConfig).mockReturnValue({ + updateCheckCronSchedule: '0 6 * * *', + templateMappings: { + redis: '/path/to/template.xml', + }, + skipTemplatePaths: [], + }); + + const scanSpy = vi.spyOn(service, 'scanTemplates'); + + const result = await service.syncMissingContainers(containers); + + expect(result).toBe(false); + expect(scanSpy).not.toHaveBeenCalled(); + }); + + it('should not trigger scan for containers in skip list', async () => { + const containers: DockerContainer[] = [ + { + id: 'container1', + names: ['/redis'], + image: 'redis:latest', + } as DockerContainer, + ]; + + vi.mocked(dockerConfigService.getConfig).mockReturnValue({ + updateCheckCronSchedule: '0 6 * * *', + templateMappings: {}, + skipTemplatePaths: ['redis'], + }); + + const scanSpy = vi.spyOn(service, 'scanTemplates'); + + const result = await service.syncMissingContainers(containers); + + expect(result).toBe(false); + expect(scanSpy).not.toHaveBeenCalled(); + }); + }); + + describe('normalizeContainerName', () => { + it('should remove leading slash', () => { + const result = (service as any).normalizeContainerName('/container-name'); + expect(result).toBe('container-name'); + }); + + it('should convert to lowercase', () => { + const result = (service as any).normalizeContainerName('/Container-Name'); + expect(result).toBe('container-name'); + }); + }); + + describe('normalizeRepository', () => { + it('should strip tag', () => { + const result = (service as any).normalizeRepository('redis:latest'); + expect(result).toBe('redis'); + }); + + it('should strip version tag', () => { + const result = (service as any).normalizeRepository('postgres:14.5'); + expect(result).toBe('postgres'); + }); + + it('should convert to lowercase', () => { + const result = (service as any).normalizeRepository('Redis:Latest'); + expect(result).toBe('redis'); + }); + + it('should handle repository without tag', () => { + const result = (service as any).normalizeRepository('nginx'); + expect(result).toBe('nginx'); + }); + }); +}); 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 new file mode 100644 index 0000000000..8d5220e807 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker-template-scanner.service.ts @@ -0,0 +1,243 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Timeout } from '@nestjs/schedule'; +import { readdir, readFile } from 'fs/promises'; +import { join } from 'path'; + +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'; + +interface ParsedTemplate { + filePath: string; + name?: string; + repository?: string; +} + +@Injectable() +export class DockerTemplateScannerService { + private readonly logger = new Logger(DockerTemplateScannerService.name); + private readonly xmlParser = new XMLParser({ + ignoreAttributes: false, + parseAttributeValue: true, + trimValues: true, + }); + + constructor( + private readonly dockerConfigService: DockerConfigService, + private readonly dockerService: DockerService + ) {} + + @Timeout(5_000) + async bootstrapScan(attempt = 1, maxAttempts = 5): Promise { + if (!ENABLE_NEXT_DOCKER_RELEASE) { + return; + } + try { + this.logger.log(`Starting template scan (attempt ${attempt}/${maxAttempts})`); + const result = await this.scanTemplates(); + this.logger.log( + `Template scan complete: ${result.matched} matched, ${result.scanned} scanned, ${result.skipped} skipped` + ); + } catch (error) { + if (attempt < maxAttempts) { + this.logger.warn( + `Template scan failed (attempt ${attempt}/${maxAttempts}), retrying in 60s: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + setTimeout(() => this.bootstrapScan(attempt + 1, maxAttempts), 60_000); + } else { + this.logger.error( + `Template scan failed after ${maxAttempts} attempts: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + } + } + } + + async syncMissingContainers(containers: DockerContainer[]): Promise { + const config = this.dockerConfigService.getConfig(); + const mappings = config.templateMappings || {}; + const skipSet = new Set(config.skipTemplatePaths || []); + + const needsSync = containers.filter((c) => { + const containerName = this.normalizeContainerName(c.names[0]); + return !mappings[containerName] && !skipSet.has(containerName); + }); + + if (needsSync.length > 0) { + this.logger.log( + `Found ${needsSync.length} containers without template mappings, triggering sync` + ); + await this.scanTemplates(); + return true; + } + return false; + } + + async scanTemplates(): Promise { + const result: DockerTemplateSyncResult = { + scanned: 0, + matched: 0, + skipped: 0, + errors: [], + }; + + const templates = await this.loadAllTemplates(result); + + try { + const containers = await this.dockerService.getContainers({ skipCache: true }); + const config = this.dockerConfigService.getConfig(); + const currentMappings = config.templateMappings || {}; + const skipSet = new Set(config.skipTemplatePaths || []); + + const newMappings: Record = { ...currentMappings }; + + for (const container of containers) { + const containerName = this.normalizeContainerName(container.names[0]); + if (skipSet.has(containerName)) { + result.skipped++; + continue; + } + + const match = this.matchContainerToTemplate(container, templates); + if (match) { + newMappings[containerName] = match.filePath; + result.matched++; + } else { + newMappings[containerName] = null; + } + } + + await this.updateMappings(newMappings); + } catch (error) { + const errorMsg = `Failed to get containers: ${error instanceof Error ? error.message : 'Unknown error'}`; + this.logger.error(error, 'Failed to get containers'); + result.errors.push(errorMsg); + } + + return result; + } + + async getTemplateDetails(filePath: string): Promise<{ + project?: string; + registry?: string; + support?: string; + overview?: string; + icon?: string; + } | null> { + try { + const content = await readFile(filePath, 'utf-8'); + const parsed = this.xmlParser.parse(content); + + if (!parsed.Container) { + return null; + } + + const container = parsed.Container; + return { + project: container.Project, + registry: container.Registry, + support: container.Support, + overview: container.ReadMe || container.Overview, + icon: container.Icon, + }; + } catch (error) { + this.logger.warn( + `Failed to parse template ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}` + ); + return null; + } + } + + private async loadAllTemplates(result: DockerTemplateSyncResult): Promise { + const allTemplates: ParsedTemplate[] = []; + + for (const directory of PATHS_DOCKER_TEMPLATES) { + try { + const files = await readdir(directory); + const xmlFiles = files.filter((f) => f.endsWith('.xml')); + result.scanned += xmlFiles.length; + + for (const file of xmlFiles) { + const filePath = join(directory, file); + try { + const template = await this.parseTemplate(filePath); + if (template) { + allTemplates.push(template); + } + } catch (error) { + const errorMsg = `Failed to parse template ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`; + this.logger.warn(errorMsg); + result.errors.push(errorMsg); + } + } + } catch (error) { + const errorMsg = `Failed to read template directory ${directory}: ${error instanceof Error ? error.message : 'Unknown error'}`; + this.logger.warn(errorMsg); + result.errors.push(errorMsg); + } + } + + return allTemplates; + } + + private async parseTemplate(filePath: string): Promise { + const content = await readFile(filePath, 'utf-8'); + const parsed = this.xmlParser.parse(content); + + if (!parsed.Container) { + return null; + } + + const container = parsed.Container; + return { + filePath, + name: container.Name, + repository: container.Repository, + }; + } + + private matchContainerToTemplate( + container: DockerContainer, + templates: ParsedTemplate[] + ): ParsedTemplate | null { + const containerName = this.normalizeContainerName(container.names[0]); + const containerImage = this.normalizeRepository(container.image); + + for (const template of templates) { + if (template.name && this.normalizeContainerName(template.name) === containerName) { + return template; + } + } + + for (const template of templates) { + if ( + template.repository && + this.normalizeRepository(template.repository) === containerImage + ) { + return template; + } + } + + return null; + } + + private normalizeContainerName(name: string): string { + return name.replace(/^\//, '').toLowerCase(); + } + + private normalizeRepository(repository: string): string { + return repository.split(':')[0].toLowerCase(); + } + + private async updateMappings(mappings: Record): Promise { + const config = this.dockerConfigService.getConfig(); + const updated = await this.dockerConfigService.validate({ + ...config, + templateMappings: mappings, + }); + this.dockerConfigService.replaceConfig(updated); + } +} diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.constants.ts b/api/src/unraid-api/graph/resolvers/docker/docker.constants.ts new file mode 100644 index 0000000000..17ce9df925 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker.constants.ts @@ -0,0 +1 @@ +export const DOCKER_SERVICE_TOKEN = Symbol('DOCKER_SERVICE'); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.model.ts b/api/src/unraid-api/graph/resolvers/docker/docker.model.ts index 6380cd5357..7b6f6021a4 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.model.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.model.ts @@ -1,8 +1,21 @@ -import { Field, ID, Int, ObjectType, registerEnumType } from '@nestjs/graphql'; - +import { + Field, + Float, + GraphQLISODateTime, + ID, + InputType, + Int, + ObjectType, + registerEnumType, +} from '@nestjs/graphql'; + +import { type Layout } from '@jsonforms/core'; import { Node } from '@unraid/shared/graphql.model.js'; +import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; import { GraphQLBigInt, GraphQLJSON, GraphQLPort } from 'graphql-scalars'; +import { DataSlice } from '@app/unraid-api/types/json-forms.js'; + export enum ContainerPortType { TCP = 'TCP', UDP = 'UDP', @@ -27,8 +40,54 @@ export class ContainerPort { type!: ContainerPortType; } +@ObjectType() +export class DockerPortConflictContainer { + @Field(() => PrefixedID) + id!: string; + + @Field(() => String) + name!: string; +} + +@ObjectType() +export class DockerContainerPortConflict { + @Field(() => GraphQLPort) + privatePort!: number; + + @Field(() => ContainerPortType) + type!: ContainerPortType; + + @Field(() => [DockerPortConflictContainer]) + containers!: DockerPortConflictContainer[]; +} + +@ObjectType() +export class DockerLanPortConflict { + @Field(() => String) + lanIpPort!: string; + + @Field(() => GraphQLPort, { nullable: true }) + publicPort?: number; + + @Field(() => ContainerPortType) + type!: ContainerPortType; + + @Field(() => [DockerPortConflictContainer]) + containers!: DockerPortConflictContainer[]; +} + +@ObjectType() +export class DockerPortConflicts { + @Field(() => [DockerContainerPortConflict]) + containerPorts!: DockerContainerPortConflict[]; + + @Field(() => [DockerLanPortConflict]) + lanPorts!: DockerLanPortConflict[]; +} + export enum ContainerState { RUNNING = 'RUNNING', + PAUSED = 'PAUSED', EXITED = 'EXITED', } @@ -89,12 +148,30 @@ export class DockerContainer extends Node { @Field(() => [ContainerPort]) ports!: ContainerPort[]; + @Field(() => [String], { + nullable: true, + description: 'List of LAN-accessible host:port values', + }) + lanIpPorts?: string[]; + @Field(() => GraphQLBigInt, { nullable: true, description: 'Total size of all files in the container (in bytes)', }) sizeRootFs?: number; + @Field(() => GraphQLBigInt, { + nullable: true, + description: 'Size of writable layer (in bytes)', + }) + sizeRw?: number; + + @Field(() => GraphQLBigInt, { + nullable: true, + description: 'Size of container logs (in bytes)', + }) + sizeLog?: number; + @Field(() => GraphQLJSON, { nullable: true }) labels?: Record; @@ -115,6 +192,30 @@ export class DockerContainer extends Node { @Field(() => Boolean) autoStart!: boolean; + + @Field(() => Int, { nullable: true, description: 'Zero-based order in the auto-start list' }) + autoStartOrder?: number; + + @Field(() => Int, { nullable: true, description: 'Wait time in seconds applied after start' }) + autoStartWait?: number; + + @Field(() => String, { nullable: true }) + templatePath?: string; + + @Field(() => String, { nullable: true, description: 'Project/Product homepage URL' }) + projectUrl?: string; + + @Field(() => String, { nullable: true, description: 'Registry/Docker Hub URL' }) + registryUrl?: string; + + @Field(() => String, { nullable: true, description: 'Support page/thread URL' }) + supportUrl?: string; + + @Field(() => String, { nullable: true, description: 'Icon URL' }) + iconUrl?: string; + + @Field(() => Boolean, { description: 'Whether the container is orphaned (no template found)' }) + isOrphaned!: boolean; } @ObjectType({ implements: () => Node }) @@ -162,6 +263,52 @@ export class DockerNetwork extends Node { labels!: Record; } +@ObjectType() +export class DockerContainerLogLine { + @Field(() => GraphQLISODateTime) + timestamp!: Date; + + @Field(() => String) + message!: string; +} + +@ObjectType() +export class DockerContainerLogs { + @Field(() => PrefixedID) + containerId!: string; + + @Field(() => [DockerContainerLogLine]) + lines!: DockerContainerLogLine[]; + + @Field(() => GraphQLISODateTime, { + nullable: true, + description: + 'Cursor that can be passed back through the since argument to continue streaming logs.', + }) + cursor?: Date | null; +} + +@ObjectType() +export class DockerContainerStats { + @Field(() => PrefixedID) + id!: string; + + @Field(() => Float, { description: 'CPU Usage Percentage' }) + cpuPercent!: number; + + @Field(() => String, { description: 'Memory Usage String (e.g. 100MB / 1GB)' }) + memUsage!: string; + + @Field(() => Float, { description: 'Memory Usage Percentage' }) + memPercent!: number; + + @Field(() => String, { description: 'Network I/O String (e.g. 100MB / 1GB)' }) + netIO!: string; + + @Field(() => String, { description: 'Block I/O String (e.g. 100MB / 1GB)' }) + blockIO!: string; +} + @ObjectType({ implements: () => Node, }) @@ -171,4 +318,43 @@ export class Docker extends Node { @Field(() => [DockerNetwork]) networks!: DockerNetwork[]; + + @Field(() => DockerPortConflicts) + portConflicts!: DockerPortConflicts; + + @Field(() => DockerContainerLogs, { + description: + 'Access container logs. Requires specifying a target container id through resolver arguments.', + }) + logs!: DockerContainerLogs; +} + +@ObjectType() +export class DockerContainerOverviewForm { + @Field(() => ID) + id!: string; + + @Field(() => GraphQLJSON) + dataSchema!: { properties: DataSlice; type: 'object' }; + + @Field(() => GraphQLJSON) + uiSchema!: Layout; + + @Field(() => GraphQLJSON) + data!: Record; +} + +@InputType() +export class DockerAutostartEntryInput { + @Field(() => PrefixedID, { description: 'Docker container identifier' }) + id!: string; + + @Field(() => Boolean, { description: 'Whether the container should auto-start' }) + autoStart!: boolean; + + @Field(() => Int, { + nullable: true, + description: 'Number of seconds to wait after starting the container', + }) + wait?: number | null; } diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.module.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker.module.spec.ts index af5500d91b..66fe22ca5d 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.module.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.module.spec.ts @@ -4,13 +4,21 @@ import { describe, expect, it, vi } from 'vitest'; import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js'; import { DockerEventService } from '@app/unraid-api/graph/resolvers/docker/docker-event.service.js'; +import { DockerFormService } from '@app/unraid-api/graph/resolvers/docker/docker-form.service.js'; +import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker-log.service.js'; +import { DockerNetworkService } from '@app/unraid-api/graph/resolvers/docker/docker-network.service.js'; import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js'; +import { DockerPortService } from '@app/unraid-api/graph/resolvers/docker/docker-port.service.js'; +import { DockerStatsService } from '@app/unraid-api/graph/resolvers/docker/docker-stats.service.js'; +import { DockerTemplateScannerService } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.service.js'; import { DockerModule } from '@app/unraid-api/graph/resolvers/docker/docker.module.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'; import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.js'; import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js'; +import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js'; +import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; describe('DockerModule', () => { it('should compile the module', async () => { @@ -23,6 +31,22 @@ describe('DockerModule', () => { .useValue({ getConfig: vi.fn() }) .overrideProvider(DockerConfigService) .useValue({ getConfig: vi.fn() }) + .overrideProvider(DockerLogService) + .useValue({}) + .overrideProvider(DockerNetworkService) + .useValue({}) + .overrideProvider(DockerPortService) + .useValue({}) + .overrideProvider(SubscriptionTrackerService) + .useValue({ + registerTopic: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }) + .overrideProvider(SubscriptionHelperService) + .useValue({ + createTrackedSubscription: vi.fn(), + }) .compile(); expect(module).toBeDefined(); @@ -47,6 +71,10 @@ describe('DockerModule', () => { }); it('should provide DockerEventService', async () => { + // DockerEventService is not exported by DockerModule but we can test if we can provide it + // But here we are creating a module with providers manually, not importing DockerModule. + // Wait, DockerEventService was NOT in DockerModule providers in my refactor? + // I should check if DockerEventService is in DockerModule. const module: TestingModule = await Test.createTestingModule({ providers: [ DockerEventService, @@ -63,8 +91,35 @@ describe('DockerModule', () => { providers: [ DockerResolver, { provide: DockerService, useValue: {} }, + { provide: DockerFormService, useValue: { getContainerOverviewForm: vi.fn() } }, { provide: DockerOrganizerService, useValue: {} }, { provide: DockerPhpService, useValue: { getContainerUpdateStatuses: vi.fn() } }, + { + provide: DockerTemplateScannerService, + useValue: { + scanTemplates: vi.fn(), + syncMissingContainers: vi.fn(), + }, + }, + { + provide: DockerStatsService, + useValue: { + startStatsStream: vi.fn(), + stopStatsStream: vi.fn(), + }, + }, + { + provide: SubscriptionTrackerService, + useValue: { + registerTopic: vi.fn(), + }, + }, + { + provide: SubscriptionHelperService, + useValue: { + createTrackedSubscription: vi.fn(), + }, + }, ], }).compile(); 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 22095f518d..dccaf57fd7 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.module.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.module.ts @@ -2,27 +2,44 @@ import { Module } from '@nestjs/common'; import { JobModule } from '@app/unraid-api/cron/job.module.js'; import { ContainerStatusJob } from '@app/unraid-api/graph/resolvers/docker/container-status.job.js'; +import { DockerAutostartService } from '@app/unraid-api/graph/resolvers/docker/docker-autostart.service.js'; import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js'; import { DockerContainerResolver } from '@app/unraid-api/graph/resolvers/docker/docker-container.resolver.js'; +import { DockerFormService } from '@app/unraid-api/graph/resolvers/docker/docker-form.service.js'; +import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker-log.service.js'; 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 { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js'; +import { DockerPortService } from '@app/unraid-api/graph/resolvers/docker/docker-port.service.js'; +import { DockerStatsService } from '@app/unraid-api/graph/resolvers/docker/docker-stats.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 { 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'; import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.js'; import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js'; +import { NotificationsModule } from '@app/unraid-api/graph/resolvers/notifications/notifications.module.js'; +import { ServicesModule } from '@app/unraid-api/graph/services/services.module.js'; @Module({ - imports: [JobModule], + imports: [JobModule, NotificationsModule, ServicesModule], providers: [ // Services DockerService, + DockerAutostartService, + DockerFormService, DockerOrganizerConfigService, DockerOrganizerService, DockerManifestService, DockerPhpService, DockerConfigService, - // DockerEventService, + DockerTemplateScannerService, + DockerTemplateIconService, + DockerStatsService, + DockerLogService, + DockerNetworkService, + DockerPortService, // Jobs ContainerStatusJob, diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.spec.ts b/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.spec.ts index 97e0626042..6dafe8bb53 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.spec.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.spec.ts @@ -45,6 +45,7 @@ describe('DockerMutationsResolver', () => { state: ContainerState.RUNNING, status: 'Up 2 hours', names: ['test-container'], + isOrphaned: false, }; vi.mocked(dockerService.start).mockResolvedValue(mockContainer); @@ -65,6 +66,7 @@ describe('DockerMutationsResolver', () => { state: ContainerState.EXITED, status: 'Exited', names: ['test-container'], + isOrphaned: false, }; vi.mocked(dockerService.stop).mockResolvedValue(mockContainer); diff --git a/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.ts b/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.ts index a1de7e0dbc..16e78075d2 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.mutations.resolver.ts @@ -4,7 +4,11 @@ import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; -import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; +import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js'; +import { + DockerAutostartEntryInput, + DockerContainer, +} from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; import { DockerMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js'; @@ -32,4 +36,83 @@ export class DockerMutationsResolver { public async stop(@Args('id', { type: () => PrefixedID }) id: string) { return this.dockerService.stop(id); } + @ResolveField(() => DockerContainer, { description: 'Pause (Suspend) a container' }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.DOCKER, + }) + public async pause(@Args('id', { type: () => PrefixedID }) id: string) { + return this.dockerService.pause(id); + } + @ResolveField(() => DockerContainer, { description: 'Unpause (Resume) a container' }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.DOCKER, + }) + public async unpause(@Args('id', { type: () => PrefixedID }) id: string) { + return this.dockerService.unpause(id); + } + + @ResolveField(() => Boolean, { description: 'Remove a container' }) + @UsePermissions({ + action: AuthAction.DELETE_ANY, + resource: Resource.DOCKER, + }) + public async removeContainer(@Args('id', { type: () => PrefixedID }) id: string) { + return this.dockerService.removeContainer(id); + } + + @ResolveField(() => Boolean, { + description: 'Update auto-start configuration for Docker containers', + }) + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.DOCKER, + }) + public async updateAutostartConfiguration( + @Args('entries', { type: () => [DockerAutostartEntryInput] }) + entries: DockerAutostartEntryInput[], + @Args('persistUserPreferences', { type: () => Boolean, nullable: true }) + persistUserPreferences?: boolean + ) { + await this.dockerService.updateAutostartConfiguration(entries, { + persistUserPreferences, + }); + return true; + } + + @ResolveField(() => DockerContainer, { description: 'Update a container to the latest image' }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.DOCKER, + }) + public async updateContainer(@Args('id', { type: () => PrefixedID }) id: string) { + return this.dockerService.updateContainer(id); + } + + @ResolveField(() => [DockerContainer], { + description: 'Update multiple containers to the latest images', + }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.DOCKER, + }) + public async updateContainers( + @Args('ids', { type: () => [PrefixedID] }) + ids: string[] + ) { + return this.dockerService.updateContainers(ids); + } + + @ResolveField(() => [DockerContainer], { + description: 'Update all containers that have available updates', + }) + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.DOCKER, + }) + public async updateAllContainers() { + return this.dockerService.updateAllContainers(); + } } 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 80000f91b2..b16c2e5bc8 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 @@ -3,11 +3,20 @@ import { Test } from '@nestjs/testing'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { DockerFormService } from '@app/unraid-api/graph/resolvers/docker/docker-form.service.js'; import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js'; -import { ContainerState, DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; +import { DockerStatsService } from '@app/unraid-api/graph/resolvers/docker/docker-stats.service.js'; +import { DockerTemplateScannerService } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.service.js'; +import { + ContainerState, + DockerContainer, + DockerContainerLogs, +} from '@app/unraid-api/graph/resolvers/docker/docker.model.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'; import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js'; +import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js'; +import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; import { GraphQLFieldHelper } from '@app/unraid-api/utils/graphql-field-helper.js'; vi.mock('@app/unraid-api/utils/graphql-field-helper.js', () => ({ @@ -29,6 +38,14 @@ describe('DockerResolver', () => { useValue: { getContainers: vi.fn(), getNetworks: vi.fn(), + getContainerLogSizes: vi.fn(), + getContainerLogs: vi.fn(), + }, + }, + { + provide: DockerFormService, + useValue: { + getContainerOverviewForm: vi.fn(), }, }, { @@ -43,6 +60,39 @@ describe('DockerResolver', () => { getContainerUpdateStatuses: vi.fn(), }, }, + { + provide: DockerTemplateScannerService, + useValue: { + scanTemplates: vi.fn().mockResolvedValue({ + scanned: 0, + matched: 0, + skipped: 0, + errors: [], + }), + syncMissingContainers: vi.fn().mockResolvedValue(false), + }, + }, + { + provide: DockerStatsService, + useValue: { + startStatsStream: vi.fn(), + stopStatsStream: vi.fn(), + }, + }, + { + provide: SubscriptionTrackerService, + useValue: { + registerTopic: vi.fn(), + subscribe: vi.fn(), + unsubscribe: vi.fn(), + }, + }, + { + provide: SubscriptionHelperService, + useValue: { + createTrackedSubscription: vi.fn(), + }, + }, ], }).compile(); @@ -51,6 +101,8 @@ describe('DockerResolver', () => { // Reset mocks before each test vi.clearAllMocks(); + vi.mocked(GraphQLFieldHelper.isFieldRequested).mockImplementation(() => false); + vi.mocked(dockerService.getContainerLogSizes).mockResolvedValue(new Map()); }); it('should be defined', () => { @@ -75,6 +127,7 @@ describe('DockerResolver', () => { ports: [], state: ContainerState.EXITED, status: 'Exited', + isOrphaned: false, }, { id: '2', @@ -87,16 +140,19 @@ describe('DockerResolver', () => { ports: [], state: ContainerState.RUNNING, status: 'Up 2 hours', + isOrphaned: false, }, ]; vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers); - vi.mocked(GraphQLFieldHelper.isFieldRequested).mockReturnValue(false); + vi.mocked(GraphQLFieldHelper.isFieldRequested).mockImplementation(() => false); const mockInfo = {} as any; const result = await resolver.containers(false, mockInfo); expect(result).toEqual(mockContainers); expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeRootFs'); + expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeRw'); + expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeLog'); expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: false, size: false }); }); @@ -114,10 +170,13 @@ describe('DockerResolver', () => { sizeRootFs: 1024000, state: ContainerState.EXITED, status: 'Exited', + isOrphaned: false, }, ]; vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers); - vi.mocked(GraphQLFieldHelper.isFieldRequested).mockReturnValue(true); + vi.mocked(GraphQLFieldHelper.isFieldRequested).mockImplementation((_, field) => { + return field === 'sizeRootFs'; + }); const mockInfo = {} as any; @@ -127,10 +186,61 @@ describe('DockerResolver', () => { expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: false, size: true }); }); + it('should request size when sizeRw field is requested', async () => { + const mockContainers: DockerContainer[] = []; + vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers); + vi.mocked(GraphQLFieldHelper.isFieldRequested).mockImplementation((_, field) => { + return field === 'sizeRw'; + }); + + const mockInfo = {} as any; + + await resolver.containers(false, mockInfo); + expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeRw'); + expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: false, size: true }); + }); + + it('should fetch log sizes when sizeLog field is requested', async () => { + const mockContainers: DockerContainer[] = [ + { + id: '1', + autoStart: false, + command: 'test', + names: ['/test-container'], + created: 1234567890, + image: 'test-image', + imageId: 'test-image-id', + ports: [], + state: ContainerState.EXITED, + status: 'Exited', + isOrphaned: false, + }, + ]; + vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers); + vi.mocked(GraphQLFieldHelper.isFieldRequested).mockImplementation((_, field) => { + if (field === 'sizeLog') return true; + return false; + }); + + const logSizeMap = new Map([['test-container', 42]]); + vi.mocked(dockerService.getContainerLogSizes).mockResolvedValue(logSizeMap); + + const mockInfo = {} as any; + + const result = await resolver.containers(false, mockInfo); + + expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeLog'); + expect(dockerService.getContainerLogSizes).toHaveBeenCalledWith(['test-container']); + expect(result[0]?.sizeLog).toBe(42); + expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: false, size: false }); + }); + it('should request size when GraphQLFieldHelper indicates sizeRootFs is requested', async () => { const mockContainers: DockerContainer[] = []; vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers); - vi.mocked(GraphQLFieldHelper.isFieldRequested).mockReturnValue(true); + vi.mocked(GraphQLFieldHelper.isFieldRequested).mockImplementation((_, field) => { + return field === 'sizeRootFs'; + }); const mockInfo = {} as any; @@ -142,7 +252,7 @@ describe('DockerResolver', () => { it('should not request size when GraphQLFieldHelper indicates sizeRootFs is not requested', async () => { const mockContainers: DockerContainer[] = []; vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers); - vi.mocked(GraphQLFieldHelper.isFieldRequested).mockReturnValue(false); + vi.mocked(GraphQLFieldHelper.isFieldRequested).mockImplementation(() => false); const mockInfo = {} as any; @@ -161,4 +271,22 @@ describe('DockerResolver', () => { await resolver.containers(true, mockInfo); expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: true, size: false }); }); + + it('should fetch container logs with provided arguments', async () => { + const since = new Date('2024-01-01T00:00:00.000Z'); + const logResult: DockerContainerLogs = { + containerId: '1', + lines: [], + cursor: since, + }; + vi.mocked(dockerService.getContainerLogs).mockResolvedValue(logResult); + + const result = await resolver.logs('1', since, 25); + + expect(result).toEqual(logResult); + expect(dockerService.getContainerLogs).toHaveBeenCalledWith('1', { + since, + tail: 25, + }); + }); }); 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 e16d1ea85d..e0cce8a10a 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.resolver.ts @@ -1,19 +1,42 @@ -import { Args, Info, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql'; +import { + Args, + GraphQLISODateTime, + Info, + Int, + Mutation, + Query, + ResolveField, + Resolver, + Subscription, +} from '@nestjs/graphql'; import type { GraphQLResolveInfo } from 'graphql'; import { AuthAction, Resource } from '@unraid/shared/graphql.model.js'; +import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; import { UsePermissions } from '@unraid/shared/use-permissions.directive.js'; +import { GraphQLJSON } from 'graphql-scalars'; +import { PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js'; +import { DockerFormService } from '@app/unraid-api/graph/resolvers/docker/docker-form.service.js'; import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js'; +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 { ExplicitStatusItem } from '@app/unraid-api/graph/resolvers/docker/docker-update-status.model.js'; import { Docker, DockerContainer, + DockerContainerLogs, + DockerContainerOverviewForm, + DockerContainerStats, DockerNetwork, + DockerPortConflicts, } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js'; +import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js'; +import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js'; import { DEFAULT_ORGANIZER_ROOT_ID } from '@app/unraid-api/organizer/organizer.js'; import { ResolvedOrganizerV1 } from '@app/unraid-api/organizer/organizer.model.js'; import { GraphQLFieldHelper } from '@app/unraid-api/utils/graphql-field-helper.js'; @@ -22,9 +45,20 @@ import { GraphQLFieldHelper } from '@app/unraid-api/utils/graphql-field-helper.j export class DockerResolver { constructor( private readonly dockerService: DockerService, + private readonly dockerFormService: DockerFormService, private readonly dockerOrganizerService: DockerOrganizerService, - private readonly dockerPhpService: DockerPhpService - ) {} + private readonly dockerPhpService: DockerPhpService, + private readonly dockerTemplateScannerService: DockerTemplateScannerService, + private readonly dockerStatsService: DockerStatsService, + private readonly subscriptionTracker: SubscriptionTrackerService, + private readonly subscriptionHelper: SubscriptionHelperService + ) { + this.subscriptionTracker.registerTopic( + PUBSUB_CHANNEL.DOCKER_STATS, + () => this.dockerStatsService.startStatsStream(), + () => this.dockerStatsService.stopStatsStream() + ); + } @UsePermissions({ action: AuthAction.READ_ANY, @@ -46,8 +80,47 @@ export class DockerResolver { @Args('skipCache', { defaultValue: false, type: () => Boolean }) skipCache: boolean, @Info() info: GraphQLResolveInfo ) { - const requestsSize = GraphQLFieldHelper.isFieldRequested(info, 'sizeRootFs'); - return this.dockerService.getContainers({ skipCache, size: requestsSize }); + const requestsRootFsSize = GraphQLFieldHelper.isFieldRequested(info, 'sizeRootFs'); + const requestsRwSize = GraphQLFieldHelper.isFieldRequested(info, 'sizeRw'); + const requestsLogSize = GraphQLFieldHelper.isFieldRequested(info, 'sizeLog'); + const containers = await this.dockerService.getContainers({ + skipCache, + size: requestsRootFsSize || requestsRwSize, + }); + + if (requestsLogSize) { + const names = Array.from( + new Set( + containers + .map((container) => container.names?.[0]?.replace(/^\//, '') || null) + .filter((name): name is string => Boolean(name)) + ) + ); + const logSizes = await this.dockerService.getContainerLogSizes(names); + containers.forEach((container) => { + const normalized = container.names?.[0]?.replace(/^\//, '') || ''; + container.sizeLog = normalized ? (logSizes.get(normalized) ?? 0) : 0; + }); + } + + const wasSynced = await this.dockerTemplateScannerService.syncMissingContainers(containers); + return wasSynced ? await this.dockerService.getContainers({ skipCache: true }) : containers; + } + + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.DOCKER, + }) + @ResolveField(() => DockerContainerLogs) + public async logs( + @Args('id', { type: () => PrefixedID }) id: string, + @Args('since', { type: () => GraphQLISODateTime, nullable: true }) since?: Date | null, + @Args('tail', { type: () => Int, nullable: true }) tail?: number | null + ) { + return this.dockerService.getContainerLogs(id, { + since: since ?? undefined, + tail, + }); } @UsePermissions({ @@ -61,14 +134,38 @@ export class DockerResolver { return this.dockerService.getNetworks({ skipCache }); } + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.DOCKER, + }) + @ResolveField(() => DockerPortConflicts) + public async portConflicts( + @Args('skipCache', { defaultValue: false, type: () => Boolean }) skipCache: boolean + ) { + return this.dockerService.getPortConflicts({ skipCache }); + } + + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.DOCKER, + }) + @Query(() => DockerContainerOverviewForm) + public async dockerContainerOverviewForm( + @Args('skipCache', { defaultValue: false, type: () => Boolean }) skipCache: boolean + ) { + return this.dockerFormService.getContainerOverviewForm(skipCache); + } + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') @UsePermissions({ action: AuthAction.READ_ANY, resource: Resource.DOCKER, }) @ResolveField(() => ResolvedOrganizerV1) - public async organizer() { - return this.dockerOrganizerService.resolveOrganizer(); + public async organizer( + @Args('skipCache', { defaultValue: false, type: () => Boolean }) skipCache: boolean + ) { + return this.dockerOrganizerService.resolveOrganizer(undefined, { skipCache }); } @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') @@ -137,6 +234,80 @@ export class DockerResolver { return this.dockerOrganizerService.resolveOrganizer(organizer); } + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.DOCKER, + }) + @Mutation(() => ResolvedOrganizerV1) + public async moveDockerItemsToPosition( + @Args('sourceEntryIds', { type: () => [String] }) sourceEntryIds: string[], + @Args('destinationFolderId') destinationFolderId: string, + @Args('position', { type: () => Number }) position: number + ) { + const organizer = await this.dockerOrganizerService.moveItemsToPosition({ + sourceEntryIds, + destinationFolderId, + position, + }); + return this.dockerOrganizerService.resolveOrganizer(organizer); + } + + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.DOCKER, + }) + @Mutation(() => ResolvedOrganizerV1) + public async renameDockerFolder( + @Args('folderId') folderId: string, + @Args('newName') newName: string + ) { + const organizer = await this.dockerOrganizerService.renameFolderById({ + folderId, + newName, + }); + return this.dockerOrganizerService.resolveOrganizer(organizer); + } + + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.DOCKER, + }) + @Mutation(() => ResolvedOrganizerV1) + public async createDockerFolderWithItems( + @Args('name') name: string, + @Args('parentId', { nullable: true }) parentId?: string, + @Args('sourceEntryIds', { type: () => [String], nullable: true }) sourceEntryIds?: string[], + @Args('position', { type: () => Number, nullable: true }) position?: number + ) { + const organizer = await this.dockerOrganizerService.createFolderWithItems({ + name, + parentId: parentId ?? DEFAULT_ORGANIZER_ROOT_ID, + sourceEntryIds: sourceEntryIds ?? [], + position, + }); + return this.dockerOrganizerService.resolveOrganizer(organizer); + } + + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.DOCKER, + }) + @Mutation(() => ResolvedOrganizerV1) + public async updateDockerViewPreferences( + @Args('viewId', { nullable: true, defaultValue: 'default' }) viewId: string, + @Args('prefs', { type: () => GraphQLJSON }) prefs: Record + ) { + const organizer = await this.dockerOrganizerService.updateViewPreferences({ + viewId, + prefs, + }); + return this.dockerOrganizerService.resolveOrganizer(organizer); + } + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') @UsePermissions({ action: AuthAction.READ_ANY, @@ -146,4 +317,25 @@ export class DockerResolver { public async containerUpdateStatuses() { return this.dockerPhpService.getContainerUpdateStatuses(); } + + @UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE') + @UsePermissions({ + action: AuthAction.UPDATE_ANY, + resource: Resource.DOCKER, + }) + @Mutation(() => DockerTemplateSyncResult) + public async syncDockerTemplatePaths() { + return this.dockerTemplateScannerService.scanTemplates(); + } + + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.DOCKER, + }) + @Subscription(() => DockerContainerStats, { + resolve: (payload) => payload.dockerContainerStats, + }) + public dockerContainerStats() { + return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.DOCKER_STATS); + } } 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 new file mode 100644 index 0000000000..0f9bef965e --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.integration.spec.ts @@ -0,0 +1,169 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Test, TestingModule } from '@nestjs/testing'; +import { mkdtemp, readFile, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { DockerAutostartService } from '@app/unraid-api/graph/resolvers/docker/docker-autostart.service.js'; +import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js'; +import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker-log.service.js'; +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 { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js'; +import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js'; + +// Mock dependencies that are not focus of integration +const mockNotificationsService = { + notifyIfUnique: vi.fn(), +}; + +const mockDockerConfigService = { + getConfig: vi.fn().mockReturnValue({ templateMappings: {} }), +}; + +const mockDockerManifestService = { + getCachedUpdateStatuses: vi.fn().mockResolvedValue({}), + isUpdateAvailableCached: vi.fn().mockResolvedValue(false), +}; + +const mockCacheManager = { + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), +}; + +// Hoisted mock for paths +const { mockPaths } = vi.hoisted(() => ({ + mockPaths: { + 'docker-autostart': '', + 'docker-userprefs': '', + 'docker-socket': '/var/run/docker.sock', + }, +})); + +vi.mock('@app/store/index.js', () => ({ + getters: { + paths: () => mockPaths, + emhttp: () => ({ networks: [] }), + }, +})); + +// Check for Docker availability +let dockerAvailable = false; +try { + const Docker = (await import('dockerode')).default; + const docker = new Docker({ socketPath: '/var/run/docker.sock' }); + await docker.ping(); + dockerAvailable = true; +} catch { + console.warn('Docker not available or not accessible at /var/run/docker.sock'); +} + +describe.runIf(dockerAvailable)('DockerService Integration', () => { + let service: DockerService; + let autostartService: DockerAutostartService; + let module: TestingModule; + let tempDir: string; + + beforeAll(async () => { + // Setup temp dir for config files + tempDir = await mkdtemp(join(tmpdir(), 'unraid-api-docker-test-')); + mockPaths['docker-autostart'] = join(tempDir, 'docker-autostart'); + mockPaths['docker-userprefs'] = join(tempDir, 'docker-userprefs'); + + module = await Test.createTestingModule({ + providers: [ + DockerService, + DockerAutostartService, + DockerLogService, + DockerNetworkService, + DockerPortService, + { provide: CACHE_MANAGER, useValue: mockCacheManager }, + { provide: DockerConfigService, useValue: mockDockerConfigService }, + { provide: DockerManifestService, useValue: mockDockerManifestService }, + { provide: NotificationsService, useValue: mockNotificationsService }, + ], + }).compile(); + + service = module.get(DockerService); + autostartService = module.get(DockerAutostartService); + }); + + afterAll(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('should fetch containers from docker daemon', async () => { + const containers = await service.getContainers({ skipCache: true }); + expect(Array.isArray(containers)).toBe(true); + if (containers.length > 0) { + expect(containers[0]).toHaveProperty('id'); + expect(containers[0]).toHaveProperty('names'); + expect(containers[0].state).toBeDefined(); + } + }); + + it('should fetch networks from docker daemon', async () => { + const networks = await service.getNetworks({ skipCache: true }); + expect(Array.isArray(networks)).toBe(true); + // Default networks (bridge, host, null) should always exist + expect(networks.length).toBeGreaterThan(0); + const bridge = networks.find((n) => n.name === 'bridge'); + expect(bridge).toBeDefined(); + }); + + it('should manage autostart configuration in temp files', async () => { + const containers = await service.getContainers({ skipCache: true }); + if (containers.length === 0) { + console.warn('No containers found, skipping autostart write test'); + return; + } + + const target = containers[0]; + // Ensure name is valid for autostart file (strip /) + const primaryName = autostartService.getContainerPrimaryName(target as any); + expect(primaryName).toBeTruthy(); + + const entry = { + id: target.id, + autoStart: true, + wait: 10, + }; + + await service.updateAutostartConfiguration([entry], { persistUserPreferences: true }); + + // Verify file content + try { + const content = await readFile(mockPaths['docker-autostart'], 'utf8'); + expect(content).toContain(primaryName); + expect(content).toContain('10'); + } catch (error: any) { + // If file doesn't exist, it might be because logic didn't write anything (e.g. name issue) + // But we expect it to write if container exists and we passed valid entry + throw new Error(`Failed to read autostart file: ${error.message}`); + } + }); + + it('should get container logs using dockerode', async () => { + const containers = await service.getContainers({ skipCache: true }); + const running = containers.find((c) => c.state === 'RUNNING'); // Enum value is string 'RUNNING' + + if (!running) { + console.warn('No running containers found, skipping log test'); + return; + } + + // This test verifies that the execa -> dockerode switch works for logs + // If it fails, it likely means the log parsing or dockerode interaction is wrong. + const logs = await service.getContainerLogs(running.id, { tail: 10 }); + expect(logs).toBeDefined(); + expect(logs.containerId).toBe(running.id); + expect(Array.isArray(logs.lines)).toBe(true); + // We can't guarantee lines length > 0 if container is silent, but it shouldn't throw. + }); +}); 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 ba7e974f22..aad50b361d 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 @@ -7,8 +7,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; // Import the mocked pubsub parts import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; -import { ContainerState, DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; +import { DockerAutostartService } from '@app/unraid-api/graph/resolvers/docker/docker-autostart.service.js'; +import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js'; +import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker-log.service.js'; +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 { + ContainerPortType, + 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'; // Mock pubsub vi.mock('@app/core/pubsub.js', () => ({ @@ -24,36 +35,58 @@ interface DockerError extends NodeJS.ErrnoException { address: string; } -const mockContainer = { - start: vi.fn(), - stop: vi.fn(), -}; +const { mockDockerInstance, mockListContainers, mockGetContainer, mockListNetworks, mockContainer } = + vi.hoisted(() => { + const mockContainer = { + start: vi.fn(), + stop: vi.fn(), + pause: vi.fn(), + unpause: vi.fn(), + inspect: vi.fn(), + }; + + const mockListContainers = vi.fn(); + const mockGetContainer = vi.fn().mockReturnValue(mockContainer); + const mockListNetworks = vi.fn(); + + const mockDockerInstance = { + getContainer: mockGetContainer, + listContainers: mockListContainers, + listNetworks: mockListNetworks, + modem: { + Promise: Promise, + protocol: 'http', + socketPath: '/var/run/docker.sock', + headers: {}, + sshOptions: { + agentForward: undefined, + }, + }, + } as unknown as Docker; + + return { + mockDockerInstance, + mockListContainers, + mockGetContainer, + mockListNetworks, + mockContainer, + }; + }); -// Create properly typed mock functions -const mockListContainers = vi.fn(); -const mockGetContainer = vi.fn().mockReturnValue(mockContainer); -const mockListNetworks = vi.fn(); - -const mockDockerInstance = { - getContainer: mockGetContainer, - listContainers: mockListContainers, - listNetworks: mockListNetworks, - modem: { - Promise: Promise, - protocol: 'http', - socketPath: '/var/run/docker.sock', - headers: {}, - sshOptions: { - agentForward: undefined, - }, - }, -} as unknown as Docker; +vi.mock('@app/unraid-api/graph/resolvers/docker/utils/docker-client.js', () => ({ + getDockerClient: vi.fn().mockReturnValue(mockDockerInstance), +})); -vi.mock('dockerode', () => { - return { - default: vi.fn().mockImplementation(() => mockDockerInstance), - }; -}); +vi.mock('execa', () => ({ + execa: vi.fn(), +})); + +const { mockEmhttpGetter } = vi.hoisted(() => ({ + mockEmhttpGetter: vi.fn().mockReturnValue({ + networks: [], + var: {}, + }), +})); // Mock the store getters vi.mock('@app/store/index.js', () => ({ @@ -61,15 +94,21 @@ vi.mock('@app/store/index.js', () => ({ docker: vi.fn().mockReturnValue({ containers: [] }), paths: vi.fn().mockReturnValue({ 'docker-autostart': '/path/to/docker-autostart', + 'docker-userprefs': '/path/to/docker-userprefs', 'docker-socket': '/var/run/docker.sock', 'var-run': '/var/run', }), + emhttp: mockEmhttpGetter, }, })); -// Mock fs/promises +// Mock fs/promises (stat only) +const { statMock } = vi.hoisted(() => ({ + statMock: vi.fn().mockResolvedValue({ size: 0 }), +})); + vi.mock('fs/promises', () => ({ - readFile: vi.fn().mockResolvedValue(''), + stat: statMock, })); // Mock Cache Manager @@ -79,6 +118,67 @@ const mockCacheManager = { del: vi.fn(), }; +// Mock DockerConfigService +const mockDockerConfigService = { + getConfig: vi.fn().mockReturnValue({ + updateCheckCronSchedule: '0 6 * * *', + templateMappings: {}, + skipTemplatePaths: [], + }), + replaceConfig: vi.fn(), + validate: vi.fn((config) => Promise.resolve(config)), +}; + +const mockDockerManifestService = { + refreshDigests: vi.fn().mockResolvedValue(true), + getCachedUpdateStatuses: vi.fn().mockResolvedValue({}), + isUpdateAvailableCached: vi.fn().mockResolvedValue(false), +}; + +// Mock NotificationsService +const mockNotificationsService = { + notifyIfUnique: vi.fn().mockResolvedValue(null), +}; + +// Mock DockerAutostartService +const mockDockerAutostartService = { + refreshAutoStartEntries: vi.fn().mockResolvedValue(undefined), + getAutoStarts: vi.fn().mockResolvedValue([]), + getContainerPrimaryName: vi.fn((c) => { + if ('Names' in c) return c.Names[0]?.replace(/^\//, '') || null; + if ('names' in c) return c.names[0]?.replace(/^\//, '') || null; + return null; + }), + getAutoStartEntry: vi.fn(), + updateAutostartConfiguration: vi.fn().mockResolvedValue(undefined), +}; + +// Mock new services +const mockDockerLogService = { + getContainerLogSizes: vi.fn().mockResolvedValue(new Map([['test-container', 1024]])), + getContainerLogs: vi.fn().mockResolvedValue({ lines: [], cursor: null }), +}; + +const mockDockerNetworkService = { + getNetworks: vi.fn().mockResolvedValue([]), +}; + +// Use a real-ish mock for DockerPortService since it is used in transformContainer +const mockDockerPortService = { + deduplicateContainerPorts: vi.fn((ports) => { + if (!ports) return []; + // Simple dedupe logic for test + const seen = new Set(); + return ports.filter((p) => { + const key = `${p.PrivatePort}-${p.PublicPort}-${p.Type}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + }), + calculateConflicts: vi.fn().mockReturnValue({ containerPorts: [], lanPorts: [] }), +}; + describe('DockerService', () => { let service: DockerService; @@ -88,9 +188,41 @@ describe('DockerService', () => { mockListNetworks.mockReset(); mockContainer.start.mockReset(); mockContainer.stop.mockReset(); + mockContainer.pause.mockReset(); + mockContainer.unpause.mockReset(); + mockContainer.inspect.mockReset(); + mockCacheManager.get.mockReset(); mockCacheManager.set.mockReset(); mockCacheManager.del.mockReset(); + statMock.mockReset(); + statMock.mockResolvedValue({ size: 0 }); + + mockEmhttpGetter.mockReset(); + mockEmhttpGetter.mockReturnValue({ + networks: [], + var: {}, + }); + mockDockerConfigService.getConfig.mockReturnValue({ + updateCheckCronSchedule: '0 6 * * *', + templateMappings: {}, + skipTemplatePaths: [], + }); + mockDockerManifestService.refreshDigests.mockReset(); + mockDockerManifestService.refreshDigests.mockResolvedValue(true); + + mockDockerAutostartService.refreshAutoStartEntries.mockReset(); + mockDockerAutostartService.getAutoStarts.mockReset(); + mockDockerAutostartService.getAutoStartEntry.mockReset(); + mockDockerAutostartService.updateAutostartConfiguration.mockReset(); + + mockDockerLogService.getContainerLogSizes.mockReset(); + mockDockerLogService.getContainerLogSizes.mockResolvedValue(new Map([['test-container', 1024]])); + mockDockerLogService.getContainerLogs.mockReset(); + + mockDockerNetworkService.getNetworks.mockReset(); + mockDockerPortService.deduplicateContainerPorts.mockClear(); + mockDockerPortService.calculateConflicts.mockReset(); const module: TestingModule = await Test.createTestingModule({ providers: [ @@ -99,6 +231,34 @@ describe('DockerService', () => { provide: CACHE_MANAGER, useValue: mockCacheManager, }, + { + provide: DockerConfigService, + useValue: mockDockerConfigService, + }, + { + provide: DockerManifestService, + useValue: mockDockerManifestService, + }, + { + provide: NotificationsService, + useValue: mockNotificationsService, + }, + { + provide: DockerAutostartService, + useValue: mockDockerAutostartService, + }, + { + provide: DockerLogService, + useValue: mockDockerLogService, + }, + { + provide: DockerNetworkService, + useValue: mockDockerNetworkService, + }, + { + provide: DockerPortService, + useValue: mockDockerPortService, + }, ], }).compile(); @@ -109,65 +269,6 @@ describe('DockerService', () => { expect(service).toBeDefined(); }); - it('should use separate cache keys for containers with and without size', async () => { - const mockContainersWithoutSize = [ - { - Id: 'abc123', - Names: ['/test-container'], - Image: 'test-image', - ImageID: 'test-image-id', - Command: 'test', - Created: 1234567890, - State: 'exited', - Status: 'Exited', - Ports: [], - Labels: {}, - HostConfig: { NetworkMode: 'bridge' }, - NetworkSettings: {}, - Mounts: [], - }, - ]; - - const mockContainersWithSize = [ - { - Id: 'abc123', - Names: ['/test-container'], - Image: 'test-image', - ImageID: 'test-image-id', - Command: 'test', - Created: 1234567890, - State: 'exited', - Status: 'Exited', - Ports: [], - Labels: {}, - HostConfig: { NetworkMode: 'bridge' }, - NetworkSettings: {}, - Mounts: [], - SizeRootFs: 1024000, - }, - ]; - - // First call without size - mockListContainers.mockResolvedValue(mockContainersWithoutSize); - mockCacheManager.get.mockResolvedValue(undefined); - - await service.getContainers({ size: false }); - - expect(mockCacheManager.set).toHaveBeenCalledWith('docker_containers', expect.any(Array), 60000); - - // Second call with size - mockListContainers.mockResolvedValue(mockContainersWithSize); - mockCacheManager.get.mockResolvedValue(undefined); - - await service.getContainers({ size: true }); - - expect(mockCacheManager.set).toHaveBeenCalledWith( - 'docker_containers_with_size', - expect.any(Array), - 60000 - ); - }); - it('should get containers', async () => { const mockContainers = [ { @@ -190,308 +291,100 @@ describe('DockerService', () => { ]; mockListContainers.mockResolvedValue(mockContainers); - mockCacheManager.get.mockResolvedValue(undefined); // Simulate cache miss - - const result = await service.getContainers({ skipCache: true }); // Skip cache for direct fetch test - - expect(result).toEqual([ - { - id: 'abc123def456', - autoStart: false, - command: 'test', - created: 1234567890, - image: 'test-image', - imageId: 'test-image-id', - ports: [], - sizeRootFs: undefined, - state: ContainerState.EXITED, - status: 'Exited', - labels: {}, - hostConfig: { - networkMode: 'bridge', - }, - networkSettings: {}, - mounts: [], - names: ['/test-container'], - }, - ]); - - expect(mockListContainers).toHaveBeenCalledWith({ - all: true, - size: false, - }); - expect(mockCacheManager.set).toHaveBeenCalled(); // Ensure cache is set - }); + mockCacheManager.get.mockResolvedValue(undefined); - it('should start container', async () => { - const mockContainers = [ - { - Id: 'abc123def456', - Names: ['/test-container'], - Image: 'test-image', - ImageID: 'test-image-id', - Command: 'test', - Created: 1234567890, - State: 'running', - Status: 'Up 2 hours', - Ports: [], - Labels: {}, - HostConfig: { - NetworkMode: 'bridge', - }, - NetworkSettings: {}, - Mounts: [], - }, - ]; + const result = await service.getContainers({ skipCache: true }); - mockListContainers.mockResolvedValue(mockContainers); - mockContainer.start.mockResolvedValue(undefined); - mockCacheManager.get.mockResolvedValue(undefined); // Simulate cache miss for getContainers call - - const result = await service.start('abc123def456'); - - expect(result).toEqual({ - id: 'abc123def456', - autoStart: false, - command: 'test', - created: 1234567890, - image: 'test-image', - imageId: 'test-image-id', - ports: [], - sizeRootFs: undefined, - state: ContainerState.RUNNING, - status: 'Up 2 hours', - labels: {}, - hostConfig: { - networkMode: 'bridge', - }, - networkSettings: {}, - mounts: [], - names: ['/test-container'], - }); + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 'abc123def456', + names: ['/test-container'], + }), + ]) + ); - expect(mockContainer.start).toHaveBeenCalled(); - expect(mockCacheManager.del).toHaveBeenCalledWith(DockerService.CONTAINER_CACHE_KEY); expect(mockListContainers).toHaveBeenCalled(); - expect(mockCacheManager.set).toHaveBeenCalled(); - expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.INFO, { - info: { - apps: { installed: 1, running: 1 }, - }, - }); + expect(mockDockerAutostartService.refreshAutoStartEntries).toHaveBeenCalled(); + expect(mockDockerPortService.deduplicateContainerPorts).toHaveBeenCalled(); }); - it('should stop container', async () => { - const mockContainers = [ + it('should update auto-start configuration', async () => { + mockListContainers.mockResolvedValue([ { - Id: 'abc123def456', - Names: ['/test-container'], - Image: 'test-image', - ImageID: 'test-image-id', - Command: 'test', - Created: 1234567890, - State: 'exited', - Status: 'Exited', - Ports: [], - Labels: {}, - HostConfig: { - NetworkMode: 'bridge', - }, - NetworkSettings: {}, - Mounts: [], - }, - ]; - - mockListContainers.mockResolvedValue(mockContainers); - mockContainer.stop.mockResolvedValue(undefined); - mockCacheManager.get.mockResolvedValue(undefined); // Simulate cache miss for getContainers calls - - const result = await service.stop('abc123def456'); - - expect(result).toEqual({ - id: 'abc123def456', - autoStart: false, - command: 'test', - created: 1234567890, - image: 'test-image', - imageId: 'test-image-id', - ports: [], - sizeRootFs: undefined, - state: ContainerState.EXITED, - status: 'Exited', - labels: {}, - hostConfig: { - networkMode: 'bridge', - }, - networkSettings: {}, - mounts: [], - names: ['/test-container'], - }); - - expect(mockContainer.stop).toHaveBeenCalledWith({ t: 10 }); - expect(mockCacheManager.del).toHaveBeenCalledWith(DockerService.CONTAINER_CACHE_KEY); - expect(mockListContainers).toHaveBeenCalled(); - expect(mockCacheManager.set).toHaveBeenCalled(); - expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.INFO, { - info: { - apps: { installed: 1, running: 0 }, + Id: 'abc123', + Names: ['/alpha'], + State: 'running', }, - }); - }); - - it('should throw error if container not found after start', async () => { - mockListContainers.mockResolvedValue([]); - mockContainer.start.mockResolvedValue(undefined); - mockCacheManager.get.mockResolvedValue(undefined); - - await expect(service.start('not-found')).rejects.toThrow( - 'Container not-found not found after starting' - ); - expect(mockCacheManager.del).toHaveBeenCalledWith(DockerService.CONTAINER_CACHE_KEY); - }); + ]); - it('should throw error if container not found after stop', async () => { - mockListContainers.mockResolvedValue([]); - mockContainer.stop.mockResolvedValue(undefined); - mockCacheManager.get.mockResolvedValue(undefined); + const input = [{ id: 'abc123', autoStart: true, wait: 15 }]; + await service.updateAutostartConfiguration(input, { persistUserPreferences: true }); - await expect(service.stop('not-found')).rejects.toThrow( - 'Container not-found not found after stopping' + expect(mockDockerAutostartService.updateAutostartConfiguration).toHaveBeenCalledWith( + input, + expect.any(Array), + { persistUserPreferences: true } ); expect(mockCacheManager.del).toHaveBeenCalledWith(DockerService.CONTAINER_CACHE_KEY); }); - it('should get networks', async () => { - const mockNetworks = [ - { - Id: 'network1', - Name: 'bridge', - Created: '2023-01-01T00:00:00Z', - Scope: 'local', - Driver: 'bridge', - EnableIPv6: false, - IPAM: { - Driver: 'default', - Config: [ - { - Subnet: '172.17.0.0/16', - Gateway: '172.17.0.1', - }, - ], - }, - Internal: false, - Attachable: false, - Ingress: false, - ConfigFrom: { - Network: '', - }, - ConfigOnly: false, - Containers: {}, - Options: { - 'com.docker.network.bridge.default_bridge': 'true', - 'com.docker.network.bridge.enable_icc': 'true', - 'com.docker.network.bridge.enable_ip_masquerade': 'true', - 'com.docker.network.bridge.host_binding_ipv4': '0.0.0.0', - 'com.docker.network.bridge.name': 'docker0', - 'com.docker.network.driver.mtu': '1500', - }, - Labels: {}, - }, - ]; - - mockListNetworks.mockResolvedValue(mockNetworks); - mockCacheManager.get.mockResolvedValue(undefined); // Simulate cache miss - - const result = await service.getNetworks({ skipCache: true }); // Skip cache for direct fetch test - - expect(result).toMatchInlineSnapshot(` - [ - { - "attachable": false, - "configFrom": { - "Network": "", - }, - "configOnly": false, - "containers": {}, - "created": "2023-01-01T00:00:00Z", - "driver": "bridge", - "enableIPv6": false, - "id": "network1", - "ingress": false, - "internal": false, - "ipam": { - "Config": [ - { - "Gateway": "172.17.0.1", - "Subnet": "172.17.0.0/16", - }, - ], - "Driver": "default", - }, - "labels": {}, - "name": "bridge", - "options": { - "com.docker.network.bridge.default_bridge": "true", - "com.docker.network.bridge.enable_icc": "true", - "com.docker.network.bridge.enable_ip_masquerade": "true", - "com.docker.network.bridge.host_binding_ipv4": "0.0.0.0", - "com.docker.network.bridge.name": "docker0", - "com.docker.network.driver.mtu": "1500", - }, - "scope": "local", - }, - ] - `); - - expect(mockListNetworks).toHaveBeenCalled(); - expect(mockCacheManager.set).toHaveBeenCalled(); // Ensure cache is set - }); - - it('should handle empty networks list', async () => { - mockListNetworks.mockResolvedValue([]); - mockCacheManager.get.mockResolvedValue(undefined); // Simulate cache miss - - const result = await service.getNetworks({ skipCache: true }); // Skip cache for direct fetch test - - expect(result).toEqual([]); - expect(mockListNetworks).toHaveBeenCalled(); - expect(mockCacheManager.set).toHaveBeenCalled(); // Ensure cache is set - }); - - it('should handle docker error when getting networks', async () => { - const error = new Error('Docker error') as DockerError; - error.code = 'ENOENT'; - error.address = '/var/run/docker.sock'; - mockListNetworks.mockRejectedValue(error); - mockCacheManager.get.mockResolvedValue(undefined); // Simulate cache miss - - await expect(service.getNetworks({ skipCache: true })).rejects.toThrow( - 'Docker socket unavailable.' - ); - expect(mockListNetworks).toHaveBeenCalled(); - expect(mockCacheManager.set).not.toHaveBeenCalled(); // Ensure cache is NOT set on error + it('should delegate getContainerLogSizes to DockerLogService', async () => { + const sizes = await service.getContainerLogSizes(['test-container']); + expect(mockDockerLogService.getContainerLogSizes).toHaveBeenCalledWith(['test-container']); + expect(sizes.get('test-container')).toBe(1024); }); describe('getAppInfo', () => { - // Common mock containers for these tests const mockContainersForMethods = [ { id: 'abc1', state: ContainerState.RUNNING }, { id: 'def2', state: ContainerState.EXITED }, ] as DockerContainer[]; it('should return correct app info object', async () => { - // Mock cache response for getContainers call mockCacheManager.get.mockResolvedValue(mockContainersForMethods); - const result = await service.getAppInfo(); // Call the renamed method + const result = await service.getAppInfo(); expect(result).toEqual({ info: { apps: { installed: 2, running: 1 }, }, }); - // getContainers should now be called only ONCE from cache - expect(mockCacheManager.get).toHaveBeenCalledTimes(1); expect(mockCacheManager.get).toHaveBeenCalledWith(DockerService.CONTAINER_CACHE_KEY); }); }); + + describe('transformContainer', () => { + it('deduplicates ports that only differ by bound IP addresses', () => { + mockEmhttpGetter.mockReturnValue({ + networks: [{ ipaddr: ['192.168.0.10'] }], + var: {}, + }); + + const container = { + Id: 'duplicate-ports', + Names: ['/duplicate-ports'], + Image: 'test-image', + ImageID: 'sha256:123', + Command: 'test', + Created: 1700000000, + State: 'running', + Status: 'Up 2 hours', + Ports: [ + { IP: '0.0.0.0', PrivatePort: 8080, PublicPort: 8080, Type: 'tcp' }, + { IP: '::', PrivatePort: 8080, PublicPort: 8080, Type: 'tcp' }, + { IP: '0.0.0.0', PrivatePort: 5000, PublicPort: 5000, Type: 'udp' }, + ], + Labels: {}, + HostConfig: { NetworkMode: 'bridge' }, + NetworkSettings: { Networks: {} }, + Mounts: [], + } as Docker.ContainerInfo; + + service.transformContainer(container); + expect(mockDockerPortService.deduplicateContainerPorts).toHaveBeenCalledWith( + container.Ports + ); + }); + }); }); 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 5b244773f6..59ac6d5c2e 100644 --- a/api/src/unraid-api/graph/resolvers/docker/docker.service.ts +++ b/api/src/unraid-api/graph/resolvers/docker/docker.service.ts @@ -1,20 +1,33 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; -import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; -import { readFile } from 'fs/promises'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { type Cache } from 'cache-manager'; import Docker from 'dockerode'; +import { execa } from 'execa'; +import { AppError } from '@app/core/errors/app-error.js'; import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js'; import { catchHandlers } from '@app/core/utils/misc/catch-handlers.js'; import { sleep } from '@app/core/utils/misc/sleep.js'; -import { getters } from '@app/store/index.js'; +import { getLanIp } from '@app/core/utils/network.js'; +import { DockerAutostartService } from '@app/unraid-api/graph/resolvers/docker/docker-autostart.service.js'; +import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js'; +import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker-log.service.js'; +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 { ContainerPortType, ContainerState, + DockerAutostartEntryInput, DockerContainer, + DockerContainerLogs, DockerNetwork, + DockerPortConflicts, } from '@app/unraid-api/graph/resolvers/docker/docker.model.js'; +import { getDockerClient } from '@app/unraid-api/graph/resolvers/docker/utils/docker-client.js'; +import { NotificationImportance } from '@app/unraid-api/graph/resolvers/notifications/notifications.model.js'; +import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js'; interface ContainerListingOptions extends Docker.ContainerListOptions { skipCache: boolean; @@ -27,25 +40,27 @@ interface NetworkListingOptions { @Injectable() export class DockerService { private client: Docker; - private autoStarts: string[] = []; private readonly logger = new Logger(DockerService.name); public static readonly CONTAINER_CACHE_KEY = 'docker_containers'; public static readonly CONTAINER_WITH_SIZE_CACHE_KEY = 'docker_containers_with_size'; public static readonly NETWORK_CACHE_KEY = 'docker_networks'; - public static readonly CACHE_TTL_SECONDS = 60; // Cache for 60 seconds + public static readonly CACHE_TTL_SECONDS = 60; - constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) { - this.client = this.getDockerClient(); + constructor( + @Inject(CACHE_MANAGER) private cacheManager: Cache, + private readonly dockerConfigService: DockerConfigService, + private readonly notificationsService: NotificationsService, + private readonly dockerManifestService: DockerManifestService, + private readonly autostartService: DockerAutostartService, + private readonly dockerLogService: DockerLogService, + private readonly dockerNetworkService: DockerNetworkService, + private readonly dockerPortService: DockerPortService + ) { + this.client = getDockerClient(); } - public getDockerClient() { - return new Docker({ - socketPath: '/var/run/docker.sock', - }); - } - - async getAppInfo() { + public async getAppInfo() { const containers = await this.getContainers({ skipCache: false }); const installedCount = containers.length; const runningCount = containers.filter( @@ -65,31 +80,46 @@ export class DockerService { * @see https://github.com/limetech/webgui/issues/502#issue-480992547 */ public async getAutoStarts(): Promise { - const autoStartFile = await readFile(getters.paths()['docker-autostart'], 'utf8') - .then((file) => file.toString()) - .catch(() => ''); - return autoStartFile.split('\n'); + return this.autostartService.getAutoStarts(); } - public transformContainer(container: Docker.ContainerInfo): DockerContainer { + public transformContainer(container: Docker.ContainerInfo): Omit { const sizeValue = (container as Docker.ContainerInfo & { SizeRootFs?: number }).SizeRootFs; + const primaryName = this.autostartService.getContainerPrimaryName(container) ?? ''; + const autoStartEntry = primaryName + ? this.autostartService.getAutoStartEntry(primaryName) + : undefined; + const lanIp = getLanIp(); + const lanPortStrings: string[] = []; + const uniquePorts = this.dockerPortService.deduplicateContainerPorts(container.Ports); - const transformed: DockerContainer = { - id: container.Id, - names: container.Names, - image: container.Image, - imageId: container.ImageID, - command: container.Command, - created: container.Created, - ports: container.Ports.map((port) => ({ + const transformedPorts = uniquePorts.map((port) => { + if (port.PublicPort) { + const lanPort = lanIp ? `${lanIp}:${port.PublicPort}` : `${port.PublicPort}`; + if (lanPort) { + lanPortStrings.push(lanPort); + } + } + return { ip: port.IP || '', privatePort: port.PrivatePort, publicPort: port.PublicPort, type: ContainerPortType[port.Type.toUpperCase() as keyof typeof ContainerPortType] || ContainerPortType.TCP, - })), + }; + }); + + const transformed: Omit = { + id: container.Id, + names: container.Names, + image: container.Image, + imageId: container.ImageID, + command: container.Command, + created: container.Created, + ports: transformedPorts, sizeRootFs: sizeValue, + sizeRw: (container as Docker.ContainerInfo & { SizeRw?: number }).SizeRw, labels: container.Labels ?? {}, state: typeof container.State === 'string' @@ -102,9 +132,15 @@ export class DockerService { }, networkSettings: container.NetworkSettings, mounts: container.Mounts, - autoStart: this.autoStarts.includes(container.Names[0].split('/')[1]), + autoStart: Boolean(autoStartEntry), + autoStartOrder: autoStartEntry?.order, + autoStartWait: autoStartEntry?.wait, }; + if (lanPortStrings.length > 0) { + transformed.lanIpPorts = lanPortStrings; + } + return transformed; } @@ -129,66 +165,65 @@ export class DockerService { } this.logger.debug(`Updating docker container cache (${size ? 'with' : 'without'} size)`); - const rawContainers = - (await this.client - .listContainers({ - all, - size, - ...listOptions, - }) - .catch(catchHandlers.docker)) ?? []; - - this.autoStarts = await this.getAutoStarts(); + let rawContainers: Docker.ContainerInfo[] = []; + try { + rawContainers = await this.client.listContainers({ + all, + size, + ...listOptions, + }); + } catch (error) { + await this.handleDockerListError(error); + } + + await this.autostartService.refreshAutoStartEntries(); const containers = rawContainers.map((container) => this.transformContainer(container)); - await this.cacheManager.set(cacheKey, containers, DockerService.CACHE_TTL_SECONDS * 1000); - return containers; + 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, + }; + }); + + await this.cacheManager.set( + cacheKey, + containersWithTemplatePaths, + DockerService.CACHE_TTL_SECONDS * 1000 + ); + return containersWithTemplatePaths; + } + + public async getPortConflicts({ + skipCache = false, + }: { + skipCache?: boolean; + } = {}): Promise { + const containers = await this.getContainers({ skipCache }); + return this.dockerPortService.calculateConflicts(containers); + } + + public async getContainerLogSizes(containerNames: string[]): Promise> { + return this.dockerLogService.getContainerLogSizes(containerNames); + } + + public async getContainerLogs( + id: string, + options?: { since?: Date | null; tail?: number | null } + ): Promise { + return this.dockerLogService.getContainerLogs(id, options); } /** * Get all Docker networks * @returns All the in/active Docker networks on the system. */ - public async getNetworks({ skipCache }: NetworkListingOptions): Promise { - if (!skipCache) { - const cachedNetworks = await this.cacheManager.get( - DockerService.NETWORK_CACHE_KEY - ); - if (cachedNetworks) { - this.logger.debug('Using docker network cache'); - return cachedNetworks; - } - } - - this.logger.debug('Updating docker network cache'); - const rawNetworks = await this.client.listNetworks().catch(catchHandlers.docker); - const networks = rawNetworks.map( - (network) => - ({ - name: network.Name || '', - id: network.Id || '', - created: network.Created || '', - scope: network.Scope || '', - driver: network.Driver || '', - enableIPv6: network.EnableIPv6 || false, - ipam: network.IPAM || {}, - internal: network.Internal || false, - attachable: network.Attachable || false, - ingress: network.Ingress || false, - configFrom: network.ConfigFrom || {}, - configOnly: network.ConfigOnly || false, - containers: network.Containers || {}, - options: network.Options || {}, - labels: network.Labels || {}, - }) as DockerNetwork - ); - - await this.cacheManager.set( - DockerService.NETWORK_CACHE_KEY, - networks, - DockerService.CACHE_TTL_SECONDS * 1000 - ); - return networks; + public async getNetworks(options: NetworkListingOptions): Promise { + return this.dockerNetworkService.getNetworks(options); } public async clearContainerCache(): Promise { @@ -214,6 +249,30 @@ export class DockerService { return updatedContainer; } + public async removeContainer(id: string): Promise { + const container = this.client.getContainer(id); + try { + await container.remove({ force: true }); + await this.clearContainerCache(); + this.logger.debug(`Invalidated container caches after removing ${id}`); + const appInfo = await this.getAppInfo(); + await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo); + return true; + } catch (error) { + this.logger.error(`Failed to remove container ${id}:`, error); + throw new Error(`Failed to remove container ${id}`); + } + } + + public async updateAutostartConfiguration( + entries: DockerAutostartEntryInput[], + options?: { persistUserPreferences?: boolean } + ): Promise { + const containers = await this.getContainers({ skipCache: true }); + await this.autostartService.updateAutostartConfiguration(entries, containers, options); + await this.clearContainerCache(); + } + public async stop(id: string): Promise { const container = this.client.getContainer(id); await container.stop({ t: 10 }); @@ -243,4 +302,179 @@ export class DockerService { await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo); return updatedContainer; } + + public async pause(id: string): Promise { + const container = this.client.getContainer(id); + await container.pause(); + await this.cacheManager.del(DockerService.CONTAINER_CACHE_KEY); + this.logger.debug(`Invalidated container cache after pausing ${id}`); + + let containers = await this.getContainers({ skipCache: true }); + let updatedContainer: DockerContainer | undefined; + for (let i = 0; i < 5; i++) { + await sleep(500); + containers = await this.getContainers({ skipCache: true }); + updatedContainer = containers.find((c) => c.id === id); + this.logger.debug( + `Container ${id} state after pause attempt ${i + 1}: ${updatedContainer?.state}` + ); + if (updatedContainer?.state === ContainerState.PAUSED) { + break; + } + } + + if (!updatedContainer) { + throw new Error(`Container ${id} not found after pausing`); + } + const appInfo = await this.getAppInfo(); + await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo); + return updatedContainer; + } + + public async unpause(id: string): Promise { + const container = this.client.getContainer(id); + await container.unpause(); + await this.cacheManager.del(DockerService.CONTAINER_CACHE_KEY); + this.logger.debug(`Invalidated container cache after unpausing ${id}`); + + let containers = await this.getContainers({ skipCache: true }); + let updatedContainer: DockerContainer | undefined; + for (let i = 0; i < 5; i++) { + await sleep(500); + containers = await this.getContainers({ skipCache: true }); + updatedContainer = containers.find((c) => c.id === id); + this.logger.debug( + `Container ${id} state after unpause attempt ${i + 1}: ${updatedContainer?.state}` + ); + if (updatedContainer?.state === ContainerState.RUNNING) { + break; + } + } + + if (!updatedContainer) { + throw new Error(`Container ${id} not found after unpausing`); + } + const appInfo = await this.getAppInfo(); + await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo); + return updatedContainer; + } + + public async updateContainer(id: string): Promise { + const containers = await this.getContainers({ skipCache: true }); + const container = containers.find((c) => c.id === id); + if (!container) { + throw new Error(`Container ${id} not found`); + } + + const containerName = container.names?.[0]?.replace(/^\//, ''); + if (!containerName) { + throw new Error(`Container ${id} has no name`); + } + + this.logger.log(`Updating container ${containerName} (${id})`); + + try { + await execa( + '/usr/local/emhttp/plugins/dynamix.docker.manager/scripts/update_container', + [encodeURIComponent(containerName)], + { shell: 'bash' } + ); + } catch (error) { + this.logger.error(`Failed to update container ${containerName}:`, error); + throw new Error(`Failed to update container ${containerName}`); + } + + await this.clearContainerCache(); + this.logger.debug(`Invalidated container caches after updating ${id}`); + + const updatedContainers = await this.getContainers({ skipCache: true }); + const updatedContainer = updatedContainers.find( + (c) => c.names?.some((name) => name.replace(/^\//, '') === containerName) || c.id === id + ); + if (!updatedContainer) { + throw new Error(`Container ${id} not found after update`); + } + + const appInfo = await this.getAppInfo(); + await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo); + return updatedContainer; + } + + public async updateContainers(ids: string[]): Promise { + const uniqueIds = Array.from(new Set(ids.filter((id) => typeof id === 'string' && id.length))); + const updatedContainers: DockerContainer[] = []; + for (const id of uniqueIds) { + const updated = await this.updateContainer(id); + updatedContainers.push(updated); + } + return updatedContainers; + } + + /** + * Updates every container with an available update. Mirrors the legacy webgui "Update All" flow. + */ + public async updateAllContainers(): Promise { + const containers = await this.getContainers({ skipCache: true }); + if (!containers.length) { + return []; + } + + const cachedStatuses = await this.dockerManifestService.getCachedUpdateStatuses(); + const idsWithUpdates: string[] = []; + + for (const container of containers) { + if (!container.image) { + continue; + } + const hasUpdate = await this.dockerManifestService.isUpdateAvailableCached( + container.image, + cachedStatuses + ); + if (hasUpdate) { + idsWithUpdates.push(container.id); + } + } + + if (!idsWithUpdates.length) { + this.logger.log('Update-all requested but no containers have available updates'); + return []; + } + + this.logger.log(`Updating ${idsWithUpdates.length} container(s) via updateAllContainers`); + return this.updateContainers(idsWithUpdates); + } + + private async handleDockerListError(error: unknown): Promise { + await this.notifyDockerListError(error); + catchHandlers.docker(error as NodeJS.ErrnoException); + throw error instanceof Error ? error : new Error('Docker list error'); + } + + private async notifyDockerListError(error: unknown): Promise { + const message = this.getDockerErrorMessage(error); + const truncatedMessage = message.length > 240 ? `${message.slice(0, 237)}...` : message; + try { + await this.notificationsService.notifyIfUnique({ + title: 'Docker Container Query Failure', + subject: truncatedMessage, + description: `An error occurred while querying Docker containers. ${truncatedMessage}`, + importance: NotificationImportance.ALERT, + }); + } catch (notificationError) { + this.logger.error( + 'Failed to send Docker container query failure notification', + notificationError as Error + ); + } + } + + private getDockerErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + if (typeof error === 'string' && error.length) { + return error; + } + return 'Unknown error occurred.'; + } } 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 ecb0bb1a71..edcc8fba6b 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 @@ -2,6 +2,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 { ContainerPortType, ContainerState, @@ -38,6 +39,7 @@ describe('containerToResource', () => { labels: { 'com.docker.compose.service': 'web', }, + isOrphaned: false, }; const result = containerToResource(container); @@ -62,6 +64,7 @@ describe('containerToResource', () => { state: ContainerState.EXITED, status: 'Exited (0) 1 hour ago', autoStart: false, + isOrphaned: false, }; const result = containerToResource(container); @@ -83,6 +86,7 @@ describe('containerToResource', () => { state: ContainerState.EXITED, status: 'Exited (0) 5 minutes ago', autoStart: false, + isOrphaned: false, }; const result = containerToResource(container); @@ -124,6 +128,7 @@ describe('containerToResource', () => { maintainer: 'dev-team', version: '1.0.0', }, + isOrphaned: false, }; const result = containerToResource(container); @@ -216,6 +221,12 @@ describe('DockerOrganizerService', () => { ]), }, }, + { + provide: DockerTemplateIconService, + useValue: { + getIconsForContainers: vi.fn().mockResolvedValue(new Map()), + }, + }, ], }).compile(); 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 41dff8257d..699770fa4f 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 @@ -9,10 +9,13 @@ import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/do import { addMissingResourcesToView, createFolderInView, + createFolderWithItems, DEFAULT_ORGANIZER_ROOT_ID, DEFAULT_ORGANIZER_VIEW_ID, deleteOrganizerEntries, moveEntriesToFolder, + moveItemsToPosition, + renameFolder, resolveOrganizer, setFolderChildrenInView, } from '@app/unraid-api/organizer/organizer.js'; @@ -51,8 +54,14 @@ export class DockerOrganizerService { private readonly dockerService: DockerService ) {} - async getResources(opts?: ContainerListOptions): Promise { - const containers = await this.dockerService.getContainers(opts); + async getResources( + opts?: Partial & { skipCache?: boolean } + ): Promise { + const { skipCache = false, ...listOptions } = opts ?? {}; + const containers = await this.dockerService.getContainers({ + skipCache, + ...(listOptions as any), + }); return containerListToResourcesObject(containers); } @@ -74,17 +83,20 @@ export class DockerOrganizerService { return newOrganizer; } - async syncAndGetOrganizer(): Promise { + async syncAndGetOrganizer(opts?: { skipCache?: boolean }): Promise { let organizer = this.dockerConfigService.getConfig(); - organizer.resources = await this.getResources(); + organizer.resources = await this.getResources(opts); organizer = await this.syncDefaultView(organizer, organizer.resources); organizer = await this.dockerConfigService.validate(organizer); this.dockerConfigService.replaceConfig(organizer); return organizer; } - async resolveOrganizer(organizer?: OrganizerV1): Promise { - organizer ??= await this.syncAndGetOrganizer(); + async resolveOrganizer( + organizer?: OrganizerV1, + opts?: { skipCache?: boolean } + ): Promise { + organizer ??= await this.syncAndGetOrganizer(opts); return resolveOrganizer(organizer); } @@ -192,7 +204,10 @@ export class DockerOrganizerService { const newOrganizer = structuredClone(organizer); deleteOrganizerEntries(newOrganizer.views.default, entryIds, { mutate: true }); - addMissingResourcesToView(newOrganizer.resources, newOrganizer.views.default); + newOrganizer.views.default = addMissingResourcesToView( + newOrganizer.resources, + newOrganizer.views.default + ); const validated = await this.dockerConfigService.validate(newOrganizer); this.dockerConfigService.replaceConfig(validated); @@ -222,4 +237,119 @@ export class DockerOrganizerService { this.dockerConfigService.replaceConfig(validated); return validated; } + + async moveItemsToPosition(params: { + sourceEntryIds: string[]; + destinationFolderId: string; + position: number; + }): Promise { + const { sourceEntryIds, destinationFolderId, position } = params; + const organizer = await this.syncAndGetOrganizer(); + const newOrganizer = structuredClone(organizer); + + const defaultView = newOrganizer.views.default; + if (!defaultView) { + throw new AppError('Default view not found'); + } + + newOrganizer.views.default = moveItemsToPosition({ + view: defaultView, + sourceEntryIds: new Set(sourceEntryIds), + destinationFolderId, + position, + resources: newOrganizer.resources, + }); + + const validated = await this.dockerConfigService.validate(newOrganizer); + this.dockerConfigService.replaceConfig(validated); + return validated; + } + + async renameFolderById(params: { folderId: string; newName: string }): Promise { + const { folderId, newName } = params; + const organizer = await this.syncAndGetOrganizer(); + const newOrganizer = structuredClone(organizer); + + const defaultView = newOrganizer.views.default; + if (!defaultView) { + throw new AppError('Default view not found'); + } + + newOrganizer.views.default = renameFolder({ + view: defaultView, + folderId, + newName, + }); + + const validated = await this.dockerConfigService.validate(newOrganizer); + this.dockerConfigService.replaceConfig(validated); + return validated; + } + + async createFolderWithItems(params: { + name: string; + parentId?: string; + sourceEntryIds?: string[]; + position?: number; + }): Promise { + const { name, parentId = DEFAULT_ORGANIZER_ROOT_ID, sourceEntryIds = [], position } = params; + + if (name === DEFAULT_ORGANIZER_ROOT_ID) { + throw new AppError(`Folder name '${name}' is reserved`); + } else if (name === parentId) { + throw new AppError(`Folder ID '${name}' cannot be the same as the parent ID`); + } else if (!name) { + throw new AppError(`Folder name cannot be empty`); + } + + const organizer = await this.syncAndGetOrganizer(); + const defaultView = organizer.views.default; + if (!defaultView) { + throw new AppError('Default view not found'); + } + + const parentEntry = defaultView.entries[parentId]; + if (!parentEntry || parentEntry.type !== 'folder') { + throw new AppError(`Parent '${parentId}' not found or is not a folder`); + } + + if (parentEntry.children.includes(name)) { + return organizer; + } + + const newOrganizer = structuredClone(organizer); + newOrganizer.views.default = createFolderWithItems({ + view: defaultView, + folderId: name, + folderName: name, + parentId, + sourceEntryIds, + position, + resources: newOrganizer.resources, + }); + + const validated = await this.dockerConfigService.validate(newOrganizer); + this.dockerConfigService.replaceConfig(validated); + return validated; + } + + async updateViewPreferences(params: { + viewId?: string; + prefs: Record; + }): Promise { + const { viewId = DEFAULT_ORGANIZER_VIEW_ID, prefs } = params; + const organizer = await this.syncAndGetOrganizer(); + const newOrganizer = structuredClone(organizer); + + const view = newOrganizer.views[viewId]; + if (!view) { + throw new AppError(`View '${viewId}' not found`); + } + + view.prefs = prefs; + + const validated = await this.dockerConfigService.validate(newOrganizer); + this.dockerConfigService.replaceConfig(validated); + return validated; + } } diff --git a/api/src/unraid-api/graph/resolvers/docker/utils/docker-client.ts b/api/src/unraid-api/graph/resolvers/docker/utils/docker-client.ts new file mode 100644 index 0000000000..1f389eae26 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/docker/utils/docker-client.ts @@ -0,0 +1,12 @@ +import Docker from 'dockerode'; + +let instance: Docker | undefined; + +export function getDockerClient(): Docker { + if (!instance) { + instance = new Docker({ + socketPath: '/var/run/docker.sock', + }); + } + return instance; +} diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.model.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.model.ts index 069620cd49..64dd0893a1 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.model.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.model.ts @@ -164,4 +164,10 @@ export class Notifications extends Node { @Field(() => [Notification]) @IsNotEmpty() list!: Notification[]; + + @Field(() => [Notification], { + description: 'Deduplicated list of unread warning and alert notifications, sorted latest first.', + }) + @IsNotEmpty() + warningsAndAlerts!: Notification[]; } diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.module.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.module.ts new file mode 100644 index 0000000000..1bb47758e4 --- /dev/null +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js'; + +@Module({ + providers: [NotificationsService], + exports: [NotificationsService], +}) +export class NotificationsModule {} diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts index fe6e56ad6b..d3e0c6797b 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.resolver.ts @@ -49,6 +49,13 @@ export class NotificationsResolver { return await this.notificationsService.getNotifications(filters); } + @ResolveField(() => [Notification], { + description: 'Deduplicated list of unread warning and alert notifications.', + }) + public async warningsAndAlerts(): Promise { + return this.notificationsService.getWarningsAndAlerts(); + } + /**============================================ * Mutations *=============================================**/ @@ -96,6 +103,18 @@ export class NotificationsResolver { return this.notificationsService.getOverview(); } + @Mutation(() => Notification, { + nullable: true, + description: + 'Creates a notification if an equivalent unread notification does not already exist.', + }) + public notifyIfUnique( + @Args('input', { type: () => NotificationData }) + data: NotificationData + ): Promise { + return this.notificationsService.notifyIfUnique(data); + } + @Mutation(() => NotificationOverview) public async archiveAll( @Args('importance', { type: () => NotificationImportance, nullable: true }) @@ -163,4 +182,13 @@ export class NotificationsResolver { async notificationsOverview() { return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW); } + + @Subscription(() => [Notification]) + @UsePermissions({ + action: AuthAction.READ_ANY, + resource: Resource.NOTIFICATIONS, + }) + async notificationsWarningsAndAlerts() { + return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_WARNINGS_AND_ALERTS); + } } diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts index 3808d55a0a..8014821982 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.spec.ts @@ -289,6 +289,112 @@ describe.sequential('NotificationsService', () => { expect(loaded.length).toEqual(3); }); + describe('getWarningsAndAlerts', () => { + it('deduplicates unread warning and alert notifications', async ({ expect }) => { + const duplicateData = { + title: 'Array Status', + subject: 'Disk 1 is getting warm', + description: 'Disk temperature has exceeded threshold.', + importance: NotificationImportance.WARNING, + } as const; + + // Create duplicate warnings and an alert with different content + await createNotification(duplicateData); + await createNotification(duplicateData); + await createNotification({ + title: 'UPS Disconnected', + subject: 'The UPS connection has been lost', + description: 'Reconnect the UPS to restore protection.', + importance: NotificationImportance.ALERT, + }); + await createNotification({ + title: 'Parity Check Complete', + subject: 'A parity check has completed successfully', + description: 'No sync errors were detected.', + importance: NotificationImportance.INFO, + }); + + const results = await service.getWarningsAndAlerts(); + const warningMatches = results.filter( + (notification) => notification.subject === duplicateData.subject + ); + const alertMatches = results.filter((notification) => + notification.subject.includes('UPS connection') + ); + + expect(results.length).toEqual(2); + expect(warningMatches).toHaveLength(1); + expect(alertMatches).toHaveLength(1); + expect( + results.every((notification) => notification.importance !== NotificationImportance.INFO) + ).toBe(true); + }); + + it('respects the provided limit', async ({ expect }) => { + const limit = 2; + await createNotification({ + title: 'Array Warning', + subject: 'Disk 2 is getting warm', + description: 'Disk temperature has exceeded threshold.', + importance: NotificationImportance.WARNING, + }); + await createNotification({ + title: 'Network Down', + subject: 'Ethernet link is down', + description: 'Physical link failure detected.', + importance: NotificationImportance.ALERT, + }); + await createNotification({ + title: 'Critical Temperature', + subject: 'CPU temperature exceeded', + description: 'CPU temperature has exceeded safe operating limits.', + importance: NotificationImportance.ALERT, + }); + + const results = await service.getWarningsAndAlerts(limit); + expect(results.length).toEqual(limit); + }); + }); + + describe('notifyIfUnique', () => { + const duplicateData: NotificationData = { + title: 'Docker Query Failure', + subject: 'Failed to fetch containers from Docker', + description: 'Please verify that the Docker service is running.', + importance: NotificationImportance.ALERT, + }; + + it('skips creating duplicate unread notifications', async ({ expect }) => { + const created = await service.notifyIfUnique(duplicateData); + expect(created).toBeDefined(); + + const skipped = await service.notifyIfUnique(duplicateData); + expect(skipped).toBeNull(); + + const notifications = await service.getNotifications({ + type: NotificationType.UNREAD, + limit: 50, + offset: 0, + }); + expect( + notifications.filter((notification) => notification.title === duplicateData.title) + ).toHaveLength(1); + }); + + it('creates new notification when no duplicate exists', async ({ expect }) => { + const uniqueData: NotificationData = { + title: 'UPS Disconnected', + subject: 'UPS connection lost', + description: 'Reconnect the UPS to restore protection.', + importance: NotificationImportance.WARNING, + }; + + const notification = await service.notifyIfUnique(uniqueData); + expect(notification).toBeDefined(); + expect(notification?.title).toEqual(uniqueData.title); + }); + }); + /**-------------------------------------------- * CRUD: Update Tests *---------------------------------------------**/ diff --git a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts index 6ec780d666..8daa13a98a 100644 --- a/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts +++ b/api/src/unraid-api/graph/resolvers/notifications/notifications.service.ts @@ -121,6 +121,7 @@ export class NotificationsService { pubsub.publish(PUBSUB_CHANNEL.NOTIFICATION_ADDED, { notificationAdded: notification, }); + void this.publishWarningsAndAlerts(); } } @@ -142,6 +143,20 @@ export class NotificationsService { }); } + private async publishWarningsAndAlerts() { + try { + const warningsAndAlerts = await this.getWarningsAndAlerts(); + await pubsub.publish(PUBSUB_CHANNEL.NOTIFICATION_WARNINGS_AND_ALERTS, { + notificationsWarningsAndAlerts: warningsAndAlerts, + }); + } catch (error) { + this.logger.error( + '[publishWarningsAndAlerts] Failed to broadcast warnings and alerts snapshot', + error as Error + ); + } + } + private increment(importance: NotificationImportance, collector: NotificationCounts) { collector[importance.toLowerCase()] += 1; collector['total'] += 1; @@ -214,6 +229,8 @@ export class NotificationsService { await writeFile(path, ini); } + void this.publishWarningsAndAlerts(); + return this.notificationFileToGqlNotification({ id, type: NotificationType.UNREAD }, fileData); } @@ -300,6 +317,9 @@ export class NotificationsService { this.decrement(notification.importance, NotificationsService.overview[type.toLowerCase()]); await this.publishOverview(); + if (type === NotificationType.UNREAD) { + void this.publishWarningsAndAlerts(); + } // return both the overview & the deleted notification // this helps us reference the deleted notification in-memory if we want @@ -320,6 +340,10 @@ export class NotificationsService { warning: 0, total: 0, }; + await this.publishOverview(); + if (type === NotificationType.UNREAD) { + void this.publishWarningsAndAlerts(); + } return this.getOverview(); } @@ -433,6 +457,8 @@ export class NotificationsService { }); await moveToArchive(notification); + void this.publishWarningsAndAlerts(); + return { ...notification, type: NotificationType.ARCHIVE, @@ -458,6 +484,7 @@ export class NotificationsService { }); await moveToUnread(notification); + void this.publishWarningsAndAlerts(); return { ...notification, type: NotificationType.UNREAD, @@ -482,6 +509,7 @@ export class NotificationsService { }); const stats = await batchProcess(notifications, archive); + void this.publishWarningsAndAlerts(); return { ...stats, overview: overviewSnapshot }; } @@ -504,6 +532,7 @@ export class NotificationsService { }); const stats = await batchProcess(notifications, unArchive); + void this.publishWarningsAndAlerts(); return { ...stats, overview: overviewSnapshot }; } @@ -567,6 +596,64 @@ export class NotificationsService { return notifications; } + /** + * Creates a notification only if an equivalent unread notification does not already exist. + * + * @param data The notification data to create. + * @returns The created notification, or null if a duplicate was detected. + */ + public async notifyIfUnique(data: NotificationData): Promise { + const fingerprint = this.getNotificationFingerprintFromData(data); + const hasDuplicate = await this.hasUnreadNotificationWithFingerprint(fingerprint); + + if (hasDuplicate) { + this.logger.verbose( + `[notifyIfUnique] Skipping notification creation for duplicate fingerprint: ${fingerprint}` + ); + return null; + } + + return this.createNotification(data); + } + + /** + * Returns a deduplicated list of unread warning and alert notifications. + * + * Deduplication is based on the combination of importance, title, subject, description, and link. + * This ensures repeated notifications with the same user-facing content are only shown once, while + * still prioritizing the most recent occurrence of each unique notification. + * + * @param limit Maximum number of unique notifications to return. Default: 50. + */ + public async getWarningsAndAlerts(limit = 50): Promise { + const notifications = await this.loadUnreadNotifications(); + const deduped: Notification[] = []; + const seen = new Set(); + + for (const notification of notifications) { + if ( + notification.importance !== NotificationImportance.ALERT && + notification.importance !== NotificationImportance.WARNING + ) { + continue; + } + + const key = this.getDeduplicationKey(notification); + if (seen.has(key)) { + continue; + } + + seen.add(key); + deduped.push(notification); + + if (deduped.length >= limit) { + break; + } + } + + return deduped; + } + /** * Given a path to a folder, returns the full (absolute) paths of the folder's top-level contents. * Sorted latest-first by default. @@ -787,8 +874,57 @@ export class NotificationsService { * Helpers *------------------------------------------------------------------------**/ + private async loadUnreadNotifications(): Promise { + const { UNREAD } = this.paths(); + const files = await this.listFilesInFolder(UNREAD); + const [notifications] = await this.loadNotificationsFromPaths(files, { + type: NotificationType.UNREAD, + }); + return notifications; + } + + private async hasUnreadNotificationWithFingerprint(fingerprint: string): Promise { + const notifications = await this.loadUnreadNotifications(); + return notifications.some( + (notification) => this.getDeduplicationKey(notification) === fingerprint + ); + } + private sortLatestFirst(a: Notification, b: Notification) { const defaultTimestamp = 0; return Number(b.timestamp ?? defaultTimestamp) - Number(a.timestamp ?? defaultTimestamp); } + + private getDeduplicationKey(notification: Notification): string { + return this.getNotificationFingerprint(notification); + } + + private getNotificationFingerprintFromData(data: NotificationData): string { + return this.getNotificationFingerprint({ + importance: data.importance, + title: data.title, + subject: data.subject, + description: data.description, + link: data.link, + }); + } + + private getNotificationFingerprint({ + importance, + title, + subject, + description, + link, + }: Pick & { + link?: string | null; + }): string { + const makePart = (value?: string | null) => (value ?? '').trim(); + return [ + importance, + makePart(title), + makePart(subject), + makePart(description), + makePart(link), + ].join('|'); + } } diff --git a/api/src/unraid-api/graph/resolvers/resolvers.module.ts b/api/src/unraid-api/graph/resolvers/resolvers.module.ts index 751d42891e..34a7884d6a 100644 --- a/api/src/unraid-api/graph/resolvers/resolvers.module.ts +++ b/api/src/unraid-api/graph/resolvers/resolvers.module.ts @@ -15,8 +15,8 @@ import { InfoModule } from '@app/unraid-api/graph/resolvers/info/info.module.js' import { LogsModule } from '@app/unraid-api/graph/resolvers/logs/logs.module.js'; import { MetricsModule } from '@app/unraid-api/graph/resolvers/metrics/metrics.module.js'; import { RootMutationsResolver } from '@app/unraid-api/graph/resolvers/mutation/mutation.resolver.js'; +import { NotificationsModule } from '@app/unraid-api/graph/resolvers/notifications/notifications.module.js'; import { NotificationsResolver } from '@app/unraid-api/graph/resolvers/notifications/notifications.resolver.js'; -import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js'; import { OnlineResolver } from '@app/unraid-api/graph/resolvers/online/online.resolver.js'; import { OwnerResolver } from '@app/unraid-api/graph/resolvers/owner/owner.resolver.js'; import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js'; @@ -47,6 +47,7 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js'; FlashBackupModule, InfoModule, LogsModule, + NotificationsModule, RCloneModule, SettingsModule, SsoModule, @@ -58,7 +59,6 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js'; FlashResolver, MeResolver, NotificationsResolver, - NotificationsService, OnlineResolver, OwnerResolver, RegistrationResolver, diff --git a/api/src/unraid-api/graph/resolvers/vms/vms.service.spec.ts b/api/src/unraid-api/graph/resolvers/vms/vms.service.spec.ts index 7b67339901..aebc4b7036 100644 --- a/api/src/unraid-api/graph/resolvers/vms/vms.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/vms/vms.service.spec.ts @@ -148,6 +148,16 @@ const verifyLibvirtConnection = async (hypervisor: Hypervisor) => { } }; +// Check if qemu-img is available before running tests +const isQemuAvailable = () => { + try { + execSync('qemu-img --version', { stdio: 'ignore' }); + return true; + } catch (error) { + return false; + } +}; + describe('VmsService', () => { let service: VmsService; let hypervisor: Hypervisor; @@ -174,6 +184,14 @@ describe('VmsService', () => { `; + beforeAll(() => { + if (!isQemuAvailable()) { + throw new Error( + 'QEMU not available - skipping VM integration tests. Please install QEMU to run these tests.' + ); + } + }); + beforeAll(async () => { // Override the LIBVIRT_URI environment variable for testing process.env.LIBVIRT_URI = LIBVIRT_URI; diff --git a/api/src/unraid-api/organizer/organizer.model.ts b/api/src/unraid-api/organizer/organizer.model.ts index a5e89cde8f..274fc329b6 100644 --- a/api/src/unraid-api/organizer/organizer.model.ts +++ b/api/src/unraid-api/organizer/organizer.model.ts @@ -222,9 +222,15 @@ export class ResolvedOrganizerView { @IsString() name!: string; - @Field(() => ResolvedOrganizerEntry) - @ValidateNested() - root!: ResolvedOrganizerEntryType; + @Field() + @IsString() + rootId!: string; + + @Field(() => [FlatOrganizerEntry]) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => FlatOrganizerEntry) + flatEntries!: FlatOrganizerEntry[]; @Field(() => GraphQLJSON, { nullable: true }) @IsOptional() @@ -246,3 +252,54 @@ export class ResolvedOrganizerV1 { @Type(() => ResolvedOrganizerView) views!: ResolvedOrganizerView[]; } + +// ============================================ +// FLAT ORGANIZER ENTRY (for efficient frontend consumption) +// ============================================ + +@ObjectType() +export class FlatOrganizerEntry { + @Field() + @IsString() + id!: string; + + @Field() + @IsString() + type!: string; + + @Field() + @IsString() + name!: string; + + @Field({ nullable: true }) + @IsOptional() + @IsString() + parentId?: string; + + @Field() + @IsNumber() + depth!: number; + + @Field() + @IsNumber() + position!: number; + + @Field(() => [String]) + @IsArray() + @IsString({ each: true }) + path!: string[]; + + @Field() + hasChildren!: boolean; + + @Field(() => [String]) + @IsArray() + @IsString({ each: true }) + childrenIds!: string[]; + + @Field(() => DockerContainer, { nullable: true }) + @IsOptional() + @ValidateNested() + @Type(() => DockerContainer) + meta?: DockerContainer; +} diff --git a/api/src/unraid-api/organizer/organizer.resolution.test.ts b/api/src/unraid-api/organizer/organizer.resolution.test.ts index b5322a11cd..e0d3712820 100644 --- a/api/src/unraid-api/organizer/organizer.resolution.test.ts +++ b/api/src/unraid-api/organizer/organizer.resolution.test.ts @@ -4,8 +4,6 @@ import { resolveOrganizer } from '@app/unraid-api/organizer/organizer.js'; import { OrganizerResource, OrganizerV1, - ResolvedOrganizerEntryType, - ResolvedOrganizerFolder, ResolvedOrganizerV1, } from '@app/unraid-api/organizer/organizer.model.js'; @@ -72,36 +70,48 @@ describe('Organizer Resolver', () => { const defaultView = resolved.views[0]; expect(defaultView.id).toBe('default'); expect(defaultView.name).toBe('Default View'); - expect(defaultView.root.type).toBe('folder'); - - if (defaultView.root.type === 'folder') { - const rootFolder = defaultView.root as ResolvedOrganizerFolder; - expect(rootFolder.name).toBe('Root'); - expect(rootFolder.children).toHaveLength(2); - - // First child should be the resolved container1 - const firstChild = rootFolder.children[0]; - expect(firstChild.type).toBe('container'); - expect(firstChild.id).toBe('container1'); - expect(firstChild.name).toBe('My Container'); - - // Second child should be the resolved subfolder - const secondChild = rootFolder.children[1]; - expect(secondChild.type).toBe('folder'); - if (secondChild.type === 'folder') { - const subFolder = secondChild as ResolvedOrganizerFolder; - expect(subFolder.name).toBe('Subfolder'); - expect(subFolder.children).toHaveLength(1); - - const nestedChild = subFolder.children[0]; - expect(nestedChild.type).toBe('container'); - expect(nestedChild.id).toBe('container2'); - expect(nestedChild.name).toBe('Another Container'); - } - } + expect(defaultView.rootId).toBe('root-folder'); + + // Check flatEntries structure + const flatEntries = defaultView.flatEntries; + expect(flatEntries).toHaveLength(4); + + // Root folder + const rootEntry = flatEntries[0]; + expect(rootEntry.id).toBe('root-folder'); + expect(rootEntry.type).toBe('folder'); + expect(rootEntry.name).toBe('Root'); + expect(rootEntry.depth).toBe(0); + expect(rootEntry.parentId).toBeUndefined(); + expect(rootEntry.childrenIds).toEqual(['container1-ref', 'subfolder']); + + // First child (container1-ref resolved to container) + const container1Entry = flatEntries[1]; + expect(container1Entry.id).toBe('container1-ref'); + expect(container1Entry.type).toBe('container'); + expect(container1Entry.name).toBe('My Container'); + expect(container1Entry.depth).toBe(1); + expect(container1Entry.parentId).toBe('root-folder'); + + // Subfolder + const subfolderEntry = flatEntries[2]; + expect(subfolderEntry.id).toBe('subfolder'); + expect(subfolderEntry.type).toBe('folder'); + expect(subfolderEntry.name).toBe('Subfolder'); + expect(subfolderEntry.depth).toBe(1); + expect(subfolderEntry.parentId).toBe('root-folder'); + expect(subfolderEntry.childrenIds).toEqual(['container2-ref']); + + // Nested container + const container2Entry = flatEntries[3]; + expect(container2Entry.id).toBe('container2-ref'); + expect(container2Entry.type).toBe('container'); + expect(container2Entry.name).toBe('Another Container'); + expect(container2Entry.depth).toBe(2); + expect(container2Entry.parentId).toBe('subfolder'); }); - test('should throw error for missing resource', () => { + test('should handle missing resource gracefully', () => { const organizer: OrganizerV1 = { version: 1, resources: {}, @@ -127,12 +137,19 @@ describe('Organizer Resolver', () => { }, }; - expect(() => resolveOrganizer(organizer)).toThrow( - "Resource with id 'nonexistent-resource' not found" - ); + const resolved = resolveOrganizer(organizer); + const flatEntries = resolved.views[0].flatEntries; + + // Should have 2 entries: root folder and the ref (kept as ref type since resource not found) + expect(flatEntries).toHaveLength(2); + + const missingRefEntry = flatEntries[1]; + expect(missingRefEntry.id).toBe('missing-ref'); + expect(missingRefEntry.type).toBe('ref'); // Stays as ref when resource not found + expect(missingRefEntry.meta).toBeUndefined(); }); - test('should throw error for missing entry', () => { + test('should skip missing entries gracefully', () => { const organizer: OrganizerV1 = { version: 1, resources: {}, @@ -153,9 +170,12 @@ describe('Organizer Resolver', () => { }, }; - expect(() => resolveOrganizer(organizer)).toThrow( - "Entry with id 'nonexistent-entry' not found in view" - ); + const resolved = resolveOrganizer(organizer); + const flatEntries = resolved.views[0].flatEntries; + + // Should only have root folder, missing entry is skipped + expect(flatEntries).toHaveLength(1); + expect(flatEntries[0].id).toBe('root-folder'); }); test('should resolve empty folders correctly', () => { @@ -207,30 +227,27 @@ describe('Organizer Resolver', () => { const defaultView = resolved.views[0]; expect(defaultView.id).toBe('default'); expect(defaultView.name).toBe('Default View'); - expect(defaultView.root.type).toBe('folder'); - - if (defaultView.root.type === 'folder') { - const rootFolder = defaultView.root as ResolvedOrganizerFolder; - expect(rootFolder.name).toBe('Root'); - expect(rootFolder.children).toHaveLength(2); - - // First child should be the resolved container - const firstChild = rootFolder.children[0]; - expect(firstChild.type).toBe('container'); - expect(firstChild.id).toBe('container1'); - - // Second child should be the resolved empty folder - const secondChild = rootFolder.children[1]; - expect(secondChild.type).toBe('folder'); - expect(secondChild.id).toBe('empty-folder'); - - if (secondChild.type === 'folder') { - const emptyFolder = secondChild as ResolvedOrganizerFolder; - expect(emptyFolder.name).toBe('Empty Folder'); - expect(emptyFolder.children).toEqual([]); - expect(emptyFolder.children).toHaveLength(0); - } - } + expect(defaultView.rootId).toBe('root'); + + const flatEntries = defaultView.flatEntries; + expect(flatEntries).toHaveLength(3); + + // Root folder + expect(flatEntries[0].id).toBe('root'); + expect(flatEntries[0].type).toBe('folder'); + expect(flatEntries[0].name).toBe('Root'); + + // First child - resolved container + expect(flatEntries[1].id).toBe('container1-ref'); + expect(flatEntries[1].type).toBe('container'); + expect(flatEntries[1].name).toBe('My Container'); + + // Second child - empty folder + expect(flatEntries[2].id).toBe('empty-folder'); + expect(flatEntries[2].type).toBe('folder'); + expect(flatEntries[2].name).toBe('Empty Folder'); + expect(flatEntries[2].childrenIds).toEqual([]); + expect(flatEntries[2].hasChildren).toBe(false); }); test('should handle real-world scenario with containers and empty folder', () => { @@ -314,24 +331,19 @@ describe('Organizer Resolver', () => { expect(resolved.views).toHaveLength(1); const defaultView = resolved.views[0]; - expect(defaultView.root.type).toBe('folder'); - - if (defaultView.root.type === 'folder') { - const rootFolder = defaultView.root as ResolvedOrganizerFolder; - expect(rootFolder.children).toHaveLength(4); - - // Last child should be the empty folder (not an empty object) - const lastChild = rootFolder.children[3]; - expect(lastChild).not.toEqual({}); // This should NOT be an empty object - expect(lastChild.type).toBe('folder'); - expect(lastChild.id).toBe('new-folder'); - - if (lastChild.type === 'folder') { - const newFolder = lastChild as ResolvedOrganizerFolder; - expect(newFolder.name).toBe('new-folder'); - expect(newFolder.children).toEqual([]); - } - } + expect(defaultView.rootId).toBe('root'); + + const flatEntries = defaultView.flatEntries; + expect(flatEntries).toHaveLength(5); // root + 3 containers + empty folder + + // Last entry should be the empty folder (not missing or malformed) + const lastEntry = flatEntries[4]; + expect(lastEntry).toBeDefined(); + expect(lastEntry.type).toBe('folder'); + expect(lastEntry.id).toBe('new-folder'); + expect(lastEntry.name).toBe('new-folder'); + expect(lastEntry.childrenIds).toEqual([]); + expect(lastEntry.hasChildren).toBe(false); }); test('should handle nested empty folders correctly', () => { @@ -373,31 +385,28 @@ describe('Organizer Resolver', () => { expect(resolved.views).toHaveLength(1); const defaultView = resolved.views[0]; - expect(defaultView.root.type).toBe('folder'); - - if (defaultView.root.type === 'folder') { - const rootFolder = defaultView.root as ResolvedOrganizerFolder; - expect(rootFolder.children).toHaveLength(1); - - const level1Folder = rootFolder.children[0]; - expect(level1Folder.type).toBe('folder'); - expect(level1Folder.id).toBe('level1-folder'); - - if (level1Folder.type === 'folder') { - const level1 = level1Folder as ResolvedOrganizerFolder; - expect(level1.children).toHaveLength(1); - - const level2Folder = level1.children[0]; - expect(level2Folder.type).toBe('folder'); - expect(level2Folder.id).toBe('level2-folder'); - - if (level2Folder.type === 'folder') { - const level2 = level2Folder as ResolvedOrganizerFolder; - expect(level2.children).toEqual([]); - expect(level2.children).toHaveLength(0); - } - } - } + expect(defaultView.rootId).toBe('root'); + + const flatEntries = defaultView.flatEntries; + expect(flatEntries).toHaveLength(3); + + // Root + expect(flatEntries[0].id).toBe('root'); + expect(flatEntries[0].depth).toBe(0); + + // Level 1 folder + expect(flatEntries[1].id).toBe('level1-folder'); + expect(flatEntries[1].type).toBe('folder'); + expect(flatEntries[1].depth).toBe(1); + expect(flatEntries[1].parentId).toBe('root'); + + // Level 2 folder (empty) + expect(flatEntries[2].id).toBe('level2-folder'); + expect(flatEntries[2].type).toBe('folder'); + expect(flatEntries[2].depth).toBe(2); + expect(flatEntries[2].parentId).toBe('level1-folder'); + expect(flatEntries[2].childrenIds).toEqual([]); + expect(flatEntries[2].hasChildren).toBe(false); }); test('should validate that all resolved objects have proper structure', () => { @@ -443,30 +452,24 @@ describe('Organizer Resolver', () => { const resolved: ResolvedOrganizerV1 = resolveOrganizer(organizer); - // Recursively validate that all objects have proper structure - function validateResolvedEntry(entry: ResolvedOrganizerEntryType) { + // Validate that all flat entries have proper structure + const flatEntries = resolved.views[0].flatEntries; + expect(flatEntries).toHaveLength(3); // root + container + empty folder + + flatEntries.forEach((entry) => { expect(entry).toBeDefined(); expect(entry).not.toEqual({}); expect(entry).toHaveProperty('id'); expect(entry).toHaveProperty('type'); expect(entry).toHaveProperty('name'); + expect(entry).toHaveProperty('depth'); + expect(entry).toHaveProperty('childrenIds'); expect(typeof entry.id).toBe('string'); expect(typeof entry.type).toBe('string'); expect(typeof entry.name).toBe('string'); - - if (entry.type === 'folder') { - const folder = entry as ResolvedOrganizerFolder; - expect(folder).toHaveProperty('children'); - expect(Array.isArray(folder.children)).toBe(true); - - // Recursively validate children - folder.children.forEach((child) => validateResolvedEntry(child)); - } - } - - if (resolved.views[0].root.type === 'folder') { - validateResolvedEntry(resolved.views[0].root); - } + expect(typeof entry.depth).toBe('number'); + expect(Array.isArray(entry.childrenIds)).toBe(true); + }); }); test('should maintain object identity and not return empty objects', () => { @@ -510,22 +513,19 @@ describe('Organizer Resolver', () => { const resolved: ResolvedOrganizerV1 = resolveOrganizer(organizer); - if (resolved.views[0].root.type === 'folder') { - const rootFolder = resolved.views[0].root as ResolvedOrganizerFolder; - expect(rootFolder.children).toHaveLength(3); - - // Ensure none of the children are empty objects - rootFolder.children.forEach((child, index) => { - expect(child).not.toEqual({}); - expect(child.type).toBe('folder'); - expect(child.id).toBe(`empty${index + 1}`); - expect(child.name).toBe(`Empty ${index + 1}`); - - if (child.type === 'folder') { - const folder = child as ResolvedOrganizerFolder; - expect(folder.children).toEqual([]); - } - }); - } + const flatEntries = resolved.views[0].flatEntries; + expect(flatEntries).toHaveLength(4); // root + 3 empty folders + + // Ensure none of the entries are malformed + const emptyFolders = flatEntries.slice(1); // Skip root + emptyFolders.forEach((entry, index) => { + expect(entry).not.toEqual({}); + expect(entry).toBeDefined(); + expect(entry.type).toBe('folder'); + expect(entry.id).toBe(`empty${index + 1}`); + expect(entry.name).toBe(`Empty ${index + 1}`); + expect(entry.childrenIds).toEqual([]); + expect(entry.hasChildren).toBe(false); + }); }); }); diff --git a/api/src/unraid-api/organizer/organizer.test.ts b/api/src/unraid-api/organizer/organizer.test.ts index 5cefa6d383..9d83cc46b9 100644 --- a/api/src/unraid-api/organizer/organizer.test.ts +++ b/api/src/unraid-api/organizer/organizer.test.ts @@ -263,4 +263,39 @@ describe('addMissingResourcesToView', () => { expect(result.entries['key-different-from-id'].id).toBe('actual-resource-id'); expect((result.entries['root1'] as OrganizerFolder).children).toContain('key-different-from-id'); }); + + it("does not re-add resources to root if they're already referenced in any folder", () => { + const resources: OrganizerV1['resources'] = { + resourceA: { id: 'resourceA', type: 'container', name: 'A' }, + resourceB: { id: 'resourceB', type: 'container', name: 'B' }, + }; + + const originalView: OrganizerView = { + id: 'view1', + name: 'Test View', + root: 'root1', + entries: { + root1: { + id: 'root1', + type: 'folder', + name: 'Root', + children: ['stuff'], + }, + stuff: { + id: 'stuff', + type: 'folder', + name: 'Stuff', + children: ['resourceA', 'resourceB'], + }, + resourceA: { id: 'resourceA', type: 'ref', target: 'resourceA' }, + resourceB: { id: 'resourceB', type: 'ref', target: 'resourceB' }, + }, + }; + + const result = addMissingResourcesToView(resources, originalView); + + // Root should still only contain the 'stuff' folder, not the resources + const rootChildren = (result.entries['root1'] as OrganizerFolder).children; + expect(rootChildren).toEqual(['stuff']); + }); }); diff --git a/api/src/unraid-api/organizer/organizer.ts b/api/src/unraid-api/organizer/organizer.ts index 7d0ac01b7e..0df45242ae 100644 --- a/api/src/unraid-api/organizer/organizer.ts +++ b/api/src/unraid-api/organizer/organizer.ts @@ -1,5 +1,7 @@ import { AnyOrganizerResource, + FlatOrganizerEntry, + OrganizerContainerResource, OrganizerFolder, OrganizerResource, OrganizerResourceRef, @@ -58,10 +60,25 @@ export function addMissingResourcesToView( }; const root = view.entries[view.root]! as OrganizerFolder; const rootChildren = new Set(root.children); + // Track if a resource id is already referenced in any folder + const referencedIds = new Set(); + Object.values(view.entries).forEach((entry) => { + if (entry.type === 'folder') { + for (const childId of entry.children) referencedIds.add(childId); + } + }); Object.entries(resources).forEach(([id, resource]) => { - if (!view.entries[id]) { + const existsInEntries = Boolean(view.entries[id]); + const isReferencedSomewhere = referencedIds.has(id); + + // Ensure a ref entry exists for the resource id + if (!existsInEntries) { view.entries[id] = resourceToResourceRef(resource, (resource) => resource.id); + } + + // Only add to root if the resource is not already referenced elsewhere + if (!isReferencedSomewhere) { rootChildren.add(id); } }); @@ -70,47 +87,80 @@ export function addMissingResourcesToView( } /** - * Recursively resolves an organizer entry (folder or resource ref) into its actual objects. - * This transforms the flat ID-based structure into a nested object structure for frontend convenience. + * Directly enriches flat entries from an organizer view without building an intermediate tree. + * This is more efficient than building a tree just to flatten it again. * * PRECONDITION: The given view is valid (ie. does not contain any cycles or depth issues). * - * @param entryId - The ID of the entry to resolve - * @param view - The organizer view containing the entry definitions + * @param view - The flat organizer view * @param resources - The collection of all available resources - * @returns The resolved entry with actual objects instead of ID references + * @returns Array of enriched flat organizer entries with metadata */ -function resolveEntry( - entryId: string, +export function enrichFlatEntries( view: OrganizerView, resources: OrganizerV1['resources'] -): ResolvedOrganizerEntryType { - const entry = view.entries[entryId]; +): FlatOrganizerEntry[] { + const entries: FlatOrganizerEntry[] = []; + const parentMap = new Map(); - if (!entry) { - throw new Error(`Entry with id '${entryId}' not found in view`); + // Build parent map + for (const [id, entry] of Object.entries(view.entries)) { + if (entry.type === 'folder') { + for (const childId of entry.children) { + parentMap.set(childId, id); + } + } } - if (entry.type === 'folder') { - // Recursively resolve all children - const resolvedChildren = entry.children.map((childId) => resolveEntry(childId, view, resources)); - - return { - id: entry.id, - type: 'folder', - name: entry.name, - children: resolvedChildren, - } as ResolvedOrganizerFolder; - } else if (entry.type === 'ref') { - // Resolve the resource reference - const resource = resources[entry.target]; - if (!resource) { - throw new Error(`Resource with id '${entry.target}' not found`); + // Walk from root to maintain order and calculate depth/position + function walk(entryId: string, depth: number, path: string[], position: number): void { + const entry = view.entries[entryId]; + if (!entry) return; + + const currentPath = [...path, entryId]; + const isFolder = entry.type === 'folder'; + const children = isFolder ? (entry as OrganizerFolder).children : []; + + // Resolve resource if ref + let meta: any = undefined; + let name = entryId; + let type: string = entry.type; + + if (entry.type === 'ref') { + const resource = resources[(entry as OrganizerResourceRef).target]; + if (resource) { + if (resource.type === 'container') { + meta = (resource as OrganizerContainerResource).meta; + type = 'container'; + } + name = resource.name; + } + } else if (entry.type === 'folder') { + name = (entry as OrganizerFolder).name; + } + + entries.push({ + id: entryId, + type, + name, + parentId: parentMap.get(entryId), + depth, + path: currentPath, + position, + hasChildren: isFolder && children.length > 0, + childrenIds: children, + meta, + }); + + if (isFolder) { + children.forEach((childId, idx) => { + walk(childId, depth + 1, currentPath, idx); + }); } - return resource; } - throw new Error(`Unknown entry type: ${(entry as any).type}`); + walk(view.root, 0, [], 0); + return entries; } /** @@ -127,12 +177,13 @@ export function resolveOrganizerView( view: OrganizerView, resources: OrganizerV1['resources'] ): ResolvedOrganizerView { - const resolvedRoot = resolveEntry(view.root, view, resources); + const flatEntries = enrichFlatEntries(view, resources); return { id: view.id, name: view.name, - root: resolvedRoot, + rootId: view.root, + flatEntries, prefs: view.prefs, }; } @@ -574,3 +625,108 @@ export function moveEntriesToFolder(params: MoveEntriesToFolderParams): Organize destinationFolder.children = Array.from(destinationChildren); return newView; } + +export interface MoveItemsToPositionParams { + view: OrganizerView; + sourceEntryIds: Set; + destinationFolderId: string; + position: number; + resources?: OrganizerV1['resources']; +} + +/** + * Moves entries to a specific position within a destination folder. + * Combines moveEntriesToFolder with position-based insertion. + */ +export function moveItemsToPosition(params: MoveItemsToPositionParams): OrganizerView { + const { view, sourceEntryIds, destinationFolderId, position, resources } = params; + + const movedView = moveEntriesToFolder({ view, sourceEntryIds, destinationFolderId }); + + const folder = movedView.entries[destinationFolderId] as OrganizerFolder; + const movedIds = Array.from(sourceEntryIds); + const otherChildren = folder.children.filter((id) => !sourceEntryIds.has(id)); + + const insertPos = Math.max(0, Math.min(position, otherChildren.length)); + const reordered = [ + ...otherChildren.slice(0, insertPos), + ...movedIds, + ...otherChildren.slice(insertPos), + ]; + + folder.children = reordered; + return movedView; +} + +export interface RenameFolderParams { + view: OrganizerView; + folderId: string; + newName: string; +} + +/** + * Renames a folder by updating its name property. + * This is simpler than the current create+delete approach. + */ +export function renameFolder(params: RenameFolderParams): OrganizerView { + const { view, folderId, newName } = params; + const newView = structuredClone(view); + + const entry = newView.entries[folderId]; + if (!entry) { + throw new Error(`Folder with id '${folderId}' not found`); + } + if (entry.type !== 'folder') { + throw new Error(`Entry '${folderId}' is not a folder`); + } + + (entry as OrganizerFolder).name = newName; + return newView; +} + +export interface CreateFolderWithItemsParams { + view: OrganizerView; + folderId: string; + folderName: string; + parentId: string; + sourceEntryIds?: string[]; + position?: number; + resources?: OrganizerV1['resources']; +} + +/** + * Creates a new folder and optionally moves items into it at a specific position. + * Combines createFolder + moveItems + positioning in a single atomic operation. + */ +export function createFolderWithItems(params: CreateFolderWithItemsParams): OrganizerView { + const { view, folderId, folderName, parentId, sourceEntryIds = [], position, resources } = params; + + let newView = createFolderInView({ + view, + folderId, + folderName, + parentId, + childrenIds: sourceEntryIds, + }); + + if (sourceEntryIds.length > 0) { + newView = moveEntriesToFolder({ + view: newView, + sourceEntryIds: new Set(sourceEntryIds), + destinationFolderId: folderId, + }); + } + + if (position !== undefined) { + const parent = newView.entries[parentId] as OrganizerFolder; + const withoutNewFolder = parent.children.filter((id) => id !== folderId); + const insertPos = Math.max(0, Math.min(position, withoutNewFolder.length)); + parent.children = [ + ...withoutNewFolder.slice(0, insertPos), + folderId, + ...withoutNewFolder.slice(insertPos), + ]; + } + + return newView; +} diff --git a/api/src/unraid-api/unraid-file-modifier/file-modification.ts b/api/src/unraid-api/unraid-file-modifier/file-modification.ts index 0dcfd0e32c..9fbc7df252 100644 --- a/api/src/unraid-api/unraid-file-modifier/file-modification.ts +++ b/api/src/unraid-api/unraid-file-modifier/file-modification.ts @@ -212,9 +212,11 @@ export abstract class FileModification { } // Default implementation that can be overridden if needed - async shouldApply(): Promise { + async shouldApply({ + checkOsVersion = true, + }: { checkOsVersion?: boolean } = {}): Promise { try { - if (await this.isUnraidVersionGreaterThanOrEqualTo('7.2.0')) { + if (checkOsVersion && (await this.isUnraidVersionGreaterThanOrEqualTo('7.2.0'))) { return { shouldApply: false, reason: 'Patch unnecessary for Unraid 7.2 or later because the Unraid API is integrated.', diff --git a/api/src/unraid-api/unraid-file-modifier/modifications/docker-containers-page.modification.ts b/api/src/unraid-api/unraid-file-modifier/modifications/docker-containers-page.modification.ts new file mode 100644 index 0000000000..f5e3cd54ab --- /dev/null +++ b/api/src/unraid-api/unraid-file-modifier/modifications/docker-containers-page.modification.ts @@ -0,0 +1,61 @@ +import { readFile } from 'node:fs/promises'; + +import { ENABLE_NEXT_DOCKER_RELEASE } from '@app/environment.js'; +import { + FileModification, + ShouldApplyWithReason, +} from '@app/unraid-api/unraid-file-modifier/file-modification.js'; + +export default class DockerContainersPageModification extends FileModification { + id: string = 'docker-containers-page'; + public readonly filePath: string = + '/usr/local/emhttp/plugins/dynamix.docker.manager/DockerContainers.page'; + + async shouldApply(): Promise { + const baseCheck = await super.shouldApply({ checkOsVersion: false }); + if (!baseCheck.shouldApply) { + return baseCheck; + } + + if (!ENABLE_NEXT_DOCKER_RELEASE) { + return { + shouldApply: false, + reason: 'ENABLE_NEXT_DOCKER_RELEASE is not enabled, so Docker overview table modification is not applied', + }; + } + + if (await this.isUnraidVersionGreaterThanOrEqualTo('7.3.0')) { + return { + shouldApply: true, + reason: 'Docker overview table WILL BE integrated in Unraid 7.3 or later. This modification is a temporary measure for testing.', + }; + } + + return { + shouldApply: true, + reason: 'Docker overview table modification needed for Unraid < 7.3', + }; + } + + protected async generatePatch(overridePath?: string): Promise { + const fileContent = await readFile(this.filePath, 'utf-8'); + const newContent = this.applyToSource(); + + return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent); + } + + private applyToSource(): string { + return `Menu="Docker:1" +Title="Docker Containers" +Tag="cubes" +Cond="is_file('/var/run/dockerd.pid')" +Markdown="false" +Nchan="docker_load" +Tabs="false" +--- +
+ +
+`; + } +} diff --git a/packages/unraid-shared/src/pubsub/graphql.pubsub.ts b/packages/unraid-shared/src/pubsub/graphql.pubsub.ts index 1470b944e3..2c48757006 100644 --- a/packages/unraid-shared/src/pubsub/graphql.pubsub.ts +++ b/packages/unraid-shared/src/pubsub/graphql.pubsub.ts @@ -13,9 +13,11 @@ export enum GRAPHQL_PUBSUB_CHANNEL { NOTIFICATION = "NOTIFICATION", NOTIFICATION_ADDED = "NOTIFICATION_ADDED", NOTIFICATION_OVERVIEW = "NOTIFICATION_OVERVIEW", + NOTIFICATION_WARNINGS_AND_ALERTS = "NOTIFICATION_WARNINGS_AND_ALERTS", OWNER = "OWNER", SERVERS = "SERVERS", VMS = "VMS", + DOCKER_STATS = "DOCKER_STATS", LOG_FILE = "LOG_FILE", PARITY = "PARITY", } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8db98eb3a4..b88b3a2375 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -187,6 +187,9 @@ importers: exit-hook: specifier: 4.0.0 version: 4.0.0 + fast-xml-parser: + specifier: ^5.3.0 + version: 5.3.0 fastify: specifier: 5.5.0 version: 5.5.0 @@ -1064,6 +1067,9 @@ importers: '@floating-ui/vue': specifier: 1.1.9 version: 1.1.9(vue@3.5.20(typescript@5.9.2)) + '@formkit/auto-animate': + specifier: ^0.9.0 + version: 0.9.0 '@headlessui/vue': specifier: 1.7.23 version: 1.7.23(vue@3.5.20(typescript@5.9.2)) @@ -1085,6 +1091,9 @@ importers: '@nuxt/ui': specifier: 4.0.0-alpha.0 version: 4.0.0-alpha.0(@babel/parser@7.28.4)(@netlify/blobs@9.1.2)(change-case@5.4.4)(db0@0.3.2)(embla-carousel@8.6.0)(focus-trap@7.6.5)(ioredis@5.7.0)(jwt-decode@4.0.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue-router@4.5.1(vue@3.5.20(typescript@5.9.2)))(vue@3.5.20(typescript@5.9.2))(zod@3.25.76) + '@tanstack/vue-table': + specifier: ^8.21.3 + version: 8.21.3(vue@3.5.20(typescript@5.9.2)) '@unraid/shared-callbacks': specifier: 1.1.1 version: 1.1.1(@vueuse/core@13.8.0(vue@3.5.20(typescript@5.9.2))) @@ -2566,6 +2575,9 @@ packages: '@floating-ui/vue@1.1.9': resolution: {integrity: sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ==} + '@formkit/auto-animate@0.9.0': + resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==} + '@golevelup/nestjs-discovery@4.0.3': resolution: {integrity: sha512-8w3CsXHN7+7Sn2i419Eal1Iw/kOjAd6Kb55M/ZqKBBwACCMn4WiEuzssC71LpBMI1090CiDxuelfPRwwIrQK+A==} peerDependencies: @@ -7732,6 +7744,10 @@ packages: resolution: {integrity: sha512-y655CeyUQ+jj7KBbYMc4FG01V8ZQqjN+gDYGJ50RtfsUB8iG9AmwmwoAgeKLJdmueKKMrH1RJ7yXHTSoczdv5w==} hasBin: true + fast-xml-parser@5.3.0: + resolution: {integrity: sha512-gkWGshjYcQCF+6qtlrqBqELqNqnt4CxruY6UVAWWnqb3DQ6qaNFEIKqzYep1XzHLM/QtrHVCxyPOtTk4LTQ7Aw==} + hasBin: true + fastify-plugin@4.5.1: resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} @@ -11377,6 +11393,9 @@ packages: strnum@1.0.5: resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} + strnum@2.1.1: + resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==} + strtok3@10.3.1: resolution: {integrity: sha512-3JWEZM6mfix/GCJBBUrkA8p2Id2pBkyTkVCJKto55w080QBKZ+8R171fGrbiSp+yMO/u6F8/yUh7K4V9K+YCnw==} engines: {node: '>=18'} @@ -12434,8 +12453,8 @@ packages: vue-component-type-helpers@3.0.6: resolution: {integrity: sha512-6CRM8X7EJqWCJOiKPvSLQG+hJPb/Oy2gyJx3pLjUEhY7PuaCthQu3e0zAGI1lqUBobrrk9IT0K8sG2GsCluxoQ==} - vue-component-type-helpers@3.1.3: - resolution: {integrity: sha512-V1dOD8XYfstOKCnXbWyEJIrhTBMwSyNjv271L1Jlx9ExpNlCSuqOs3OdWrGJ0V544zXufKbcYabi/o+gK8lyfQ==} + vue-component-type-helpers@3.1.4: + resolution: {integrity: sha512-Uws7Ew1OzTTqHW8ZVl/qLl/HB+jf08M0NdFONbVWAx0N4gMLK8yfZDgeB77hDnBmaigWWEn5qP8T9BG59jIeyQ==} vue-demi@0.14.10: resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} @@ -14141,6 +14160,8 @@ snapshots: - '@vue/composition-api' - vue + '@formkit/auto-animate@0.9.0': {} + '@golevelup/nestjs-discovery@4.0.3(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2))': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2) @@ -16500,7 +16521,7 @@ snapshots: storybook: 9.1.3(@testing-library/dom@10.4.0)(prettier@3.6.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)) type-fest: 2.19.0 vue: 3.5.20(typescript@5.9.2) - vue-component-type-helpers: 3.1.3 + vue-component-type-helpers: 3.1.4 '@swc/core-darwin-arm64@1.13.5': optional: true @@ -20088,6 +20109,10 @@ snapshots: dependencies: strnum: 1.0.5 + fast-xml-parser@5.3.0: + dependencies: + strnum: 2.1.1 + fastify-plugin@4.5.1: {} fastify-plugin@5.0.1: {} @@ -24249,6 +24274,8 @@ snapshots: strnum@1.0.5: {} + strnum@2.1.1: {} + strtok3@10.3.1: dependencies: '@tokenizer/token': 0.3.0 @@ -25339,7 +25366,7 @@ snapshots: vue-component-type-helpers@3.0.6: {} - vue-component-type-helpers@3.1.3: {} + vue-component-type-helpers@3.1.4: {} vue-demi@0.14.10(vue@3.5.20(typescript@5.9.2)): dependencies: diff --git a/web/.prettierignore b/web/.prettierignore index dd58567ac2..ab4a79413c 100644 --- a/web/.prettierignore +++ b/web/.prettierignore @@ -2,3 +2,6 @@ auto-imports.d.ts components.d.ts composables/gql/ src/composables/gql/ +dist/ +.output/ +.nuxt/ diff --git a/web/__test__/components/Wrapper/mount-engine.test.ts b/web/__test__/components/Wrapper/mount-engine.test.ts index ec4fd3e5da..ba3e76d966 100644 --- a/web/__test__/components/Wrapper/mount-engine.test.ts +++ b/web/__test__/components/Wrapper/mount-engine.test.ts @@ -5,12 +5,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { ComponentMapping } from '~/components/Wrapper/component-registry'; import type { MockInstance } from 'vitest'; +let lastUAppPortal: string | undefined; + // Mock @nuxt/ui components vi.mock('@nuxt/ui/components/App.vue', () => ({ default: defineComponent({ name: 'UApp', - setup(_, { slots }) { - return () => h('div', { class: 'u-app' }, slots.default?.()); + props: { + portal: { + type: String, + required: false, + }, + }, + setup(props, { slots }) { + lastUAppPortal = props.portal; + return () => h('div', { class: 'u-app', 'data-portal': props.portal ?? '' }, slots.default?.()); }, }), })); @@ -77,6 +86,7 @@ describe('mount-engine', () => { // Import fresh module vi.resetModules(); + lastUAppPortal = undefined; mockCreateI18nInstance.mockClear(); mockEnsureLocale.mockClear(); mockGetWindowLocale.mockReset(); @@ -109,6 +119,7 @@ describe('mount-engine', () => { // Clean up DOM document.body.innerHTML = ''; + lastUAppPortal = undefined; }); afterEach(() => { @@ -203,6 +214,46 @@ describe('mount-engine', () => { expect(element.getAttribute('message')).toBe('{"text": "Encoded"}'); }); + it('should configure UApp portal within scoped container', async () => { + const element = document.createElement('div'); + element.id = 'portal-app'; + document.body.appendChild(element); + + mockComponentMappings.push({ + selector: '#portal-app', + appId: 'portal-app', + component: TestComponent, + }); + + await mountUnifiedApp(); + + const portalRoot = document.getElementById('unraid-api-modals-virtual'); + expect(portalRoot).toBeTruthy(); + expect(portalRoot?.classList.contains('unapi')).toBe(true); + expect(lastUAppPortal).toBe('#unraid-api-modals-virtual'); + }); + + it('should decorate the parent container when requested', async () => { + const container = document.createElement('div'); + container.id = 'container'; + const element = document.createElement('div'); + element.id = 'test-app'; + container.appendChild(element); + document.body.appendChild(container); + + mockComponentMappings.push({ + selector: '#test-app', + appId: 'test-app', + component: TestComponent, + decorateContainer: true, + }); + + await mountUnifiedApp(); + + expect(container.classList.contains('unapi')).toBe(true); + expect(element.classList.contains('unapi')).toBe(true); + }); + it('should handle multiple selector aliases', async () => { const element1 = document.createElement('div'); element1.id = 'app1'; diff --git a/web/components.d.ts b/web/components.d.ts index 87e1693d43..a9d7f53892 100644 --- a/web/components.d.ts +++ b/web/components.d.ts @@ -20,6 +20,8 @@ declare module 'vue' { 'ApiStatus.standalone': typeof import('./src/components/ApiStatus/ApiStatus.standalone.vue')['default'] 'Auth.standalone': typeof import('./src/components/Auth.standalone.vue')['default'] Avatar: typeof import('./src/components/Brand/Avatar.vue')['default'] + BaseLogViewer: typeof import('./src/components/Logs/BaseLogViewer.vue')['default'] + BaseTreeTable: typeof import('./src/components/Common/BaseTreeTable.vue')['default'] Beta: typeof import('./src/components/UserProfile/Beta.vue')['default'] CallbackButton: typeof import('./src/components/UpdateOs/CallbackButton.vue')['default'] CallbackFeedback: typeof import('./src/components/UserProfile/CallbackFeedback.vue')['default'] @@ -36,6 +38,8 @@ declare module 'vue' { ConfirmDialog: typeof import('./src/components/ConfirmDialog.vue')['default'] 'ConnectSettings.standalone': typeof import('./src/components/ConnectSettings/ConnectSettings.standalone.vue')['default'] Console: typeof import('./src/components/Docker/Console.vue')['default'] + ContainerSizesModal: typeof import('./src/components/Docker/ContainerSizesModal.vue')['default'] + 'CriticalNotifications.standalone': typeof import('./src/components/Notifications/CriticalNotifications.standalone.vue')['default'] Detail: typeof import('./src/components/LayoutViews/Detail/Detail.vue')['default'] DetailContentHeader: typeof import('./src/components/LayoutViews/Detail/DetailContentHeader.vue')['default'] DetailLeftNavigation: typeof import('./src/components/LayoutViews/Detail/DetailLeftNavigation.vue')['default'] @@ -45,6 +49,18 @@ declare module 'vue' { 'DevModalTest.standalone': typeof import('./src/components/DevModalTest.standalone.vue')['default'] DevSettings: typeof import('./src/components/DevSettings.vue')['default'] 'DevThemeSwitcher.standalone': typeof import('./src/components/DevThemeSwitcher.standalone.vue')['default'] + DockerAutostartSettings: typeof import('./src/components/Docker/DockerAutostartSettings.vue')['default'] + DockerConsoleViewer: typeof import('./src/components/Docker/DockerConsoleViewer.vue')['default'] + DockerContainerManagement: typeof import('./src/components/Docker/DockerContainerManagement.vue')['default'] + DockerContainerOverview: typeof import('./src/components/Docker/DockerContainerOverview.vue')['default'] + 'DockerContainerOverview.standalone': typeof import('./src/components/Docker/DockerContainerOverview.standalone.vue')['default'] + DockerContainersTable: typeof import('./src/components/Docker/DockerContainersTable.vue')['default'] + DockerContainerStatCell: typeof import('./src/components/Docker/DockerContainerStatCell.vue')['default'] + DockerLogViewerModal: typeof import('./src/components/Docker/DockerLogViewerModal.vue')['default'] + DockerNameCell: typeof import('./src/components/Docker/DockerNameCell.vue')['default'] + DockerOrphanedAlert: typeof import('./src/components/Docker/DockerOrphanedAlert.vue')['default'] + DockerPortConflictsAlert: typeof import('./src/components/Docker/DockerPortConflictsAlert.vue')['default'] + DockerSidebarTree: typeof import('./src/components/Docker/DockerSidebarTree.vue')['default'] Downgrade: typeof import('./src/components/UpdateOs/Downgrade.vue')['default'] 'DowngradeOs.standalone': typeof import('./src/components/DowngradeOs.standalone.vue')['default'] DropdownConnectStatus: typeof import('./src/components/UserProfile/DropdownConnectStatus.vue')['default'] @@ -71,12 +87,12 @@ declare module 'vue' { LocaleSwitcher: typeof import('./src/components/LocaleSwitcher.vue')['default'] LogFilterInput: typeof import('./src/components/Logs/LogFilterInput.vue')['default'] Logo: typeof import('./src/components/Brand/Logo.vue')['default'] - Logs: typeof import('./src/components/Docker/Logs.vue')['default'] 'LogViewer.standalone': typeof import('./src/components/Logs/LogViewer.standalone.vue')['default'] LogViewerToolbar: typeof import('./src/components/Logs/LogViewerToolbar.vue')['default'] Mark: typeof import('./src/components/Brand/Mark.vue')['default'] Modal: typeof import('./src/components/Modal.vue')['default'] 'Modals.standalone': typeof import('./src/components/Modals.standalone.vue')['default'] + MultiValueCopyBadges: typeof import('./src/components/Common/MultiValueCopyBadges.vue')['default'] OidcDebugButton: typeof import('./src/components/Logs/OidcDebugButton.vue')['default'] OidcDebugLogs: typeof import('./src/components/ConnectSettings/OidcDebugLogs.vue')['default'] Overview: typeof import('./src/components/Docker/Overview.vue')['default'] @@ -89,6 +105,7 @@ declare module 'vue' { ReleaseNotesModal: typeof import('./src/components/ReleaseNotesModal.vue')['default'] RemoteItem: typeof import('./src/components/RClone/RemoteItem.vue')['default'] ReplaceCheck: typeof import('./src/components/Registration/ReplaceCheck.vue')['default'] + ResizableSlideover: typeof import('./src/components/Common/ResizableSlideover.vue')['default'] ResponsiveModal: typeof import('./src/components/ResponsiveModal.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] @@ -96,11 +113,13 @@ declare module 'vue' { ServerStateBuy: typeof import('./src/components/UserProfile/ServerStateBuy.vue')['default'] ServerStatus: typeof import('./src/components/UserProfile/ServerStatus.vue')['default'] Sidebar: typeof import('./src/components/Notifications/Sidebar.vue')['default'] + SingleDockerLogViewer: typeof import('./src/components/Docker/SingleDockerLogViewer.vue')['default'] SingleLogViewer: typeof import('./src/components/Logs/SingleLogViewer.vue')['default'] 'SsoButton.standalone': typeof import('./src/components/SsoButton.standalone.vue')['default'] SsoButtons: typeof import('./src/components/sso/SsoButtons.vue')['default'] SsoProviderButton: typeof import('./src/components/sso/SsoProviderButton.vue')['default'] Status: typeof import('./src/components/UpdateOs/Status.vue')['default'] + TableColumnMenu: typeof import('./src/components/Common/TableColumnMenu.vue')['default'] 'TestThemeSwitcher.standalone': typeof import('./src/components/TestThemeSwitcher.standalone.vue')['default'] 'TestUpdateModal.standalone': typeof import('./src/components/UpdateOs/TestUpdateModal.standalone.vue')['default'] 'ThemeSwitcher.standalone': typeof import('./src/components/ThemeSwitcher.standalone.vue')['default'] @@ -115,6 +134,7 @@ declare module 'vue' { UFormField: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/FormField.vue')['default'] UIcon: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default'] UInput: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default'] + UModal: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Modal.vue')['default'] UNavigationMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/NavigationMenu.vue')['default'] UnraidToaster: typeof import('./src/components/UnraidToaster.vue')['default'] Update: typeof import('./src/components/UpdateOs/Update.vue')['default'] @@ -122,10 +142,14 @@ declare module 'vue' { UpdateExpirationAction: typeof import('./src/components/Registration/UpdateExpirationAction.vue')['default'] UpdateIneligible: typeof import('./src/components/UpdateOs/UpdateIneligible.vue')['default'] 'UpdateOs.standalone': typeof import('./src/components/UpdateOs.standalone.vue')['default'] + UPopover: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Popover.vue')['default'] UptimeExpire: typeof import('./src/components/UserProfile/UptimeExpire.vue')['default'] USelectMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default'] 'UserProfile.standalone': typeof import('./src/components/UserProfile.standalone.vue')['default'] + USkeleton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Skeleton.vue')['default'] + USlideover: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Slideover.vue')['default'] USwitch: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Switch.vue')['default'] + UTable: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Table.vue')['default'] UTabs: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default'] 'WanIpCheck.standalone': typeof import('./src/components/WanIpCheck.standalone.vue')['default'] 'WelcomeModal.standalone': typeof import('./src/components/Activation/WelcomeModal.standalone.vue')['default'] diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index cda36533e3..d40b444bdc 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -71,7 +71,7 @@ const vueRules = { 'vue/no-unsupported-features': [ 'error', { - version: '^3.3.0', + version: '^3.5.0', }, ], 'vue/no-unused-properties': [ diff --git a/web/package.json b/web/package.json index e5ee057f1a..256ec301f3 100644 --- a/web/package.json +++ b/web/package.json @@ -102,6 +102,7 @@ "@floating-ui/dom": "1.7.4", "@floating-ui/utils": "0.2.10", "@floating-ui/vue": "1.1.9", + "@formkit/auto-animate": "^0.9.0", "@headlessui/vue": "1.7.23", "@heroicons/vue": "2.2.0", "@jsonforms/core": "3.6.0", @@ -109,6 +110,7 @@ "@jsonforms/vue-vanilla": "3.6.0", "@jsonforms/vue-vuetify": "3.6.0", "@nuxt/ui": "4.0.0-alpha.0", + "@tanstack/vue-table": "^8.21.3", "@unraid/shared-callbacks": "1.1.1", "@unraid/ui": "link:../unraid-ui", "@vue/apollo-composable": "4.2.2", diff --git a/web/postcss/scopeTailwindToUnapi.ts b/web/postcss/scopeTailwindToUnapi.ts index 21690626c5..e0a7527ec2 100644 --- a/web/postcss/scopeTailwindToUnapi.ts +++ b/web/postcss/scopeTailwindToUnapi.ts @@ -13,9 +13,24 @@ interface AtRule extends Container { params: string; } +type WalkAtRulesRoot = { + walkAtRules: (name: string, callback: (atRule: AtRule) => void) => void; +}; + +type ParentContainer = Container & { + insertBefore?: (oldNode: Container, newNode: Container) => void; + removeChild?: (node: Container) => void; +}; + +type RemovableAtRule = AtRule & { + nodes?: Container[]; + remove?: () => void; +}; + type PostcssPlugin = { postcssPlugin: string; Rule?(rule: Rule): void; + OnceExit?(root: WalkAtRulesRoot): void; }; type PluginCreator = { @@ -163,6 +178,34 @@ export const scopeTailwindToUnapi: PluginCreator = (options: Scope rule.selector = scopedSelectors.join(', '); } }, + OnceExit(root) { + root.walkAtRules('layer', (atRule: AtRule) => { + const removableAtRule = atRule as RemovableAtRule; + const parent = atRule.parent as ParentContainer | undefined; + if (!parent) { + return; + } + + if ( + Array.isArray(removableAtRule.nodes) && + removableAtRule.nodes.length > 0 && + typeof (parent as ParentContainer).insertBefore === 'function' + ) { + const parentContainer = parent as ParentContainer; + while (removableAtRule.nodes.length) { + const node = removableAtRule.nodes[0]!; + parentContainer.insertBefore?.(atRule as unknown as Container, node); + } + } + + if (typeof removableAtRule.remove === 'function') { + removableAtRule.remove(); + return; + } + + (parent as ParentContainer).removeChild?.(atRule as unknown as Container); + }); + }, }; }; diff --git a/web/public/test-pages/all-components.html b/web/public/test-pages/all-components.html new file mode 100644 index 0000000000..0e51644c5d --- /dev/null +++ b/web/public/test-pages/all-components.html @@ -0,0 +1,390 @@ + + + + + + All Components - Unraid Component Test + + + + +
+
🔔 Notifications
+
+

Critical Notifications

+ <unraid-critical-notifications> +
+ +
+
+ +
🐳 Docker
+
+
+

Docker Container Overview

+ <unraid-docker-container-overview> +
+ +
+
+
+ + +
👤 Authentication & User
+
+
+

Authentication

+ <unraid-auth> +
+ +
+
+ +
+

User Profile

+ <unraid-user-profile> +
+ +
+
+ +
+

SSO Button

+ <unraid-sso-button> +
+ +
+
+ +
+

Registration

+ <unraid-registration> +
+ +
+
+
+ + +
⚙️ System & Settings
+
+
+

Connect Settings

+ <unraid-connect-settings> +
+ +
+
+ +
+

Theme Switcher

+ <unraid-theme-switcher> +
+ +
+
+ +
+

Header OS Version

+ <unraid-header-os-version> +
+ +
+
+ +
+

WAN IP Check

+ <unraid-wan-ip-check> +
+ +
+
+
+ + +
💿 OS Management
+
+
+

Update OS

+ <unraid-update-os> +
+ +
+
+ +
+

Downgrade OS

+ <unraid-downgrade-os> +
+ +
+
+
+ + +
🔧 API & Developer
+
+
+

API Key Manager

+ <unraid-api-key-manager> +
+ +
+
+ +
+

API Key Authorize

+ <unraid-api-key-authorize> +
+ +
+
+ +
+

Download API Logs

+ <unraid-download-api-logs> +
+ +
+
+ +
+

Log Viewer

+ <unraid-log-viewer> +
+ +
+
+
+ + +
🎨 UI Components
+
+
+

Modals

+ <unraid-modals> +
+ +
+
+ +
+

Welcome Modal

+ <unraid-welcome-modal> +
+ +
+
+ +
+

Dev Modal Test

+ <unraid-dev-modal-test> +
+ +
+
+ +
+

Toaster

+ <unraid-toaster> +
+ +
+
+
+ + +
🎮 Test Controls
+
+

Language Selection

+
+ +
+ +

jQuery Interaction Tests

+
+ + + + + +
+ +
+

Console Output

+
+ > Ready for testing... +
+
+
+
+ + + + + + + + + + + + diff --git a/web/src/assets/main.css b/web/src/assets/main.css index dea0c1086f..6978e6b089 100644 --- a/web/src/assets/main.css +++ b/web/src/assets/main.css @@ -9,7 +9,7 @@ /* Import theme and utilities only - no global preflight */ @import "tailwindcss/theme.css" layer(theme); @import "tailwindcss/utilities.css" layer(utilities); -/* @import "@nuxt/ui"; temporarily disabled */ +@import "@nuxt/ui"; @import 'tw-animate-css'; @import '../../../@tailwind-shared/index.css'; @@ -37,6 +37,49 @@ background: none; } + /* Reset button-like controls back to UA defaults so Nuxt UI styles win */ + .unapi button, + .unapi button[type='button'], + .unapi button:hover, + .unapi button:hover[disabled], + .unapi input[type='button'], + .unapi input[type='button']:hover, + .unapi input[type='button']:hover[disabled], + .unapi input[type='reset'], + .unapi input[type='submit'], + .unapi a.button { + box-sizing: border-box; + font: inherit; + min-width: revert; + text-transform: revert; + background: revert; + color: revert; + } + + /* Reset text inputs so Nuxt UI field styles render correctly */ + .unapi input[type='text'], + .unapi input[type='password'], + .unapi input[type='number'], + .unapi input[type='url'], + .unapi input[type='email'], + .unapi input[type='date'], + .unapi input[type='file'], + .unapi textarea, + .unapi .textarea { + box-sizing: border-box; + font: inherit; + color: inherit; + } + + /* Clear global table row sizing that fights the tree table layout */ + .unapi table thead td, + .unapi table thead th, + .unapi table tbody td, + .unapi table tbody th { + all: revert; + box-sizing: border-box; + } + /* Accessible focus styles for keyboard navigation */ .unapi button:focus-visible { outline: 2px solid #ff8c2f; @@ -45,7 +88,7 @@ /* Restore button functionality while removing Unraid's forced styles */ .unapi button:not([role="switch"]) { - display: inline-flex; + /* display: inline-flex; */ align-items: center; justify-content: center; cursor: pointer; @@ -56,7 +99,7 @@ .unapi [role="dialog"] button, .unapi [data-radix-collection-item] button { margin: 0 !important; - background: transparent !important; + /* background: transparent !important; */ border: none !important; } @@ -102,7 +145,6 @@ .unapi button[role="switch"][data-state="unchecked"] { background-color: transparent; background: transparent; - border: 1px solid #ccc; } /* Style for checked state */ diff --git a/web/src/components/Common/BaseTreeTable.vue b/web/src/components/Common/BaseTreeTable.vue new file mode 100644 index 0000000000..b1dd55e506 --- /dev/null +++ b/web/src/components/Common/BaseTreeTable.vue @@ -0,0 +1,603 @@ + + + + + diff --git a/web/src/components/Common/MultiValueCopyBadges.vue b/web/src/components/Common/MultiValueCopyBadges.vue new file mode 100644 index 0000000000..f83cfea009 --- /dev/null +++ b/web/src/components/Common/MultiValueCopyBadges.vue @@ -0,0 +1,260 @@ + + + diff --git a/web/src/components/Common/ResizableSlideover.vue b/web/src/components/Common/ResizableSlideover.vue new file mode 100644 index 0000000000..699c16410c --- /dev/null +++ b/web/src/components/Common/ResizableSlideover.vue @@ -0,0 +1,153 @@ + + +