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:
Anso
2026-04-26 18:42:28 -04:00
committed by GitHub
parent fdab44fe07
commit d7d8f9bfe8
19 changed files with 1049 additions and 206 deletions
@@ -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 };
}