Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions client/src/components/SystemHealthWidget.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@ import {
XCircle,
Clock,
Zap,
RefreshCw
RefreshCw,
X
} from 'lucide-react';
import { MicroGlyph } from './micrographics';
import { useHealthWarningDismiss } from '../hooks/useHealthWarningDismiss.jsx';

/**
* SystemHealthWidget - Compact system health overview for the Dashboard
Expand All @@ -24,6 +26,7 @@ const SystemHealthWidget = memo(function SystemHealthWidget({ dashboardState })
const health = dashboardState?.health;
const refetchHealth = dashboardState?.refetchHealth;
const [refreshing, setRefreshing] = useState(false);
const { dismissingType, handleDismissWarning } = useHealthWarningDismiss(refetchHealth);

const handleRefresh = async () => {
if (!refetchHealth || refreshing) return;
Expand Down Expand Up @@ -154,8 +157,18 @@ const SystemHealthWidget = memo(function SystemHealthWidget({ dashboardState })
key={idx}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-port-warning/10 text-port-warning text-sm"
>
<AlertTriangle size={14} />
<span>{warning.message}</span>
<AlertTriangle size={14} className="shrink-0" />
<span className="flex-1">{warning.message}</span>
<button
type="button"
onClick={() => handleDismissWarning(warning)}
disabled={!refetchHealth || dismissingType === warning.type}
className="shrink-0 inline-flex min-h-[28px] min-w-[28px] items-center justify-center rounded text-port-warning/70 transition-colors hover:bg-port-warning/20 hover:text-port-warning disabled:cursor-not-allowed disabled:opacity-50"
title="Dismiss as resolved"
aria-label={`Dismiss warning: ${warning.message}`}
>
<X size={13} aria-hidden="true" />
</button>
</div>
))}
</div>
Expand Down
38 changes: 36 additions & 2 deletions client/src/components/SystemHealthWidget.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,21 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router';
import SystemHealthWidget from './SystemHealthWidget.jsx';
import { dismissHealthWarning } from '../services/apiSystem.js';
import toast from './ui/Toast';

vi.mock('../services/apiSystem.js', () => ({
dismissHealthWarning: vi.fn().mockResolvedValue({ message: 'x', dismissedAt: '2026-01-01T00:00:00.000Z' }),
undismissHealthWarning: vi.fn().mockResolvedValue({ success: true }),
}));
vi.mock('./ui/Toast', () => {
const toast = Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn(), dismiss: vi.fn() });
return { default: toast };
});

const HEALTH = {
overallHealth: 'healthy',
warnings: [],
overallHealth: 'warning',
warnings: [{ type: 'disk', severity: 'warning', message: 'Disk usage at or above 90%' }],
system: {
uptimeFormatted: '3h 12m',
memory: { usagePercent: 40, usedFormatted: '12 GB', totalFormatted: '32 GB' },
Expand Down Expand Up @@ -60,4 +71,27 @@ describe('SystemHealthWidget', () => {
expect(screen.getByRole('link', { name: 'Open disk usage report' })).toHaveAttribute('href', '/system-resources/storage');
expect(screen.getByRole('link', { name: /Details/ })).toHaveAttribute('href', '/system-resources/overview');
});

it('dismisses a warning as resolved and refetches health', async () => {
const user = userEvent.setup();
const refetchHealth = vi.fn().mockResolvedValue(undefined);
renderWidget({ health: HEALTH, refetchHealth });

await user.click(screen.getByRole('button', { name: /Dismiss warning: Disk usage at or above 90%/ }));

expect(dismissHealthWarning).toHaveBeenCalledWith('disk', 'Disk usage at or above 90%', { silent: true });
expect(refetchHealth).toHaveBeenCalledTimes(1);
});

it('toasts an error and does not refetch when dismissing fails', async () => {
const user = userEvent.setup();
dismissHealthWarning.mockRejectedValueOnce(new Error('offline'));
const refetchHealth = vi.fn();
renderWidget({ health: HEALTH, refetchHealth });

await user.click(screen.getByRole('button', { name: /Dismiss warning: Disk usage at or above 90%/ }));

expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('offline'));
expect(refetchHealth).not.toHaveBeenCalled();
});
});
1 change: 1 addition & 0 deletions client/src/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ grep -i "what you want to do" client/src/hooks/README.md
| `useOnDemandTaskToast` | Toasts when a user-triggered on-demand task run found no work (parked). | Wire once high in the tree so an explicit "Run" that parks isn't a silent no-op. |
| `useEngagementReminderToast` | Polls deterministic POST/creative-feedback actions and shows each reminder once per browser tab/day with a deep link. | Wire once high in the tree so daily actions remain visible outside the dashboard. |
| `useSharingNotifications` | Subscriber for share-bucket notifications. | Wire once to surface federation/sync events. |
| `useHealthWarningDismiss` | Dismisses a system-health dashboard warning as resolved, with an Undo toast. | The dashboard widget and the Live health overview page both dismiss/undo warnings. |

## Pipeline / Story Builder wiring

Expand Down
1 change: 1 addition & 0 deletions client/src/hooks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ export * from './useCanonPatch.js';
export * from './useDeathClock.js';
export * from './useFederatedMediaTarget.js';
export * from './useGoalDetail.js';
export * from './useHealthWarningDismiss.jsx';
export * from './usePostSession.js';
export * from './useRecordMerge.js';
export * from './useRenderJobQueue.js';
Expand Down
46 changes: 46 additions & 0 deletions client/src/hooks/useHealthWarningDismiss.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useState, useCallback } from 'react';
import { dismissHealthWarning, undismissHealthWarning } from '../services/apiSystem.js';
import toast from '../components/ui/Toast';
import { Undo2 } from 'lucide-react';

// Shared by SystemHealthWidget (dashboard) and SystemHealthPage (the "Live
// health" overview) — both dismiss a system-health warning the same way:
// record it server-side, refetch, then offer an Undo toast. Warnings are
// recomputed fresh on every /health/details read rather than stored, so
// there's nothing to remove client-side; `refetchFn` is what pulls the
// trimmed list back in.
export function useHealthWarningDismiss(refetchFn) {
const [dismissingType, setDismissingType] = useState(null);

const handleDismissWarning = useCallback(async (warning) => {
if (!refetchFn || dismissingType) return;
setDismissingType(warning.type);
try {
await dismissHealthWarning(warning.type, warning.message, { silent: true });
await refetchFn();
toast((t) => (
<span className="flex items-center gap-3 text-xs">
<span className="text-gray-200">Dismissed: <span className="font-medium text-white">{warning.message}</span></span>
<button
type="button"
onClick={() => {
undismissHealthWarning(warning.type, { silent: true })
.then(() => refetchFn())
.catch((err) => toast.error(err?.message || 'Failed to undo dismissal'));
toast.dismiss(t.id);
}}
className="inline-flex shrink-0 items-center gap-1 rounded border border-port-border px-2 py-0.5 text-[11px] text-port-accent hover:border-port-accent/40 hover:text-white"
>
<Undo2 size={12} /> Undo
</button>
</span>
), { duration: 8000 });
} catch (err) {
toast.error(err?.message || 'Failed to dismiss warning');
} finally {
setDismissingType(null);
}
}, [refetchFn, dismissingType]);

return { dismissingType, handleDismissWarning };
}
24 changes: 22 additions & 2 deletions client/src/pages/SystemHealthPage.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { useEffect, useState, useRef } from 'react';
import { Link, Navigate, NavLink, useParams } from 'react-router';
import { Activity, AlertTriangle, CheckCircle, XCircle, HardDrive, Cpu, Database, ListOrdered, RefreshCw, ServerCog, Zap } from 'lucide-react';
import { Activity, AlertTriangle, CheckCircle, XCircle, HardDrive, Cpu, Database, ListOrdered, RefreshCw, ServerCog, X, Zap } from 'lucide-react';
import * as api from '../services/api';
import toast from '../components/ui/Toast';
import PageSkeleton from '../components/ui/PageSkeleton';
import Banner from '../components/ui/Banner';
import { useAutoRefetch } from '../hooks/useAutoRefetch';
import { useHealthWarningDismiss } from '../hooks/useHealthWarningDismiss.jsx';
import { useSystemResourceReport } from '../hooks/useSystemResourceReport.js';
import StoragePanel from '../components/system-resources/StoragePanel.jsx';
import QueuesPanel from '../components/system-resources/QueuesPanel.jsx';
Expand Down Expand Up @@ -132,6 +133,7 @@ function SystemHealthOverview() {
() => api.getSystemHealth({ silent: true }),
15_000,
);
const { dismissingType, handleDismissWarning } = useHealthWarningDismiss(refetch);

const handleRefresh = async () => {
if (refreshing) return;
Expand Down Expand Up @@ -233,7 +235,25 @@ function SystemHealthOverview() {
{health.warnings.map((w, i) => {
const remedy = REMEDIATION[w.type];
return (
<Banner key={`${w.type || 'warning'}-${i}`} tone="warning" size="md" icon={AlertTriangle} align="start">
<Banner
key={`${w.type || 'warning'}-${i}`}
tone="warning"
size="md"
icon={AlertTriangle}
align="start"
actions={(
<button
type="button"
onClick={() => handleDismissWarning(w)}
disabled={dismissingType === w.type}
className="inline-flex min-h-[28px] min-w-[28px] items-center justify-center rounded text-port-warning/70 transition-colors hover:bg-port-warning/20 hover:text-port-warning disabled:cursor-not-allowed disabled:opacity-50"
title="Dismiss as resolved"
aria-label={`Dismiss warning: ${w.message}`}
>
<X size={14} aria-hidden="true" />
</button>
)}
>
<div>{w.message}</div>
{remedy && (
<Link to={remedy.to} className="inline-block mt-1 font-medium underline underline-offset-2 hover:no-underline">
Expand Down
43 changes: 41 additions & 2 deletions client/src/pages/SystemHealthPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,23 @@ vi.mock('../hooks/useProviderModels', () => ({
}),
}));

vi.mock('../components/ui/Toast', () => ({
default: { success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), custom: vi.fn() }
vi.mock('../components/ui/Toast', () => {
const toast = Object.assign(vi.fn(), {
success: vi.fn(), error: vi.fn(), loading: vi.fn(), dismiss: vi.fn(), custom: vi.fn()
});
return { default: toast };
});

// useHealthWarningDismiss (client/src/hooks/) calls apiSystem.js directly
// rather than through the '../services/api' barrel — mock it separately so
// dismiss/undo assertions observe what the hook actually calls.
vi.mock('../services/apiSystem.js', () => ({
dismissHealthWarning: vi.fn(() => Promise.resolve({ message: 'x', dismissedAt: '2026-01-01T00:00:00.000Z' })),
undismissHealthWarning: vi.fn(() => Promise.resolve({ success: true })),
}));

import * as api from '../services/api';
import { dismissHealthWarning } from '../services/apiSystem.js';
import SystemHealthPage, { RESOURCE_TABS } from './SystemHealthPage';
import { expectPageNavTabs } from '../test/pageNavTabAssertions.js';

Expand Down Expand Up @@ -133,6 +145,33 @@ describe('SystemHealthPage remediation links', () => {
expect(api.getSystemHealth).toHaveBeenCalledTimes(2);
});

it('dismisses a warning as resolved and refetches health', async () => {
const user = userEvent.setup();
api.getSystemHealth
.mockResolvedValueOnce(withWarnings([{ type: 'disk', message: 'Disk usage at or above 90%' }]))
.mockResolvedValueOnce(withWarnings([]));
renderPage();

await screen.findByText('Disk usage at or above 90%');
await user.click(screen.getByRole('button', { name: 'Dismiss warning: Disk usage at or above 90%' }));

expect(dismissHealthWarning).toHaveBeenCalledWith('disk', 'Disk usage at or above 90%', { silent: true });
await waitFor(() => expect(screen.queryByText('Disk usage at or above 90%')).not.toBeInTheDocument());
});

it('toasts an error and does not refetch when dismissing fails', async () => {
const user = userEvent.setup();
api.getSystemHealth.mockResolvedValue(withWarnings([{ type: 'disk', message: 'Disk usage at or above 90%' }]));
dismissHealthWarning.mockRejectedValueOnce(new Error('offline'));
renderPage();

await screen.findByText('Disk usage at or above 90%');
await user.click(screen.getByRole('button', { name: 'Dismiss warning: Disk usage at or above 90%' }));

await waitFor(() => expect(api.getSystemHealth).toHaveBeenCalledTimes(1));
expect(screen.getByText('Disk usage at or above 90%')).toBeInTheDocument();
});

it('keeps the active section in the URL and runs storage scans explicitly', async () => {
api.runSystemResourceReport.mockResolvedValue({
generatedAt: '2026-08-16T00:00:00.000Z',
Expand Down
12 changes: 12 additions & 0 deletions client/src/services/apiSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ export const updateHealthThresholds = (thresholds, options = {}) => request('/sy
body: JSON.stringify(thresholds),
...options
});
// Dismiss/undo a dashboard health warning as resolved. `message` must be the
// warning's current `message` field — the server keys the dismissal on the
// (type, message) pair so an unrelated recurrence isn't silently hidden.
export const dismissHealthWarning = (type, message, options = {}) => request(`/system/health/warnings/${encodeURIComponent(type)}/dismiss`, {
method: 'POST',
body: JSON.stringify({ message }),
...options
});
export const undismissHealthWarning = (type, options = {}) => request(`/system/health/warnings/${encodeURIComponent(type)}/dismiss`, {
method: 'DELETE',
...options
});

// Update
export const getUpdateStatus = () => request('/update/status');
Expand Down
20 changes: 18 additions & 2 deletions server/lib/apiRouteCatalog.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -15617,6 +15617,22 @@
"server/routes/systemHealth.js"
]
},
{
"method": "DELETE",
"path": "/api/system/health/warnings/:type/dismiss",
"mountPath": "/api/system",
"sources": [
"server/routes/systemHealth.js"
]
},
{
"method": "POST",
"path": "/api/system/health/warnings/:type/dismiss",
"mountPath": "/api/system",
"sources": [
"server/routes/systemHealth.js"
]
},
{
"method": "GET",
"path": "/api/system/processing",
Expand Down Expand Up @@ -17796,8 +17812,8 @@
],
"stats": {
"mounts": 150,
"operations": 2204,
"declarations": 2212,
"operations": 2206,
"declarations": 2214,
"sourceFiles": 233
}
}
7 changes: 7 additions & 0 deletions server/lib/validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -1304,6 +1304,13 @@ export const databaseExportSchema = z.object({
backend: z.enum(DB_BACKENDS).optional()
});

// System health dashboard warnings — see server/routes/systemHealth.js. The
// `type` enum mirrors every `rawWarnings.push({ type: ... })` call site there;
// keep the two lists in sync.
export const SYSTEM_HEALTH_WARNING_TYPES = ['memory', 'cpu', 'disk', 'process', 'restarts', 'apps', 'database', 'forge'];
export const systemHealthWarningParamsSchema = z.object({ type: z.enum(SYSTEM_HEALTH_WARNING_TYPES) });
export const systemHealthWarningDismissSchema = z.object({ message: z.string().trim().min(1).max(500) });

/**
* Validate data against a Zod schema, throwing on failure.
* Returns parsed data on success, throws ServerError on failure.
Expand Down
Loading