mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 09:48:58 +03:00
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:
committed by
Nicolas Mowen
parent
6d33b31bc6
commit
70ce193e09
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user