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
+7 -15
View File
@@ -4,6 +4,7 @@ import {
StatusMessage,
} from "@/context/statusbar-provider";
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
import StatusBarNotices from "@/components/health/StatusBarNotices";
import { cn } from "@/lib/utils";
import type { ProfilesApiResponse } from "@/types/profile";
import { getProfileColor } from "@/utils/profileColors";
@@ -186,21 +187,11 @@ export default function Statusbar() {
))}
</div>
<div className="no-scrollbar flex h-full max-w-[50%] items-center gap-2 overflow-x-auto">
{Object.entries(messages).length === 0 ? (
isAdmin ? (
<Link
to="/system#health"
className="flex items-center gap-2 text-sm"
>
<FaCheck className="size-3 text-green-500" />
{t("stats.healthy")}
</Link>
) : (
<div className="flex items-center gap-2 text-sm">
<FaCheck className="size-3 text-green-500" />
{t("stats.healthy")}
</div>
)
{!isAdmin ? null : Object.entries(messages).length === 0 ? (
<Link to="/system#health" className="flex items-center gap-2 text-sm">
<FaCheck className="size-3 text-green-500" />
{t("stats.healthy")}
</Link>
) : (
Object.entries(messages).map(([key, messageArray]) => (
<div key={key} className="flex h-full items-center gap-2">
@@ -230,6 +221,7 @@ export default function Statusbar() {
</div>
))
)}
{isAdmin && <StatusBarNotices />}
</div>
</div>
);
@@ -0,0 +1,101 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { FaFilter } from "react-icons/fa";
import FilterSwitch from "@/components/filter/FilterSwitch";
import PlatformAwareDialog from "@/components/overlay/dialog/PlatformAwareDialog";
import { Button } from "@/components/ui/button";
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import {
DEFAULT_NOTICE_FILTER,
type HealthSeverity,
type NoticeFilter,
} from "@/types/health";
type NoticeFilterButtonProps = {
filter: NoticeFilter;
onFilterChange: (filter: NoticeFilter) => void;
};
export default function NoticeFilterButton({
filter,
onFilterChange,
}: NoticeFilterButtonProps) {
const { t } = useTranslation(["views/system", "components/filter"]);
const [open, setOpen] = useState(false);
const active =
filter.showDismissed ||
filter.severities.length < DEFAULT_NOTICE_FILTER.severities.length;
const severityLabels: Record<HealthSeverity, string> = {
error: t("health.notices.filter.error"),
warning: t("health.notices.filter.warning"),
info: t("health.notices.filter.info"),
};
const trigger = (
<Button
size="sm"
variant={active ? "select" : "default"}
className="flex items-center gap-2 smart-capitalize"
aria-label={t("filter", { ns: "components/filter" })}
>
<FaFilter
className={
active ? "text-selected-foreground" : "text-secondary-foreground"
}
/>
<div
className={cn(
"hidden md:block",
active ? "text-selected-foreground" : "text-primary",
)}
>
{t("filter", { ns: "components/filter" })}
</div>
</Button>
);
const content = (
<div className="space-y-3 p-4">
<FilterSwitch
label={t("health.notices.filter.showDismissed")}
isChecked={filter.showDismissed}
onCheckedChange={(showDismissed) =>
onFilterChange({ ...filter, showDismissed })
}
/>
<DropdownMenuSeparator />
<div className="space-y-2.5">
<div className="mx-2 text-sm text-muted-foreground">
{t("health.notices.filter.severity")}
</div>
{DEFAULT_NOTICE_FILTER.severities.map((severity) => (
<FilterSwitch
key={severity}
label={severityLabels[severity]}
isChecked={filter.severities.includes(severity)}
onCheckedChange={(checked) =>
onFilterChange({
...filter,
severities: checked
? [...filter.severities, severity]
: filter.severities.filter((s) => s !== severity),
})
}
/>
))}
</div>
</div>
);
return (
<PlatformAwareDialog
trigger={trigger}
content={content}
contentClassName="p-1"
open={open}
onOpenChange={setOpen}
/>
);
}
+95 -150
View File
@@ -1,164 +1,49 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FaCircleCheck } from "react-icons/fa6";
import useSWR from "swr";
import HealthProblemRow from "@/components/health/HealthProblemRow";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button, buttonVariants } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useNotices } from "@/hooks/use-notices";
import { useDateLocale } from "@/hooks/use-date-locale";
import { useTimezone } from "@/hooks/use-date-utils";
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
import { useHealthChecks } from "@/hooks/use-health-checks";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { releaseUrl } from "@/utils/versionUtil";
import { evaluateConfigHealth } from "@/utils/configHealth";
import { isStartupWindow } from "@/utils/health";
import { sortHealthProblems } from "@/utils/healthSort";
import { streamHealth } from "@/utils/streamHealth";
import type { FrigateConfig } from "@/types/frigateConfig";
import type { HealthProblem } from "@/types/health";
import type { Notice, NoticeKind, NoticeStats } from "@/types/notice";
import { useHealthProblems } from "@/hooks/use-health-problems";
import type { NoticeFilter } from "@/types/health";
const SETTINGS_LINK_BY_KIND: Partial<
Record<NoticeKind, (scope: string | null) => string>
> = {
ffmpeg_crash_loop: (scope) => `/settings?page=cameraFfmpeg&camera=${scope}`,
retention_unmet: () => "/system#storage",
detector_stuck: () => "/system#general",
type NoticesPaneProps = {
filter: NoticeFilter;
};
const EXTERNAL_LINK_BY_KIND: Partial<
Record<NoticeKind, (params: Notice["params"]) => string | undefined>
> = {
update_available: (params) =>
typeof params.version === "string" ? releaseUrl(params.version) : undefined,
};
function useNoticeProblems(
notices: Notice[] | undefined,
statsByKind: Partial<Record<NoticeKind, NoticeStats>>,
dismiss: (id: string) => Promise<void>,
): HealthProblem[] {
const { t } = useTranslation(["views/system"]);
const { data: config } = useSWR<FrigateConfig>("config");
const timezone = useTimezone(config);
const locale = useDateLocale();
return useMemo(() => {
if (!notices) {
return [];
}
const formatTime = (timestamp: number, date_format: string) =>
formatUnixTimestampToDateTime(timestamp, {
timezone,
date_format,
locale,
});
return notices.map((notice) => {
const stats = statsByKind[notice.kind];
const seen = formatTime(notice.first_seen, "MMM d, h:mm a");
let meta: string;
if (notice.mode === "event") {
meta = t("health.notices.firstSeen", {
time: seen,
times: notice.count,
});
} else if (stats && stats.occurrences > 1) {
meta = t("health.notices.sinceWithCount", {
time: seen,
times: stats.occurrences,
firstSeen: formatTime(stats.first_seen, "MMM d"),
});
} else {
meta = t("health.notices.since", { time: seen });
}
const link = SETTINGS_LINK_BY_KIND[notice.kind];
const external = EXTERNAL_LINK_BY_KIND[notice.kind];
return {
id: `notice:${notice.id}`,
source: "registry" as const,
severity: notice.severity,
scope: notice.scope ?? undefined,
scopeIsCamera: notice.category === "camera",
// replace keeps backend params out of i18next's own option names
text: t(`health.notices.kinds.${notice.kind}`, {
replace: notice.params,
}),
meta,
link: link ? link(notice.scope) : undefined,
externalLink: external ? external(notice.params) : undefined,
onDismiss:
notice.mode === "event" ? () => dismiss(notice.id) : undefined,
};
});
}, [notices, statsByKind, dismiss, t, timezone, locale]);
}
export default function NoticesPane() {
const { t } = useTranslation(["views/system", "views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const stats = useAutoFrigateStats();
const { notices, statsByKind, dismiss } = useNotices();
const registryProblems = useNoticeProblems(notices, statsByKind, dismiss);
const { potentialProblems } = useStats(stats);
const {
stream: { results },
} = useHealthChecks();
const liveProblems = useMemo<HealthProblem[]>(() => {
if (!stats) {
return [];
}
if (isStartupWindow(stats)) {
return [
{
id: "live:startup",
source: "live",
severity: "info",
text: t("health.notices.startupWindow"),
},
];
}
return potentialProblems.map((problem, index) => ({
id: `live:${index}:${problem.text}`,
source: "live",
severity: problem.severity,
text: problem.text,
link: problem.relevantLink?.replace(/^(?!\/)/, "/"),
}));
}, [stats, potentialProblems, t]);
const configProblems = useMemo<HealthProblem[]>(
() => (config ? evaluateConfigHealth(config, t) : []),
[config, t],
export default function NoticesPane({ filter }: NoticesPaneProps) {
const { t } = useTranslation(["views/system", "views/settings", "common"]);
const { problems, dismissed, loading, clearDismissed } = useHealthProblems(
t,
filter.showDismissed,
);
const [confirmClear, setConfirmClear] = useState(false);
const streamProblems = useMemo<HealthProblem[]>(
() => (config ? streamHealth(config, results, t).problems : []),
[config, results, t],
);
const problems = useMemo(
const shown = useMemo(
() =>
sortHealthProblems([
...registryProblems,
...liveProblems,
...configProblems,
...streamProblems,
]),
[registryProblems, liveProblems, configProblems, streamProblems],
problems.filter((problem) =>
filter.severities.includes(problem.severity),
),
[problems, filter.severities],
);
const loading = notices === undefined || !config;
const shownDismissed = useMemo(
() =>
dismissed?.filter((problem) =>
filter.severities.includes(problem.severity),
),
[dismissed, filter.severities],
);
return (
<div className="flex flex-col gap-4">
@@ -173,14 +58,74 @@ export default function NoticesPane() {
<FaCircleCheck className="size-4 text-success" />
<span>{t("health.notices.empty")}</span>
</div>
) : shown.length === 0 ? (
<div className="px-1 py-2 text-sm text-muted-foreground">
{t("health.notices.noMatches")}
</div>
) : (
<div className="flex flex-col">
{problems.map((problem) => (
{shown.map((problem) => (
<HealthProblemRow key={problem.id} problem={problem} />
))}
</div>
)}
</div>
{filter.showDismissed && (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<div className="text-sm text-muted-foreground">
{t("health.notices.dismissedTitle")}
</div>
{dismissed && dismissed.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmClear(true)}
>
{t("health.notices.clearDismissed")}
</Button>
)}
</div>
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
{shownDismissed === undefined ? (
<Skeleton className="h-10 w-full" />
) : shownDismissed.length === 0 ? (
<div className="px-1 py-2 text-sm text-muted-foreground">
{t("health.notices.noneDismissed")}
</div>
) : (
<div className="flex flex-col">
{shownDismissed.map((problem) => (
<HealthProblemRow key={problem.id} problem={problem} />
))}
</div>
)}
</div>
</div>
)}
<AlertDialog open={confirmClear} onOpenChange={setConfirmClear}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("health.notices.clearDismissedTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("health.notices.clearDismissedDesc")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "destructive" })}
onClick={clearDismissed}
>
{t("health.notices.clearDismissed")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -0,0 +1,25 @@
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { useHealthProblems } from "@/hooks/use-health-problems";
/** The count of undismissed Notices rows, shown before the status bar's health text. */
export default function StatusBarNotices() {
const { t } = useTranslation(["views/system"]);
const { problems, loading } = useHealthProblems(t);
if (loading || problems.length === 0) {
return null;
}
return (
<>
<span className="text-sm text-muted-foreground">•</span>
<Link
to="/system#health"
className="whitespace-nowrap text-sm hover:underline"
>
{t("stats.systemNotices", { count: problems.length })}
</Link>
</>
);
}
+5 -1
View File
@@ -13,6 +13,7 @@ import {
useState,
} from "react";
import useStats from "@/hooks/use-stats";
import { useIsAdmin } from "@/hooks/use-is-admin";
import GeneralSettings from "../menu/GeneralSettings";
import useNavigation from "@/hooks/use-navigation";
import {
@@ -151,7 +152,10 @@ function StatusAlertNav({ className, large }: StatusAlertNavProps) {
}
}, [reindexState, addMessage, clearMessages, t]);
if (!messages || Object.keys(messages).length === 0) {
const isAdmin = useIsAdmin();
// problems link to admin-only pages
if (!isAdmin || !messages || Object.keys(messages).length === 0) {
return;
}
+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",
),
+12 -1
View File
@@ -21,6 +21,8 @@ import { Toaster } from "@/components/ui/sonner";
import { FrigateConfig } from "@/types/frigateConfig";
import EnrichmentMetrics from "@/views/system/EnrichmentMetrics";
import HealthMetrics from "@/views/system/HealthMetrics";
import NoticeFilterButton from "@/components/health/NoticeFilterButton";
import { DEFAULT_NOTICE_FILTER, NoticeFilter } from "@/types/health";
import { useTranslation } from "react-i18next";
const allMetrics = [
@@ -65,6 +67,9 @@ function System() {
const [lastUpdated, setLastUpdated] = useState<number>(
Math.floor(Date.now() / 1000),
);
const [noticeFilter, setNoticeFilter] = useState<NoticeFilter>(
DEFAULT_NOTICE_FILTER,
);
// Track which tabs have been visited so we can keep them mounted after first visit.
// Using a ref updated during render avoids extra render cycles from state/effects.
@@ -122,6 +127,12 @@ function System() {
</ToggleGroup>
<div className="flex h-full items-center">
{pageToggle == "health" && (
<NoticeFilterButton
filter={noticeFilter}
onFilterChange={setNoticeFilter}
/>
)}
{lastUpdated && pageToggle != "health" && (
<div className="h-full content-center text-sm text-muted-foreground">
{t("lastRefreshed")}
@@ -140,7 +151,7 @@ function System() {
</div>
{visitedTabs.has("health") && (
<div className={pageToggle == "health" ? "contents" : "hidden"}>
<HealthMetrics />
<HealthMetrics noticeFilter={noticeFilter} />
</div>
)}
{visitedTabs.has("general") && (
+15 -4
View File
@@ -3,14 +3,14 @@ import type { ReactNode } from "react";
export type HealthSeverity = "error" | "warning" | "info";
/**
* One row in the Health tab's Notices list. Every source (registry notices
* now, live stats, config checks, and stream checks in PR 2) maps into this
* shape so the row component never needs to know where a problem came from.
* One row in the Health tab's Notices list. Registry notices, config checks,
* and stream checks all map into this shape so the row component never needs
* to know where a problem came from.
*/
export type HealthProblem = {
id: string;
/** which source produced the row; part of the sort order */
source: "registry" | "live" | "config" | "stream";
source: "registry" | "config" | "stream";
severity: HealthSeverity;
/** camera name or other scope shown as a chip before the text */
scope?: string;
@@ -29,3 +29,14 @@ export type HealthProblem = {
pending?: boolean;
onDismiss?: () => void;
};
/** What the Health tab's filter shows. */
export type NoticeFilter = {
showDismissed: boolean;
severities: HealthSeverity[];
};
export const DEFAULT_NOTICE_FILTER: NoticeFilter = {
showDismissed: false,
severities: ["error", "warning", "info"],
};
+8 -21
View File
@@ -1,36 +1,23 @@
export type NoticeMode = "state" | "event";
export type NoticeSeverity = "error" | "warning" | "info";
export type NoticeCategory =
| "camera"
| "detector"
| "storage"
| "model"
| "system";
export type NoticeKind =
| "ffmpeg_crash_loop"
| "detector_stuck"
| "model_download_failed"
| "retention_unmet"
| "update_available";
export type NoticeCategory = "camera" | "detector" | "model" | "system";
export type Notice = {
id: string;
kind: NoticeKind;
mode: NoticeMode;
kind: string;
severity: NoticeSeverity;
category: NoticeCategory;
scope: string | null;
params: Record<string, string | number | boolean>;
/** app route or absolute URL */
link: string | null;
first_seen: number;
last_seen: number;
count: number;
dismissed_at: number | null;
};
export type NoticeStats = {
kind: NoticeKind;
occurrences: number;
dismissals: number;
first_seen: number;
last_seen: number;
/** a config or stream check row an admin dismissed */
export type DismissedCheck = {
id: string;
dismissed_at: number;
};
+4
View File
@@ -24,6 +24,7 @@ export type CameraStats = {
pid: number;
process_fps: number;
skipped_fps: number;
skipped_pct: number;
connection_quality: "excellent" | "fair" | "poor" | "unusable";
expected_fps: number;
reconnects_last_hour: number;
@@ -54,6 +55,8 @@ export type EmbeddingsStats = {
face_embedding_speed: number;
plate_recognition_speed: number;
text_embedding_speed: number;
face_recognition_speed?: number;
yolov9_plate_detection_speed?: number;
devices?: Record<string, string>;
};
@@ -91,6 +94,7 @@ export type ServiceStats = {
uptime: number;
latest_version: string;
version: string;
retention_unmet: boolean;
};
export type StorageStats = {
+3 -1
View File
@@ -180,13 +180,15 @@ export function evaluateConfigHealth(
cameraMessages
.filter((message) => isActive(message, ctx))
.forEach((message) => {
// camera names have no dots, so the backend can tell this suffix
// from global and modelN when the camera is deleted
const problem = toProblem(
message,
section,
ctx,
camera.name,
true,
camera.name,
`camera.${camera.name}`,
t,
);
+28 -6
View File
@@ -8,8 +8,8 @@ import type {
DetectionModelConfig,
FrigateConfig,
} from "@/types/frigateConfig";
import type { FrigateStats, GpuVendor } from "@/types/stats";
import { InferenceThreshold } from "@/types/graph";
import type { EmbeddingsStats, FrigateStats, GpuVendor } from "@/types/stats";
import { EmbeddingThreshold, InferenceThreshold } from "@/types/graph";
import { summarizeDevices } from "@/utils/detectionHardware";
import { isReplayCamera } from "@/utils/cameraUtil";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
@@ -527,6 +527,26 @@ type EnrichmentSpec = {
presenceOnly: boolean;
};
type SpeedKey = Exclude<keyof EmbeddingsStats, "devices">;
// the stats that time each enrichment's inference
const SPEED_KEYS: Record<EnrichmentSpec["id"], SpeedKey[]> = {
semantic_search: ["image_embedding_speed", "text_embedding_speed"],
face_recognition: ["face_recognition_speed"],
lpr: ["plate_recognition_speed", "yolov9_plate_detection_speed"],
audio_transcription: [],
};
/** Whether an enrichment's inference is past the warning line its chart draws. */
function inferenceIsSlow(
stats: FrigateStats | undefined,
id: EnrichmentSpec["id"],
): boolean {
return SPEED_KEYS[id].some(
(key) => (stats?.embeddings?.[key] ?? 0) > EmbeddingThreshold.warning,
);
}
function enrichmentSpecs(config: FrigateConfig): EnrichmentSpec[] {
const ss = config.semantic_search;
const anyCameraTranscribes = Object.values(config.cameras).some(
@@ -682,9 +702,10 @@ export function enrichmentRows({
id,
state: "unknown",
label,
message: t("health.hardware.modelNotRunYet", {
ns: "views/system",
}),
message:
startup || !stats
? t("health.hardware.justStarted", { ns: "views/system" })
: t("health.hardware.modelNotRunYet", { ns: "views/system" }),
};
}
@@ -706,7 +727,8 @@ export function enrichmentRows({
};
}
if (runtimeIsCpu && present) {
// a model that keeps up on the CPU needs no accelerator
if (runtimeIsCpu && present && inferenceIsSlow(stats, spec.id)) {
return {
id,
state: "warning",
+1 -1
View File
@@ -1,7 +1,7 @@
import type { HealthProblem } from "@/types/health";
const SEVERITY_ORDER = { error: 0, warning: 1, info: 2 } as const;
const SOURCE_ORDER = { registry: 0, live: 1, config: 2, stream: 3 } as const;
const SOURCE_ORDER = { registry: 0, config: 1, stream: 2 } as const;
/** errors first, then warnings, then info; within a severity by source, then scope */
export function sortHealthProblems(problems: HealthProblem[]): HealthProblem[] {
-4
View File
@@ -1,4 +0,0 @@
/** GitHub release page for a Frigate version such as "0.19.0". */
export function releaseUrl(version: string): string {
return `https://github.com/blakeblackshear/frigate/releases/tag/v${version}`;
}
+7 -2
View File
@@ -1,10 +1,15 @@
import HardwarePane from "@/components/health/HardwarePane";
import NoticesPane from "@/components/health/NoticesPane";
import type { NoticeFilter } from "@/types/health";
export default function HealthMetrics() {
type HealthMetricsProps = {
noticeFilter: NoticeFilter;
};
export default function HealthMetrics({ noticeFilter }: HealthMetricsProps) {
return (
<div className="scrollbar-container mt-4 flex size-full flex-col gap-4 overflow-y-auto">
<NoticesPane />
<NoticesPane filter={noticeFilter} />
<HardwarePane />
</div>
);