Refactor Notices and System Health pane (#24243)

* refactor notices

* show startup message for enrichments in health pane

* tweaks
This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent 5cef6823a6
commit 7bc32fd4c9
50 changed files with 2471 additions and 898 deletions
+198
View File
@@ -0,0 +1,198 @@
import { useCallback, useMemo } from "react";
import axios from "axios";
import type { TFunction } from "i18next";
import useSWR from "swr";
import { useDateLocale } from "@/hooks/use-date-locale";
import { useTimezone } from "@/hooks/use-date-utils";
import { useHealthChecks } from "@/hooks/use-health-checks";
import { useNotices } from "@/hooks/use-notices";
import { evaluateConfigHealth } from "@/utils/configHealth";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { sortHealthProblems } from "@/utils/healthSort";
import { streamHealth } from "@/utils/streamHealth";
import type { FrigateConfig } from "@/types/frigateConfig";
import type { HealthProblem } from "@/types/health";
import type { DismissedCheck, Notice } from "@/types/notice";
const EXTERNAL_LINK = /^https?:\/\//;
type HealthProblems = {
/** undismissed rows, most severe first */
problems: HealthProblem[];
/** dismissed rows, most recently dismissed first; undefined until loaded */
dismissed?: HealthProblem[];
loading: boolean;
/** delete every dismissed row, so each can show again */
clearDismissed: () => Promise<void>;
};
/**
* Every row of the Health tab's Notices list: registry notices plus the config
* and stream checks the browser builds. The Notices pane and the status bar
* both read it, so they always agree. `t` only fills in the text; row ids and
* counts never depend on it.
*/
export function useHealthProblems(
t: TFunction,
showDismissed = false,
): HealthProblems {
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const timezone = useTimezone(config);
const locale = useDateLocale();
const {
notices,
dismissed: dismissedNotices,
dismiss,
mutateDismissed,
} = useNotices(showDismissed);
const { data: dismissedChecks, mutate: mutateDismissedChecks } = useSWR<
DismissedCheck[]
>("notices/dismissed_checks");
const {
stream: { results },
} = useHealthChecks();
const dismissCheck = useCallback(
async (id: string) => {
await axios.post(`notices/${id}/dismiss`);
mutateDismissedChecks();
},
[mutateDismissedChecks],
);
const clearDismissed = useCallback(async () => {
await axios.delete("notices/dismissed");
mutateDismissed();
mutateDismissedChecks();
}, [mutateDismissed, mutateDismissedChecks]);
const formatTime = useCallback(
(timestamp: number) =>
formatUnixTimestampToDateTime(timestamp, {
timezone,
date_format: "MMM d, h:mm a",
locale,
}),
[timezone, locale],
);
const noticeRow = useCallback(
(notice: Notice): HealthProblem => {
const link = notice.link ?? undefined;
const external = link !== undefined && EXTERNAL_LINK.test(link);
const isCamera = notice.category === "camera";
return {
id: `notice:${notice.id}`,
source: "registry",
severity: notice.severity,
// only a camera scope is a name; other scopes are ids like a release
scope: isCamera ? (notice.scope ?? undefined) : undefined,
scopeIsCamera: isCamera,
// replace keeps backend params out of i18next's own option names
text: t(`health.notices.kinds.${notice.kind}`, {
ns: "views/system",
replace: notice.params,
count: notice.count,
}),
meta:
notice.dismissed_at === null
? t("health.notices.firstSeen", {
ns: "views/system",
time: formatTime(notice.first_seen),
count: notice.count,
})
: t("health.notices.dismissedAt", {
ns: "views/system",
time: formatTime(notice.dismissed_at),
}),
link: external ? undefined : link,
externalLink: external ? link : undefined,
onDismiss:
notice.dismissed_at === null ? () => dismiss(notice.id) : undefined,
};
},
[dismiss, formatTime, t],
);
const checks = useMemo<HealthProblem[]>(
() =>
config
? [
...evaluateConfigHealth(config, t),
...streamHealth(config, results, t).problems,
]
: [],
[config, results, t],
);
const dismissedAt = useMemo(
() =>
new Map(
(dismissedChecks ?? []).map((check) => [check.id, check.dismissed_at]),
),
[dismissedChecks],
);
const problems = useMemo(
() =>
sortHealthProblems([
...(notices ?? []).map(noticeRow),
...checks
.filter((check) => !dismissedAt.has(check.id))
.map((check) => ({
...check,
onDismiss: () => dismissCheck(check.id),
})),
]),
[notices, noticeRow, checks, dismissedAt, dismissCheck],
);
const dismissed = useMemo(() => {
if (!showDismissed || dismissedNotices === undefined) {
return undefined;
}
const rows = [
...dismissedNotices.map((notice) => ({
at: notice.dismissed_at ?? 0,
row: noticeRow(notice),
})),
...checks.flatMap((check) => {
const at = dismissedAt.get(check.id);
return at === undefined
? []
: [
{
at,
row: {
...check,
meta: t("health.notices.dismissedAt", {
ns: "views/system",
time: formatTime(at),
}),
},
},
];
}),
];
return rows.sort((a, b) => b.at - a.at).map(({ row }) => row);
}, [
showDismissed,
dismissedNotices,
noticeRow,
checks,
dismissedAt,
formatTime,
t,
]);
const loading =
notices === undefined || dismissedChecks === undefined || !config;
return { problems, dismissed, loading, clearDismissed };
}
+25 -19
View File
@@ -1,21 +1,22 @@
import { useCallback, useMemo } from "react";
import { useCallback, useEffect, useMemo } from "react";
import axios from "axios";
import useSWR from "swr";
import { useWs } from "@/api/ws";
import type { Notice, NoticeStats } from "@/types/notice";
import type { Notice } from "@/types/notice";
/**
* Active notices: a REST snapshot, replaced by every `notices` websocket
* payload after that. Stats are fetched separately and only change on
* dismiss or a new occurrence, so they revalidate after each dismiss.
* Active notices come from a REST snapshot, then from every `notices`
* websocket payload. Dismissed notices are fetched only while the history is
* shown, and again when the active list changes or the tab regains focus. A
* purge in another tab leaves the active list unchanged, so only focus
* catches it.
*/
export function useNotices() {
export function useNotices(showDismissed: boolean) {
const { data: initial, mutate } = useSWR<Notice[]>("notices", {
revalidateOnFocus: false,
});
const { data: stats, mutate: mutateStats } = useSWR<NoticeStats[]>(
"notices/stats",
{ revalidateOnFocus: false },
const { data: history, mutate: mutateHistory } = useSWR<Notice[]>(
showDismissed ? ["notices", { include_dismissed: true }] : null,
);
const {
value: { payload },
@@ -33,22 +34,27 @@ export function useNotices() {
// still shows up because the registry publishes a new frame after it
const notices = live ?? initial;
const statsByKind = useMemo(() => {
const byKind: Partial<Record<Notice["kind"], NoticeStats>> = {};
(stats ?? []).forEach((entry) => {
byKind[entry.kind] = entry;
});
return byKind;
}, [stats]);
// refetch the history whenever the active list changes; SWR ignores the
// call while the history is hidden
useEffect(() => {
mutateHistory();
}, [live, mutateHistory]);
const dismissed = useMemo(
() =>
history
?.filter((notice) => notice.dismissed_at !== null)
.sort((a, b) => (b.dismissed_at ?? 0) - (a.dismissed_at ?? 0)),
[history],
);
const dismiss = useCallback(
async (id: string) => {
await axios.post(`notices/${id}/dismiss`);
mutate();
mutateStats();
},
[mutate, mutateStats],
[mutate],
);
return { notices, statsByKind, dismiss };
return { notices, dismissed, dismiss, mutateDismissed: mutateHistory };
}
+10 -13
View File
@@ -31,6 +31,9 @@ function problem(
return { text, severity, color: SEVERITY_COLOR[severity], relevantLink };
}
// matches SKIPPED_DETECTIONS_PCT in frigate/stats/emitter.py
const SKIPPED_DETECTIONS_PCT = 5;
export default function useStats(stats: FrigateStats | undefined) {
const { t } = useTranslation(["views/system"]);
const { data: config } = useSWR<FrigateConfig>("config");
@@ -61,18 +64,9 @@ export default function useStats(stats: FrigateStats | undefined) {
return problems;
}
// check shm level
const shm = memoizedStats.service.storage["/dev/shm"];
if (shm?.total && shm?.min_shm && shm.total < shm.min_shm) {
if (memoizedStats.service.retention_unmet) {
problems.push(
problem(
"error",
t("stats.shmTooLow", {
total: shm.total,
min: shm.min_shm,
}),
"/system#storage",
),
problem("error", t("stats.retentionUnmet"), "/system#storage"),
);
}
@@ -144,13 +138,16 @@ export default function useStats(stats: FrigateStats | undefined) {
const cameraName = config?.cameras?.[name]?.friendly_name ?? name;
if (config?.cameras?.[name]?.enabled && cam["skipped_fps"] > 1) {
if (
config?.cameras?.[name]?.enabled &&
cam["skipped_pct"] >= SKIPPED_DETECTIONS_PCT
) {
problems.push(
problem(
"warning",
t("stats.cameraSkippedDetections", {
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
fps: cam["skipped_fps"],
pct: cam["skipped_pct"],
}),
"/system#cameras",
),