feat(dashboard): redesign as DevOps command center (#371)

* feat(dashboard): redesign as DevOps command center

Transform the dashboard from a basic stats viewer into a high-signal
operational command center with 5 composable sections:

- Health status bar with system health derivation (Healthy/Degraded/Critical)
- Resource gauges with visual progress bars and threshold coloring
- Paginated stack health table with per-stack UP/DN, CPU, memory, and
  click-to-navigate (8 per page)
- Enhanced historical CPU/RAM charts with skeleton empty states
- Recent alerts feed with severity-coded notifications

Extract monolithic HomeDashboard.tsx (447 lines) into composable
sub-components under dashboard/ directory. Remove Docker Run to Compose
converter from the landing surface. Add defensive .ok check on container
status fallback in EditorLayout.

* feat(dashboard): add Clear All Notifications button to Recent Alerts

Add a destructive ghost button below the alerts feed that calls
DELETE /api/notifications to clear all notifications, then refreshes
the list. Button only appears when there are alerts to clear.

* feat(dashboard): add pagination to Recent Alerts section

Same pattern as Stack Health table: 8 items per page with prev/next
chevron controls and page indicator in the card header. Pagination
auto-hides when there are 8 or fewer alerts. Page resets on clear all.

* fix(dashboard): resolve container count oscillation and add cursor hover detail

Fix container stats flickering between 0 and correct values by moving
state resets to the top of each useEffect body (runs once per node
switch, not on every poll tick). Add animate-ui cursor primitive and
wire it to the active containers number in ResourceGauges to show
managed/external breakdown on hover. Silence noisy Docker socket
errors when engine is unreachable.

* feat(dashboard): add cursor hover to health status with reason breakdown

Wrap the health badge (pulsing dot + label) in a CursorFollow tooltip
that explains why the node is Critical, Degraded, or Healthy. Shows
specific metrics (e.g. "RAM at 97.9%", "Disk at 96.4%") when hovered.
Displays "All systems nominal" for healthy nodes.

* fix(dashboard): resolve OOM from unbounded Docker stats polling

Three root causes addressed:

1. updateGlobalDockerNetwork had no overlap guard. When Docker was slow,
   3-second interval ticks stacked up, creating dozens of concurrent
   container.stats() calls that exhausted the heap. Added isUpdatingNetwork
   flag and increased interval from 3s to 5s.

2. Historical metrics query returned ~20K rows (1-minute buckets x 14
   containers x 24h). Downsampled to 5-minute buckets, reducing response
   size by ~5x.

3. Dashboard polling continued when the browser tab was hidden, creating
   phantom load. Replaced setInterval with visibilityInterval helper that
   pauses polling on tab hide and resumes with an immediate fetch on focus.

* fix(dashboard): use loadFile for stack navigation from Stack Health table

The onNavigateToStack callback was only calling setSelectedFile and
setActiveView, skipping the full load flow (YAML content, env files,
containers, backup info). This caused the editor to show stale state
with a "Start" button for running stacks and empty YAML. Now calls
loadFile() which is the same path the sidebar uses.

* refactor(dashboard): simplify Containers card layout

Replace 2-column grid with vertical layout matching other gauge cards.
Active count uses text-2xl hero number, exited count sits in subtitle
position. Removed redundant total count row.

* fix(dashboard): unify notification types and fix multi-node clear

- Replace duplicate Notification interface in EditorLayout with shared
  NotificationItem from dashboard/types.ts
- Tighten is_read type from number | boolean to number (matches SQLite)
- Pass notifications from EditorLayout (which aggregates all nodes) to
  HomeDashboard as props, removing duplicate local-only polling from
  useDashboardData
- Fix Clear All to use clearAllNotifications (deletes from all nodes)
  instead of fetchNotifications (which was just a re-fetch, causing
  remote notifications to reappear immediately after clearing)
- Delegate DELETE responsibility from RecentAlerts to parent handler

* fix(dashboard): handle optional nodeId in notification operations

Guard against undefined nodeId when calling fetchForNode for mark-read,
delete, and clear-all notification operations. The shared NotificationItem
type has nodeId as optional since the API response doesn't include it;
EditorLayout enriches it but TypeScript correctly flags the possibility.
This commit is contained in:
Anso
2026-04-04 02:51:42 -04:00
committed by GitHub
parent aff1981d25
commit 2ee959ec3b
14 changed files with 1408 additions and 462 deletions
+18 -21
View File
@@ -5,6 +5,7 @@ import Editor from '@monaco-editor/react';
import TerminalComponent from './Terminal';
import ErrorBoundary from './ErrorBoundary';
import HomeDashboard from './HomeDashboard';
import type { NotificationItem } from './dashboard/types';
import BashExecModal from './BashExecModal';
import HostConsole from './HostConsole';
import { AdmiralGate } from './AdmiralGate';
@@ -73,15 +74,6 @@ interface StackStatusInfo {
type StackAction = 'deploy' | 'stop' | 'restart' | 'update' | 'delete' | 'rollback';
interface Notification {
id: number;
level: 'info' | 'warning' | 'error';
message: string;
timestamp: number;
is_read: number; // 0 | 1 (SQLite boolean)
nodeId: number;
nodeName: string;
}
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
@@ -203,7 +195,7 @@ export default function EditorLayout() {
const [stackUpdates, setStackUpdates] = useState<Record<string, boolean>>({});
// Notifications & Settings state
const [notifications, setNotifications] = useState<Notification[]>([]);
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [settingsInitialSection, setSettingsInitialSection] = useState<'account' | 'labels'>('account');
const [alertSheetOpen, setAlertSheetOpen] = useState(false);
@@ -359,6 +351,7 @@ export default function EditorLayout() {
const statusResults = await Promise.allSettled(
fileList.map(async (file) => {
const containersRes = await apiFetch(`/stacks/${file}/containers`);
if (!containersRes.ok) return { file, status: 'unknown' as const };
const containers = await containersRes.json();
const hasRunning = Array.isArray(containers) && containers.some((c: ContainerInfo) => c.State === 'running');
return { file, status: hasRunning ? 'running' as const : (Array.isArray(containers) && containers.length > 0 ? 'exited' as const : 'unknown' as const) };
@@ -469,8 +462,8 @@ export default function EditorLayout() {
const msg = JSON.parse(event.data as string);
if (msg.type === 'notification' && msg.payload) {
const localNode = nodesRef.current.find(n => n.type === 'local');
const tagged: Notification = {
...(msg.payload as Omit<Notification, 'nodeId' | 'nodeName'>),
const tagged: NotificationItem = {
...(msg.payload as Omit<NotificationItem, 'nodeId' | 'nodeName'>),
nodeId: localNode?.id ?? -1,
nodeName: localNode?.name ?? 'Local',
};
@@ -554,7 +547,7 @@ export default function EditorLayout() {
// Read node name from ref so it stays fresh even if the node was renamed
const current = nodesRef.current.find(n => n.id === rn.id);
setNotifications(prev =>
[{ ...msg.payload as Omit<Notification, 'nodeId' | 'nodeName'>, nodeId: rn.id, nodeName: current?.name ?? rn.name }, ...prev]
[{ ...msg.payload as Omit<NotificationItem, 'nodeId' | 'nodeName'>, nodeId: rn.id, nodeName: current?.name ?? rn.name }, ...prev]
.sort((a, b) => b.timestamp - a.timestamp)
);
}
@@ -631,17 +624,17 @@ export default function EditorLayout() {
...remoteNodes.map(n => fetchForNode('/notifications', n.id)),
]);
const all: Notification[] = [];
const all: NotificationItem[] = [];
if (localResult.status === 'fulfilled' && localResult.value.ok) {
const data = await localResult.value.json() as Omit<Notification, 'nodeId' | 'nodeName'>[];
const data = await localResult.value.json() as Omit<NotificationItem, 'nodeId' | 'nodeName'>[];
data.forEach(n => all.push({ ...n, nodeId: localNode?.id ?? -1, nodeName: localNode?.name ?? 'Local' }));
}
for (let i = 0; i < remoteNodes.length; i++) {
const result = remoteResults[i];
if (result?.status === 'fulfilled' && result.value.ok) {
const data = await result.value.json() as Omit<Notification, 'nodeId' | 'nodeName'>[];
const data = await result.value.json() as Omit<NotificationItem, 'nodeId' | 'nodeName'>[];
const rn = remoteNodes[i];
data.forEach(n => all.push({ ...n, nodeId: rn.id, nodeName: rn.name }));
}
@@ -669,7 +662,7 @@ export default function EditorLayout() {
const markAllRead = async () => {
try {
const localNode = nodesRef.current.find(n => n.type === 'local');
const unreadNodeIds = [...new Set(notifications.filter(n => !n.is_read).map(n => n.nodeId))];
const unreadNodeIds = [...new Set(notifications.filter(n => !n.is_read && n.nodeId != null).map(n => n.nodeId as number))];
await Promise.allSettled(unreadNodeIds.map(nodeId =>
nodeId === localNode?.id
? apiFetch('/notifications/read', { method: 'POST', localOnly: true })
@@ -682,12 +675,12 @@ export default function EditorLayout() {
}
};
const deleteNotification = async (notif: Notification) => {
const deleteNotification = async (notif: NotificationItem) => {
try {
const localNode = nodesRef.current.find(n => n.type === 'local');
if (notif.nodeId === localNode?.id) {
await apiFetch(`/notifications/${notif.id}`, { method: 'DELETE', localOnly: true });
} else {
} else if (notif.nodeId != null) {
await fetchForNode(`/notifications/${notif.id}`, notif.nodeId, { method: 'DELETE' });
}
setNotifications(prev => prev.filter(n => !(n.id === notif.id && n.nodeId === notif.nodeId)));
@@ -700,7 +693,7 @@ export default function EditorLayout() {
const clearAllNotifications = async () => {
try {
const localNode = nodesRef.current.find(n => n.type === 'local');
const uniqueNodeIds = [...new Set(notifications.map(n => n.nodeId))];
const uniqueNodeIds = [...new Set(notifications.filter(n => n.nodeId != null).map(n => n.nodeId as number))];
await Promise.allSettled(uniqueNodeIds.map(nodeId =>
nodeId === localNode?.id
? apiFetch('/notifications', { method: 'DELETE', localOnly: true })
@@ -2241,7 +2234,11 @@ export default function EditorLayout() {
<ScheduledOperationsView filterNodeId={filterNodeId} onClearFilter={() => setFilterNodeId(null)} />
</CapabilityGate>
) : (
<HomeDashboard />
<HomeDashboard
onNavigateToStack={(stackFile) => { loadFile(stackFile); }}
notifications={notifications}
onClearNotifications={clearAllNotifications}
/>
)}
</div>
</div>