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:
Josh Hawkins
2026-09-24 15:39:18 -06:00
committed by GitHub
parent c959df32c9
commit 9664d9ceae
30 changed files with 1307 additions and 707 deletions
+3 -3
View File
@@ -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
View File
@@ -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);
});
});
+18 -10
View File
@@ -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)",
+78
View File
@@ -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>
);
}
+55 -75
View File
@@ -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>
);
}
+46 -6
View File
@@ -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 />
+17 -19
View File
@@ -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);
+10 -98
View File
@@ -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>
);
+3 -2
View File
@@ -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;
+5 -4
View File
@@ -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;
+79 -55
View File
@@ -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 };
}
+38 -22
View File
@@ -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 };
}
+3 -48
View File
@@ -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
+74
View File
@@ -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
View File
@@ -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"],
};
+9 -4
View File
@@ -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;
};
-1
View File
@@ -129,7 +129,6 @@ export type ProblemSeverity = "error" | "warning" | "info";
export type PotentialProblem = {
text: string;
severity: ProblemSeverity;
color: string;
relevantLink?: string;
};