mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user