diff --git a/client/src/components/SystemHealthWidget.jsx b/client/src/components/SystemHealthWidget.jsx index e045f244bb..80d3e6706f 100644 --- a/client/src/components/SystemHealthWidget.jsx +++ b/client/src/components/SystemHealthWidget.jsx @@ -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 @@ -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; @@ -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" > - - {warning.message} + + {warning.message} + ))} diff --git a/client/src/components/SystemHealthWidget.test.jsx b/client/src/components/SystemHealthWidget.test.jsx index 99df33dfd3..1796964653 100644 --- a/client/src/components/SystemHealthWidget.test.jsx +++ b/client/src/components/SystemHealthWidget.test.jsx @@ -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' }, @@ -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(); + }); }); diff --git a/client/src/hooks/README.md b/client/src/hooks/README.md index a78f71fdab..9e1edc2926 100644 --- a/client/src/hooks/README.md +++ b/client/src/hooks/README.md @@ -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 diff --git a/client/src/hooks/index.js b/client/src/hooks/index.js index 5778eefe7b..168f69f80e 100644 --- a/client/src/hooks/index.js +++ b/client/src/hooks/index.js @@ -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'; diff --git a/client/src/hooks/useHealthWarningDismiss.jsx b/client/src/hooks/useHealthWarningDismiss.jsx new file mode 100644 index 0000000000..116140bb89 --- /dev/null +++ b/client/src/hooks/useHealthWarningDismiss.jsx @@ -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) => ( + + Dismissed: {warning.message} + + + ), { duration: 8000 }); + } catch (err) { + toast.error(err?.message || 'Failed to dismiss warning'); + } finally { + setDismissingType(null); + } + }, [refetchFn, dismissingType]); + + return { dismissingType, handleDismissWarning }; +} diff --git a/client/src/pages/SystemHealthPage.jsx b/client/src/pages/SystemHealthPage.jsx index 056558ff80..35d3fc830b 100644 --- a/client/src/pages/SystemHealthPage.jsx +++ b/client/src/pages/SystemHealthPage.jsx @@ -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'; @@ -132,6 +133,7 @@ function SystemHealthOverview() { () => api.getSystemHealth({ silent: true }), 15_000, ); + const { dismissingType, handleDismissWarning } = useHealthWarningDismiss(refetch); const handleRefresh = async () => { if (refreshing) return; @@ -233,7 +235,25 @@ function SystemHealthOverview() { {health.warnings.map((w, i) => { const remedy = REMEDIATION[w.type]; return ( - + 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}`} + > +