Files
sencho/frontend/src/components/dashboard/ConfigurationStatus.tsx
T
Anso d7d8f9bfe8 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
2026-04-26 18:42:28 -04:00

235 lines
8.9 KiB
TypeScript

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>
);
}