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 ( +