Improve System Health pane (#24188)

* build out system health pane

* tweaks

* fixes

* fix notice link so it opens the correct camera

* tweak language
This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent 6d33b31bc6
commit 70ce193e09
57 changed files with 3965 additions and 464 deletions
+2 -1
View File
@@ -22,6 +22,7 @@ import {
type ApiMockOverrides,
} from "../helpers/api-mocker";
import { WsMocker } from "../helpers/ws-mocker";
import { statsFactory } from "./mock-data/stats";
import { installErrorCollector, type ErrorCollector } from "./error-collector";
import { GLOBAL_ALLOWLIST } from "./error-allowlist";
@@ -53,7 +54,7 @@ export class FrigateApp {
return route.fallback();
});
await this.ws.install(this.page);
await this.ws.install(this.page, statsFactory(overrides?.stats));
await this.api.install(overrides);
// media goes last so its per-event routes win over the broader
// `**/api/events**` list route, which otherwise answers thumbnail and
+14
View File
@@ -41,6 +41,13 @@ export const BASE_STATS = {
},
gpu_usages: {},
npu_usages: {},
embeddings: {
image_embedding_speed: 0,
face_embedding_speed: 0,
plate_recognition_speed: 0,
text_embedding_speed: 0,
devices: {} as Record<string, string>,
},
processes: {},
service: {
last_updated: Date.now() / 1000,
@@ -57,6 +64,13 @@ export const BASE_STATS = {
used: 500000000,
mount_type: "tmpfs",
},
"/dev/shm": {
free: 98,
total: 128,
used: 30,
mount_type: "tmpfs",
min_shm: 64,
},
},
uptime: 86400,
latest_version: "0.15.0",
+31
View File
@@ -50,8 +50,29 @@ export interface ApiMockOverrides {
users?: { username: string; role: string }[];
notices?: unknown[];
noticeStats?: unknown[];
/** camera name to the ffprobe entries returned for `paths=camera:<name>` */
ffprobe?: Record<string, unknown[]>;
}
export const FFPROBE_OK = [
{
return_code: 0,
stderr: "",
stdout: {
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 1920,
height: 1080,
avg_frame_rate: "15/1",
},
{ codec_type: "audio", codec_name: "aac" },
],
},
},
];
export class ApiMocker {
private page: Page;
@@ -203,6 +224,16 @@ export class ApiMocker {
}),
);
// ffprobe. The Health tab's stream checks probe `camera:<name>`; the
// wizard probes raw URLs. Both get a healthy h264 + aac answer by default.
await this.page.route("**/api/ffprobe**", (route) => {
const url = new URL(route.request().url());
const paths = url.searchParams.get("paths") ?? "";
const camera = paths.startsWith("camera:") ? paths.slice(7) : undefined;
const entries = (camera && overrides?.ffprobe?.[camera]) || FFPROBE_OK;
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.
await this.page.route("**/api/notices", (route) =>
+35 -29
View File
@@ -12,12 +12,16 @@ import { cameraActivityPayload } from "../fixtures/mock-data/camera-activity";
export class WsMocker {
private mockWs: WebSocketRoute | null = null;
private cameras: string[];
// the live stats payload wins over the REST one in useAutoFrigateStats, so
// both come from the same factory or a test's `stats` override is ignored
private stats: unknown;
constructor(cameras: string[] = ["front_door", "backyard", "garage"]) {
this.cameras = cameras;
}
async install(page: Page) {
async install(page: Page, stats?: unknown) {
this.stats = stats;
await page.routeWebSocket("**/ws", (ws) => {
this.mockWs = ws;
@@ -42,35 +46,37 @@ export class WsMocker {
// Send initial stats
this.send(
"stats",
JSON.stringify({
cameras: Object.fromEntries(
this.cameras.map((c) => [
c,
{
camera_fps: 5,
detection_fps: 5,
process_fps: 5,
skipped_fps: 0,
detection_enabled: 1,
connection_quality: "excellent",
},
]),
),
service: {
last_updated: Date.now() / 1000,
uptime: 86400,
version: "0.15.0-test",
latest_version: "0.15.0",
storage: {},
JSON.stringify(
this.stats ?? {
cameras: Object.fromEntries(
this.cameras.map((c) => [
c,
{
camera_fps: 5,
detection_fps: 5,
process_fps: 5,
skipped_fps: 0,
detection_enabled: 1,
connection_quality: "excellent",
},
]),
),
service: {
last_updated: Date.now() / 1000,
uptime: 86400,
version: "0.15.0-test",
latest_version: "0.15.0",
storage: {},
},
detectors: {},
cpu_usages: {},
gpu_usages: {},
camera_fps: 15,
process_fps: 15,
skipped_fps: 0,
detection_fps: 15,
},
detectors: {},
cpu_usages: {},
gpu_usages: {},
camera_fps: 15,
process_fps: 15,
skipped_fps: 0,
detection_fps: 15,
}),
),
);
}
+665 -7
View File
@@ -8,6 +8,9 @@ import { test, expect } from "../fixtures/frigate-test";
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",
@@ -41,6 +44,7 @@ test.describe("System — Health tab @medium", () => {
frigateApp,
}) => {
await frigateApp.installDefaults({
stats: QUIET_STATS,
notices: [STATE_NOTICE, EVENT_NOTICE],
noticeStats: [
{
@@ -65,8 +69,6 @@ test.describe("System — Health tab @medium", () => {
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);
@@ -78,7 +80,10 @@ test.describe("System — Health tab @medium", () => {
});
test("dismiss posts and removes the row", async ({ frigateApp }) => {
await frigateApp.installDefaults({ notices: [EVENT_NOTICE] });
await frigateApp.installDefaults({
stats: QUIET_STATS,
notices: [EVENT_NOTICE],
});
// the list shrinks after the dismiss so the refetch shows the row gone
let dismissed = false;
@@ -105,14 +110,18 @@ test.describe("System — Health tab @medium", () => {
await expect(
frigateApp.page.locator("[data-testid^='health-problem-']"),
).toHaveCount(0, { timeout: 5_000 });
await expect(frigateApp.page.getByText("No notices")).toBeVisible();
await expect(
frigateApp.page.getByText("Your Frigate installation is healthy"),
).toBeVisible();
});
test("empty state with no notices", async ({ frigateApp }) => {
await frigateApp.installDefaults();
await frigateApp.installDefaults({ stats: QUIET_STATS });
await frigateApp.goto("/system#health");
await expect(frigateApp.page.getByText("No notices")).toBeVisible({
await expect(
frigateApp.page.getByText("Your Frigate installation is healthy"),
).toBeVisible({
timeout: 15_000,
});
});
@@ -121,6 +130,7 @@ test.describe("System — Health tab @medium", () => {
frigateApp,
}) => {
await frigateApp.installDefaults({
stats: QUIET_STATS,
notices: [
{
id: "update_available",
@@ -157,7 +167,10 @@ 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.installDefaults({
stats: QUIET_STATS,
notices: [STATE_NOTICE],
});
await frigateApp.goto("/system#health");
await expect(
@@ -167,3 +180,648 @@ test.describe("System — Health tab mobile @medium @mobile", () => {
).toBeVisible({ timeout: 15_000 });
});
});
test.describe("System — Health hardware pane @medium", () => {
test("detection row is ok with matching probe and fast inference", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { models: [{ scene: "all", devices: ["openvino:GPU"] }] },
stats: {
...QUIET_STATS,
detectors: { "openvino:GPU": { inference_speed: 12.3 } },
},
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId("hardware-row-detection:0");
await expect(row).toHaveAttribute("data-state", "ok", { timeout: 15_000 });
await expect(row).toContainText("12.3 ms");
});
test("detection row errors when the device is not probed", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { models: [{ scene: "all", devices: ["hailo8l"] }] },
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId("hardware-row-detection:0");
await expect(row).toHaveAttribute("data-state", "error", {
timeout: 15_000,
});
await expect(row).toContainText("hailo8l was not found on this system");
});
test("a generic device the probe cannot enumerate is judged by its runtime", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { models: [{ scene: "all", devices: ["openvino:AUTO"] }] },
stats: {
...QUIET_STATS,
detectors: { "openvino:AUTO": { inference_speed: 12 } },
},
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId("hardware-row-detection:0");
await expect(row).toHaveAttribute("data-state", "ok", {
timeout: 15_000,
});
await expect(row).toContainText("12 ms");
});
test("a bare onnx detector with no probed accelerator is not an error", async ({
frigateApp,
}) => {
// the default image runs onnx on the CPU and the probe reports nothing
await frigateApp.installDefaults({
config: { models: [{ scene: "all", devices: ["onnx"] }] },
stats: { ...QUIET_STATS, detectors: { onnx: { inference_speed: 40 } } },
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId("hardware-row-detection:0");
await expect(row).toHaveAttribute("data-state", "ok", {
timeout: 15_000,
});
});
test("detection row warns on slow inference", async ({ frigateApp }) => {
await frigateApp.installDefaults({
config: { models: [{ scene: "all", devices: ["openvino:GPU"] }] },
stats: {
...QUIET_STATS,
detectors: { "openvino:GPU": { inference_speed: 60 } },
},
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId("hardware-row-detection:0");
await expect(row).toHaveAttribute("data-state", "warning", {
timeout: 15_000,
});
await expect(row).toContainText("Inference is slow (60 ms)");
});
test("hwaccel row states", async ({ frigateApp }) => {
await frigateApp.installDefaults({
config: {
cameras: {
front_door: { ffmpeg: { hwaccel_args: "preset-vaapi" } },
backyard: { ffmpeg: { hwaccel_args: "preset-nvidia" } },
garage: { ffmpeg: { hwaccel_args: "" } },
},
},
hwaccel: {
recommended: "vaapi",
available: [{ key: "vaapi", presets: { any: "preset-vaapi" } }],
},
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
await expect(
frigateApp.page.getByTestId("hardware-row-hwaccel:preset-vaapi"),
).toHaveAttribute("data-state", "ok", { timeout: 15_000 });
await expect(
frigateApp.page.getByTestId("hardware-row-hwaccel:preset-nvidia"),
).toHaveAttribute("data-state", "warning");
await expect(
frigateApp.page.getByTestId("hardware-row-hwaccel:preset-nvidia"),
).toContainText("the hardware probe did not report it");
await expect(
frigateApp.page.getByTestId("hardware-row-hwaccel:"),
).toHaveAttribute("data-state", "warning");
});
test("face recognition row reflects the runtime device", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { face_recognition: { enabled: true } },
stats: {
...QUIET_STATS,
embeddings: { 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", "warning", {
timeout: 15_000,
});
await expect(row).toContainText(
"Running on the CPU although an accelerator is available",
);
});
test("explicit GPU that loaded on CUDA is ok despite the probe", async ({
frigateApp,
}) => {
// the fixture probes an Intel GPU only; ONNX Runtime still puts a GPU
// request on CUDA when that image has it
await frigateApp.installDefaults({
config: { face_recognition: { enabled: true, device: "GPU" } },
stats: {
...QUIET_STATS,
embeddings: { devices: { face_recognition: "CUDA" } },
},
});
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("CUDA");
});
test("explicit GPU falling back to CPU is an error", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { face_recognition: { enabled: true, device: "GPU" } },
stats: {
...QUIET_STATS,
embeddings: { 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", "error", {
timeout: 15_000,
});
});
test("enrichment without a runtime device is unknown", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: { face_recognition: { enabled: true } },
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId(
"hardware-row-enrichment:face_recognition",
);
await expect(row).toHaveAttribute("data-state", "unknown", {
timeout: 15_000,
});
});
test("all-excellent camera connections show a green check", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ stats: QUIET_STATS });
await frigateApp.goto("/system#health");
const line = frigateApp.page.getByText(
"All cameras have an excellent connection.",
);
await expect(line).toBeVisible({ timeout: 15_000 });
await expect(line.locator("svg")).toHaveClass(/text-success/);
});
test("camera connections lists only non-excellent cameras", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
stats: {
...QUIET_STATS,
cameras: {
backyard: { connection_quality: "poor", camera_fps: 2.1 },
},
},
});
await frigateApp.goto("/system#health");
await expect(
frigateApp.page.getByTestId("camera-connection-backyard"),
).toBeVisible({ timeout: 15_000 });
await expect(
frigateApp.page.getByTestId("camera-connection-front_door"),
).toHaveCount(0);
});
});
test.describe("System — Health notices sources @medium", () => {
test("live offline camera and 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 } } },
});
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");
const fpsRow = frigateApp.page.getByTestId(
"health-problem-config:detect:fps-greater-than-five:garage",
);
await expect(fpsRow).toHaveAttribute("data-severity", "info");
await expect(
fpsRow.getByRole("link", { name: "Open settings" }),
).toHaveAttribute("href", "/settings?page=cameraDetect&camera=garage");
});
// 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 = {
ffmpeg: { inputs: [{ path: "rtsp://x", roles: ["detect"] }] },
};
test("record role warning is hidden while recording is off", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: {
cameras: {
front_door: { ...NO_RECORD_ROLE, record: { enabled: false } },
},
},
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute(
"data-state",
"on",
{ timeout: 15_000 },
);
await expect(
frigateApp.page.getByText("No streams have the record role defined"),
).toHaveCount(0);
});
test("record role warning shows once recording is on", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: {
cameras: {
front_door: { ...NO_RECORD_ROLE, record: { enabled: true } },
},
},
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
await expect(
frigateApp.page.getByText("No streams have the record role defined"),
).toBeVisible({ timeout: 15_000 });
});
test("a global config problem is not repeated per camera", async ({
frigateApp,
}) => {
// every fixture camera inherits the global size, with resolved defaults
// the global block leaves null, so only text equality can dedupe them
const size = { width: 2560, height: 1440 };
await frigateApp.installDefaults({
config: {
detect: size,
cameras: {
front_door: { detect: size },
backyard: { detect: size },
garage: { detect: size },
},
},
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
const rows = frigateApp.page.locator(
"[data-testid^='health-problem-config:detect:detect-resolution-high']",
);
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
await expect(rows).toHaveCount(1);
await expect(rows.first()).toHaveAttribute(
"data-testid",
"health-problem-config:detect:detect-resolution-high:global",
);
});
test("a camera that overrides the global value keeps its own row", async ({
frigateApp,
}) => {
// two cameras inherit the global size and are folded into the global
// row; the one that overrides it keeps a row with its own link
const size = { width: 2560, height: 1440 };
await frigateApp.installDefaults({
config: {
detect: size,
cameras: {
front_door: { detect: size },
backyard: { detect: size },
garage: { detect: { width: 3840, height: 2160 } },
},
},
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
const rows = frigateApp.page.locator(
"[data-testid^='health-problem-config:detect:detect-resolution-high']",
);
await expect(rows.first()).toBeVisible({ timeout: 15_000 });
await expect(rows).toHaveCount(2);
await expect(
frigateApp.page.getByTestId(
"health-problem-config:detect:detect-resolution-high: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,
}) => {
await frigateApp.installDefaults({ stats: QUIET_STATS });
await frigateApp.goto("/system#health");
await expect(
frigateApp.page.getByText("Your Frigate installation is healthy"),
).toBeVisible({
timeout: 15_000,
});
await expect(
frigateApp.page.locator("[data-testid^='health-problem-']"),
).toHaveCount(0);
});
test("stream checks probe every camera and flag non-AAC audio", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
// the default record preset transcodes to AAC, so the codec only
// matters for a camera that copies audio through
config: {
cameras: {
backyard: {
ffmpeg: {
output_args: { record: "preset-record-generic-audio-copy" },
},
},
},
},
ffprobe: {
backyard: [
{
return_code: 0,
stderr: "",
stdout: {
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 1920,
height: 1080,
},
{ codec_type: "audio", codec_name: "pcm_mulaw" },
],
},
},
],
garage: [
{
return_code: 1,
// the backend sends every line; the tab shows the last one
stderr: [
"[tcp @ 0x1] Connection to tcp://10.0.0.3:554 failed",
"Connection refused",
],
stdout: "",
},
],
},
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
const requests: string[] = [];
frigateApp.page.on("request", (req) => {
if (req.url().includes("/api/ffprobe")) {
requests.push(req.url());
}
});
await frigateApp.page
.getByRole("button", { name: "Run stream checks" })
.click();
await expect(
frigateApp.page.getByText("Stream 1: The AAC audio codec is required"),
).toBeVisible({ timeout: 15_000 });
await expect(
frigateApp.page.getByText(
"Stream 1 could not be probed: Connection refused",
),
).toBeVisible();
const streams = frigateApp.page.getByTestId("camera-streams");
await expect(streams).toContainText("3 cameras checked");
await expect(streams).toContainText("2 with problems");
await expect(frigateApp.page.getByText(/^Checked/)).toBeVisible();
await expect(
frigateApp.page.getByRole("button", { name: "Run again" }),
).toBeVisible();
// axios leaves ":" unescaped in query strings
expect(requests.filter((u) => u.includes("paths=camera:")).length).toBe(3);
});
test("non-AAC audio is fine when recordings transcode to AAC", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
ffprobe: {
backyard: [
{
return_code: 0,
stderr: "",
stdout: {
streams: [
{
codec_type: "video",
codec_name: "h264",
width: 1920,
height: 1080,
},
{ codec_type: "audio", codec_name: "pcm_mulaw" },
],
},
},
],
},
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
await frigateApp.page
.getByRole("button", { name: "Run stream checks" })
.click();
await expect(frigateApp.page.getByText(/^Checked/)).toBeVisible({
timeout: 15_000,
});
await expect(
frigateApp.page.getByText("The AAC audio codec is required"),
).toHaveCount(0);
});
test("wizard-only Reolink advice stays off the tab", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
config: {
cameras: {
front_door: {
ffmpeg: {
inputs: [
{
path: "rtsp://10.0.0.1:554/h264Preview_01_main",
roles: ["detect", "record"],
},
],
},
},
},
},
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
await frigateApp.page
.getByRole("button", { name: "Run stream checks" })
.click();
await expect(frigateApp.page.getByText(/^Checked/)).toBeVisible({
timeout: 15_000,
});
await expect(
frigateApp.page.getByText("Reolink RTSP is not recommended"),
).toHaveCount(0);
});
test("re-check re-probes the hardware and dates the probe", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({ stats: QUIET_STATS });
await frigateApp.goto("/system#health");
const probes: string[] = [];
frigateApp.page.on("request", (req) => {
if (req.url().includes("refresh=true")) {
probes.push(req.url());
}
});
const recheck = frigateApp.page.getByRole("button", {
name: "Re-check hardware",
});
await expect(recheck).toBeVisible({ timeout: 15_000 });
await expect(frigateApp.page.getByText(/^Probed/)).toHaveCount(0);
await recheck.click();
await expect(frigateApp.page.getByText(/^Probed/)).toBeVisible();
await expect(recheck).toBeEnabled();
expect(probes.length).toBe(1);
});
test("a camera notice link opens that camera's settings page", async ({
frigateApp,
}) => {
// garage is not the camera Settings would pick on its own, so a wrong
// selection here is visible rather than accidentally right
await frigateApp.installDefaults({
config: {
lpr: { enabled: false },
cameras: { garage: { lpr: { enabled: true } } },
},
stats: QUIET_STATS,
});
await frigateApp.goto("/system#health");
const row = frigateApp.page.getByTestId(
"health-problem-config:lpr:global-disabled:garage",
);
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(
row.getByRole("link", { name: "Open settings" }),
).toHaveAttribute("href", "/settings?page=cameraLpr&camera=garage");
await row.getByRole("link", { name: "Open settings" }).click();
await expect(frigateApp.page.getByLabel("Select a camera")).toContainText(
"Garage",
{ timeout: 15_000 },
);
});
test("status bar healthy text links to the Health tab", async ({
frigateApp,
}) => {
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
await frigateApp.installDefaults({ stats: QUIET_STATS });
await frigateApp.goto("/");
await frigateApp.page
.getByRole("link", { name: "System is healthy" })
.click();
await expect(frigateApp.page).toHaveURL(/\/system#health/);
});
});
+56 -2
View File
@@ -18,7 +18,7 @@
"title": "Health",
"notices": {
"title": "Notices",
"empty": "No notices",
"empty": "Your Frigate installation is healthy",
"dismiss": "Dismiss",
"openSettings": "Open settings",
"openLink": "Open link",
@@ -31,7 +31,61 @@
"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"
}
},
"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"
},
"hardware": {
"title": "Hardware",
"objectDetection": "Object detection",
"hardwareAcceleration": "Hardware acceleration",
"enrichments": {
"title": "Enrichments",
"semantic_search": "Semantic search",
"face_recognition": "Face recognition",
"lpr": "License plate recognition",
"audio_transcription": "Audio transcription"
},
"cameraConnections": "Camera connections",
"allCamerasExcellent": "All cameras have an excellent connection.",
"fps": "{{camera}} / {{expected}} fps",
"nothingConfigured": "Nothing configured",
"allCameras": "All cameras",
"probeUnavailable": "Hardware detection is unavailable",
"justStarted": "Frigate just started",
"deviceNotFound": "{{devices}} was not found on this system",
"detectorNotRunning": "Detector process is not running",
"inferenceVerySlow": "Inference is very slow ({{speed}} ms)",
"inferenceSlow": "Inference is slow ({{speed}} ms)",
"inferenceMs": "{{speed}} ms",
"customArgs": "Custom ffmpeg arguments",
"customArgsNotVerified": "Custom ffmpeg arguments, not verified",
"hwaccelNotConfigured": "No hardware decoding is in use, but {{family}} is available. Select it in the ffmpeg settings",
"hwaccelHardwareMissing": "{{family}} is configured, but the hardware probe did not report it",
"unrecognizedDevice": "Unrecognized device override, not verified",
"decoderUsage": "decoder {{usage}}",
"remoteProvider": "Remote provider",
"acceleratorMissing": "Configured for {{device}} but no compatible accelerator was found",
"modelNotRunYet": "Model has not run yet, the device is verified after first use",
"fellBackToCpu": "Configured for {{device}} but running on the CPU",
"cpuDespiteAccelerator": "Running on the CPU although an accelerator is available",
"probed": "Probed",
"probing": "Probing...",
"recheck": "Re-check hardware",
"cameraStreams": "Camera streams",
"streamsNotChecked": "Not checked yet",
"streamsChecking": "Checking {{done}} of {{total}}",
"streamsClean": "No stream problems",
"checked": "Checked",
"runAgain": "Run again",
"runStreamChecks": "Run stream checks",
"streamsChecked_one": "{{count}} camera checked",
"streamsChecked_other": "{{count}} cameras checked",
"streamsFlagged_one": "{{count}} with problems, listed under Notices",
"streamsFlagged_other": "{{count}} with problems, listed under Notices"
}
},
"logs": {
+14 -4
View File
@@ -187,10 +187,20 @@ 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 ? (
<div className="flex items-center gap-2 text-sm">
<FaCheck className="size-3 text-green-500" />
{t("stats.healthy")}
</div>
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>
)
) : (
Object.entries(messages).map(([key, messageArray]) => (
<div key={key} className="flex h-full items-center gap-2">
@@ -6,6 +6,7 @@ const audio: SectionConfigOverrides = {
messages: [
{
key: "no-audio-role",
health: (ctx) => ctx.fullCameraConfig?.audio?.enabled === true,
messageKey: "configMessages.audio.noAudioRole",
severity: "warning",
condition: (ctx) => {
@@ -6,6 +6,8 @@ const audioTranscription: SectionConfigOverrides = {
messages: [
{
key: "audio-detection-disabled",
health: (ctx) =>
ctx.fullCameraConfig?.audio_transcription?.enabled === true,
messageKey: "configMessages.audioTranscription.audioDetectionDisabled",
severity: "warning",
condition: (ctx) => {
@@ -15,6 +15,7 @@ const detect: SectionConfigOverrides = {
fieldMessages: [
{
key: "detect-resolution-not-multiple-of-four",
health: true,
field: "width",
position: "before",
messageKey: "configMessages.detect.resolutionShouldBeMultipleOfFour",
@@ -46,6 +47,7 @@ const detect: SectionConfigOverrides = {
},
{
key: "detect-resolution-high",
health: true,
field: "width",
position: "before",
messageKey: "configMessages.detect.resolutionHigh",
@@ -61,6 +63,7 @@ const detect: SectionConfigOverrides = {
},
{
key: "detect-square-resolution",
health: true,
field: "width",
position: "before",
messageKey: "configMessages.detect.squareResolution",
@@ -112,6 +115,7 @@ const detect: SectionConfigOverrides = {
},
{
key: "detect-scene-without-model",
health: true,
field: "scene",
position: "after",
messageKey: "configMessages.detect.sceneWithoutModel",
@@ -127,6 +131,7 @@ const detect: SectionConfigOverrides = {
},
{
key: "fps-greater-than-five",
health: true,
field: "fps",
messageKey: "configMessages.detect.fpsGreaterThanFive",
severity: "info",
@@ -6,6 +6,8 @@ const faceRecognition: SectionConfigOverrides = {
messages: [
{
key: "global-disabled",
health: (ctx) =>
ctx.fullCameraConfig?.face_recognition?.enabled === true,
messageKey: "configMessages.faceRecognition.globalDisabled",
severity: "warning",
condition: (ctx) => {
@@ -15,6 +17,9 @@ const faceRecognition: SectionConfigOverrides = {
},
{
key: "person-not-tracked",
health: (ctx) =>
ctx.fullCameraConfig?.face_recognition?.enabled === true &&
ctx.fullConfig.face_recognition?.enabled === true,
messageKey: "configMessages.faceRecognition.personNotTracked",
severity: "info",
condition: (ctx) => {
@@ -52,6 +52,7 @@ const ffmpeg: SectionConfigOverrides = {
},
{
key: "inputs-missing-go2rtc-stream",
health: true,
field: "inputs",
position: "before",
messageKey: "configMessages.ffmpeg.inputsMissingGo2rtcStream",
@@ -7,6 +7,7 @@ const lpr: SectionConfigOverrides = {
messages: [
{
key: "global-disabled",
health: (ctx) => ctx.fullCameraConfig?.lpr?.enabled === true,
messageKey: "configMessages.lpr.globalDisabled",
severity: "warning",
condition: (ctx) => {
@@ -16,6 +17,9 @@ const lpr: SectionConfigOverrides = {
},
{
key: "vehicle-not-tracked",
health: (ctx) =>
ctx.fullCameraConfig?.lpr?.enabled === true &&
ctx.fullConfig.lpr?.enabled === true,
messageKey: "configMessages.lpr.vehicleNotTracked",
severity: "info",
condition: (ctx) => {
@@ -35,6 +35,7 @@ const models: SectionConfigOverrides = {
},
{
key: "model-input-dimensions-not-detect-resolution",
health: true,
field: "height",
position: "after",
messageKey: "configMessages.model.inputDimensionsNotDetectResolution",
@@ -72,6 +72,9 @@ const objects: SectionConfigOverrides = {
fieldMessages: [
{
key: "genai-no-descriptions-provider",
health: (ctx) =>
(ctx.formData as { genai?: { enabled?: boolean } })?.genai
?.enabled === true,
field: "genai.enabled",
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
severity: "warning",
@@ -28,6 +28,8 @@ const onvif: SectionConfigOverrides = {
fieldMessages: [
{
key: "autotracking-no-zones",
health: (ctx) =>
ctx.fullCameraConfig?.onvif?.autotracking?.enabled === true,
field: "autotracking.required_zones",
messageKey: "configMessages.onvif.autotrackingNoZones",
severity: "error",
@@ -6,6 +6,7 @@ const record: SectionConfigOverrides = {
messages: [
{
key: "no-record-role",
health: (ctx) => ctx.fullCameraConfig?.record?.enabled === true,
messageKey: "configMessages.record.noRecordRole",
severity: "warning",
condition: (ctx) => {
@@ -17,6 +18,7 @@ const record: SectionConfigOverrides = {
},
{
key: "no-record-sub-role",
health: (ctx) => ctx.fullCameraConfig?.record?.sub?.enabled === true,
messageKey: "configMessages.record.noRecordSubRole",
severity: "warning",
condition: (ctx) => {
@@ -43,6 +43,9 @@ const review: SectionConfigOverrides = {
},
{
key: "genai-no-descriptions-provider",
health: (ctx) =>
(ctx.formData as { genai?: { enabled?: boolean } })?.genai
?.enabled === true,
field: "genai.enabled",
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
severity: "warning",
@@ -57,6 +60,7 @@ const review: SectionConfigOverrides = {
},
{
key: "genai-image-source-recordings-record-disabled",
health: true,
field: "genai.image_source",
messageKey:
"configMessages.review.genaiImageSourceRecordingsRecordDisabled",
@@ -21,6 +21,7 @@ const semanticSearch: SectionConfigOverrides = {
fieldMessages: [
{
key: "jinav2-small-model-size",
health: (ctx) => ctx.fullConfig.semantic_search?.enabled === true,
field: "model_size",
messageKey: "configMessages.semanticSearch.jinav2SmallModelSize",
severity: "warning",
@@ -28,6 +28,13 @@ export type ConditionalMessage = {
values?: Record<string, unknown>;
/** Optional documentation path (e.g. "/configuration/object_detectors#model"). */
docLink?: string;
/**
* Whether the Health tab evaluates this message against the saved config.
* Absent or false: form only. true: shown whenever condition() holds. A
* function: shown when both condition(ctx) and health(ctx) hold, for
* messages the form deliberately shows even when the feature is off.
*/
health?: boolean | ((ctx: MessageConditionContext) => boolean);
};
/** Field-level conditional message, adds field targeting */
@@ -0,0 +1,67 @@
/** settings page id that edits each config section at camera level */
export const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
detect: "cameraDetect",
ffmpeg: "cameraFfmpeg",
record: "cameraRecording",
snapshots: "cameraSnapshots",
motion: "cameraMotion",
objects: "cameraObjects",
review: "cameraReview",
audio: "cameraAudioEvents",
audio_transcription: "cameraAudioTranscription",
notifications: "cameraNotifications",
live: "cameraLivePlayback",
birdseye: "cameraBirdseye",
face_recognition: "cameraFaceRecognition",
lpr: "cameraLpr",
timestamp_style: "cameraTimestampStyle",
onvif: "cameraOnvif",
};
/** settings page id that edits each config section at global level */
export const GLOBAL_PAGE_BY_SECTION: Record<string, string> = {
detect: "globalDetect",
record: "globalRecording",
snapshots: "globalSnapshots",
ffmpeg: "globalFfmpeg",
motion: "globalMotion",
objects: "globalObjects",
review: "globalReview",
audio: "globalAudioEvents",
live: "globalLivePlayback",
timestamp_style: "globalTimestampStyle",
database: "systemDatabase",
tls: "systemTls",
auth: "systemAuthentication",
networking: "systemNetworking",
proxy: "systemProxy",
ui: "systemUi",
logger: "systemLogging",
environment_vars: "systemEnvironmentVariables",
telemetry: "systemTelemetry",
birdseye: "systemBirdseye",
models: "systemDetectorsAndModel",
mqtt: "systemMqtt",
semantic_search: "integrationSemanticSearch",
genai: "integrationGenerativeAi",
face_recognition: "integrationFaceRecognition",
lpr: "integrationLpr",
classification: "integrationObjectClassification",
audio_transcription: "integrationAudioTranscription",
};
export function settingsLink(
section: string,
level: "global" | "camera",
cameraName?: string,
): string | undefined {
if (level === "camera") {
const page = CAMERA_PAGE_BY_SECTION[section];
return page && cameraName
? `/settings?page=${page}&camera=${encodeURIComponent(cameraName)}`
: undefined;
}
const page = GLOBAL_PAGE_BY_SECTION[section];
return page ? `/settings?page=${page}` : undefined;
}
@@ -25,24 +25,7 @@ import {
pathMatchesHiddenPattern,
} from "@/utils/configUtil";
import { useOverrideFieldLabel } from "./useOverrideFieldLabel";
const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
detect: "cameraDetect",
ffmpeg: "cameraFfmpeg",
record: "cameraRecording",
snapshots: "cameraSnapshots",
motion: "cameraMotion",
objects: "cameraObjects",
review: "cameraReview",
audio: "cameraAudioEvents",
audio_transcription: "cameraAudioTranscription",
notifications: "cameraNotifications",
live: "cameraLivePlayback",
birdseye: "cameraBirdseye",
face_recognition: "cameraFaceRecognition",
lpr: "cameraLpr",
timestamp_style: "cameraTimestampStyle",
};
import { CAMERA_PAGE_BY_SECTION } from "@/components/config-form/sectionPages";
const MAX_FIELDS_PER_CAMERA = 5;
+294
View File
@@ -0,0 +1,294 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { FaCircleCheck } from "react-icons/fa6";
import { LuRefreshCw } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import useSWR from "swr";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import TimeAgo from "@/components/dynamic/TimeAgo";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { ConnectionQualityIndicator } from "@/components/camera/ConnectionQualityIndicator";
import HardwareStatusRow from "@/components/health/HardwareStatusRow";
import { useHardwareHealth } from "@/hooks/use-hardware-health";
import { useHealthChecks } from "@/hooks/use-health-checks";
import type { FrigateConfig } from "@/types/frigateConfig";
import type { HardwareRow } from "@/utils/health";
import { streamHealth } from "@/utils/streamHealth";
function Card({
title,
className,
action,
subtitle,
children,
}: {
title: string;
className?: string;
/** an icon button right after the title */
action?: React.ReactNode;
/** a muted line under the title */
subtitle?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div
className={cn(
"rounded-lg bg-background_alt p-2.5 pb-5 md:rounded-2xl",
className,
)}
>
<div className="mb-5 flex flex-col">
<div className="flex items-center gap-3">
<span>{title}</span>
{action}
</div>
{subtitle && (
<div className="text-xs text-muted-foreground">{subtitle}</div>
)}
</div>
{children}
</div>
);
}
function InlineAction({
label,
busy,
disabled,
onClick,
}: {
label: string;
busy?: boolean;
disabled?: boolean;
onClick: () => void;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-6 shrink-0 text-muted-foreground hover:text-primary"
aria-label={label}
disabled={busy || disabled}
onClick={onClick}
>
{busy ? (
<ActivityIndicator className="size-3.5" size={14} />
) : (
<LuRefreshCw className="size-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{label}</TooltipContent>
</TooltipPortal>
</Tooltip>
);
}
function Group({ title, rows }: { title: string; rows: HardwareRow[] }) {
const { t } = useTranslation(["views/system"]);
return (
<Card title={title}>
{rows.length === 0 ? (
<div className="text-sm text-muted-foreground">
{t("health.hardware.nothingConfigured")}
</div>
) : (
<div className="flex flex-col gap-3">
{rows.map((row) => (
<HardwareStatusRow key={row.id} row={row} />
))}
</div>
)}
</Card>
);
}
/** Stream checks live here: the check sits next to its result. */
function StreamsGroup() {
const { t } = useTranslation(["views/system", "views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const { stream } = useHealthChecks();
const summary = useMemo(
() => (config ? streamHealth(config, stream.results, t) : undefined),
[config, stream.results, t],
);
const row: HardwareRow | undefined = useMemo(() => {
if (stream.running || !summary || !stream.results) {
return undefined;
}
const flagged = summary.checked - summary.clean;
return {
id: "streams",
state: flagged > 0 ? "warning" : "ok",
label: t("health.hardware.streamsChecked", { count: summary.checked }),
detail:
flagged > 0
? t("health.hardware.streamsFlagged", { count: flagged })
: t("health.hardware.streamsClean"),
};
}, [stream.running, stream.results, summary, t]);
return (
<Card
title={t("health.hardware.cameraStreams")}
action={
<InlineAction
label={
stream.results
? t("health.hardware.runAgain")
: t("health.hardware.runStreamChecks")
}
busy={stream.running}
disabled={!config}
onClick={() => stream.run()}
/>
}
subtitle={
stream.running
? t("health.hardware.streamsChecking", {
done: stream.total - stream.pending.length,
total: stream.total,
})
: stream.results && (
<>
{t("health.hardware.checked")}{" "}
<TimeAgo time={stream.results.checkedAt * 1000} dense />
</>
)
}
>
<div className="flex flex-col gap-3 text-sm" data-testid="camera-streams">
{row ? (
<HardwareStatusRow row={row} />
) : (
<HardwareStatusRow
row={{
id: "streams",
state: "unknown",
label: t("health.hardware.streamsNotChecked"),
}}
/>
)}
</div>
</Card>
);
}
function HardwareHeading() {
const { t } = useTranslation(["views/system"]);
const { hardware } = useHealthChecks();
return (
<div className="flex flex-col">
<div className="flex items-center gap-3">
<div className="text-md font-medium text-primary-variant">
{t("health.hardware.title")}
</div>
<InlineAction
label={t("health.hardware.recheck")}
busy={hardware.rechecking}
onClick={() => hardware.recheck()}
/>
</div>
{hardware.rechecking ? (
<div className="text-xs text-muted-foreground">
{t("health.hardware.probing")}
</div>
) : (
hardware.probedAt && (
<div className="text-xs text-muted-foreground">
{t("health.hardware.probed")}{" "}
<TimeAgo time={hardware.probedAt * 1000} dense />
</div>
)
)}
</div>
);
}
export default function HardwarePane() {
const { t } = useTranslation(["views/system"]);
const { rows, statsLoaded } = useHardwareHealth();
return (
<div className="flex flex-col gap-4">
<HardwareHeading />
{!rows ? (
<Skeleton className="h-40 w-full rounded-lg md:rounded-2xl" />
) : (
<>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-2 md:grid-cols-3">
<Group
title={t("health.hardware.objectDetection")}
rows={rows.detection}
/>
<Group
title={t("health.hardware.hardwareAcceleration")}
rows={rows.hwaccel}
/>
<Group
title={t("health.hardware.enrichments.title")}
rows={rows.enrichments}
/>
</div>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<StreamsGroup />
<Card title={t("health.hardware.cameraConnections")}>
{!statsLoaded ? (
<Skeleton className="h-10 w-full" />
) : rows.cameras.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FaCircleCheck className="size-4 text-success" />
{t("health.hardware.allCamerasExcellent")}
</div>
) : (
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{rows.cameras.map((cell) => (
<div
key={cell.camera}
className="flex items-center gap-2 text-sm"
data-testid={`camera-connection-${cell.camera}`}
>
<ConnectionQualityIndicator
quality={cell.quality}
expectedFps={cell.expectedFps}
reconnects={cell.reconnects}
stalls={cell.stalls}
/>
<CameraNameLabel
camera={cell.camera}
className="smart-capitalize"
/>
<span className="text-muted-foreground">
{t("health.hardware.fps", {
camera: cell.cameraFps.toFixed(1),
expected: cell.expectedFps,
})}
</span>
</div>
))}
</div>
)}
</Card>
</div>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,47 @@
import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
import { LuCircleHelp, LuX } from "react-icons/lu";
import { cn } from "@/lib/utils";
import type { HardwareRow } from "@/utils/health";
const MESSAGE_COLOR: Record<HardwareRow["state"], string> = {
ok: "text-success",
warning: "text-yellow-500",
error: "text-danger",
unknown: "text-muted-foreground",
};
export default function HardwareStatusRow({ row }: { row: HardwareRow }) {
return (
<div
className="flex items-start gap-2 text-sm"
data-testid={`hardware-row-${row.id}`}
data-state={row.state}
>
<div className="mt-0.5 flex shrink-0">
{row.state === "ok" && (
<FaCircleCheck className="size-4 text-success" />
)}
{row.state === "warning" && (
<FaTriangleExclamation className="size-4 text-yellow-500" />
)}
{row.state === "error" && <LuX className="size-4 text-danger" />}
{row.state === "unknown" && (
<LuCircleHelp className="size-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0">
<div>
<span>{row.label}</span>
{row.detail && (
<span className="ml-2 text-muted-foreground">{row.detail}</span>
)}
</div>
{row.message && (
<div className={cn("mt-0.5", MESSAGE_COLOR[row.state])}>
{row.message}
</div>
)}
</div>
</div>
);
}
+136 -54
View File
@@ -7,82 +7,164 @@ import {
LuSlidersHorizontal,
LuX,
} from "react-icons/lu";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useDocDomain } from "@/hooks/use-doc-domain";
import type { HealthProblem } from "@/types/health";
type HealthProblemRowProps = {
problem: HealthProblem;
};
const ICON_BUTTON_CLASS =
"size-6 shrink-0 text-muted-foreground hover:text-primary";
function RowAction({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{label}</TooltipContent>
</TooltipPortal>
</Tooltip>
);
}
export default function HealthProblemRow({ problem }: HealthProblemRowProps) {
const { t } = useTranslation(["views/system"]);
const { t } = useTranslation(["views/system", "common"]);
const { getLocaleDocUrl } = useDocDomain();
const hasDetails = problem.scope || problem.meta;
const hasActions =
problem.link ||
problem.docLink ||
problem.externalLink ||
problem.onDismiss;
return (
<div
className="flex items-start gap-2 border-b border-border px-1 py-2 text-sm last:border-b-0"
className="flex items-center 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 className="flex shrink-0 self-start pt-0.5">
{problem.pending ? (
<ActivityIndicator className="" size={16} />
) : (
<>
{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}
{hasDetails && (
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{problem.scope && (
<span className="rounded bg-secondary px-1.5 py-0.5 text-xs text-primary-variant">
{problem.scopeIsCamera ? (
<CameraNameLabel camera={problem.scope} />
) : (
problem.scope
)}
</span>
)}
{problem.meta && <span>{problem.meta}</span>}
</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>
{hasActions && (
<div className="ml-auto flex shrink-0 items-center gap-1">
{problem.link && (
<RowAction label={t("health.notices.openSettings")}>
<Button
asChild
variant="ghost"
size="icon"
className={ICON_BUTTON_CLASS}
>
<Link
to={problem.link}
aria-label={t("health.notices.openSettings")}
>
<LuSlidersHorizontal className="size-3.5" />
</Link>
</Button>
</RowAction>
)}
{problem.docLink && (
<RowAction label={t("readTheDocumentation", { ns: "common" })}>
<Button
asChild
variant="ghost"
size="icon"
className={ICON_BUTTON_CLASS}
>
<a
href={getLocaleDocUrl(problem.docLink)}
target="_blank"
rel="noreferrer"
aria-label={t("readTheDocumentation", { ns: "common" })}
>
<LuExternalLink className="size-3.5" />
</a>
</Button>
</RowAction>
)}
{problem.externalLink && (
<RowAction label={t("health.notices.openLink")}>
<Button
asChild
variant="ghost"
size="icon"
className={ICON_BUTTON_CLASS}
>
<a
href={problem.externalLink}
target="_blank"
rel="noreferrer"
aria-label={t("health.notices.openLink")}
>
<LuExternalLink className="size-3.5" />
</a>
</Button>
</RowAction>
)}
{problem.onDismiss && (
<RowAction label={t("health.notices.dismiss")}>
<Button
variant="ghost"
size="icon"
className={ICON_BUTTON_CLASS}
aria-label={t("health.notices.dismiss")}
onClick={problem.onDismiss}
>
<LuX className="size-3.5" />
</Button>
</RowAction>
)}
</div>
)}
</div>
);
}
+68 -7
View File
@@ -7,8 +7,14 @@ 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";
@@ -75,6 +81,7 @@ function useNoticeProblems(
return {
id: `notice:${notice.id}`,
source: "registry" as const,
severity: notice.severity,
scope: notice.scope ?? undefined,
scopeIsCamera: notice.category === "camera",
@@ -93,19 +100,73 @@ function useNoticeProblems(
}
export default function NoticesPane() {
const { t } = useTranslation(["views/system"]);
const { t } = useTranslation(["views/system", "views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const stats = useAutoFrigateStats();
const { notices, statsByKind, dismiss } = useNotices();
const problems = useNoticeProblems(notices, statsByKind, dismiss);
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],
);
const streamProblems = useMemo<HealthProblem[]>(
() => (config ? streamHealth(config, results, t).problems : []),
[config, results, t],
);
const problems = useMemo(
() =>
sortHealthProblems([
...registryProblems,
...liveProblems,
...configProblems,
...streamProblems,
]),
[registryProblems, liveProblems, configProblems, streamProblems],
);
const loading = notices === undefined || !config;
return (
<div className="flex flex-col gap-4">
<div>
<div className="text-md font-medium text-primary-variant">
{t("health.notices.title")}
</div>
<div className="text-md font-medium text-primary-variant">
{t("health.notices.title")}
</div>
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
{notices === undefined ? (
{loading ? (
<Skeleton className="h-24 w-full" />
) : problems.length === 0 ? (
<div className="flex items-center gap-2 px-1 py-2 text-sm">
@@ -20,6 +20,7 @@ import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
import { LuX } from "react-icons/lu";
import { Card, CardContent } from "../../ui/card";
import { maskUri } from "@/utils/cameraUtil";
import { ffprobeToTestResult, getStreamIssues } from "@/utils/streamIssues";
type Step4ValidationProps = {
wizardData: Partial<WizardFormData>;
@@ -74,44 +75,7 @@ export default function Step4Validation({
timeout: 10000,
});
if (response.data?.[0]?.return_code === 0) {
const probeData = response.data[0];
const streamData = probeData.stdout.streams || [];
const videoStream = streamData.find(
(s: { codec_type?: string; codec_name?: string }) =>
s.codec_type === "video" ||
s.codec_name?.includes("h264") ||
s.codec_name?.includes("h265"),
);
const audioStream = streamData.find(
(s: { codec_type?: string; codec_name?: string }) =>
s.codec_type === "audio" ||
s.codec_name?.includes("aac") ||
s.codec_name?.includes("mp3"),
);
const resolution = videoStream
? `${videoStream.width}x${videoStream.height}`
: undefined;
const fps = videoStream?.avg_frame_rate
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
parseFloat(videoStream.avg_frame_rate.split("/")[1])
: undefined;
return {
success: true,
resolution,
videoCodec: videoStream?.codec_name,
audioCodec: audioStream?.codec_name,
fps: fps && !isNaN(fps) ? fps : undefined,
};
} else {
const error = response.data?.[0]?.stderr || "Unknown error";
return { success: false, error };
}
return ffprobeToTestResult(response.data?.[0]);
} catch (error) {
const axiosError = error as {
response?: { data?: { message?: string; detail?: string } };
@@ -528,149 +492,21 @@ function StreamIssues({
}: StreamIssuesProps) {
const { t } = useTranslation(["views/settings"]);
const issues = useMemo(() => {
const result: Array<{
type: "good" | "warning" | "error";
message: string;
}> = [];
if (wizardData.brandTemplate === "reolink") {
const streamUrl = stream.url.toLowerCase();
if (streamUrl.startsWith("rtsp://")) {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.brands.reolink-rtsp"),
});
}
if (streamUrl.startsWith("http://") && !stream.useFfmpeg) {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.brands.reolink-http"),
});
}
}
// Video codec check
if (stream.testResult?.videoCodec) {
const videoCodec = stream.testResult.videoCodec.toLowerCase();
if (["h264", "h265", "hevc"].includes(videoCodec)) {
result.push({
type: "good",
message: t("cameraWizard.step4.issues.videoCodecGood", {
codec: stream.testResult.videoCodec,
}),
});
}
}
// Audio codec check
if (stream.roles.includes("record")) {
if (stream.testResult?.audioCodec) {
const audioCodec = stream.testResult.audioCodec.toLowerCase();
if (audioCodec === "aac") {
result.push({
type: "good",
message: t("cameraWizard.step4.issues.audioCodecGood", {
codec: stream.testResult.audioCodec,
}),
});
} else {
result.push({
type: "error",
message: t("cameraWizard.step4.issues.audioCodecRecordError"),
});
}
} else {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.noAudioWarning"),
});
}
}
// Audio detection check
if (stream.roles.includes("audio")) {
if (!stream.testResult?.audioCodec) {
result.push({
type: "error",
message: t("cameraWizard.step4.issues.audioCodecRequired"),
});
}
}
// Restreaming check
if (stream.roles.includes("record")) {
if (stream.restream) {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.restreamingWarning"),
});
}
}
if (stream.roles.includes("detect") && stream.testResult) {
const probedResolution = stream.testResult.resolution;
let probedWidth = 0;
let probedHeight = 0;
if (probedResolution) {
const [w, h] = probedResolution.split("x").map(Number);
if (!isNaN(w) && !isNaN(h)) {
probedWidth = w;
probedHeight = h;
}
}
if (probedWidth <= 0 || probedHeight <= 0) {
result.push({
type: "error",
message: t("cameraWizard.step4.issues.resolutionUnknown"),
});
} else {
const minDimension = Math.min(probedWidth, probedHeight);
const maxDimension = Math.max(probedWidth, probedHeight);
if (minDimension > 1080) {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.resolutionHigh", {
resolution: probedResolution,
}),
});
} else if (maxDimension < 640) {
result.push({
type: "error",
message: t("cameraWizard.step4.issues.resolutionLow", {
resolution: probedResolution,
}),
});
}
}
}
// Substream Check
if (
wizardData.brandTemplate == "dahua" &&
stream.roles.includes("detect") &&
stream.url.includes("subtype=1")
) {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.dahua.substreamWarning"),
});
}
if (
wizardData.brandTemplate == "hikvision" &&
stream.roles.includes("detect") &&
stream.url.includes("/102")
) {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.hikvision.substreamWarning"),
});
}
return result;
}, [stream, wizardData, t]);
const issues = useMemo(
() =>
getStreamIssues(
{
url: stream.url,
roles: stream.roles,
brand: wizardData.brandTemplate,
useFfmpeg: stream.useFfmpeg,
restream: stream.restream,
testResult: stream.testResult,
},
t,
),
[stream, wizardData, t],
);
if (issues.length === 0) {
return null;
+67
View File
@@ -0,0 +1,67 @@
import { useMemo } from "react";
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import type {
DetectionHardware,
HwaccelRecommendation,
} from "@/types/hardware";
import type { FrigateConfig } from "@/types/frigateConfig";
import { useAutoFrigateStats } from "@/hooks/use-stats";
import {
cameraConnectionCells,
detectionRows,
enrichmentRows,
hwaccelRows,
isStartupWindow,
} from "@/utils/health";
export function useHardwareHealth() {
const { t } = useTranslation(["views/system", "views/setup"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const { data: hardware, error: probeError } = useSWR<DetectionHardware[]>(
"hardware/probe",
{ revalidateOnFocus: false },
);
const { data: hwaccel, error: hwaccelError } = useSWR<HwaccelRecommendation>(
"hardware/hwaccel",
{ revalidateOnFocus: false },
);
const stats = useAutoFrigateStats();
const rows = useMemo(() => {
if (!config) {
return undefined;
}
const startup = isStartupWindow(stats);
return {
detection: detectionRows({
models: config.models,
hardware,
probeFailed: !!probeError,
stats,
startup,
t,
}),
hwaccel: hwaccelRows({
config,
hwaccel,
hwaccelFailed: !!hwaccelError,
stats,
t,
}),
enrichments: enrichmentRows({
config,
hardware,
probeFailed: !!probeError,
stats,
startup,
t,
}),
cameras: cameraConnectionCells(config, stats),
};
}, [config, hardware, probeError, hwaccel, hwaccelError, stats, t]);
return { rows, statsLoaded: !!stats };
}
+192
View File
@@ -0,0 +1,192 @@
import { useCallback, useSyncExternalStore } from "react";
import axios from "axios";
import useSWR, { useSWRConfig } from "swr";
import type { TestResult } from "@/types/cameraWizard";
import type { FrigateConfig } from "@/types/frigateConfig";
import { ffprobeToTestResult, type FfprobeEntry } from "@/utils/streamIssues";
import { activeCameras } from "@/utils/health";
export type CameraStreamCheck = {
/** whole-camera failure (request error or timeout) */
error?: string;
/** one entry per config input, in config order */
streams: TestResult[];
};
export type StreamCheckResults = {
checkedAt: number;
byCamera: Record<string, CameraStreamCheck>;
};
type HealthChecksState = {
stream: {
results?: StreamCheckResults;
/** cameras still being probed in the current run */
pending: string[];
/** camera count of the current run */
total: number;
};
hardware: {
rechecking: boolean;
/** last on-demand probe; absent means the startup probe is current */
probedAt?: number;
};
};
// The tab bar button and both panes read this, so it lives outside React
// and survives tab switches until reload. SWR is not used because a null
// fetcher falls back to the global one and would GET /api/health/...
let state: HealthChecksState = {
stream: { pending: [], total: 0 },
hardware: { rechecking: false },
};
const listeners = new Set<() => void>();
let streamRunning = false;
function subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function getState() {
return state;
}
function update(patch: (current: HealthChecksState) => HealthChecksState) {
state = patch(state);
listeners.forEach((listener) => listener());
}
const CONCURRENCY = 2;
// the backend probes each input with a 6 s timeout plus one retry
const TIMEOUT_PER_INPUT_MS = 12_000;
const TIMEOUT_BASE_MS = 5_000;
async function probeCamera(
name: string,
inputs: number,
): Promise<CameraStreamCheck> {
try {
const response = await axios.get("ffprobe", {
params: { paths: `camera:${name}`, detailed: true },
timeout: TIMEOUT_BASE_MS + TIMEOUT_PER_INPUT_MS * Math.max(inputs, 1),
});
const entries: FfprobeEntry[] = Array.isArray(response.data)
? response.data
: [];
return { streams: entries.map(ffprobeToTestResult) };
} catch (error) {
const axiosError = error as {
response?: { data?: { message?: string } };
message?: string;
};
return {
error:
axiosError.response?.data?.message ||
axiosError.message ||
"Connection failed",
streams: [],
};
}
}
async function runStreamChecks(config: FrigateConfig) {
if (streamRunning) {
return;
}
streamRunning = true;
const cameras = activeCameras(config);
const names = cameras.map((camera) => camera.name);
update((s) => ({
...s,
stream: { ...s.stream, pending: names, total: names.length },
}));
const byCamera: Record<string, CameraStreamCheck> = {};
const queue = [...cameras];
const worker = async () => {
while (queue.length > 0) {
const camera = queue.shift();
if (!camera) {
return;
}
byCamera[camera.name] = await probeCamera(
camera.name,
camera.ffmpeg.inputs.length,
);
update((s) => ({
...s,
stream: {
...s.stream,
pending: s.stream.pending.filter((c) => c !== camera.name),
},
}));
}
};
try {
await Promise.all(
Array.from({ length: Math.min(CONCURRENCY, cameras.length) }, worker),
);
update((s) => ({
...s,
stream: {
...s.stream,
results: { checkedAt: Date.now() / 1000, byCamera },
},
}));
} finally {
streamRunning = false;
}
}
export function useHealthChecks() {
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const { mutate } = useSWRConfig();
const current = useSyncExternalStore(subscribe, getState);
const run = useCallback(() => {
if (config) {
return runStreamChecks(config);
}
}, [config]);
const recheck = useCallback(async () => {
if (state.hardware.rechecking) {
return;
}
update((s) => ({ ...s, hardware: { ...s.hardware, rechecking: true } }));
try {
await axios.get("hardware/probe", { params: { refresh: true } });
await Promise.all([mutate("hardware/probe"), mutate("hardware/hwaccel")]);
update((s) => ({
...s,
hardware: { rechecking: false, probedAt: Date.now() / 1000 },
}));
} catch {
update((s) => ({ ...s, hardware: { ...s.hardware, rechecking: false } }));
}
}, [mutate]);
const runAll = useCallback(
() => Promise.all([recheck(), run()]),
[recheck, run],
);
return {
stream: {
...current.stream,
running: current.stream.pending.length > 0,
run,
},
hardware: { ...current.hardware, recheck },
runAll,
ready: !!config,
};
}
+85 -55
View File
@@ -4,7 +4,7 @@ import {
CameraFfmpegThreshold,
InferenceThreshold,
} from "@/types/graph";
import { FrigateStats, PotentialProblem } from "@/types/stats";
import { FrigateStats, PotentialProblem, ProblemSeverity } from "@/types/stats";
import { useMemo } from "react";
import useSWR from "swr";
import useDeepMemo from "./use-deep-memo";
@@ -15,6 +15,22 @@ import { useIsAdmin } from "./use-is-admin";
import { useTranslation } from "react-i18next";
// the status bar has always rendered these exact classes; keep them byte for
// byte so its output does not change
const SEVERITY_COLOR: Record<ProblemSeverity, string> = {
error: "text-danger",
warning: "text-orange-400",
info: "text-selected",
};
function problem(
severity: ProblemSeverity,
text: string,
relevantLink?: string,
): PotentialProblem {
return { text, severity, color: SEVERITY_COLOR[severity], relevantLink };
}
export default function useStats(stats: FrigateStats | undefined) {
const { t } = useTranslation(["views/system"]);
const { data: config } = useSWR<FrigateConfig>("config");
@@ -48,36 +64,42 @@ export default function useStats(stats: FrigateStats | undefined) {
// check shm level
const shm = memoizedStats.service.storage["/dev/shm"];
if (shm?.total && shm?.min_shm && shm.total < shm.min_shm) {
problems.push({
text: t("stats.shmTooLow", {
total: shm.total,
min: shm.min_shm,
}),
color: "text-danger",
relevantLink: "/system#storage",
});
problems.push(
problem(
"error",
t("stats.shmTooLow", {
total: shm.total,
min: shm.min_shm,
}),
"/system#storage",
),
);
}
// check detectors for high inference speeds
Object.entries(memoizedStats["detectors"]).forEach(([key, det]) => {
if (det["inference_speed"] > InferenceThreshold.error) {
problems.push({
text: t("stats.detectIsVerySlow", {
detect: capitalizeFirstLetter(key),
speed: det["inference_speed"],
}),
color: "text-danger",
relevantLink: "/system#general",
});
problems.push(
problem(
"error",
t("stats.detectIsVerySlow", {
detect: capitalizeFirstLetter(key),
speed: det["inference_speed"],
}),
"/system#general",
),
);
} else if (det["inference_speed"] > InferenceThreshold.warning) {
problems.push({
text: t("stats.detectIsSlow", {
detect: capitalizeFirstLetter(key),
speed: det["inference_speed"],
}),
color: "text-orange-400",
relevantLink: "/system#general",
});
problems.push(
problem(
"warning",
t("stats.detectIsSlow", {
detect: capitalizeFirstLetter(key),
speed: det["inference_speed"],
}),
"/system#general",
),
);
}
});
@@ -94,13 +116,15 @@ export default function useStats(stats: FrigateStats | undefined) {
const cameraName = config.cameras?.[name]?.friendly_name ?? name;
if (config.cameras?.[name]?.enabled && cam["camera_fps"] == 0) {
problems.push({
text: t("stats.cameraIsOffline", {
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
}),
color: "text-danger",
relevantLink: "logs",
});
problems.push(
problem(
"error",
t("stats.cameraIsOffline", {
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
}),
"logs",
),
);
}
});
@@ -121,37 +145,43 @@ export default function useStats(stats: FrigateStats | undefined) {
const cameraName = config?.cameras?.[name]?.friendly_name ?? name;
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
problems.push({
text: t("stats.ffmpegHighCpuUsage", {
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
ffmpegAvg,
}),
color: "text-danger",
relevantLink: "/system#cameras",
});
problems.push(
problem(
"error",
t("stats.ffmpegHighCpuUsage", {
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
ffmpegAvg,
}),
"/system#cameras",
),
);
}
if (!isNaN(detectAvg) && detectAvg >= CameraDetectThreshold.error) {
problems.push({
text: t("stats.detectHighCpuUsage", {
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
detectAvg,
}),
color: "text-danger",
relevantLink: "/system#cameras",
});
problems.push(
problem(
"error",
t("stats.detectHighCpuUsage", {
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
detectAvg,
}),
"/system#cameras",
),
);
}
});
// Add message if debug replay is active
if (replayActive) {
problems.push({
text: t("stats.debugReplayActive", {
defaultValue: "Debug replay session is active",
}),
color: "text-selected",
relevantLink: "/replay",
});
problems.push(
problem(
"info",
t("stats.debugReplayActive", {
defaultValue: "Debug replay session is active",
}),
"/replay",
),
);
}
return problems;
+9 -1
View File
@@ -705,7 +705,9 @@ export default function Settings() {
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const [selectedCamera, setSelectedCamera] = useState<string>("");
const [selectedCamera, setSelectedCamera] = useState<string>(
() => searchParams.get("camera") ?? "",
);
// Get all camera overrides for the selected camera
const cameraOverrides = useAllCameraOverrides(config, selectedCamera);
@@ -1164,6 +1166,12 @@ export default function Settings() {
});
useSearchEffect("camera", (camera: string) => {
// the config drives the camera list, so keep the param until it loads
// rather than consuming it against an empty list
if (cameras.length === 0) {
return false;
}
const cameraNames = cameras.map((c) => c.name);
if (cameraNames.includes(camera)) {
setSelectedCamera(camera);
+1 -1
View File
@@ -122,7 +122,7 @@ function System() {
</ToggleGroup>
<div className="flex h-full items-center">
{lastUpdated && (
{lastUpdated && pageToggle != "health" && (
<div className="h-full content-center text-sm text-muted-foreground">
{t("lastRefreshed")}
<TimeAgo time={lastUpdated * 1000} dense />
+22
View File
@@ -222,3 +222,25 @@ export type OnvifProbeResponse = {
message?: string;
detail?: string;
};
/**
* Best-effort brand from a camera URL, so the wizard's brand-specific stream
* warnings can run for cameras that were not created by the wizard.
*/
export function inferCameraBrand(url: string): CameraBrand | undefined {
const lower = url.toLowerCase();
if (lower.includes("app=bcs") || lower.includes("/preview_")) {
return "reolink";
}
if (lower.includes("/cam/realmonitor")) {
return "dahua";
}
if (lower.includes("/streaming/channels/")) {
return "hikvision";
}
return undefined;
}
+9 -4
View File
@@ -28,13 +28,15 @@ export type BirdseyeMode =
export interface FaceRecognitionConfig {
enabled: boolean;
device?: string | null;
model_size: SearchModelSize;
unknown_score: number;
detection_threshold: number;
recognition_threshold: number;
}
export type SearchModel = "jinav1" | "jinav2";
// a GenAI provider name is also accepted by the backend
export type SearchModel = "jinav1" | "jinav2" | (string & NonNullable<unknown>);
export type SearchModelSize = "small" | "large";
export interface CameraConfig {
@@ -85,11 +87,11 @@ export interface CameraConfig {
};
ffmpeg: {
global_args: string[];
hwaccel_args: string;
hwaccel_args: string | string[];
input_args: string;
inputs: {
global_args: string[];
hwaccel_args: string[];
hwaccel_args: string | string[];
input_args: string;
path: string;
roles: string[];
@@ -448,6 +450,7 @@ export interface FrigateConfig {
audio_transcription: {
enabled: boolean;
device: "GPU" | "CPU";
};
auth: {
@@ -501,7 +504,7 @@ export interface FrigateConfig {
ffmpeg: {
global_args: string[];
hwaccel_args: string;
hwaccel_args: string | string[];
input_args: string;
output_args: {
detect: string[];
@@ -527,6 +530,7 @@ export interface FrigateConfig {
lpr: {
enabled: boolean;
device?: string | null;
};
logger: {
@@ -615,6 +619,7 @@ export interface FrigateConfig {
semantic_search: {
enabled: boolean;
device?: string | null;
reindex: boolean;
model: SearchModel;
model_size: SearchModelSize;
+4
View File
@@ -9,6 +9,8 @@ export type HealthSeverity = "error" | "warning" | "info";
*/
export type HealthProblem = {
id: string;
/** which source produced the row; part of the sort order */
source: "registry" | "live" | "config" | "stream";
severity: HealthSeverity;
/** camera name or other scope shown as a chip before the text */
scope?: string;
@@ -23,5 +25,7 @@ export type HealthProblem = {
docLink?: string;
/** absolute URL rendered as an external link (the update notice's release page) */
externalLink?: string;
/** render with a spinner instead of the severity icon (stream check running) */
pending?: boolean;
onDismiss?: () => void;
};
+4
View File
@@ -54,6 +54,7 @@ export type EmbeddingsStats = {
face_embedding_speed: number;
plate_recognition_speed: number;
text_embedding_speed: number;
devices?: Record<string, string>;
};
export type ExtraProcessStats = {
@@ -119,8 +120,11 @@ export type CameraStorage = {
};
};
export type ProblemSeverity = "error" | "warning" | "info";
export type PotentialProblem = {
text: string;
severity: ProblemSeverity;
color: string;
relevantLink?: string;
};
+204
View File
@@ -0,0 +1,204 @@
import type { TFunction } from "i18next";
import { sectionConfigs } from "@/components/config-form/sectionConfigs";
import type {
ConditionalMessage,
MessageConditionContext,
} from "@/components/config-form/section-configs/types";
import { settingsLink } from "@/components/config-form/sectionPages";
import type { ConfigSectionData } from "@/types/configForm";
import type { FrigateConfig } from "@/types/frigateConfig";
import type { HealthProblem } from "@/types/health";
import { getSectionConfig } from "@/utils/configUtil";
import { activeCameras } from "@/utils/health";
function healthMessages(
section: string,
level: "global" | "camera",
): ConditionalMessage[] {
const config = getSectionConfig(section, level);
return [...(config.messages ?? []), ...(config.fieldMessages ?? [])].filter(
(message) => message.health,
);
}
function isActive(
message: ConditionalMessage,
ctx: MessageConditionContext,
): boolean {
if (!message.condition(ctx)) {
return false;
}
return typeof message.health === "function" ? message.health(ctx) : true;
}
function toProblem(
message: ConditionalMessage,
section: string,
ctx: MessageConditionContext,
scope: string | undefined,
scopeIsCamera: boolean,
idSuffix: string,
t: TFunction,
): HealthProblem {
return {
id: `config:${section}:${message.key}:${idSuffix}`,
source: "config",
severity: message.severity,
scope,
scopeIsCamera,
text: t(message.messageKey, {
ns: "views/settings",
...(message.values ?? {}),
}),
docLink: message.docLink,
link: settingsLink(section, ctx.level, ctx.cameraName),
};
}
/**
* Evaluate every config message flagged for the Health tab against the saved,
* resolved config. The rules stay in the section configs, so the settings
* form and the Health tab can never disagree.
*/
/**
* Whether a camera section still carries the global section's values. Only
* keys the global block sets count: the resolved global detect leaves width
* and height null while every camera has numbers, so a full JSON comparison
* would never match.
*/
function inheritsGlobal(
cameraSection: Record<string, unknown>,
globalSection: Record<string, unknown>,
): boolean {
return Object.entries(globalSection).every(([key, globalValue]) => {
if (globalValue === null || globalValue === undefined) {
return true;
}
const cameraValue = cameraSection[key];
if (
typeof globalValue === "object" &&
!Array.isArray(globalValue) &&
typeof cameraValue === "object" &&
cameraValue !== null &&
!Array.isArray(cameraValue)
) {
return inheritsGlobal(
cameraValue as Record<string, unknown>,
globalValue as Record<string, unknown>,
);
}
return JSON.stringify(cameraValue) === JSON.stringify(globalValue);
});
}
export function evaluateConfigHealth(
config: FrigateConfig,
t: TFunction,
): HealthProblem[] {
const problems: HealthProblem[] = [];
const firedGlobally = new Set<string>();
const cameras = activeCameras(config);
const record = config as unknown as Record<string, unknown>;
Object.keys(sectionConfigs).forEach((section) => {
const globalMessages = healthMessages(section, "global");
if (globalMessages.length > 0) {
const sectionData = record[section];
// models is a list; every other section is one object
const items: {
formData: ConfigSectionData;
scope?: string;
idSuffix: string;
}[] =
section === "models" && Array.isArray(sectionData)
? sectionData.map((model, index) => ({
formData: model as ConfigSectionData,
scope: t(
`detectionModels.scenes.${(model as { scene?: string }).scene || "all"}`,
{ ns: "views/settings" },
),
idSuffix: `model${index}`,
}))
: [
{
formData: (sectionData ?? {}) as ConfigSectionData,
idSuffix: "global",
},
];
items.forEach(({ formData, scope, idSuffix }) => {
const ctx: MessageConditionContext = {
fullConfig: config,
level: "global",
formData,
};
globalMessages
.filter((message) => isActive(message, ctx))
.forEach((message) => {
const problem = toProblem(
message,
section,
ctx,
scope,
false,
idSuffix,
t,
);
firedGlobally.add(`${section}:${message.key}`);
problems.push(problem);
});
});
}
const cameraMessages = healthMessages(section, "camera");
if (cameraMessages.length > 0) {
const globalSection = (record[section] ?? {}) as Record<string, unknown>;
cameras.forEach((camera) => {
const cameraRecord = camera as unknown as Record<string, unknown>;
const sectionData = cameraRecord[section] ?? {};
// cameras inherit global values, so a problem the global row already
// states would repeat once per camera; a camera that overrides the
// section keeps its own row and link
const inherited = inheritsGlobal(
sectionData as Record<string, unknown>,
globalSection,
);
const ctx: MessageConditionContext = {
fullConfig: config,
fullCameraConfig: camera,
level: "camera",
cameraName: camera.name,
formData: sectionData as ConfigSectionData,
};
cameraMessages
.filter((message) => isActive(message, ctx))
.forEach((message) => {
const problem = toProblem(
message,
section,
ctx,
camera.name,
true,
camera.name,
t,
);
if (
!(inherited && firedGlobally.has(`${section}:${message.key}`))
) {
problems.push(problem);
}
});
});
}
});
return problems;
}
+779
View File
@@ -0,0 +1,779 @@
import type { TFunction } from "i18next";
import type {
DetectionHardware,
HwaccelRecommendation,
} from "@/types/hardware";
import type {
CameraConfig,
DetectionModelConfig,
FrigateConfig,
} from "@/types/frigateConfig";
import type { FrigateStats, GpuVendor } from "@/types/stats";
import { InferenceThreshold } from "@/types/graph";
import { summarizeDevices } from "@/utils/detectionHardware";
import { isReplayCamera } from "@/utils/cameraUtil";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
export type HealthState = "ok" | "warning" | "error" | "unknown";
export type HardwareRow = {
id: string;
state: HealthState;
label: string;
/** muted text on the label line, what is actually running */
detail?: string;
/** reason line under the label, colored by state */
message?: string;
};
/** seconds after startup during which stats-based rules report unknown */
export const STARTUP_WINDOW_S = 120;
// ---------------------------------------------------------------- detection
/**
* Detector runner names exactly as the backend's runner_names() builds them:
* every model's devices in config order, first occurrence is the raw device
* string, the Nth repeat is "raw#N".
*/
export function runnerNames(models: DetectionModelConfig[]): string[] {
const counts = new Map<string, number>();
const names: string[] = [];
models.forEach((model) => {
model.devices.forEach((raw) => {
const count = (counts.get(raw) ?? 0) + 1;
counts.set(raw, count);
names.push(count === 1 ? raw : `${raw}#${count}`);
});
});
return names;
}
/** detectors the probe reports; anything else cannot be checked for presence */
export const PROBED_DETECTORS = new Set([
"cpu",
"edgetpu",
"hailo8l",
"memryx",
"openvino",
"onnx",
"tensorrt",
"rknn",
"axengine",
"synaptics",
]);
/** detectors that fall back to the CPU when no accelerator is present */
const CPU_FALLBACK_DETECTORS = new Set(["onnx", "openvino"]);
export type DevicePresence = "present" | "unverified" | "absent";
/**
* Whether a configured device string was found by the hardware probe.
* "unverified" means the detector's hardware is present but the probe does
* not enumerate this particular device (openvino:AUTO, rknn:0), so it must
* not be reported as missing.
*/
export function devicePresence(
device: string,
hardware: DetectionHardware[],
): DevicePresence {
const [detector, ...rest] = device.split(":");
const devicePart = rest.join(":");
if (detector === "cpu" || devicePart.toUpperCase() === "CPU") {
return "present";
}
if (!PROBED_DETECTORS.has(detector)) {
return "unverified";
}
const entries = hardware.filter((entry) => entry.detector === detector);
const generic = devicePart === "" || devicePart.toUpperCase() === "AUTO";
if (entries.length === 0) {
// a bare onnx or openvino runs on the CPU when nothing is attached, so
// an empty probe is not proof of missing hardware for those
return generic && CPU_FALLBACK_DETECTORS.has(detector)
? "unverified"
: "absent";
}
if (generic) {
return "present";
}
const unitMatch = entries.some((entry) =>
entry.units.some(
(unit) =>
unit.device === device ||
unit.device.startsWith(`${device}:`) ||
unit.device.startsWith(`${device}.`),
),
);
return unitMatch ? "present" : "unverified";
}
type DetectionArgs = {
models: DetectionModelConfig[];
hardware: DetectionHardware[] | undefined;
probeFailed: boolean;
stats: FrigateStats | undefined;
startup: boolean;
t: TFunction;
};
export function detectionRows({
models,
hardware,
probeFailed,
stats,
startup,
t,
}: DetectionArgs): HardwareRow[] {
const names = runnerNames(models);
let cursor = 0;
return models.map((model, index) => {
const modelRunners = names.slice(cursor, cursor + model.devices.length);
cursor += model.devices.length;
const label = t(`detectionModels.scenes.${model.scene || "all"}`, {
ns: "views/settings",
});
const id = `detection:${index}`;
const detail = probeFailed
? t("health.hardware.probeUnavailable", {
ns: "views/system",
})
: summarizeDevices(hardware ?? [], model.devices);
if (!probeFailed && hardware) {
const presence = new Map(
model.devices.map((device) => [
device,
devicePresence(device, hardware),
]),
);
const missing = [...presence]
.filter(([, state]) => state === "absent")
.map(([device]) => device);
if (missing.length > 0) {
return {
id,
state: "error",
label,
detail,
message: t("health.hardware.deviceNotFound", {
ns: "views/system",
devices: missing.join(", "),
}),
};
}
// unverified devices are skipped by the presence rule; the runtime
// rules below still decide the row
}
if (startup || !stats) {
return {
id,
state: "unknown",
label,
detail,
message: t("health.hardware.justStarted", {
ns: "views/system",
}),
};
}
const missingRunner = modelRunners.find((name) => !stats.detectors[name]);
if (missingRunner) {
return {
id,
state: "error",
label,
detail,
message: t("health.hardware.detectorNotRunning", {
ns: "views/system",
}),
};
}
const slowest = Math.max(
...modelRunners.map((name) => stats.detectors[name].inference_speed),
);
if (slowest > InferenceThreshold.error) {
return {
id,
state: "error",
label,
detail,
message: t("health.hardware.inferenceVerySlow", {
ns: "views/system",
speed: slowest,
}),
};
}
if (slowest > InferenceThreshold.warning) {
return {
id,
state: "warning",
label,
detail,
message: t("health.hardware.inferenceSlow", {
ns: "views/system",
speed: slowest,
}),
};
}
return {
id,
state: "ok",
label,
detail: [
detail,
t("health.hardware.inferenceMs", {
ns: "views/system",
speed: slowest,
}),
]
.filter(Boolean)
.join(" · "),
};
});
}
// ------------------------------------------------------------------ hwaccel
export type HwaccelFamilyKey =
| "nvidia"
| "vaapi"
| "intel-qsv"
| "rkmpp"
| "jetson"
| "rpi";
export type HwaccelClass =
| { kind: "none" }
| { kind: "custom" }
| { kind: "preset"; family: HwaccelFamilyKey };
const PRESET_FAMILIES: [string, HwaccelFamilyKey][] = [
["preset-nvidia", "nvidia"],
["preset-vaapi", "vaapi"],
["preset-intel-qsv", "intel-qsv"],
["preset-rk", "rkmpp"],
["preset-jetson", "jetson"],
["preset-rpi", "rpi"],
];
export function hwaccelFamily(value: string | string[]): HwaccelClass {
if (Array.isArray(value)) {
return value.length === 0 ? { kind: "none" } : { kind: "custom" };
}
// the backend resolves global and camera auto at startup; a literal auto
// left on an input means no hardware decoding for it at runtime
if (value === "" || value === "auto") {
return { kind: "none" };
}
const match = PRESET_FAMILIES.find(([prefix]) => value.startsWith(prefix));
return match ? { kind: "preset", family: match[1] } : { kind: "custom" };
}
const FAMILY_VENDORS: Record<HwaccelFamilyKey, GpuVendor[]> = {
nvidia: ["nvidia"],
jetson: ["nvidia"],
"intel-qsv": ["intel"],
vaapi: ["intel", "amd"],
rkmpp: ["rockchip"],
rpi: ["rpi"],
};
function decoderUsage(
family: HwaccelFamilyKey,
stats: FrigateStats | undefined,
): string | undefined {
if (!stats?.gpu_usages) {
return undefined;
}
const entry = Object.values(stats.gpu_usages).find(
(gpu) =>
gpu.vendor && FAMILY_VENDORS[family].includes(gpu.vendor) && gpu.dec,
);
return entry?.dec;
}
function valueKey(value: string | string[]): string {
return Array.isArray(value) ? JSON.stringify(value) : value;
}
type HwaccelArgs = {
config: FrigateConfig;
hwaccel: HwaccelRecommendation | undefined;
hwaccelFailed: boolean;
stats: FrigateStats | undefined;
t: TFunction;
};
export function hwaccelRows({
config,
hwaccel,
hwaccelFailed,
stats,
t,
}: HwaccelArgs): HardwareRow[] {
const cameras = activeCameras(config);
const camerasByValue = new Map<
string,
{ value: string | string[]; cameras: string[] }
>();
cameras.forEach((camera) => {
const values: (string | string[])[] = [camera.ffmpeg.hwaccel_args ?? ""];
camera.ffmpeg.inputs.forEach((input) => {
if (input.hwaccel_args && input.hwaccel_args.length > 0) {
values.push(input.hwaccel_args);
}
});
values.forEach((value) => {
const key = valueKey(value);
const entry = camerasByValue.get(key) ?? { value, cameras: [] };
if (!entry.cameras.includes(camera.name)) {
entry.cameras.push(camera.name);
}
camerasByValue.set(key, entry);
});
});
const familyName = (family: HwaccelFamilyKey | "none") =>
t(`setupWizard.hwaccel.families.${family}`, { ns: "views/setup" });
return [...camerasByValue.entries()].map(([key, entry]) => {
const id = `hwaccel:${key}`;
const cameraList =
entry.cameras.length === cameras.length
? t("health.hardware.allCameras", {
ns: "views/system",
})
: entry.cameras
.map((name) => resolveCameraName(config, name))
.join(", ");
const classified = hwaccelFamily(entry.value);
if (hwaccelFailed) {
return {
id,
state: "unknown",
label:
classified.kind === "preset" ? familyName(classified.family) : key,
detail: cameraList,
message: t("health.hardware.probeUnavailable", {
ns: "views/system",
}),
};
}
if (classified.kind === "custom") {
return {
id,
state: "unknown",
label: t("health.hardware.customArgs", {
ns: "views/system",
}),
detail: cameraList,
message: t("health.hardware.customArgsNotVerified", {
ns: "views/system",
}),
};
}
const available = hwaccel?.available ?? [];
if (classified.kind === "none") {
if (available.length > 0 && hwaccel?.recommended) {
return {
id,
state: "warning",
label: familyName("none"),
detail: cameraList,
message: t("health.hardware.hwaccelNotConfigured", {
ns: "views/system",
family: familyName(hwaccel.recommended as HwaccelFamilyKey),
}),
};
}
return { id, state: "ok", label: familyName("none"), detail: cameraList };
}
const label = familyName(classified.family);
const present = available.some(
(family) => family.key === classified.family,
);
// a warning, not an error: the resolved config comes from go2rtc's
// answer while `available` comes from the device probe, and the two
// disagree on whole platform families
if (!present) {
return {
id,
state: "warning",
label,
detail: cameraList,
message: t("health.hardware.hwaccelHardwareMissing", {
ns: "views/system",
family: label,
}),
};
}
const dec = decoderUsage(classified.family, stats);
const detail = dec
? `${cameraList} · ${t("health.hardware.decoderUsage", {
ns: "views/system",
usage: dec,
})}`
: cameraList;
return { id, state: "ok", label, detail };
});
}
// -------------------------------------------------------------- enrichments
const ANY_ACCELERATOR = [
"onnx:nvidia",
"onnx:amd",
"openvino:GPU",
"openvino:NPU",
"rknn",
"tensorrt",
];
/**
* Probe keys that satisfy a requested device string. AUTO and the implicit
* defaults accept any accelerator; an explicit override must match its own
* hardware. Undefined means the string is not one we can check.
*/
export function acceleratorKeysFor(
requested: string,
nvidiaOnly: boolean,
): string[] | undefined {
if (nvidiaOnly) {
return ["onnx:nvidia"];
}
const upper = requested.toUpperCase();
if (upper === "AUTO") {
return ANY_ACCELERATOR;
}
// ONNX Runtime puts a plain GPU request on whichever GPU it has; only an
// indexed GPU.n names OpenVINO specifically
if (upper === "GPU") {
return ["openvino:GPU", "onnx:nvidia", "onnx:amd"];
}
if (/^GPU\.\d+$/.test(upper)) {
return ["openvino:GPU"];
}
if (upper === "NPU") {
return ["openvino:NPU"];
}
if (upper.startsWith("CUDA") || upper.startsWith("TENSORRT")) {
return ["onnx:nvidia"];
}
if (upper.startsWith("ROCM") || upper.startsWith("MIGRAPHX")) {
return ["onnx:amd"];
}
return undefined;
}
function acceleratorPresent(
hardware: DetectionHardware[] | undefined,
keys: string[],
): boolean {
return (hardware ?? []).some((entry) => keys.includes(entry.key));
}
type EnrichmentSpec = {
id: "semantic_search" | "face_recognition" | "lpr" | "audio_transcription";
enabled: boolean;
/** what the config asks for, after the backend's own defaults */
requested: string;
explicit: boolean;
remote: boolean;
nvidiaOnly: boolean;
/** runtime device is not reported for this enrichment in v1 */
presenceOnly: boolean;
};
function enrichmentSpecs(config: FrigateConfig): EnrichmentSpec[] {
const ss = config.semantic_search;
const anyCameraTranscribes = Object.values(config.cameras).some(
(camera) => camera.audio_transcription?.enabled,
);
return [
{
id: "semantic_search",
enabled: ss.enabled,
requested: ss.device ?? (ss.model_size === "large" ? "GPU" : "CPU"),
explicit: ss.device != null,
remote: ss.model !== "jinav1" && ss.model !== "jinav2",
nvidiaOnly: false,
presenceOnly: false,
},
{
id: "face_recognition",
enabled: config.face_recognition.enabled,
requested: config.face_recognition.device ?? "GPU",
explicit: config.face_recognition.device != null,
remote: false,
nvidiaOnly: false,
presenceOnly: false,
},
{
id: "lpr",
enabled: config.lpr.enabled,
requested: config.lpr.device ?? "AUTO",
explicit: config.lpr.device != null,
remote: false,
nvidiaOnly: false,
presenceOnly: false,
},
{
id: "audio_transcription",
enabled: config.audio_transcription.enabled || anyCameraTranscribes,
requested: config.audio_transcription.device ?? "CPU",
explicit: true,
remote: false,
nvidiaOnly: true,
presenceOnly: true,
},
];
}
type EnrichmentArgs = {
config: FrigateConfig;
hardware: DetectionHardware[] | undefined;
probeFailed: boolean;
stats: FrigateStats | undefined;
startup: boolean;
t: TFunction;
};
export function enrichmentRows({
config,
hardware,
probeFailed,
stats,
startup,
t,
}: EnrichmentArgs): HardwareRow[] {
return enrichmentSpecs(config)
.filter((spec) => spec.enabled)
.map((spec) => {
const id = `enrichment:${spec.id}`;
const label = t(`health.hardware.enrichments.${spec.id}`, {
ns: "views/system",
});
if (spec.remote) {
return {
id,
state: "ok",
label,
detail: t("health.hardware.remoteProvider", {
ns: "views/system",
}),
};
}
if (spec.requested.toUpperCase() === "CPU") {
return { id, state: "ok", label, detail: "CPU" };
}
if (probeFailed) {
return {
id,
state: "unknown",
label,
message: t("health.hardware.probeUnavailable", {
ns: "views/system",
}),
};
}
// implicit defaults (GPU for face recognition and large semantic
// search) accept any accelerator; only an explicit override is matched
// against its own hardware
const keys = spec.explicit
? acceleratorKeysFor(spec.requested, spec.nvidiaOnly)
: spec.nvidiaOnly
? ["onnx:nvidia"]
: ANY_ACCELERATOR;
if (!keys) {
return {
id,
state: "unknown",
label,
message: t("health.hardware.unrecognizedDevice", {
ns: "views/system",
}),
};
}
const present = acceleratorPresent(hardware, keys);
const runtime = startup
? undefined
: stats?.embeddings?.devices?.[spec.id];
const runtimeIsCpu = !!runtime && runtime.toUpperCase().includes("CPU");
// a model that reports an accelerator is proof enough, whatever the
// probe keys say
if (runtime && !runtimeIsCpu) {
return { id, state: "ok", label, detail: runtime };
}
if (
spec.explicit &&
spec.requested.toUpperCase() !== "AUTO" &&
hardware &&
!present
) {
return {
id,
state: "error",
label,
message: t("health.hardware.acceleratorMissing", {
ns: "views/system",
device: spec.requested,
}),
};
}
if (spec.presenceOnly) {
return { id, state: "ok", label, detail: spec.requested };
}
if (!runtime) {
return {
id,
state: "unknown",
label,
message: t("health.hardware.modelNotRunYet", {
ns: "views/system",
}),
};
}
if (
runtimeIsCpu &&
present &&
spec.explicit &&
spec.requested.toUpperCase() !== "AUTO"
) {
return {
id,
state: "error",
label,
detail: "CPU",
message: t("health.hardware.fellBackToCpu", {
ns: "views/system",
device: spec.requested,
}),
};
}
if (runtimeIsCpu && present) {
return {
id,
state: "warning",
label,
detail: "CPU",
message: t("health.hardware.cpuDespiteAccelerator", {
ns: "views/system",
}),
};
}
return { id, state: "ok", label, detail: runtimeIsCpu ? "CPU" : runtime };
});
}
// ------------------------------------------------------- camera connections
export type CameraConnectionCell = {
camera: string;
quality: "excellent" | "fair" | "poor" | "unusable";
cameraFps: number;
expectedFps: number;
reconnects: number;
stalls: number;
};
/** enabled, non-replay cameras whose latest connection is not excellent */
export function cameraConnectionCells(
config: FrigateConfig,
stats: FrigateStats | undefined,
): CameraConnectionCell[] {
if (!stats) {
return [];
}
return activeCameras(config)
.map((camera): CameraConnectionCell | undefined => {
const cam = stats.cameras[camera.name];
if (
!cam ||
!cam.connection_quality ||
cam.connection_quality === "excellent"
) {
return undefined;
}
return {
camera: camera.name,
quality: cam.connection_quality,
cameraFps: cam.camera_fps,
expectedFps: cam.expected_fps ?? 0,
reconnects: cam.reconnects_last_hour ?? 0,
stalls: cam.stalls_last_hour ?? 0,
};
})
.filter((cell): cell is CameraConnectionCell => cell !== undefined);
}
// --------------------------------------------------------------- helpers
export function activeCameras(config: FrigateConfig): CameraConfig[] {
return Object.values(config.cameras)
.filter((camera) => camera.enabled && !isReplayCamera(camera.name))
.sort((a, b) => a.ui.order - b.ui.order);
}
export function isStartupWindow(stats: FrigateStats | undefined): boolean {
return !!stats && stats.service.uptime < STARTUP_WINDOW_S;
}
+14
View File
@@ -0,0 +1,14 @@
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;
/** errors first, then warnings, then info; within a severity by source, then scope */
export function sortHealthProblems(problems: HealthProblem[]): HealthProblem[] {
return [...problems].sort(
(a, b) =>
SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] ||
SOURCE_ORDER[a.source] - SOURCE_ORDER[b.source] ||
(a.scope ?? "").localeCompare(b.scope ?? ""),
);
}
+154
View File
@@ -0,0 +1,154 @@
import type { TFunction } from "i18next";
import type { StreamCheckResults } from "@/hooks/use-health-checks";
import type { StreamRole } from "@/types/cameraWizard";
import { inferCameraBrand } from "@/types/cameraWizard";
import type { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import type { HealthProblem } from "@/types/health";
import {
getStreamIssues,
lastErrorLine,
resolveRestreamSource,
type StreamIssue,
} from "@/utils/streamIssues";
// rules the add camera wizard shows that do not belong on the Health tab
const WIZARD_ONLY_RULES = new Set(["restream", "reolink-rtsp", "reolink-http"]);
/**
* Whether the record output keeps the camera's audio codec. The default
* preset transcodes to AAC, so a non-AAC source only matters when the args
* copy audio through.
*/
function recordCopiesAudio(camera: CameraConfig): boolean {
const args = camera.ffmpeg.output_args?.record;
const text = Array.isArray(args) ? args.join(" ") : (args ?? "");
return (
text === "preset-record-generic-audio-copy" ||
/(^|\s)-(c:a|acodec)\s+copy(\s|$)/.test(text)
);
}
export type StreamHealth = {
problems: HealthProblem[];
/** enabled cameras the results cover */
checked: number;
/** cameras with no stream problem */
clean: number;
};
/** Turn one stream check run into notice rows plus the counts a summary needs. */
export function streamHealth(
config: FrigateConfig,
results: StreamCheckResults | undefined,
t: TFunction,
): StreamHealth {
const problems: HealthProblem[] = [];
let checked = 0;
let clean = 0;
if (!results) {
return { problems, checked, clean };
}
Object.entries(results.byCamera).forEach(([name, check]) => {
const camera = config.cameras[name];
if (!camera || !camera.enabled) {
return;
}
checked += 1;
const link = `/settings?page=cameraFfmpeg&camera=${encodeURIComponent(name)}`;
const copiesAudio = recordCopiesAudio(camera);
let flagged = false;
if (check.error) {
problems.push({
id: `stream:${name}:error`,
source: "stream",
severity: "error",
scope: name,
scopeIsCamera: true,
text: t("health.notices.cameraProbeFailed", {
ns: "views/system",
error: check.error,
}),
link,
});
return;
}
camera.ffmpeg.inputs.forEach((input, index) => {
const result = check.streams[index];
const streamNumber = index + 1;
if (!result || !result.success) {
flagged = true;
problems.push({
id: `stream:${name}:${index}:probe`,
source: "stream",
severity: "error",
scope: name,
scopeIsCamera: true,
text: t("health.notices.streamProbeFailed", {
ns: "views/system",
index: streamNumber,
error: lastErrorLine(result?.error),
}),
link,
});
return;
}
const restream = resolveRestreamSource(
input.path,
config.go2rtc?.streams,
);
const url = restream?.url ?? input.path;
// a restreamed input's probe describes go2rtc's output, and a missing
// AAC track is fixed on the go2rtc stream, not on the camera
const streamLink = restream ? "/settings?page=systemGo2rtcStreams" : link;
const prefixKey = restream
? "health.notices.streamPrefixRestream"
: "health.notices.streamPrefix";
getStreamIssues(
{
url,
roles: input.roles as StreamRole[],
brand: inferCameraBrand(url),
useFfmpeg: restream?.useFfmpeg,
restream: !!restream,
testResult: result,
},
t,
)
.filter(
(issue): issue is StreamIssue & { type: "warning" | "error" } =>
issue.type !== "good" &&
!WIZARD_ONLY_RULES.has(issue.rule) &&
(issue.rule !== "audio-codec-record" || copiesAudio),
)
.forEach((issue) => {
flagged = true;
problems.push({
id: `stream:${name}:${index}:${issue.rule}`,
source: "stream",
severity: issue.type,
scope: name,
scopeIsCamera: true,
text: t(prefixKey, {
ns: "views/system",
index: streamNumber,
message: issue.message,
}),
link: streamLink,
});
});
});
if (!flagged) {
clean += 1;
}
});
return { problems, checked, clean };
}
+298
View File
@@ -0,0 +1,298 @@
import type { TFunction } from "i18next";
import { parseRestreamStreamName } from "@/components/config-form/theme/fields/streamSource";
import type { CameraBrand, StreamRole, TestResult } from "@/types/cameraWizard";
export type StreamIssue = {
type: "good" | "warning" | "error";
message: string;
/** stable key for the rule that fired, for filtering and tests */
rule: string;
};
export type StreamIssueInput = {
url: string;
roles: StreamRole[];
brand?: CameraBrand;
useFfmpeg?: boolean;
restream?: boolean;
testResult?: TestResult;
};
type ProbeStream = {
codec_type?: string;
codec_name?: string;
width?: number;
height?: number;
avg_frame_rate?: string;
};
export type FfprobeEntry = {
return_code?: number;
stdout?: { streams?: ProbeStream[] } | string;
/** the backend sends a list of non-empty lines on failure */
stderr?: string | string[];
};
function errorText(stderr: string | string[] | undefined): string {
const text = Array.isArray(stderr) ? stderr.join("\n") : stderr;
return text?.trim() || "Unknown error";
}
/** The human-readable end of an ffprobe error; the first lines are plumbing. */
export function lastErrorLine(error: string | undefined): string {
const lines = (error ?? "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
return lines[lines.length - 1] ?? "";
}
/** Parse one entry of the ffprobe API response the way the wizard does. */
export function ffprobeToTestResult(
entry: FfprobeEntry | undefined,
): TestResult {
if (!entry || entry.return_code !== 0 || typeof entry.stdout !== "object") {
return { success: false, error: errorText(entry?.stderr) };
}
const streams = entry.stdout?.streams ?? [];
const videoStream = streams.find(
(s) =>
s.codec_type === "video" ||
s.codec_name?.includes("h264") ||
s.codec_name?.includes("h265"),
);
const audioStream = streams.find(
(s) =>
s.codec_type === "audio" ||
s.codec_name?.includes("aac") ||
s.codec_name?.includes("mp3"),
);
const resolution = videoStream
? `${videoStream.width}x${videoStream.height}`
: undefined;
const fps = videoStream?.avg_frame_rate
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
parseFloat(videoStream.avg_frame_rate.split("/")[1])
: undefined;
return {
success: true,
resolution,
videoCodec: videoStream?.codec_name,
audioCodec: audioStream?.codec_name,
fps: fps && !isNaN(fps) ? fps : undefined,
};
}
/** The wizard's Stream Validation rules, unchanged, over plain input. */
export function getStreamIssues(
input: StreamIssueInput,
t: TFunction,
): StreamIssue[] {
const result: StreamIssue[] = [];
const { roles, testResult } = input;
if (input.brand === "reolink") {
const streamUrl = input.url.toLowerCase();
if (streamUrl.startsWith("rtsp://")) {
result.push({
type: "warning",
rule: "reolink-rtsp",
message: t("cameraWizard.step4.issues.brands.reolink-rtsp", {
ns: "views/settings",
}),
});
}
if (streamUrl.startsWith("http://") && !input.useFfmpeg) {
result.push({
type: "warning",
rule: "reolink-http",
message: t("cameraWizard.step4.issues.brands.reolink-http", {
ns: "views/settings",
}),
});
}
}
if (testResult?.videoCodec) {
const videoCodec = testResult.videoCodec.toLowerCase();
if (["h264", "h265", "hevc"].includes(videoCodec)) {
result.push({
type: "good",
rule: "video-codec",
message: t("cameraWizard.step4.issues.videoCodecGood", {
ns: "views/settings",
codec: testResult.videoCodec,
}),
});
}
}
if (roles.includes("record")) {
if (testResult?.audioCodec) {
const audioCodec = testResult.audioCodec.toLowerCase();
if (audioCodec === "aac") {
result.push({
type: "good",
rule: "audio-codec",
message: t("cameraWizard.step4.issues.audioCodecGood", {
ns: "views/settings",
codec: testResult.audioCodec,
}),
});
} else {
result.push({
type: "error",
rule: "audio-codec-record",
message: t("cameraWizard.step4.issues.audioCodecRecordError", {
ns: "views/settings",
}),
});
}
} else {
result.push({
type: "warning",
rule: "no-audio",
message: t("cameraWizard.step4.issues.noAudioWarning", {
ns: "views/settings",
}),
});
}
}
if (roles.includes("audio") && !testResult?.audioCodec) {
result.push({
type: "error",
rule: "audio-required",
message: t("cameraWizard.step4.issues.audioCodecRequired", {
ns: "views/settings",
}),
});
}
if (roles.includes("record") && input.restream) {
result.push({
type: "warning",
rule: "restream",
message: t("cameraWizard.step4.issues.restreamingWarning", {
ns: "views/settings",
}),
});
}
if (roles.includes("detect") && testResult) {
const probedResolution = testResult.resolution;
let probedWidth = 0;
let probedHeight = 0;
if (probedResolution) {
const [w, h] = probedResolution.split("x").map(Number);
if (!isNaN(w) && !isNaN(h)) {
probedWidth = w;
probedHeight = h;
}
}
if (probedWidth <= 0 || probedHeight <= 0) {
result.push({
type: "error",
rule: "resolution-unknown",
message: t("cameraWizard.step4.issues.resolutionUnknown", {
ns: "views/settings",
}),
});
} else {
const minDimension = Math.min(probedWidth, probedHeight);
const maxDimension = Math.max(probedWidth, probedHeight);
if (minDimension > 1080) {
result.push({
type: "warning",
rule: "resolution-high",
message: t("cameraWizard.step4.issues.resolutionHigh", {
ns: "views/settings",
resolution: probedResolution,
}),
});
} else if (maxDimension < 640) {
result.push({
type: "error",
rule: "resolution-low",
message: t("cameraWizard.step4.issues.resolutionLow", {
ns: "views/settings",
resolution: probedResolution,
}),
});
}
}
}
if (
input.brand === "dahua" &&
roles.includes("detect") &&
input.url.includes("subtype=1")
) {
result.push({
type: "warning",
rule: "dahua-substream",
message: t("cameraWizard.step4.issues.dahua.substreamWarning", {
ns: "views/settings",
}),
});
}
if (
input.brand === "hikvision" &&
roles.includes("detect") &&
input.url.includes("/102")
) {
result.push({
type: "warning",
rule: "hikvision-substream",
message: t("cameraWizard.step4.issues.hikvision.substreamWarning", {
ns: "views/settings",
}),
});
}
return result;
}
/**
* For an input that points at a go2rtc restream, find the camera URL behind
* it. Returns undefined when the path is not a restream or the stream is not
* in the go2rtc config. The /config response redacts credentials in these
* sources, so the URL is only good for pattern matching.
*/
export function resolveRestreamSource(
path: string,
streams: Record<string, string | string[]> | undefined,
): { url: string; useFfmpeg: boolean } | undefined {
const name = parseRestreamStreamName(path);
if (!name || !streams) {
return undefined;
}
const configured = streams[name];
const sources = Array.isArray(configured)
? configured
: configured
? [configured]
: [];
const source = sources.find((s) => !s.startsWith(`ffmpeg:${name}`));
if (!source) {
return undefined;
}
if (source.startsWith("ffmpeg:")) {
return {
url: source.slice("ffmpeg:".length).split("#")[0],
useFfmpeg: true,
};
}
return { url: source, useFfmpeg: false };
}
@@ -114,6 +114,11 @@ export default function EnrichmentMetrics({
}
Object.entries(stats.embeddings).forEach(([rawKey, stat]) => {
// embeddings.devices is a label map, not a metric series
if (typeof stat !== "number") {
return;
}
const key = rawKey.replaceAll("_", " ");
if (!(key in series)) {
const classificationIndex = rawKey.indexOf("_classification_");
+2
View File
@@ -1,9 +1,11 @@
import HardwarePane from "@/components/health/HardwarePane";
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 />
<HardwarePane />
</div>
);
}