From 10fb93dcb1697376f8add20ee80451f9345419ab Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 2 Jul 2026 22:31:29 -0400 Subject: [PATCH] feat(scheduler): schedule container restart, stop, and start (#1526) * feat(scheduler): schedule container restart, stop, and start Add container as a scheduled-task target type so operators can automate lifecycle actions against standalone containers by node and name, with matching UI pickers, validation, execution on local and remote nodes, and tests. * fix(scheduler): stack service matching and container picker hygiene Backfill Service on smartFallback containers so per-service stack restarts work when container_name is set. Match services by compose label and container name in stack routes and scheduled restarts. Exclude Sencho from GET /api/containers lists. Hide the Restart Stack service picker when a stack has only one service. * test(scheduler): scope service checkbox assertion to Services block The create dialog also has a Delete after run checkbox. Count checkboxes only inside the Services section so CI does not include unrelated form controls. * fix(scheduler): narrow closest() result to HTMLElement in schedule test The service-checkbox assertion passed an Element from closest() into within(), which requires an HTMLElement, failing tsc -b in the frontend build and Docker build stages. Use the closest() type argument so the value type-checks without an unsafe cast. * fix(scheduler): hide Sencho container on remote node picker lists Remote container lists are proxied from peer Sencho instances, so id-only self filtering missed peers on older builds. Await SelfIdentity init, match ImageID, and drop official saelix/sencho images. Apply the same heuristic in the scheduled-operations UI and when the hub fetches remote containers for scheduled runs. * test(monitor): add missing DatabaseService mocks for scan history cleanup * test(scheduler): add missing markStaleScansAsFailed mock SchedulerService.tick() calls db.markStaleScansAsFailed() to sweep stale vulnerability scans. The scheduler-service test was missing this method in its DatabaseService mock, causing TypeError failures during test initialization. Added mockMarkStaleScansAsFailed to hoisted mocks and DatabaseService mock object, returning safe default of 0 scans marked as failed. * test(compose): add missing FileSystemService mocks for getStackContent/getEnvContent * test(containers-route): mock SelfIdentityService to prevent initialize() crash The excludeSelfContainers() helper calls SelfIdentityService.initialize(), which tries to access DockerController. Without a proper SelfIdentityService mock, the initialize() call fails silently, causing a 500 error on GET /api/containers. Added SelfIdentityService mock with initialize(), isOwnContainer(), and isOwnImage() methods to prevent the crash. --- .../__tests__/compose-service-match.test.ts | 36 +++++ backend/src/__tests__/compose-service.test.ts | 5 + .../__tests__/containers-route-authz.test.ts | 9 +- .../containers-route-self-filter.test.ts | 92 ++++++++++++ .../__tests__/exclude-self-containers.test.ts | 11 ++ backend/src/__tests__/monitor-service.test.ts | 5 + .../scheduled-action-registry.test.ts | 14 +- .../__tests__/scheduled-tasks-routes.test.ts | 58 ++++++++ .../src/__tests__/scheduler-service.test.ts | 112 ++++++++++++++- .../__tests__/stack-service-actions.test.ts | 22 +++ backend/src/helpers/composeServiceMatch.ts | 29 ++++ backend/src/helpers/excludeSelfContainers.ts | 38 +++++ backend/src/routes/containers.ts | 8 +- backend/src/routes/scheduledTasks.ts | 38 ++++- backend/src/routes/stacks.ts | 3 +- backend/src/services/DatabaseService.ts | 2 +- backend/src/services/DockerController.ts | 37 ++++- backend/src/services/SchedulerService.ts | 133 +++++++++++++++++- .../src/services/scheduledActionRegistry.ts | 14 +- backend/src/utils/validation.ts | 4 + docs/features/scheduled-operations.mdx | 12 +- docs/openapi.yaml | 18 +-- .../components/ScheduledOperationsView.tsx | 113 +++++++++++++-- .../ScheduledOperationsView.test.tsx | 84 ++++++++++- .../lib/__tests__/scheduledActions.test.ts | 23 +++ frontend/src/lib/scheduledActions.ts | 35 +++-- .../src/lib/senchoContainerFilter.test.ts | 19 +++ frontend/src/lib/senchoContainerFilter.ts | 24 ++++ frontend/src/types/scheduling.ts | 2 +- 29 files changed, 932 insertions(+), 68 deletions(-) create mode 100644 backend/src/__tests__/compose-service-match.test.ts create mode 100644 backend/src/__tests__/containers-route-self-filter.test.ts create mode 100644 backend/src/__tests__/exclude-self-containers.test.ts create mode 100644 backend/src/helpers/composeServiceMatch.ts create mode 100644 backend/src/helpers/excludeSelfContainers.ts create mode 100644 frontend/src/lib/senchoContainerFilter.test.ts create mode 100644 frontend/src/lib/senchoContainerFilter.ts diff --git a/backend/src/__tests__/compose-service-match.test.ts b/backend/src/__tests__/compose-service-match.test.ts new file mode 100644 index 00000000..9ca3e9a7 --- /dev/null +++ b/backend/src/__tests__/compose-service-match.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { + containerBelongsToComposeService, + filterContainersByComposeService, +} from '../helpers/composeServiceMatch'; + +describe('composeServiceMatch', () => { + it('matches by Service field from docker compose ps', () => { + const c = { Id: 'abc', Service: 'mariadb', Names: ['/mariadb'] }; + expect(containerBelongsToComposeService(c, 'mariadb')).toBe(true); + expect(containerBelongsToComposeService(c, 'phpmyadmin')).toBe(false); + }); + + it('matches by com.docker.compose.service label', () => { + const c = { + Id: 'abc', + Names: ['/custom-name'], + Labels: { 'com.docker.compose.service': 'mariadb' }, + }; + expect(containerBelongsToComposeService(c, 'mariadb')).toBe(true); + }); + + it('matches when container name equals service (container_name in compose)', () => { + const c = { Id: 'abc', Service: '', Names: ['/mariadb'] }; + expect(containerBelongsToComposeService(c, 'mariadb')).toBe(true); + }); + + it('filterContainersByComposeService returns all replicas', () => { + const containers = [ + { Id: '1', Service: 'app', Names: ['/web-app-1'] }, + { Id: '2', Service: 'app', Names: ['/web-app-2'] }, + { Id: '3', Names: ['/db'] }, + ]; + expect(filterContainersByComposeService(containers, 'app')).toHaveLength(2); + }); +}); diff --git a/backend/src/__tests__/compose-service.test.ts b/backend/src/__tests__/compose-service.test.ts index e5506574..aa3ff69c 100644 --- a/backend/src/__tests__/compose-service.test.ts +++ b/backend/src/__tests__/compose-service.test.ts @@ -19,6 +19,7 @@ const { mockGetComposeFilename, mockGetOverrideFilename, mockEnsureStackOverride, mockMkdtempSync, mockWriteFileSync, mockUnlinkSync, mockRmdirSync, mockGetGlobalSettings, mockPruneDanglingImages, mockGetBindMounts, + mockGetStackContent, mockGetEnvContent, } = vi.hoisted(() => ({ mockSpawn: vi.fn(), mockGetContainersByStack: vi.fn().mockResolvedValue([]), @@ -40,6 +41,8 @@ const { mockGetGlobalSettings: vi.fn().mockReturnValue({}), mockPruneDanglingImages: vi.fn().mockResolvedValue({ reclaimedBytes: 0 }), mockGetBindMounts: vi.fn().mockResolvedValue(null), + mockGetStackContent: vi.fn().mockResolvedValue(''), + mockGetEnvContent: vi.fn().mockResolvedValue(''), })); vi.mock('child_process', () => ({ spawn: mockSpawn, execFile: vi.fn() })); @@ -109,6 +112,8 @@ vi.mock('../services/FileSystemService', () => ({ restoreStackFiles: mockRestoreStackFiles, getComposeFilename: mockGetComposeFilename, getOverrideFilename: mockGetOverrideFilename, + getStackContent: mockGetStackContent, + getEnvContent: mockGetEnvContent, }), }, })); diff --git a/backend/src/__tests__/containers-route-authz.test.ts b/backend/src/__tests__/containers-route-authz.test.ts index 1dd76e46..2b98ea23 100644 --- a/backend/src/__tests__/containers-route-authz.test.ts +++ b/backend/src/__tests__/containers-route-authz.test.ts @@ -16,6 +16,7 @@ let app: import('express').Express; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; let DockerController: typeof import('../services/DockerController').default; let FileSystemService: typeof import('../services/FileSystemService').FileSystemService; +let SelfIdentityService: typeof import('../services/SelfIdentityService').default; let ROLE_PERMISSIONS: typeof import('../middleware/permissions').ROLE_PERMISSIONS; const VIEWER = 'container-read-viewer'; @@ -28,7 +29,7 @@ function viewerToken(): string { } /** - * Replace the DockerController / FileSystemService singletons with stubs so the + * Replace the DockerController / FileSystemService / SelfIdentityService singletons with stubs so the * handlers run without a Docker daemon. The logs stub ends the response itself * (the real streamContainerLogs flushes SSE headers and streams), so a request * that clears the guard resolves instead of hanging. Returns the spies so a test @@ -46,6 +47,11 @@ function stubDockerAndFs(): { docker: ReturnType; fs: ReturnTyp const fs = vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({ getStacks: vi.fn().mockResolvedValue([]), } as unknown as ReturnType); + vi.spyOn(SelfIdentityService, 'getInstance').mockReturnValue({ + initialize: vi.fn().mockResolvedValue(undefined), + isOwnContainer: vi.fn().mockReturnValue(false), + isOwnImage: vi.fn().mockReturnValue(false), + } as unknown as ReturnType); return { docker, fs }; } @@ -54,6 +60,7 @@ beforeAll(async () => { ({ DatabaseService } = await import('../services/DatabaseService')); ({ default: DockerController } = await import('../services/DockerController')); ({ FileSystemService } = await import('../services/FileSystemService')); + ({ default: SelfIdentityService } = await import('../services/SelfIdentityService')); ({ ROLE_PERMISSIONS } = await import('../middleware/permissions')); ({ app } = await import('../index')); diff --git a/backend/src/__tests__/containers-route-self-filter.test.ts b/backend/src/__tests__/containers-route-self-filter.test.ts new file mode 100644 index 00000000..611d67ce --- /dev/null +++ b/backend/src/__tests__/containers-route-self-filter.test.ts @@ -0,0 +1,92 @@ +/** + * GET /api/containers must omit Sencho's own container from picker lists. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let DockerController: typeof import('../services/DockerController').default; +let SelfIdentityServiceMod: typeof import('../services/SelfIdentityService').default; + +const VIEWER = 'container-self-filter-viewer'; + +function viewerToken(): string { + const user = DatabaseService.getInstance().getUserByUsername(VIEWER)!; + return jwt.sign({ username: VIEWER, role: 'viewer', tv: user.token_version }, TEST_JWT_SECRET, { expiresIn: '1m' }); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ default: DockerController } = await import('../services/DockerController')); + ({ default: SelfIdentityServiceMod } = await import('../services/SelfIdentityService')); + ({ app } = await import('../index')); + + const hash = await bcrypt.hash('password123', 1); + DatabaseService.getInstance().addUser({ username: VIEWER, password_hash: hash, role: 'viewer' }); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('GET /api/containers self-filter', () => { + beforeEach(() => { + vi.spyOn(SelfIdentityServiceMod, 'getInstance').mockReturnValue({ + initialize: vi.fn().mockResolvedValue(undefined), + isOwnContainer: (idOrName: string) => idOrName === 'sencho' || idOrName.startsWith('sencho-id'), + isOwnImage: vi.fn().mockReturnValue(false), + } as unknown as ReturnType); + + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getAllContainers: vi.fn().mockResolvedValue([ + { Id: 'sencho-id-full', Names: ['/sencho'], State: 'running', Status: 'Up 1 day' }, + { Id: 'other-id', Names: ['/mariadb'], State: 'running', Status: 'Up 1 day' }, + ]), + getRunningContainers: vi.fn().mockResolvedValue([]), + } as unknown as ReturnType); + }); + + it('excludes Sencho from all=true container list', async () => { + const res = await request(app) + .get('/api/containers?all=true') + .set('Authorization', `Bearer ${viewerToken()}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].Names).toEqual(['/mariadb']); + }); + + it('excludes official Sencho images when SelfIdentity does not match by id', async () => { + vi.spyOn(SelfIdentityServiceMod, 'getInstance').mockReturnValue({ + initialize: vi.fn().mockResolvedValue(undefined), + isOwnContainer: vi.fn().mockReturnValue(false), + isOwnImage: vi.fn().mockReturnValue(false), + } as unknown as ReturnType); + + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getAllContainers: vi.fn().mockResolvedValue([ + { Id: 'remote-sencho', Names: ['/sencho'], Image: 'saelix/sencho:latest', State: 'running' }, + { Id: 'other-id', Names: ['/mariadb'], State: 'running' }, + ]), + getRunningContainers: vi.fn().mockResolvedValue([]), + } as unknown as ReturnType); + + const res = await request(app) + .get('/api/containers?all=true') + .set('Authorization', `Bearer ${viewerToken()}`); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].Names).toEqual(['/mariadb']); + }); +}); diff --git a/backend/src/__tests__/exclude-self-containers.test.ts b/backend/src/__tests__/exclude-self-containers.test.ts new file mode 100644 index 00000000..a762caa5 --- /dev/null +++ b/backend/src/__tests__/exclude-self-containers.test.ts @@ -0,0 +1,11 @@ +import { describe, it, expect } from 'vitest'; +import { isPublishedSenchoImage } from '../helpers/excludeSelfContainers'; + +describe('isPublishedSenchoImage', () => { + it('matches Docker Hub and GHCR release paths', () => { + expect(isPublishedSenchoImage('saelix/sencho:latest')).toBe(true); + expect(isPublishedSenchoImage('ghcr.io/studio-saelix/sencho:0.93.1')).toBe(true); + expect(isPublishedSenchoImage('ghcr.io/studio-saelix/sencho-dev:dev')).toBe(true); + expect(isPublishedSenchoImage('lscr.io/linuxserver/mariadb:latest')).toBe(false); + }); +}); diff --git a/backend/src/__tests__/monitor-service.test.ts b/backend/src/__tests__/monitor-service.test.ts index 86e3081d..f8a0bcac 100644 --- a/backend/src/__tests__/monitor-service.test.ts +++ b/backend/src/__tests__/monitor-service.test.ts @@ -10,6 +10,7 @@ import { installArcstatsFsMock, arcstatsBody, DEFAULT_ARC_PATH, type ArcstatsFsM const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContainerMetric, mockCleanupOldMetrics, mockCleanupOldNotifications, mockCleanupOldAuditLogs, mockUpdateStackAlertLastFired, mockGetSystemState, mockSetSystemState, + mockPruneScanHistoryPerImage, mockDeleteScansByImageRef, mockGetRunningContainers, mockGetAllContainers, mockGetContainerStatsStream, mockGetContainerRestartCount, mockGetDiskUsage, mockGetImages, mockGetStacks, mockDispatchAlert, @@ -30,6 +31,8 @@ const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContaine mockUpdateStackAlertLastFired: vi.fn(), mockGetSystemState: vi.fn().mockReturnValue(null), mockSetSystemState: vi.fn(), + mockPruneScanHistoryPerImage: vi.fn().mockReturnValue(0), + mockDeleteScansByImageRef: vi.fn().mockReturnValue(0), mockGetRunningContainers: vi.fn().mockResolvedValue([]), mockGetAllContainers: vi.fn().mockResolvedValue([]), mockGetContainerStatsStream: vi.fn().mockResolvedValue('{}'), @@ -64,6 +67,8 @@ vi.mock('../services/DatabaseService', () => ({ updateStackAlertLastFired: mockUpdateStackAlertLastFired, getSystemState: mockGetSystemState, setSystemState: mockSetSystemState, + pruneScanHistoryPerImage: mockPruneScanHistoryPerImage, + deleteScansByImageRef: mockDeleteScansByImageRef, }), }, })); diff --git a/backend/src/__tests__/scheduled-action-registry.test.ts b/backend/src/__tests__/scheduled-action-registry.test.ts index 6ea16769..e56df52f 100644 --- a/backend/src/__tests__/scheduled-action-registry.test.ts +++ b/backend/src/__tests__/scheduled-action-registry.test.ts @@ -19,7 +19,7 @@ const EXPECTED_ACTIONS: BackendScheduledAction[] = [ 'auto_backup', 'auto_stop', 'auto_down', 'auto_start', ]; -const ALL_TARGET_TYPES: TargetType[] = ['stack', 'fleet', 'system']; +const ALL_TARGET_TYPES: TargetType[] = ['stack', 'fleet', 'system', 'container']; describe('scheduledActionRegistry', () => { it('exposes exactly the known backend actions, in order', () => { @@ -46,27 +46,27 @@ describe('scheduledActionRegistry', () => { describe('validateActionTarget', () => { const validPairs: Record = { - restart: ['stack'], + restart: ['stack', 'container'], snapshot: ['fleet'], prune: ['system'], update: ['stack', 'fleet'], scan: ['system'], auto_backup: ['stack'], - auto_stop: ['stack'], + auto_stop: ['stack', 'container'], auto_down: ['stack'], - auto_start: ['stack'], + auto_start: ['stack', 'container'], }; const mismatchMessage: Record = { - restart: 'Restart action requires target_type "stack".', + restart: 'Restart action requires target_type "stack" or "container".', snapshot: 'Snapshot action requires target_type "fleet".', prune: 'Prune action requires target_type "system".', update: 'Update action requires target_type "stack" or "fleet".', scan: 'Scan action requires target_type "system".', auto_backup: 'auto_backup action requires target_type "stack".', - auto_stop: 'auto_stop action requires target_type "stack".', + auto_stop: 'auto_stop action requires target_type "stack" or "container".', auto_down: 'auto_down action requires target_type "stack".', - auto_start: 'auto_start action requires target_type "stack".', + auto_start: 'auto_start action requires target_type "stack" or "container".', }; for (const action of EXPECTED_ACTIONS) { diff --git a/backend/src/__tests__/scheduled-tasks-routes.test.ts b/backend/src/__tests__/scheduled-tasks-routes.test.ts index 444e5fa9..39d0271b 100644 --- a/backend/src/__tests__/scheduled-tasks-routes.test.ts +++ b/backend/src/__tests__/scheduled-tasks-routes.test.ts @@ -594,6 +594,64 @@ describe('POST /api/scheduled-tasks - new lifecycle actions', () => { }); }); +describe('POST /api/scheduled-tasks - container lifecycle', () => { + it('creates a container restart schedule', async () => { + const res = await request(app) + .post('/api/scheduled-tasks') + .set('Cookie', adminCookie) + .send({ + name: 'daily-watchtower-restart', + target_type: 'container', + target_id: 'watchtower', + node_id: 1, + action: 'restart', + cron_expression: '0 3 * * *', + enabled: true, + }); + expect(res.status).toBe(201); + expect(res.body.target_type).toBe('container'); + expect(res.body.target_id).toBe('watchtower'); + expect(res.body.action).toBe('restart'); + }); + + it('rejects invalid container names', async () => { + const res = await request(app) + .post('/api/scheduled-tasks') + .set('Cookie', adminCookie) + .send({ + name: 'bad-container', + target_type: 'container', + target_id: '../escape', + node_id: 1, + action: 'restart', + cron_expression: '0 3 * * *', + enabled: true, + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/valid container name/); + }); + + for (const action of ['auto_stop', 'auto_start'] as const) { + it(`creates container ${action} schedule`, async () => { + const res = await request(app) + .post('/api/scheduled-tasks') + .set('Cookie', adminCookie) + .send({ + name: `ctr-${action}`, + target_type: 'container', + target_id: 'sidecar', + node_id: 1, + action, + cron_expression: '0 4 * * *', + enabled: true, + }); + expect(res.status).toBe(201); + expect(res.body.target_type).toBe('container'); + expect(res.body.action).toBe(action); + }); + } +}); + describe('POST /api/scheduled-tasks - available on the Community tier', () => { beforeEach(() => { tierSpy.mockReturnValue('community'); diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index c482d4de..5d4cd312 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -12,9 +12,10 @@ const { mockUpdateScheduledTask, mockCleanupOldTaskRuns, mockGetScheduledTask, mockGetNodes, mockGetNode, mockGetGlobalSettings, mockGetStackDossier, mockCreateSnapshot, mockInsertSnapshotFiles, mockClearStackUpdateStatus, - mockMarkStaleRunsAsFailed, mockDeleteOldScans, + mockMarkStaleRunsAsFailed, mockMarkStaleScansAsFailed, mockDeleteOldScans, mockGetTier, mockGetProxyHeaders, - mockGetContainersByStack, mockRestartContainer, mockPruneSystem, + mockGetContainersByStack, mockRestartContainer, mockFindContainerByName, + mockStartContainer, mockStopContainer, mockPruneSystem, mockUpdateStack, mockGetStacks, mockGetStackContent, mockGetEnvContent, mockCheckImage, @@ -43,11 +44,15 @@ const { mockInsertSnapshotFiles: vi.fn(), mockClearStackUpdateStatus: vi.fn(), mockMarkStaleRunsAsFailed: vi.fn().mockReturnValue(0), + mockMarkStaleScansAsFailed: vi.fn().mockReturnValue(0), mockDeleteOldScans: vi.fn().mockReturnValue(0), mockGetTier: vi.fn().mockReturnValue('paid'), mockGetProxyHeaders: vi.fn().mockReturnValue({ tier: 'paid' }), mockGetContainersByStack: vi.fn().mockResolvedValue([]), mockRestartContainer: vi.fn().mockResolvedValue(undefined), + mockFindContainerByName: vi.fn().mockResolvedValue(null), + mockStartContainer: vi.fn().mockResolvedValue(undefined), + mockStopContainer: vi.fn().mockResolvedValue(undefined), mockPruneSystem: vi.fn().mockResolvedValue({ success: true, reclaimedBytes: 0 }), mockUpdateStack: vi.fn().mockResolvedValue(undefined), mockGetStacks: vi.fn().mockResolvedValue([]), @@ -89,6 +94,7 @@ vi.mock('../services/DatabaseService', () => ({ insertSnapshotFiles: mockInsertSnapshotFiles, clearStackUpdateStatus: mockClearStackUpdateStatus, markStaleRunsAsFailed: mockMarkStaleRunsAsFailed, + markStaleScansAsFailed: mockMarkStaleScansAsFailed, deleteOldScans: mockDeleteOldScans, deleteScheduledTask: mockDeleteScheduledTask, getMatchingPolicy: mockGetMatchingPolicy, @@ -117,6 +123,9 @@ vi.mock('../services/DockerController', () => ({ getInstance: () => ({ getContainersByStack: mockGetContainersByStack, restartContainer: mockRestartContainer, + findContainerByName: mockFindContainerByName, + startContainer: mockStartContainer, + stopContainer: mockStopContainer, pruneSystem: mockPruneSystem, }), }, @@ -530,6 +539,105 @@ describe('SchedulerService - executeRestart', () => { expect.objectContaining({ status: 'failure', error: expect.stringContaining('No containers') }) ); }); + + it('restarts a standalone container by name', async () => { + mockGetScheduledTask.mockReturnValue({ + id: 63, + name: 'restart-ctr', + action: 'restart', + target_type: 'container', + cron_expression: '0 3 * * *', + enabled: true, + target_id: 'watchtower', + node_id: 1, + created_by: 'admin', + last_status: null, + }); + mockFindContainerByName.mockResolvedValue({ + id: 'abc123deadbeef', + name: 'watchtower', + state: 'running', + image: 'containrrr/watchtower', + stackProject: null, + }); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(63); + + expect(mockFindContainerByName).toHaveBeenCalledWith('watchtower'); + expect(mockRestartContainer).toHaveBeenCalledWith('abc123deadbeef'); + expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith( + expect.any(Number), + expect.objectContaining({ + status: 'success', + output: expect.stringContaining('watchtower'), + }), + ); + }); + + it('records failure when container name is missing', async () => { + mockGetScheduledTask.mockReturnValue({ + id: 64, + name: 'restart-missing', + action: 'restart', + target_type: 'container', + cron_expression: '0 3 * * *', + enabled: true, + target_id: 'gone-container', + node_id: 1, + created_by: 'admin', + last_status: null, + }); + mockFindContainerByName.mockResolvedValue(null); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(64); + + expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith( + expect.any(Number), + expect.objectContaining({ + status: 'failure', + error: expect.stringContaining('gone-container'), + }), + ); + }); + + it('resolves a new container ID when the name matches after recreation', async () => { + mockGetScheduledTask.mockReturnValue({ + id: 65, + name: 'restart-recreated', + action: 'restart', + target_type: 'container', + cron_expression: '0 3 * * *', + enabled: true, + target_id: 'sidecar', + node_id: 1, + created_by: 'admin', + last_status: null, + }); + mockFindContainerByName + .mockResolvedValueOnce({ + id: 'old-id-111', + name: 'sidecar', + state: 'running', + image: 'sidecar:latest', + stackProject: null, + }) + .mockResolvedValueOnce({ + id: 'new-id-222', + name: 'sidecar', + state: 'running', + image: 'sidecar:latest', + stackProject: null, + }); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(65); + await svc.triggerTask(65); + + expect(mockRestartContainer).toHaveBeenNthCalledWith(1, 'old-id-111'); + expect(mockRestartContainer).toHaveBeenNthCalledWith(2, 'new-id-222'); + }); }); // ── executePrune ─────────────────────────────────────────────────────── diff --git a/backend/src/__tests__/stack-service-actions.test.ts b/backend/src/__tests__/stack-service-actions.test.ts index 0bbd3fca..93c79415 100644 --- a/backend/src/__tests__/stack-service-actions.test.ts +++ b/backend/src/__tests__/stack-service-actions.test.ts @@ -140,6 +140,28 @@ describe('POST /api/stacks/:stackName/services/:serviceName/restart', () => { expect(mockRestartContainer).toHaveBeenCalledWith('container-app-1'); expect(mockRestartContainer).not.toHaveBeenCalledWith('container-db-1'); }); + + it('matches smartFallback containers when Service is empty but container name equals service', async () => { + mockGetContainersByStack.mockResolvedValue([ + { + Id: 'container-mariadb-1', + Service: '', + Names: ['/mariadb'], + State: 'running', + Status: 'Up 12 days', + Ports: [], + }, + makeContainer('container-phpmyadmin-1', 'phpmyadmin'), + ]); + + const res = await request(app) + .post('/api/stacks/db-compose/services/mariadb/restart') + .set('Cookie', authCookie); + + expect(res.status).toBe(200); + expect(res.body.count).toBe(1); + expect(mockRestartContainer).toHaveBeenCalledWith('container-mariadb-1'); + }); }); describe('POST /api/stacks/:stackName/services/:serviceName/stop', () => { diff --git a/backend/src/helpers/composeServiceMatch.ts b/backend/src/helpers/composeServiceMatch.ts new file mode 100644 index 00000000..c929667c --- /dev/null +++ b/backend/src/helpers/composeServiceMatch.ts @@ -0,0 +1,29 @@ +/** + * Match stack containers to a compose service name. `docker compose ps` sets + * `Service`, but smartFallback containers only had `Names` until Service was + * backfilled; these matchers also accept the compose service label and a + * container name equal to the service (common with `container_name:`). + */ +export interface ComposeServiceContainer { + Id: string; + Service?: string; + Names?: string[]; + Labels?: Record; +} + +export function containerBelongsToComposeService( + container: ComposeServiceContainer, + serviceName: string, +): boolean { + if (container.Service === serviceName) return true; + if (container.Labels?.['com.docker.compose.service'] === serviceName) return true; + const containerName = container.Names?.[0]?.replace(/^\//, ''); + return containerName === serviceName; +} + +export function filterContainersByComposeService( + containers: T[], + serviceName: string, +): T[] { + return containers.filter(c => containerBelongsToComposeService(c, serviceName)); +} diff --git a/backend/src/helpers/excludeSelfContainers.ts b/backend/src/helpers/excludeSelfContainers.ts new file mode 100644 index 00000000..5e1fdd68 --- /dev/null +++ b/backend/src/helpers/excludeSelfContainers.ts @@ -0,0 +1,38 @@ +import SelfIdentityService from '../services/SelfIdentityService'; + +export interface DockerContainerListRow { + Id: string; + Names?: string[]; + Image?: string; + ImageID?: string; + Labels?: Record; +} + +/** Official Sencho release images (Docker Hub + GHCR). Used when SelfIdentity is unavailable on a peer. */ +export function isPublishedSenchoImage(image: string): boolean { + const lower = image.toLowerCase(); + return /(?:^|\/)saelix\/sencho(?:-dev)?(?:[:@]|$)/.test(lower) + || /studio-saelix\/sencho(?:-dev)?(?:[:@]|$)/.test(lower); +} + +function isLikelySenchoManagementContainer(c: DockerContainerListRow): boolean { + const name = c.Names?.[0]?.replace(/^\//, '').toLowerCase() ?? ''; + if (name === 'sencho' || name === 'sencho-agent') return true; + if (c.Image && isPublishedSenchoImage(c.Image)) return true; + return false; +} + +/** Drop the running Sencho instance from container picker lists. */ +export async function excludeSelfContainers(containers: T[]): Promise { + const self = SelfIdentityService.getInstance(); + await self.initialize(); + + return containers.filter(c => { + const name = c.Names?.[0]?.replace(/^\//, '') ?? ''; + if (self.isOwnContainer(c.Id)) return false; + if (name && self.isOwnContainer(name)) return false; + if (c.ImageID && self.isOwnImage(c.ImageID)) return false; + if (isLikelySenchoManagementContainer(c)) return false; + return true; + }); +} diff --git a/backend/src/routes/containers.ts b/backend/src/routes/containers.ts index dea81cb9..b91ba7bf 100644 --- a/backend/src/routes/containers.ts +++ b/backend/src/routes/containers.ts @@ -1,6 +1,7 @@ import { Router, type Request, type Response } from 'express'; import DockerController from '../services/DockerController'; import { FileSystemService } from '../services/FileSystemService'; +import { excludeSelfContainers } from '../helpers/excludeSelfContainers'; import { requireAdmin } from '../middleware/tierGates'; import { requirePermission } from '../middleware/permissions'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; @@ -11,8 +12,11 @@ containersRouter.get('/', async (req: Request, res: Response) => { if (!requirePermission(req, res, 'stack:read')) return; try { const dockerController = DockerController.getInstance(req.nodeId); - const containers = await dockerController.getRunningContainers(); - res.json(containers); + const all = req.query.all === 'true' || req.query.all === '1'; + const containers = all + ? await dockerController.getAllContainers() + : await dockerController.getRunningContainers(); + res.json(await excludeSelfContainers(containers)); } catch (error) { res.status(500).json({ error: 'Failed to fetch containers' }); } diff --git a/backend/src/routes/scheduledTasks.ts b/backend/src/routes/scheduledTasks.ts index 0296c5c5..f621e9a3 100644 --- a/backend/src/routes/scheduledTasks.ts +++ b/backend/src/routes/scheduledTasks.ts @@ -17,7 +17,7 @@ import { escapeCsvField } from '../utils/csv'; import { getErrorMessage } from '../utils/errors'; import { parseIntParam } from '../utils/parseIntParam'; import { sanitizeForLog } from '../utils/safeLog'; -import { isValidStackName } from '../utils/validation'; +import { isValidStackName, isValidContainerName } from '../utils/validation'; // Frontend listeners filter on scope === 'scheduled-tasks'. Wrapped so a // broken subscriber socket cannot turn a successful mutation into a 500. @@ -78,12 +78,30 @@ function validateStackTarget(targetType: TargetType, targetId: unknown, nodeId: return null; } +function validateContainerTarget(targetType: TargetType, targetId: unknown, nodeId: unknown): string | null { + if (targetType !== 'container') return null; + + if (typeof targetId !== 'string' || !targetId.trim() || nodeId === null || nodeId === undefined) { + return 'Container operations require target_id and node_id.'; + } + + if (targetId !== targetId.trim() || !isValidContainerName(targetId)) { + return 'Container target_id must be a valid container name.'; + } + + if (parsePositiveNodeId(nodeId) === null) { + return 'Container operations require a valid node_id.'; + } + + return null; +} + /** * Shared guard for non-stack actions that require a node. Stack actions use * validateStackTarget because they also require target_id. */ function validateActionNode(action: BackendScheduledAction, targetType: TargetType, nodeId: unknown): string | null { - if (targetType === 'stack') return null; + if (targetType === 'stack' || targetType === 'container') return null; const def = getScheduledActionDefinition(action); if (!def?.requiresNode) return null; @@ -226,7 +244,7 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { res.status(400).json({ error: 'Name is required' }); return; } if (!(VALID_TARGET_TYPES as readonly string[]).includes(target_type)) { - res.status(400).json({ error: 'Invalid target_type. Must be stack, fleet, or system.' }); return; + res.status(400).json({ error: 'Invalid target_type. Must be stack, fleet, system, or container.' }); return; } if (!(VALID_ACTIONS as readonly string[]).includes(action)) { res.status(400).json({ error: INVALID_ACTION_MESSAGE }); return; @@ -239,6 +257,8 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { if (nodeErr) { res.status(400).json({ error: nodeErr }); return; } const stackTargetErr = validateStackTarget(target_type, target_id, node_id); if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; } + const containerTargetErr = validateContainerTarget(target_type, target_id, node_id); + if (containerTargetErr) { res.status(400).json({ error: containerTargetErr }); return; } const optionalErr = validateOptionalFields(action, target_type, prune_targets, target_services, prune_label_filter); if (optionalErr) { res.status(400).json({ error: optionalErr }); return; } @@ -260,7 +280,8 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { const nextRun = (enabled === false) ? null : (pinnedRunAt ?? scheduler.calculateNextRun(cron_expression)); - const normalizedTargetId = target_type === 'stack' ? target_id : null; + const normalizedTargetId = + target_type === 'stack' || target_type === 'container' ? target_id : null; const normalizedNodeId = actionRequiresNode(action) ? parsePositiveNodeId(node_id) : null; const id = DatabaseService.getInstance().createScheduledTask({ @@ -330,7 +351,7 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { const finalAction = (action ?? existing.action) as BackendScheduledAction; const finalTargetType = (target_type ?? existing.target_type) as TargetType; - const finalTargetId = finalTargetType === 'stack' + const finalTargetId = finalTargetType === 'stack' || finalTargetType === 'container' ? (target_id !== undefined ? target_id : existing.target_id) : null; const finalNodeId = actionRequiresNode(finalAction) @@ -345,6 +366,9 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { const stackTargetErr = validateStackTarget(finalTargetType, finalTargetId, finalNodeId); if (stackTargetErr) { res.status(400).json({ error: stackTargetErr }); return; } + const containerTargetErr = validateContainerTarget(finalTargetType, finalTargetId, finalNodeId); + if (containerTargetErr) { res.status(400).json({ error: containerTargetErr }); return; } + const optionalErr = validateOptionalFields(finalAction, finalTargetType, prune_targets, target_services, prune_label_filter); if (optionalErr) { res.status(400).json({ error: optionalErr }); return; } @@ -364,7 +388,9 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { updates.name = name.trim(); } if (target_type !== undefined) updates.target_type = finalTargetType; - if (target_id !== undefined || finalTargetType !== 'stack') updates.target_id = finalTargetId || null; + if (target_id !== undefined || (finalTargetType !== 'stack' && finalTargetType !== 'container')) { + updates.target_id = finalTargetId || null; + } if (node_id !== undefined || !actionRequiresNode(finalAction)) { updates.node_id = finalNodeId != null ? parsePositiveNodeId(finalNodeId) : null; } diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index b853070f..6a3c36ad 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -44,6 +44,7 @@ import { sanitizeForLog } from '../utils/safeLog'; import { sendGitSourceError } from '../utils/gitSourceHttp'; import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan, describePolicyBlock } from '../helpers/policyGate'; import { parseComposePreview, type ComposePreview } from '../helpers/composePreview'; +import { filterContainersByComposeService } from '../helpers/composeServiceMatch'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; import { parseComposeSelection, defaultEnvPath } from '../helpers/gitSourceSelection'; import { resolveStackEnvSources, discoverStackLocalEnvFiles } from '../helpers/envFileResolution'; @@ -1646,7 +1647,7 @@ async function handleServiceAction( res.status(404).json({ error: 'No containers found for this stack.' }); return; } - const matching = all.filter(c => c.Service === serviceName); + const matching = filterContainersByComposeService(all, serviceName); if (matching.length === 0) { res.status(404).json({ error: `Service '${serviceName}' not found in stack '${stackName}'.` }); return; diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index b1443e82..b09d810c 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -537,7 +537,7 @@ export interface ApiToken { export interface ScheduledTask { id: number; name: string; - target_type: 'stack' | 'fleet' | 'system'; + target_type: 'stack' | 'fleet' | 'system' | 'container'; target_id: string | null; node_id: number | null; action: BackendScheduledAction; diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index a4c90b40..f3c44f56 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -863,6 +863,31 @@ class DockerController { return this.validateApiData(containers); } + /** Resolve a container by its durable name (not ephemeral ID). */ + public async findContainerByName(name: string): Promise<{ + id: string; + name: string; + state: string; + image: string; + stackProject: string | null; + } | null> { + const normalized = name.replace(/^\//, ''); + const containers = await this.getAllContainers(); + for (const c of containers) { + const containerName = c.Names?.[0]?.replace(/^\//, ''); + if (containerName === normalized) { + return { + id: c.Id, + name: containerName, + state: c.State ?? 'unknown', + image: c.Image ?? '', + stackProject: c.Labels?.['com.docker.compose.project'] ?? null, + }; + } + } + return null; + } + /** * Builds topology data with 2 Docker API calls instead of N+1. * Fetches all networks + all containers in parallel, then maps @@ -1488,10 +1513,13 @@ class DockerController { // 2. Extract expected container names with legacy prefix support const expectedNames: string[] = []; + const nameToService = new Map(); for (const [serviceName, serviceConfig] of Object.entries(parsedYaml.services)) { - const config = serviceConfig as any; + const config = serviceConfig as { container_name?: string }; + nameToService.set(serviceName, serviceName); if (config.container_name) { expectedNames.push(config.container_name); + nameToService.set(config.container_name, serviceName); } else { // Standard v2 naming expectedNames.push(serviceName); @@ -1516,6 +1544,11 @@ class DockerController { // 5. Map to the frontend interface return fallbackContainers.map(c => { + const strippedName = c.Names?.[0]?.replace(/^\//, '') ?? ''; + const labelService = c.Labels?.['com.docker.compose.service']; + const service = (typeof labelService === 'string' && labelService.length > 0 + ? labelService + : nameToService.get(strippedName)) ?? ''; let Ports: { PrivatePort: number, PublicPort: number, Type?: string }[] = []; if (c.Ports && Array.isArray(c.Ports)) { Ports = c.Ports @@ -1525,8 +1558,10 @@ class DockerController { return { Id: c.Id, Names: c.Names, + Service: service, State: c.State, Status: c.Status, + Labels: c.Labels, Ports }; }); diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 832521a9..8db8ee54 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -21,6 +21,8 @@ import type { ScanAllNodeImagesResult } from './TrivyService'; import TrivyInstaller from './TrivyInstaller'; import { CloudBackupService } from './CloudBackupService'; import { buildSystemPolicyGateOptions } from '../helpers/policyGate'; +import { filterContainersByComposeService } from '../helpers/composeServiceMatch'; +import { excludeSelfContainers } from '../helpers/excludeSelfContainers'; import { enforcePolicyPreDeploy } from './PolicyEnforcement'; import { summarizeBlockReasons } from '../utils/policy-risk'; @@ -424,6 +426,9 @@ export class SchedulerService { } private async executeRestart(task: ScheduledTask): Promise { + if (task.target_type === 'container') { + return this.executeContainerRestart(task); + } if (!task.target_id || task.node_id == null) { throw new Error('Stack restart requires target_id and node_id'); } @@ -439,7 +444,7 @@ export class SchedulerService { let filtered = containers; if (task.target_services) { const serviceNames: string[] = JSON.parse(task.target_services); - filtered = containers.filter(c => c.Service && serviceNames.includes(c.Service)); + filtered = serviceNames.flatMap(svc => filterContainersByComposeService(containers, svc)); if (filtered.length === 0) { throw new Error(`No containers found matching services [${serviceNames.join(', ')}] in stack "${task.target_id}"`); } @@ -507,6 +512,9 @@ export class SchedulerService { } private async executeAutoStop(task: ScheduledTask): Promise { + if (task.target_type === 'container') { + return this.executeContainerStop(task); + } this.assertStackTarget(task, 'Auto-stop'); if (this.isRemoteNode(task.node_id)) { await this.postToRemoteStack(task.node_id, `${encodeURIComponent(task.target_id)}/stop`); @@ -537,6 +545,9 @@ export class SchedulerService { } private async executeAutoStart(task: ScheduledTask): Promise { + if (task.target_type === 'container') { + return this.executeContainerStart(task); + } this.assertStackTarget(task, 'Auto-start'); // Remote auto-start proxies to the remote's own deploy route, which runs // that node's scan-policy gate against the images it actually holds. The @@ -802,6 +813,126 @@ export class SchedulerService { * `routeSuffix` is the path under `/api/stacks/`; the caller URL-encodes each * segment. */ + private containerNotFoundMessage(containerName: string, nodeId: number): string { + const nodeName = NodeRegistry.getInstance().getNode(nodeId)?.name ?? String(nodeId); + return `Container "${containerName}" not found on node "${nodeName}". It may have been renamed or removed.`; + } + + private async resolveContainerId(task: ScheduledTask): Promise<{ id: string; name: string }> { + if (!task.target_id || task.node_id == null) { + throw new Error('Container operations require target_id and node_id'); + } + const name = task.target_id; + if (this.isRemoteNode(task.node_id)) { + const containers = await this.getRemoteContainers(task.node_id); + const match = containers.find( + c => c.Names?.[0]?.replace(/^\//, '') === name, + ); + if (!match) throw new Error(this.containerNotFoundMessage(name, task.node_id)); + return { id: match.Id, name }; + } + const found = await DockerController.getInstance(task.node_id).findContainerByName(name); + if (!found) throw new Error(this.containerNotFoundMessage(name, task.node_id)); + return { id: found.id, name: found.name }; + } + + private async executeContainerRestart(task: ScheduledTask): Promise { + const { id, name } = await this.resolveContainerId(task); + const nodeId = task.node_id!; + if (this.isRemoteNode(nodeId)) { + await this.postToRemoteContainer(nodeId, id, 'restart'); + } else { + await DockerController.getInstance(nodeId).restartContainer(id); + } + const nodeName = NodeRegistry.getInstance().getNode(nodeId)?.name ?? String(nodeId); + return `Restarted container "${name}" (id ${id.slice(0, 12)}) on node "${nodeName}"`; + } + + private async executeContainerStop(task: ScheduledTask): Promise { + const { id, name } = await this.resolveContainerId(task); + const nodeId = task.node_id!; + if (this.isRemoteNode(nodeId)) { + await this.postToRemoteContainer(nodeId, id, 'stop'); + } else { + await DockerController.getInstance(nodeId).stopContainer(id); + } + const nodeName = NodeRegistry.getInstance().getNode(nodeId)?.name ?? String(nodeId); + return `Stopped container "${name}" (id ${id.slice(0, 12)}) on node "${nodeName}"`; + } + + private async executeContainerStart(task: ScheduledTask): Promise { + const { id, name } = await this.resolveContainerId(task); + const nodeId = task.node_id!; + if (this.isRemoteNode(nodeId)) { + await this.postToRemoteContainer(nodeId, id, 'start'); + } else { + await DockerController.getInstance(nodeId).startContainer(id); + } + const nodeName = NodeRegistry.getInstance().getNode(nodeId)?.name ?? String(nodeId); + return `Started container "${name}" (id ${id.slice(0, 12)}) on node "${nodeName}"`; + } + + private async getRemoteContainers(nodeId: number): Promise> { + const proxyTarget = NodeRegistry.getInstance().getProxyTarget(nodeId); + if (!proxyTarget) { + throw new Error('Remote node is not configured or missing API credentials'); + } + const baseUrl = proxyTarget.apiUrl.replace(/\/$/, ''); + const proxyHeaders = LicenseService.getInstance().getProxyHeaders(); + const response = await fetch(`${baseUrl}/api/containers?all=true`, { + headers: { + 'Authorization': `Bearer ${proxyTarget.apiToken}`, + [PROXY_TIER_HEADER]: proxyHeaders.tier, + }, + signal: AbortSignal.timeout(60_000), + }); + if (!response.ok) { + const body = await response.json().catch(() => ({ error: `HTTP ${response.status}` })); + throw new Error((body as { error?: string }).error || `Remote node returned ${response.status}`); + } + return excludeSelfContainers(await response.json() as Array<{ + Id: string; + Names?: string[]; + State?: string; + Image?: string; + ImageID?: string; + }>); + } + + private async postToRemoteContainer( + nodeId: number, + containerId: string, + action: 'start' | 'stop' | 'restart', + ): Promise { + const proxyTarget = NodeRegistry.getInstance().getProxyTarget(nodeId); + if (!proxyTarget) { + throw new Error('Remote node is not configured or missing API credentials'); + } + const baseUrl = proxyTarget.apiUrl.replace(/\/$/, ''); + const proxyHeaders = LicenseService.getInstance().getProxyHeaders(); + const response = await fetch( + `${baseUrl}/api/containers/${encodeURIComponent(containerId)}/${action}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${proxyTarget.apiToken}`, + [PROXY_TIER_HEADER]: proxyHeaders.tier, + }, + signal: AbortSignal.timeout(300_000), + }, + ); + if (!response.ok) { + const body = await response.json().catch(() => ({ error: `HTTP ${response.status}` })); + throw new Error((body as { error?: string }).error || `Remote node returned ${response.status}`); + } + } + private async postToRemoteStack(nodeId: number, routeSuffix: string): Promise { const proxyTarget = NodeRegistry.getInstance().getProxyTarget(nodeId); if (!proxyTarget) { diff --git a/backend/src/services/scheduledActionRegistry.ts b/backend/src/services/scheduledActionRegistry.ts index 417a9959..295c94a2 100644 --- a/backend/src/services/scheduledActionRegistry.ts +++ b/backend/src/services/scheduledActionRegistry.ts @@ -10,7 +10,7 @@ * each side. */ -export const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const; +export const VALID_TARGET_TYPES = ['stack', 'fleet', 'system', 'container'] as const; export type TargetType = typeof VALID_TARGET_TYPES[number]; export interface BackendScheduledActionDefinition { @@ -26,15 +26,15 @@ export interface BackendScheduledActionDefinition { * in `routes/scheduledTasks.ts` ("Must be restart, snapshot, prune, ..."). */ export const BACKEND_SCHEDULED_ACTIONS = [ - { id: 'restart', targetTypes: ['stack'], requiresNode: true }, + { id: 'restart', targetTypes: ['stack', 'container'], requiresNode: true }, { id: 'snapshot', targetTypes: ['fleet'], requiresNode: false }, { id: 'prune', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' }, { id: 'update', targetTypes: ['stack', 'fleet'], requiresNode: true }, { id: 'scan', targetTypes: ['system'], requiresNode: true, nodeScope: 'local' }, { id: 'auto_backup', targetTypes: ['stack'], requiresNode: true }, - { id: 'auto_stop', targetTypes: ['stack'], requiresNode: true }, + { id: 'auto_stop', targetTypes: ['stack', 'container'], requiresNode: true }, { id: 'auto_down', targetTypes: ['stack'], requiresNode: true }, - { id: 'auto_start', targetTypes: ['stack'], requiresNode: true }, + { id: 'auto_start', targetTypes: ['stack', 'container'], requiresNode: true }, ] as const satisfies readonly BackendScheduledActionDefinition[]; export type BackendScheduledAction = typeof BACKEND_SCHEDULED_ACTIONS[number]['id']; @@ -58,15 +58,15 @@ const ACTION_BY_ID = new Map = { - restart: 'Restart action requires target_type "stack".', + restart: 'Restart action requires target_type "stack" or "container".', snapshot: 'Snapshot action requires target_type "fleet".', prune: 'Prune action requires target_type "system".', update: 'Update action requires target_type "stack" or "fleet".', scan: 'Scan action requires target_type "system".', auto_backup: 'auto_backup action requires target_type "stack".', - auto_stop: 'auto_stop action requires target_type "stack".', + auto_stop: 'auto_stop action requires target_type "stack" or "container".', auto_down: 'auto_down action requires target_type "stack".', - auto_start: 'auto_start action requires target_type "stack".', + auto_start: 'auto_start action requires target_type "stack" or "container".', }; /** diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index 1a450cd1..fce3cf17 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -8,6 +8,10 @@ import { sanitizeForLog } from './safeLog'; export const isValidStackName = (name: string): boolean => /^[a-zA-Z0-9_-]+$/.test(name); +/** Docker container name (no path separators). Used for scheduled container targets. */ +export const isValidContainerName = (name: string): boolean => + !name.includes('..') && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,254}$/.test(name); + /** * Validates that a remote node API URL is a safe, well-formed HTTP/HTTPS URL. * Rejects loopback addresses to prevent SSRF against local services. diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx index 72db0b8c..95e0788a 100644 --- a/docs/features/scheduled-operations.mdx +++ b/docs/features/scheduled-operations.mdx @@ -26,8 +26,8 @@ Open the **Schedules** tab from the top navigation bar. The page opens on the Ti The Timeline plots every firing of every enabled task across a rolling 24-hour window starting from the current minute. - **Masthead.** A `NEXT 24 HOURS` kicker, an italic display heading, the window's start and end timestamps in a monospace range, and a right-anchored **Next** pill that reads out the time and task name of the next firing and a relative countdown. -- **Five lanes.** Stack lifecycle (label blue), Updates (success green), Security (label purple), Maintenance (warning amber), and Backups (brand cyan). The Stack lifecycle lane holds the five stack-lifecycle actions (Backup Stack Compose Files, Start / Bring Up Stack, Restart Stack, Stop Stack, Take Stack Down); Updates holds per-node and fleet image updates; Security holds vulnerability scans; Maintenance holds node resource prunes; Backups holds fleet snapshots. -- **Pills.** One pill per firing within the window, positioned proportionally to the firing's time. Each pill shows the firing time and a target: the stack for stack actions, the selected node for prune and scan, and "Entire fleet" for a fleet snapshot. Hover a pill for the full detail (action, task name, and node). Pills are color-matched to their lane. Click a pill to open the run history sheet for that task. +- **Five lanes.** Lifecycle (label blue), Updates (success green), Security (label purple), Maintenance (warning amber), and Backups (brand cyan). The Lifecycle lane holds stack lifecycle actions (Backup Stack Compose Files, Start / Bring Up Stack, Restart Stack, Stop Stack, Take Stack Down) and standalone container actions (Restart Container, Stop Container, Start Container); Updates holds per-node and fleet image updates; Security holds vulnerability scans; Maintenance holds node resource prunes; Backups holds fleet snapshots. +- **Pills.** One pill per firing within the window, positioned proportionally to the firing's time. Each pill shows the firing time and a target: the stack for stack actions, the container name for container actions, the selected node for prune and scan, and "Entire fleet" for a fleet snapshot. Hover a pill for the full detail (action, task name, and node). Pills are color-matched to their lane. Click a pill to open the run history sheet for that task. - **Now rail.** A glowing vertical rail at the current minute, anchored to the left of the track at page open and drifting right as time passes (the page recomputes positions periodically). - **Axis.** Six monospace time ticks run along the bottom, evenly spaced through the window. @@ -47,7 +47,7 @@ The All tasks toggle swaps the lane track for a sortable table. |---|---| | **Name** | The task name. | | **Action** | A badge labelling the operation (e.g. Restart Stack, Scan Node Images, Create Fleet Snapshot). | -| **Target** | The stack the task targets (with a service list in parentheses when restart is scoped to specific services), or the target type for non-stack actions (`system`, `fleet`). | +| **Target** | The stack the task targets (with a service list in parentheses when restart is scoped to specific services), the container name for container actions, or the target type for other non-stack actions (`system`, `fleet`). | | **Schedule** | A human-readable description of the cron with the raw expression on a second line. | | **Status** | The last run result: **Success** (green), **Failed** (red), or `Never run` if the task has not fired yet. | | **Next Run** | The timestamp of the next firing, or a dash if the task is disabled or has no upcoming runs. | @@ -68,10 +68,13 @@ The All tasks toggle swaps the lane track for a sortable table. | **Stop Stack** | A specific stack on a specific node | Runs `docker compose stop`. Containers are stopped but preserved. Use for off-hours power saving when you want a fast restart later. | | **Take Stack Down** | A specific stack on a specific node | Runs `docker compose down`. Containers are removed. Use to fully release resources when the stack is not needed for an extended period. | | **Start / Bring Up Stack** | A specific stack on a specific node | Runs `docker compose up -d`. Works for both stopped and removed containers: if they exist they are started, if not they are created from the compose file. | +| **Restart Container** | A specific container by name on a specific node | Restarts one container directly through Docker. Use for third-party or standalone containers that are not managed as a Sencho stack. | +| **Stop Container** | A specific container by name on a specific node | Stops one container. The container remains on disk for a faster start later. | +| **Start Container** | A specific container by name on a specific node | Starts one stopped container by name. | ## Creating a scheduled task -Click **New Schedule** in the header. The form opens in a centered modal. The Action picker lists every supported operation, grouped by category: Stack lifecycle, Updates, Security, Maintenance, and Backups. +Click **New Schedule** in the header. The form opens in a centered modal. The Action picker lists every supported operation, grouped by category: Lifecycle, Updates, Security, Maintenance, and Backups. The New scheduled task modal with the Action combobox expanded. The dropdown groups actions under category headers: Stack lifecycle (Backup Stack Compose Files, Start / Bring Up Stack, Restart Stack, Stop Stack, Take Stack Down), Updates (Auto-update Stack, Auto-update All Stacks on Node), Security (Scan Node Images), Maintenance (Prune Node Resources), and Backups (Create Fleet Snapshot). Below the picker, partly visible, sit a Services row with 'echo' and 'prober' checkboxes, the Cron Expression input, the Enabled toggle, and the Delete after successful run checkbox. @@ -88,6 +91,7 @@ Common fields: Conditional fields per action: - **Stack actions** (Backup Stack Compose Files, Start / Bring Up Stack, Restart Stack, Auto-update Stack, Stop Stack, Take Stack Down) add a **Node** combobox and a **Stack** combobox. Restart Stack additionally renders a **Services** checkbox grid sourced from the stack's compose services on the selected node, so you can scope the restart to a subset instead of restarting the entire stack. +- **Container actions** (Restart Container, Stop Container, Start Container) add a **Node** combobox and a **Container** combobox listing every container on that node (running and stopped). The picker shows each container's name, state, and image. When the container is not part of a Sencho stack, helper text explains that the schedule targets the container by node and name. - **Auto-update All Stacks on Node** adds a **Node** combobox. The helper text "Checks every stack on the selected node and updates stacks with newer images" appears above, next to the Runtime change badge. - **Scan Node Images** adds a **Node** combobox listing local nodes only. The helper text "Runs Trivy against images on the selected local node and records the findings" and Read-only badge appear above. - **Prune Node Resources** adds a **Node** combobox listing local nodes only, then a **Prune Targets** group (Containers, Images, Networks, Volumes; all selected by default) and a **Label Filter** input for scoping the prune to resources matching a Docker label. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index a704df31..af88cc97 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -386,13 +386,13 @@ components: type: string target_type: type: string - enum: [stack, fleet, system] + enum: [stack, fleet, system, container] target_id: type: ["string", "null"] - description: Stack name (when target_type is `stack`). + description: Stack or container name (when target_type is `stack` or `container`). node_id: type: ["integer", "null"] - description: Target node ID (when target_type is `stack`). + description: Target node ID (when target_type is `stack` or `container`). action: type: string enum: [restart, snapshot, prune] @@ -2717,7 +2717,8 @@ paths: summary: Create scheduled task description: | Creates a new recurring task. Action-target rules: - - `restart` requires `target_type: stack` (with `target_id` and `node_id`) + - `restart` requires `target_type: stack` or `target_type: container` (with `target_id` and `node_id`) + - `auto_stop` and `auto_start` accept `target_type: stack` or `target_type: container` - `snapshot` requires `target_type: fleet` - `prune` requires `target_type: system` @@ -2735,13 +2736,13 @@ paths: example: Nightly restart target_type: type: string - enum: [stack, fleet, system] + enum: [stack, fleet, system, container] target_id: type: string - description: Stack name (required when target_type is `stack`). + description: Stack or container name (required when target_type is `stack` or `container`). node_id: type: integer - description: Target node ID (required when target_type is `stack`). + description: Target node ID (required when target_type is `stack` or `container`). action: type: string enum: [restart, snapshot, prune] @@ -2828,9 +2829,10 @@ paths: type: string target_type: type: string - enum: [stack, fleet, system] + enum: [stack, fleet, system, container] target_id: type: string + description: Stack or container name when target_type is `stack` or `container`. node_id: type: integer action: diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx index c7b8672e..ed692e6c 100644 --- a/frontend/src/components/ScheduledOperationsView.tsx +++ b/frontend/src/components/ScheduledOperationsView.tsx @@ -12,6 +12,7 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, ChevronRight, Download, CalendarClock, Table2 } from 'lucide-react'; import { toast } from '@/components/ui/toast-store'; import { apiFetch, fetchForNode } from '@/lib/api'; +import { excludeLikelySenchoContainers } from '@/lib/senchoContainerFilter'; import { Combobox } from '@/components/ui/combobox'; import { SegmentedControl } from '@/components/ui/segmented-control'; import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling'; @@ -46,6 +47,18 @@ const DEFAULT_SIMPLE_SCHEDULE: SimpleSchedule = { const TIMELINE_WINDOW_HOURS = 24; const TIMELINE_WINDOW_MS = TIMELINE_WINDOW_HOURS * 60 * 60 * 1000; +interface ContainerListItem { + Id: string; + Names?: string[]; + State?: string; + Image?: string; + Labels?: Record; +} + +function containerDisplayName(c: ContainerListItem): string { + return c.Names?.[0]?.replace(/^\//, '') || c.Id.slice(0, 12); +} + function formatHourTick(ts: number): string { const d = new Date(ts); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; @@ -106,8 +119,9 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p const [runsTotal, setRunsTotal] = useState(0); const runsLimit = 20; - // Available stacks and nodes for selection + // Available stacks, containers, and nodes for selection const [stacks, setStacks] = useState([]); + const [containers, setContainers] = useState([]); const [nodes, setNodes] = useState([]); const filteredTasks = filterNodeId != null @@ -146,6 +160,20 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p } }, []); + const fetchContainers = useCallback(async (nodeId: string) => { + try { + const res = await fetchForNode('/containers?all=true', parseInt(nodeId, 10)); + if (res.ok) { + const rows = (await res.json()) as ContainerListItem[]; + setContainers(excludeLikelySenchoContainers(rows)); + } else { + setContainers([]); + } + } catch { + setContainers([]); + } + }, []); + const fetchNodes = useCallback(async () => { try { const res = await apiFetch('/nodes', { localOnly: true }); @@ -192,7 +220,11 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p ? await fetchForNode(endpoint, parseInt(formNodeId, 10)) : await apiFetch(endpoint); if (res.ok && !cancelled) { - setAvailableServices(await res.json()); + const services = (await res.json()) as string[]; + setAvailableServices(services); + if (services.length <= 1) { + setFormTargetServices([]); + } } } catch { // Non-critical @@ -202,17 +234,20 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p return () => { cancelled = true; }; }, [formAction, formTargetId, formNodeId]); - // Re-fetch stacks when the node changes. Clearing a stale stack selection is - // done in the Node picker's onValueChange (a user-driven change), not here, so - // a prefilled or edited node keeps its stack instead of being wiped on open. useEffect(() => { if (!dialogOpen) return; - if (formNodeId) { + const actionDef = getActionById(formAction); + if (actionDef?.requiresContainer && formNodeId) { + fetchContainers(formNodeId); fetchStacks(formNodeId); + } else if (formNodeId) { + fetchStacks(formNodeId); + setContainers([]); } else { setStacks([]); + setContainers([]); } - }, [formNodeId, dialogOpen, fetchStacks]); + }, [formNodeId, formAction, dialogOpen, fetchStacks, fetchContainers]); const openCreate = (prefillData?: { stackName: string; nodeId: string }) => { const nodeId = prefillData?.nodeId ?? (filterNodeId != null ? String(filterNodeId) : ''); @@ -299,7 +334,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p enabled: formEnabled, delete_after_run: formDeleteAfterRun, run_at: runAt, - target_id: actionDef.requiresStack ? formTargetId : null, + target_id: (actionDef.requiresStack || actionDef.requiresContainer) ? formTargetId : null, node_id: actionDef.requiresNode && formNodeId ? parseInt(formNodeId, 10) : null, prune_targets: formAction === 'prune' && formPruneTargets.length > 0 ? formPruneTargets : null, target_services: actionDef.supportsServiceSelection && formTargetServices.length > 0 ? formTargetServices : null, @@ -453,13 +488,31 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p [nodes], ); const currentNodeOptions = currentAction?.nodeScope === 'local' ? localNodeOptions : nodeOptions; + const containerOptions = useMemo( + () => containers.map(c => { + const name = containerDisplayName(c); + const state = c.State ?? 'unknown'; + const image = (c.Image ?? '').split('@')[0]; + return { value: name, label: `${name} · ${state} · ${image}` }; + }), + [containers], + ); + const selectedContainer = useMemo( + () => containers.find(c => containerDisplayName(c) === formTargetId), + [containers, formTargetId], + ); + const selectedContainerStack = selectedContainer?.Labels?.['com.docker.compose.project']; + const isUnmanagedContainer = !!selectedContainer && ( + !selectedContainerStack || !stacks.includes(selectedContainerStack) + ); const scheduleInvalid = scheduleMode === 'simple' ? !!simpleCronError : (!formCron || !!cronFieldError); const isSaveDisabled = saving || !currentAction || !formName || scheduleInvalid || (!!currentAction?.requiresStack && (!formTargetId || !formNodeId)) - || (!!currentAction?.requiresNode && !currentAction.requiresStack && !formNodeId) + || (!!currentAction?.requiresContainer && (!formTargetId || !formNodeId)) + || (!!currentAction?.requiresNode && !currentAction.requiresStack && !currentAction.requiresContainer && !formNodeId) || (formAction === 'prune' && formPruneTargets.length === 0); const windowEnd = now + TIMELINE_WINDOW_MS; @@ -699,6 +752,8 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p ? task.target_services ? `${task.target_id} (${(JSON.parse(task.target_services) as string[]).join(', ')})` : task.target_id + : task.target_type === 'container' + ? task.target_id : task.action === 'update' ? 'All eligible stacks' : task.target_type} @@ -799,6 +854,40 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p )} + {currentAction?.requiresContainer && ( + <> +
+ + { setFormNodeId(val); setFormTargetId(''); }} + placeholder="Select node..." + /> +
+
+ + { setFormTargetId(val); setFormTargetServices([]); }} + placeholder={formNodeId ? 'Select container...' : 'Select a node first'} + disabled={!formNodeId} + /> +
+ {isUnmanagedContainer && ( +

+ This container is not associated with a Sencho stack. The schedule will target the container by node and name. +

+ )} + {selectedContainerStack && stacks.includes(selectedContainerStack) && ( +

+ Part of stack: {selectedContainerStack} +

+ )} + + )} + {currentAction?.requiresStack && ( <>
@@ -815,12 +904,12 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p ({ value: s, label: s }))} value={formTargetId} - onValueChange={setFormTargetId} + onValueChange={(val) => { setFormTargetId(val); setFormTargetServices([]); }} placeholder={formNodeId ? "Select stack..." : "Select a node first"} disabled={!formNodeId} />
- {currentAction.supportsServiceSelection && formTargetId && availableServices.length > 0 && ( + {currentAction.supportsServiceSelection && formTargetId && availableServices.length > 1 && (
@@ -853,7 +942,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
)} - {currentAction?.requiresNode && !currentAction.requiresStack && ( + {currentAction?.requiresNode && !currentAction.requiresStack && !currentAction.requiresContainer && (
{ ); }); + it('hides service checkboxes when the stack has only one service', async () => { + mockedFetchForNode.mockImplementation(async (url: string) => { + if (url.endsWith('/services')) return jsonResponse(['mariadb']); + return jsonResponse(['db-compose']); + }); + render(); + + await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ })); + await userEvent.click(screen.getAllByRole('combobox')[1]); + await userEvent.click(await screen.findByRole('button', { name: 'edge' })); + await userEvent.click(screen.getAllByRole('combobox')[2]); + await userEvent.click(await screen.findByRole('button', { name: 'db-compose' })); + + await waitFor(() => + expect(mockedFetchForNode).toHaveBeenCalledWith('/stacks/db-compose/services', 2), + ); + expect(screen.queryByText(/^Services/)).not.toBeInTheDocument(); + }); + + it('shows service checkboxes when the stack has multiple services', async () => { + render(); + + await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ })); + await userEvent.click(screen.getAllByRole('combobox')[1]); + await userEvent.click(await screen.findByRole('button', { name: 'edge' })); + await userEvent.click(screen.getAllByRole('combobox')[2]); + await userEvent.click(await screen.findByRole('button', { name: 'web' })); + + const servicesBlock = (await screen.findByText(/^Services/)).closest('.space-y-2'); + expect(within(servicesBlock!).getAllByRole('checkbox')).toHaveLength(2); + }); + it('renders the five registry category lanes in the timeline view', async () => { render(); // Timeline is the default view; the lane track always renders. - for (const lane of ['Stack lifecycle', 'Updates', 'Security', 'Maintenance', 'Backups']) { + for (const lane of ['Lifecycle', 'Updates', 'Security', 'Maintenance', 'Backups']) { expect(await screen.findByText(lane)).toBeInTheDocument(); } }); @@ -425,6 +457,54 @@ describe('ScheduledOperationsView', () => { await selectAction('Prune Node Resources'); expect(screen.getByText('Prune Targets')).toBeInTheDocument(); expect(screen.getByText('Node')).toBeInTheDocument(); + + // Container action: Node + Container, no Stack. + await selectAction('Restart Container'); + expect(screen.getByText('Node')).toBeInTheDocument(); + expect(screen.getByText('Container')).toBeInTheDocument(); + expect(screen.queryByText('Stack')).not.toBeInTheDocument(); + }); + + it('emits container target payload for a container restart save', async () => { + mockedFetchForNode.mockImplementation(async (url: string) => { + if (url === '/stacks') return jsonResponse(['web']); + if (url.startsWith('/containers')) { + return jsonResponse([ + { Id: 'abc', Names: ['/watchtower'], State: 'running', Image: 'containrrr/watchtower' }, + ]); + } + return jsonResponse([]); + }); + + render(); + + await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ })); + await userEvent.type(await screen.findByPlaceholderText('e.g. Nightly stack restart'), 'daily-watchtower'); + + await userEvent.click(screen.getAllByRole('combobox')[0]); + await userEvent.click(await screen.findByRole('button', { name: 'Restart Container' })); + + await userEvent.click(screen.getAllByRole('combobox')[1]); + await userEvent.click(await screen.findByRole('button', { name: 'hub' })); + await userEvent.click(screen.getAllByRole('combobox')[2]); + await userEvent.click(await screen.findByRole('button', { name: /watchtower/ })); + + await userEvent.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + const postCall = mockedFetch.mock.calls.find( + ([url, opts]) => url === '/scheduled-tasks' && opts?.method === 'POST', + ); + expect(postCall).toBeTruthy(); + const body = JSON.parse(postCall![1].body); + expect(body).toMatchObject({ + name: 'daily-watchtower', + target_type: 'container', + action: 'restart', + target_id: 'watchtower', + node_id: 1, + }); + }); }); it('emits node_id and target_id for a stack update save', async () => { diff --git a/frontend/src/lib/__tests__/scheduledActions.test.ts b/frontend/src/lib/__tests__/scheduledActions.test.ts index c64146d0..439f0da5 100644 --- a/frontend/src/lib/__tests__/scheduledActions.test.ts +++ b/frontend/src/lib/__tests__/scheduledActions.test.ts @@ -71,6 +71,12 @@ describe('scheduledActions registry', () => { expect(def?.id).toBe('update'); }); + it('maps container target types to container UI entries', () => { + expect(resolveTaskAction({ action: 'restart', target_type: 'container' })?.id).toBe('container-restart'); + expect(resolveTaskAction({ action: 'auto_stop', target_type: 'container' })?.id).toBe('container-stop'); + expect(resolveTaskAction({ action: 'auto_start', target_type: 'container' })?.id).toBe('container-start'); + }); + it('maps a non-aliased action to its direct entry', () => { expect(resolveTaskAction({ action: 'restart', target_type: 'stack' })?.id).toBe('restart'); expect(resolveTaskAction({ action: 'snapshot', target_type: 'fleet' })?.id).toBe('snapshot'); @@ -90,6 +96,7 @@ describe('scheduledActions registry', () => { // Verify the exact order: lifecycle first, then updates, security, maintenance, backups. expect(ids).toEqual([ 'auto_backup', 'auto_start', 'restart', 'auto_stop', 'auto_down', + 'container-restart', 'container-stop', 'container-start', 'update', 'update-fleet', 'scan', 'prune', @@ -111,6 +118,9 @@ describe('scheduledActions registry', () => { 'restart': 'Restarts containers in place. Running services are stopped and started again on the same configuration.', 'auto_stop': 'Stops containers but keeps them in place for a faster start later.', 'auto_down': 'Runs compose down. Containers are removed, but compose files remain on disk.', + 'container-restart': 'Restarts a single container by name on the selected node. Targets the container directly, not through compose.', + 'container-stop': 'Stops a single container by name. The container remains on disk for a faster start later.', + 'container-start': 'Starts a stopped container by name on the selected node.', 'update': "Checks this stack's images and recreates the stack only when newer images are available.", 'update-fleet': 'Checks every stack on the selected node and updates stacks with newer images.', 'scan': 'Runs Trivy against images on the selected local node and records the findings.', @@ -132,6 +142,9 @@ describe('scheduledActions registry', () => { 'restart': 'interruptive', 'auto_stop': 'interruptive', 'auto_down': 'removes-containers', + 'container-restart': 'interruptive', + 'container-stop': 'interruptive', + 'container-start': 'runtime-change', 'update': 'runtime-change', 'update-fleet': 'runtime-change', 'scan': 'read-only', @@ -212,6 +225,16 @@ describe('scheduledActions registry', () => { expect(scheduleTargetDescriptor(task)).toBe('Entire fleet'); }); + it('shows the container name for container actions', () => { + const task: TargetTask = { + action: 'restart', + target_type: 'container', + target_id: 'watchtower', + name: 'Daily watchtower restart', + }; + expect(scheduleTargetDescriptor(task, 'hub')).toBe('watchtower'); + }); + it('shows the node for system actions (prune / scan), with a fallback', () => { const scan: TargetTask = { action: 'scan', target_type: 'system', target_id: null, name: 'Vul Scan' }; const prune: TargetTask = { action: 'prune', target_type: 'system', target_id: null, name: 'Nightly Prune' }; diff --git a/frontend/src/lib/scheduledActions.ts b/frontend/src/lib/scheduledActions.ts index dc5876b4..11da3c80 100644 --- a/frontend/src/lib/scheduledActions.ts +++ b/frontend/src/lib/scheduledActions.ts @@ -19,7 +19,7 @@ export type BackendAction = ScheduledTask['action']; * UI action ids. `update-fleet` is a frontend-only alias for `update` with * `target_type: 'fleet'`; it never reaches the backend. */ -export type ScheduledActionId = BackendAction | 'update-fleet'; +export type ScheduledActionId = BackendAction | 'update-fleet' | 'container-restart' | 'container-stop' | 'container-start'; export type ScheduledActionCategory = 'lifecycle' | 'updates' | 'security' | 'maintenance' | 'backups'; export type ScheduledActionTone = 'success' | 'warning' | 'destructive' | 'brand'; @@ -79,6 +79,7 @@ export interface ScheduledActionDefinition { tone: ScheduledActionTone; requiresNode: boolean; requiresStack: boolean; + requiresContainer: boolean; supportsServiceSelection: boolean; nodeScope?: 'local'; /** One-line explanation shown below the action picker in the create/edit form. */ @@ -93,20 +94,23 @@ export const DEFAULT_SCHEDULED_ACTION_ID: ScheduledActionId = 'restart'; /** Ordered for the create-flow action picker, grouped by category. */ export const SCHEDULED_ACTIONS: ScheduledActionDefinition[] = [ // Lifecycle - { id: 'auto_backup', backendAction: 'auto_backup', label: 'Backup Stack Compose Files', shortLabel: 'backup', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, supportsServiceSelection: false, helperText: 'Backs up compose and env files only. This does not back up application volumes.', riskLevel: 'safe' }, - { id: 'auto_start', backendAction: 'auto_start', label: 'Start / Bring Up Stack', shortLabel: 'start', category: 'lifecycle', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, supportsServiceSelection: false, helperText: 'Creates containers if they do not exist, or starts existing stopped containers.', riskLevel: 'runtime-change' }, - { id: 'restart', backendAction: 'restart', label: 'Restart Stack', shortLabel: 'restart', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, supportsServiceSelection: true, helperText: 'Restarts containers in place. Running services are stopped and started again on the same configuration.', riskLevel: 'interruptive' }, - { id: 'auto_stop', backendAction: 'auto_stop', label: 'Stop Stack', shortLabel: 'stop', category: 'lifecycle', targetType: 'stack', tone: 'warning', requiresNode: true, requiresStack: true, supportsServiceSelection: false, helperText: 'Stops containers but keeps them in place for a faster start later.', riskLevel: 'interruptive' }, - { id: 'auto_down', backendAction: 'auto_down', label: 'Take Stack Down', shortLabel: 'down', category: 'lifecycle', targetType: 'stack', tone: 'destructive', requiresNode: true, requiresStack: true, supportsServiceSelection: false, helperText: 'Runs compose down. Containers are removed, but compose files remain on disk.', riskLevel: 'removes-containers' }, + { id: 'auto_backup', backendAction: 'auto_backup', label: 'Backup Stack Compose Files', shortLabel: 'backup', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Backs up compose and env files only. This does not back up application volumes.', riskLevel: 'safe' }, + { id: 'auto_start', backendAction: 'auto_start', label: 'Start / Bring Up Stack', shortLabel: 'start', category: 'lifecycle', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Creates containers if they do not exist, or starts existing stopped containers.', riskLevel: 'runtime-change' }, + { id: 'restart', backendAction: 'restart', label: 'Restart Stack', shortLabel: 'restart', category: 'lifecycle', targetType: 'stack', tone: 'brand', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: true, helperText: 'Restarts containers in place. Running services are stopped and started again on the same configuration.', riskLevel: 'interruptive' }, + { id: 'auto_stop', backendAction: 'auto_stop', label: 'Stop Stack', shortLabel: 'stop', category: 'lifecycle', targetType: 'stack', tone: 'warning', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Stops containers but keeps them in place for a faster start later.', riskLevel: 'interruptive' }, + { id: 'auto_down', backendAction: 'auto_down', label: 'Take Stack Down', shortLabel: 'down', category: 'lifecycle', targetType: 'stack', tone: 'destructive', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Runs compose down. Containers are removed, but compose files remain on disk.', riskLevel: 'removes-containers' }, + { id: 'container-restart', backendAction: 'restart', label: 'Restart Container', shortLabel: 'restart ctr', category: 'lifecycle', targetType: 'container', tone: 'brand', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Restarts a single container by name on the selected node. Targets the container directly, not through compose.', riskLevel: 'interruptive' }, + { id: 'container-stop', backendAction: 'auto_stop', label: 'Stop Container', shortLabel: 'stop ctr', category: 'lifecycle', targetType: 'container', tone: 'warning', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Stops a single container by name. The container remains on disk for a faster start later.', riskLevel: 'interruptive' }, + { id: 'container-start', backendAction: 'auto_start', label: 'Start Container', shortLabel: 'start ctr', category: 'lifecycle', targetType: 'container', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: true, supportsServiceSelection: false, helperText: 'Starts a stopped container by name on the selected node.', riskLevel: 'runtime-change' }, // Updates - { id: 'update', backendAction: 'update', label: 'Auto-update Stack', shortLabel: 'update', category: 'updates', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, supportsServiceSelection: false, helperText: 'Checks this stack\'s images and recreates the stack only when newer images are available.', riskLevel: 'runtime-change' }, - { id: 'update-fleet', backendAction: 'update', label: 'Auto-update All Stacks on Node', shortLabel: 'update node', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, helperText: 'Checks every stack on the selected node and updates stacks with newer images.', riskLevel: 'runtime-change' }, + { id: 'update', backendAction: 'update', label: 'Auto-update Stack', shortLabel: 'update', category: 'updates', targetType: 'stack', tone: 'success', requiresNode: true, requiresStack: true, requiresContainer: false, supportsServiceSelection: false, helperText: 'Checks this stack\'s images and recreates the stack only when newer images are available.', riskLevel: 'runtime-change' }, + { id: 'update-fleet', backendAction: 'update', label: 'Auto-update All Stacks on Node', shortLabel: 'update node', category: 'updates', targetType: 'fleet', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Checks every stack on the selected node and updates stacks with newer images.', riskLevel: 'runtime-change' }, // Security - { id: 'scan', backendAction: 'scan', label: 'Scan Node Images', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Runs Trivy against images on the selected local node and records the findings.', riskLevel: 'read-only' }, + { id: 'scan', backendAction: 'scan', label: 'Scan Node Images', shortLabel: 'scan', category: 'security', targetType: 'system', tone: 'success', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Runs Trivy against images on the selected local node and records the findings.', riskLevel: 'read-only' }, // Maintenance - { id: 'prune', backendAction: 'prune', label: 'Prune Node Resources', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: true, requiresStack: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Removes unused Docker resources on the selected node. Be careful when pruning volumes.', riskLevel: 'destructive' }, + { id: 'prune', backendAction: 'prune', label: 'Prune Node Resources', shortLabel: 'prune', category: 'maintenance', targetType: 'system', tone: 'warning', requiresNode: true, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, nodeScope: 'local', helperText: 'Removes unused Docker resources on the selected node. Be careful when pruning volumes.', riskLevel: 'destructive' }, // Backups - { id: 'snapshot', backendAction: 'snapshot', label: 'Create Fleet Snapshot', shortLabel: 'snapshot', category: 'backups', targetType: 'fleet', tone: 'warning', requiresNode: false, requiresStack: false, supportsServiceSelection: false, helperText: 'Creates a versioned snapshot of compose and env files across the fleet.', riskLevel: 'safe' }, + { id: 'snapshot', backendAction: 'snapshot', label: 'Create Fleet Snapshot', shortLabel: 'snapshot', category: 'backups', targetType: 'fleet', tone: 'warning', requiresNode: false, requiresStack: false, requiresContainer: false, supportsServiceSelection: false, helperText: 'Creates a versioned snapshot of compose and env files across the fleet.', riskLevel: 'safe' }, ]; const ACTION_BY_ID = new Map(SCHEDULED_ACTIONS.map(a => [a.id, a])); @@ -126,6 +130,11 @@ export function resolveTaskAction( if (task.action === 'update' && task.target_type === 'fleet') { return getActionById('update-fleet'); } + if (task.target_type === 'container') { + if (task.action === 'restart') return getActionById('container-restart'); + if (task.action === 'auto_stop') return getActionById('container-stop'); + if (task.action === 'auto_start') return getActionById('container-start'); + } return getActionById(task.action); } @@ -153,6 +162,8 @@ export function scheduleTargetDescriptor( : 'Entire fleet'; case 'system': return nodeName ?? 'Selected node'; + case 'container': + return task.target_id ?? task.name; default: { const exhaustive: never = task.target_type; return exhaustive; @@ -169,7 +180,7 @@ export interface ScheduledActionCategoryLane { /** Ordered Timeline lanes; each scheduled action maps to one lane by category. */ export const SCHEDULED_ACTION_CATEGORIES: ScheduledActionCategoryLane[] = [ - { key: 'lifecycle', label: 'Stack lifecycle', color: 'var(--label-blue)', bg: 'var(--label-blue-bg)' }, + { key: 'lifecycle', label: 'Lifecycle', color: 'var(--label-blue)', bg: 'var(--label-blue-bg)' }, { key: 'updates', label: 'Updates', color: 'var(--success)', bg: 'oklch(from var(--success) l c h / 0.18)' }, { key: 'security', label: 'Security', color: 'var(--label-purple)', bg: 'var(--label-purple-bg)' }, { key: 'maintenance', label: 'Maintenance', color: 'var(--warning)', bg: 'oklch(from var(--warning) l c h / 0.18)' }, diff --git a/frontend/src/lib/senchoContainerFilter.test.ts b/frontend/src/lib/senchoContainerFilter.test.ts new file mode 100644 index 00000000..ffd84caa --- /dev/null +++ b/frontend/src/lib/senchoContainerFilter.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { excludeLikelySenchoContainers, isLikelySenchoManagementContainer } from '../lib/senchoContainerFilter'; + +describe('senchoContainerFilter', () => { + it('detects sencho management containers by name and image', () => { + expect(isLikelySenchoManagementContainer({ Id: '1', Names: ['/sencho'], Image: 'saelix/sencho:latest' })).toBe(true); + expect(isLikelySenchoManagementContainer({ Id: '2', Names: ['/sencho-agent'] })).toBe(true); + expect(isLikelySenchoManagementContainer({ Id: '3', Names: ['/mariadb'], Image: 'lscr.io/linuxserver/mariadb:latest' })).toBe(false); + }); + + it('excludeLikelySenchoContainers keeps user containers', () => { + const rows = excludeLikelySenchoContainers([ + { Id: '1', Names: ['/sencho'], Image: 'ghcr.io/studio-saelix/sencho:dev' }, + { Id: '2', Names: ['/mariadb'], Image: 'lscr.io/linuxserver/mariadb:latest' }, + ]); + expect(rows).toHaveLength(1); + expect(rows[0].Names).toEqual(['/mariadb']); + }); +}); diff --git a/frontend/src/lib/senchoContainerFilter.ts b/frontend/src/lib/senchoContainerFilter.ts new file mode 100644 index 00000000..0970e2fa --- /dev/null +++ b/frontend/src/lib/senchoContainerFilter.ts @@ -0,0 +1,24 @@ +/** Mirrors backend/helpers/excludeSelfContainers.ts heuristics for proxied remote lists. */ + +export interface ContainerPickerRow { + Id: string; + Names?: string[]; + Image?: string; +} + +function isPublishedSenchoImage(image: string): boolean { + const lower = image.toLowerCase(); + return /(?:^|\/)saelix\/sencho(?:-dev)?(?:[:@]|$)/.test(lower) + || /studio-saelix\/sencho(?:-dev)?(?:[:@]|$)/.test(lower); +} + +export function isLikelySenchoManagementContainer(c: ContainerPickerRow): boolean { + const name = c.Names?.[0]?.replace(/^\//, '').toLowerCase() ?? ''; + if (name === 'sencho' || name === 'sencho-agent') return true; + if (c.Image && isPublishedSenchoImage(c.Image)) return true; + return false; +} + +export function excludeLikelySenchoContainers(containers: T[]): T[] { + return containers.filter(c => !isLikelySenchoManagementContainer(c)); +} diff --git a/frontend/src/types/scheduling.ts b/frontend/src/types/scheduling.ts index a6cafa62..1d5d911f 100644 --- a/frontend/src/types/scheduling.ts +++ b/frontend/src/types/scheduling.ts @@ -1,7 +1,7 @@ export interface ScheduledTask { id: number; name: string; - target_type: 'stack' | 'fleet' | 'system'; + target_type: 'stack' | 'fleet' | 'system' | 'container'; target_id: string | null; node_id: number | null; action: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan' | 'auto_backup' | 'auto_stop' | 'auto_down' | 'auto_start';