mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-04 07:57:54 +00:00
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<HTMLElement>() 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.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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<typeof vi.spyOn>; fs: ReturnTyp
|
||||
const fs = vi.spyOn(FileSystemService, 'getInstance').mockReturnValue({
|
||||
getStacks: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as ReturnType<typeof FileSystemService.getInstance>);
|
||||
vi.spyOn(SelfIdentityService, 'getInstance').mockReturnValue({
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
isOwnContainer: vi.fn().mockReturnValue(false),
|
||||
isOwnImage: vi.fn().mockReturnValue(false),
|
||||
} as unknown as ReturnType<typeof SelfIdentityService.getInstance>);
|
||||
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'));
|
||||
|
||||
|
||||
@@ -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<typeof SelfIdentityServiceMod.getInstance>);
|
||||
|
||||
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<typeof DockerController.getInstance>);
|
||||
});
|
||||
|
||||
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<typeof SelfIdentityServiceMod.getInstance>);
|
||||
|
||||
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<typeof DockerController.getInstance>);
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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<BackendScheduledAction, TargetType[]> = {
|
||||
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<BackendScheduledAction, string> = {
|
||||
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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
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<T extends ComposeServiceContainer>(
|
||||
containers: T[],
|
||||
serviceName: string,
|
||||
): T[] {
|
||||
return containers.filter(c => containerBelongsToComposeService(c, serviceName));
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import SelfIdentityService from '../services/SelfIdentityService';
|
||||
|
||||
export interface DockerContainerListRow {
|
||||
Id: string;
|
||||
Names?: string[];
|
||||
Image?: string;
|
||||
ImageID?: string;
|
||||
Labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 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<T extends DockerContainerListRow>(containers: T[]): Promise<T[]> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -863,6 +863,31 @@ class DockerController {
|
||||
return this.validateApiData<any[]>(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<string, string>();
|
||||
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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<Array<{
|
||||
Id: string;
|
||||
Names?: string[];
|
||||
State?: string;
|
||||
Image?: string;
|
||||
}>> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const proxyTarget = NodeRegistry.getInstance().getProxyTarget(nodeId);
|
||||
if (!proxyTarget) {
|
||||
|
||||
@@ -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<BackendScheduledAction, BackendScheduledActionDefin
|
||||
* the API contract, so it is kept explicit rather than templated.
|
||||
*/
|
||||
const TARGET_MISMATCH_MESSAGE: Record<BackendScheduledAction, string> = {
|
||||
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".',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/scheduled-operations/action-picker.png" alt="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.
|
||||
|
||||
+10
-8
@@ -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:
|
||||
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
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<string[]>([]);
|
||||
const [containers, setContainers] = useState<ContainerListItem[]>([]);
|
||||
const [nodes, setNodes] = useState<NodeOption[]>([]);
|
||||
|
||||
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
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentAction?.requiresContainer && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>Node</Label>
|
||||
<Combobox
|
||||
options={nodeOptions}
|
||||
value={formNodeId}
|
||||
onValueChange={(val) => { setFormNodeId(val); setFormTargetId(''); }}
|
||||
placeholder="Select node..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Container</Label>
|
||||
<Combobox
|
||||
options={containerOptions}
|
||||
value={formTargetId}
|
||||
onValueChange={(val) => { setFormTargetId(val); setFormTargetServices([]); }}
|
||||
placeholder={formNodeId ? 'Select container...' : 'Select a node first'}
|
||||
disabled={!formNodeId}
|
||||
/>
|
||||
</div>
|
||||
{isUnmanagedContainer && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This container is not associated with a Sencho stack. The schedule will target the container by node and name.
|
||||
</p>
|
||||
)}
|
||||
{selectedContainerStack && stacks.includes(selectedContainerStack) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Part of stack: {selectedContainerStack}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentAction?.requiresStack && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
@@ -815,12 +904,12 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
<Combobox
|
||||
options={stacks.map(s => ({ value: s, label: s }))}
|
||||
value={formTargetId}
|
||||
onValueChange={setFormTargetId}
|
||||
onValueChange={(val) => { setFormTargetId(val); setFormTargetServices([]); }}
|
||||
placeholder={formNodeId ? "Select stack..." : "Select a node first"}
|
||||
disabled={!formNodeId}
|
||||
/>
|
||||
</div>
|
||||
{currentAction.supportsServiceSelection && formTargetId && availableServices.length > 0 && (
|
||||
{currentAction.supportsServiceSelection && formTargetId && availableServices.length > 1 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Services <span className="text-xs text-muted-foreground">(leave empty for all)</span></Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
@@ -853,7 +942,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentAction?.requiresNode && !currentAction.requiresStack && (
|
||||
{currentAction?.requiresNode && !currentAction.requiresStack && !currentAction.requiresContainer && (
|
||||
<div className="space-y-2">
|
||||
<Label>Node</Label>
|
||||
<Combobox
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* hub-local endpoint.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ScheduledTask } from '@/types/scheduling';
|
||||
|
||||
@@ -338,10 +338,42 @@ describe('ScheduledOperationsView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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(<ScheduledOperationsView />);
|
||||
|
||||
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(<ScheduledOperationsView />);
|
||||
|
||||
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<HTMLElement>('.space-y-2');
|
||||
expect(within(servicesBlock!).getAllByRole('checkbox')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders the five registry category lanes in the timeline view', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
// 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(<ScheduledOperationsView />);
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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<string, ScheduledActionDefinition>(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)' },
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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<T extends ContainerPickerRow>(containers: T[]): T[] {
|
||||
return containers.filter(c => !isLikelySenchoManagementContainer(c));
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user