mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 10:56:52 +03:00
* 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
209 lines
6.6 KiB
TypeScript
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>
|
|
);
|
|
}
|