mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 08:58:05 +00:00
feat(schedules): make every scheduled action available on Skipper (#1141)
Collapse the Admiral carveout that restricted restart, prune, auto_backup, auto_stop, auto_down, and auto_start schedules to the Admiral variant. Scheduled Operations stays at Skipper+ (paid). The action picker now lists every supported operation for any paid admin, and the scheduler runner executes every action on either variant.
This commit is contained in:
@@ -14,13 +14,14 @@ let DatabaseService: typeof import('../services/DatabaseService').DatabaseServic
|
||||
let adminCookie: string;
|
||||
let viewerCookie: string;
|
||||
let variantSpy: ReturnType<typeof vi.spyOn>;
|
||||
let tierSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
variantSpy = vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
|
||||
|
||||
@@ -90,7 +91,7 @@ describe('GET /api/scheduled-tasks', () => {
|
||||
expect(Array.isArray(res.body[0].next_runs)).toBe(true);
|
||||
});
|
||||
|
||||
it('shows scan and snapshot tasks to Skipper users', async () => {
|
||||
it('shows every action to Skipper users', async () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const now = Date.now();
|
||||
db.createScheduledTask({
|
||||
@@ -154,7 +155,7 @@ describe('GET /api/scheduled-tasks', () => {
|
||||
|
||||
const res = await request(app).get('/api/scheduled-tasks').set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.map((t: { action: string }) => t.action).sort()).toEqual(['scan', 'snapshot']);
|
||||
expect(res.body.map((t: { action: string }) => t.action).sort()).toEqual(['prune', 'scan', 'snapshot']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -441,16 +442,35 @@ describe('POST /api/scheduled-tasks - Skipper tier gating', () => {
|
||||
expect(res.body.action).toBe('snapshot');
|
||||
});
|
||||
|
||||
for (const action of ['restart', 'prune', 'auto_backup', 'auto_stop', 'auto_down', 'auto_start']) {
|
||||
it(`rejects Skipper admins from creating ${action} tasks with 403`, async () => {
|
||||
for (const action of ['restart', 'auto_backup', 'auto_stop', 'auto_down', 'auto_start']) {
|
||||
it(`allows Skipper admins to create ${action} tasks`, async () => {
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||
name: `skipper-${action}`, target_type: 'stack', target_id: 'my-stack', node_id: 1,
|
||||
action, cron_expression: '0 3 * * *', enabled: true,
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('ADMIRAL_REQUIRED');
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.action).toBe(action);
|
||||
});
|
||||
}
|
||||
|
||||
it('allows Skipper admins to create prune tasks', async () => {
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||
name: 'skipper-prune', target_type: 'system', node_id: 1,
|
||||
action: 'prune', cron_expression: '0 4 * * *', enabled: true,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.action).toBe('prune');
|
||||
});
|
||||
|
||||
it('rejects Community admins from creating any scheduled task with 403', async () => {
|
||||
tierSpy.mockReturnValueOnce('community');
|
||||
const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({
|
||||
name: 'community-update', target_type: 'stack', target_id: 'my-stack', node_id: 1,
|
||||
action: 'update', cron_expression: '0 3 * * *', enabled: true,
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('PAID_REQUIRED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/scheduled-tasks/:id - delete_after_run', () => {
|
||||
|
||||
@@ -292,15 +292,17 @@ describe('SchedulerService - license gating', () => {
|
||||
expect(mockCreateScheduledTaskRun).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips non-update/scan/snapshot tasks for non-admiral pro', async () => {
|
||||
it('executes restart tasks for non-admiral pro (Skipper)', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('individual');
|
||||
mockGetDueScheduledTasks.mockReturnValue([makeTask({ action: 'restart' })]);
|
||||
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]);
|
||||
|
||||
const svc = SchedulerService.getInstance();
|
||||
await (svc as any).tick();
|
||||
|
||||
expect(mockCreateScheduledTaskRun).not.toHaveBeenCalled();
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
expect(mockCreateScheduledTaskRun).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows snapshot tasks for non-admiral pro (Skipper)', async () => {
|
||||
@@ -1525,7 +1527,7 @@ describe('SchedulerService - lifecycle actions', () => {
|
||||
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'failure' }));
|
||||
});
|
||||
|
||||
it('non-admiral paid tier skips lifecycle actions', async () => {
|
||||
it('non-admiral paid tier executes lifecycle actions', async () => {
|
||||
mockGetTier.mockReturnValue('paid');
|
||||
mockGetVariant.mockReturnValue('standard');
|
||||
mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_stop'));
|
||||
@@ -1534,7 +1536,8 @@ describe('SchedulerService - lifecycle actions', () => {
|
||||
const svc = SchedulerService.getInstance();
|
||||
await (svc as any).tick();
|
||||
|
||||
expect(mockRunCommand).not.toHaveBeenCalled();
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
expect(mockRunCommand).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -59,15 +59,6 @@ export const requireNodeProxy = (req: Request, res: Response): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Scheduled task actions a Skipper-tier license may create and view. All other actions are Admiral-only. */
|
||||
export const SKIPPER_SCHEDULED_ACTIONS: ReadonlySet<string> = new Set(['update', 'scan', 'snapshot']);
|
||||
|
||||
/** Tier gate for scheduled tasks: SKIPPER_SCHEDULED_ACTIONS require Skipper+, everything else requires Admiral. */
|
||||
export const requireScheduledTaskTier = (action: string, req: Request, res: Response): boolean => {
|
||||
if (SKIPPER_SCHEDULED_ACTIONS.has(action)) return requirePaid(req, res);
|
||||
return requireAdmiral(req, res);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tier gate for SSO providers. The split is by delivery (turnkey vs self-configured), not by
|
||||
* protocol: Custom OIDC stays free so self-hosters can wire any OIDC IdP (Authelia, Keycloak,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { CronExpressionParser } from 'cron-parser';
|
||||
import { DatabaseService, type ScheduledTask } from '../services/DatabaseService';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { SchedulerService } from '../services/SchedulerService';
|
||||
import { requirePaid, requireAdmin, requireScheduledTaskTier, SKIPPER_SCHEDULED_ACTIONS } from '../middleware/tierGates';
|
||||
import { requirePaid, requireAdmin } from '../middleware/tierGates';
|
||||
import { escapeCsvField } from '../utils/csv';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { parseIntParam } from '../utils/parseIntParam';
|
||||
@@ -109,11 +108,6 @@ scheduledTasksRouter.get('/', (req: Request, res: Response): void => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
let tasks = DatabaseService.getInstance().getScheduledTasks();
|
||||
// Skipper users see v1 fleet-maintenance tasks; Admiral sees all.
|
||||
const ls = LicenseService.getInstance();
|
||||
if (ls.getVariant() !== 'admiral') {
|
||||
tasks = tasks.filter(t => SKIPPER_SCHEDULED_ACTIONS.has(t.action));
|
||||
}
|
||||
// Split Auto-Update and Scheduled Operations into distinct views.
|
||||
const actionFilter = typeof req.query.action === 'string' ? req.query.action : undefined;
|
||||
const excludeAction = typeof req.query.exclude_action === 'string' ? req.query.exclude_action : undefined;
|
||||
@@ -142,6 +136,7 @@ scheduledTasksRouter.get('/', (req: Request, res: Response): void => {
|
||||
|
||||
scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
if (!requirePaid(req, res)) return;
|
||||
try {
|
||||
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter, delete_after_run } = req.body;
|
||||
|
||||
@@ -154,7 +149,6 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
|
||||
if (!(VALID_ACTIONS as readonly string[]).includes(action)) {
|
||||
res.status(400).json({ error: 'Invalid action. Must be restart, snapshot, prune, update, scan, auto_backup, auto_stop, auto_down, or auto_start.' }); return;
|
||||
}
|
||||
if (!requireScheduledTaskTier(action, req, res)) return;
|
||||
|
||||
const targetErr = validateActionTarget(action, target_type);
|
||||
if (targetErr) { res.status(400).json({ error: targetErr }); return; }
|
||||
@@ -222,7 +216,6 @@ scheduledTasksRouter.get('/:id', (req: Request, res: Response): void => {
|
||||
if (id === null) return;
|
||||
const task = DatabaseService.getInstance().getScheduledTask(id);
|
||||
if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
|
||||
if (!requireScheduledTaskTier(task.action, req, res)) return;
|
||||
res.json(task);
|
||||
} catch (error) {
|
||||
console.error('[ScheduledTasks] Get error:', error);
|
||||
@@ -240,7 +233,6 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const existing = db.getScheduledTask(id);
|
||||
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
|
||||
if (!requireScheduledTaskTier(existing.action, req, res)) return;
|
||||
|
||||
const { name, target_type, target_id, node_id, action, cron_expression, enabled, prune_targets, target_services, prune_label_filter, delete_after_run } = req.body;
|
||||
|
||||
@@ -322,7 +314,6 @@ scheduledTasksRouter.delete('/:id', (req: Request, res: Response): void => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const existing = db.getScheduledTask(id);
|
||||
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
|
||||
if (!requireScheduledTaskTier(existing.action, req, res)) return;
|
||||
|
||||
db.deleteScheduledTask(id);
|
||||
console.log(`[ScheduledTasks] Deleted task id=${id}`);
|
||||
@@ -343,7 +334,6 @@ scheduledTasksRouter.patch('/:id/toggle', (req: Request, res: Response): void =>
|
||||
const db = DatabaseService.getInstance();
|
||||
const existing = db.getScheduledTask(id);
|
||||
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
|
||||
if (!requireScheduledTaskTier(existing.action, req, res)) return;
|
||||
|
||||
const newEnabled = existing.enabled ? 0 : 1;
|
||||
const nextRun = newEnabled ? SchedulerService.getInstance().calculateNextRun(existing.cron_expression) : null;
|
||||
@@ -373,7 +363,6 @@ scheduledTasksRouter.post('/:id/run', (req: Request, res: Response): void => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const existing = db.getScheduledTask(id);
|
||||
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
|
||||
if (!requireScheduledTaskTier(existing.action, req, res)) return;
|
||||
|
||||
const scheduler = SchedulerService.getInstance();
|
||||
if (scheduler.isTaskRunning(id)) {
|
||||
@@ -404,7 +393,6 @@ scheduledTasksRouter.get('/:id/runs/export', (req: Request, res: Response): void
|
||||
const db = DatabaseService.getInstance();
|
||||
const task = db.getScheduledTask(id);
|
||||
if (!task) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
|
||||
if (!requireScheduledTaskTier(task.action, req, res)) return;
|
||||
|
||||
const runs = db.getAllScheduledTaskRuns(id);
|
||||
|
||||
@@ -440,7 +428,6 @@ scheduledTasksRouter.get('/:id/runs', (req: Request, res: Response): void => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const existing = db.getScheduledTask(id);
|
||||
if (!existing) { res.status(404).json({ error: 'Scheduled task not found' }); return; }
|
||||
if (!requireScheduledTaskTier(existing.action, req, res)) return;
|
||||
|
||||
const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100);
|
||||
const offset = Math.max(parseInt(req.query.offset as string, 10) || 0, 0);
|
||||
|
||||
@@ -195,9 +195,7 @@ export class SchedulerService {
|
||||
await this.maybeRedetectTrivy();
|
||||
|
||||
const ls = LicenseService.getInstance();
|
||||
const isPaid = ls.getTier() === 'paid';
|
||||
const isAdmiral = isPaid && ls.getVariant() === 'admiral';
|
||||
if (!isPaid) return;
|
||||
if (ls.getTier() !== 'paid') return;
|
||||
|
||||
const now = Date.now();
|
||||
const dueTasks = db.getDueScheduledTasks(now);
|
||||
@@ -211,10 +209,6 @@ export class SchedulerService {
|
||||
db.deleteOldScans(90 * 24 * 60 * 60 * 1000);
|
||||
|
||||
for (const task of dueTasks) {
|
||||
if (!isAdmiral && task.action !== 'update' && task.action !== 'scan' && task.action !== 'snapshot') {
|
||||
if (isDebugEnabled()) console.log(`[SchedulerService] Task ${task.id} skipped: action "${task.action}" requires Admiral tier`);
|
||||
continue;
|
||||
}
|
||||
if (this.runningTasks.has(task.id)) {
|
||||
if (isDebugEnabled()) console.log(`[SchedulerService] Task ${task.id} skipped: already running`);
|
||||
continue;
|
||||
|
||||
@@ -43,7 +43,7 @@ For larger deployments, an **Enterprise** tier is available with custom pricing,
|
||||
- Atomic deployments with rollback
|
||||
- Auto-update policies for stack images
|
||||
- Auto-heal policies
|
||||
- Scheduled tasks for stack updates, vulnerability scans, and fleet snapshots
|
||||
- Scheduled operations across the full action catalog (lifecycle, updates, scans, snapshots, prune)
|
||||
- Scan policies with `block_on_deploy` deploy enforcement, SBOM (SPDX, CycloneDX), and SARIF export
|
||||
- Bulk actions on a label (deploy, stop, or restart every stack tagged with it)
|
||||
- Remote OTA node updates from the control instance
|
||||
@@ -64,11 +64,10 @@ For larger deployments, an **Enterprise** tier is available with custom pricing,
|
||||
- Sencho Mesh (cross-node container networking)
|
||||
- Sencho Cloud Backup
|
||||
- Auto-update of the managed Trivy binary
|
||||
- All other scheduled task actions (restart, prune, and more)
|
||||
|
||||
## Free trial
|
||||
|
||||
Sencho offers a **14-day Admiral trial** so you can evaluate the flagship features (Host Console, Sencho Mesh, scheduled operations, LDAP / Active Directory, audit log, unlimited accounts) with your real infrastructure before committing. The trial is offered on the monthly and annual Admiral plans; the Founder Lifetime plan does not include a trial.
|
||||
Sencho offers a **14-day Admiral trial** so you can evaluate the flagship features (Host Console, Sencho Mesh, LDAP / Active Directory, audit log, unlimited accounts) with your real infrastructure before committing. The trial is offered on the monthly and annual Admiral plans; the Founder Lifetime plan does not include a trial.
|
||||
|
||||
To start a trial:
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Automate stack lifecycle, image updates, vulnerability scans, fleet
|
||||
Schedules is a unified surface for every recurring maintenance operation Sencho knows how to run: stack restarts, per-node and fleet-wide image updates, lifecycle events (stop, take down, start, backup), system prunes, and vulnerability scans. The default view is a next-24-hour Timeline of upcoming runs across five lanes; an All tasks table view lists every schedule regardless of when it next fires.
|
||||
|
||||
<Note>
|
||||
Available to admins on Skipper and Admiral. Skipper unlocks **Auto-update Stack**, **Auto-update All Stacks**, **Vulnerability Scan**, and **Fleet Snapshot**. The remaining actions (Restart Stack, System Prune, Backup Stack Files, Stop / Take Down / Start Stack) require Admiral. The action picker hides operations your tier cannot run.
|
||||
Available to admins on Skipper and Admiral.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
@@ -56,22 +56,22 @@ The All tasks toggle swaps the lane track for a sortable table.
|
||||
|
||||
## Supported actions
|
||||
|
||||
| Action | Tier | Target | What it does |
|
||||
|---|---|---|---|
|
||||
| **Restart Stack** | Admiral | A specific stack (optionally specific services) on a specific node | Restarts all or selected containers in the stack. |
|
||||
| **Auto-update Stack** | Skipper | A specific stack on a specific node | Checks each image in the stack for a newer tag and recreates the stack if any image has an update. See [Auto-Update Readiness](/features/auto-update-policies) for the companion review board. |
|
||||
| **Auto-update All Stacks** | Skipper | A specific node | Runs the auto-update check across every stack on the node that has auto-updates enabled. Stacks with auto-updates turned off are skipped. |
|
||||
| **Fleet Snapshot** | Skipper | The whole fleet | Creates a versioned, fleet-wide snapshot of every node's compose files and `.env` files. See [Fleet Backups](/features/fleet-backups). |
|
||||
| **System Prune** | Admiral | The selected node | Prunes containers, images, networks, and volumes (any subset), optionally filtered by a Docker label. |
|
||||
| **Vulnerability Scan** | Skipper | A specific node | Runs Trivy against every image on the node and persists the results. Requires Trivy to be installed on the target node ([Installing Trivy](/operations/trivy-setup)). |
|
||||
| **Backup Stack Files** | Admiral | A specific stack on a specific node | Copies the stack's compose file and `.env` to `<DATA_DIR>/backups/<stack>/`. One slot per stack; each run overwrites the previous backup. For a versioned archive use a Fleet Snapshot instead. |
|
||||
| **Stop Stack** | Admiral | 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** | Admiral | 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 Stack** | Admiral | 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. |
|
||||
| Action | Target | What it does |
|
||||
|---|---|---|
|
||||
| **Restart Stack** | A specific stack (optionally specific services) on a specific node | Restarts all or selected containers in the stack. |
|
||||
| **Auto-update Stack** | A specific stack on a specific node | Checks each image in the stack for a newer tag and recreates the stack if any image has an update. See [Auto-Update Readiness](/features/auto-update-policies) for the companion review board. |
|
||||
| **Auto-update All Stacks** | A specific node | Runs the auto-update check across every stack on the node that has auto-updates enabled. Stacks with auto-updates turned off are skipped. |
|
||||
| **Fleet Snapshot** | The whole fleet | Creates a versioned, fleet-wide snapshot of every node's compose files and `.env` files. See [Fleet Backups](/features/fleet-backups). |
|
||||
| **System Prune** | The selected node | Prunes containers, images, networks, and volumes (any subset), optionally filtered by a Docker label. |
|
||||
| **Vulnerability Scan** | A specific node | Runs Trivy against every image on the node and persists the results. Requires Trivy to be installed on the target node ([Installing Trivy](/operations/trivy-setup)). |
|
||||
| **Backup Stack Files** | A specific stack on a specific node | Copies the stack's compose file and `.env` to `<DATA_DIR>/backups/<stack>/`. One slot per stack; each run overwrites the previous backup. For a versioned archive use a Fleet Snapshot instead. |
|
||||
| **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 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. |
|
||||
|
||||
## Creating a scheduled task
|
||||
|
||||
Click **New Schedule** in the header. The form opens in a centered modal. The Action picker only shows operations your tier can run, so Skipper admins see four options and Admiral admins see all ten.
|
||||
Click **New Schedule** in the header. The form opens in a centered modal. The Action picker lists every supported operation.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/scheduled-operations/action-picker.png" alt="The New scheduled task modal with the Action combobox expanded. The dropdown lists Restart Stack, Auto-update Stack, Auto-update All Stacks, Fleet Snapshot, System Prune, and Vulnerability Scan as the first six entries. 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." />
|
||||
|
||||
@@ -13,22 +13,11 @@ import { Clock, Plus, Pencil, Trash2, History, RefreshCw, Play, ChevronLeft, Che
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import type { ScheduledTask, TaskRun, NodeOption } from '@/types/scheduling';
|
||||
import { getCronDescription, formatTimestamp } from '@/lib/scheduling';
|
||||
|
||||
const UPDATE_FLEET_ACTION = 'update-fleet' as const;
|
||||
|
||||
// Mirrors backend `SKIPPER_SCHEDULED_ACTIONS` in tierGates.ts. Picker options
|
||||
// whose backend action falls outside this set are Admiral-only and hidden from
|
||||
// Skipper users so the Combobox never offers a choice the API will reject.
|
||||
const SKIPPER_BACKEND_ACTIONS: ReadonlySet<string> = new Set(['update', 'scan', 'snapshot']);
|
||||
|
||||
function isActionAllowedForVariant(option: { value: string; backendAction?: string }, variant: string | null | undefined): boolean {
|
||||
if (variant === 'admiral') return true;
|
||||
return SKIPPER_BACKEND_ACTIONS.has(option.backendAction ?? option.value);
|
||||
}
|
||||
|
||||
const ACTION_OPTIONS: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
@@ -86,11 +75,6 @@ interface ScheduledOperationsViewProps {
|
||||
}
|
||||
|
||||
export default function ScheduledOperationsView({ filterNodeId, onClearFilter, prefill, onPrefillConsumed }: ScheduledOperationsViewProps) {
|
||||
const { license } = useLicense();
|
||||
const visibleActionOptions = useMemo(
|
||||
() => ACTION_OPTIONS.filter(o => isActionAllowedForVariant(o, license?.variant)),
|
||||
[license?.variant]
|
||||
);
|
||||
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [view, setView] = useState<'timeline' | 'table'>('timeline');
|
||||
@@ -225,7 +209,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
const nodeId = prefillData?.nodeId ?? (filterNodeId != null ? String(filterNodeId) : '');
|
||||
setEditingTask(null);
|
||||
setFormName('');
|
||||
setFormAction(visibleActionOptions[0]?.value ?? 'restart');
|
||||
setFormAction(ACTION_OPTIONS[0]?.value ?? 'restart');
|
||||
setFormTargetId(prefillData?.stackName ?? '');
|
||||
setFormNodeId(nodeId);
|
||||
setFormCron('0 3 * * *');
|
||||
@@ -692,7 +676,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
<div className="space-y-2">
|
||||
<Label>Action</Label>
|
||||
<Combobox
|
||||
options={visibleActionOptions.map(o => ({ value: o.value, label: o.label }))}
|
||||
options={ACTION_OPTIONS.map(o => ({ value: o.value, label: o.label }))}
|
||||
value={formAction}
|
||||
onValueChange={(val) => { setFormAction(val); setFormTargetId(''); setFormNodeId(''); setFormTargetServices([]); setFormPruneLabelFilter(''); }}
|
||||
placeholder="Select action..."
|
||||
|
||||
Reference in New Issue
Block a user