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}`}
+ >
+
+
+ )}
+ >
{w.message}
{remedy && (
diff --git a/client/src/pages/SystemHealthPage.test.jsx b/client/src/pages/SystemHealthPage.test.jsx
index f07d948424..bf309cd2a7 100644
--- a/client/src/pages/SystemHealthPage.test.jsx
+++ b/client/src/pages/SystemHealthPage.test.jsx
@@ -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';
@@ -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',
diff --git a/client/src/services/apiSystem.js b/client/src/services/apiSystem.js
index c07a0570a4..78c5932b88 100644
--- a/client/src/services/apiSystem.js
+++ b/client/src/services/apiSystem.js
@@ -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');
diff --git a/server/lib/apiRouteCatalog.generated.json b/server/lib/apiRouteCatalog.generated.json
index d40813031c..eb45634980 100644
--- a/server/lib/apiRouteCatalog.generated.json
+++ b/server/lib/apiRouteCatalog.generated.json
@@ -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",
@@ -17796,8 +17812,8 @@
],
"stats": {
"mounts": 150,
- "operations": 2204,
- "declarations": 2212,
+ "operations": 2206,
+ "declarations": 2214,
"sourceFiles": 233
}
}
diff --git a/server/lib/validation.js b/server/lib/validation.js
index 11110c7408..a431333b90 100644
--- a/server/lib/validation.js
+++ b/server/lib/validation.js
@@ -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.
diff --git a/server/routes/health.test.js b/server/routes/health.test.js
index 5b3f46d407..efda007887 100644
--- a/server/routes/health.test.js
+++ b/server/routes/health.test.js
@@ -8,6 +8,8 @@ import { getSelf } from '../services/instances.js';
import { isAuthEnabled } from '../services/auth.js';
import { checkGhHealth } from '../services/github.js';
import { getBuildIdentity } from '../lib/buildIdentity.js';
+import { getSettings, updateSettingsWith } from '../services/settings.js';
+import { statfs } from 'fs/promises';
vi.mock('../services/pm2.js', () => ({
listProcesses: vi.fn().mockResolvedValue([])
@@ -395,4 +397,94 @@ describe('System Health Routes', () => {
expect(body.processes.totalRestarts).toBe(3);
});
});
+
+ describe('dismissing dashboard warnings as resolved', () => {
+ it('suppresses a warning whose stored dismissal message still matches', async () => {
+ // Force a disk-warn condition (95% used) so the 'disk' warning fires,
+ // then supply a dismissal recorded against that exact message.
+ vi.mocked(statfs).mockResolvedValueOnce({ blocks: 100, bavail: 5, bsize: 1 });
+ getSettings.mockResolvedValueOnce({
+ health: { dismissedWarnings: { disk: { message: 'Disk usage at or above 90%', dismissedAt: '2026-01-01T00:00:00.000Z' } } }
+ });
+
+ const response = await request(app).get('/api/system/health/details');
+
+ expect(response.body.warnings.some(w => w.type === 'disk')).toBe(false);
+ });
+
+ it('shows the warning again when the current message differs from the dismissal (new occurrence)', async () => {
+ // 99% used crosses diskCritical (98), not diskWarn (90) — a different
+ // message than the one that was dismissed.
+ vi.mocked(statfs).mockResolvedValueOnce({ blocks: 100, bavail: 1, bsize: 1 });
+ getSettings.mockResolvedValueOnce({
+ health: { dismissedWarnings: { disk: { message: 'Disk usage at or above 90%', dismissedAt: '2026-01-01T00:00:00.000Z' } } }
+ });
+
+ const response = await request(app).get('/api/system/health/details');
+
+ const diskWarnings = response.body.warnings.filter(w => w.type === 'disk');
+ expect(diskWarnings).toHaveLength(1);
+ expect(diskWarnings[0].message).toContain('98%');
+ });
+
+ it('prunes a stale dismissal once its condition no longer holds', async () => {
+ // Default disk mock (50% used) never raises a 'disk' warning, so a
+ // stored disk dismissal is now stale and should be dropped.
+ getSettings.mockResolvedValueOnce({
+ health: { dismissedWarnings: { disk: { message: 'Disk usage at or above 90%', dismissedAt: '2026-01-01T00:00:00.000Z' } } }
+ });
+ vi.mocked(updateSettingsWith).mockClear();
+
+ await request(app).get('/api/system/health/details');
+
+ expect(updateSettingsWith).toHaveBeenCalledTimes(1);
+ const mutate = vi.mocked(updateSettingsWith).mock.calls[0][0];
+ const next = await mutate({});
+ expect(next.health.dismissedWarnings).toEqual({});
+ });
+
+ describe('POST /health/warnings/:type/dismiss', () => {
+ it('records the dismissal and echoes it back', async () => {
+ const response = await request(app)
+ .post('/api/system/health/warnings/disk/dismiss')
+ .send({ message: 'Disk usage at or above 90%' });
+
+ expect(response.status).toBe(200);
+ expect(response.body).toMatchObject({ message: 'Disk usage at or above 90%' });
+ expect(response.body.dismissedAt).toEqual(expect.any(String));
+ });
+
+ it('rejects an unknown warning type', async () => {
+ const response = await request(app)
+ .post('/api/system/health/warnings/bogus/dismiss')
+ .send({ message: 'anything' });
+ expect(response.status).toBe(400);
+ });
+
+ it('rejects a missing message', async () => {
+ const response = await request(app)
+ .post('/api/system/health/warnings/disk/dismiss')
+ .send({});
+ expect(response.status).toBe(400);
+ });
+ });
+
+ describe('DELETE /health/warnings/:type/dismiss', () => {
+ it('undoes a dismissal', async () => {
+ vi.mocked(updateSettingsWith).mockClear();
+ const response = await request(app).delete('/api/system/health/warnings/disk/dismiss');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ success: true });
+ const mutate = vi.mocked(updateSettingsWith).mock.calls[0][0];
+ const next = await mutate({ health: { dismissedWarnings: { disk: { message: 'x', dismissedAt: 'y' } } } });
+ expect(next.health.dismissedWarnings).toEqual({});
+ });
+
+ it('rejects an unknown warning type', async () => {
+ const response = await request(app).delete('/api/system/health/warnings/bogus/dismiss');
+ expect(response.status).toBe(400);
+ });
+ });
+ });
});
diff --git a/server/routes/systemHealth.js b/server/routes/systemHealth.js
index ae60c339eb..680031d3a9 100644
--- a/server/routes/systemHealth.js
+++ b/server/routes/systemHealth.js
@@ -10,6 +10,7 @@ import { getCurrentVersion } from '../services/updateChecker.js';
import { asyncHandler, ServerError } from '../lib/errorHandler.js';
import { getMemoryStats } from '../lib/memoryStats.js';
import { formatBytes } from '../lib/fileUtils.js';
+import { validateRequest, systemHealthWarningParamsSchema, systemHealthWarningDismissSchema } from '../lib/validation.js';
import { getSettings, updateSettingsWith } from '../services/settings.js';
import { checkGhHealth } from '../services/github.js';
import { isAuthEnabled } from '../services/auth.js';
@@ -30,17 +31,39 @@ const DEFAULT_THRESHOLDS = {
diskCritical: 98
};
-async function loadThresholds() {
+// Dashboard warnings are recomputed fresh on every read (nothing about them is
+// persisted), so "dismiss" can't delete a row — it has to remember, per warning
+// TYPE, the exact message that was dismissed. A later read matching that same
+// (type, message) pair stays suppressed; a DIFFERENT message for the same type
+// (severity escalated, a different process started crash-looping) is a new
+// occurrence and is shown again automatically. Keyed by type rather than a
+// generated id because each health check emits at most one warning per type.
+//
+// Thresholds and dismissals both live under settings.health, so one read
+// covers both — GET /health/details used to call getSettings() twice (once
+// per concern), paying for two deep-clones of the settings cache on every
+// dashboard poll.
+async function loadHealthSettings() {
const settings = await getSettings().catch(() => ({}));
const h = settings.health || {};
+ const dismissedWarnings = h.dismissedWarnings;
return {
- memoryWarn: Number(h.memoryWarn) || DEFAULT_THRESHOLDS.memoryWarn,
- memoryCritical: Number(h.memoryCritical) || DEFAULT_THRESHOLDS.memoryCritical,
- diskWarn: Number(h.diskWarn) || DEFAULT_THRESHOLDS.diskWarn,
- diskCritical: Number(h.diskCritical) || DEFAULT_THRESHOLDS.diskCritical
+ thresholds: {
+ memoryWarn: Number(h.memoryWarn) || DEFAULT_THRESHOLDS.memoryWarn,
+ memoryCritical: Number(h.memoryCritical) || DEFAULT_THRESHOLDS.memoryCritical,
+ diskWarn: Number(h.diskWarn) || DEFAULT_THRESHOLDS.diskWarn,
+ diskCritical: Number(h.diskCritical) || DEFAULT_THRESHOLDS.diskCritical
+ },
+ dismissedWarnings: dismissedWarnings && typeof dismissedWarnings === 'object' && !Array.isArray(dismissedWarnings)
+ ? dismissedWarnings
+ : {}
};
}
+// Every write below only ever touches settings.health — shallow-merging a
+// patch into whatever the write queue's freshest snapshot already holds there.
+const patchHealth = (current, patch) => ({ ...current, health: { ...(current.health || {}), ...patch } });
+
const router = Router();
router.get('/processing', asyncHandler(async (req, res) => {
@@ -108,7 +131,7 @@ router.get('/health/details', asyncHandler(async (req, res) => {
const startTime = Date.now();
// Gather data in parallel
- const [pm2Processes, appStatusSummary, cosStatus, self, dbHealth, version, diskStats, memStats, thresholds, forgeHealth, mediaCapacity] = await Promise.all([
+ const [pm2Processes, appStatusSummary, cosStatus, self, dbHealth, version, diskStats, memStats, healthSettings, forgeHealth, mediaCapacity] = await Promise.all([
listProcesses().catch(() => []),
apps.getAppStatusSummary().catch(() => ({ total: 0, online: 0, stopped: 0, notStarted: 0, unknown: 0, degraded: false, unmanaged: 0 })),
cos.getStatus().catch(() => null),
@@ -117,12 +140,13 @@ router.get('/health/details', asyncHandler(async (req, res) => {
getCurrentVersion().catch(() => null),
statfs('/').catch(() => null),
getMemoryStats(),
- loadThresholds(),
+ loadHealthSettings(),
checkGhHealth().catch(() => ({ status: 'error', ok: false, detail: 'Health check failed', remedy: null, checkedAt: null })),
// Media-lane capacity never fails the health report: an unreadable GPU probe
// degrades to `null`, which the UI renders as unknown rather than as idle.
getMediaCapacity().catch(() => null)
]);
+ const { thresholds, dismissedWarnings } = healthSettings;
const memUsagePercent = Math.round((memStats.used / memStats.total) * 100);
const cpuLoad = os.loadavg()[0]; // 1-minute load average
@@ -181,44 +205,41 @@ router.get('/health/details', asyncHandler(async (req, res) => {
// and excluded from the running denominator)
const appStats = appStatusSummary;
- // Determine overall health status
- let overallHealth = 'healthy';
- const warnings = [];
+ // Determine overall health status. Each condition below records its
+ // severity on the warning itself rather than mutating `overallHealth`
+ // inline, because a dismissed warning (see loadDismissedWarnings above)
+ // must not count toward the badge — overallHealth is derived once, after
+ // dismissals are filtered out, from whatever warnings remain visible.
+ const rawWarnings = [];
if (memUsagePercent >= thresholds.memoryCritical) {
- overallHealth = 'critical';
- warnings.push({ type: 'memory', message: `Memory usage at or above ${thresholds.memoryCritical}%` });
+ rawWarnings.push({ type: 'memory', severity: 'critical', message: `Memory usage at or above ${thresholds.memoryCritical}%` });
} else if (memUsagePercent >= thresholds.memoryWarn) {
- if (overallHealth !== 'critical') overallHealth = 'warning';
- warnings.push({ type: 'memory', message: `Memory usage at or above ${thresholds.memoryWarn}%` });
+ rawWarnings.push({ type: 'memory', severity: 'warning', message: `Memory usage at or above ${thresholds.memoryWarn}%` });
}
if (cpuUsagePercent > 100) {
- if (overallHealth !== 'critical') overallHealth = 'warning';
- warnings.push({ type: 'cpu', message: 'CPU load high' });
+ rawWarnings.push({ type: 'cpu', severity: 'warning', message: 'CPU load high' });
}
if (disk) {
if (disk.usagePercent >= thresholds.diskCritical) {
- overallHealth = 'critical';
- warnings.push({ type: 'disk', message: `Disk usage at or above ${thresholds.diskCritical}%` });
+ rawWarnings.push({ type: 'disk', severity: 'critical', message: `Disk usage at or above ${thresholds.diskCritical}%` });
} else if (disk.usagePercent >= thresholds.diskWarn) {
- if (overallHealth !== 'critical') overallHealth = 'warning';
- warnings.push({ type: 'disk', message: `Disk usage at or above ${thresholds.diskWarn}%` });
+ rawWarnings.push({ type: 'disk', severity: 'warning', message: `Disk usage at or above ${thresholds.diskWarn}%` });
}
}
if (processStats.errored > 0) {
- overallHealth = 'critical';
- warnings.push({ type: 'process', message: `${processStats.errored} process(es) errored` });
+ rawWarnings.push({ type: 'process', severity: 'critical', message: `${processStats.errored} process(es) errored` });
}
if (processStats.unstableRestarts > 0) {
- if (overallHealth !== 'critical') overallHealth = 'warning';
const crashing = supervised.filter(p => (p.unstableRestarts || 0) > 0).map(p => p.name);
const plural = processStats.unstableRestarts === 1 ? '' : 's';
- warnings.push({
+ rawWarnings.push({
type: 'restarts',
+ severity: 'warning',
message: `${processStats.unstableRestarts} crash-loop restart${plural} (${crashing.join(', ')})`
});
}
@@ -227,17 +248,14 @@ router.get('/health/details', asyncHandler(async (req, res) => {
// those apps' online/stopped status is unknown — surface it rather than letting
// the counts silently read as "everything not started."
if (appStats.degraded) {
- if (overallHealth !== 'critical') overallHealth = 'warning';
const unknown = appStats.unknown || 0;
- warnings.push({ type: 'apps', message: `App status unavailable for ${unknown} app(s) — PM2 read failed` });
+ rawWarnings.push({ type: 'apps', severity: 'warning', message: `App status unavailable for ${unknown} app(s) — PM2 read failed` });
}
if (!dbHealth.connected) {
- if (overallHealth !== 'critical') overallHealth = 'warning';
- warnings.push({ type: 'database', message: `PostgreSQL disconnected${dbHealth.error ? `: ${dbHealth.error}` : ''}` });
+ rawWarnings.push({ type: 'database', severity: 'warning', message: `PostgreSQL disconnected${dbHealth.error ? `: ${dbHealth.error}` : ''}` });
} else if (!dbHealth.hasSchema) {
- if (overallHealth !== 'critical') overallHealth = 'warning';
- warnings.push({ type: 'database', message: 'PostgreSQL connected but schema missing' });
+ rawWarnings.push({ type: 'database', severity: 'warning', message: 'PostgreSQL connected but schema missing' });
}
// A `gh` that cannot reach the forge does not fail loudly anywhere else: the
@@ -247,13 +265,36 @@ router.get('/health/details', asyncHandler(async (req, res) => {
// filed none. Warn only when gh is present but unusable; an install that
// never had gh has opted out of those features rather than broken them.
if (!forgeHealth.ok && forgeHealth.status !== 'not-installed') {
- if (overallHealth !== 'critical') overallHealth = 'warning';
- warnings.push({
+ rawWarnings.push({
type: 'forge',
+ severity: 'warning',
message: `GitHub CLI unusable (${forgeHealth.status})${forgeHealth.remedy ? ` — ${forgeHealth.remedy}` : ''}`
});
}
+ // A dismissal only stays applied while the warning it was recorded against
+ // is still current (same type AND same message) — see loadHealthSettings.
+ // Anything else (the condition cleared, or recurred with a different
+ // message) drops out of `dismissedWarnings` here so a genuinely new
+ // occurrence is never silently hidden by a stale record.
+ const nextDismissedWarnings = {};
+ const warnings = [];
+ for (const warning of rawWarnings) {
+ const dismissal = dismissedWarnings[warning.type];
+ if (dismissal?.message === warning.message) {
+ nextDismissedWarnings[warning.type] = dismissal;
+ continue;
+ }
+ warnings.push(warning);
+ }
+ if (Object.keys(dismissedWarnings).length !== Object.keys(nextDismissedWarnings).length) {
+ await updateSettingsWith((current) => patchHealth(current, { dismissedWarnings: nextDismissedWarnings })).catch(() => {});
+ }
+
+ const overallHealth = warnings.some(w => w.severity === 'critical')
+ ? 'critical'
+ : warnings.length > 0 ? 'warning' : 'healthy';
+
// CoS status
const cosInfo = cosStatus ? {
running: cosStatus.running,
@@ -334,6 +375,41 @@ router.get('/health/details', asyncHandler(async (req, res) => {
});
}));
+/**
+ * POST /api/system/health/warnings/:type/dismiss — mark the CURRENT instance
+ * of a dashboard warning as resolved. Warnings are computed fresh on every
+ * /health/details read rather than stored, so this records `{ message,
+ * dismissedAt }` per warning type in settings.health.dismissedWarnings; the
+ * next read hides it as long as the same (type, message) pair recurs, and
+ * automatically un-dismisses (and prunes the record) once the condition
+ * clears or changes. See the comment above loadHealthSettings.
+ */
+router.post('/health/warnings/:type/dismiss', asyncHandler(async (req, res) => {
+ const { type } = validateRequest(systemHealthWarningParamsSchema, req.params);
+ const { message } = validateRequest(systemHealthWarningDismissSchema, req.body || {});
+ const next = await updateSettingsWith((current) => patchHealth(current, {
+ dismissedWarnings: {
+ ...(current.health?.dismissedWarnings || {}),
+ [type]: { message, dismissedAt: new Date().toISOString() }
+ }
+ }));
+ res.json(next.health.dismissedWarnings[type]);
+}));
+
+/**
+ * DELETE /api/system/health/warnings/:type/dismiss — undo a dismissal so the
+ * warning (if its underlying condition is still true) reappears immediately.
+ */
+router.delete('/health/warnings/:type/dismiss', asyncHandler(async (req, res) => {
+ const { type } = validateRequest(systemHealthWarningParamsSchema, req.params);
+ await updateSettingsWith((current) => {
+ const dismissedWarnings = { ...(current.health?.dismissedWarnings || {}) };
+ delete dismissedWarnings[type];
+ return patchHealth(current, { dismissedWarnings });
+ });
+ res.json({ success: true });
+}));
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
router.put('/health/thresholds', asyncHandler(async (req, res) => {
@@ -364,7 +440,7 @@ router.put('/health/thresholds', asyncHandler(async (req, res) => {
// Merge the health thresholds against the freshest snapshot inside the write
// queue so a concurrent settings write isn't clobbered by a stale base.
- await updateSettingsWith((current) => ({ ...current, health: { ...(current.health || {}), ...next } }));
+ await updateSettingsWith((current) => patchHealth(current, next));
res.json(next);
}));