mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-27 00:38:58 +03:00
Add a notice registry and System Health tab (#24178)
* add a notice registry and System Health tab Problems Frigate detects on its own (ffmpeg crash loops, stuck detectors, failed model downloads, recordings deleted before their retention period) only ever existed as log lines. This adds a `NoticeRegistry` in the main process backed by two tables, an `update_notice` IPC topic so producers in other processes can reach it through the dispatcher, an admin-only API and websocket topic, and a Health tab that lists them. Kinds declare their own mode, severity, and category in one place: state notices are resolved by their producer, event notices are dismissed by the user. * treat a prerelease as behind its final release * fix notices clearing early * rename menu items and update docs * don't resolve the update notice on a failed version lookup
This commit is contained in:
committed by
Nicolas Mowen
parent
af60d2db48
commit
f3a31e2fb4
@@ -19,7 +19,7 @@ export type DeepPartial<T> = {
|
||||
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
|
||||
};
|
||||
|
||||
function deepMerge<T extends Record<string, unknown>>(
|
||||
export function deepMerge<T extends Record<string, unknown>>(
|
||||
base: T,
|
||||
overrides?: DeepPartial<T>,
|
||||
): T {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* FrigateStats factory for E2E tests.
|
||||
*/
|
||||
|
||||
import type { DeepPartial } from "./config";
|
||||
import { deepMerge, type DeepPartial } from "./config";
|
||||
|
||||
function cameraStats(_name: string) {
|
||||
return {
|
||||
@@ -72,5 +72,5 @@ export function statsFactory(
|
||||
overrides?: DeepPartial<typeof BASE_STATS>,
|
||||
): typeof BASE_STATS {
|
||||
if (!overrides) return BASE_STATS;
|
||||
return { ...BASE_STATS, ...overrides } as typeof BASE_STATS;
|
||||
return deepMerge(BASE_STATS, overrides) as typeof BASE_STATS;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface ApiMockOverrides {
|
||||
available?: { key: string; presets: Record<string, string> }[];
|
||||
};
|
||||
users?: { username: string; role: string }[];
|
||||
notices?: unknown[];
|
||||
noticeStats?: unknown[];
|
||||
}
|
||||
|
||||
export class ApiMocker {
|
||||
@@ -201,6 +203,15 @@ export class ApiMocker {
|
||||
}),
|
||||
);
|
||||
|
||||
// Notices. The stats route is registered after the list route so it wins
|
||||
// for /api/notices/stats; the list glob does not match a sub-path anyway.
|
||||
await this.page.route("**/api/notices", (route) =>
|
||||
route.fulfill({ json: overrides?.notices ?? [] }),
|
||||
);
|
||||
await this.page.route("**/api/notices/stats", (route) =>
|
||||
route.fulfill({ json: overrides?.noticeStats ?? [] }),
|
||||
);
|
||||
|
||||
// Users. GET lists them; POST/PUT (create, password) just succeed, so
|
||||
// tests assert on the intercepted request body instead of a response.
|
||||
await this.page.route("**/api/users**", (route) =>
|
||||
|
||||
@@ -130,8 +130,8 @@ test.describe("Navigation — settings menu (desktop) @critical", () => {
|
||||
|
||||
const TARGETS = [
|
||||
{ label: "Settings", url: /\/settings/ },
|
||||
{ label: "System metrics", url: /\/system/ },
|
||||
{ label: "System logs", url: /\/logs/ },
|
||||
{ label: "Health and Metrics", url: /\/system/ },
|
||||
{ label: "Logs", url: /\/logs/ },
|
||||
{ label: "Configuration Editor", url: /\/config/ },
|
||||
];
|
||||
|
||||
@@ -142,7 +142,7 @@ test.describe("Navigation — settings menu (desktop) @critical", () => {
|
||||
.locator("aside .mb-8 div[class*='cursor-pointer']")
|
||||
.first();
|
||||
await gear.click();
|
||||
await frigateApp.page.getByLabel(target.label).click();
|
||||
await frigateApp.page.getByLabel(target.label, { exact: true }).click();
|
||||
await expect(frigateApp.page).toHaveURL(target.url);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Health tab tests -- MEDIUM tier.
|
||||
*
|
||||
* Default tab, notice list rendering, dismiss, empty state, update notice.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
|
||||
const NOW = Math.floor(Date.now() / 1000);
|
||||
|
||||
const STATE_NOTICE = {
|
||||
id: "ffmpeg_crash_loop:front_door",
|
||||
kind: "ffmpeg_crash_loop",
|
||||
mode: "state",
|
||||
severity: "error",
|
||||
category: "camera",
|
||||
scope: "front_door",
|
||||
params: { restarts: 6 },
|
||||
first_seen: NOW - 600,
|
||||
last_seen: NOW,
|
||||
count: 1,
|
||||
dismissed_at: null,
|
||||
};
|
||||
|
||||
const EVENT_NOTICE = {
|
||||
id: "detector_stuck",
|
||||
kind: "detector_stuck",
|
||||
mode: "event",
|
||||
severity: "warning",
|
||||
category: "detector",
|
||||
scope: null,
|
||||
params: { detector: "ov" },
|
||||
first_seen: NOW - 7200,
|
||||
last_seen: NOW - 60,
|
||||
count: 3,
|
||||
dismissed_at: null,
|
||||
};
|
||||
|
||||
test.describe("System — Health tab @medium", () => {
|
||||
test("Health is the default tab and lists notices by severity", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
notices: [STATE_NOTICE, EVENT_NOTICE],
|
||||
noticeStats: [
|
||||
{
|
||||
kind: "ffmpeg_crash_loop",
|
||||
occurrences: 14,
|
||||
dismissals: 0,
|
||||
first_seen: NOW - 86400 * 20,
|
||||
last_seen: NOW,
|
||||
},
|
||||
],
|
||||
});
|
||||
await frigateApp.goto("/system");
|
||||
|
||||
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
|
||||
"data-state",
|
||||
"on",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
const rows = frigateApp.page.locator("[data-testid^='health-problem-']");
|
||||
await expect(rows).toHaveCount(2);
|
||||
await expect(rows.nth(0)).toHaveAttribute("data-severity", "error");
|
||||
await expect(rows.nth(0)).toContainText("ffmpeg has crashed 6 times");
|
||||
await expect(rows.nth(0)).toContainText("14 times since");
|
||||
// the default fixture's cpu detector is slow (75.5 ms); PR 2 merges live
|
||||
// problems into this list, PR 1 does not, so no extra row here
|
||||
await expect(
|
||||
rows.nth(0).getByRole("button", { name: "Dismiss" }),
|
||||
).toHaveCount(0);
|
||||
await expect(rows.nth(1)).toContainText("Detector ov was restarted");
|
||||
await expect(rows.nth(1)).toContainText("3 times");
|
||||
await expect(
|
||||
rows.nth(1).getByRole("button", { name: "Dismiss" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("dismiss posts and removes the row", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ notices: [EVENT_NOTICE] });
|
||||
|
||||
// the list shrinks after the dismiss so the refetch shows the row gone
|
||||
let dismissed = false;
|
||||
await frigateApp.page.route("**/api/notices", (route) =>
|
||||
route.fulfill({ json: dismissed ? [] : [EVENT_NOTICE] }),
|
||||
);
|
||||
await frigateApp.page.route(
|
||||
"**/api/notices/detector_stuck/dismiss",
|
||||
(route) => {
|
||||
dismissed = true;
|
||||
return route.fulfill({ json: { success: true } });
|
||||
},
|
||||
);
|
||||
|
||||
await frigateApp.goto("/system#health");
|
||||
const request = frigateApp.page.waitForRequest(
|
||||
(req) =>
|
||||
req.url().includes("/api/notices/detector_stuck/dismiss") &&
|
||||
req.method() === "POST",
|
||||
);
|
||||
await frigateApp.page.getByRole("button", { name: "Dismiss" }).click();
|
||||
await request;
|
||||
|
||||
await expect(
|
||||
frigateApp.page.locator("[data-testid^='health-problem-']"),
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
await expect(frigateApp.page.getByText("No notices")).toBeVisible();
|
||||
});
|
||||
|
||||
test("empty state with no notices", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults();
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await expect(frigateApp.page.getByText("No notices")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("update notice renders as info with a release link", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
notices: [
|
||||
{
|
||||
id: "update_available",
|
||||
kind: "update_available",
|
||||
mode: "state",
|
||||
severity: "info",
|
||||
category: "system",
|
||||
scope: null,
|
||||
params: { version: "0.19.0" },
|
||||
first_seen: NOW - 3600,
|
||||
last_seen: NOW,
|
||||
count: 1,
|
||||
dismissed_at: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"health-problem-notice:update_available",
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(row).toHaveAttribute("data-severity", "info");
|
||||
await expect(row).toContainText("Frigate 0.19.0 is available");
|
||||
await expect(row.getByRole("link", { name: "Open link" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/blakeblackshear/frigate/releases/tag/v0.19.0",
|
||||
);
|
||||
await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("System — Health tab mobile @medium @mobile", () => {
|
||||
test.skip(({ frigateApp }) => !frigateApp.isMobile, "Mobile-only");
|
||||
|
||||
test("notices render at mobile viewport", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ notices: [STATE_NOTICE] });
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByTestId(
|
||||
"health-problem-notice:ffmpeg_crash_loop:front_door",
|
||||
),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
});
|
||||
@@ -168,9 +168,9 @@
|
||||
},
|
||||
"menu": {
|
||||
"system": "System",
|
||||
"systemMetrics": "System metrics",
|
||||
"systemMetrics": "Health and Metrics",
|
||||
"configuration": "Configuration",
|
||||
"systemLogs": "System logs",
|
||||
"systemLogs": "Logs",
|
||||
"profiles": "Profiles",
|
||||
"settings": "Settings",
|
||||
"configurationEditor": "Configuration Editor",
|
||||
|
||||
@@ -9,10 +9,31 @@
|
||||
"go2rtc": "Go2RTC Logs - Frigate",
|
||||
"nginx": "Nginx Logs - Frigate",
|
||||
"websocket": "Messages Logs - Frigate"
|
||||
}
|
||||
},
|
||||
"health": "Health - Frigate"
|
||||
},
|
||||
"title": "System",
|
||||
"metrics": "System metrics",
|
||||
"health": {
|
||||
"title": "Health",
|
||||
"notices": {
|
||||
"title": "Notices",
|
||||
"empty": "No notices",
|
||||
"dismiss": "Dismiss",
|
||||
"openSettings": "Open settings",
|
||||
"openLink": "Open link",
|
||||
"since": "Since {{time}}",
|
||||
"sinceWithCount": "Since {{time}} · {{times}} times since {{firstSeen}}",
|
||||
"firstSeen": "First seen {{time}} · {{times}} times",
|
||||
"kinds": {
|
||||
"ffmpeg_crash_loop": "ffmpeg has crashed {{restarts}} times in the last hour",
|
||||
"detector_stuck": "Detector {{detector}} was restarted after it stopped responding",
|
||||
"model_download_failed": "Downloading {{file}} failed: {{error}}",
|
||||
"retention_unmet": "Recordings were deleted before their retention period to free space ({{cleared_mb}} of {{needed_mb}} MB needed)",
|
||||
"update_available": "Frigate {{version}} is available"
|
||||
}
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
"websocket": {
|
||||
"label": "Messages",
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaTriangleExclamation } from "react-icons/fa6";
|
||||
import {
|
||||
LuExternalLink,
|
||||
LuInfo,
|
||||
LuSlidersHorizontal,
|
||||
LuX,
|
||||
} from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import type { HealthProblem } from "@/types/health";
|
||||
|
||||
type HealthProblemRowProps = {
|
||||
problem: HealthProblem;
|
||||
};
|
||||
|
||||
export default function HealthProblemRow({ problem }: HealthProblemRowProps) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-2 border-b border-border px-1 py-2 text-sm last:border-b-0"
|
||||
data-testid={`health-problem-${problem.id}`}
|
||||
data-severity={problem.severity}
|
||||
>
|
||||
<div className="mt-0.5 flex shrink-0">
|
||||
{problem.severity === "error" && <LuX className="size-4 text-danger" />}
|
||||
{problem.severity === "warning" && (
|
||||
<FaTriangleExclamation className="size-4 text-yellow-500" />
|
||||
)}
|
||||
{problem.severity === "info" && (
|
||||
<LuInfo className="size-4 text-selected" />
|
||||
)}
|
||||
</div>
|
||||
{problem.scope && (
|
||||
<span className="rounded-md bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground smart-capitalize">
|
||||
{problem.scopeIsCamera ? (
|
||||
<CameraNameLabel camera={problem.scope} />
|
||||
) : (
|
||||
problem.scope
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div>{problem.text}</div>
|
||||
{problem.meta && (
|
||||
<div className="mt-0.5 text-xs text-muted-foreground">
|
||||
{problem.meta}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 text-muted-foreground">
|
||||
{problem.onDismiss && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2"
|
||||
onClick={problem.onDismiss}
|
||||
aria-label={t("health.notices.dismiss")}
|
||||
>
|
||||
{t("health.notices.dismiss")}
|
||||
</Button>
|
||||
)}
|
||||
{problem.link && (
|
||||
<Link
|
||||
to={problem.link}
|
||||
aria-label={t("health.notices.openSettings")}
|
||||
className="hover:text-primary"
|
||||
>
|
||||
<LuSlidersHorizontal className="size-4" />
|
||||
</Link>
|
||||
)}
|
||||
{problem.externalLink && (
|
||||
<a
|
||||
href={problem.externalLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={t("health.notices.openLink")}
|
||||
className="hover:text-primary"
|
||||
>
|
||||
<LuExternalLink className="size-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import useSWR from "swr";
|
||||
import HealthProblemRow from "@/components/health/HealthProblemRow";
|
||||
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 { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { releaseUrl } from "@/utils/versionUtil";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { HealthProblem } from "@/types/health";
|
||||
import type { Notice, NoticeKind, NoticeStats } from "@/types/notice";
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
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}`,
|
||||
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"]);
|
||||
const { notices, statsByKind, dismiss } = useNotices();
|
||||
const problems = useNoticeProblems(notices, statsByKind, dismiss);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<div className="text-md font-medium text-primary-variant">
|
||||
{t("health.notices.title")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
|
||||
{notices === undefined ? (
|
||||
<Skeleton className="h-24 w-full" />
|
||||
) : problems.length === 0 ? (
|
||||
<div className="flex items-center gap-2 px-1 py-2 text-sm">
|
||||
<FaCircleCheck className="size-4 text-success" />
|
||||
<span>{t("health.notices.empty")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{problems.map((problem) => (
|
||||
<HealthProblemRow key={problem.id} problem={problem} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -326,7 +326,7 @@ export default function GeneralSettings({
|
||||
<DropdownMenuLabel>{t("menu.system")}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup className={isDesktop ? "" : "flex flex-col"}>
|
||||
<Link to="/system#general">
|
||||
<Link to="/system">
|
||||
<MenuItem
|
||||
className={
|
||||
isDesktop
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import axios from "axios";
|
||||
import useSWR from "swr";
|
||||
import { useWs } from "@/api/ws";
|
||||
import type { Notice, NoticeStats } 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.
|
||||
*/
|
||||
export function useNotices() {
|
||||
const { data: initial, mutate } = useSWR<Notice[]>("notices", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const { data: stats, mutate: mutateStats } = useSWR<NoticeStats[]>(
|
||||
"notices/stats",
|
||||
{ revalidateOnFocus: false },
|
||||
);
|
||||
const {
|
||||
value: { payload },
|
||||
} = useWs("notices", "");
|
||||
|
||||
const live = useMemo(
|
||||
() =>
|
||||
payload && typeof payload === "string"
|
||||
? (JSON.parse(payload) as Notice[])
|
||||
: undefined,
|
||||
[payload],
|
||||
);
|
||||
|
||||
// once a websocket frame has arrived it is the source of truth; a dismiss
|
||||
// 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]);
|
||||
|
||||
const dismiss = useCallback(
|
||||
async (id: string) => {
|
||||
await axios.post(`notices/${id}/dismiss`);
|
||||
mutate();
|
||||
mutateStats();
|
||||
},
|
||||
[mutate, mutateStats],
|
||||
);
|
||||
|
||||
return { notices, statsByKind, dismiss };
|
||||
}
|
||||
@@ -6,7 +6,12 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import GeneralMetrics from "@/views/system/GeneralMetrics";
|
||||
import StorageMetrics from "@/views/system/StorageMetrics";
|
||||
import { LuActivity, LuHardDrive, LuSearchCode } from "react-icons/lu";
|
||||
import {
|
||||
LuActivity,
|
||||
LuHardDrive,
|
||||
LuHeartPulse,
|
||||
LuSearchCode,
|
||||
} from "react-icons/lu";
|
||||
import { FaVideo } from "react-icons/fa";
|
||||
import Logo from "@/components/Logo";
|
||||
import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||
@@ -15,9 +20,16 @@ import { useHashState } from "@/hooks/use-overlay-state";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import EnrichmentMetrics from "@/views/system/EnrichmentMetrics";
|
||||
import HealthMetrics from "@/views/system/HealthMetrics";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const allMetrics = ["general", "enrichments", "storage", "cameras"] as const;
|
||||
const allMetrics = [
|
||||
"health",
|
||||
"general",
|
||||
"enrichments",
|
||||
"storage",
|
||||
"cameras",
|
||||
] as const;
|
||||
type SystemMetric = (typeof allMetrics)[number];
|
||||
|
||||
function System() {
|
||||
@@ -44,8 +56,9 @@ function System() {
|
||||
// stats page
|
||||
|
||||
const [page, setPage] = useHashState<SystemMetric>();
|
||||
// useHashState yields "" with no hash, which ?? would not catch
|
||||
const [pageToggle, setPageToggle] = useOptimisticState(
|
||||
page ?? "general",
|
||||
page || "health",
|
||||
setPage,
|
||||
100,
|
||||
);
|
||||
@@ -56,9 +69,7 @@ function System() {
|
||||
// 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.
|
||||
const visitedTabsRef = useRef(new Set<string>());
|
||||
if (page) {
|
||||
visitedTabsRef.current.add(page);
|
||||
}
|
||||
visitedTabsRef.current.add(pageToggle);
|
||||
const visitedTabs = visitedTabsRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -98,6 +109,7 @@ function System() {
|
||||
value={item}
|
||||
aria-label={`Select ${item}`}
|
||||
>
|
||||
{item == "health" && <LuHeartPulse className="size-4" />}
|
||||
{item == "general" && <LuActivity className="size-4" />}
|
||||
{item == "enrichments" && <LuSearchCode className="size-4" />}
|
||||
{item == "storage" && <LuHardDrive className="size-4" />}
|
||||
@@ -126,6 +138,11 @@ function System() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{visitedTabs.has("health") && (
|
||||
<div className={pageToggle == "health" ? "contents" : "hidden"}>
|
||||
<HealthMetrics />
|
||||
</div>
|
||||
)}
|
||||
{visitedTabs.has("general") && (
|
||||
<div className={page == "general" ? "contents" : "hidden"}>
|
||||
<GeneralMetrics
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
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.
|
||||
*/
|
||||
export type HealthProblem = {
|
||||
id: string;
|
||||
severity: HealthSeverity;
|
||||
/** camera name or other scope shown as a chip before the text */
|
||||
scope?: string;
|
||||
/** whether scope is a camera name, so the chip can use the friendly name */
|
||||
scopeIsCamera?: boolean;
|
||||
text: string;
|
||||
/** muted line under the text, for example when it was first seen */
|
||||
meta?: ReactNode;
|
||||
/** in-app route for a settings icon link */
|
||||
link?: string;
|
||||
/** docs path for an external link, rendered in PR 2 */
|
||||
docLink?: string;
|
||||
/** absolute URL rendered as an external link (the update notice's release page) */
|
||||
externalLink?: string;
|
||||
onDismiss?: () => void;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
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 Notice = {
|
||||
id: string;
|
||||
kind: NoticeKind;
|
||||
mode: NoticeMode;
|
||||
severity: NoticeSeverity;
|
||||
category: NoticeCategory;
|
||||
scope: string | null;
|
||||
params: Record<string, string | number | boolean>;
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
/** 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}`;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import NoticesPane from "@/components/health/NoticesPane";
|
||||
|
||||
export default function HealthMetrics() {
|
||||
return (
|
||||
<div className="scrollbar-container mt-4 flex size-full flex-col gap-4 overflow-y-auto">
|
||||
<NoticesPane />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user