mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 10:49:35 +00:00
d7d8f9bfe8
* 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
235 lines
8.9 KiB
TypeScript
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>
|
|
);
|
|
}
|