mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 19:09:02 +03:00
Notices and status bar improvements (#24459)
* Notice and status bar improvements Status bar problems added in the same pass got the same `Date.now()` id and overwrote each other, so usually only one showed. Messages now fall back to their text as the id. The desktop status bar shows the most severe message with a count of the rest that opens a popover listing all of them, and the mobile drawer stacks them vertically instead of placing them side by side. Dismissing a notice hid it for good, so a detector that restarted again after a dismissal was never shown. Dismiss is replaced by acknowledge, which hides a notice until it happens again, and mute, which hides it permanently. Kinds that never repeat (config and stream checks, the update notice) can only be muted. `reopen_at_count` is removed since acknowledge covers the failed login case. * move camera CPU warnings to notices High ffmpeg and detect CPU warnings sat in the status bar with no way to dismiss them. They're now `ffmpeg_high_cpu` and `detect_high_cpu` notices, raised per episode by the same tracker as skipped detections. Also stop failed login attempts held from before an acknowledgement from reopening the notice. * fix mypy and handle missing cpu stats in notices
This commit is contained in:
@@ -49,7 +49,7 @@ export interface ApiMockOverrides {
|
||||
};
|
||||
users?: { username: string; role: string }[];
|
||||
notices?: unknown[];
|
||||
dismissedChecks?: unknown[];
|
||||
mutedChecks?: unknown[];
|
||||
/** camera name to the ffprobe entries returned for `paths=camera:<name>` */
|
||||
ffprobe?: Record<string, unknown[]>;
|
||||
}
|
||||
@@ -238,8 +238,8 @@ export class ApiMocker {
|
||||
await this.page.route("**/api/notices", (route) =>
|
||||
route.fulfill({ json: overrides?.notices ?? [] }),
|
||||
);
|
||||
await this.page.route("**/api/notices/dismissed_checks", (route) =>
|
||||
route.fulfill({ json: overrides?.dismissedChecks ?? [] }),
|
||||
await this.page.route("**/api/notices/muted_checks", (route) =>
|
||||
route.fulfill({ json: overrides?.mutedChecks ?? [] }),
|
||||
);
|
||||
|
||||
// Users. GET lists them; POST/PUT (create, password) just succeed, so
|
||||
|
||||
+258
-121
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Health tab tests -- MEDIUM tier.
|
||||
*
|
||||
* Default tab, notice list rendering, dismiss, empty state, update notice.
|
||||
* Default tab, notice list rendering, acknowledge and mute, empty state,
|
||||
* update notice.
|
||||
*/
|
||||
|
||||
import { test, expect } from "../fixtures/frigate-test";
|
||||
@@ -23,7 +24,9 @@ const ERROR_NOTICE = {
|
||||
first_seen: NOW - 600,
|
||||
last_seen: NOW,
|
||||
count: 2,
|
||||
dismissed_at: null,
|
||||
acknowledgeable: true,
|
||||
acknowledged_at: null,
|
||||
muted_at: null,
|
||||
};
|
||||
|
||||
const EVENT_NOTICE = {
|
||||
@@ -37,7 +40,9 @@ const EVENT_NOTICE = {
|
||||
first_seen: NOW - 7200,
|
||||
last_seen: NOW - 60,
|
||||
count: 3,
|
||||
dismissed_at: null,
|
||||
acknowledgeable: true,
|
||||
acknowledged_at: null,
|
||||
muted_at: null,
|
||||
};
|
||||
|
||||
test.describe("System — Health tab @medium", () => {
|
||||
@@ -63,54 +68,61 @@ test.describe("System — Health tab @medium", () => {
|
||||
"Downloading model.onnx for yolo failed: timeout",
|
||||
);
|
||||
await expect(rows.nth(0)).toContainText("2 times");
|
||||
await expect(
|
||||
rows.nth(0).getByRole("button", { name: "Dismiss" }),
|
||||
).toBeVisible();
|
||||
await expect(rows.nth(1)).toContainText("Detector ov was restarted");
|
||||
await expect(rows.nth(1)).toContainText("3 times");
|
||||
await expect(
|
||||
rows.nth(1).getByRole("button", { name: "Dismiss" }),
|
||||
).toBeVisible();
|
||||
for (const index of [0, 1]) {
|
||||
await expect(
|
||||
rows.nth(index).getByRole("button", { name: "Acknowledge" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
rows.nth(index).getByRole("button", { name: "Mute", exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("dismiss posts and removes the row", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [EVENT_NOTICE],
|
||||
for (const action of ["acknowledge", "mute"] as const) {
|
||||
test(`${action} posts and removes the row`, async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
notices: [EVENT_NOTICE],
|
||||
});
|
||||
|
||||
// the list shrinks after the POST so the refetch shows the row gone
|
||||
let hidden = false;
|
||||
await frigateApp.page.route("**/api/notices", (route) =>
|
||||
route.fulfill({ json: hidden ? [] : [EVENT_NOTICE] }),
|
||||
);
|
||||
await frigateApp.page.route(
|
||||
`**/api/notices/detector_stuck/${action}`,
|
||||
(route) => {
|
||||
hidden = true;
|
||||
return route.fulfill({ json: { success: true } });
|
||||
},
|
||||
);
|
||||
|
||||
await frigateApp.goto("/system#health");
|
||||
const request = frigateApp.page.waitForRequest(
|
||||
(req) =>
|
||||
req.url().includes(`/api/notices/detector_stuck/${action}`) &&
|
||||
req.method() === "POST",
|
||||
);
|
||||
await frigateApp.page
|
||||
.getByTestId("health-problem-notice:detector_stuck")
|
||||
.getByRole("button", {
|
||||
name: action === "mute" ? "Mute" : "Acknowledge",
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await request;
|
||||
|
||||
await expect(
|
||||
frigateApp.page.locator("[data-testid^='health-problem-']"),
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
await expect(
|
||||
frigateApp.page.getByText("Your Frigate installation is healthy"),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// the list shrinks after the dismiss so the refetch shows the row gone
|
||||
let dismissed = false;
|
||||
await frigateApp.page.route("**/api/notices", (route) =>
|
||||
route.fulfill({ json: dismissed ? [] : [EVENT_NOTICE] }),
|
||||
);
|
||||
await frigateApp.page.route(
|
||||
"**/api/notices/detector_stuck/dismiss",
|
||||
(route) => {
|
||||
dismissed = true;
|
||||
return route.fulfill({ json: { success: true } });
|
||||
},
|
||||
);
|
||||
|
||||
await frigateApp.goto("/system#health");
|
||||
const request = frigateApp.page.waitForRequest(
|
||||
(req) =>
|
||||
req.url().includes("/api/notices/detector_stuck/dismiss") &&
|
||||
req.method() === "POST",
|
||||
);
|
||||
await frigateApp.page
|
||||
.getByTestId("health-problem-notice:detector_stuck")
|
||||
.getByRole("button", { name: "Dismiss" })
|
||||
.click();
|
||||
await request;
|
||||
|
||||
await expect(
|
||||
frigateApp.page.locator("[data-testid^='health-problem-']"),
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
await expect(
|
||||
frigateApp.page.getByText("Your Frigate installation is healthy"),
|
||||
).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
test("empty state with no notices", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ stats: QUIET_STATS });
|
||||
@@ -140,7 +152,9 @@ test.describe("System — Health tab @medium", () => {
|
||||
first_seen: NOW - 3600,
|
||||
last_seen: NOW,
|
||||
count: 1,
|
||||
dismissed_at: null,
|
||||
acknowledgeable: false,
|
||||
acknowledged_at: null,
|
||||
muted_at: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -156,10 +170,23 @@ test.describe("System — Health tab @medium", () => {
|
||||
"href",
|
||||
"https://github.com/blakeblackshear/frigate/releases/tag/v0.19.0",
|
||||
);
|
||||
await expect(row.getByRole("button", { name: "Dismiss" })).toBeVisible();
|
||||
await expect(
|
||||
row.getByRole("button", { name: "Mute", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(row.getByRole("button", { name: "Acknowledge" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("the filter shows dismissed notices without a Dismiss button", async ({
|
||||
const ACKNOWLEDGED_NOTICE = {
|
||||
...EVENT_NOTICE,
|
||||
id: "detector_stuck:coral",
|
||||
params: { detector: "coral" },
|
||||
acknowledged_at: NOW - 60,
|
||||
};
|
||||
const MUTED_NOTICE = { ...ERROR_NOTICE, muted_at: NOW - 120 };
|
||||
|
||||
test("the filter shows hidden notices marked by how they were hidden", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
@@ -169,33 +196,85 @@ test.describe("System — Health tab @medium", () => {
|
||||
await frigateApp.page.route(
|
||||
(url) =>
|
||||
url.pathname.endsWith("/api/notices") &&
|
||||
url.searchParams.get("include_dismissed") === "true",
|
||||
url.searchParams.get("include_hidden") === "true",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: [EVENT_NOTICE, { ...ERROR_NOTICE, dismissed_at: NOW - 120 }],
|
||||
json: [EVENT_NOTICE, ACKNOWLEDGED_NOTICE, MUTED_NOTICE],
|
||||
}),
|
||||
);
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
|
||||
const showDismissed = frigateApp.page.getByRole("switch", {
|
||||
name: "Show dismissed",
|
||||
const showHidden = frigateApp.page.getByRole("switch", {
|
||||
name: "Show hidden",
|
||||
});
|
||||
await expect(showDismissed).toHaveAttribute("aria-checked", "false");
|
||||
await showDismissed.click();
|
||||
await expect(showHidden).toHaveAttribute("aria-checked", "false");
|
||||
await showHidden.click();
|
||||
// mobile opens the filter as a modal drawer, which hides the rows' roles
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
|
||||
const row = frigateApp.page.getByTestId(
|
||||
const acknowledged = frigateApp.page.getByTestId(
|
||||
"health-problem-notice:detector_stuck:coral",
|
||||
);
|
||||
await expect(acknowledged).toBeVisible({ timeout: 15_000 });
|
||||
await expect(acknowledged).toContainText("Acknowledged");
|
||||
await expect(
|
||||
acknowledged.getByRole("button", { name: "Show again" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
acknowledged.getByRole("button", { name: "Acknowledge" }),
|
||||
).toHaveCount(0);
|
||||
|
||||
const muted = frigateApp.page.getByTestId(
|
||||
"health-problem-notice:model_download_failed:yolo/model.onnx",
|
||||
);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(row).toContainText("Dismissed");
|
||||
await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
|
||||
await expect(muted).toContainText("Muted");
|
||||
await expect(muted.getByRole("button", { name: "Unmute" })).toBeVisible();
|
||||
await expect(
|
||||
muted.getByRole("button", { name: "Mute", exact: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await showDismissed.click();
|
||||
await expect(row).toHaveCount(0);
|
||||
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
|
||||
await showHidden.click();
|
||||
await expect(muted).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("clear dismissed deletes the dismissed rows after confirming", async ({
|
||||
test("unmute deletes the row's hidden state", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({ stats: QUIET_STATS, notices: [] });
|
||||
await frigateApp.page.route(
|
||||
(url) =>
|
||||
url.pathname.endsWith("/api/notices") &&
|
||||
url.searchParams.get("include_hidden") === "true",
|
||||
(route) => route.fulfill({ json: [MUTED_NOTICE] }),
|
||||
);
|
||||
await frigateApp.page.route(
|
||||
"**/api/notices/model_download_failed:yolo/model.onnx/hidden",
|
||||
(route) => route.fulfill({ json: { success: true } }),
|
||||
);
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
|
||||
await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click();
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
|
||||
const request = frigateApp.page.waitForRequest(
|
||||
(req) =>
|
||||
req
|
||||
.url()
|
||||
.endsWith(
|
||||
"/api/notices/model_download_failed:yolo/model.onnx/hidden",
|
||||
) && req.method() === "DELETE",
|
||||
);
|
||||
await frigateApp.page
|
||||
.getByTestId(
|
||||
"health-problem-notice:model_download_failed:yolo/model.onnx",
|
||||
)
|
||||
.getByRole("button", { name: "Unmute" })
|
||||
.click({ timeout: 15_000 });
|
||||
await request;
|
||||
});
|
||||
|
||||
test("show all again unhides every row after confirming", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
@@ -203,29 +282,25 @@ test.describe("System — Health tab @medium", () => {
|
||||
notices: [EVENT_NOTICE],
|
||||
});
|
||||
|
||||
// the history loses its dismissed row once the DELETE lands
|
||||
// the hidden list loses its muted row once the DELETE lands
|
||||
let cleared = false;
|
||||
await frigateApp.page.route(
|
||||
(url) =>
|
||||
url.pathname.endsWith("/api/notices") &&
|
||||
url.searchParams.get("include_dismissed") === "true",
|
||||
url.searchParams.get("include_hidden") === "true",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: cleared
|
||||
? [EVENT_NOTICE]
|
||||
: [EVENT_NOTICE, { ...ERROR_NOTICE, dismissed_at: NOW - 120 }],
|
||||
json: cleared ? [EVENT_NOTICE] : [EVENT_NOTICE, MUTED_NOTICE],
|
||||
}),
|
||||
);
|
||||
await frigateApp.page.route("**/api/notices/dismissed", (route) => {
|
||||
await frigateApp.page.route("**/api/notices/hidden", (route) => {
|
||||
cleared = true;
|
||||
return route.fulfill({ json: { success: true } });
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
|
||||
await frigateApp.page
|
||||
.getByRole("switch", { name: "Show dismissed" })
|
||||
.click();
|
||||
await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click();
|
||||
const row = frigateApp.page.getByTestId(
|
||||
"health-problem-notice:model_download_failed:yolo/model.onnx",
|
||||
);
|
||||
@@ -233,23 +308,20 @@ test.describe("System — Health tab @medium", () => {
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
|
||||
await frigateApp.page
|
||||
.getByRole("button", { name: "Clear dismissed" })
|
||||
.getByRole("button", { name: "Show all again" })
|
||||
.click();
|
||||
const request = frigateApp.page.waitForRequest(
|
||||
(req) =>
|
||||
req.url().endsWith("/api/notices/dismissed") &&
|
||||
req.method() === "DELETE",
|
||||
req.url().endsWith("/api/notices/hidden") && req.method() === "DELETE",
|
||||
);
|
||||
await frigateApp.page
|
||||
.getByRole("alertdialog")
|
||||
.getByRole("button", { name: "Clear dismissed" })
|
||||
.getByRole("button", { name: "Show all again" })
|
||||
.click();
|
||||
await request;
|
||||
|
||||
await expect(row).toHaveCount(0);
|
||||
await expect(
|
||||
frigateApp.page.getByText("No dismissed notices"),
|
||||
).toBeVisible();
|
||||
await expect(frigateApp.page.getByText("No hidden notices")).toBeVisible();
|
||||
});
|
||||
|
||||
test("severity switches hide notices of that severity", async ({
|
||||
@@ -290,7 +362,9 @@ test.describe("System — Health tab @medium", () => {
|
||||
first_seen: start,
|
||||
last_seen: start + 60,
|
||||
count,
|
||||
dismissed_at: null,
|
||||
acknowledgeable: true,
|
||||
acknowledged_at: null,
|
||||
muted_at: null,
|
||||
});
|
||||
await frigateApp.installDefaults({
|
||||
stats: QUIET_STATS,
|
||||
@@ -332,7 +406,9 @@ test.describe("System — Health tab @medium", () => {
|
||||
first_seen: NOW - 600,
|
||||
last_seen: NOW - 600,
|
||||
count: 1,
|
||||
dismissed_at: null,
|
||||
acknowledgeable: true,
|
||||
acknowledged_at: null,
|
||||
muted_at: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -363,7 +439,9 @@ test.describe("System — Health tab @medium", () => {
|
||||
first_seen: NOW - 600,
|
||||
last_seen: NOW - 600,
|
||||
count: 1,
|
||||
dismissed_at: null,
|
||||
acknowledgeable: true,
|
||||
acknowledged_at: null,
|
||||
muted_at: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -695,7 +773,7 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
|
||||
// the status bar shows a problem, so stats have loaded
|
||||
await expect(
|
||||
frigateApp.page.getByText("Front Door is offline"),
|
||||
frigateApp.page.getByText("Recordings are being deleted"),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(
|
||||
frigateApp.page.locator("[data-testid^='health-problem-']"),
|
||||
@@ -1046,7 +1124,7 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
await expect(frigateApp.page).toHaveURL(/\/system#health/);
|
||||
});
|
||||
|
||||
test("status bar counts undismissed notices next to the health text", async ({
|
||||
test("status bar counts shown notices next to the health text", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
|
||||
@@ -1089,41 +1167,61 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
await expect(frigateApp.page.getByText("System is healthy")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a config row can be dismissed", async ({ frigateApp }) => {
|
||||
const id = "config:detect:fps-greater-than-five:camera.garage";
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
cameras: {
|
||||
garage: { detect: { width: 2560, height: 1440, fps: 10 } },
|
||||
},
|
||||
},
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
// the slow detector warning is added before the offline error
|
||||
const TWO_PROBLEM_STATS = {
|
||||
detectors: { cpu: { inference_speed: 60 } },
|
||||
cameras: { front_door: { camera_fps: 0 } },
|
||||
};
|
||||
|
||||
// the list gains the dismissal after the POST so the refetch hides the row
|
||||
let dismissed = false;
|
||||
await frigateApp.page.route(`**/api/notices/${id}/dismiss`, (route) => {
|
||||
dismissed = true;
|
||||
return route.fulfill({ json: { success: true } });
|
||||
});
|
||||
await frigateApp.page.route("**/api/notices/dismissed_checks", (route) =>
|
||||
route.fulfill({ json: dismissed ? [{ id, dismissed_at: NOW }] : [] }),
|
||||
);
|
||||
await frigateApp.goto("/system#health");
|
||||
test("status bar collapses several problems behind the most severe", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
test.skip(frigateApp.isMobile, "Status bar is desktop-only");
|
||||
await frigateApp.installDefaults({ stats: TWO_PROBLEM_STATS });
|
||||
await frigateApp.goto("/");
|
||||
|
||||
const row = frigateApp.page.getByTestId(`health-problem-${id}`);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
const request = frigateApp.page.waitForRequest(
|
||||
(req) =>
|
||||
req.url().includes(`/api/notices/${id}/dismiss`) &&
|
||||
req.method() === "POST",
|
||||
);
|
||||
await row.getByRole("button", { name: "Dismiss" }).click();
|
||||
await request;
|
||||
await expect(row).toHaveCount(0);
|
||||
const summary = frigateApp.page.getByRole("button", {
|
||||
name: "Front Door is offline +1",
|
||||
});
|
||||
await expect(summary).toBeVisible({ timeout: 15_000 });
|
||||
await expect(frigateApp.page.getByText("Cpu is slow")).toHaveCount(0);
|
||||
|
||||
await summary.click();
|
||||
const list = frigateApp.page.getByTestId("status-message-list");
|
||||
await expect(list.getByRole("link")).toHaveText([
|
||||
"Front Door is offline",
|
||||
"Cpu is slow (60 ms)",
|
||||
]);
|
||||
|
||||
await list.getByRole("link", { name: "Cpu is slow (60 ms)" }).click();
|
||||
await expect(frigateApp.page).toHaveURL(/\/system#general/);
|
||||
await expect(list).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("dismissed config rows move to the dismissed list", async ({
|
||||
test("mobile status drawer stacks every problem", async ({ frigateApp }) => {
|
||||
test.skip(!frigateApp.isMobile, "Mobile-only");
|
||||
await frigateApp.installDefaults({ stats: TWO_PROBLEM_STATS });
|
||||
await frigateApp.goto("/");
|
||||
|
||||
await frigateApp.page
|
||||
.getByTestId("status-alert-trigger")
|
||||
.click({ timeout: 15_000 });
|
||||
const items = frigateApp.page
|
||||
.getByTestId("status-message-list")
|
||||
.getByRole("link");
|
||||
await expect(items).toHaveText([
|
||||
"Front Door is offline",
|
||||
"Cpu is slow (60 ms)",
|
||||
]);
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
items.nth(0).boundingBox(),
|
||||
items.nth(1).boundingBox(),
|
||||
]);
|
||||
expect(second!.y).toBeGreaterThan(first!.y);
|
||||
});
|
||||
|
||||
test("a config row can be muted but not acknowledged", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
const id = "config:detect:fps-greater-than-five:camera.garage";
|
||||
@@ -1134,12 +1232,49 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
},
|
||||
},
|
||||
stats: QUIET_STATS,
|
||||
dismissedChecks: [{ id, dismissed_at: NOW - 120 }],
|
||||
});
|
||||
|
||||
// the list gains the mute after the POST so the refetch hides the row
|
||||
let muted = false;
|
||||
await frigateApp.page.route(`**/api/notices/${id}/mute`, (route) => {
|
||||
muted = true;
|
||||
return route.fulfill({ json: { success: true } });
|
||||
});
|
||||
await frigateApp.page.route("**/api/notices/muted_checks", (route) =>
|
||||
route.fulfill({ json: muted ? [{ id, muted_at: NOW }] : [] }),
|
||||
);
|
||||
await frigateApp.goto("/system#health");
|
||||
|
||||
const row = frigateApp.page.getByTestId(`health-problem-${id}`);
|
||||
await expect(row).toBeVisible({ timeout: 15_000 });
|
||||
await expect(row.getByRole("button", { name: "Acknowledge" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
const request = frigateApp.page.waitForRequest(
|
||||
(req) =>
|
||||
req.url().includes(`/api/notices/${id}/mute`) &&
|
||||
req.method() === "POST",
|
||||
);
|
||||
await row.getByRole("button", { name: "Mute", exact: true }).click();
|
||||
await request;
|
||||
await expect(row).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("muted config rows move to the hidden list", async ({ frigateApp }) => {
|
||||
const id = "config:detect:fps-greater-than-five:camera.garage";
|
||||
await frigateApp.installDefaults({
|
||||
config: {
|
||||
cameras: {
|
||||
garage: { detect: { width: 2560, height: 1440, fps: 10 } },
|
||||
},
|
||||
},
|
||||
stats: QUIET_STATS,
|
||||
mutedChecks: [{ id, muted_at: NOW - 120 }],
|
||||
});
|
||||
await frigateApp.page.route(
|
||||
(url) =>
|
||||
url.pathname.endsWith("/api/notices") &&
|
||||
url.searchParams.get("include_dismissed") === "true",
|
||||
url.searchParams.get("include_hidden") === "true",
|
||||
(route) => route.fulfill({ json: [] }),
|
||||
);
|
||||
await frigateApp.goto("/system#health");
|
||||
@@ -1153,12 +1288,14 @@ test.describe("System — Health notices sources @medium", () => {
|
||||
await expect(row).toHaveCount(0);
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Filter" }).click();
|
||||
await frigateApp.page
|
||||
.getByRole("switch", { name: "Show dismissed" })
|
||||
.click();
|
||||
await frigateApp.page.getByRole("switch", { name: "Show hidden" }).click();
|
||||
await frigateApp.page.keyboard.press("Escape");
|
||||
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row).toContainText("Dismissed");
|
||||
await expect(row.getByRole("button", { name: "Dismiss" })).toHaveCount(0);
|
||||
await expect(row).toContainText("Muted");
|
||||
await expect(row.getByRole("button", { name: "Unmute" })).toBeVisible();
|
||||
await expect(
|
||||
row.getByRole("button", { name: "Mute", exact: true }),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,20 +19,26 @@
|
||||
"notices": {
|
||||
"title": "Notices",
|
||||
"empty": "Your Frigate installation is healthy",
|
||||
"dismiss": "Dismiss",
|
||||
"acknowledge": "Acknowledge",
|
||||
"acknowledgeHint": "Hide until this happens again",
|
||||
"mute": "Mute",
|
||||
"muteHint": "Never show this again",
|
||||
"unmute": "Unmute",
|
||||
"showAgain": "Show again",
|
||||
"openSettings": "Open settings",
|
||||
"openLink": "Open link",
|
||||
"noMatches": "No notices match the filter",
|
||||
"dismissedTitle": "Dismissed",
|
||||
"noneDismissed": "No dismissed notices",
|
||||
"clearDismissed": "Clear dismissed",
|
||||
"clearDismissedTitle": "Clear dismissed notices?",
|
||||
"clearDismissedDesc": "Every dismissed notice is deleted. Config and stream checks show again right away, and other notices return the next time they happen.",
|
||||
"hiddenTitle": "Hidden",
|
||||
"noneHidden": "No hidden notices",
|
||||
"showAll": "Show all again",
|
||||
"showAllTitle": "Show all hidden notices?",
|
||||
"showAllDesc": "Every acknowledged and muted notice returns to the Notices list, including config and stream checks.",
|
||||
"firstSeen_one": "First seen {{time}}",
|
||||
"firstSeen_other": "First seen {{time}} · {{count}} times",
|
||||
"dismissedAt": "Dismissed {{time}}",
|
||||
"acknowledgedAt": "Acknowledged {{time}}",
|
||||
"mutedAt": "Muted {{time}}",
|
||||
"filter": {
|
||||
"showDismissed": "Show dismissed",
|
||||
"showHidden": "Show hidden",
|
||||
"severity": "Severity",
|
||||
"error": "Error",
|
||||
"warning": "Warning",
|
||||
@@ -42,6 +48,8 @@
|
||||
"detector_stuck": "Detector {{detector}} was restarted after it stopped responding",
|
||||
"model_download_failed": "Downloading {{file}} for {{model}} failed: {{error}}",
|
||||
"skipped_detections": "Detection could not keep up and skipped {{pct}}% of frames for over a minute",
|
||||
"ffmpeg_high_cpu": "FFmpeg CPU usage is high ({{cpu}}% average)",
|
||||
"detect_high_cpu": "Detection CPU usage is high ({{cpu}}% average)",
|
||||
"shm_too_low": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB",
|
||||
"failed_login_one": "Failed login attempt for {{user}}",
|
||||
"failed_login_other": "Failed login attempts for {{user}}",
|
||||
@@ -324,12 +332,12 @@
|
||||
},
|
||||
"lastRefreshed": "Last refreshed: ",
|
||||
"stats": {
|
||||
"ffmpegHighCpuUsage": "{{camera}} has high FFmpeg CPU usage ({{ffmpegAvg}}%)",
|
||||
"cameraSkippedDetections": "{{camera}} is skipping detection on {{pct}}% of frames",
|
||||
"detectHighCpuUsage": "{{camera}} has high detect CPU usage ({{detectAvg}}%)",
|
||||
"healthy": "System is healthy",
|
||||
"systemNotices_one": "{{count}} system notice",
|
||||
"systemNotices_other": "{{count}} system notices",
|
||||
"moreMessages_one": "+{{count}}",
|
||||
"moreMessages_other": "+{{count}}",
|
||||
"reindexingEmbeddings": "Reindexing embeddings ({{processed}}% complete)",
|
||||
"cameraIsOffline": "{{camera}} is offline",
|
||||
"detectIsSlow": "{{detect}} is slow ({{speed}} ms)",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { StatusMessage } from "@/context/statusbar-context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProblemSeverity } from "@/types/stats";
|
||||
import { IoIosWarning } from "react-icons/io";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const SEVERITY_COLOR: Record<ProblemSeverity, string> = {
|
||||
error: "text-danger",
|
||||
warning: "text-orange-400",
|
||||
info: "text-selected",
|
||||
};
|
||||
|
||||
type StatusMessageItemProps = {
|
||||
message: StatusMessage;
|
||||
className?: string;
|
||||
onNavigate?: () => void;
|
||||
};
|
||||
|
||||
/** One status bar message, a link when it has one. */
|
||||
export function StatusMessageItem({
|
||||
message,
|
||||
className,
|
||||
onNavigate,
|
||||
}: StatusMessageItemProps) {
|
||||
const content = (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm",
|
||||
message.link && "cursor-pointer hover:underline",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<IoIosWarning
|
||||
className={cn("size-5 shrink-0", SEVERITY_COLOR[message.severity])}
|
||||
/>
|
||||
{message.text}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!message.link) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={message.link} onClick={onNavigate}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
type StatusMessageListProps = {
|
||||
messages: StatusMessage[];
|
||||
className?: string;
|
||||
onNavigate?: () => void;
|
||||
};
|
||||
|
||||
/** Status bar messages stacked one per line. */
|
||||
export default function StatusMessageList({
|
||||
messages,
|
||||
className,
|
||||
onNavigate,
|
||||
}: StatusMessageListProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
data-testid="status-message-list"
|
||||
>
|
||||
{messages.map((message, index) => (
|
||||
// ids are unique only within a message key
|
||||
<StatusMessageItem
|
||||
key={`${index}:${message.id}`}
|
||||
message={message}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,24 @@
|
||||
import { useEmbeddingsReindexProgress } from "@/api/ws";
|
||||
import { StatusMessage } from "@/context/statusbar-context";
|
||||
import { useAutoFrigateStats } from "@/hooks/use-stats";
|
||||
import useStatusMessages from "@/hooks/use-status-messages";
|
||||
import StatusMessageList, {
|
||||
StatusMessageItem,
|
||||
} from "@/components/StatusMessageList";
|
||||
import {
|
||||
StatusBarMessagesContext,
|
||||
StatusMessage,
|
||||
} from "@/context/statusbar-context";
|
||||
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import StatusBarNotices from "@/components/health/StatusBarNotices";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ProfilesApiResponse } from "@/types/profile";
|
||||
import { getProfileColor } from "@/utils/profileColors";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useContext, useEffect, useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useSWR from "swr";
|
||||
|
||||
import { FaCheck } from "react-icons/fa";
|
||||
import { IoIosWarning } from "react-icons/io";
|
||||
import { MdCircle } from "react-icons/md";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
@@ -22,9 +26,7 @@ export default function Statusbar() {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const { messages, addMessage, clearMessages } = useContext(
|
||||
StatusBarMessagesContext,
|
||||
)!;
|
||||
const messages = useStatusMessages();
|
||||
|
||||
const stats = useAutoFrigateStats();
|
||||
|
||||
@@ -38,21 +40,6 @@ export default function Statusbar() {
|
||||
return parseInt(systemCpu);
|
||||
}, [stats]);
|
||||
|
||||
const { potentialProblems } = useStats(stats);
|
||||
|
||||
useEffect(() => {
|
||||
clearMessages("stats");
|
||||
potentialProblems.forEach((problem) => {
|
||||
addMessage(
|
||||
"stats",
|
||||
problem.text,
|
||||
problem.color,
|
||||
undefined,
|
||||
problem.relevantLink,
|
||||
);
|
||||
});
|
||||
}, [potentialProblems, addMessage, clearMessages]);
|
||||
|
||||
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
|
||||
|
||||
const activeProfile = useMemo(() => {
|
||||
@@ -68,28 +55,6 @@ export default function Statusbar() {
|
||||
};
|
||||
}, [profilesData]);
|
||||
|
||||
const { payload: reindexState } = useEmbeddingsReindexProgress();
|
||||
|
||||
useEffect(() => {
|
||||
if (reindexState) {
|
||||
if (reindexState.status == "indexing") {
|
||||
clearMessages("embeddings-reindex");
|
||||
addMessage(
|
||||
"embeddings-reindex",
|
||||
t("stats.reindexingEmbeddings", {
|
||||
processed: Math.floor(
|
||||
(reindexState.processed_objects / reindexState.total_objects) *
|
||||
100,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (reindexState.status === "completed") {
|
||||
clearMessages("embeddings-reindex");
|
||||
}
|
||||
}
|
||||
}, [reindexState, addMessage, clearMessages, t]);
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10 flex h-8 w-full items-center justify-between border-t border-secondary-highlight bg-background_alt px-4 dark:text-secondary-foreground">
|
||||
<div className="flex h-full items-center gap-2">
|
||||
@@ -187,42 +152,57 @@ export default function Statusbar() {
|
||||
))}
|
||||
</div>
|
||||
<div className="no-scrollbar flex h-full max-w-[50%] items-center gap-2 overflow-x-auto">
|
||||
{!isAdmin ? null : Object.entries(messages).length === 0 ? (
|
||||
{!isAdmin ? null : messages.length === 0 ? (
|
||||
<Link to="/system#health" className="flex items-center gap-2 text-sm">
|
||||
<FaCheck className="size-3 text-green-500" />
|
||||
{t("stats.healthy")}
|
||||
</Link>
|
||||
) : messages.length === 1 ? (
|
||||
<StatusMessageItem
|
||||
message={messages[0]}
|
||||
className="whitespace-nowrap"
|
||||
/>
|
||||
) : (
|
||||
Object.entries(messages).map(([key, messageArray]) => (
|
||||
<div key={key} className="flex h-full items-center gap-2">
|
||||
{messageArray.map(({ text, color, link }: StatusMessage) => {
|
||||
const message = (
|
||||
<div
|
||||
key={text}
|
||||
className={`flex items-center gap-2 whitespace-nowrap text-sm ${link ? "cursor-pointer hover:underline" : ""}`}
|
||||
>
|
||||
<IoIosWarning
|
||||
className={`size-5 ${color || "text-danger"}`}
|
||||
/>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (link) {
|
||||
return (
|
||||
<Link key={text} to={link}>
|
||||
{message}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return message;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
<StatusMessagesPopover messages={messages} />
|
||||
)}
|
||||
{isAdmin && <StatusBarNotices />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StatusMessagesPopoverProps = {
|
||||
messages: StatusMessage[];
|
||||
};
|
||||
|
||||
/** The most severe message and a count of the rest, which open the full list. */
|
||||
function StatusMessagesPopover({ messages }: StatusMessagesPopoverProps) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [first, ...rest] = messages;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 text-sm hover:underline"
|
||||
>
|
||||
<StatusMessageItem
|
||||
message={{ ...first, link: undefined }}
|
||||
className="whitespace-nowrap"
|
||||
/>
|
||||
<span className="shrink-0 rounded-full bg-secondary px-1.5 text-xs text-secondary-foreground">
|
||||
{t("stats.moreMessages", { count: rest.length })}
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" align="end" className="w-auto max-w-md">
|
||||
<StatusMessageList
|
||||
messages={messages}
|
||||
onNavigate={() => setOpen(false)}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,11 @@ import { Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaTriangleExclamation } from "react-icons/fa6";
|
||||
import {
|
||||
LuBell,
|
||||
LuBellOff,
|
||||
LuCheck,
|
||||
LuExternalLink,
|
||||
LuEye,
|
||||
LuInfo,
|
||||
LuSlidersHorizontal,
|
||||
LuX,
|
||||
@@ -51,7 +55,13 @@ export default function HealthProblemRow({ problem }: HealthProblemRowProps) {
|
||||
problem.link ||
|
||||
problem.docLink ||
|
||||
problem.externalLink ||
|
||||
problem.onDismiss;
|
||||
problem.onAcknowledge ||
|
||||
problem.onMute ||
|
||||
problem.onUnhide;
|
||||
const unhideLabel =
|
||||
problem.hidden === "muted"
|
||||
? t("health.notices.unmute")
|
||||
: t("health.notices.showAgain");
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -150,16 +160,46 @@ export default function HealthProblemRow({ problem }: HealthProblemRowProps) {
|
||||
</Button>
|
||||
</RowAction>
|
||||
)}
|
||||
{problem.onDismiss && (
|
||||
<RowAction label={t("health.notices.dismiss")}>
|
||||
{problem.onAcknowledge && (
|
||||
<RowAction label={t("health.notices.acknowledgeHint")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={ICON_BUTTON_CLASS}
|
||||
aria-label={t("health.notices.dismiss")}
|
||||
onClick={problem.onDismiss}
|
||||
aria-label={t("health.notices.acknowledge")}
|
||||
onClick={problem.onAcknowledge}
|
||||
>
|
||||
<LuX className="size-3.5" />
|
||||
<LuCheck className="size-3.5" />
|
||||
</Button>
|
||||
</RowAction>
|
||||
)}
|
||||
{problem.onMute && (
|
||||
<RowAction label={t("health.notices.muteHint")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={ICON_BUTTON_CLASS}
|
||||
aria-label={t("health.notices.mute")}
|
||||
onClick={problem.onMute}
|
||||
>
|
||||
<LuBellOff className="size-3.5" />
|
||||
</Button>
|
||||
</RowAction>
|
||||
)}
|
||||
{problem.onUnhide && (
|
||||
<RowAction label={unhideLabel}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={ICON_BUTTON_CLASS}
|
||||
aria-label={unhideLabel}
|
||||
onClick={problem.onUnhide}
|
||||
>
|
||||
{problem.hidden === "muted" ? (
|
||||
<LuBell className="size-3.5" />
|
||||
) : (
|
||||
<LuEye className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</RowAction>
|
||||
)}
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function NoticeFilterButton({
|
||||
const { t } = useTranslation(["views/system", "components/filter"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const active =
|
||||
filter.showDismissed ||
|
||||
filter.showHidden ||
|
||||
filter.severities.length < DEFAULT_NOTICE_FILTER.severities.length;
|
||||
|
||||
const severityLabels: Record<HealthSeverity, string> = {
|
||||
@@ -59,10 +59,10 @@ export default function NoticeFilterButton({
|
||||
const content = (
|
||||
<div className="space-y-3 p-4">
|
||||
<FilterSwitch
|
||||
label={t("health.notices.filter.showDismissed")}
|
||||
isChecked={filter.showDismissed}
|
||||
onCheckedChange={(showDismissed) =>
|
||||
onFilterChange({ ...filter, showDismissed })
|
||||
label={t("health.notices.filter.showHidden")}
|
||||
isChecked={filter.showHidden}
|
||||
onCheckedChange={(showHidden) =>
|
||||
onFilterChange({ ...filter, showHidden })
|
||||
}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -23,9 +23,9 @@ type NoticesPaneProps = {
|
||||
|
||||
export default function NoticesPane({ filter }: NoticesPaneProps) {
|
||||
const { t } = useTranslation(["views/system", "views/settings", "common"]);
|
||||
const { problems, dismissed, loading, clearDismissed } = useHealthProblems(
|
||||
const { problems, hidden, loading, unhideAll } = useHealthProblems(
|
||||
t,
|
||||
filter.showDismissed,
|
||||
filter.showHidden,
|
||||
);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
|
||||
@@ -37,12 +37,10 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
|
||||
[problems, filter.severities],
|
||||
);
|
||||
|
||||
const shownDismissed = useMemo(
|
||||
const shownHidden = useMemo(
|
||||
() =>
|
||||
dismissed?.filter((problem) =>
|
||||
filter.severities.includes(problem.severity),
|
||||
),
|
||||
[dismissed, filter.severities],
|
||||
hidden?.filter((problem) => filter.severities.includes(problem.severity)),
|
||||
[hidden, filter.severities],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -70,32 +68,32 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{filter.showDismissed && (
|
||||
{filter.showHidden && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("health.notices.dismissedTitle")}
|
||||
{t("health.notices.hiddenTitle")}
|
||||
</div>
|
||||
{dismissed && dismissed.length > 0 && (
|
||||
{hidden && hidden.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setConfirmClear(true)}
|
||||
>
|
||||
{t("health.notices.clearDismissed")}
|
||||
{t("health.notices.showAll")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
|
||||
{shownDismissed === undefined ? (
|
||||
{shownHidden === undefined ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : shownDismissed.length === 0 ? (
|
||||
) : shownHidden.length === 0 ? (
|
||||
<div className="px-1 py-2 text-sm text-muted-foreground">
|
||||
{t("health.notices.noneDismissed")}
|
||||
{t("health.notices.noneHidden")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{shownDismissed.map((problem) => (
|
||||
{shownHidden.map((problem) => (
|
||||
<HealthProblemRow key={problem.id} problem={problem} />
|
||||
))}
|
||||
</div>
|
||||
@@ -107,10 +105,10 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("health.notices.clearDismissedTitle")}
|
||||
{t("health.notices.showAllTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("health.notices.clearDismissedDesc")}
|
||||
{t("health.notices.showAllDesc")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
@@ -119,9 +117,9 @@ export default function NoticesPane({ filter }: NoticesPaneProps) {
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: "destructive" })}
|
||||
onClick={clearDismissed}
|
||||
onClick={unhideAll}
|
||||
>
|
||||
{t("health.notices.clearDismissed")}
|
||||
{t("health.notices.showAll")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useHealthProblems } from "@/hooks/use-health-problems";
|
||||
|
||||
/** The count of undismissed Notices rows, shown before the status bar's health text. */
|
||||
/** The count of shown Notices rows, shown before the status bar's health text. */
|
||||
export default function StatusBarNotices() {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { problems, loading } = useHealthProblems(t);
|
||||
|
||||
@@ -1,30 +1,15 @@
|
||||
import NavItem from "./NavItem";
|
||||
import { IoIosWarning } from "react-icons/io";
|
||||
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
|
||||
import useSWR from "swr";
|
||||
import { FrigateStats } from "@/types/stats";
|
||||
import { useEmbeddingsReindexProgress, useFrigateStats } from "@/api/ws";
|
||||
import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import useStats from "@/hooks/use-stats";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import useStatusMessages from "@/hooks/use-status-messages";
|
||||
import StatusMessageList from "../StatusMessageList";
|
||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import GeneralSettings from "../menu/GeneralSettings";
|
||||
import useNavigation from "@/hooks/use-navigation";
|
||||
import {
|
||||
StatusBarMessagesContext,
|
||||
StatusMessage,
|
||||
} from "@/context/statusbar-context";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { isPWA } from "@/utils/isPWA";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function Bottombar() {
|
||||
const navItems = useNavigation("secondary");
|
||||
@@ -98,64 +83,12 @@ type StatusAlertNavProps = {
|
||||
large?: boolean;
|
||||
};
|
||||
function StatusAlertNav({ className, large }: StatusAlertNavProps) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { data: initialStats } = useSWR<FrigateStats>("stats", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const latestStats = useFrigateStats();
|
||||
|
||||
const { messages, addMessage, clearMessages } = useContext(
|
||||
StatusBarMessagesContext,
|
||||
)!;
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (latestStats) {
|
||||
return latestStats;
|
||||
}
|
||||
|
||||
return initialStats;
|
||||
}, [initialStats, latestStats]);
|
||||
const { potentialProblems } = useStats(stats);
|
||||
|
||||
useEffect(() => {
|
||||
clearMessages("stats");
|
||||
potentialProblems.forEach((problem) => {
|
||||
addMessage(
|
||||
"stats",
|
||||
problem.text,
|
||||
problem.color,
|
||||
undefined,
|
||||
problem.relevantLink,
|
||||
);
|
||||
});
|
||||
}, [potentialProblems, addMessage, clearMessages]);
|
||||
|
||||
const { payload: reindexState } = useEmbeddingsReindexProgress();
|
||||
|
||||
useEffect(() => {
|
||||
if (reindexState) {
|
||||
if (reindexState.status == "indexing") {
|
||||
clearMessages("embeddings-reindex");
|
||||
addMessage(
|
||||
"embeddings-reindex",
|
||||
t("stats.reindexingEmbeddings", {
|
||||
processed: Math.floor(
|
||||
(reindexState.processed_objects / reindexState.total_objects) *
|
||||
100,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (reindexState.status === "completed") {
|
||||
clearMessages("embeddings-reindex");
|
||||
}
|
||||
}
|
||||
}, [reindexState, addMessage, clearMessages, t]);
|
||||
const messages = useStatusMessages();
|
||||
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
// problems link to admin-only pages
|
||||
if (!isAdmin || !messages || Object.keys(messages).length === 0) {
|
||||
if (!isAdmin || messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -163,6 +96,7 @@ function StatusAlertNav({ className, large }: StatusAlertNavProps) {
|
||||
<Drawer>
|
||||
<DrawerTrigger asChild>
|
||||
<div
|
||||
data-testid="status-alert-trigger"
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center p-2",
|
||||
large && "size-12",
|
||||
@@ -182,32 +116,10 @@ function StatusAlertNav({ className, large }: StatusAlertNavProps) {
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="scrollbar-container flex h-auto w-full flex-col items-center gap-2 overflow-y-auto overflow-x-hidden px-2 py-4">
|
||||
{Object.entries(messages).map(([key, messageArray]) => (
|
||||
<div key={key} className="flex w-full items-center gap-2">
|
||||
{messageArray.map(({ id, text, color, link }: StatusMessage) => {
|
||||
const message = (
|
||||
<div key={id} className="flex items-center gap-2 text-xs">
|
||||
<IoIosWarning
|
||||
className={`size-5 ${color || "text-danger"}`}
|
||||
/>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (link) {
|
||||
return (
|
||||
<Link key={id} to={link}>
|
||||
{message}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return message;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<StatusMessageList
|
||||
messages={messages}
|
||||
className="scrollbar-container w-full overflow-y-auto overflow-x-hidden px-4 py-4"
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createContext } from "react";
|
||||
import { ProblemSeverity } from "@/types/stats";
|
||||
|
||||
export type StatusMessage = {
|
||||
id: string;
|
||||
text: string;
|
||||
color?: string;
|
||||
severity: ProblemSeverity;
|
||||
link?: string;
|
||||
};
|
||||
|
||||
@@ -16,7 +17,7 @@ type StatusBarMessagesContextValue = {
|
||||
addMessage: (
|
||||
key: string,
|
||||
message: string,
|
||||
color?: string,
|
||||
severity?: ProblemSeverity,
|
||||
messageId?: string,
|
||||
link?: string,
|
||||
) => string | undefined;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
StatusBarMessagesContext,
|
||||
StatusMessagesState,
|
||||
} from "@/context/statusbar-context";
|
||||
import { ProblemSeverity } from "@/types/stats";
|
||||
|
||||
type StatusBarMessagesProviderProps = {
|
||||
children: ReactNode;
|
||||
@@ -19,21 +20,21 @@ export function StatusBarMessagesProvider({
|
||||
(
|
||||
key: string,
|
||||
message: string,
|
||||
color?: string,
|
||||
severity: ProblemSeverity = "error",
|
||||
messageId?: string,
|
||||
link?: string,
|
||||
) => {
|
||||
if (!key || !message) return;
|
||||
|
||||
const id = messageId ?? Date.now().toString();
|
||||
const msgColor = color ?? "text-danger";
|
||||
// the text is the fallback id, so repeating a message replaces it
|
||||
const id = messageId ?? message;
|
||||
|
||||
setMessagesState((prevMessages) => {
|
||||
const existingMessages = prevMessages[key] || [];
|
||||
// Check if a message with the same ID already exists
|
||||
const messageIndex = existingMessages.findIndex((msg) => msg.id === id);
|
||||
|
||||
const newMessage = { id, text: message, color: msgColor, link };
|
||||
const newMessage = { id, text: message, severity, link };
|
||||
|
||||
// If the message exists, replace it, otherwise add the new message
|
||||
let updatedMessages;
|
||||
|
||||
@@ -5,25 +5,25 @@ import useSWR from "swr";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
import { useTimezone } from "@/hooks/use-date-utils";
|
||||
import { useHealthChecks } from "@/hooks/use-health-checks";
|
||||
import { useNotices } from "@/hooks/use-notices";
|
||||
import { hiddenAt, useNotices } from "@/hooks/use-notices";
|
||||
import { evaluateConfigHealth } from "@/utils/configHealth";
|
||||
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||
import { sortHealthProblems } from "@/utils/healthSort";
|
||||
import { streamHealth } from "@/utils/streamHealth";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { HealthProblem } from "@/types/health";
|
||||
import type { DismissedCheck, Notice } from "@/types/notice";
|
||||
import type { MutedCheck, Notice } from "@/types/notice";
|
||||
|
||||
const EXTERNAL_LINK = /^https?:\/\//;
|
||||
|
||||
type HealthProblems = {
|
||||
/** undismissed rows, most severe first */
|
||||
/** shown rows, most severe first */
|
||||
problems: HealthProblem[];
|
||||
/** dismissed rows, most recently dismissed first; undefined until loaded */
|
||||
dismissed?: HealthProblem[];
|
||||
/** acknowledged and muted rows, most recently hidden first; undefined until loaded */
|
||||
hidden?: HealthProblem[];
|
||||
loading: boolean;
|
||||
/** delete every dismissed row, so each can show again */
|
||||
clearDismissed: () => Promise<void>;
|
||||
/** show every hidden row again */
|
||||
unhideAll: () => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -34,7 +34,7 @@ type HealthProblems = {
|
||||
*/
|
||||
export function useHealthProblems(
|
||||
t: TFunction,
|
||||
showDismissed = false,
|
||||
showHidden = false,
|
||||
): HealthProblems {
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
@@ -43,30 +43,40 @@ export function useHealthProblems(
|
||||
const locale = useDateLocale();
|
||||
const {
|
||||
notices,
|
||||
dismissed: dismissedNotices,
|
||||
dismiss,
|
||||
mutateDismissed,
|
||||
} = useNotices(showDismissed);
|
||||
const { data: dismissedChecks, mutate: mutateDismissedChecks } = useSWR<
|
||||
DismissedCheck[]
|
||||
>("notices/dismissed_checks");
|
||||
hidden: hiddenNotices,
|
||||
acknowledge,
|
||||
mute,
|
||||
unhide,
|
||||
mutateHidden,
|
||||
} = useNotices(showHidden);
|
||||
const { data: mutedChecks, mutate: mutateMutedChecks } = useSWR<MutedCheck[]>(
|
||||
"notices/muted_checks",
|
||||
);
|
||||
const {
|
||||
stream: { results },
|
||||
} = useHealthChecks();
|
||||
|
||||
const dismissCheck = useCallback(
|
||||
const muteCheck = useCallback(
|
||||
async (id: string) => {
|
||||
await axios.post(`notices/${id}/dismiss`);
|
||||
mutateDismissedChecks();
|
||||
await axios.post(`notices/${id}/mute`);
|
||||
mutateMutedChecks();
|
||||
},
|
||||
[mutateDismissedChecks],
|
||||
[mutateMutedChecks],
|
||||
);
|
||||
|
||||
const clearDismissed = useCallback(async () => {
|
||||
await axios.delete("notices/dismissed");
|
||||
mutateDismissed();
|
||||
mutateDismissedChecks();
|
||||
}, [mutateDismissed, mutateDismissedChecks]);
|
||||
const unmuteCheck = useCallback(
|
||||
async (id: string) => {
|
||||
await axios.delete(`notices/${id}/hidden`);
|
||||
mutateMutedChecks();
|
||||
},
|
||||
[mutateMutedChecks],
|
||||
);
|
||||
|
||||
const unhideAll = useCallback(async () => {
|
||||
await axios.delete("notices/hidden");
|
||||
mutateHidden();
|
||||
mutateMutedChecks();
|
||||
}, [mutateHidden, mutateMutedChecks]);
|
||||
|
||||
const formatTime = useCallback(
|
||||
(timestamp: number) =>
|
||||
@@ -98,23 +108,37 @@ export function useHealthProblems(
|
||||
count: notice.count,
|
||||
}),
|
||||
meta:
|
||||
notice.dismissed_at === null
|
||||
? t("health.notices.firstSeen", {
|
||||
notice.muted_at !== null
|
||||
? t("health.notices.mutedAt", {
|
||||
ns: "views/system",
|
||||
time: formatTime(notice.first_seen),
|
||||
count: notice.count,
|
||||
time: formatTime(notice.muted_at),
|
||||
})
|
||||
: t("health.notices.dismissedAt", {
|
||||
ns: "views/system",
|
||||
time: formatTime(notice.dismissed_at),
|
||||
}),
|
||||
: notice.acknowledged_at !== null
|
||||
? t("health.notices.acknowledgedAt", {
|
||||
ns: "views/system",
|
||||
time: formatTime(notice.acknowledged_at),
|
||||
})
|
||||
: t("health.notices.firstSeen", {
|
||||
ns: "views/system",
|
||||
time: formatTime(notice.first_seen),
|
||||
count: notice.count,
|
||||
}),
|
||||
link: external ? undefined : link,
|
||||
externalLink: external ? link : undefined,
|
||||
onDismiss:
|
||||
notice.dismissed_at === null ? () => dismiss(notice.id) : undefined,
|
||||
...(hiddenAt(notice) > 0
|
||||
? {
|
||||
hidden: notice.muted_at !== null ? "muted" : "acknowledged",
|
||||
onUnhide: () => unhide(notice.id),
|
||||
}
|
||||
: {
|
||||
onAcknowledge: notice.acknowledgeable
|
||||
? () => acknowledge(notice.id)
|
||||
: undefined,
|
||||
onMute: () => mute(notice.id),
|
||||
}),
|
||||
};
|
||||
},
|
||||
[dismiss, formatTime, t],
|
||||
[acknowledge, mute, unhide, formatTime, t],
|
||||
);
|
||||
|
||||
const checks = useMemo<HealthProblem[]>(
|
||||
@@ -128,12 +152,10 @@ export function useHealthProblems(
|
||||
[config, results, t],
|
||||
);
|
||||
|
||||
const dismissedAt = useMemo(
|
||||
const mutedAt = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
(dismissedChecks ?? []).map((check) => [check.id, check.dismissed_at]),
|
||||
),
|
||||
[dismissedChecks],
|
||||
new Map((mutedChecks ?? []).map((check) => [check.id, check.muted_at])),
|
||||
[mutedChecks],
|
||||
);
|
||||
|
||||
const problems = useMemo(
|
||||
@@ -141,27 +163,27 @@ export function useHealthProblems(
|
||||
sortHealthProblems([
|
||||
...(notices ?? []).map(noticeRow),
|
||||
...checks
|
||||
.filter((check) => !dismissedAt.has(check.id))
|
||||
.filter((check) => !mutedAt.has(check.id))
|
||||
.map((check) => ({
|
||||
...check,
|
||||
onDismiss: () => dismissCheck(check.id),
|
||||
onMute: () => muteCheck(check.id),
|
||||
})),
|
||||
]),
|
||||
[notices, noticeRow, checks, dismissedAt, dismissCheck],
|
||||
[notices, noticeRow, checks, mutedAt, muteCheck],
|
||||
);
|
||||
|
||||
const dismissed = useMemo(() => {
|
||||
if (!showDismissed || dismissedNotices === undefined) {
|
||||
const hidden = useMemo(() => {
|
||||
if (!showHidden || hiddenNotices === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rows = [
|
||||
...dismissedNotices.map((notice) => ({
|
||||
at: notice.dismissed_at ?? 0,
|
||||
...hiddenNotices.map((notice) => ({
|
||||
at: hiddenAt(notice),
|
||||
row: noticeRow(notice),
|
||||
})),
|
||||
...checks.flatMap((check) => {
|
||||
const at = dismissedAt.get(check.id);
|
||||
const at = mutedAt.get(check.id);
|
||||
|
||||
return at === undefined
|
||||
? []
|
||||
@@ -170,10 +192,12 @@ export function useHealthProblems(
|
||||
at,
|
||||
row: {
|
||||
...check,
|
||||
meta: t("health.notices.dismissedAt", {
|
||||
meta: t("health.notices.mutedAt", {
|
||||
ns: "views/system",
|
||||
time: formatTime(at),
|
||||
}),
|
||||
hidden: "muted" as const,
|
||||
onUnhide: () => unmuteCheck(check.id),
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -182,17 +206,17 @@ export function useHealthProblems(
|
||||
|
||||
return rows.sort((a, b) => b.at - a.at).map(({ row }) => row);
|
||||
}, [
|
||||
showDismissed,
|
||||
dismissedNotices,
|
||||
showHidden,
|
||||
hiddenNotices,
|
||||
noticeRow,
|
||||
checks,
|
||||
dismissedAt,
|
||||
mutedAt,
|
||||
formatTime,
|
||||
unmuteCheck,
|
||||
t,
|
||||
]);
|
||||
|
||||
const loading =
|
||||
notices === undefined || dismissedChecks === undefined || !config;
|
||||
const loading = notices === undefined || mutedChecks === undefined || !config;
|
||||
|
||||
return { problems, dismissed, loading, clearDismissed };
|
||||
return { problems, hidden, loading, unhideAll };
|
||||
}
|
||||
|
||||
@@ -4,19 +4,22 @@ import useSWR from "swr";
|
||||
import { useWs } from "@/api/ws";
|
||||
import type { Notice } from "@/types/notice";
|
||||
|
||||
/** When a hidden notice was acknowledged or muted. */
|
||||
export function hiddenAt(notice: Notice): number {
|
||||
return notice.muted_at ?? notice.acknowledged_at ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active notices come from a REST snapshot, then from every `notices`
|
||||
* websocket payload. Dismissed notices are fetched only while the history is
|
||||
* shown, and again when the active list changes or the tab regains focus. A
|
||||
* purge in another tab leaves the active list unchanged, so only focus
|
||||
* catches it.
|
||||
* websocket payload. Hidden notices are fetched only while the hidden list is
|
||||
* shown, and again when the active list changes or the tab regains focus.
|
||||
*/
|
||||
export function useNotices(showDismissed: boolean) {
|
||||
export function useNotices(showHidden: boolean) {
|
||||
const { data: initial, mutate } = useSWR<Notice[]>("notices", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const { data: history, mutate: mutateHistory } = useSWR<Notice[]>(
|
||||
showDismissed ? ["notices", { include_dismissed: true }] : null,
|
||||
const { data: all, mutate: mutateHidden } = useSWR<Notice[]>(
|
||||
showHidden ? ["notices", { include_hidden: true }] : null,
|
||||
);
|
||||
const {
|
||||
value: { payload },
|
||||
@@ -30,31 +33,44 @@ export function useNotices(showDismissed: boolean) {
|
||||
[payload],
|
||||
);
|
||||
|
||||
// once a websocket frame has arrived it is the source of truth; a dismiss
|
||||
// still shows up because the registry publishes a new frame after it
|
||||
// once a websocket frame has arrived it is the source of truth; every
|
||||
// acknowledge, mute, and unhide publishes a new frame
|
||||
const notices = live ?? initial;
|
||||
|
||||
// refetch the history whenever the active list changes; SWR ignores the
|
||||
// call while the history is hidden
|
||||
// refetch the hidden list whenever the active list changes; SWR ignores the
|
||||
// call while the hidden list is not shown
|
||||
useEffect(() => {
|
||||
mutateHistory();
|
||||
}, [live, mutateHistory]);
|
||||
mutateHidden();
|
||||
}, [live, mutateHidden]);
|
||||
|
||||
const dismissed = useMemo(
|
||||
const hidden = useMemo(
|
||||
() =>
|
||||
history
|
||||
?.filter((notice) => notice.dismissed_at !== null)
|
||||
.sort((a, b) => (b.dismissed_at ?? 0) - (a.dismissed_at ?? 0)),
|
||||
[history],
|
||||
all
|
||||
?.filter((notice) => hiddenAt(notice) > 0)
|
||||
.sort((a, b) => hiddenAt(b) - hiddenAt(a)),
|
||||
[all],
|
||||
);
|
||||
|
||||
const dismiss = useCallback(
|
||||
async (id: string) => {
|
||||
await axios.post(`notices/${id}/dismiss`);
|
||||
const act = useCallback(
|
||||
async (request: Promise<unknown>) => {
|
||||
await request;
|
||||
mutate();
|
||||
},
|
||||
[mutate],
|
||||
);
|
||||
|
||||
return { notices, dismissed, dismiss, mutateDismissed: mutateHistory };
|
||||
const acknowledge = useCallback(
|
||||
(id: string) => act(axios.post(`notices/${id}/acknowledge`)),
|
||||
[act],
|
||||
);
|
||||
const mute = useCallback(
|
||||
(id: string) => act(axios.post(`notices/${id}/mute`)),
|
||||
[act],
|
||||
);
|
||||
const unhide = useCallback(
|
||||
(id: string) => act(axios.delete(`notices/${id}/hidden`)),
|
||||
[act],
|
||||
);
|
||||
|
||||
return { notices, hidden, acknowledge, mute, unhide, mutateHidden };
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import {
|
||||
CameraDetectThreshold,
|
||||
CameraFfmpegThreshold,
|
||||
InferenceThreshold,
|
||||
} from "@/types/graph";
|
||||
import { InferenceThreshold } from "@/types/graph";
|
||||
import { FrigateStats, PotentialProblem, ProblemSeverity } from "@/types/stats";
|
||||
import { useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
@@ -15,20 +11,12 @@ 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 };
|
||||
return { text, severity, relevantLink };
|
||||
}
|
||||
|
||||
// matches SKIPPED_DETECTIONS_PCT in frigate/stats/emitter.py
|
||||
@@ -122,20 +110,13 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
}
|
||||
});
|
||||
|
||||
// check camera cpu usages
|
||||
// check for skipped detections
|
||||
Object.entries(memoizedStats["cameras"]).forEach(([name, cam]) => {
|
||||
// Skip replay cameras
|
||||
if (isReplayCamera(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ffmpegAvg = parseFloat(
|
||||
memoizedStats["cpu_usages"][cam["ffmpeg_pid"]]?.cpu_average,
|
||||
);
|
||||
const detectAvg = parseFloat(
|
||||
memoizedStats["cpu_usages"][cam["pid"]]?.cpu_average,
|
||||
);
|
||||
|
||||
const cameraName = config?.cameras?.[name]?.friendly_name ?? name;
|
||||
|
||||
if (
|
||||
@@ -153,32 +134,6 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
|
||||
problems.push(
|
||||
problem(
|
||||
"error",
|
||||
t("stats.ffmpegHighCpuUsage", {
|
||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||
ffmpegAvg,
|
||||
}),
|
||||
"/system#cameras",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNaN(detectAvg) && detectAvg >= CameraDetectThreshold.error) {
|
||||
problems.push(
|
||||
problem(
|
||||
"error",
|
||||
t("stats.detectHighCpuUsage", {
|
||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||
detectAvg,
|
||||
}),
|
||||
"/system#cameras",
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Add message if debug replay is active
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEmbeddingsReindexProgress } from "@/api/ws";
|
||||
import {
|
||||
StatusBarMessagesContext,
|
||||
StatusMessage,
|
||||
} from "@/context/statusbar-context";
|
||||
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
|
||||
import { ProblemSeverity } from "@/types/stats";
|
||||
import { useContext, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const SEVERITY_ORDER: Record<ProblemSeverity, number> = {
|
||||
error: 0,
|
||||
warning: 1,
|
||||
info: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Publishes the stats problems and reindex progress to the status bar, then
|
||||
* returns every status bar message, most severe first.
|
||||
*/
|
||||
export default function useStatusMessages(): StatusMessage[] {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { messages, addMessage, clearMessages } = useContext(
|
||||
StatusBarMessagesContext,
|
||||
)!;
|
||||
|
||||
const stats = useAutoFrigateStats();
|
||||
const { potentialProblems } = useStats(stats);
|
||||
|
||||
useEffect(() => {
|
||||
clearMessages("stats");
|
||||
potentialProblems.forEach((problem) => {
|
||||
addMessage(
|
||||
"stats",
|
||||
problem.text,
|
||||
problem.severity,
|
||||
undefined,
|
||||
problem.relevantLink,
|
||||
);
|
||||
});
|
||||
}, [potentialProblems, addMessage, clearMessages]);
|
||||
|
||||
const { payload: reindexState } = useEmbeddingsReindexProgress();
|
||||
|
||||
useEffect(() => {
|
||||
if (reindexState) {
|
||||
if (reindexState.status == "indexing") {
|
||||
clearMessages("embeddings-reindex");
|
||||
addMessage(
|
||||
"embeddings-reindex",
|
||||
t("stats.reindexingEmbeddings", {
|
||||
processed: Math.floor(
|
||||
(reindexState.processed_objects / reindexState.total_objects) *
|
||||
100,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (reindexState.status === "completed") {
|
||||
clearMessages("embeddings-reindex");
|
||||
}
|
||||
}
|
||||
}, [reindexState, addMessage, clearMessages, t]);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
Object.values(messages)
|
||||
.flat()
|
||||
.sort(
|
||||
(a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity],
|
||||
),
|
||||
[messages],
|
||||
);
|
||||
}
|
||||
+10
-3
@@ -27,16 +27,23 @@ export type HealthProblem = {
|
||||
externalLink?: string;
|
||||
/** render with a spinner instead of the severity icon (stream check running) */
|
||||
pending?: boolean;
|
||||
onDismiss?: () => void;
|
||||
/** why a hidden row is hidden */
|
||||
hidden?: "acknowledged" | "muted";
|
||||
/** hide until the next occurrence; only for kinds that repeat */
|
||||
onAcknowledge?: () => void;
|
||||
/** hide for good */
|
||||
onMute?: () => void;
|
||||
/** show a hidden row again */
|
||||
onUnhide?: () => void;
|
||||
};
|
||||
|
||||
/** What the Health tab's filter shows. */
|
||||
export type NoticeFilter = {
|
||||
showDismissed: boolean;
|
||||
showHidden: boolean;
|
||||
severities: HealthSeverity[];
|
||||
};
|
||||
|
||||
export const DEFAULT_NOTICE_FILTER: NoticeFilter = {
|
||||
showDismissed: false,
|
||||
showHidden: false,
|
||||
severities: ["error", "warning", "info"],
|
||||
};
|
||||
|
||||
@@ -13,11 +13,16 @@ export type Notice = {
|
||||
first_seen: number;
|
||||
last_seen: number;
|
||||
count: number;
|
||||
dismissed_at: number | null;
|
||||
/** whether the kind repeats, so acknowledging it can hide it until next time */
|
||||
acknowledgeable: boolean;
|
||||
/** hidden until the next occurrence */
|
||||
acknowledged_at: number | null;
|
||||
/** hidden for good */
|
||||
muted_at: number | null;
|
||||
};
|
||||
|
||||
/** a config or stream check row an admin dismissed */
|
||||
export type DismissedCheck = {
|
||||
/** a config or stream check row an admin muted */
|
||||
export type MutedCheck = {
|
||||
id: string;
|
||||
dismissed_at: number;
|
||||
muted_at: number;
|
||||
};
|
||||
|
||||
@@ -129,7 +129,6 @@ export type ProblemSeverity = "error" | "warning" | "info";
|
||||
export type PotentialProblem = {
|
||||
text: string;
|
||||
severity: ProblemSeverity;
|
||||
color: string;
|
||||
relevantLink?: string;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user