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
+14 -4
View File
@@ -187,10 +187,20 @@ export default function Statusbar() {
</div>
<div className="no-scrollbar flex h-full max-w-[50%] items-center gap-2 overflow-x-auto">
{Object.entries(messages).length === 0 ? (
<div className="flex items-center gap-2 text-sm">
<FaCheck className="size-3 text-green-500" />
{t("stats.healthy")}
</div>
isAdmin ? (
<Link
to="/system#health"
className="flex items-center gap-2 text-sm"
>
<FaCheck className="size-3 text-green-500" />
{t("stats.healthy")}
</Link>
) : (
<div className="flex items-center gap-2 text-sm">
<FaCheck className="size-3 text-green-500" />
{t("stats.healthy")}
</div>
)
) : (
Object.entries(messages).map(([key, messageArray]) => (
<div key={key} className="flex h-full items-center gap-2">
@@ -6,6 +6,7 @@ const audio: SectionConfigOverrides = {
messages: [
{
key: "no-audio-role",
health: (ctx) => ctx.fullCameraConfig?.audio?.enabled === true,
messageKey: "configMessages.audio.noAudioRole",
severity: "warning",
condition: (ctx) => {
@@ -6,6 +6,8 @@ const audioTranscription: SectionConfigOverrides = {
messages: [
{
key: "audio-detection-disabled",
health: (ctx) =>
ctx.fullCameraConfig?.audio_transcription?.enabled === true,
messageKey: "configMessages.audioTranscription.audioDetectionDisabled",
severity: "warning",
condition: (ctx) => {
@@ -15,6 +15,7 @@ const detect: SectionConfigOverrides = {
fieldMessages: [
{
key: "detect-resolution-not-multiple-of-four",
health: true,
field: "width",
position: "before",
messageKey: "configMessages.detect.resolutionShouldBeMultipleOfFour",
@@ -46,6 +47,7 @@ const detect: SectionConfigOverrides = {
},
{
key: "detect-resolution-high",
health: true,
field: "width",
position: "before",
messageKey: "configMessages.detect.resolutionHigh",
@@ -61,6 +63,7 @@ const detect: SectionConfigOverrides = {
},
{
key: "detect-square-resolution",
health: true,
field: "width",
position: "before",
messageKey: "configMessages.detect.squareResolution",
@@ -112,6 +115,7 @@ const detect: SectionConfigOverrides = {
},
{
key: "detect-scene-without-model",
health: true,
field: "scene",
position: "after",
messageKey: "configMessages.detect.sceneWithoutModel",
@@ -127,6 +131,7 @@ const detect: SectionConfigOverrides = {
},
{
key: "fps-greater-than-five",
health: true,
field: "fps",
messageKey: "configMessages.detect.fpsGreaterThanFive",
severity: "info",
@@ -6,6 +6,8 @@ const faceRecognition: SectionConfigOverrides = {
messages: [
{
key: "global-disabled",
health: (ctx) =>
ctx.fullCameraConfig?.face_recognition?.enabled === true,
messageKey: "configMessages.faceRecognition.globalDisabled",
severity: "warning",
condition: (ctx) => {
@@ -15,6 +17,9 @@ const faceRecognition: SectionConfigOverrides = {
},
{
key: "person-not-tracked",
health: (ctx) =>
ctx.fullCameraConfig?.face_recognition?.enabled === true &&
ctx.fullConfig.face_recognition?.enabled === true,
messageKey: "configMessages.faceRecognition.personNotTracked",
severity: "info",
condition: (ctx) => {
@@ -52,6 +52,7 @@ const ffmpeg: SectionConfigOverrides = {
},
{
key: "inputs-missing-go2rtc-stream",
health: true,
field: "inputs",
position: "before",
messageKey: "configMessages.ffmpeg.inputsMissingGo2rtcStream",
@@ -7,6 +7,7 @@ const lpr: SectionConfigOverrides = {
messages: [
{
key: "global-disabled",
health: (ctx) => ctx.fullCameraConfig?.lpr?.enabled === true,
messageKey: "configMessages.lpr.globalDisabled",
severity: "warning",
condition: (ctx) => {
@@ -16,6 +17,9 @@ const lpr: SectionConfigOverrides = {
},
{
key: "vehicle-not-tracked",
health: (ctx) =>
ctx.fullCameraConfig?.lpr?.enabled === true &&
ctx.fullConfig.lpr?.enabled === true,
messageKey: "configMessages.lpr.vehicleNotTracked",
severity: "info",
condition: (ctx) => {
@@ -35,6 +35,7 @@ const models: SectionConfigOverrides = {
},
{
key: "model-input-dimensions-not-detect-resolution",
health: true,
field: "height",
position: "after",
messageKey: "configMessages.model.inputDimensionsNotDetectResolution",
@@ -72,6 +72,9 @@ const objects: SectionConfigOverrides = {
fieldMessages: [
{
key: "genai-no-descriptions-provider",
health: (ctx) =>
(ctx.formData as { genai?: { enabled?: boolean } })?.genai
?.enabled === true,
field: "genai.enabled",
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
severity: "warning",
@@ -28,6 +28,8 @@ const onvif: SectionConfigOverrides = {
fieldMessages: [
{
key: "autotracking-no-zones",
health: (ctx) =>
ctx.fullCameraConfig?.onvif?.autotracking?.enabled === true,
field: "autotracking.required_zones",
messageKey: "configMessages.onvif.autotrackingNoZones",
severity: "error",
@@ -6,6 +6,7 @@ const record: SectionConfigOverrides = {
messages: [
{
key: "no-record-role",
health: (ctx) => ctx.fullCameraConfig?.record?.enabled === true,
messageKey: "configMessages.record.noRecordRole",
severity: "warning",
condition: (ctx) => {
@@ -17,6 +18,7 @@ const record: SectionConfigOverrides = {
},
{
key: "no-record-sub-role",
health: (ctx) => ctx.fullCameraConfig?.record?.sub?.enabled === true,
messageKey: "configMessages.record.noRecordSubRole",
severity: "warning",
condition: (ctx) => {
@@ -43,6 +43,9 @@ const review: SectionConfigOverrides = {
},
{
key: "genai-no-descriptions-provider",
health: (ctx) =>
(ctx.formData as { genai?: { enabled?: boolean } })?.genai
?.enabled === true,
field: "genai.enabled",
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
severity: "warning",
@@ -57,6 +60,7 @@ const review: SectionConfigOverrides = {
},
{
key: "genai-image-source-recordings-record-disabled",
health: true,
field: "genai.image_source",
messageKey:
"configMessages.review.genaiImageSourceRecordingsRecordDisabled",
@@ -21,6 +21,7 @@ const semanticSearch: SectionConfigOverrides = {
fieldMessages: [
{
key: "jinav2-small-model-size",
health: (ctx) => ctx.fullConfig.semantic_search?.enabled === true,
field: "model_size",
messageKey: "configMessages.semanticSearch.jinav2SmallModelSize",
severity: "warning",
@@ -28,6 +28,13 @@ export type ConditionalMessage = {
values?: Record<string, unknown>;
/** Optional documentation path (e.g. "/configuration/object_detectors#model"). */
docLink?: string;
/**
* Whether the Health tab evaluates this message against the saved config.
* Absent or false: form only. true: shown whenever condition() holds. A
* function: shown when both condition(ctx) and health(ctx) hold, for
* messages the form deliberately shows even when the feature is off.
*/
health?: boolean | ((ctx: MessageConditionContext) => boolean);
};
/** Field-level conditional message, adds field targeting */
@@ -0,0 +1,67 @@
/** settings page id that edits each config section at camera level */
export const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
detect: "cameraDetect",
ffmpeg: "cameraFfmpeg",
record: "cameraRecording",
snapshots: "cameraSnapshots",
motion: "cameraMotion",
objects: "cameraObjects",
review: "cameraReview",
audio: "cameraAudioEvents",
audio_transcription: "cameraAudioTranscription",
notifications: "cameraNotifications",
live: "cameraLivePlayback",
birdseye: "cameraBirdseye",
face_recognition: "cameraFaceRecognition",
lpr: "cameraLpr",
timestamp_style: "cameraTimestampStyle",
onvif: "cameraOnvif",
};
/** settings page id that edits each config section at global level */
export const GLOBAL_PAGE_BY_SECTION: Record<string, string> = {
detect: "globalDetect",
record: "globalRecording",
snapshots: "globalSnapshots",
ffmpeg: "globalFfmpeg",
motion: "globalMotion",
objects: "globalObjects",
review: "globalReview",
audio: "globalAudioEvents",
live: "globalLivePlayback",
timestamp_style: "globalTimestampStyle",
database: "systemDatabase",
tls: "systemTls",
auth: "systemAuthentication",
networking: "systemNetworking",
proxy: "systemProxy",
ui: "systemUi",
logger: "systemLogging",
environment_vars: "systemEnvironmentVariables",
telemetry: "systemTelemetry",
birdseye: "systemBirdseye",
models: "systemDetectorsAndModel",
mqtt: "systemMqtt",
semantic_search: "integrationSemanticSearch",
genai: "integrationGenerativeAi",
face_recognition: "integrationFaceRecognition",
lpr: "integrationLpr",
classification: "integrationObjectClassification",
audio_transcription: "integrationAudioTranscription",
};
export function settingsLink(
section: string,
level: "global" | "camera",
cameraName?: string,
): string | undefined {
if (level === "camera") {
const page = CAMERA_PAGE_BY_SECTION[section];
return page && cameraName
? `/settings?page=${page}&camera=${encodeURIComponent(cameraName)}`
: undefined;
}
const page = GLOBAL_PAGE_BY_SECTION[section];
return page ? `/settings?page=${page}` : undefined;
}
@@ -25,24 +25,7 @@ import {
pathMatchesHiddenPattern,
} from "@/utils/configUtil";
import { useOverrideFieldLabel } from "./useOverrideFieldLabel";
const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
detect: "cameraDetect",
ffmpeg: "cameraFfmpeg",
record: "cameraRecording",
snapshots: "cameraSnapshots",
motion: "cameraMotion",
objects: "cameraObjects",
review: "cameraReview",
audio: "cameraAudioEvents",
audio_transcription: "cameraAudioTranscription",
notifications: "cameraNotifications",
live: "cameraLivePlayback",
birdseye: "cameraBirdseye",
face_recognition: "cameraFaceRecognition",
lpr: "cameraLpr",
timestamp_style: "cameraTimestampStyle",
};
import { CAMERA_PAGE_BY_SECTION } from "@/components/config-form/sectionPages";
const MAX_FIELDS_PER_CAMERA = 5;
+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">
@@ -20,6 +20,7 @@ import { FaCircleCheck, FaTriangleExclamation } from "react-icons/fa6";
import { LuX } from "react-icons/lu";
import { Card, CardContent } from "../../ui/card";
import { maskUri } from "@/utils/cameraUtil";
import { ffprobeToTestResult, getStreamIssues } from "@/utils/streamIssues";
type Step4ValidationProps = {
wizardData: Partial<WizardFormData>;
@@ -74,44 +75,7 @@ export default function Step4Validation({
timeout: 10000,
});
if (response.data?.[0]?.return_code === 0) {
const probeData = response.data[0];
const streamData = probeData.stdout.streams || [];
const videoStream = streamData.find(
(s: { codec_type?: string; codec_name?: string }) =>
s.codec_type === "video" ||
s.codec_name?.includes("h264") ||
s.codec_name?.includes("h265"),
);
const audioStream = streamData.find(
(s: { codec_type?: string; codec_name?: string }) =>
s.codec_type === "audio" ||
s.codec_name?.includes("aac") ||
s.codec_name?.includes("mp3"),
);
const resolution = videoStream
? `${videoStream.width}x${videoStream.height}`
: undefined;
const fps = videoStream?.avg_frame_rate
? parseFloat(videoStream.avg_frame_rate.split("/")[0]) /
parseFloat(videoStream.avg_frame_rate.split("/")[1])
: undefined;
return {
success: true,
resolution,
videoCodec: videoStream?.codec_name,
audioCodec: audioStream?.codec_name,
fps: fps && !isNaN(fps) ? fps : undefined,
};
} else {
const error = response.data?.[0]?.stderr || "Unknown error";
return { success: false, error };
}
return ffprobeToTestResult(response.data?.[0]);
} catch (error) {
const axiosError = error as {
response?: { data?: { message?: string; detail?: string } };
@@ -528,149 +492,21 @@ function StreamIssues({
}: StreamIssuesProps) {
const { t } = useTranslation(["views/settings"]);
const issues = useMemo(() => {
const result: Array<{
type: "good" | "warning" | "error";
message: string;
}> = [];
if (wizardData.brandTemplate === "reolink") {
const streamUrl = stream.url.toLowerCase();
if (streamUrl.startsWith("rtsp://")) {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.brands.reolink-rtsp"),
});
}
if (streamUrl.startsWith("http://") && !stream.useFfmpeg) {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.brands.reolink-http"),
});
}
}
// Video codec check
if (stream.testResult?.videoCodec) {
const videoCodec = stream.testResult.videoCodec.toLowerCase();
if (["h264", "h265", "hevc"].includes(videoCodec)) {
result.push({
type: "good",
message: t("cameraWizard.step4.issues.videoCodecGood", {
codec: stream.testResult.videoCodec,
}),
});
}
}
// Audio codec check
if (stream.roles.includes("record")) {
if (stream.testResult?.audioCodec) {
const audioCodec = stream.testResult.audioCodec.toLowerCase();
if (audioCodec === "aac") {
result.push({
type: "good",
message: t("cameraWizard.step4.issues.audioCodecGood", {
codec: stream.testResult.audioCodec,
}),
});
} else {
result.push({
type: "error",
message: t("cameraWizard.step4.issues.audioCodecRecordError"),
});
}
} else {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.noAudioWarning"),
});
}
}
// Audio detection check
if (stream.roles.includes("audio")) {
if (!stream.testResult?.audioCodec) {
result.push({
type: "error",
message: t("cameraWizard.step4.issues.audioCodecRequired"),
});
}
}
// Restreaming check
if (stream.roles.includes("record")) {
if (stream.restream) {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.restreamingWarning"),
});
}
}
if (stream.roles.includes("detect") && stream.testResult) {
const probedResolution = stream.testResult.resolution;
let probedWidth = 0;
let probedHeight = 0;
if (probedResolution) {
const [w, h] = probedResolution.split("x").map(Number);
if (!isNaN(w) && !isNaN(h)) {
probedWidth = w;
probedHeight = h;
}
}
if (probedWidth <= 0 || probedHeight <= 0) {
result.push({
type: "error",
message: t("cameraWizard.step4.issues.resolutionUnknown"),
});
} else {
const minDimension = Math.min(probedWidth, probedHeight);
const maxDimension = Math.max(probedWidth, probedHeight);
if (minDimension > 1080) {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.resolutionHigh", {
resolution: probedResolution,
}),
});
} else if (maxDimension < 640) {
result.push({
type: "error",
message: t("cameraWizard.step4.issues.resolutionLow", {
resolution: probedResolution,
}),
});
}
}
}
// Substream Check
if (
wizardData.brandTemplate == "dahua" &&
stream.roles.includes("detect") &&
stream.url.includes("subtype=1")
) {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.dahua.substreamWarning"),
});
}
if (
wizardData.brandTemplate == "hikvision" &&
stream.roles.includes("detect") &&
stream.url.includes("/102")
) {
result.push({
type: "warning",
message: t("cameraWizard.step4.issues.hikvision.substreamWarning"),
});
}
return result;
}, [stream, wizardData, t]);
const issues = useMemo(
() =>
getStreamIssues(
{
url: stream.url,
roles: stream.roles,
brand: wizardData.brandTemplate,
useFfmpeg: stream.useFfmpeg,
restream: stream.restream,
testResult: stream.testResult,
},
t,
),
[stream, wizardData, t],
);
if (issues.length === 0) {
return null;