mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 11:48:57 +03:00
* build out system health pane * tweaks * fixes * fix notice link so it opens the correct camera * tweak language
780 lines
20 KiB
TypeScript
780 lines
20 KiB
TypeScript
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;
|
|
}
|