Improve System Health pane (#24188)

* build out system health pane

* tweaks

* fixes

* fix notice link so it opens the correct camera

* tweak language
This commit is contained in:
Josh Hawkins
2026-09-12 07:30:04 -06:00
committed by Nicolas Mowen
parent 6d33b31bc6
commit 70ce193e09
57 changed files with 3965 additions and 464 deletions
+294
View File
@@ -0,0 +1,294 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { FaCircleCheck } from "react-icons/fa6";
import { LuRefreshCw } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import useSWR from "swr";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import TimeAgo from "@/components/dynamic/TimeAgo";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { ConnectionQualityIndicator } from "@/components/camera/ConnectionQualityIndicator";
import HardwareStatusRow from "@/components/health/HardwareStatusRow";
import { useHardwareHealth } from "@/hooks/use-hardware-health";
import { useHealthChecks } from "@/hooks/use-health-checks";
import type { FrigateConfig } from "@/types/frigateConfig";
import type { HardwareRow } from "@/utils/health";
import { streamHealth } from "@/utils/streamHealth";
function Card({
title,
className,
action,
subtitle,
children,
}: {
title: string;
className?: string;
/** an icon button right after the title */
action?: React.ReactNode;
/** a muted line under the title */
subtitle?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div
className={cn(
"rounded-lg bg-background_alt p-2.5 pb-5 md:rounded-2xl",
className,
)}
>
<div className="mb-5 flex flex-col">
<div className="flex items-center gap-3">
<span>{title}</span>
{action}
</div>
{subtitle && (
<div className="text-xs text-muted-foreground">{subtitle}</div>
)}
</div>
{children}
</div>
);
}
function InlineAction({
label,
busy,
disabled,
onClick,
}: {
label: string;
busy?: boolean;
disabled?: boolean;
onClick: () => void;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-6 shrink-0 text-muted-foreground hover:text-primary"
aria-label={label}
disabled={busy || disabled}
onClick={onClick}
>
{busy ? (
<ActivityIndicator className="size-3.5" size={14} />
) : (
<LuRefreshCw className="size-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{label}</TooltipContent>
</TooltipPortal>
</Tooltip>
);
}
function Group({ title, rows }: { title: string; rows: HardwareRow[] }) {
const { t } = useTranslation(["views/system"]);
return (
<Card title={title}>
{rows.length === 0 ? (
<div className="text-sm text-muted-foreground">
{t("health.hardware.nothingConfigured")}
</div>
) : (
<div className="flex flex-col gap-3">
{rows.map((row) => (
<HardwareStatusRow key={row.id} row={row} />
))}
</div>
)}
</Card>
);
}
/** Stream checks live here: the check sits next to its result. */
function StreamsGroup() {
const { t } = useTranslation(["views/system", "views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const { stream } = useHealthChecks();
const summary = useMemo(
() => (config ? streamHealth(config, stream.results, t) : undefined),
[config, stream.results, t],
);
const row: HardwareRow | undefined = useMemo(() => {
if (stream.running || !summary || !stream.results) {
return undefined;
}
const flagged = summary.checked - summary.clean;
return {
id: "streams",
state: flagged > 0 ? "warning" : "ok",
label: t("health.hardware.streamsChecked", { count: summary.checked }),
detail:
flagged > 0
? t("health.hardware.streamsFlagged", { count: flagged })
: t("health.hardware.streamsClean"),
};
}, [stream.running, stream.results, summary, t]);
return (
<Card
title={t("health.hardware.cameraStreams")}
action={
<InlineAction
label={
stream.results
? t("health.hardware.runAgain")
: t("health.hardware.runStreamChecks")
}
busy={stream.running}
disabled={!config}
onClick={() => stream.run()}
/>
}
subtitle={
stream.running
? t("health.hardware.streamsChecking", {
done: stream.total - stream.pending.length,
total: stream.total,
})
: stream.results && (
<>
{t("health.hardware.checked")}{" "}
<TimeAgo time={stream.results.checkedAt * 1000} dense />
</>
)
}
>
<div className="flex flex-col gap-3 text-sm" data-testid="camera-streams">
{row ? (
<HardwareStatusRow row={row} />
) : (
<HardwareStatusRow
row={{
id: "streams",
state: "unknown",
label: t("health.hardware.streamsNotChecked"),
}}
/>
)}
</div>
</Card>
);
}
function HardwareHeading() {
const { t } = useTranslation(["views/system"]);
const { hardware } = useHealthChecks();
return (
<div className="flex flex-col">
<div className="flex items-center gap-3">
<div className="text-md font-medium text-primary-variant">
{t("health.hardware.title")}
</div>
<InlineAction
label={t("health.hardware.recheck")}
busy={hardware.rechecking}
onClick={() => hardware.recheck()}
/>
</div>
{hardware.rechecking ? (
<div className="text-xs text-muted-foreground">
{t("health.hardware.probing")}
</div>
) : (
hardware.probedAt && (
<div className="text-xs text-muted-foreground">
{t("health.hardware.probed")}{" "}
<TimeAgo time={hardware.probedAt * 1000} dense />
</div>
)
)}
</div>
);
}
export default function HardwarePane() {
const { t } = useTranslation(["views/system"]);
const { rows, statsLoaded } = useHardwareHealth();
return (
<div className="flex flex-col gap-4">
<HardwareHeading />
{!rows ? (
<Skeleton className="h-40 w-full rounded-lg md:rounded-2xl" />
) : (
<>
<div className="flex flex-col gap-2">
<div className="grid grid-cols-1 gap-2 md:grid-cols-3">
<Group
title={t("health.hardware.objectDetection")}
rows={rows.detection}
/>
<Group
title={t("health.hardware.hardwareAcceleration")}
rows={rows.hwaccel}
/>
<Group
title={t("health.hardware.enrichments.title")}
rows={rows.enrichments}
/>
</div>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
<StreamsGroup />
<Card title={t("health.hardware.cameraConnections")}>
{!statsLoaded ? (
<Skeleton className="h-10 w-full" />
) : rows.cameras.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FaCircleCheck className="size-4 text-success" />
{t("health.hardware.allCamerasExcellent")}
</div>
) : (
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{rows.cameras.map((cell) => (
<div
key={cell.camera}
className="flex items-center gap-2 text-sm"
data-testid={`camera-connection-${cell.camera}`}
>
<ConnectionQualityIndicator
quality={cell.quality}
expectedFps={cell.expectedFps}
reconnects={cell.reconnects}
stalls={cell.stalls}
/>
<CameraNameLabel
camera={cell.camera}
className="smart-capitalize"
/>
<span className="text-muted-foreground">
{t("health.hardware.fps", {
camera: cell.cameraFps.toFixed(1),
expected: cell.expectedFps,
})}
</span>
</div>
))}
</div>
)}
</Card>
</div>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,47 @@
import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
import { LuCircleHelp, LuX } from "react-icons/lu";
import { cn } from "@/lib/utils";
import type { HardwareRow } from "@/utils/health";
const MESSAGE_COLOR: Record<HardwareRow["state"], string> = {
ok: "text-success",
warning: "text-yellow-500",
error: "text-danger",
unknown: "text-muted-foreground",
};
export default function HardwareStatusRow({ row }: { row: HardwareRow }) {
return (
<div
className="flex items-start gap-2 text-sm"
data-testid={`hardware-row-${row.id}`}
data-state={row.state}
>
<div className="mt-0.5 flex shrink-0">
{row.state === "ok" && (
<FaCircleCheck className="size-4 text-success" />
)}
{row.state === "warning" && (
<FaTriangleExclamation className="size-4 text-yellow-500" />
)}
{row.state === "error" && <LuX className="size-4 text-danger" />}
{row.state === "unknown" && (
<LuCircleHelp className="size-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0">
<div>
<span>{row.label}</span>
{row.detail && (
<span className="ml-2 text-muted-foreground">{row.detail}</span>
)}
</div>
{row.message && (
<div className={cn("mt-0.5", MESSAGE_COLOR[row.state])}>
{row.message}
</div>
)}
</div>
</div>
);
}
+136 -54
View File
@@ -7,82 +7,164 @@ import {
LuSlidersHorizontal,
LuX,
} from "react-icons/lu";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { useDocDomain } from "@/hooks/use-doc-domain";
import type { HealthProblem } from "@/types/health";
type HealthProblemRowProps = {
problem: HealthProblem;
};
const ICON_BUTTON_CLASS =
"size-6 shrink-0 text-muted-foreground hover:text-primary";
function RowAction({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{label}</TooltipContent>
</TooltipPortal>
</Tooltip>
);
}
export default function HealthProblemRow({ problem }: HealthProblemRowProps) {
const { t } = useTranslation(["views/system"]);
const { t } = useTranslation(["views/system", "common"]);
const { getLocaleDocUrl } = useDocDomain();
const hasDetails = problem.scope || problem.meta;
const hasActions =
problem.link ||
problem.docLink ||
problem.externalLink ||
problem.onDismiss;
return (
<div
className="flex items-start gap-2 border-b border-border px-1 py-2 text-sm last:border-b-0"
className="flex items-center gap-2 border-b border-border px-1 py-2 text-sm last:border-b-0"
data-testid={`health-problem-${problem.id}`}
data-severity={problem.severity}
>
<div className="mt-0.5 flex shrink-0">
{problem.severity === "error" && <LuX className="size-4 text-danger" />}
{problem.severity === "warning" && (
<FaTriangleExclamation className="size-4 text-yellow-500" />
)}
{problem.severity === "info" && (
<LuInfo className="size-4 text-selected" />
<div className="flex shrink-0 self-start pt-0.5">
{problem.pending ? (
<ActivityIndicator className="" size={16} />
) : (
<>
{problem.severity === "error" && (
<LuX className="size-4 text-danger" />
)}
{problem.severity === "warning" && (
<FaTriangleExclamation className="size-4 text-yellow-500" />
)}
{problem.severity === "info" && (
<LuInfo className="size-4 text-selected" />
)}
</>
)}
</div>
{problem.scope && (
<span className="rounded-md bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground smart-capitalize">
{problem.scopeIsCamera ? (
<CameraNameLabel camera={problem.scope} />
) : (
problem.scope
)}
</span>
)}
<div className="min-w-0 flex-1">
<div>{problem.text}</div>
{problem.meta && (
<div className="mt-0.5 text-xs text-muted-foreground">
{problem.meta}
{hasDetails && (
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{problem.scope && (
<span className="rounded bg-secondary px-1.5 py-0.5 text-xs text-primary-variant">
{problem.scopeIsCamera ? (
<CameraNameLabel camera={problem.scope} />
) : (
problem.scope
)}
</span>
)}
{problem.meta && <span>{problem.meta}</span>}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2 text-muted-foreground">
{problem.onDismiss && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2"
onClick={problem.onDismiss}
aria-label={t("health.notices.dismiss")}
>
{t("health.notices.dismiss")}
</Button>
)}
{problem.link && (
<Link
to={problem.link}
aria-label={t("health.notices.openSettings")}
className="hover:text-primary"
>
<LuSlidersHorizontal className="size-4" />
</Link>
)}
{problem.externalLink && (
<a
href={problem.externalLink}
target="_blank"
rel="noreferrer"
aria-label={t("health.notices.openLink")}
className="hover:text-primary"
>
<LuExternalLink className="size-4" />
</a>
)}
</div>
{hasActions && (
<div className="ml-auto flex shrink-0 items-center gap-1">
{problem.link && (
<RowAction label={t("health.notices.openSettings")}>
<Button
asChild
variant="ghost"
size="icon"
className={ICON_BUTTON_CLASS}
>
<Link
to={problem.link}
aria-label={t("health.notices.openSettings")}
>
<LuSlidersHorizontal className="size-3.5" />
</Link>
</Button>
</RowAction>
)}
{problem.docLink && (
<RowAction label={t("readTheDocumentation", { ns: "common" })}>
<Button
asChild
variant="ghost"
size="icon"
className={ICON_BUTTON_CLASS}
>
<a
href={getLocaleDocUrl(problem.docLink)}
target="_blank"
rel="noreferrer"
aria-label={t("readTheDocumentation", { ns: "common" })}
>
<LuExternalLink className="size-3.5" />
</a>
</Button>
</RowAction>
)}
{problem.externalLink && (
<RowAction label={t("health.notices.openLink")}>
<Button
asChild
variant="ghost"
size="icon"
className={ICON_BUTTON_CLASS}
>
<a
href={problem.externalLink}
target="_blank"
rel="noreferrer"
aria-label={t("health.notices.openLink")}
>
<LuExternalLink className="size-3.5" />
</a>
</Button>
</RowAction>
)}
{problem.onDismiss && (
<RowAction label={t("health.notices.dismiss")}>
<Button
variant="ghost"
size="icon"
className={ICON_BUTTON_CLASS}
aria-label={t("health.notices.dismiss")}
onClick={problem.onDismiss}
>
<LuX className="size-3.5" />
</Button>
</RowAction>
)}
</div>
)}
</div>
);
}
+68 -7
View File
@@ -7,8 +7,14 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useNotices } from "@/hooks/use-notices";
import { useDateLocale } from "@/hooks/use-date-locale";
import { useTimezone } from "@/hooks/use-date-utils";
import useStats, { useAutoFrigateStats } from "@/hooks/use-stats";
import { useHealthChecks } from "@/hooks/use-health-checks";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import { releaseUrl } from "@/utils/versionUtil";
import { evaluateConfigHealth } from "@/utils/configHealth";
import { isStartupWindow } from "@/utils/health";
import { sortHealthProblems } from "@/utils/healthSort";
import { streamHealth } from "@/utils/streamHealth";
import type { FrigateConfig } from "@/types/frigateConfig";
import type { HealthProblem } from "@/types/health";
import type { Notice, NoticeKind, NoticeStats } from "@/types/notice";
@@ -75,6 +81,7 @@ function useNoticeProblems(
return {
id: `notice:${notice.id}`,
source: "registry" as const,
severity: notice.severity,
scope: notice.scope ?? undefined,
scopeIsCamera: notice.category === "camera",
@@ -93,19 +100,73 @@ function useNoticeProblems(
}
export default function NoticesPane() {
const { t } = useTranslation(["views/system"]);
const { t } = useTranslation(["views/system", "views/settings"]);
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const stats = useAutoFrigateStats();
const { notices, statsByKind, dismiss } = useNotices();
const problems = useNoticeProblems(notices, statsByKind, dismiss);
const registryProblems = useNoticeProblems(notices, statsByKind, dismiss);
const { potentialProblems } = useStats(stats);
const {
stream: { results },
} = useHealthChecks();
const liveProblems = useMemo<HealthProblem[]>(() => {
if (!stats) {
return [];
}
if (isStartupWindow(stats)) {
return [
{
id: "live:startup",
source: "live",
severity: "info",
text: t("health.notices.startupWindow"),
},
];
}
return potentialProblems.map((problem, index) => ({
id: `live:${index}:${problem.text}`,
source: "live",
severity: problem.severity,
text: problem.text,
link: problem.relevantLink?.replace(/^(?!\/)/, "/"),
}));
}, [stats, potentialProblems, t]);
const configProblems = useMemo<HealthProblem[]>(
() => (config ? evaluateConfigHealth(config, t) : []),
[config, t],
);
const streamProblems = useMemo<HealthProblem[]>(
() => (config ? streamHealth(config, results, t).problems : []),
[config, results, t],
);
const problems = useMemo(
() =>
sortHealthProblems([
...registryProblems,
...liveProblems,
...configProblems,
...streamProblems,
]),
[registryProblems, liveProblems, configProblems, streamProblems],
);
const loading = notices === undefined || !config;
return (
<div className="flex flex-col gap-4">
<div>
<div className="text-md font-medium text-primary-variant">
{t("health.notices.title")}
</div>
<div className="text-md font-medium text-primary-variant">
{t("health.notices.title")}
</div>
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
{notices === undefined ? (
{loading ? (
<Skeleton className="h-24 w-full" />
) : problems.length === 0 ? (
<div className="flex items-center gap-2 px-1 py-2 text-sm">