Files
frigate/web/src/components/Statusbar.tsx
T
Josh HawkinsandGitHub 9664d9ceae 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
2026-09-24 15:39:18 -06:00

209 lines
6.6 KiB
TypeScript

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 {
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 { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { FaCheck } from "react-icons/fa";
import { MdCircle } from "react-icons/md";
import { Link } from "react-router-dom";
export default function Statusbar() {
const { t } = useTranslation(["views/system"]);
const isAdmin = useIsAdmin();
const messages = useStatusMessages();
const stats = useAutoFrigateStats();
const cpuPercent = useMemo(() => {
const systemCpu = stats?.cpu_usages["frigate.full_system"]?.cpu;
if (!systemCpu || systemCpu == "0.0") {
return null;
}
return parseInt(systemCpu);
}, [stats]);
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
const activeProfile = useMemo(() => {
if (!profilesData?.active_profile || !profilesData.profiles) return null;
const info = profilesData.profiles.find(
(p) => p.name === profilesData.active_profile,
);
const allNames = profilesData.profiles.map((p) => p.name).sort();
return {
name: profilesData.active_profile,
friendlyName: info?.friendly_name ?? profilesData.active_profile,
color: getProfileColor(profilesData.active_profile, allNames),
};
}, [profilesData]);
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">
{cpuPercent && (
<Link to="/system#general">
<div className="flex cursor-pointer items-center gap-2 text-sm hover:underline">
<MdCircle
className={`size-2 ${
cpuPercent < 50
? "text-success"
: cpuPercent < 80
? "text-orange-400"
: "text-danger"
}`}
/>
CPU {cpuPercent}%
</div>
</Link>
)}
{Object.entries(stats?.gpu_usages || {}).map(([name, stats]) => {
if (name == "error-gpu") {
return;
}
let gpuTitle;
switch (name) {
case "amd-vaapi":
gpuTitle = "AMD GPU";
break;
case "intel-gpu":
gpuTitle = "Intel GPU";
break;
case "rockchip":
gpuTitle = "Rockchip GPU";
break;
default:
gpuTitle = name;
break;
}
const gpu = parseInt(stats.gpu);
if (isNaN(gpu)) {
return;
}
return (
<Link key={name} to="/system#general">
{" "}
<div
key={name}
className="flex cursor-pointer items-center gap-2 text-sm hover:underline"
>
<MdCircle
className={`size-2 ${
gpu < 50
? "text-success"
: gpu < 80
? "text-orange-400"
: "text-danger"
}`}
/>
{gpuTitle} {gpu}%
</div>
</Link>
);
})}
{activeProfile &&
(isAdmin ? (
<Link to="/settings?page=profiles">
<div className="flex cursor-pointer items-center gap-2 text-sm hover:underline">
<span
className={cn(
"size-2 shrink-0 rounded-full",
activeProfile.color.dot,
)}
/>
<span className="max-w-[150px] truncate">
{activeProfile.friendlyName}
</span>
</div>
</Link>
) : (
<div className="flex items-center gap-2 text-sm">
<span
className={cn(
"size-2 shrink-0 rounded-full",
activeProfile.color.dot,
)}
/>
<span className="max-w-[150px] truncate">
{activeProfile.friendlyName}
</span>
</div>
))}
</div>
<div className="no-scrollbar flex h-full max-w-[50%] items-center gap-2 overflow-x-auto">
{!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"
/>
) : (
<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>
);
}