From 775fab7d6451c306e9930bf2c8ec486add8f6a87 Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 5 May 2026 15:23:05 -0400 Subject: [PATCH] feat(dashboard): replace duplicate Recent Activity card with Fleet Heartbeat / Stack Restart Map (#932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: open security basics, manual fleet ops, and basic fleet management to Community Realign tier guards to the user-stated philosophy: Community covers deploy/monitor at scale plus security basics, Skipper adds automation and advanced fleet management, Admiral keeps enterprise control. Community now includes: - Trivy install / uninstall / update from the Settings Hub (admin role) - CVE suppressions CRUD (admin role; replicates fleet-wide) - Manual image scan with vuln, secret, and misconfig results - Stack-config scan, scan comparison - Manual fleet snapshots: create, list, view, restore, delete - Per-node Sencho self-update (Check Updates + per-node Update) - Fleet Overview search, sort, filters, node-card expand, auto-refresh Stays paid: - Scan policies with block_on_deploy enforcement (Skipper+) - SBOM (SPDX, CycloneDX), SARIF export (Skipper+) - Bulk Update All across the fleet (Skipper+) - Scheduled snapshot create (now Skipper, was Admiral) - Trivy auto-update toggle, fleet-wide policy push (Admiral) The Settings -> Security tab is unhidden by setting the registry tier to null. The SecuritySection no longer early-returns a PaidGate; the policy list, Add Policy button, and policy dialogs are wrapped in {isPaid && }. The Fleet view drops isPaid gates on the Snapshots tab, Check Updates button, per-node update handlers, OverviewToolbar grid controls, the NodeCard expand affordance, and the auto-refresh notice. The NodeUpdatesSheet receives a canBulkUpdate prop and gates the Update All button on it. useFleetUpdateStatus and useFleetPolling drop their isPaid guards so polling runs for Community; useFleetOverview drops the isPaid wrap on the filter and sort path. Backend route guards are flipped per the matrix above. The scheduler tick and requireScheduledTaskTier add 'snapshot' to the Skipper+ branch. Backend test assertions are inverted for the now-Community endpoints and a positive Skipper-snapshot-task test is added. Documentation across features/, api-reference/, and operations/ is updated to reflect the new tier mapping. * feat: add node last-contact tracking, fleet latency, and stack-restart summary - DatabaseService: add last_successful_contact column to nodes table via idempotent migration; expose updateNodeLastContact() and getStackRestartSummary() methods; include the column in NODE_COLUMNS so getNodes/getNode return it - fleet.ts: record latency_ms and last_successful_contact on each remote node overview fetch; pilot-agent nodes surface pilot_last_seen instead; pass db singleton into fetchRemoteNodeOverview to avoid redundant getInstance calls - dashboard.ts: replace /recent-activity with /stack-restarts endpoint that groups notification_history events by stack and category (crash/autoheal/manual) over a configurable window (default 7 days, max 30) * refactor(dashboard): remove redundant per-route authMiddleware All routes under /api/ are covered by the global auth gate in app.ts. The inline authMiddleware arguments on /configuration and /stack-restarts were redundant with that gate and inconsistent with every other route in the file. Remove them and drop the now-unused import. * refactor(backend): consolidate Date.now(), move SQL aggregation, normalize node row mapping - Capture a single completedAt timestamp in fetchRemoteNodeOverview to eliminate two separate Date.now() calls and ensure latency_ms and last_successful_contact are derived from the same instant - Inline the redundant contactedAt variable; use completedAt directly - Move stack-restart aggregation from JS into SQL (GROUP BY stack_name with CASE/SUM counts), replacing the Map loop in the route handler - Export StackRestartSummary interface from DatabaseService and remove the duplicate local definition in dashboard.ts; handler now returns the query result directly - Add last_successful_contact normalization in decryptNodeRow, mirroring the existing pilot_last_seen pattern - Add authGate reliance comment above dashboardRouter route handlers * feat(dashboard): replace Recent Activity card with context-aware Fleet Heartbeat / Stack Restart Map - Multi-node installs (≥1 remote node): shows Fleet Heartbeat — real-time reachability, latency, and container count per registered node - Local-only installs: shows Stack Restart Map — 7-day restart frequency per stack grouped by crash / auto-heal / manual category - Conditional wrapper (DashboardActivityCard) switches states automatically when the node list changes, with no page reload required - Deletes RecentActivity card and hook (duplicated data already in Recent Alerts) - Extracts formatRelativeTime to frontend/src/lib/utils.ts for reuse * fix(dashboard): add pilot_last_seen to FleetNodeOverview and use it in getLastSeenLabel * fix(fleet): expose mode and pilot_last_seen in overview, consolidate formatRelativeTime, drop em dash - Add `mode` and `pilot_last_seen` (in seconds) to the FleetNodeOverview interface and to both the pilot-agent and HTTP-proxy return paths in fetchRemoteNodeOverview so the frontend getLastSeenLabel pilot branch can fire correctly - Remove the private formatRelativeTime from RecentAlerts.tsx and use the shared implementation from lib/utils, converting the millisecond timestamp at the call site - Replace the em dash in getLatencyLabel with 'n/a' per project rules --- backend/src/routes/dashboard.ts | 22 ++- backend/src/routes/fleet.ts | 46 ++++- backend/src/services/DatabaseService.ts | 41 ++++- frontend/src/components/HomeDashboard.tsx | 4 +- .../dashboard/DashboardActivityCard.tsx | 14 ++ .../components/dashboard/FleetHeartbeat.tsx | 170 ++++++++++++++++++ .../components/dashboard/RecentActivity.tsx | 104 ----------- .../src/components/dashboard/RecentAlerts.tsx | 13 +- .../components/dashboard/StackRestartMap.tsx | 138 ++++++++++++++ frontend/src/components/dashboard/index.ts | 10 +- .../components/dashboard/useFleetHeartbeat.ts | 73 ++++++++ .../components/dashboard/useRecentActivity.ts | 55 ------ .../dashboard/useStackRestartMap.ts | 63 +++++++ frontend/src/lib/utils.ts | 11 ++ 14 files changed, 572 insertions(+), 192 deletions(-) create mode 100644 frontend/src/components/dashboard/DashboardActivityCard.tsx create mode 100644 frontend/src/components/dashboard/FleetHeartbeat.tsx delete mode 100644 frontend/src/components/dashboard/RecentActivity.tsx create mode 100644 frontend/src/components/dashboard/StackRestartMap.tsx create mode 100644 frontend/src/components/dashboard/useFleetHeartbeat.ts delete mode 100644 frontend/src/components/dashboard/useRecentActivity.ts create mode 100644 frontend/src/components/dashboard/useStackRestartMap.ts diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts index bbfef29d..896aa5a3 100644 --- a/backend/src/routes/dashboard.ts +++ b/backend/src/routes/dashboard.ts @@ -1,7 +1,6 @@ import { Router, type Request, type Response } from 'express'; -import { DatabaseService } from '../services/DatabaseService'; +import { DatabaseService, type StackRestartSummary } from '../services/DatabaseService'; import { CloudBackupService } from '../services/CloudBackupService'; -import { authMiddleware } from '../middleware/auth'; import { effectiveTier, effectiveVariant } from '../middleware/tierGates'; import type { LicenseTier, LicenseVariant } from '../services/license-types'; @@ -155,9 +154,8 @@ export function buildLocalConfigurationStatus( }; } -// Sits after authGate and before the remote proxy in index.ts so remote-node -// requests are transparently forwarded to the target Sencho instance. -dashboardRouter.get('/configuration', authMiddleware, (req: Request, res: Response): void => { +// All routes below are protected by the global authGate mounted at app.use('/api', authGate) +dashboardRouter.get('/configuration', (req: Request, res: Response): void => { try { const nodeId = req.nodeId ?? 0; const userId = req.user?.userId ?? 0; @@ -171,17 +169,17 @@ dashboardRouter.get('/configuration', authMiddleware, (req: Request, res: Respon } }); -dashboardRouter.get('/recent-activity', authMiddleware, (req: Request, res: Response): void => { +dashboardRouter.get('/stack-restarts', (req: Request, res: Response): void => { try { const db = DatabaseService.getInstance(); const nodeId = req.nodeId ?? 0; - const rawLimit = parseInt(String(req.query['limit'] ?? '10'), 10); - const limit = isNaN(rawLimit) || rawLimit < 1 ? 10 : Math.min(rawLimit, 50); + const rawDays = parseInt(String(req.query['days'] ?? '7'), 10); + const days = isNaN(rawDays) || rawDays < 1 ? 7 : Math.min(rawDays, 30); - const items = db.getNotificationHistory(nodeId, limit); - res.json(items); + const result: StackRestartSummary[] = db.getStackRestartSummary(nodeId, days); + res.json(result); } catch (error) { - console.error('[Dashboard] Failed to fetch recent activity:', error); - res.status(500).json({ error: 'Failed to fetch recent activity' }); + console.error('[Dashboard] Failed to fetch stack restarts:', error); + res.status(500).json({ error: 'Failed to fetch stack restarts' }); } }); diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index c3c3aa33..95e43a5b 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -73,6 +73,7 @@ interface FleetNodeOverview { id: number; name: string; type: 'local' | 'remote'; + mode?: string; status: 'online' | 'offline' | 'unknown'; stats: { active: number; @@ -87,6 +88,9 @@ interface FleetNodeOverview { disk: { total: number; used: number; free: number; usagePercent: string } | null; } | null; stacks: string[] | null; + latency_ms?: number; + last_successful_contact?: number | null; + pilot_last_seen?: number | null; } /** Resolve the version to compare nodes against (latest from GitHub, or gateway fallback). */ @@ -154,26 +158,46 @@ async function fetchLocalNodeOverview(node: Node): Promise { } : null, }, stacks, + last_successful_contact: node.last_successful_contact ?? null, }; } catch (error) { console.error(`[Fleet] Local node ${node.name} error:`, error); return { id: node.id, name: node.name, type: node.type, status: 'offline', stats: null, systemStats: null, stacks: null, + last_successful_contact: node.last_successful_contact ?? null, }; } } -async function fetchRemoteNodeOverview(node: Node): Promise { +async function fetchRemoteNodeOverview(node: Node, db: DatabaseService): Promise { + // Pilot-agent nodes: use pilot_last_seen as the contact signal; no HTTP fetch. + if (node.mode === 'pilot_agent') { + return { + id: node.id, + name: node.name, + type: node.type, + mode: node.mode, + status: node.pilot_last_seen ? 'online' : 'offline', + stats: null, + systemStats: null, + stacks: null, + last_successful_contact: node.pilot_last_seen ? Math.floor(node.pilot_last_seen / 1000) : null, + pilot_last_seen: node.pilot_last_seen ? Math.floor(node.pilot_last_seen / 1000) : null, + }; + } + if (!node.api_url || !node.api_token) { return { id: node.id, name: node.name, type: node.type, status: 'offline', stats: null, systemStats: null, stacks: null, + last_successful_contact: node.last_successful_contact ?? null, }; } const baseUrl = node.api_url.replace(/\/$/, ''); const headers = { Authorization: `Bearer ${node.api_token}` }; + const t0 = Date.now(); try { const [statsRes, systemStatsRes, stacksRes] = await Promise.allSettled([ @@ -206,20 +230,34 @@ async function fetchRemoteNodeOverview(node: Node): Promise { } : null, } : null; + const completedAt = Date.now(); + const latency_ms = completedAt - t0; + const isOnline = !!(stats || systemStats); + + if (isOnline) { + db.updateNodeLastContact(node.id); + } + return { id: node.id, name: node.name, type: node.type, - status: stats || systemStats ? 'online' : 'offline', + mode: node.mode, + status: isOnline ? 'online' : 'offline', stats, systemStats, stacks, + latency_ms, + last_successful_contact: isOnline + ? Math.floor(completedAt / 1000) + : node.last_successful_contact ?? null, }; } catch (error) { console.error(`[Fleet] Remote node ${node.name} error:`, error); return { - id: node.id, name: node.name, type: node.type, status: 'offline', + id: node.id, name: node.name, type: node.type, mode: node.mode, status: 'offline', stats: null, systemStats: null, stacks: null, + last_successful_contact: node.last_successful_contact ?? null, }; } } @@ -287,7 +325,7 @@ fleetRouter.get('/overview', authMiddleware, async (_req: Request, res: Response const results = await Promise.allSettled( nodes.map(async (node): Promise => { if (node.type === 'remote') { - return fetchRemoteNodeOverview(node); + return fetchRemoteNodeOverview(node, db); } return fetchLocalNodeOverview(node); }), diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index d103f3da..8f41cf20 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -71,6 +71,15 @@ export interface Node { api_token?: string; pilot_last_seen?: number | null; pilot_agent_version?: string | null; + last_successful_contact?: number | null; +} + +export interface StackRestartSummary { + stackName: string; + crash: number; + autoheal: number; + manual: number; + total: number; } export interface PilotEnrollment { @@ -577,6 +586,7 @@ export class DatabaseService { this.migrateMeshTables(); this.migrateNodeLabels(); this.migrateBlueprints(); + this.migrateAddNodeLastContact(); // Reset the cache once at end of constructor in case any migration // populated it via getGlobalSettings() and a subsequent migration @@ -1397,6 +1407,10 @@ export class DatabaseService { } } + private migrateAddNodeLastContact(): void { + this.tryAddColumn('nodes', 'last_successful_contact', 'INTEGER'); + } + // --- Sencho Mesh --- public listMeshStacks(nodeId?: number): Array<{ id: number; node_id: number; stack_name: string; created_at: number; created_by: string | null }> { @@ -1789,6 +1803,25 @@ export class DatabaseService { this.db.prepare('UPDATE notification_history SET dispatch_error = ? WHERE id = ?').run(error, id); } + public getStackRestartSummary(nodeId: number, days: number): StackRestartSummary[] { + const since = Date.now() - days * 86400 * 1000; + return this.db.prepare(` + SELECT + stack_name AS stackName, + SUM(CASE WHEN category = 'deploy_failure' THEN 1 ELSE 0 END) AS crash, + SUM(CASE WHEN category = 'autoheal_triggered' THEN 1 ELSE 0 END) AS autoheal, + SUM(CASE WHEN category = 'stack_restarted' THEN 1 ELSE 0 END) AS manual, + COUNT(*) AS total + FROM notification_history + WHERE node_id = ? + AND timestamp >= ? + AND category IN ('deploy_failure', 'autoheal_triggered', 'stack_restarted') + AND stack_name IS NOT NULL + GROUP BY stack_name + ORDER BY total DESC + `).all(nodeId, since) as StackRestartSummary[]; + } + // --- Container Metrics --- public addContainerMetric(metric: Omit): void { @@ -1854,11 +1887,12 @@ export class DatabaseService { api_token: row.api_token ? crypto.decrypt(row.api_token) : '', pilot_last_seen: row.pilot_last_seen ?? null, pilot_agent_version: row.pilot_agent_version ?? null, + last_successful_contact: row.last_successful_contact ?? null, }; } private static readonly NODE_COLUMNS = - 'id, name, type, compose_dir, is_default, status, created_at, api_url, api_token, mode, pilot_last_seen, pilot_agent_version'; + 'id, name, type, compose_dir, is_default, status, created_at, api_url, api_token, mode, pilot_last_seen, pilot_agent_version, last_successful_contact'; public getNodes(): Node[] { const stmt = this.db.prepare(`SELECT ${DatabaseService.NODE_COLUMNS} FROM nodes ORDER BY is_default DESC, name ASC`); @@ -1952,6 +1986,11 @@ export class DatabaseService { this.db.prepare('UPDATE nodes SET status = ? WHERE id = ?').run(status, id); } + public updateNodeLastContact(nodeId: number): void { + this.db.prepare('UPDATE nodes SET last_successful_contact = ? WHERE id = ?') + .run(Math.floor(Date.now() / 1000), nodeId); + } + // --- Pilot enrollments --- public getPilotEnrollment(nodeId: number): PilotEnrollment | undefined { diff --git a/frontend/src/components/HomeDashboard.tsx b/frontend/src/components/HomeDashboard.tsx index 01dc110f..3b1d1e2f 100644 --- a/frontend/src/components/HomeDashboard.tsx +++ b/frontend/src/components/HomeDashboard.tsx @@ -6,10 +6,10 @@ import { ResourceGauges, StackHealthTable, ConfigurationStatus, - RecentActivity, RecentAlerts, useDashboardData, } from './dashboard'; +import { DashboardActivityCard } from './dashboard/DashboardActivityCard'; interface HomeDashboardProps { onNavigateToStack?: (stackFile: string) => void; @@ -53,7 +53,7 @@ export default function HomeDashboard({ onNavigateToStack, onOpenSettingsSection
- +
n.type === 'remote'); + + if (hasRemoteNodes) { + return ; + } + + return ; +} diff --git a/frontend/src/components/dashboard/FleetHeartbeat.tsx b/frontend/src/components/dashboard/FleetHeartbeat.tsx new file mode 100644 index 00000000..d92182ef --- /dev/null +++ b/frontend/src/components/dashboard/FleetHeartbeat.tsx @@ -0,0 +1,170 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Radio, CheckCircle2 } from 'lucide-react'; +import { formatRelativeTime } from '@/lib/utils'; +import { useFleetHeartbeat } from './useFleetHeartbeat'; +import { useNodes } from '@/context/NodeContext'; +import type { FleetNodeOverview } from './useFleetHeartbeat'; +import type { Node } from '@/context/NodeContext'; + +function StatusDot({ status }: { status: 'online' | 'offline' | 'unknown' }) { + const colorClass = + status === 'online' + ? 'bg-success' + : status === 'unknown' + ? 'bg-warning' + : 'bg-destructive'; + return ( +