feat(nodes): add per-node scheduling and update visibility (#344)

* feat(labels): add stack_labels schema and DatabaseService CRUD methods

* feat(labels): add label CRUD, assignment, and bulk action API routes

* feat(labels): add oklch label color palette for light and dark themes

* feat(labels): add LabelPill and LabelDot reusable components

* feat(labels): add LabelAssignPopover component for inline label management

* feat(labels): add label pill bar, label dots, and label assignment to sidebar

* feat(labels): add label filtering and label dots to fleet view

* feat(labels): add label-scoped bulk actions (deploy/stop/restart all)

* docs: add Stack Labels feature documentation

* fix(labels): use context menu sub-menu for label assignment and add settings integration

Replace broken Popover-inside-ContextMenu pattern with native Radix
ContextMenuSub for reliable label toggling on right-click. Wrap
ContextMenuSubContent in a Portal to prevent overflow clipping. Add
"Manage labels..." item that opens Settings directly to Labels section.
Fix close button overlap in LabelsSection header. Add LabelsSection
settings component with full CRUD, assignment counts, and ProGate.
Add initialSection prop to SettingsModal for deep-linking. Include
screenshots for documentation.

* docs: update stack labels documentation with screenshots and corrected instructions

* fix(labels): address security and quality issues from code review

- Add NaN validation on parseInt(req.params.id) in label routes
- Scope updateLabel/deleteLabel by nodeId to prevent cross-node IDOR
- Validate labelIds belong to correct node in setStackLabels
- Add requireAdmin check on bulk action endpoint
- Replace error: any with error: unknown and proper narrowing
- Remove unused Label import from index.ts
- Remove unused isPro prop from LabelsSection
- Add strokeWidth={1.5} to Check icons per design system

* chore: update CHANGELOG with stack labels feature

* feat(nodes): add per-node scheduling and update visibility

Add Schedules and Updates columns to the Nodes table showing active
task counts, next run times, and auto-update status per node. A calendar
action button navigates to filtered schedule/auto-update views.

Backend changes:
- Add node_id to stack_update_status table (migration + unique index)
- Cascade cleanup on node deletion (scheduled_tasks + update status)
- Pre-check target node existence/status before executing scheduled tasks
- New GET /api/nodes/scheduling-summary endpoint
- New GET /api/image-updates/fleet endpoint with 2-minute cache
- Parallelize remote node fetches with Promise.allSettled
- Wrap deleteNode cascade in a transaction

Frontend changes:
- NodeManager: Schedules/Updates columns with summary data fetch
- EditorLayout: sencho-navigate event listener for cross-component nav
- ScheduledOperationsView/AutoUpdatePoliciesView: filterNodeId prop,
  filter bar UI, pre-selected node in create dialog
This commit is contained in:
Anso
2026-04-02 20:37:53 -04:00
committed by GitHub
parent 2527c355c9
commit efbd20fed5
14 changed files with 416 additions and 34 deletions
@@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
const {
mockGetDueScheduledTasks, mockCreateScheduledTaskRun, mockUpdateScheduledTaskRun,
mockUpdateScheduledTask, mockCleanupOldTaskRuns, mockGetScheduledTask, mockGetNodes,
mockUpdateScheduledTask, mockCleanupOldTaskRuns, mockGetScheduledTask, mockGetNodes, mockGetNode,
mockCreateSnapshot, mockInsertSnapshotFiles, mockClearStackUpdateStatus,
mockGetTier, mockGetVariant,
mockGetContainersByStack, mockRestartContainer, mockPruneSystem,
@@ -24,6 +24,7 @@ const {
mockCleanupOldTaskRuns: vi.fn(),
mockGetScheduledTask: vi.fn(),
mockGetNodes: vi.fn().mockReturnValue([]),
mockGetNode: vi.fn().mockReturnValue({ id: 1, name: 'local', type: 'local', status: 'online' }),
mockCreateSnapshot: vi.fn().mockReturnValue(1),
mockInsertSnapshotFiles: vi.fn(),
mockClearStackUpdateStatus: vi.fn(),
@@ -50,6 +51,7 @@ vi.mock('../services/DatabaseService', () => ({
cleanupOldTaskRuns: mockCleanupOldTaskRuns,
getScheduledTask: mockGetScheduledTask,
getNodes: mockGetNodes,
getNode: mockGetNode,
createSnapshot: mockCreateSnapshot,
insertSnapshotFiles: mockInsertSnapshotFiles,
clearStackUpdateStatus: mockClearStackUpdateStatus,
@@ -489,7 +491,7 @@ describe('SchedulerService - executeUpdate', () => {
await svc.triggerTask(80);
expect(mockUpdateStack).toHaveBeenCalledWith('web-app', undefined, true);
expect(mockClearStackUpdateStatus).toHaveBeenCalledWith('web-app');
expect(mockClearStackUpdateStatus).toHaveBeenCalledWith(1, 'web-app');
});
it('skips when all images up to date', async () => {
+106 -3
View File
@@ -3085,7 +3085,7 @@ app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) =>
try {
const atomic = LicenseService.getInstance().getTier() === 'pro';
await ComposeService.getInstance(req.nodeId).updateStack(stackName, terminalWs || undefined, atomic);
DatabaseService.getInstance().clearStackUpdateStatus(stackName);
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
res.json({ status: 'Update completed' });
} catch (error) {
const rolledBack = LicenseService.getInstance().getTier() === 'pro';
@@ -4761,9 +4761,9 @@ app.post('/api/templates/deploy', async (req: Request, res: Response) => {
// Image Update Checker API
// =========================
app.get('/api/image-updates', authMiddleware, (_req: Request, res: Response) => {
app.get('/api/image-updates', authMiddleware, (req: Request, res: Response) => {
try {
const updates = DatabaseService.getInstance().getStackUpdateStatus();
const updates = DatabaseService.getInstance().getStackUpdateStatus(req.nodeId);
res.json(updates);
} catch (error) {
console.error('Failed to fetch image update status:', error);
@@ -4790,6 +4790,67 @@ app.get('/api/image-updates/status', authMiddleware, (_req: Request, res: Respon
res.json({ checking: ImageUpdateService.getInstance().isChecking() });
});
// Fleet-wide image update aggregation (local DB + remote node APIs)
let fleetUpdateCache: { data: Record<number, Record<string, boolean>>; fetchedAt: number } | null = null;
const FLEET_CACHE_TTL = 120_000; // 2 minutes
app.get('/api/image-updates/fleet', authMiddleware, async (_req: Request, res: Response) => {
try {
if (fleetUpdateCache && Date.now() - fleetUpdateCache.fetchedAt < FLEET_CACHE_TTL) {
res.json(fleetUpdateCache.data);
return;
}
const db = DatabaseService.getInstance();
const nodes = db.getNodes();
const nr = NodeRegistry.getInstance();
const result: Record<number, Record<string, boolean>> = {};
// Local nodes: synchronous DB reads
for (const node of nodes) {
if (node.type === 'local') {
result[node.id] = db.getStackUpdateStatus(node.id);
}
}
// Remote nodes: parallel fetches with individual timeouts
const remoteNodes = nodes.filter(n => n.type === 'remote' && n.status === 'online' && n.api_url);
const remoteResults = await Promise.allSettled(
remoteNodes.map(async (node) => {
const proxyTarget = nr.getProxyTarget(node.id);
const baseUrl = node.api_url!.replace(/\/$/, '');
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const resp = await fetch(`${baseUrl}/api/image-updates`, {
headers: proxyTarget?.apiToken
? { Authorization: `Bearer ${proxyTarget.apiToken}` }
: {},
signal: controller.signal,
});
clearTimeout(timeout);
if (resp.ok) return { nodeId: node.id, data: await resp.json() as Record<string, boolean> };
} catch {
clearTimeout(timeout);
}
return null;
})
);
for (const entry of remoteResults) {
if (entry.status === 'fulfilled' && entry.value) {
result[entry.value.nodeId] = entry.value.data;
}
}
fleetUpdateCache = { data: result, fetchedAt: Date.now() };
res.json(result);
} catch (error) {
console.error('Failed to aggregate fleet update status:', error);
res.status(500).json({ error: 'Failed to aggregate fleet update status' });
}
});
// =========================
// Node Management API
// =========================
@@ -4805,6 +4866,48 @@ app.get('/api/nodes', async (req: Request, res: Response) => {
}
});
// Per-node scheduling + update summary (must be before :id route)
app.get('/api/nodes/scheduling-summary', authMiddleware, (_req: Request, res: Response) => {
try {
const db = DatabaseService.getInstance();
const scheduleSummary = db.getNodeSchedulingSummary();
const updateSummary = db.getNodeUpdateSummary();
const result: Record<number, {
active_tasks: number;
auto_update_enabled: boolean;
next_run_at: number | null;
stacks_with_updates: number;
}> = {};
for (const s of scheduleSummary) {
result[s.node_id] = {
active_tasks: s.active_tasks,
auto_update_enabled: s.auto_update_enabled === 1,
next_run_at: s.next_run_at,
stacks_with_updates: 0,
};
}
for (const u of updateSummary) {
if (result[u.node_id]) {
result[u.node_id].stacks_with_updates = u.stacks_with_updates;
} else {
result[u.node_id] = {
active_tasks: 0,
auto_update_enabled: false,
next_run_at: null,
stacks_with_updates: u.stacks_with_updates,
};
}
}
res.json(result);
} catch (error) {
console.error('Failed to fetch node scheduling summary:', error);
res.status(500).json({ error: 'Failed to fetch node scheduling summary' });
}
});
// Get a specific node
app.get('/api/nodes/:id', async (req: Request, res: Response) => {
try {
+47 -8
View File
@@ -458,6 +458,13 @@ export class DatabaseService {
maybeAddCol('scheduled_tasks', 'target_services', 'TEXT DEFAULT NULL');
maybeAddCol('scheduled_tasks', 'prune_label_filter', 'TEXT DEFAULT NULL');
// Per-node scoping for stack update status (pre-0.10 had stack_name as sole PK)
maybeAddCol('stack_update_status', 'node_id', 'INTEGER NOT NULL DEFAULT 0');
this.db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_stack_update_status_node_stack
ON stack_update_status(node_id, stack_name);
`);
// Drop legacy SSH/TLS columns from pre-0.7 databases (no longer read or written)
const legacyCols = ['host', 'port', 'ssh_port', 'ssh_user', 'ssh_password', 'ssh_key', 'tls_ca', 'tls_cert', 'tls_key'];
for (const col of legacyCols) {
@@ -864,7 +871,11 @@ export class DatabaseService {
if (node?.is_default) {
throw new Error('Cannot delete the default node');
}
this.db.prepare('DELETE FROM nodes WHERE id = ?').run(id);
this.db.transaction(() => {
this.db.prepare('DELETE FROM nodes WHERE id = ?').run(id);
this.db.prepare('DELETE FROM scheduled_tasks WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ?').run(id);
})();
}
public updateNodeStatus(id: number, status: 'online' | 'offline' | 'unknown'): void {
@@ -873,14 +884,18 @@ export class DatabaseService {
// --- Stack Update Status ---
public upsertStackUpdateStatus(stackName: string, hasUpdate: boolean, checkedAt: number): void {
public upsertStackUpdateStatus(nodeId: number, stackName: string, hasUpdate: boolean, checkedAt: number): void {
this.db.prepare(
'INSERT OR REPLACE INTO stack_update_status (stack_name, has_update, checked_at) VALUES (?, ?, ?)'
).run(stackName, hasUpdate ? 1 : 0, checkedAt);
`INSERT INTO stack_update_status (node_id, stack_name, has_update, checked_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(node_id, stack_name) DO UPDATE SET has_update = excluded.has_update, checked_at = excluded.checked_at`
).run(nodeId, stackName, hasUpdate ? 1 : 0, checkedAt);
}
public getStackUpdateStatus(): Record<string, boolean> {
const rows = this.db.prepare('SELECT stack_name, has_update FROM stack_update_status').all() as any[];
public getStackUpdateStatus(nodeId?: number): Record<string, boolean> {
const rows = nodeId !== undefined
? this.db.prepare('SELECT stack_name, has_update FROM stack_update_status WHERE node_id = ?').all(nodeId) as Array<{ stack_name: string; has_update: number }>
: this.db.prepare('SELECT stack_name, has_update FROM stack_update_status').all() as Array<{ stack_name: string; has_update: number }>;
const result: Record<string, boolean> = {};
for (const row of rows) {
result[row.stack_name] = row.has_update === 1;
@@ -888,8 +903,32 @@ export class DatabaseService {
return result;
}
public clearStackUpdateStatus(stackName: string): void {
this.db.prepare('DELETE FROM stack_update_status WHERE stack_name = ?').run(stackName);
public clearStackUpdateStatus(nodeId: number, stackName: string): void {
this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
}
public getNodeUpdateSummary(): Array<{ node_id: number; stacks_with_updates: number }> {
return this.db.prepare(
'SELECT node_id, SUM(has_update) as stacks_with_updates FROM stack_update_status WHERE has_update = 1 GROUP BY node_id'
).all() as Array<{ node_id: number; stacks_with_updates: number }>;
}
public getNodeSchedulingSummary(): Array<{
node_id: number;
active_tasks: number;
auto_update_enabled: number;
next_run_at: number | null;
}> {
return this.db.prepare(`
SELECT
node_id,
COUNT(*) as active_tasks,
MAX(CASE WHEN action = 'update' AND enabled = 1 THEN 1 ELSE 0 END) as auto_update_enabled,
MIN(next_run_at) as next_run_at
FROM scheduled_tasks
WHERE enabled = 1 AND node_id IS NOT NULL
GROUP BY node_id
`).all() as Array<{ node_id: number; active_tasks: number; auto_update_enabled: number; next_run_at: number | null }>;
}
// --- Webhooks ---
+1 -1
View File
@@ -276,7 +276,7 @@ export class ImageUpdateService {
const now = Date.now();
for (const [stackName, images] of stackImages) {
const hasUpdate = Array.from(images).some(img => imageUpdateMap.get(img) === true);
db.upsertStackUpdateStatus(stackName, hasUpdate, now);
db.upsertStackUpdateStatus(nodeId, stackName, hasUpdate, now);
}
}
+10 -2
View File
@@ -102,6 +102,13 @@ export class SchedulerService {
});
try {
// Pre-check: ensure target node exists and is reachable
if (task.node_id != null && task.action !== 'snapshot') {
const node = db.getNode(task.node_id);
if (!node) throw new Error(`Target node (id=${task.node_id}) no longer exists`);
if (node.status === 'offline') throw new Error(`Target node "${node.name}" is offline`);
}
let output = '';
switch (task.action) {
case 'restart':
@@ -379,7 +386,7 @@ export class SchedulerService {
for (const stackName of stackNames) {
try {
const output = await this.executeUpdateForStack(stackName, docker, imageUpdateService, compose, db);
const output = await this.executeUpdateForStack(stackName, task.node_id ?? 0, docker, imageUpdateService, compose, db);
results.push(output);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
@@ -393,6 +400,7 @@ export class SchedulerService {
private async executeUpdateForStack(
stackName: string,
nodeId: number,
docker: DockerController,
imageUpdateService: ImageUpdateService,
compose: ComposeService,
@@ -432,7 +440,7 @@ export class SchedulerService {
}
await compose.updateStack(stackName, undefined, true);
db.clearStackUpdateStatus(stackName);
db.clearStackUpdateStatus(nodeId, stackName);
NotificationService.getInstance().dispatchAlert(
'info',