mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-27 22:18:58 +03:00
Refactor Notices and System Health pane (#24243)
* refactor notices * show startup message for enrichments in health pane * tweaks
This commit is contained in:
committed by
Nicolas Mowen
parent
5cef6823a6
commit
7bc32fd4c9
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user