Files
frigate/web/src/utils/configHealth.ts
T
Josh HawkinsandGitHub cabdffea20 Update more web deps and reformat with updated prettier (#24358)
* update date-fns, i18next, react-dropzone, react-markdown and @types/node

react-i18next 17 peers `i18next >= 26.2.0`, so the two move together. react-day-picker already depends on date-fns 4, so date-fns now dedupes to a single copy. react-dropzone 20 declares `node >=22` in `engines`, but npm only warns on Node 20 and nothing in the build needs Node 22.

* update js-yaml, @hookform/resolvers and prettier

js-yaml 5 has no default export, so `DictAsYamlField` imports `dump`, `load` and `YAMLException` by name. @hookform/resolvers 5 types `zodResolver` with the schema's input and output types separately, and fields with defaults are optional on input, so the zone and classification model forms pass both types to `useForm`. zod's range now starts at 3.25, which resolvers 5 requires.

* format with prettier 3.9
2026-09-15 15:18:49 -06:00

207 lines
6.2 KiB
TypeScript

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) => {
// camera names have no dots, so the backend can tell this suffix
// from global and modelN when the camera is deleted
const problem = toProblem(
message,
section,
ctx,
camera.name,
true,
`camera.${camera.name}`,
t,
);
if (!(
inherited && firedGlobally.has(`${section}:${message.key}`)
)) {
problems.push(problem);
}
});
});
}
});
return problems;
}