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
+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;
};