diff --git a/CHANGELOG.md b/CHANGELOG.md
index 507068e7..11d3482c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+* **nodes:** Per-node scheduling and update visibility in the Nodes table
+ * New "Schedules" and "Updates" columns show active task counts, next run time, and auto-update status per node
+ * Calendar action button navigates to Schedules/Auto-Update views filtered to that node
+ * Filter bar in schedule views with pre-selected node when creating tasks
+* **nodes:** Fleet-wide image update aggregation endpoint (`/api/image-updates/fleet`) with 2-minute cache
+* **nodes:** Node scheduling summary endpoint (`/api/nodes/scheduling-summary`)
+
+### Fixed
+
+* **scheduler:** `stack_update_status` table now includes `node_id` — stacks with the same name on different nodes no longer collide
+* **scheduler:** Scheduled tasks targeting offline or deleted nodes now fail with clear error messages instead of cryptic Docker connection errors
+* **nodes:** Deleting a node now cascades cleanup to its scheduled tasks and update status data (wrapped in a transaction)
* **labels:** Stack Labels — organize stacks with custom colored labels for filtering and bulk actions (Pro)
* Create, edit, and delete labels from Settings → Labels
* Assign labels to stacks via right-click context menu or dropdown menu
diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts
index e98aeea3..71c9656d 100644
--- a/backend/src/__tests__/scheduler-service.test.ts
+++ b/backend/src/__tests__/scheduler-service.test.ts
@@ -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 () => {
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 81421572..d943fe05 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -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>; 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> = {};
+
+ // 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 };
+ } 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 = {};
+
+ 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 {
diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts
index cdc1b200..e4d0cbc9 100644
--- a/backend/src/services/DatabaseService.ts
+++ b/backend/src/services/DatabaseService.ts
@@ -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 {
- const rows = this.db.prepare('SELECT stack_name, has_update FROM stack_update_status').all() as any[];
+ public getStackUpdateStatus(nodeId?: number): Record {
+ 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 = {};
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 ---
diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts
index e367c2c5..ed18496e 100644
--- a/backend/src/services/ImageUpdateService.ts
+++ b/backend/src/services/ImageUpdateService.ts
@@ -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);
}
}
diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts
index 25149265..104554d7 100644
--- a/backend/src/services/SchedulerService.ts
+++ b/backend/src/services/SchedulerService.ts
@@ -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',
diff --git a/docs/features/auto-update-policies.mdx b/docs/features/auto-update-policies.mdx
index d2c457c6..6d9c9e82 100644
--- a/docs/features/auto-update-policies.mdx
+++ b/docs/features/auto-update-policies.mdx
@@ -52,6 +52,10 @@ For convenience, Sencho offers common schedule presets:
| Weekly (Sunday 3 AM) | `0 3 * * 0` | Minimal disruption for stable stacks |
| Custom | User-defined | Any valid cron expression |
+## Filtering by Node
+
+In a multi-node environment, you can filter the policy list to show only policies targeting a specific node. Click the **calendar icon** on any node row in **Settings → Nodes** to jump directly to a filtered view. A filter bar at the top shows the active node, with a **Clear filter** button to return to the full list.
+
## Managing Policies
Each policy in the list shows:
diff --git a/docs/features/multi-node.mdx b/docs/features/multi-node.mdx
index 48e380e0..e75e7fb8 100644
--- a/docs/features/multi-node.mdx
+++ b/docs/features/multi-node.mdx
@@ -73,6 +73,23 @@ Node status indicators:
| Red dot | Node is unreachable |
| Gray dot | Status not yet checked |
+## Per-node scheduling and update indicators
+
+The Nodes table surfaces scheduling and update status at a glance for each node:
+
+
+
+
+
+| Column | What it shows |
+|--------|---------------|
+| **Schedules** | Number of active scheduled tasks targeting this node, plus a relative countdown to the next run (e.g. "next 2h") |
+| **Updates** | Whether auto-update policies are enabled ("Auto" badge), and how many stacks have pending image updates (pulsing blue dot with count) |
+
+Click the **calendar icon** on any node row to jump directly to the Schedules view filtered to that node. From there you can create, edit, or manage scheduled tasks scoped to the selected node. The filter bar shows which node you're viewing, with a **Clear filter** button to return to the full list.
+
+When a node is deleted, all scheduled tasks and update status data associated with it are automatically cleaned up.
+
## Editing and deleting nodes
Click the **pencil icon** on any remote node row to edit its name, URL, or token. Click the **trash icon** to remove it. The local node cannot be edited or deleted.
diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx
index f72122a0..dda380ec 100644
--- a/docs/features/scheduled-operations.mdx
+++ b/docs/features/scheduled-operations.mdx
@@ -87,6 +87,15 @@ Sencho uses standard 5-field cron expressions:
| `30 2 1 * *` | 1st of every month at 2:30 AM |
| `0 0 * * 1-5` | Midnight on weekdays |
+## Filtering by Node
+
+When managing a multi-node fleet, you can filter the schedule list to show only tasks targeting a specific node. There are two ways to access this:
+
+- **From the Nodes table:** Click the **calendar icon** on any node row in **Settings → Nodes** to jump directly to the Schedules view filtered to that node.
+- **From the Schedules view:** A filter bar appears at the top showing which node you're viewing, with a **Clear filter** button to return to the full list.
+
+When creating a new task while a node filter is active, Sencho pre-selects that node in the create dialog.
+
## Managing Tasks
- **Enable/Disable** - Use the toggle switch in the task list to pause or resume a schedule without deleting it.
diff --git a/docs/images/per-node-scheduling/nodes-table-overview.png b/docs/images/per-node-scheduling/nodes-table-overview.png
new file mode 100644
index 00000000..d9a40ff8
Binary files /dev/null and b/docs/images/per-node-scheduling/nodes-table-overview.png differ
diff --git a/frontend/src/components/AutoUpdatePoliciesView.tsx b/frontend/src/components/AutoUpdatePoliciesView.tsx
index 24693ddb..2544a342 100644
--- a/frontend/src/components/AutoUpdatePoliciesView.tsx
+++ b/frontend/src/components/AutoUpdatePoliciesView.tsx
@@ -73,7 +73,12 @@ function formatTimestamp(ts: number | null): string {
return new Date(ts).toLocaleString();
}
-function AutoUpdatePoliciesContent() {
+interface AutoUpdatePoliciesProps {
+ filterNodeId?: number | null;
+ onClearFilter?: () => void;
+}
+
+function AutoUpdatePoliciesContent({ filterNodeId, onClearFilter }: AutoUpdatePoliciesProps) {
const [policies, setPolicies] = useState([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -100,6 +105,13 @@ function AutoUpdatePoliciesContent() {
const [stacks, setStacks] = useState([]);
const [nodes, setNodes] = useState([]);
+ const filteredPolicies = filterNodeId != null
+ ? policies.filter(p => p.node_id === filterNodeId)
+ : policies;
+ const filterNodeName = filterNodeId != null
+ ? nodes.find(n => n.id === filterNodeId)?.name
+ : null;
+
const fetchPolicies = useCallback(async () => {
setLoading(true);
try {
@@ -153,11 +165,14 @@ function AutoUpdatePoliciesContent() {
setEditingPolicy(null);
setFormName('');
setFormTargetId('');
- setFormNodeId('');
+ setFormNodeId(filterNodeId != null ? String(filterNodeId) : '');
setFormCron('0 3 * * *');
setFormCronPreset('0 3 * * *');
setFormEnabled(true);
setDialogOpen(true);
+ if (filterNodeId != null) {
+ fetchStacks(String(filterNodeId));
+ }
};
const openEdit = (policy: ScheduledTask) => {
@@ -297,11 +312,23 @@ function AutoUpdatePoliciesContent() {
- {loading && policies.length === 0 ? (
+ {filterNodeId != null && filterNodeName && (
+
+
+ Filtered to node: {filterNodeName}
+
+
+ Clear filter
+
+
+ )}
+ {loading && filteredPolicies.length === 0 ? (
Loading...
- ) : policies.length === 0 ? (
+ ) : filteredPolicies.length === 0 ? (
- No auto-update policies yet. Create one to keep your stacks up to date automatically.
+ {filterNodeId != null
+ ? 'No auto-update policies for this node. Create one to keep your stacks up to date automatically.'
+ : 'No auto-update policies yet. Create one to keep your stacks up to date automatically.'}
) : (
@@ -318,7 +345,7 @@ function AutoUpdatePoliciesContent() {
- {policies.map((policy) => (
+ {filteredPolicies.map((policy) => (
{policy.name}
{policy.target_id === '*' ? 'All Stacks' : policy.target_id}
@@ -561,10 +588,10 @@ function AutoUpdatePoliciesContent() {
);
}
-export default function AutoUpdatePoliciesView() {
+export default function AutoUpdatePoliciesView({ filterNodeId, onClearFilter }: AutoUpdatePoliciesProps) {
return (
-
+
);
}
diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx
index d9d78c3e..7eaaae92 100644
--- a/frontend/src/components/EditorLayout.tsx
+++ b/frontend/src/components/EditorLayout.tsx
@@ -46,6 +46,8 @@ import { FleetView } from './FleetView';
import { AuditLogView } from './AuditLogView';
import ScheduledOperationsView from './ScheduledOperationsView';
import AutoUpdatePoliciesView from './AutoUpdatePoliciesView';
+import { SENCHO_NAVIGATE_EVENT } from './NodeManager';
+import type { SenchoNavigateDetail } from './NodeManager';
import { useNodes } from '@/context/NodeContext';
import type { Node } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
@@ -131,6 +133,7 @@ export default function EditorLayout() {
);
const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark);
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log' | 'scheduled-ops' | 'auto-updates'>('dashboard');
+ const [filterNodeId, setFilterNodeId] = useState(null);
const [isEditing, setIsEditing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [stackStatuses, setStackStatuses] = useState({});
@@ -218,6 +221,7 @@ export default function EditorLayout() {
setActiveView('dashboard');
} else {
setActiveView(value as typeof activeView);
+ setFilterNodeId(null);
}
};
@@ -235,6 +239,19 @@ export default function EditorLayout() {
localStorage.setItem('sencho-theme', theme);
}, [isDarkMode, theme]);
+ // Listen for cross-component navigation (e.g., NodeManager → Schedules)
+ useEffect(() => {
+ const handler = (e: Event) => {
+ const detail = (e as CustomEvent).detail;
+ if (detail?.view) {
+ setActiveView(detail.view);
+ setFilterNodeId(detail.nodeId ?? null);
+ }
+ };
+ window.addEventListener(SENCHO_NAVIGATE_EVENT, handler);
+ return () => window.removeEventListener(SENCHO_NAVIGATE_EVENT, handler);
+ }, []);
+
// Force Monaco to re-measure its container after the tab switch DOM settles.
// Monaco's internal child is position:static with an explicit pixel height that
// creates a circular CSS dependency (Monaco drives card height → grid height → Monaco).
@@ -2017,9 +2034,9 @@ export default function EditorLayout() {
) : activeView === 'audit-log' ? (
) : activeView === 'auto-updates' ? (
-
+ setFilterNodeId(null)} />
) : activeView === 'scheduled-ops' ? (
-
+ setFilterNodeId(null)} />
) : (
)}
diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx
index 238f91fc..1c48de89 100644
--- a/frontend/src/components/NodeManager.tsx
+++ b/frontend/src/components/NodeManager.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useState, useEffect, useCallback, useMemo } from 'react';
import { useNodes } from '@/context/NodeContext';
import type { Node } from '@/context/NodeContext';
import { apiFetch } from '@/lib/api';
@@ -13,7 +13,30 @@ import { Separator } from './ui/separator';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
-import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Server, Monitor, Globe, Copy, KeyRound, Check, AlertTriangle } from 'lucide-react';
+import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Server, Monitor, Globe, Copy, KeyRound, Check, AlertTriangle, Calendar, RefreshCw } from 'lucide-react';
+
+interface NodeSchedulingSummary {
+ active_tasks: number;
+ auto_update_enabled: boolean;
+ next_run_at: number | null;
+ stacks_with_updates: number;
+}
+
+export const SENCHO_NAVIGATE_EVENT = 'sencho-navigate';
+export interface SenchoNavigateDetail {
+ view: 'scheduled-ops' | 'auto-updates';
+ nodeId: number;
+}
+
+function formatRelativeTime(timestamp: number): string {
+ const diff = timestamp - Date.now();
+ if (diff < 0) return 'overdue';
+ const mins = Math.floor(diff / 60000);
+ if (mins < 60) return `${mins}m`;
+ const hrs = Math.floor(mins / 60);
+ if (hrs < 24) return `${hrs}h`;
+ return `${Math.floor(hrs / 24)}d`;
+}
interface NodeFormData {
name: string;
@@ -49,6 +72,24 @@ export function NodeManager() {
const [generatingToken, setGeneratingToken] = useState(false);
const [tokenCopied, setTokenCopied] = useState(false);
+ // Per-node scheduling summary
+ const [nodeSummary, setNodeSummary] = useState>({});
+
+ const fetchSchedulingSummary = useCallback(async () => {
+ try {
+ const res = await apiFetch('/nodes/scheduling-summary', { localOnly: true });
+ if (res.ok) setNodeSummary(await res.json());
+ } catch {
+ // Non-fatal — summary is supplementary info
+ }
+ }, []);
+
+ const nodeIdKey = useMemo(() => nodes.map(n => n.id).join(','), [nodes]);
+
+ useEffect(() => {
+ fetchSchedulingSummary();
+ }, [nodeIdKey, fetchSchedulingSummary]);
+
const handleCreate = async () => {
try {
const res = await apiFetch('/nodes', {
@@ -407,6 +448,8 @@ export function NodeManager() {
Type
Endpoint
Status
+ Schedules
+ Updates
Actions
@@ -438,8 +481,82 @@ export function NodeManager() {
{node.type === 'local' ? 'docker.sock' : (node.api_url || '-')}
{getStatusBadge(node.status)}
+
+ {(() => {
+ const summary = nodeSummary[node.id];
+ if (!summary || summary.active_tasks === 0) {
+ return — ;
+ }
+ return (
+
+
+ {summary.active_tasks}
+
+ {summary.next_run_at && (
+
+
+
+
+ next {formatRelativeTime(summary.next_run_at)}
+
+
+
+ {new Date(summary.next_run_at).toLocaleString()}
+
+
+
+ )}
+
+ );
+ })()}
+
+
+ {(() => {
+ const summary = nodeSummary[node.id];
+ return (
+
+ {summary?.auto_update_enabled ? (
+
+
+ Auto
+
+ ) : (
+ Off
+ )}
+ {(summary?.stacks_with_updates ?? 0) > 0 && (
+
+
+
+ {summary!.stacks_with_updates}
+
+
+ )}
+
+ );
+ })()}
+
+
+
+
+ {
+ window.dispatchEvent(new CustomEvent(SENCHO_NAVIGATE_EVENT, {
+ detail: { view: 'scheduled-ops', nodeId: node.id },
+ }));
+ }}
+ >
+
+
+
+ View Schedules
+
+
+
diff --git a/frontend/src/components/ScheduledOperationsView.tsx b/frontend/src/components/ScheduledOperationsView.tsx
index 881162a7..f140a597 100644
--- a/frontend/src/components/ScheduledOperationsView.tsx
+++ b/frontend/src/components/ScheduledOperationsView.tsx
@@ -72,7 +72,12 @@ function formatTimestamp(ts: number | null): string {
return new Date(ts).toLocaleString();
}
-export default function ScheduledOperationsView() {
+interface ScheduledOperationsViewProps {
+ filterNodeId?: number | null;
+ onClearFilter?: () => void;
+}
+
+export default function ScheduledOperationsView({ filterNodeId, onClearFilter }: ScheduledOperationsViewProps) {
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
@@ -103,6 +108,13 @@ export default function ScheduledOperationsView() {
const [stacks, setStacks] = useState([]);
const [nodes, setNodes] = useState([]);
+ const filteredTasks = filterNodeId != null
+ ? tasks.filter(t => t.node_id === filterNodeId)
+ : tasks;
+ const filterNodeName = filterNodeId != null
+ ? nodes.find(n => n.id === filterNodeId)?.name
+ : null;
+
const fetchTasks = useCallback(async () => {
setLoading(true);
try {
@@ -184,13 +196,16 @@ export default function ScheduledOperationsView() {
setFormName('');
setFormAction('restart');
setFormTargetId('');
- setFormNodeId('');
+ setFormNodeId(filterNodeId != null ? String(filterNodeId) : '');
setFormCron('0 3 * * *');
setFormEnabled(true);
setFormPruneTargets(['containers', 'images', 'networks', 'volumes']);
setFormTargetServices([]);
setFormPruneLabelFilter('');
setDialogOpen(true);
+ if (filterNodeId != null) {
+ fetchStacks(String(filterNodeId));
+ }
};
const openEdit = (task: ScheduledTask) => {
@@ -352,11 +367,23 @@ export default function ScheduledOperationsView() {
- {loading && tasks.length === 0 ? (
+ {filterNodeId != null && filterNodeName && (
+
+
+ Filtered to node: {filterNodeName}
+
+
+ Clear filter
+
+
+ )}
+ {loading && filteredTasks.length === 0 ? (
Loading...
- ) : tasks.length === 0 ? (
+ ) : filteredTasks.length === 0 ? (
- No scheduled tasks yet. Create one to automate recurring operations.
+ {filterNodeId != null
+ ? 'No scheduled tasks for this node. Create one to automate recurring operations.'
+ : 'No scheduled tasks yet. Create one to automate recurring operations.'}
) : (
@@ -373,7 +400,7 @@ export default function ScheduledOperationsView() {
- {tasks.map((task) => (
+ {filteredTasks.map((task) => (
{task.name}