mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 01:14:14 +00:00
feat(dashboard): replace 24h charts with Configuration Status and Recent Activity (#785)
* feat(dashboard): replace 24h charts with Configuration Status and Recent Activity The 24-hour CPU/Memory area charts summed per-container metrics normalized to each container's CPU quota, producing numbers that bore no honest relationship to host load. The live ResourceGauges strip already shows accurate host-level stats, making the historical charts both inaccurate and redundant. This commit replaces that row with two side-by-side cards: - **Configuration Status**: aggregates every toggleable feature on the active node (notification agents, alert rules, routing rules, auto-heal, auto-update, webhooks, scheduled tasks, MFA, SSO, vulnerability scanning, cloud backup, and alert thresholds) into a single at-a-glance card. Tier-locked rows display an upgrade indicator instead of a value. Each row is clickable and navigates to the relevant settings section. Data refreshes every 60 s and immediately on state-invalidate events. - **Recent Activity**: lists the ten most recent notification-history events for the active node (deployments, image updates, auto-heal actions, scan findings, cloud backup events, system notices) with category icons and relative timestamps. Refreshes every 30 s. New backend endpoints: - GET /api/dashboard/configuration - per-node feature status with locked/ requiredTier markers so the frontend renders upgrade chips without extra calls. The endpoint sits after authGate and before the remote proxy so remote-node requests are transparently forwarded. - GET /api/dashboard/recent-activity?limit=N - thin wrapper over DatabaseService.getNotificationHistory. - GET /api/fleet/configuration - fleet-wide fan-out using the same Promise.allSettled dead-node-tolerant pattern as /fleet/overview. Exposed as the new "Status" tab on the Fleet page (after Snapshots). Shared utilities: - visibilityInterval and formatCount extracted to frontend/src/lib/utils.ts so the three polling hooks and two components share a single copy. * docs(dashboard): fix stale alt text referencing removed historical charts
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
@@ -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<void> => {
|
||||
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<FleetNodeConfiguration> => {
|
||||
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<void> => {
|
||||
if (!requirePaid(req, res)) return;
|
||||
|
||||
|
||||
+51
-11
@@ -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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/dashboard/dashboard-overview.png" alt="Sencho dashboard showing status masthead, unified gauge strip, stack health table, and historical charts" />
|
||||
<img src="/images/dashboard/dashboard-overview.png" alt="Sencho dashboard showing status masthead, unified gauge strip, and stack health table" />
|
||||
</Frame>
|
||||
|
||||
## 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.
|
||||
<Frame>
|
||||
<img src="/images/dashboard/configuration-status.png" alt="Dashboard showing the Configuration Status card on the left and the Recent Activity feed on the right" />
|
||||
</Frame>
|
||||
|
||||
Hover over a data point to see the exact value at that moment.
|
||||
The card is divided into four sections:
|
||||
|
||||
<Note>
|
||||
Charts only show data from the moment Sencho started collecting. If you just installed Sencho, they will be mostly empty until metrics accumulate.
|
||||
</Note>
|
||||
### 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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/fleet/configuration-tab.png" alt="Fleet Configuration tab showing per-node configuration summaries with one online node and one offline node" />
|
||||
</Frame>
|
||||
|
||||
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.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 163 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 115 KiB |
@@ -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<NotificationItem[]>([]);
|
||||
const [tickerConnected, setTickerConnected] = useState(false);
|
||||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<'account' | 'labels' | 'nodes'>('account');
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SectionId>('account');
|
||||
const [alertSheetOpen, setAlertSheetOpen] = useState(false);
|
||||
const [alertSheetStack, setAlertSheetStack] = useState('');
|
||||
const [autoHealStackName, setAutoHealStackName] = useState<string | null>(null);
|
||||
@@ -2922,6 +2923,7 @@ export default function EditorLayout() {
|
||||
onNavigateToStack={(stackFile) => { loadFile(stackFile); }}
|
||||
notifications={notifications}
|
||||
onClearNotifications={clearAllNotifications}
|
||||
onOpenSettings={(section) => { setSettingsInitialSection(section); setSettingsModalOpen(true); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
)}
|
||||
<TabsHighlightItem value="configuration">
|
||||
<TabsTrigger value="configuration">
|
||||
<SlidersHorizontal className="w-4 h-4 mr-1.5" />Status
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
</TabsHighlight>
|
||||
</TabsList>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1337,6 +1343,9 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
||||
<FleetSnapshots />
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="configuration">
|
||||
<FleetConfiguration />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Reconnecting overlay shown when local node is updating */}
|
||||
|
||||
@@ -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<void>;
|
||||
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}
|
||||
/>
|
||||
|
||||
<HistoricalCharts
|
||||
metrics={data.metrics}
|
||||
systemStats={data.systemStats}
|
||||
/>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<ConfigurationStatus onOpenSettings={onOpenSettings} />
|
||||
<RecentActivity />
|
||||
</div>
|
||||
|
||||
<RecentAlerts
|
||||
notifications={notifications}
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Bell, Zap, Shield, HardDrive, ChevronRight } from 'lucide-react';
|
||||
import { formatCount } from '@/lib/utils';
|
||||
import { useConfigurationStatus } from './useConfigurationStatus';
|
||||
import type { SectionId } from '@/components/settings/types';
|
||||
|
||||
interface ConfigurationStatusProps {
|
||||
onOpenSettings?: (section: SectionId) => void;
|
||||
}
|
||||
|
||||
function StatusBadge({ value, locked, requiredTier }: {
|
||||
value: string;
|
||||
locked?: boolean;
|
||||
requiredTier?: string;
|
||||
}) {
|
||||
if (locked && requiredTier) {
|
||||
const label = requiredTier === 'admiral' ? 'Admiral' : 'Skipper';
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-sm border border-warning/30 bg-warning/10 px-1.5 py-0.5 text-[10px] font-mono tracking-wide uppercase text-warning/80">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const lower = value.toLowerCase();
|
||||
if (lower === 'on' || lower === 'enabled') {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-sm border border-success/30 bg-success/10 px-1.5 py-0.5 text-[10px] font-mono tracking-wide uppercase text-success">
|
||||
ON
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (lower === 'off' || lower === 'disabled') {
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-sm border border-card-border bg-card px-1.5 py-0.5 text-[10px] font-mono tracking-wide uppercase text-stat-subtitle">
|
||||
OFF
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="text-xs font-mono tabular-nums text-stat-value">{value}</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left hover:bg-accent/5 rounded-sm transition-colors cursor-pointer group"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center justify-between py-1 px-1">
|
||||
<span className={`${labelClass} group-hover:text-stat-value transition-colors`}>{label}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<StatusBadge value={value} locked={locked} requiredTier={requiredTier} />
|
||||
<ChevronRight className="h-3 w-3 text-stat-icon opacity-0 group-hover:opacity-60 transition-opacity shrink-0" strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1 px-1 rounded-sm">
|
||||
<span className={labelClass}>{label}</span>
|
||||
<StatusBadge value={value} locked={locked} requiredTier={requiredTier} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ icon: Icon, label }: { icon: typeof Bell; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 pt-2 pb-0.5 first:pt-0">
|
||||
<Icon className="h-3 w-3 text-stat-icon shrink-0" strokeWidth={1.5} />
|
||||
<span className="text-[10px] font-mono tracking-widest uppercase text-stat-icon">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonRow() {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1 px-1">
|
||||
<div className="h-3 w-24 rounded-sm bg-accent/10 animate-pulse" />
|
||||
<div className="h-4 w-12 rounded-sm bg-accent/10 animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfigurationStatus({ onOpenSettings }: ConfigurationStatusProps) {
|
||||
const { status, loading } = useConfigurationStatus();
|
||||
|
||||
const open = (section: SectionId) => () => onOpenSettings?.(section);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-stat-title">Configuration Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-0.5">
|
||||
{Array.from({ length: 8 }).map((_, i) => <SkeletonRow key={i} />)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
return (
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-stat-title">Configuration Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-xs text-stat-subtitle py-4 text-center">Unable to load configuration.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, string> = {
|
||||
oidc_custom: 'OIDC', oidc_google: 'Google', oidc_github: 'GitHub',
|
||||
oidc_okta: 'Okta', ldap: 'LDAP',
|
||||
};
|
||||
return (security.ssoProvider && names[security.ssoProvider]) ?? 'Enabled';
|
||||
})();
|
||||
|
||||
return (
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-stat-title">Configuration Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-0">
|
||||
<SectionHeader icon={Bell} label="Notifications" />
|
||||
<Row label="Notification agents" value={agentSummary} onClick={open('notifications')} />
|
||||
<Row
|
||||
label="Alert rules"
|
||||
value={formatCount(notifications.alertRules, 'rule')}
|
||||
onClick={open('notifications')}
|
||||
/>
|
||||
<Row
|
||||
label="Notification routing"
|
||||
value={notifications.routingRules.locked ? '' : formatCount(notifications.routingRules.enabledCount, 'route')}
|
||||
locked={notifications.routingRules.locked}
|
||||
requiredTier={notifications.routingRules.locked ? notifications.routingRules.requiredTier : undefined}
|
||||
onClick={open('notification-routing')}
|
||||
/>
|
||||
|
||||
<SectionHeader icon={Zap} label="Automation" />
|
||||
<Row
|
||||
label="Auto-heal policies"
|
||||
value={automation.autoHeal.total === 0 ? 'None' : `${automation.autoHeal.enabled} / ${automation.autoHeal.total} active`}
|
||||
onClick={open('system')}
|
||||
/>
|
||||
<Row
|
||||
label="Auto-update stacks"
|
||||
value={automation.autoUpdate.total === 0 ? 'None' : `${automation.autoUpdate.enabled} / ${automation.autoUpdate.total}`}
|
||||
onClick={open('system')}
|
||||
/>
|
||||
<Row
|
||||
label="Webhooks"
|
||||
value={automation.webhooks.locked ? '' : formatCount(automation.webhooks.enabled, 'active')}
|
||||
locked={automation.webhooks.locked}
|
||||
requiredTier={automation.webhooks.locked ? automation.webhooks.requiredTier : undefined}
|
||||
onClick={open('webhooks')}
|
||||
/>
|
||||
<Row
|
||||
label="Scheduled tasks"
|
||||
value={automation.scheduledTasks.locked ? '' : formatCount(automation.scheduledTasks.enabled, 'active')}
|
||||
locked={automation.scheduledTasks.locked}
|
||||
requiredTier={automation.scheduledTasks.locked ? automation.scheduledTasks.requiredTier : undefined}
|
||||
onClick={open('system')}
|
||||
/>
|
||||
|
||||
<SectionHeader icon={Shield} label="Security" />
|
||||
<Row
|
||||
label="MFA"
|
||||
value={security.mfaEnabled === null ? 'Not set up' : security.mfaEnabled ? 'On' : 'Off'}
|
||||
onClick={open('account')}
|
||||
/>
|
||||
<Row label="SSO" value={ssoLabel} onClick={open('sso')} />
|
||||
<Row
|
||||
label="Vulnerability scanning"
|
||||
value={security.scanPolicies.locked ? '' : formatCount(security.scanPolicies.enabled, 'policy')}
|
||||
locked={security.scanPolicies.locked}
|
||||
requiredTier={security.scanPolicies.locked ? security.scanPolicies.requiredTier : undefined}
|
||||
onClick={open('security')}
|
||||
/>
|
||||
|
||||
<SectionHeader icon={HardDrive} label="Backups & Thresholds" />
|
||||
<Row
|
||||
label="Cloud Backup"
|
||||
value={backup.locked ? '' : backup.provider === 'disabled' ? 'Disabled' : backup.provider === 'sencho' ? 'Sencho Cloud' : `Custom S3${backup.autoUpload ? ' (auto)' : ''}`}
|
||||
locked={backup.locked}
|
||||
requiredTier={backup.locked ? backup.requiredTier : undefined}
|
||||
onClick={open('cloud-backup')}
|
||||
/>
|
||||
<Row
|
||||
label="Alert thresholds"
|
||||
value={`CPU ${thresholds.cpuLimit}% · RAM ${thresholds.ramLimit}% · Disk ${thresholds.diskLimit}%`}
|
||||
onClick={open('system')}
|
||||
/>
|
||||
<Row
|
||||
label="Crash detection"
|
||||
value={thresholds.globalCrash ? 'On' : 'Off'}
|
||||
onClick={open('system')}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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<string, { time: string; timestamp: number; cpu: number; ram: number }> = {};
|
||||
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 (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="font-display italic text-xl leading-none tracking-tight text-stat-value">CPU</h2>
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
last 24h · normalized over cores
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[250px]">
|
||||
{hasData ? (
|
||||
<ChartContainer config={chartConfig} className="w-full h-full">
|
||||
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="dash-cpu-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--chart-1)" stopOpacity={0.35} />
|
||||
<stop offset="100%" stopColor="var(--chart-1)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--chart-grid)" />
|
||||
<XAxis dataKey="time" minTickGap={30} tickMargin={8} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
|
||||
<YAxis tickFormatter={(val) => `${Number(val).toFixed(0)}%`} domain={[0, (dataMax: number) => Math.max(100, Math.ceil(dataMax / 10) * 10)]} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cpu"
|
||||
stroke="var(--chart-1)"
|
||||
strokeWidth={1.25}
|
||||
fill="url(#dash-cpu-fill)"
|
||||
/>
|
||||
{cpuPeak ? (
|
||||
<ReferenceDot
|
||||
x={cpuPeak.time}
|
||||
y={cpuPeak.cpu}
|
||||
r={3}
|
||||
fill="var(--chart-2)"
|
||||
stroke="var(--background)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
) : null}
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
<Skeleton className="w-full h-[180px] rounded-md" />
|
||||
<span className="text-xs text-stat-icon">Waiting for CPU metrics...</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<h2 className="font-display italic text-xl leading-none tracking-tight text-stat-value">Memory</h2>
|
||||
<span className="font-mono text-[11px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
last 24h · total allocation
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="h-[250px]">
|
||||
{hasData ? (
|
||||
<ChartContainer config={chartConfig} className="w-full h-full">
|
||||
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||
<defs>
|
||||
<linearGradient id="dash-ram-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="var(--chart-2)" stopOpacity={0.3} />
|
||||
<stop offset="100%" stopColor="var(--chart-2)" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--chart-grid)" />
|
||||
<XAxis dataKey="time" minTickGap={30} tickMargin={8} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
|
||||
<YAxis tickFormatter={(val) => `${Number(val).toFixed(1)} GB`} tick={{ fill: 'var(--chart-tick)', fontSize: 11 }} />
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="ram"
|
||||
stroke="var(--chart-2)"
|
||||
strokeWidth={1.25}
|
||||
fill="url(#dash-ram-fill)"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
<Skeleton className="w-full h-[180px] rounded-md" />
|
||||
<span className="text-xs text-stat-icon">Waiting for RAM metrics...</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, { icon: IconType; className: string }> = {
|
||||
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<string, { icon: IconType; className: string }> = {
|
||||
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 (
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-stat-title">Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 space-y-0.5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-2.5 py-1.5 px-1">
|
||||
<div className="h-3.5 w-3.5 rounded-full bg-accent/10 animate-pulse shrink-0" />
|
||||
<div className="h-3 flex-1 rounded-sm bg-accent/10 animate-pulse" />
|
||||
<div className="h-3 w-8 rounded-sm bg-accent/10 animate-pulse shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium text-stat-title">Recent Activity</CardTitle>
|
||||
<Activity className="h-3.5 w-3.5 text-stat-icon" strokeWidth={1.5} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{items.length === 0 ? (
|
||||
<div className="flex items-center justify-center gap-2 py-6 text-stat-subtitle">
|
||||
<CheckCircle2 className="h-4 w-4 text-success" strokeWidth={1.5} />
|
||||
<span className="text-sm">No recent activity.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{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 (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start gap-2.5 py-1.5 px-1 rounded-sm hover:bg-accent/5"
|
||||
>
|
||||
<Icon
|
||||
className={`h-3.5 w-3.5 shrink-0 mt-0.5 ${className}`}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<span className={`text-xs flex-1 leading-relaxed ${item.is_read ? 'text-stat-subtitle' : 'text-stat-value'}`}>
|
||||
{item.message}
|
||||
</span>
|
||||
<span className="text-xs font-mono tabular-nums text-stat-icon shrink-0 mt-0.5">
|
||||
{formatRelativeTime(item.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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<ConfigurationStatus | null>(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 };
|
||||
}
|
||||
@@ -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<typeof setInterval> | 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();
|
||||
|
||||
@@ -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<ActivityItem[]>([]);
|
||||
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 };
|
||||
}
|
||||
@@ -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 (
|
||||
<span className="inline-flex items-center rounded-sm border border-warning/30 bg-warning/10 px-1.5 py-0.5 text-[10px] font-mono tracking-wide uppercase text-warning/80">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryRow({ icon: Icon, label, value, locked, requiredTier }: {
|
||||
icon: typeof Bell;
|
||||
label: string;
|
||||
value: string;
|
||||
locked?: boolean;
|
||||
requiredTier?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-0.5">
|
||||
<Icon className="h-3 w-3 shrink-0 text-stat-icon" strokeWidth={1.5} />
|
||||
<span className={`text-xs flex-1 ${locked ? 'text-stat-subtitle/60' : 'text-stat-subtitle'}`}>{label}</span>
|
||||
{locked && requiredTier
|
||||
? <TierChip tier={requiredTier} />
|
||||
: <span className="text-xs font-mono tabular-nums text-stat-value">{value}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeCard({ node }: { node: FleetNodeConfiguration }) {
|
||||
if (!node.configuration) {
|
||||
return (
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="font-medium text-sm text-stat-value">{node.name}</span>
|
||||
<Badge variant="outline" className="text-[10px] font-normal py-0 px-1.5 text-stat-subtitle">
|
||||
{node.type === 'remote' ? 'Remote' : 'Local'}
|
||||
</Badge>
|
||||
<WifiOff className="h-3.5 w-3.5 text-stat-subtitle ml-auto" strokeWidth={1.5} />
|
||||
<span className="text-xs text-stat-subtitle">Offline</span>
|
||||
</div>
|
||||
<p className="text-xs text-stat-subtitle/60">Node is unreachable. Configuration unavailable.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Card className="bg-card shadow-card-bevel">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="font-medium text-sm text-stat-value">{node.name}</span>
|
||||
<Badge variant="outline" className="text-[10px] font-normal py-0 px-1.5 text-stat-subtitle">
|
||||
{node.type === 'remote' ? 'Remote' : 'Local'}
|
||||
</Badge>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-success" strokeWidth={1.5} />
|
||||
<span className="text-xs text-success">Online</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-0.5">
|
||||
<SummaryRow icon={Bell} label="Agents"
|
||||
value={agentCount === 0 ? 'None' : `${agentCount} active`} />
|
||||
<SummaryRow icon={Bell} label="Alert rules"
|
||||
value={formatCount(notifications.alertRules, 'rule')} />
|
||||
<SummaryRow icon={Zap} label="Auto-heal"
|
||||
value={automation.autoHeal.total === 0
|
||||
? 'None'
|
||||
: `${automation.autoHeal.enabled}/${automation.autoHeal.total}`} />
|
||||
<SummaryRow icon={Zap} label="Webhooks"
|
||||
value={
|
||||
automation.webhooks.locked
|
||||
? ''
|
||||
: formatCount(automation.webhooks.enabled, 'active')
|
||||
}
|
||||
locked={automation.webhooks.locked}
|
||||
requiredTier={automation.webhooks.locked ? automation.webhooks.requiredTier : undefined} />
|
||||
<SummaryRow icon={Shield} label="MFA"
|
||||
value={security.mfaEnabled === null ? 'Not set' : security.mfaEnabled ? 'On' : 'Off'} />
|
||||
<SummaryRow icon={Shield} label="Scanning"
|
||||
value={
|
||||
security.scanPolicies.locked
|
||||
? ''
|
||||
: formatCount(security.scanPolicies.enabled, 'policy')
|
||||
}
|
||||
locked={security.scanPolicies.locked}
|
||||
requiredTier={security.scanPolicies.locked ? security.scanPolicies.requiredTier : undefined} />
|
||||
<SummaryRow icon={HardDrive} label="Backup"
|
||||
value={
|
||||
backup.locked
|
||||
? ''
|
||||
: backup.provider === 'disabled' ? 'Disabled' : 'Enabled'
|
||||
}
|
||||
locked={backup.locked}
|
||||
requiredTier={backup.locked ? backup.requiredTier : undefined} />
|
||||
<SummaryRow icon={HardDrive} label="Crash detect"
|
||||
value={thresholds.globalCrash ? 'On' : 'Off'} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function FleetConfiguration() {
|
||||
const [nodes, setNodes] = useState<FleetNodeConfiguration[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-1">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<Card key={i} className="bg-card shadow-card-bevel">
|
||||
<CardContent className="p-4 space-y-2">
|
||||
<div className="h-4 w-32 rounded-sm bg-accent/10 animate-pulse" />
|
||||
{Array.from({ length: 4 }).map((_, j) => (
|
||||
<div key={j} className="h-3 rounded-sm bg-accent/10 animate-pulse" />
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-stat-subtitle">
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16 text-stat-subtitle">
|
||||
<p className="text-sm">No nodes configured.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 p-1">
|
||||
{nodes.map(node => <NodeCard key={node.id} node={node} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<typeof setInterval> | 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); };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user