mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 17:48:59 +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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ?? ""),
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user