diff --git a/backend/src/index.ts b/backend/src/index.ts index cf2d4e40..d923cec0 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -38,6 +38,7 @@ import { registriesRouter } from './routes/registries'; import { systemMaintenanceRouter } from './routes/systemMaintenance'; import { templatesRouter } from './routes/templates'; import { securityRouter } from './routes/security'; +import { dashboardRouter } from './routes/dashboard'; import { containersRouter, portsRouter } from './routes/containers'; import { nodesRouter } from './routes/nodes'; import { stacksRouter } from './routes/stacks'; @@ -114,6 +115,7 @@ app.use('/api/templates', templatesRouter); app.use('/api/security', securityRouter); app.use('/api/containers', containersRouter); app.use('/api/ports', portsRouter); +app.use('/api/dashboard', dashboardRouter); app.use('/api/nodes', nodesRouter); app.use('/api/stacks', stacksRouter); diff --git a/backend/src/routes/dashboard.ts b/backend/src/routes/dashboard.ts new file mode 100644 index 00000000..e0b12c9a --- /dev/null +++ b/backend/src/routes/dashboard.ts @@ -0,0 +1,187 @@ +import { Router, type Request, type Response } from 'express'; +import { DatabaseService } 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/LicenseService'; + +export const dashboardRouter = Router(); + +export interface AgentStatus { + configured: boolean; + enabled: boolean; +} + +export interface ConfigurationStatus { + tier: LicenseTier; + variant: LicenseVariant; + notifications: { + agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus }; + alertRules: number; + routingRules: { count: number; enabledCount: number; locked: boolean; requiredTier: 'admiral' }; + }; + automation: { + autoHeal: { total: number; enabled: number }; + autoUpdate: { enabled: number; total: number }; + scheduledTasks: { total: number; enabled: number; locked: boolean; requiredTier: 'admiral' }; + webhooks: { total: number; enabled: number; locked: boolean; requiredTier: 'skipper' }; + }; + security: { + mfaEnabled: boolean | null; + ssoEnabled: boolean; + ssoProvider: string | null; + scanPolicies: { total: number; enabled: number; locked: boolean; requiredTier: 'skipper' }; + }; + thresholds: { + cpuLimit: number; + ramLimit: number; + diskLimit: number; + dockerJanitorGb: number; + globalCrash: boolean; + }; + backup: { + provider: 'disabled' | 'sencho' | 'custom'; + autoUpload: boolean; + locked: boolean; + requiredTier: 'admiral'; + }; +} + +export function buildLocalConfigurationStatus( + nodeId: number, + userId: number, + tier: LicenseTier, + variant: LicenseVariant, +): ConfigurationStatus { + const db = DatabaseService.getInstance(); + const isPaid = tier === 'paid'; + const isAdmiral = isPaid && variant === 'admiral'; + + const agents = db.getAgents(nodeId); + const agentByType = (type: 'discord' | 'slack' | 'webhook'): AgentStatus => { + const a = agents.find(ag => ag.type === type); + return { configured: !!a?.url, enabled: a?.enabled ?? false }; + }; + + const alertRules = db.getStackAlerts().length; + const notifRoutes = db.getNotificationRoutes(); + + const healPolicies = db.getAutoHealPolicies(); + const autoUpdateMap = db.getStackAutoUpdateSettingsForNode(nodeId); + const autoUpdateEnabled = Object.values(autoUpdateMap).filter(Boolean).length; + const autoUpdateTotal = Object.keys(autoUpdateMap).length; + const scheduledTasks = db.getScheduledTasks(); + const webhooks = db.getWebhooks(); + + const mfaRow = userId ? db.getUserMfa(userId) : undefined; + const ssoConfigs = db.getSSOConfigs(); + const enabledSso = ssoConfigs.find(c => c.enabled === 1); + const scanPolicies = db.getScanPolicies(); + + const settings = db.getGlobalSettings(); + const cpuLimit = parseInt(settings['host_cpu_limit'] ?? '90', 10); + const ramLimit = parseInt(settings['host_ram_limit'] ?? '90', 10); + const diskLimit = parseInt(settings['host_disk_limit'] ?? '90', 10); + const dockerJanitorGb = parseFloat(settings['docker_janitor_gb'] ?? '5'); + const globalCrash = settings['global_crash'] === '1'; + + const cloudSvc = CloudBackupService.getInstance(); + const cloudProvider = cloudSvc.getProvider(); + const cloudAutoUpload = cloudSvc.isAutoUploadOn(); + + return { + tier, + variant, + notifications: { + agents: { + discord: agentByType('discord'), + slack: agentByType('slack'), + webhook: agentByType('webhook'), + }, + alertRules, + routingRules: { + count: notifRoutes.length, + enabledCount: notifRoutes.filter(r => r.enabled).length, + locked: !isAdmiral, + requiredTier: 'admiral', + }, + }, + automation: { + autoHeal: { + total: healPolicies.length, + enabled: healPolicies.filter(p => p.enabled === 1).length, + }, + autoUpdate: { + enabled: autoUpdateEnabled, + total: autoUpdateTotal, + }, + scheduledTasks: { + total: scheduledTasks.length, + enabled: scheduledTasks.filter(t => t.enabled === 1).length, + locked: !isAdmiral, + requiredTier: 'admiral', + }, + webhooks: { + total: webhooks.length, + enabled: webhooks.filter(w => w.enabled).length, + locked: !isPaid, + requiredTier: 'skipper', + }, + }, + security: { + mfaEnabled: mfaRow ? mfaRow.enabled === 1 : null, + ssoEnabled: !!enabledSso, + ssoProvider: enabledSso?.provider ?? null, + scanPolicies: { + total: scanPolicies.length, + enabled: scanPolicies.filter(p => p.enabled === 1).length, + locked: !isPaid, + requiredTier: 'skipper', + }, + }, + thresholds: { + cpuLimit, + ramLimit, + diskLimit, + dockerJanitorGb, + globalCrash, + }, + backup: { + provider: cloudProvider, + autoUpload: cloudAutoUpload, + locked: !isAdmiral, + requiredTier: 'admiral', + }, + }; +} + +// 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 => { + try { + const nodeId = req.nodeId ?? 0; + const userId = req.user?.userId ?? 0; + const tier = effectiveTier(req); + const variant = effectiveVariant(req); + + res.json(buildLocalConfigurationStatus(nodeId, userId, tier, variant)); + } catch (error) { + console.error('[Dashboard] Failed to build configuration status:', error); + res.status(500).json({ error: 'Failed to fetch configuration status' }); + } +}); + +dashboardRouter.get('/recent-activity', authMiddleware, (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 items = db.getNotificationHistory(nodeId, limit); + res.json(items); + } catch (error) { + console.error('[Dashboard] Failed to fetch recent activity:', error); + res.status(500).json({ error: 'Failed to fetch recent activity' }); + } +}); diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index 3b97510d..ac3ba626 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -22,6 +22,8 @@ import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; import { CloudBackupService } from '../services/CloudBackupService'; import { NotificationService } from '../services/NotificationService'; +import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard'; +import { LicenseService } from '../services/LicenseService'; const updateTracker = FleetUpdateTrackerService.getInstance(); const UPDATE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes @@ -329,6 +331,70 @@ fleetRouter.get('/overview', authMiddleware, async (_req: Request, res: Response } }); +interface FleetNodeConfiguration { + id: number; + name: string; + type: 'local' | 'remote'; + status: 'online' | 'offline'; + configuration: ConfigurationStatus | null; +} + +fleetRouter.get('/configuration', authMiddleware, async (req: Request, res: Response): Promise => { + try { + const db = DatabaseService.getInstance(); + const nodes = db.getNodes(); + const userId = req.user?.userId ?? 0; + const localTier = LicenseService.getInstance().getTier(); + const localVariant = LicenseService.getInstance().getVariant(); + + const results = await Promise.allSettled( + nodes.map(async (node: Node): Promise => { + if (node.type === 'local') { + return { + id: node.id, + name: node.name, + type: 'local', + status: 'online', + configuration: buildLocalConfigurationStatus(node.id, userId, localTier, localVariant), + }; + } + + if (!node.api_url || !node.api_token) { + return { id: node.id, name: node.name, type: 'remote', status: 'offline', configuration: null }; + } + + try { + const resp = await fetch( + `${node.api_url.replace(/\/$/, '')}/api/dashboard/configuration`, + { headers: { Authorization: `Bearer ${node.api_token}` }, signal: AbortSignal.timeout(10000) }, + ); + const configuration = resp.ok ? (await resp.json() as ConfigurationStatus) : null; + return { + id: node.id, + name: node.name, + type: 'remote', + status: configuration ? 'online' : 'offline', + configuration, + }; + } catch { + return { id: node.id, name: node.name, type: 'remote', status: 'offline', configuration: null }; + } + }), + ); + + const fleet: FleetNodeConfiguration[] = results.map((result, i) => { + if (result.status === 'fulfilled') return result.value; + console.error(`[Fleet] Configuration fetch failed for node ${nodes[i].name}:`, result.reason); + return { id: nodes[i].id, name: nodes[i].name, type: nodes[i].type, status: 'offline', configuration: null }; + }); + + res.json(fleet); + } catch (error) { + console.error('[Fleet] Configuration overview error:', error); + res.status(500).json({ error: 'Failed to fetch fleet configuration' }); + } +}); + fleetRouter.get('/node/:nodeId/stacks', authMiddleware, async (req: Request, res: Response): Promise => { if (!requirePaid(req, res)) return; diff --git a/docs/features/dashboard.mdx b/docs/features/dashboard.mdx index 03d4ec78..0722fd03 100644 --- a/docs/features/dashboard.mdx +++ b/docs/features/dashboard.mdx @@ -1,12 +1,12 @@ --- title: Dashboard -description: Real-time system stats, stack health, historical metrics, and recent alerts for your host machine. +description: Real-time system stats, stack health, configuration overview, and recent activity for your active node. --- -The **Home** tab is the first thing you see after logging in. It provides a live overview of your node's health, resource usage, stack status, and recent alert activity. +The **Home** tab is the first thing you see after logging in. It provides a live overview of your node's health, resource usage, stack status, active configuration, and recent activity. - Sencho dashboard showing status masthead, unified gauge strip, stack health table, and historical charts + Sencho dashboard showing status masthead, unified gauge strip, and stack health table ## Status masthead @@ -57,18 +57,58 @@ A mono table of every stack discovered in your `COMPOSE_DIR`, sorted by load so Warning rows take on a subtle amber wash; critical rows take on a rose wash. Click any row to jump to that stack's editor. If you have more than 8 stacks, the list paginates automatically. -## Historical charts +## Configuration Status -Two area charts display time-series data sampled at one-minute intervals, retained for up to 24 hours: +The Configuration Status card gives you an at-a-glance view of every toggleable automation and security feature on the active node, so nothing is silently off when you expect it to be on. -- **CPU** - normalized total CPU percentage across all managed containers over host cores, stroked in cyan with the peak highlighted in amber. -- **Memory** - total memory allocated by managed containers in GB. + + Dashboard showing the Configuration Status card on the left and the Recent Activity feed on the right + -Hover over a data point to see the exact value at that moment. +The card is divided into four sections: - - Charts only show data from the moment Sencho started collecting. If you just installed Sencho, they will be mostly empty until metrics accumulate. - +### Notifications & Alerts + +| Row | What it shows | +|-----|---------------| +| **Notification agents** | Which delivery agents (Discord, Slack, custom webhook) are configured and enabled | +| **Alert rules** | Number of per-stack alert rules in effect | +| **Notification routing** | Number of enabled routing rules that direct specific categories to specific agents | + +### Automation + +| Row | What it shows | +|-----|---------------| +| **Auto-heal policies** | Enabled and total crash-recovery policies across all stacks | +| **Auto-update stacks** | Stacks enrolled in automated image-update checks | +| **Webhooks** | Outbound webhook triggers that fire on stack events | +| **Scheduled tasks** | Active scheduled operations (backups, restarts, scripts) | + +### Security + +| Row | What it shows | +|-----|---------------| +| **MFA** | Whether multi-factor authentication is set up for your account | +| **SSO** | Whether single sign-on is enabled and the active provider | +| **Vulnerability scanning** | Number of enabled scan policies | + +### Backups & Thresholds + +| Row | What it shows | +|-----|---------------| +| **Cloud Backup** | Active cloud backup provider (or Disabled) | +| **Alert thresholds** | Current CPU, RAM, and disk alert thresholds | +| **Crash detection** | Whether global crash detection is active | + +**Click any row** to jump directly to the settings section that manages it. + +Rows for features locked to a higher tier are shown in a muted state with an upgrade indicator. The data refreshes automatically every 60 seconds and updates immediately when any setting changes. + +## Recent Activity + +The Recent Activity feed, to the right of Configuration Status, shows the ten most recent events on the active node: deployments, image updates, auto-heal actions, vulnerability scan results, cloud backups, and system notices. Each entry shows a category icon, the event message, and a relative timestamp. + +If no activity has been recorded yet, the feed shows an empty state. ## Recent alerts diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index 416a3fa7..bf24061f 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -37,10 +37,11 @@ Below the masthead, switch between two layouts: ### Tabs -The Fleet page has two tabs: +The Fleet page has three tabs: - **Overview**: the monitoring view described on this page - **Snapshots**: fleet-wide backup snapshots (covered in [Fleet Backups](/features/fleet-backups)) +- **Status**: fleet-wide feature status rollup (covered below) ### Action buttons @@ -176,6 +177,33 @@ If you update the local node, a confirmation dialog appears first, then a reconn --- +## Fleet Status tab + +The **Status** tab gives you a fleet-wide summary of which automations and security features are active on each node, without having to open each node's settings individually. + + + Fleet Configuration tab showing per-node configuration summaries with one online node and one offline node + + +Each node appears as a card. Online nodes display a compact two-column summary grid: + +| Field | What it shows | +|-------|---------------| +| **Agents** | Delivery agents configured on this node | +| **Alert rules** | Number of per-stack alert rules | +| **Auto-heal** | Active auto-heal policies | +| **Webhooks** | Outbound webhook triggers | +| **MFA** | Whether MFA is set up for the admin account | +| **Scanning** | Active vulnerability scan policies | +| **Backup** | Cloud backup provider, or Disabled | +| **Crash detect** | Whether global crash detection is on | + +Offline nodes show a muted card with "Node is unreachable. Configuration unavailable." The online/offline badge confirms the node's current reachability at the time of the last fetch. + +The tab fetches configuration data from all nodes in parallel. A dead node does not block the others from rendering. + +--- + ## How fleet data is fetched Fleet View queries all registered nodes in parallel. Each node responds independently; one slow or offline node does not block the others. Local node data comes from the Docker socket and system stats directly. Remote node data is fetched over the Distributed API proxy using each node's Bearer token. diff --git a/docs/images/dashboard/configuration-status.png b/docs/images/dashboard/configuration-status.png new file mode 100644 index 00000000..da3e4a35 Binary files /dev/null and b/docs/images/dashboard/configuration-status.png differ diff --git a/docs/images/fleet/configuration-tab.png b/docs/images/fleet/configuration-tab.png new file mode 100644 index 00000000..7c425a0c Binary files /dev/null and b/docs/images/fleet/configuration-tab.png differ diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 9bb13ed6..2832ad36 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -6,6 +6,7 @@ import TerminalComponent from './Terminal'; import ErrorBoundary from './ErrorBoundary'; import HomeDashboard from './HomeDashboard'; import type { NotificationItem } from './dashboard/types'; +import type { SectionId } from './settings/types'; import BashExecModal from './BashExecModal'; import HostConsole from './HostConsole'; import { AdmiralGate } from './AdmiralGate'; @@ -360,7 +361,7 @@ export default function EditorLayout() { const [notifications, setNotifications] = useState([]); const [tickerConnected, setTickerConnected] = useState(false); const [settingsModalOpen, setSettingsModalOpen] = useState(false); - const [settingsInitialSection, setSettingsInitialSection] = useState<'account' | 'labels' | 'nodes'>('account'); + const [settingsInitialSection, setSettingsInitialSection] = useState('account'); const [alertSheetOpen, setAlertSheetOpen] = useState(false); const [alertSheetStack, setAlertSheetStack] = useState(''); const [autoHealStackName, setAutoHealStackName] = useState(null); @@ -2922,6 +2923,7 @@ export default function EditorLayout() { onNavigateToStack={(stackFile) => { loadFile(stackFile); }} notifications={notifications} onClearNotifications={clearAllNotifications} + onOpenSettings={(section) => { setSettingsInitialSection(section); setSettingsModalOpen(true); }} /> )} diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index ca46e450..da643be2 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -27,6 +27,7 @@ import { apiFetch, fetchForNode } from '@/lib/api'; import { useLicense } from '@/context/LicenseContext'; import { PaidGate } from './PaidGate'; import FleetSnapshots from './FleetSnapshots'; +import { FleetConfiguration } from './fleet/FleetConfiguration'; import { toast } from '@/components/ui/toast-store'; import { LabelDot } from './LabelPill'; import { type Label as StackLabel, type LabelColor } from './label-types'; @@ -1041,6 +1042,11 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { )} + + + Status + +
@@ -1337,6 +1343,9 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { )} + + + {/* Reconnecting overlay shown when local node is updating */} diff --git a/frontend/src/components/HomeDashboard.tsx b/frontend/src/components/HomeDashboard.tsx index 156c19d6..c998ab19 100644 --- a/frontend/src/components/HomeDashboard.tsx +++ b/frontend/src/components/HomeDashboard.tsx @@ -1,10 +1,12 @@ import { useNodes } from '@/context/NodeContext'; import type { NotificationItem } from './dashboard/types'; +import type { SectionId } from './settings/types'; import { HealthStatusBar, ResourceGauges, StackHealthTable, - HistoricalCharts, + ConfigurationStatus, + RecentActivity, RecentAlerts, useDashboardData, } from './dashboard'; @@ -13,11 +15,12 @@ interface HomeDashboardProps { onNavigateToStack?: (stackFile: string) => void; notifications: NotificationItem[]; onClearNotifications: () => void | Promise; + onOpenSettings?: (section: SectionId) => void; } const NOOP = () => {}; -export default function HomeDashboard({ onNavigateToStack, notifications, onClearNotifications }: HomeDashboardProps) { +export default function HomeDashboard({ onNavigateToStack, notifications, onClearNotifications, onOpenSettings }: HomeDashboardProps) { const { activeNode, nodes } = useNodes(); const data = useDashboardData(); const activeNodeName = activeNode?.name || 'Local'; @@ -48,10 +51,10 @@ export default function HomeDashboard({ onNavigateToStack, notifications, onClea onNavigateToStack={onNavigateToStack ?? NOOP} /> - +
+ + +
void; +} + +function StatusBadge({ value, locked, requiredTier }: { + value: string; + locked?: boolean; + requiredTier?: string; +}) { + if (locked && requiredTier) { + const label = requiredTier === 'admiral' ? 'Admiral' : 'Skipper'; + return ( + + {label} + + ); + } + const lower = value.toLowerCase(); + if (lower === 'on' || lower === 'enabled') { + return ( + + ON + + ); + } + if (lower === 'off' || lower === 'disabled') { + return ( + + OFF + + ); + } + return ( + {value} + ); +} + +function Row({ label, value, locked, requiredTier, onClick }: { + label: string; + value: string; + locked?: boolean; + requiredTier?: string; + onClick?: () => void; +}) { + const labelClass = `text-xs ${locked ? 'text-stat-subtitle/60' : 'text-stat-subtitle'}`; + + if (onClick) { + return ( + + ); + } + + return ( +
+ {label} + +
+ ); +} + +function SectionHeader({ icon: Icon, label }: { icon: typeof Bell; label: string }) { + return ( +
+ + {label} +
+ ); +} + +function SkeletonRow() { + return ( +
+
+
+
+ ); +} + +export function ConfigurationStatus({ onOpenSettings }: ConfigurationStatusProps) { + const { status, loading } = useConfigurationStatus(); + + const open = (section: SectionId) => () => onOpenSettings?.(section); + + if (loading) { + return ( + + + Configuration Status + + + {Array.from({ length: 8 }).map((_, i) => )} + + + ); + } + + if (!status) { + return ( + + + Configuration Status + + +

Unable to load configuration.

+
+
+ ); + } + + const { notifications, automation, security, thresholds, backup } = status; + + const agentSummary = (() => { + const { discord, slack, webhook } = notifications.agents; + const active = [ + discord.enabled ? 'Discord' : null, + slack.enabled ? 'Slack' : null, + webhook.enabled ? 'Webhook' : null, + ].filter(Boolean); + return active.length === 0 ? 'None' : active.join(', '); + })(); + + const ssoLabel = (() => { + if (!security.ssoEnabled) return 'Off'; + const names: Record = { + oidc_custom: 'OIDC', oidc_google: 'Google', oidc_github: 'GitHub', + oidc_okta: 'Okta', ldap: 'LDAP', + }; + return (security.ssoProvider && names[security.ssoProvider]) ?? 'Enabled'; + })(); + + return ( + + + Configuration Status + + +
+ + + + + + + + + + + + + + + + + + + + +
+
+
+ ); +} diff --git a/frontend/src/components/dashboard/HistoricalCharts.tsx b/frontend/src/components/dashboard/HistoricalCharts.tsx deleted file mode 100644 index 07192d52..00000000 --- a/frontend/src/components/dashboard/HistoricalCharts.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { useMemo } from 'react'; -import { Card, CardContent, CardHeader } from '@/components/ui/card'; -import { Area, AreaChart, CartesianGrid, ReferenceDot, XAxis, YAxis } from 'recharts'; -import { ChartContainer, ChartTooltip, ChartTooltipContent } from '@/components/ui/chart'; -import { Skeleton } from '@/components/ui/skeleton'; -import type { MetricPoint, SystemStats } from './types'; - -interface HistoricalChartsProps { - metrics: MetricPoint[]; - systemStats: SystemStats | null; -} - -const chartConfig = { - cpu: { label: 'CPU Usage (%)', color: 'var(--chart-1)' }, - ram: { label: 'RAM Usage (GB)', color: 'var(--chart-2)' }, -}; - -export function HistoricalCharts({ metrics, systemStats }: HistoricalChartsProps) { - const chartData = useMemo(() => { - const buckets: Record = {}; - const cores = systemStats?.cpu.cores || 1; - - metrics.forEach(m => { - const date = new Date(m.timestamp); - date.setSeconds(0, 0); - const key = date.getTime() + ''; - - if (!buckets[key]) { - buckets[key] = { - time: date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), - timestamp: date.getTime(), - cpu: 0, - ram: 0, - }; - } - buckets[key].cpu += (m.cpu_percent / cores); - buckets[key].ram += (m.memory_mb / 1024); - }); - - return Object.values(buckets).sort((a, b) => a.timestamp - b.timestamp); - }, [metrics, systemStats]); - - const hasData = chartData.length > 0; - - const cpuPeak = useMemo(() => { - if (chartData.length === 0) return null; - let peak = chartData[0]; - for (const row of chartData) { - if (row.cpu > peak.cpu) peak = row; - } - return peak; - }, [chartData]); - - return ( -
- - -
-

CPU

- - last 24h · normalized over cores - -
-
- - {hasData ? ( - - - - - - - - - - - `${Number(val).toFixed(0)}%`} domain={[0, (dataMax: number) => Math.max(100, Math.ceil(dataMax / 10) * 10)]} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} /> - } /> - - {cpuPeak ? ( - - ) : null} - - - ) : ( -
- - Waiting for CPU metrics... -
- )} -
-
- - - -
-

Memory

- - last 24h · total allocation - -
-
- - {hasData ? ( - - - - - - - - - - - `${Number(val).toFixed(1)} GB`} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} /> - } /> - - - - ) : ( -
- - Waiting for RAM metrics... -
- )} -
-
-
- ); -} diff --git a/frontend/src/components/dashboard/RecentActivity.tsx b/frontend/src/components/dashboard/RecentActivity.tsx new file mode 100644 index 00000000..543b03f2 --- /dev/null +++ b/frontend/src/components/dashboard/RecentActivity.tsx @@ -0,0 +1,104 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Activity, AlertOctagon, AlertTriangle, Cloud, RefreshCw, + RotateCcw, Search, ServerCrash, CheckCircle2, Info, +} from 'lucide-react'; +import { useRecentActivity } from './useRecentActivity'; + +function formatRelativeTime(timestamp: number): string { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +} + +type IconType = typeof Activity; + +const CATEGORY_ICONS: Record = { + deploy_failure: { icon: ServerCrash, className: 'text-destructive' }, + deploy_success: { icon: CheckCircle2, className: 'text-success' }, + image_update_applied: { icon: RefreshCw, className: 'text-info' }, + image_update_available: { icon: RefreshCw, className: 'text-warning' }, + auto_heal_restarted: { icon: RotateCcw, className: 'text-info' }, + auto_heal_failed: { icon: AlertOctagon, className: 'text-destructive' }, + auto_heal_policy_disabled: { icon: AlertTriangle, className: 'text-warning' }, + scan_finding: { icon: Search, className: 'text-warning' }, + cloud_backup_success: { icon: Cloud, className: 'text-success' }, + cloud_backup_failed: { icon: Cloud, className: 'text-destructive' }, +}; + +const LEVEL_ICONS: Record = { + info: { icon: Info, className: 'text-info' }, + warning: { icon: AlertTriangle, className: 'text-warning' }, + error: { icon: AlertOctagon, className: 'text-destructive' }, +}; + +export function RecentActivity() { + const { items, loading } = useRecentActivity(10); + + if (loading) { + return ( + + + Recent Activity + + + {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+
+
+ ))} + + + ); + } + + return ( + + +
+ Recent Activity + +
+
+ + {items.length === 0 ? ( +
+ + No recent activity. +
+ ) : ( +
+ {items.map(item => { + const categoryConfig = item.category ? CATEGORY_ICONS[item.category] : null; + const levelConfig = LEVEL_ICONS[item.level] ?? LEVEL_ICONS.info; + const { icon: Icon, className } = categoryConfig ?? levelConfig; + return ( +
+ + + {item.message} + + + {formatRelativeTime(item.timestamp)} + +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/dashboard/index.ts b/frontend/src/components/dashboard/index.ts index 5e66c86e..eb77803d 100644 --- a/frontend/src/components/dashboard/index.ts +++ b/frontend/src/components/dashboard/index.ts @@ -1,7 +1,12 @@ export { HealthStatusBar } from './HealthStatusBar'; export { ResourceGauges } from './ResourceGauges'; export { StackHealthTable } from './StackHealthTable'; -export { HistoricalCharts } from './HistoricalCharts'; export { RecentAlerts } from './RecentAlerts'; +export { ConfigurationStatus } from './ConfigurationStatus'; +export { RecentActivity } from './RecentActivity'; export { useDashboardData } from './useDashboardData'; +export { useConfigurationStatus } from './useConfigurationStatus'; +export { useRecentActivity } from './useRecentActivity'; export type * from './types'; +export type { ConfigurationStatus as ConfigurationStatusPayload } from './useConfigurationStatus'; +export type { ActivityItem } from './useRecentActivity'; diff --git a/frontend/src/components/dashboard/useConfigurationStatus.ts b/frontend/src/components/dashboard/useConfigurationStatus.ts new file mode 100644 index 00000000..f0d87926 --- /dev/null +++ b/frontend/src/components/dashboard/useConfigurationStatus.ts @@ -0,0 +1,84 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useNodes } from '@/context/NodeContext'; +import { apiFetch } from '@/lib/api'; +import { visibilityInterval } from '@/lib/utils'; + +export interface AgentStatus { + configured: boolean; + enabled: boolean; +} + +export interface ConfigurationStatus { + tier: 'community' | 'paid'; + variant: 'skipper' | 'admiral' | null; + notifications: { + agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus }; + alertRules: number; + routingRules: { count: number; enabledCount: number; locked: boolean; requiredTier: 'admiral' }; + }; + automation: { + autoHeal: { total: number; enabled: number }; + autoUpdate: { enabled: number; total: number }; + scheduledTasks: { total: number; enabled: number; locked: boolean; requiredTier: 'admiral' }; + webhooks: { total: number; enabled: number; locked: boolean; requiredTier: 'skipper' }; + }; + security: { + mfaEnabled: boolean | null; + ssoEnabled: boolean; + ssoProvider: string | null; + scanPolicies: { total: number; enabled: number; locked: boolean; requiredTier: 'skipper' }; + }; + thresholds: { + cpuLimit: number; + ramLimit: number; + diskLimit: number; + dockerJanitorGb: number; + globalCrash: boolean; + }; + backup: { + provider: 'disabled' | 'sencho' | 'custom'; + autoUpload: boolean; + locked: boolean; + requiredTier: 'admiral'; + }; +} + +export function useConfigurationStatus() { + const { activeNode } = useNodes(); + const nodeId = activeNode?.id; + const nodeIdRef = useRef(nodeId); + useEffect(() => { nodeIdRef.current = nodeId; }, [nodeId]); + + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchStatus = useCallback(async () => { + try { + const res = await apiFetch('/dashboard/configuration'); + if (!res.ok) return; + const data = await res.json() as ConfigurationStatus; + setStatus(data); + } catch { + // Silent; stale data stays visible + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + setStatus(null); + setLoading(true); + const currentNodeId = nodeId; + const guard = () => { if (nodeIdRef.current === currentNodeId) void fetchStatus(); }; + guard(); + return visibilityInterval(guard, 60_000); + }, [nodeId, fetchStatus]); + + useEffect(() => { + const handler = () => void fetchStatus(); + window.addEventListener('sencho:state-invalidate', handler); + return () => window.removeEventListener('sencho:state-invalidate', handler); + }, [fetchStatus]); + + return { status, loading }; +} diff --git a/frontend/src/components/dashboard/useDashboardData.ts b/frontend/src/components/dashboard/useDashboardData.ts index 8a9ce1dd..08441214 100644 --- a/frontend/src/components/dashboard/useDashboardData.ts +++ b/frontend/src/components/dashboard/useDashboardData.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useNodes } from '@/context/NodeContext'; import { apiFetch } from '@/lib/api'; +import { visibilityInterval } from '@/lib/utils'; import type { Stats, SystemStats, @@ -14,43 +15,6 @@ const DEFAULT_STATS: Stats = { active: 0, managed: 0, unmanaged: 0, exited: 0, t const SPARK_BUCKETS = 20; const SPARK_WINDOW_MS = 10 * 60 * 1000; -/** - * Start a polling interval that pauses when the tab is hidden. - * Returns a cleanup function that stops the interval. - */ -function visibilityInterval(fn: () => void, ms: number): () => void { - let interval: ReturnType | null = null; - - const start = () => { - if (interval) return; - interval = setInterval(fn, ms); - }; - - const stop = () => { - if (interval) { - clearInterval(interval); - interval = null; - } - }; - - const onVisChange = () => { - if (document.hidden) { - stop(); - } else { - fn(); // Fetch immediately on re-focus - start(); - } - }; - - document.addEventListener('visibilitychange', onVisChange); - start(); - - return () => { - stop(); - document.removeEventListener('visibilitychange', onVisChange); - }; -} - function bucketCpu(points: MetricPoint[], windowMs: number, buckets: number): number[] { if (points.length === 0) return Array(buckets).fill(0); const now = Date.now(); diff --git a/frontend/src/components/dashboard/useRecentActivity.ts b/frontend/src/components/dashboard/useRecentActivity.ts new file mode 100644 index 00000000..7982a03b --- /dev/null +++ b/frontend/src/components/dashboard/useRecentActivity.ts @@ -0,0 +1,55 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useNodes } from '@/context/NodeContext'; +import { apiFetch } from '@/lib/api'; +import { visibilityInterval } from '@/lib/utils'; + +export interface ActivityItem { + id: number; + level: 'info' | 'warning' | 'error'; + category?: string; + message: string; + timestamp: number; + is_read: boolean; + stack_name?: string; + container_name?: string; +} + +export function useRecentActivity(limit = 10) { + const { activeNode } = useNodes(); + const nodeId = activeNode?.id; + const nodeIdRef = useRef(nodeId); + useEffect(() => { nodeIdRef.current = nodeId; }, [nodeId]); + + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchActivity = useCallback(async () => { + try { + const res = await apiFetch(`/dashboard/recent-activity?limit=${limit}`); + if (!res.ok) return; + const data = await res.json() as ActivityItem[]; + setItems(data); + } catch { + // Silent; stale data stays + } finally { + setLoading(false); + } + }, [limit]); + + useEffect(() => { + setItems([]); + setLoading(true); + const currentNodeId = nodeId; + const guard = () => { if (nodeIdRef.current === currentNodeId) void fetchActivity(); }; + guard(); + return visibilityInterval(guard, 30_000); + }, [nodeId, fetchActivity]); + + useEffect(() => { + const handler = () => void fetchActivity(); + window.addEventListener('sencho:state-invalidate', handler); + return () => window.removeEventListener('sencho:state-invalidate', handler); + }, [fetchActivity]); + + return { items, loading }; +} diff --git a/frontend/src/components/fleet/FleetConfiguration.tsx b/frontend/src/components/fleet/FleetConfiguration.tsx new file mode 100644 index 00000000..d0b52bd7 --- /dev/null +++ b/frontend/src/components/fleet/FleetConfiguration.tsx @@ -0,0 +1,194 @@ +import { useState, useEffect, useCallback } from 'react'; +import { apiFetch } from '@/lib/api'; +import { formatCount } from '@/lib/utils'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { + Bell, Zap, Shield, HardDrive, WifiOff, CheckCircle2, +} from 'lucide-react'; +import type { ConfigurationStatusPayload } from '@/components/dashboard'; + +interface FleetNodeConfiguration { + id: number; + name: string; + type: 'local' | 'remote'; + status: 'online' | 'offline'; + configuration: ConfigurationStatusPayload | null; +} + +function TierChip({ tier }: { tier: string }) { + const label = tier === 'admiral' ? 'Admiral' : 'Skipper'; + return ( + + {label} + + ); +} + +function SummaryRow({ icon: Icon, label, value, locked, requiredTier }: { + icon: typeof Bell; + label: string; + value: string; + locked?: boolean; + requiredTier?: string; +}) { + return ( +
+ + {label} + {locked && requiredTier + ? + : {value}} +
+ ); +} + +function NodeCard({ node }: { node: FleetNodeConfiguration }) { + if (!node.configuration) { + return ( + + +
+ {node.name} + + {node.type === 'remote' ? 'Remote' : 'Local'} + + + Offline +
+

Node is unreachable. Configuration unavailable.

+
+
+ ); + } + + const { notifications, automation, security, backup, thresholds } = node.configuration; + + const agentCount = [ + notifications.agents.discord.enabled, + notifications.agents.slack.enabled, + notifications.agents.webhook.enabled, + ].filter(Boolean).length; + + return ( + + +
+ {node.name} + + {node.type === 'remote' ? 'Remote' : 'Local'} + +
+ + Online +
+
+ +
+ + + + + + + + +
+
+
+ ); +} + +export function FleetConfiguration() { + const [nodes, setNodes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + try { + const res = await apiFetch('/fleet/configuration', { localOnly: true }); + if (!res.ok) { + setError('Failed to fetch fleet configuration.'); + return; + } + const data = await res.json() as FleetNodeConfiguration[]; + setNodes(data); + setError(null); + } catch { + setError('Unable to reach the server.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchData(); + }, [fetchData]); + + if (loading) { + return ( +
+ {Array.from({ length: 2 }).map((_, i) => ( + + +
+ {Array.from({ length: 4 }).map((_, j) => ( +
+ ))} + + + ))} +
+ ); + } + + if (error) { + return ( +
+

{error}

+
+ ); + } + + if (nodes.length === 0) { + return ( +
+

No nodes configured.

+
+ ); + } + + return ( +
+ {nodes.map(node => )} +
+ ); +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index d09767ee..3c7aff00 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -13,3 +13,18 @@ export function formatBytes(bytes: number, decimals = 2) { const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; } + +export function formatCount(n: number, unit: string): string { + if (n === 0) return 'None'; + return `${n} ${unit}${n === 1 ? '' : 's'}`; +} + +export function visibilityInterval(fn: () => void, ms: number): () => void { + let interval: ReturnType | null = null; + const start = () => { if (interval) return; interval = setInterval(fn, ms); }; + const stop = () => { if (interval) { clearInterval(interval); interval = null; } }; + const onVisChange = () => { if (document.hidden) { stop(); } else { fn(); start(); } }; + document.addEventListener('visibilitychange', onVisChange); + start(); + return () => { stop(); document.removeEventListener('visibilitychange', onVisChange); }; +}