mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 02:08:57 +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
@@ -16,6 +16,7 @@ function cameraStats(_name: string) {
|
||||
pid: 102,
|
||||
process_fps: 5.0,
|
||||
skipped_fps: 0,
|
||||
skipped_pct: 0,
|
||||
connection_quality: "excellent" as const,
|
||||
expected_fps: 5,
|
||||
reconnects_last_hour: 0,
|
||||
@@ -75,6 +76,7 @@ export const BASE_STATS = {
|
||||
uptime: 86400,
|
||||
latest_version: "0.15.0",
|
||||
version: "0.15.0-test",
|
||||
retention_unmet: false,
|
||||
},
|
||||
camera_fps: 15.0,
|
||||
process_fps: 15.0,
|
||||
|
||||
@@ -49,7 +49,7 @@ export interface ApiMockOverrides {
|
||||
};
|
||||
users?: { username: string; role: string }[];
|
||||
notices?: unknown[];
|
||||
noticeStats?: unknown[];
|
||||
dismissedChecks?: unknown[];
|
||||
/** camera name to the ffprobe entries returned for `paths=camera:<name>` */
|
||||
ffprobe?: Record<string, unknown[]>;
|
||||
}
|
||||
@@ -234,13 +234,12 @@ export class ApiMocker {
|
||||
return route.fulfill({ json: entries });
|
||||
});
|
||||
|
||||
// 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.
|
||||
// Notices
|
||||
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 ?? [] }),
|
||||
await this.page.route("**/api/notices/dismissed_checks", (route) =>
|
||||
route.fulfill({ json: overrides?.dismissedChecks ?? [] }),
|
||||
);
|
||||
|
||||
// Users. GET lists them; POST/PUT (create, password) just succeed, so
|
||||
|
||||
@@ -5,34 +5,35 @@
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||
|
||||
const NOW = Math.floor(Date.now() / 1000);
|
||||
|
||||
// the fixture detector runs at 75.5 ms, above the live warning threshold
|
||||
const QUIET_STATS = { detectors: { cpu: { inference_speed: 10 } } };
|
||||
|
||||
const STATE_NOTICE = {
|
||||
id: "ffmpeg_crash_loop:front_door",
|
||||
kind: "ffmpeg_crash_loop",
|
||||
mode: "state",
|
||||
const ERROR_NOTICE = {
|
||||
id: "model_download_failed:yolo/model.onnx",
|
||||
kind: "model_download_failed",
|
||||
severity: "error",
|
||||
category: "camera",
|
||||
scope: "front_door",
|
||||
params: { restarts: 6 },
|
||||
category: "model",
|
||||
scope: "yolo/model.onnx",
|
||||
params: { file: "model.onnx", model: "yolo", error: "timeout" },
|
||||
link: null,
|
||||
first_seen: NOW - 600,
|
||||
last_seen: NOW,
|
||||
count: 1,
|
||||
count: 2,
|
||||
dismissed_at: null,
|
||||
};
|
||||
|
||||
const EVENT_NOTICE = {
|
||||
id: "detector_stuck",
|
||||
kind: "detector_stuck",
|
||||
mode: "event",
|
||||
severity: "warning",
|
||||
category: "detector",
|
||||
scope: null,
|
||||
params: { detector: "ov" },
|
||||
link: "/system#general",
|
||||
first_seen: NOW - 7200,
|
||||
last_seen: NOW - 60,
|
||||
count: 3,
|
||||
@@ -45,16 +46,7 @@ test.describe("System — Health tab @medium", () => {
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [STATE_NOTICE, EVENT_NOTICE],
|
||||
noticeStats: [
|
||||
{
|
||||
kind: "ffmpeg_crash_loop",
|
||||
occurrences: 14,
|
||||
dismissals: 0,
|
||||
first_seen: NOW - 86400 * 20,
|
||||
last_seen: NOW,
|
||||
},
|
||||
],
|
||||
notices: [ERROR_NOTICE, EVENT_NOTICE],
|
||||
});
|
||||
await frigateApp.goto("/system");
|
||||
|
||||
@@ -67,11 +59,13 @@ test.describe("System — Health tab @medium", () => {
|
||||
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");
|
||||
await expect(rows.nth(0)).toContainText(
|
||||
"Downloading model.onnx for yolo failed: timeout",
|
||||
);
|
||||
await expect(rows.nth(0)).toContainText("2 times");
|
||||
await expect(
|
||||
rows.nth(0).getByRole("button", { name: "Dismiss" }),
|
||||
).toHaveCount(0);
|
||||
).toBeVisible();
|
||||
await expect(rows.nth(1)).toContainText("Detector ov was restarted");
|
||||
await expect(rows.nth(1)).toContainText("3 times");
|
||||
await expect(
|
||||
@@ -104,7 +98,10 @@ test.describe("System — Health tab @medium", () => {
|
||||
req.url().includes("/api/notices/detector_stuck/dismiss") &&
|
||||
req.method() === "POST",
|
||||
);
|
||||
await frigateApp.page.getByRole("button", { name: "Dismiss" }).click();
|
||||
await frigateApp.page
|
||||
.getByTestId("health-problem-notice:detector_stuck")
|
||||
.getByRole("button", { name: "Dismiss" })
|
||||
.click();
|
||||
await request;
|
||||
|
||||
await expect(
|
||||
@@ -133,13 +130,13 @@ test.describe("System — Health tab @medium", () => {
|
||||
stats: QUIET_STATS,
|
||||
notices: [
|
||||
{
|
||||
id: "update_available",
|
||||
id: "update_available:0.19.0",
|
||||
kind: "update_available",
|
||||
mode: "state",
|
||||
severity: "info",
|
||||
category: "system",
|
||||
scope: null,
|
||||
scope: "0.19.0",
|
||||
params: { version: "0.19.0" },
|
||||
link: "https://github.com/blakeblackshear/frigate/releases/tag/v0.19.0",
|
||||
first_seen: NOW - 3600,
|
||||
last_seen: NOW,
|
||||
count: 1,
|
||||
@@ -150,7 +147,7 @@ test.describe("System — Health tab @medium", () => {
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"health-problem-notice:update_available",
|
||||
"health-problem-notice:update_available:0.19.0",
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(row).toHaveAttribute("data-severity", "info");
|
||||
@@ -159,7 +156,229 @@ test.describe("System — Health tab @medium", () => {
|
||||
"href",
|
||||
"https://github.com/blakeblackshear/frigate/releases/tag/v0.19.0",
|
||||
);
|
||||
await expect(row.getByRole("button", { name: "Dismiss" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("the filter shows dismissed notices without a Dismiss button", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [EVENT_NOTICE],
|
||||
});
|
||||
await frigateApp.page.route(
|
||||
(url) =>
|
||||
url.pathname.endsWith("/api/notices") &&
|
||||
url.searchParams.get("include_dismissed") === "true",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: [EVENT_NOTICE, { ...ERROR_NOTICE, dismissed_at: NOW - 120 }],
|
||||
}),
|
||||
);
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
|
||||
const showDismissed = frigateApp.page.getByRole("switch", {
|
||||
name: "Show dismissed",
|
||||
});
|
||||
await expect(showDismissed).toHaveAttribute("aria-checked", "false");
|
||||
await showDismissed.click();
|
||||
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"health-problem-notice:model_download_failed:yolo/model.onnx",
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(row).toContainText("Dismissed");
|
||||
await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
|
||||
|
||||
await showDismissed.click();
|
||||
await expect(row).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("clear dismissed deletes the dismissed rows after confirming", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [EVENT_NOTICE],
|
||||
});
|
||||
|
||||
// the history loses its dismissed row once the DELETE lands
|
||||
let cleared = false;
|
||||
await frigateApp.page.route(
|
||||
(url) =>
|
||||
url.pathname.endsWith("/api/notices") &&
|
||||
url.searchParams.get("include_dismissed") === "true",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: cleared
|
||||
? [EVENT_NOTICE]
|
||||
: [EVENT_NOTICE, { ...ERROR_NOTICE, dismissed_at: NOW - 120 }],
|
||||
}),
|
||||
);
|
||||
await frigateApp.page.route("**/api/notices/dismissed", (route) => {
|
||||
cleared = true;
|
||||
return route.fulfill({ json: { success: true } });
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
|
||||
await frigateApp.page
|
||||
.getByRole("switch", { name: "Show dismissed" })
|
||||
.click();
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"health-problem-notice:model_download_failed:yolo/model.onnx",
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: "Clear dismissed" })
|
||||
.click();
|
||||
const request = frigateApp.page.waitForRequest(
|
||||
(req) =>
|
||||
req.url().endsWith("/api/notices/dismissed") &&
|
||||
req.method() === "DELETE",
|
||||
);
|
||||
await frigateApp.page
|
||||
.getByRole("alertdialog")
|
||||
.getByRole("button", { name: "Clear dismissed" })
|
||||
.click();
|
||||
await request;
|
||||
|
||||
await expect(row).toHaveCount(0);
|
||||
await expect(
|
||||
frigateApp.page.getByText("No dismissed notices"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("severity switches hide notices of that severity", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [ERROR_NOTICE, EVENT_NOTICE],
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const rows = frigateApp.page.locator("[data-testid^='health-problem-']");
|
||||
await expect(rows).toHaveCount(2, { timeout: 15_000 });
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
|
||||
await frigateApp.page.getByRole("switch", { name: "Warning" }).click();
|
||||
await expect(rows).toHaveCount(1);
|
||||
await expect(rows.first()).toHaveAttribute("data-severity", "error");
|
||||
|
||||
await frigateApp.page.getByRole("switch", { name: "Error" }).click();
|
||||
await expect(rows).toHaveCount(0);
|
||||
await expect(
|
||||
frigateApp.page.getByText("No notices match the filter"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("failed login bursts are per user and show their attempt count", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const burst = (user: string, start: number, count: number) => ({
|
||||
id: `failed_login:${user}:${start}`,
|
||||
kind: "failed_login",
|
||||
severity: "warning",
|
||||
category: "system",
|
||||
scope: `${user}:${start}`,
|
||||
params: { user },
|
||||
link: "/logs",
|
||||
first_seen: start,
|
||||
last_seen: start + 60,
|
||||
count,
|
||||
dismissed_at: null,
|
||||
});
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [burst("admin", NOW - 60, 7), burst("ghost", NOW - 3600, 1)],
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const attack = frigateApp.page.getByTestId(
|
||||
`health-problem-notice:failed_login:admin:${NOW - 60}`,
|
||||
);
|
||||
await expect(attack).toBeVisible({ timeout: 15_000 });
|
||||
await expect(attack).toContainText("Failed login attempts for admin");
|
||||
await expect(attack).toContainText("7 times");
|
||||
await expect(attack).not.toContainText(String(NOW - 60));
|
||||
await expect(
|
||||
attack.getByRole("link", { name: "Open settings" }),
|
||||
).toHaveAttribute("href", "/logs");
|
||||
await expect(
|
||||
frigateApp.page.getByTestId(
|
||||
`health-problem-notice:failed_login:ghost:${NOW - 3600}`,
|
||||
),
|
||||
).toContainText("Failed login attempt for ghost");
|
||||
});
|
||||
|
||||
test("skipped detections notice links to camera stats", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [
|
||||
{
|
||||
id: "skipped_detections:front_door",
|
||||
kind: "skipped_detections",
|
||||
severity: "warning",
|
||||
category: "camera",
|
||||
scope: "front_door",
|
||||
params: { pct: 12.5 },
|
||||
link: "/system#cameras",
|
||||
first_seen: NOW - 600,
|
||||
last_seen: NOW - 600,
|
||||
count: 1,
|
||||
dismissed_at: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"health-problem-notice:skipped_detections:front_door",
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(row).toContainText("skipped 12.5% of frames");
|
||||
await expect(
|
||||
row.getByRole("link", { name: "Open settings" }),
|
||||
).toHaveAttribute("href", "/system#cameras");
|
||||
});
|
||||
|
||||
test("shm notice links to storage metrics", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [
|
||||
{
|
||||
id: "shm_too_low",
|
||||
kind: "shm_too_low",
|
||||
severity: "warning",
|
||||
category: "system",
|
||||
scope: null,
|
||||
params: { total: 64, min: 180 },
|
||||
link: "/system#storage",
|
||||
first_seen: NOW - 600,
|
||||
last_seen: NOW - 600,
|
||||
count: 1,
|
||||
dismissed_at: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"health-problem-notice:shm_too_low",
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(row).toContainText(
|
||||
"/dev/shm allocation (64 MB) should be increased to at least 180 MB",
|
||||
);
|
||||
await expect(
|
||||
row.getByRole("link", { name: "Open settings" }),
|
||||
).toHaveAttribute("href", "/system#storage");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,13 +388,13 @@ test.describe("System — Health tab mobile @medium @mobile", () => {
|
||||
test("notices render at mobile viewport", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [STATE_NOTICE],
|
||||
notices: [ERROR_NOTICE],
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByTestId(
|
||||
"health-problem-notice:ffmpeg_crash_loop:front_door",
|
||||
"health-problem-notice:model_download_failed:yolo/model.onnx",
|
||||
),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
});
|
||||
@@ -298,14 +517,17 @@ test.describe("System — Health hardware pane @medium", () => {
|
||||
).toHaveAttribute("data-state", "warning");
|
||||
});
|
||||
|
||||
test("face recognition row reflects the runtime device", async ({
|
||||
test("slow face recognition on the CPU warns when an accelerator exists", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: { face_recognition: { enabled: true } },
|
||||
stats: {
|
||||
...QUIET_STATS,
|
||||
embeddings: { devices: { face_recognition: "CPU" } },
|
||||
embeddings: {
|
||||
face_recognition_speed: 800,
|
||||
devices: { face_recognition: "CPU" },
|
||||
},
|
||||
},
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
@@ -321,6 +543,27 @@ test.describe("System — Health hardware pane @medium", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("fast face recognition on the CPU is ok", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: { face_recognition: { enabled: true } },
|
||||
stats: {
|
||||
...QUIET_STATS,
|
||||
embeddings: {
|
||||
face_recognition_speed: 40,
|
||||
devices: { face_recognition: "CPU" },
|
||||
},
|
||||
},
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"hardware-row-enrichment:face_recognition",
|
||||
);
|
||||
await expect(row).toHaveAttribute("data-state", "ok", { timeout: 15_000 });
|
||||
await expect(row).toContainText("CPU");
|
||||
await expect(row).not.toContainText("although an accelerator");
|
||||
});
|
||||
|
||||
test("explicit GPU that loaded on CUDA is ok despite the probe", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
@@ -415,36 +658,24 @@ test.describe("System — Health hardware pane @medium", () => {
|
||||
});
|
||||
|
||||
test.describe("System — Health notices sources @medium", () => {
|
||||
test("live offline camera and config checks render with links", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test("config checks render with links", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
cameras: {
|
||||
garage: { detect: { width: 2560, height: 1440, fps: 10 } },
|
||||
},
|
||||
},
|
||||
stats: { ...QUIET_STATS, cameras: { front_door: { camera_fps: 0 } } },
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const rows = frigateApp.page.locator("[data-testid^='health-problem-']");
|
||||
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(rows.filter({ hasText: "Front Door is offline" })).toHaveCount(
|
||||
1,
|
||||
);
|
||||
await expect(
|
||||
frigateApp.page.getByText(
|
||||
"This detect resolution is higher than recommended",
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
rows
|
||||
.filter({ hasText: "Front Door is offline" })
|
||||
.getByRole("link", { name: "Open settings" }),
|
||||
).toHaveAttribute("href", "/logs");
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
const fpsRow = frigateApp.page.getByTestId(
|
||||
"health-problem-config:detect:fps-greater-than-five:garage",
|
||||
"health-problem-config:detect:fps-greater-than-five:camera.garage",
|
||||
);
|
||||
await expect(fpsRow).toHaveAttribute("data-severity", "info");
|
||||
await expect(
|
||||
@@ -452,6 +683,28 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
).toHaveAttribute("href", "/settings?page=cameraDetect&camera=garage");
|
||||
});
|
||||
|
||||
test("status bar problems stay out of the list", async ({ frigateApp }) => {
|
||||
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
|
||||
await frigateApp.installDefaults({
|
||||
stats: {
|
||||
service: { retention_unmet: true },
|
||||
cameras: { front_door: { camera_fps: 0 } },
|
||||
},
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
// the status bar shows a problem, so stats have loaded
|
||||
await expect(
|
||||
frigateApp.page.getByText("Front Door is offline"),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
frigateApp.page.locator("[data-testid^='health-problem-']"),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
frigateApp.page.getByText("Your Frigate installation is healthy"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// the fixture's cameras all have a record role with recording off, so
|
||||
// dropping the role isolates the gate on record.enabled
|
||||
const NO_RECORD_ROLE = {
|
||||
@@ -553,43 +806,11 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
await expect(rows).toHaveCount(2);
|
||||
await expect(
|
||||
frigateApp.page.getByTestId(
|
||||
"health-problem-config:detect:detect-resolution-high:garage",
|
||||
"health-problem-config:detect:detect-resolution-high:camera.garage",
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("registry rows sort ahead of live rows and keep Dismiss", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
notices: [
|
||||
{
|
||||
id: "detector_stuck:cpu",
|
||||
kind: "detector_stuck",
|
||||
mode: "event",
|
||||
severity: "warning",
|
||||
category: "detector",
|
||||
scope: "cpu",
|
||||
params: { detector: "cpu" },
|
||||
first_seen: NOW - 60,
|
||||
last_seen: NOW - 60,
|
||||
count: 1,
|
||||
dismissed_at: null,
|
||||
},
|
||||
],
|
||||
// the default fixture's slow cpu detector is the live warning here
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const rows = frigateApp.page.locator("[data-severity='warning']");
|
||||
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(rows.nth(0)).toContainText("Detector cpu was restarted");
|
||||
await expect(
|
||||
rows.nth(0).getByRole("button", { name: "Dismiss" }),
|
||||
).toBeVisible();
|
||||
await expect(rows.nth(1)).toContainText("Cpu is slow");
|
||||
});
|
||||
|
||||
test("empty state when stats, config, and registry are clean", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
@@ -797,7 +1018,7 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"health-problem-config:lpr:global-disabled:garage",
|
||||
"health-problem-config:lpr:global-disabled:camera.garage",
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
@@ -824,4 +1045,120 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
.click();
|
||||
await expect(frigateApp.page).toHaveURL(/\/system#health/);
|
||||
});
|
||||
|
||||
test("status bar counts undismissed notices next to the health text", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [EVENT_NOTICE],
|
||||
});
|
||||
await frigateApp.goto("/");
|
||||
|
||||
const notices = frigateApp.page.getByRole("link", {
|
||||
name: "1 system notice",
|
||||
});
|
||||
await expect(notices).toBeVisible({ timeout: 15_000 });
|
||||
await expect(notices).toHaveAttribute("href", "/system#health");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("link", { name: "System is healthy" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("status bar shows viewers no problems or health text", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
|
||||
await frigateApp.installDefaults({
|
||||
profile: viewerProfile(),
|
||||
stats: {
|
||||
cpu_usages: { "frigate.full_system": { cpu: "12.0" } },
|
||||
cameras: { front_door: { camera_fps: 0 } },
|
||||
},
|
||||
});
|
||||
await frigateApp.goto("/");
|
||||
|
||||
// the CPU reading comes from the same stats as the offline problem
|
||||
await expect(frigateApp.page.getByText("CPU 12%")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(
|
||||
frigateApp.page.getByText("Front Door is offline"),
|
||||
).toHaveCount(0);
|
||||
await expect(frigateApp.page.getByText("System is healthy")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a config row can be dismissed", async ({ frigateApp }) => {
|
||||
const id = "config:detect:fps-greater-than-five:camera.garage";
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
cameras: {
|
||||
garage: { detect: { width: 2560, height: 1440, fps: 10 } },
|
||||
},
|
||||
},
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
|
||||
// the list gains the dismissal after the POST so the refetch hides the row
|
||||
let dismissed = false;
|
||||
await frigateApp.page.route(`**/api/notices/${id}/dismiss`, (route) => {
|
||||
dismissed = true;
|
||||
return route.fulfill({ json: { success: true } });
|
||||
});
|
||||
await frigateApp.page.route("**/api/notices/dismissed_checks", (route) =>
|
||||
route.fulfill({ json: dismissed ? [{ id, dismissed_at: NOW }] : [] }),
|
||||
);
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const row = frigateApp.page.getByTestId(`health-problem-${id}`);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
const request = frigateApp.page.waitForRequest(
|
||||
(req) =>
|
||||
req.url().includes(`/api/notices/${id}/dismiss`) &&
|
||||
req.method() === "POST",
|
||||
);
|
||||
await row.getByRole("button", { name: "Dismiss" }).click();
|
||||
await request;
|
||||
await expect(row).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("dismissed config rows move to the dismissed list", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const id = "config:detect:fps-greater-than-five:camera.garage";
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
cameras: {
|
||||
garage: { detect: { width: 2560, height: 1440, fps: 10 } },
|
||||
},
|
||||
},
|
||||
stats: QUIET_STATS,
|
||||
dismissedChecks: [{ id, dismissed_at: NOW - 120 }],
|
||||
});
|
||||
await frigateApp.page.route(
|
||||
(url) =>
|
||||
url.pathname.endsWith("/api/notices") &&
|
||||
url.searchParams.get("include_dismissed") === "true",
|
||||
(route) => route.fulfill({ json: [] }),
|
||||
);
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await expect(
|
||||
frigateApp.page.getByText(
|
||||
"This detect resolution is higher than recommended",
|
||||
),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
const row = frigateApp.page.getByTestId(`health-problem-${id}`);
|
||||
await expect(row).toHaveCount(0);
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
|
||||
await frigateApp.page
|
||||
.getByRole("switch", { name: "Show dismissed" })
|
||||
.click();
|
||||
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row).toContainText("Dismissed");
|
||||
await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,21 +22,35 @@
|
||||
"dismiss": "Dismiss",
|
||||
"openSettings": "Open settings",
|
||||
"openLink": "Open link",
|
||||
"since": "Since {{time}}",
|
||||
"sinceWithCount": "Since {{time}} · {{times}} times since {{firstSeen}}",
|
||||
"firstSeen": "First seen {{time}} · {{times}} times",
|
||||
"noMatches": "No notices match the filter",
|
||||
"dismissedTitle": "Dismissed",
|
||||
"noneDismissed": "No dismissed notices",
|
||||
"clearDismissed": "Clear dismissed",
|
||||
"clearDismissedTitle": "Clear dismissed notices?",
|
||||
"clearDismissedDesc": "Every dismissed notice is deleted. Config and stream checks show again right away, and other notices return the next time they happen.",
|
||||
"firstSeen_one": "First seen {{time}}",
|
||||
"firstSeen_other": "First seen {{time}} · {{count}} times",
|
||||
"dismissedAt": "Dismissed {{time}}",
|
||||
"filter": {
|
||||
"showDismissed": "Show dismissed",
|
||||
"severity": "Severity",
|
||||
"error": "Error",
|
||||
"warning": "Warning",
|
||||
"info": "Info"
|
||||
},
|
||||
"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)",
|
||||
"model_download_failed": "Downloading {{file}} for {{model}} failed: {{error}}",
|
||||
"skipped_detections": "Detection could not keep up and skipped {{pct}}% of frames for over a minute",
|
||||
"shm_too_low": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB",
|
||||
"failed_login_one": "Failed login attempt for {{user}}",
|
||||
"failed_login_other": "Failed login attempts for {{user}}",
|
||||
"update_available": "Frigate {{version}} is available"
|
||||
},
|
||||
"streamPrefix": "Stream {{index}}: {{message}}",
|
||||
"streamPrefixRestream": "Stream {{index}} (via go2rtc): {{message}}",
|
||||
"streamProbeFailed": "Stream {{index}} could not be probed: {{error}}",
|
||||
"cameraProbeFailed": "Streams could not be probed: {{error}}",
|
||||
"startupWindow": "Frigate started less than two minutes ago, live checks begin after startup"
|
||||
"cameraProbeFailed": "Streams could not be probed: {{error}}"
|
||||
},
|
||||
"hardware": {
|
||||
"title": "Hardware",
|
||||
@@ -311,14 +325,16 @@
|
||||
"lastRefreshed": "Last refreshed: ",
|
||||
"stats": {
|
||||
"ffmpegHighCpuUsage": "{{camera}} has high FFmpeg CPU usage ({{ffmpegAvg}}%)",
|
||||
"cameraSkippedDetections": "{{camera}} has skipped detections ({{fps}}).",
|
||||
"cameraSkippedDetections": "{{camera}} is skipping detection on {{pct}}% of frames",
|
||||
"detectHighCpuUsage": "{{camera}} has high detect CPU usage ({{detectAvg}}%)",
|
||||
"healthy": "System is healthy",
|
||||
"systemNotices_one": "{{count}} system notice",
|
||||
"systemNotices_other": "{{count}} system notices",
|
||||
"reindexingEmbeddings": "Reindexing embeddings ({{processed}}% complete)",
|
||||
"cameraIsOffline": "{{camera}} is offline",
|
||||
"detectIsSlow": "{{detect}} is slow ({{speed}} ms)",
|
||||
"detectIsVerySlow": "{{detect}} is very slow ({{speed}} ms)",
|
||||
"shmTooLow": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB.",
|
||||
"retentionUnmet": "Recordings are being deleted before their retention period ends to free space",
|
||||
"debugReplayActive": "Debug replay session is active"
|
||||
},
|
||||
"enrichments": {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
@@ -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,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[] {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user