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
+204
View File
@@ -0,0 +1,204 @@
import type { TFunction } from "i18next";
import { sectionConfigs } from "@/components/config-form/sectionConfigs";
import type {
ConditionalMessage,
MessageConditionContext,
} from "@/components/config-form/section-configs/types";
import { settingsLink } from "@/components/config-form/sectionPages";
import type { ConfigSectionData } from "@/types/configForm";
import type { FrigateConfig } from "@/types/frigateConfig";
import type { HealthProblem } from "@/types/health";
import { getSectionConfig } from "@/utils/configUtil";
import { activeCameras } from "@/utils/health";
function healthMessages(
section: string,
level: "global" | "camera",
): ConditionalMessage[] {
const config = getSectionConfig(section, level);
return [...(config.messages ?? []), ...(config.fieldMessages ?? [])].filter(
(message) => message.health,
);
}
function isActive(
message: ConditionalMessage,
ctx: MessageConditionContext,
): boolean {
if (!message.condition(ctx)) {
return false;
}
return typeof message.health === "function" ? message.health(ctx) : true;
}
function toProblem(
message: ConditionalMessage,
section: string,
ctx: MessageConditionContext,
scope: string | undefined,
scopeIsCamera: boolean,
idSuffix: string,
t: TFunction,
): HealthProblem {
return {
id: `config:${section}:${message.key}:${idSuffix}`,
source: "config",
severity: message.severity,
scope,
scopeIsCamera,
text: t(message.messageKey, {
ns: "views/settings",
...(message.values ?? {}),
}),
docLink: message.docLink,
link: settingsLink(section, ctx.level, ctx.cameraName),
};
}
/**
* Evaluate every config message flagged for the Health tab against the saved,
* resolved config. The rules stay in the section configs, so the settings
* form and the Health tab can never disagree.
*/
/**
* Whether a camera section still carries the global section's values. Only
* keys the global block sets count: the resolved global detect leaves width
* and height null while every camera has numbers, so a full JSON comparison
* would never match.
*/
function inheritsGlobal(
cameraSection: Record<string, unknown>,
globalSection: Record<string, unknown>,
): boolean {
return Object.entries(globalSection).every(([key, globalValue]) => {
if (globalValue === null || globalValue === undefined) {
return true;
}
const cameraValue = cameraSection[key];
if (
typeof globalValue === "object" &&
!Array.isArray(globalValue) &&
typeof cameraValue === "object" &&
cameraValue !== null &&
!Array.isArray(cameraValue)
) {
return inheritsGlobal(
cameraValue as Record<string, unknown>,
globalValue as Record<string, unknown>,
);
}
return JSON.stringify(cameraValue) === JSON.stringify(globalValue);
});
}
export function evaluateConfigHealth(
config: FrigateConfig,
t: TFunction,
): HealthProblem[] {
const problems: HealthProblem[] = [];
const firedGlobally = new Set<string>();
const cameras = activeCameras(config);
const record = config as unknown as Record<string, unknown>;
Object.keys(sectionConfigs).forEach((section) => {
const globalMessages = healthMessages(section, "global");
if (globalMessages.length > 0) {
const sectionData = record[section];
// models is a list; every other section is one object
const items: {
formData: ConfigSectionData;
scope?: string;
idSuffix: string;
}[] =
section === "models" && Array.isArray(sectionData)
? sectionData.map((model, index) => ({
formData: model as ConfigSectionData,
scope: t(
`detectionModels.scenes.${(model as { scene?: string }).scene || "all"}`,
{ ns: "views/settings" },
),
idSuffix: `model${index}`,
}))
: [
{
formData: (sectionData ?? {}) as ConfigSectionData,
idSuffix: "global",
},
];
items.forEach(({ formData, scope, idSuffix }) => {
const ctx: MessageConditionContext = {
fullConfig: config,
level: "global",
formData,
};
globalMessages
.filter((message) => isActive(message, ctx))
.forEach((message) => {
const problem = toProblem(
message,
section,
ctx,
scope,
false,
idSuffix,
t,
);
firedGlobally.add(`${section}:${message.key}`);
problems.push(problem);
});
});
}
const cameraMessages = healthMessages(section, "camera");
if (cameraMessages.length > 0) {
const globalSection = (record[section] ?? {}) as Record<string, unknown>;
cameras.forEach((camera) => {
const cameraRecord = camera as unknown as Record<string, unknown>;
const sectionData = cameraRecord[section] ?? {};
// cameras inherit global values, so a problem the global row already
// states would repeat once per camera; a camera that overrides the
// section keeps its own row and link
const inherited = inheritsGlobal(
sectionData as Record<string, unknown>,
globalSection,
);
const ctx: MessageConditionContext = {
fullConfig: config,
fullCameraConfig: camera,
level: "camera",
cameraName: camera.name,
formData: sectionData as ConfigSectionData,
};
cameraMessages
.filter((message) => isActive(message, ctx))
.forEach((message) => {
const problem = toProblem(
message,
section,
ctx,
camera.name,
true,
camera.name,
t,
);
if (
!(inherited && firedGlobally.has(`${section}:${message.key}`))
) {
problems.push(problem);
}
});
});
}
});
return problems;
}
+779
View File
@@ -0,0 +1,779 @@
import type { TFunction } from "i18next";
import type {
DetectionHardware,
HwaccelRecommendation,
} from "@/types/hardware";
import type {
CameraConfig,
DetectionModelConfig,
FrigateConfig,
} from "@/types/frigateConfig";
import type { FrigateStats, GpuVendor } from "@/types/stats";
import { InferenceThreshold } from "@/types/graph";
import { summarizeDevices } from "@/utils/detectionHardware";
import { isReplayCamera } from "@/utils/cameraUtil";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
export type HealthState = "ok" | "warning" | "error" | "unknown";
export type HardwareRow = {
id: string;
state: HealthState;
label: string;
/** muted text on the label line, what is actually running */
detail?: string;
/** reason line under the label, colored by state */
message?: string;
};
/** seconds after startup during which stats-based rules report unknown */
export const STARTUP_WINDOW_S = 120;
// ---------------------------------------------------------------- detection
/**
* Detector runner names exactly as the backend's runner_names() builds them:
* every model's devices in config order, first occurrence is the raw device
* string, the Nth repeat is "raw#N".
*/
export function runnerNames(models: DetectionModelConfig[]): string[] {
const counts = new Map<string, number>();
const names: string[] = [];
models.forEach((model) => {
model.devices.forEach((raw) => {
const count = (counts.get(raw) ?? 0) + 1;
counts.set(raw, count);
names.push(count === 1 ? raw : `${raw}#${count}`);
});
});
return names;
}
/** detectors the probe reports; anything else cannot be checked for presence */
export const PROBED_DETECTORS = new Set([
"cpu",
"edgetpu",
"hailo8l",
"memryx",
"openvino",
"onnx",
"tensorrt",
"rknn",
"axengine",
"synaptics",
]);
/** detectors that fall back to the CPU when no accelerator is present */
const CPU_FALLBACK_DETECTORS = new Set(["onnx", "openvino"]);
export type DevicePresence = "present" | "unverified" | "absent";
/**
* Whether a configured device string was found by the hardware probe.
* "unverified" means the detector's hardware is present but the probe does
* not enumerate this particular device (openvino:AUTO, rknn:0), so it must
* not be reported as missing.
*/
export function devicePresence(
device: string,
hardware: DetectionHardware[],
): DevicePresence {
const [detector, ...rest] = device.split(":");
const devicePart = rest.join(":");
if (detector === "cpu" || devicePart.toUpperCase() === "CPU") {
return "present";
}
if (!PROBED_DETECTORS.has(detector)) {
return "unverified";
}
const entries = hardware.filter((entry) => entry.detector === detector);
const generic = devicePart === "" || devicePart.toUpperCase() === "AUTO";
if (entries.length === 0) {
// a bare onnx or openvino runs on the CPU when nothing is attached, so
// an empty probe is not proof of missing hardware for those
return generic && CPU_FALLBACK_DETECTORS.has(detector)
? "unverified"
: "absent";
}
if (generic) {
return "present";
}
const unitMatch = entries.some((entry) =>
entry.units.some(
(unit) =>
unit.device === device ||
unit.device.startsWith(`${device}:`) ||
unit.device.startsWith(`${device}.`),
),
);
return unitMatch ? "present" : "unverified";
}
type DetectionArgs = {
models: DetectionModelConfig[];
hardware: DetectionHardware[] | undefined;
probeFailed: boolean;
stats: FrigateStats | undefined;
startup: boolean;
t: TFunction;
};
export function detectionRows({
models,
hardware,
probeFailed,
stats,
startup,
t,
}: DetectionArgs): HardwareRow[] {
const names = runnerNames(models);
let cursor = 0;
return models.map((model, index) => {
const modelRunners = names.slice(cursor, cursor + model.devices.length);
cursor += model.devices.length;
const label = t(`detectionModels.scenes.${model.scene || "all"}`, {
ns: "views/settings",
});
const id = `detection:${index}`;
const detail = probeFailed
? t("health.hardware.probeUnavailable", {
ns: "views/system",
})
: summarizeDevices(hardware ?? [], model.devices);
if (!probeFailed && hardware) {
const presence = new Map(
model.devices.map((device) => [
device,
devicePresence(device, hardware),
]),
);
const missing = [...presence]
.filter(([, state]) => state === "absent")
.map(([device]) => device);
if (missing.length > 0) {
return {
id,
state: "error",
label,
detail,
message: t("health.hardware.deviceNotFound", {
ns: "views/system",
devices: missing.join(", "),
}),
};
}
// unverified devices are skipped by the presence rule; the runtime
// rules below still decide the row
}
if (startup || !stats) {
return {
id,
state: "unknown",
label,
detail,
message: t("health.hardware.justStarted", {
ns: "views/system",
}),
};
}
const missingRunner = modelRunners.find((name) => !stats.detectors[name]);
if (missingRunner) {
return {
id,
state: "error",
label,
detail,
message: t("health.hardware.detectorNotRunning", {
ns: "views/system",
}),
};
}
const slowest = Math.max(
...modelRunners.map((name) => stats.detectors[name].inference_speed),
);
if (slowest > InferenceThreshold.error) {
return {
id,
state: "error",
label,
detail,
message: t("health.hardware.inferenceVerySlow", {
ns: "views/system",
speed: slowest,
}),
};
}
if (slowest > InferenceThreshold.warning) {
return {
id,
state: "warning",
label,
detail,
message: t("health.hardware.inferenceSlow", {
ns: "views/system",
speed: slowest,
}),
};
}
return {
id,
state: "ok",
label,
detail: [
detail,
t("health.hardware.inferenceMs", {
ns: "views/system",
speed: slowest,
}),
]
.filter(Boolean)
.join(" · "),
};
});
}
// ------------------------------------------------------------------ hwaccel
export type HwaccelFamilyKey =
| "nvidia"
| "vaapi"
| "intel-qsv"
| "rkmpp"
| "jetson"
| "rpi";
export type HwaccelClass =
| { kind: "none" }
| { kind: "custom" }
| { kind: "preset"; family: HwaccelFamilyKey };
const PRESET_FAMILIES: [string, HwaccelFamilyKey][] = [
["preset-nvidia", "nvidia"],
["preset-vaapi", "vaapi"],
["preset-intel-qsv", "intel-qsv"],
["preset-rk", "rkmpp"],
["preset-jetson", "jetson"],
["preset-rpi", "rpi"],
];
export function hwaccelFamily(value: string | string[]): HwaccelClass {
if (Array.isArray(value)) {
return value.length === 0 ? { kind: "none" } : { kind: "custom" };
}
// the backend resolves global and camera auto at startup; a literal auto
// left on an input means no hardware decoding for it at runtime
if (value === "" || value === "auto") {
return { kind: "none" };
}
const match = PRESET_FAMILIES.find(([prefix]) => value.startsWith(prefix));
return match ? { kind: "preset", family: match[1] } : { kind: "custom" };
}
const FAMILY_VENDORS: Record<HwaccelFamilyKey, GpuVendor[]> = {
nvidia: ["nvidia"],
jetson: ["nvidia"],
"intel-qsv": ["intel"],
vaapi: ["intel", "amd"],
rkmpp: ["rockchip"],
rpi: ["rpi"],
};
function decoderUsage(
family: HwaccelFamilyKey,
stats: FrigateStats | undefined,
): string | undefined {
if (!stats?.gpu_usages) {
return undefined;
}
const entry = Object.values(stats.gpu_usages).find(
(gpu) =>
gpu.vendor && FAMILY_VENDORS[family].includes(gpu.vendor) && gpu.dec,
);
return entry?.dec;
}
function valueKey(value: string | string[]): string {
return Array.isArray(value) ? JSON.stringify(value) : value;
}
type HwaccelArgs = {
config: FrigateConfig;
hwaccel: HwaccelRecommendation | undefined;
hwaccelFailed: boolean;
stats: FrigateStats | undefined;
t: TFunction;
};
export function hwaccelRows({
config,
hwaccel,
hwaccelFailed,
stats,
t,
}: HwaccelArgs): HardwareRow[] {
const cameras = activeCameras(config);
const camerasByValue = new Map<
string,
{ value: string | string[]; cameras: string[] }
>();
cameras.forEach((camera) => {
const values: (string | string[])[] = [camera.ffmpeg.hwaccel_args ?? ""];
camera.ffmpeg.inputs.forEach((input) => {
if (input.hwaccel_args && input.hwaccel_args.length > 0) {
values.push(input.hwaccel_args);
}
});
values.forEach((value) => {
const key = valueKey(value);
const entry = camerasByValue.get(key) ?? { value, cameras: [] };
if (!entry.cameras.includes(camera.name)) {
entry.cameras.push(camera.name);
}
camerasByValue.set(key, entry);
});
});
const familyName = (family: HwaccelFamilyKey | "none") =>
t(`setupWizard.hwaccel.families.${family}`, { ns: "views/setup" });
return [...camerasByValue.entries()].map(([key, entry]) => {
const id = `hwaccel:${key}`;
const cameraList =
entry.cameras.length === cameras.length
? t("health.hardware.allCameras", {
ns: "views/system",
})
: entry.cameras
.map((name) => resolveCameraName(config, name))
.join(", ");
const classified = hwaccelFamily(entry.value);
if (hwaccelFailed) {
return {
id,
state: "unknown",
label:
classified.kind === "preset" ? familyName(classified.family) : key,
detail: cameraList,
message: t("health.hardware.probeUnavailable", {
ns: "views/system",
}),
};
}
if (classified.kind === "custom") {
return {
id,
state: "unknown",
label: t("health.hardware.customArgs", {
ns: "views/system",
}),
detail: cameraList,
message: t("health.hardware.customArgsNotVerified", {
ns: "views/system",
}),
};
}
const available = hwaccel?.available ?? [];
if (classified.kind === "none") {
if (available.length > 0 && hwaccel?.recommended) {
return {
id,
state: "warning",
label: familyName("none"),
detail: cameraList,
message: t("health.hardware.hwaccelNotConfigured", {
ns: "views/system",
family: familyName(hwaccel.recommended as HwaccelFamilyKey),
}),
};
}
return { id, state: "ok", label: familyName("none"), detail: cameraList };
}
const label = familyName(classified.family);
const present = available.some(
(family) => family.key === classified.family,
);
// a warning, not an error: the resolved config comes from go2rtc's
// answer while `available` comes from the device probe, and the two
// disagree on whole platform families
if (!present) {
return {
id,
state: "warning",
label,
detail: cameraList,
message: t("health.hardware.hwaccelHardwareMissing", {
ns: "views/system",
family: label,
}),
};
}
const dec = decoderUsage(classified.family, stats);
const detail = dec
? `${cameraList} · ${t("health.hardware.decoderUsage", {
ns: "views/system",
usage: dec,
})}`
: cameraList;
return { id, state: "ok", label, detail };
});
}
// -------------------------------------------------------------- enrichments
const ANY_ACCELERATOR = [
"onnx:nvidia",
"onnx:amd",
"openvino:GPU",
"openvino:NPU",
"rknn",
"tensorrt",
];
/**
* Probe keys that satisfy a requested device string. AUTO and the implicit
* defaults accept any accelerator; an explicit override must match its own
* hardware. Undefined means the string is not one we can check.
*/
export function acceleratorKeysFor(
requested: string,
nvidiaOnly: boolean,
): string[] | undefined {
if (nvidiaOnly) {
return ["onnx:nvidia"];
}
const upper = requested.toUpperCase();
if (upper === "AUTO") {
return ANY_ACCELERATOR;
}
// ONNX Runtime puts a plain GPU request on whichever GPU it has; only an
// indexed GPU.n names OpenVINO specifically
if (upper === "GPU") {
return ["openvino:GPU", "onnx:nvidia", "onnx:amd"];
}
if (/^GPU\.\d+$/.test(upper)) {
return ["openvino:GPU"];
}
if (upper === "NPU") {
return ["openvino:NPU"];
}
if (upper.startsWith("CUDA") || upper.startsWith("TENSORRT")) {
return ["onnx:nvidia"];
}
if (upper.startsWith("ROCM") || upper.startsWith("MIGRAPHX")) {
return ["onnx:amd"];
}
return undefined;
}
function acceleratorPresent(
hardware: DetectionHardware[] | undefined,
keys: string[],
): boolean {
return (hardware ?? []).some((entry) => keys.includes(entry.key));
}
type EnrichmentSpec = {
id: "semantic_search" | "face_recognition" | "lpr" | "audio_transcription";
enabled: boolean;
/** what the config asks for, after the backend's own defaults */
requested: string;
explicit: boolean;
remote: boolean;
nvidiaOnly: boolean;
/** runtime device is not reported for this enrichment in v1 */
presenceOnly: boolean;
};
function enrichmentSpecs(config: FrigateConfig): EnrichmentSpec[] {
const ss = config.semantic_search;
const anyCameraTranscribes = Object.values(config.cameras).some(
(camera) => camera.audio_transcription?.enabled,
);
return [
{
id: "semantic_search",
enabled: ss.enabled,
requested: ss.device ?? (ss.model_size === "large" ? "GPU" : "CPU"),
explicit: ss.device != null,
remote: ss.model !== "jinav1" && ss.model !== "jinav2",
nvidiaOnly: false,
presenceOnly: false,
},
{
id: "face_recognition",
enabled: config.face_recognition.enabled,
requested: config.face_recognition.device ?? "GPU",
explicit: config.face_recognition.device != null,
remote: false,
nvidiaOnly: false,
presenceOnly: false,
},
{
id: "lpr",
enabled: config.lpr.enabled,
requested: config.lpr.device ?? "AUTO",
explicit: config.lpr.device != null,
remote: false,
nvidiaOnly: false,
presenceOnly: false,
},
{
id: "audio_transcription",
enabled: config.audio_transcription.enabled || anyCameraTranscribes,
requested: config.audio_transcription.device ?? "CPU",
explicit: true,
remote: false,
nvidiaOnly: true,
presenceOnly: true,
},
];
}
type EnrichmentArgs = {
config: FrigateConfig;
hardware: DetectionHardware[] | undefined;
probeFailed: boolean;
stats: FrigateStats | undefined;
startup: boolean;
t: TFunction;
};
export function enrichmentRows({
config,
hardware,
probeFailed,
stats,
startup,
t,
}: EnrichmentArgs): HardwareRow[] {
return enrichmentSpecs(config)
.filter((spec) => spec.enabled)
.map((spec) => {
const id = `enrichment:${spec.id}`;
const label = t(`health.hardware.enrichments.${spec.id}`, {
ns: "views/system",
});
if (spec.remote) {
return {
id,
state: "ok",
label,
detail: t("health.hardware.remoteProvider", {
ns: "views/system",
}),
};
}
if (spec.requested.toUpperCase() === "CPU") {
return { id, state: "ok", label, detail: "CPU" };
}
if (probeFailed) {
return {
id,
state: "unknown",
label,
message: t("health.hardware.probeUnavailable", {
ns: "views/system",
}),
};
}
// implicit defaults (GPU for face recognition and large semantic
// search) accept any accelerator; only an explicit override is matched
// against its own hardware
const keys = spec.explicit
? acceleratorKeysFor(spec.requested, spec.nvidiaOnly)
: spec.nvidiaOnly
? ["onnx:nvidia"]
: ANY_ACCELERATOR;
if (!keys) {
return {
id,
state: "unknown",
label,
message: t("health.hardware.unrecognizedDevice", {
ns: "views/system",
}),
};
}
const present = acceleratorPresent(hardware, keys);
const runtime = startup
? undefined
: stats?.embeddings?.devices?.[spec.id];
const runtimeIsCpu = !!runtime && runtime.toUpperCase().includes("CPU");
// a model that reports an accelerator is proof enough, whatever the
// probe keys say
if (runtime && !runtimeIsCpu) {
return { id, state: "ok", label, detail: runtime };
}
if (
spec.explicit &&
spec.requested.toUpperCase() !== "AUTO" &&
hardware &&
!present
) {
return {
id,
state: "error",
label,
message: t("health.hardware.acceleratorMissing", {
ns: "views/system",
device: spec.requested,
}),
};
}
if (spec.presenceOnly) {
return { id, state: "ok", label, detail: spec.requested };
}
if (!runtime) {
return {
id,
state: "unknown",
label,
message: t("health.hardware.modelNotRunYet", {
ns: "views/system",
}),
};
}
if (
runtimeIsCpu &&
present &&
spec.explicit &&
spec.requested.toUpperCase() !== "AUTO"
) {
return {
id,
state: "error",
label,
detail: "CPU",
message: t("health.hardware.fellBackToCpu", {
ns: "views/system",
device: spec.requested,
}),
};
}
if (runtimeIsCpu && present) {
return {
id,
state: "warning",
label,
detail: "CPU",
message: t("health.hardware.cpuDespiteAccelerator", {
ns: "views/system",
}),
};
}
return { id, state: "ok", label, detail: runtimeIsCpu ? "CPU" : runtime };
});
}
// ------------------------------------------------------- camera connections
export type CameraConnectionCell = {
camera: string;
quality: "excellent" | "fair" | "poor" | "unusable";
cameraFps: number;
expectedFps: number;
reconnects: number;
stalls: number;
};
/** enabled, non-replay cameras whose latest connection is not excellent */
export function cameraConnectionCells(
config: FrigateConfig,
stats: FrigateStats | undefined,
): CameraConnectionCell[] {
if (!stats) {
return [];
}
return activeCameras(config)
.map((camera): CameraConnectionCell | undefined => {
const cam = stats.cameras[camera.name];
if (
!cam ||
!cam.connection_quality ||
cam.connection_quality === "excellent"
) {
return undefined;
}
return {
camera: camera.name,
quality: cam.connection_quality,
cameraFps: cam.camera_fps,
expectedFps: cam.expected_fps ?? 0,
reconnects: cam.reconnects_last_hour ?? 0,
stalls: cam.stalls_last_hour ?? 0,
};
})
.filter((cell): cell is CameraConnectionCell => cell !== undefined);
}
// --------------------------------------------------------------- helpers
export function activeCameras(config: FrigateConfig): CameraConfig[] {
return Object.values(config.cameras)
.filter((camera) => camera.enabled && !isReplayCamera(camera.name))
.sort((a, b) => a.ui.order - b.ui.order);
}
export function isStartupWindow(stats: FrigateStats | undefined): boolean {
return !!stats && stats.service.uptime < STARTUP_WINDOW_S;
}
+14
View File
@@ -0,0 +1,14 @@
import type { HealthProblem } from "@/types/health";
const SEVERITY_ORDER = { error: 0, warning: 1, info: 2 } as const;
const SOURCE_ORDER = { registry: 0, live: 1, config: 2, stream: 3 } as const;
/** errors first, then warnings, then info; within a severity by source, then scope */
export function sortHealthProblems(problems: HealthProblem[]): HealthProblem[] {
return [...problems].sort(
(a, b) =>
SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] ||
SOURCE_ORDER[a.source] - SOURCE_ORDER[b.source] ||
(a.scope ?? "").localeCompare(b.scope ?? ""),
);
}
+154
View File
@@ -0,0 +1,154 @@
import type { TFunction } from "i18next";
import type { StreamCheckResults } from "@/hooks/use-health-checks";
import type { StreamRole } from "@/types/cameraWizard";
import { inferCameraBrand } from "@/types/cameraWizard";
import type { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import type { HealthProblem } from "@/types/health";
import {
getStreamIssues,
lastErrorLine,
resolveRestreamSource,
type StreamIssue,
} from "@/utils/streamIssues";
// rules the add camera wizard shows that do not belong on the Health tab
const WIZARD_ONLY_RULES = new Set(["restream", "reolink-rtsp", "reolink-http"]);
/**
* Whether the record output keeps the camera's audio codec. The default
* preset transcodes to AAC, so a non-AAC source only matters when the args
* copy audio through.
*/
function recordCopiesAudio(camera: CameraConfig): boolean {
const args = camera.ffmpeg.output_args?.record;
const text = Array.isArray(args) ? args.join(" ") : (args ?? "");
return (
text === "preset-record-generic-audio-copy" ||
/(^|\s)-(c:a|acodec)\s+copy(\s|$)/.test(text)
);
}
export type StreamHealth = {
problems: HealthProblem[];
/** enabled cameras the results cover */
checked: number;
/** cameras with no stream problem */
clean: number;
};
/** Turn one stream check run into notice rows plus the counts a summary needs. */
export function streamHealth(
config: FrigateConfig,
results: StreamCheckResults | undefined,
t: TFunction,
): StreamHealth {
const problems: HealthProblem[] = [];
let checked = 0;
let clean = 0;
if (!results) {
return { problems, checked, clean };
}
Object.entries(results.byCamera).forEach(([name, check]) => {
const camera = config.cameras[name];
if (!camera || !camera.enabled) {
return;
}
checked += 1;
const link = `/settings?page=cameraFfmpeg&camera=${encodeURIComponent(name)}`;
const copiesAudio = recordCopiesAudio(camera);
let flagged = false;
if (check.error) {
problems.push({
id: `stream:${name}:error`,
source: "stream",
severity: "error",
scope: name,
scopeIsCamera: true,
text: t("health.notices.cameraProbeFailed", {
ns: "views/system",
error: check.error,
}),
link,
});
return;
}
camera.ffmpeg.inputs.forEach((input, index) => {
const result = check.streams[index];
const streamNumber = index + 1;
if (!result || !result.success) {
flagged = true;
problems.push({
id: `stream:${name}:${index}:probe`,
source: "stream",
severity: "error",
scope: name,
scopeIsCamera: true,
text: t("health.notices.streamProbeFailed", {
ns: "views/system",
index: streamNumber,
error: lastErrorLine(result?.error),
}),
link,
});
return;
}
const restream = resolveRestreamSource(
input.path,
config.go2rtc?.streams,
);
const url = restream?.url ?? input.path;
// a restreamed input's probe describes go2rtc's output, and a missing
// AAC track is fixed on the go2rtc stream, not on the camera
const streamLink = restream ? "/settings?page=systemGo2rtcStreams" : link;
const prefixKey = restream
? "health.notices.streamPrefixRestream"
: "health.notices.streamPrefix";
getStreamIssues(
{
url,
roles: input.roles as StreamRole[],
brand: inferCameraBrand(url),
useFfmpeg: restream?.useFfmpeg,
restream: !!restream,
testResult: result,
},
t,
)
.filter(
(issue): issue is StreamIssue & { type: "warning" | "error" } =>
issue.type !== "good" &&
!WIZARD_ONLY_RULES.has(issue.rule) &&
(issue.rule !== "audio-codec-record" || copiesAudio),
)
.forEach((issue) => {
flagged = true;
problems.push({
id: `stream:${name}:${index}:${issue.rule}`,
source: "stream",
severity: issue.type,
scope: name,
scopeIsCamera: true,
text: t(prefixKey, {
ns: "views/system",
index: streamNumber,
message: issue.message,
}),
link: streamLink,
});
});
});
if (!flagged) {
clean += 1;
}
});
return { problems, checked, clean };
}
+298
View File
@@ -0,0 +1,298 @@
import type { TFunction } from "i18next";
import { parseRestreamStreamName } from "@/components/config-form/theme/fields/streamSource";
import type { CameraBrand, StreamRole, TestResult } from "@/types/cameraWizard";
export type StreamIssue = {
type: "good" | "warning" | "error";
message: string;
/** stable key for the rule that fired, for filtering and tests */
rule: string;
};
export type StreamIssueInput = {
url: string;
roles: StreamRole[];
brand?: CameraBrand;
useFfmpeg?: boolean;
restream?: boolean;
testResult?: TestResult;
};
type ProbeStream = {
codec_type?: string;
codec_name?: string;
width?: number;
height?: number;
avg_frame_rate?: string;
};
export type FfprobeEntry = {
return_code?: number;
stdout?: { streams?: ProbeStream[] } | string;
/** the backend sends a list of non-empty lines on failure */
stderr?: string | string[];
};
function errorText(stderr: string | string[] | undefined): string {
const text = Array.isArray(stderr) ? stderr.join("\n") : stderr;
return text?.trim() || "Unknown error";
}
/** The human-readable end of an ffprobe error; the first lines are plumbing. */
export function lastErrorLine(error: string | undefined): string {
const lines = (error ?? "")
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
return lines[lines.length - 1] ?? "";
}
/** Parse one entry of the ffprobe API response the way the wizard does. */
export function ffprobeToTestResult(
entry: FfprobeEntry | undefined,
): TestResult {
if (!entry || entry.return_code !== 0 || typeof entry.stdout !== "object") {
return { success: false, error: errorText(entry?.stderr) };
}
const streams = entry.stdout?.streams ?? [];
const videoStream = streams.find(
(s) =>
s.codec_type === "video" ||
s.codec_name?.includes("h264") ||
s.codec_name?.includes("h265"),
);
const audioStream = streams.find(
(s) =>
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,
};
}
/** The wizard's Stream Validation rules, unchanged, over plain input. */
export function getStreamIssues(
input: StreamIssueInput,
t: TFunction,
): StreamIssue[] {
const result: StreamIssue[] = [];
const { roles, testResult } = input;
if (input.brand === "reolink") {
const streamUrl = input.url.toLowerCase();
if (streamUrl.startsWith("rtsp://")) {
result.push({
type: "warning",
rule: "reolink-rtsp",
message: t("cameraWizard.step4.issues.brands.reolink-rtsp", {
ns: "views/settings",
}),
});
}
if (streamUrl.startsWith("http://") && !input.useFfmpeg) {
result.push({
type: "warning",
rule: "reolink-http",
message: t("cameraWizard.step4.issues.brands.reolink-http", {
ns: "views/settings",
}),
});
}
}
if (testResult?.videoCodec) {
const videoCodec = testResult.videoCodec.toLowerCase();
if (["h264", "h265", "hevc"].includes(videoCodec)) {
result.push({
type: "good",
rule: "video-codec",
message: t("cameraWizard.step4.issues.videoCodecGood", {
ns: "views/settings",
codec: testResult.videoCodec,
}),
});
}
}
if (roles.includes("record")) {
if (testResult?.audioCodec) {
const audioCodec = testResult.audioCodec.toLowerCase();
if (audioCodec === "aac") {
result.push({
type: "good",
rule: "audio-codec",
message: t("cameraWizard.step4.issues.audioCodecGood", {
ns: "views/settings",
codec: testResult.audioCodec,
}),
});
} else {
result.push({
type: "error",
rule: "audio-codec-record",
message: t("cameraWizard.step4.issues.audioCodecRecordError", {
ns: "views/settings",
}),
});
}
} else {
result.push({
type: "warning",
rule: "no-audio",
message: t("cameraWizard.step4.issues.noAudioWarning", {
ns: "views/settings",
}),
});
}
}
if (roles.includes("audio") && !testResult?.audioCodec) {
result.push({
type: "error",
rule: "audio-required",
message: t("cameraWizard.step4.issues.audioCodecRequired", {
ns: "views/settings",
}),
});
}
if (roles.includes("record") && input.restream) {
result.push({
type: "warning",
rule: "restream",
message: t("cameraWizard.step4.issues.restreamingWarning", {
ns: "views/settings",
}),
});
}
if (roles.includes("detect") && testResult) {
const probedResolution = 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",
rule: "resolution-unknown",
message: t("cameraWizard.step4.issues.resolutionUnknown", {
ns: "views/settings",
}),
});
} else {
const minDimension = Math.min(probedWidth, probedHeight);
const maxDimension = Math.max(probedWidth, probedHeight);
if (minDimension > 1080) {
result.push({
type: "warning",
rule: "resolution-high",
message: t("cameraWizard.step4.issues.resolutionHigh", {
ns: "views/settings",
resolution: probedResolution,
}),
});
} else if (maxDimension < 640) {
result.push({
type: "error",
rule: "resolution-low",
message: t("cameraWizard.step4.issues.resolutionLow", {
ns: "views/settings",
resolution: probedResolution,
}),
});
}
}
}
if (
input.brand === "dahua" &&
roles.includes("detect") &&
input.url.includes("subtype=1")
) {
result.push({
type: "warning",
rule: "dahua-substream",
message: t("cameraWizard.step4.issues.dahua.substreamWarning", {
ns: "views/settings",
}),
});
}
if (
input.brand === "hikvision" &&
roles.includes("detect") &&
input.url.includes("/102")
) {
result.push({
type: "warning",
rule: "hikvision-substream",
message: t("cameraWizard.step4.issues.hikvision.substreamWarning", {
ns: "views/settings",
}),
});
}
return result;
}
/**
* For an input that points at a go2rtc restream, find the camera URL behind
* it. Returns undefined when the path is not a restream or the stream is not
* in the go2rtc config. The /config response redacts credentials in these
* sources, so the URL is only good for pattern matching.
*/
export function resolveRestreamSource(
path: string,
streams: Record<string, string | string[]> | undefined,
): { url: string; useFfmpeg: boolean } | undefined {
const name = parseRestreamStreamName(path);
if (!name || !streams) {
return undefined;
}
const configured = streams[name];
const sources = Array.isArray(configured)
? configured
: configured
? [configured]
: [];
const source = sources.find((s) => !s.startsWith(`ffmpeg:${name}`));
if (!source) {
return undefined;
}
if (source.startsWith("ffmpeg:")) {
return {
url: source.slice("ffmpeg:".length).split("#")[0],
useFfmpeg: true,
};
}
return { url: source, useFfmpeg: false };
}