mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-29 11:26:49 +03:00
The settings form read a camera section's saved config value, but its dependent warnings (audio transcription requiring audio detection, snapshots requiring detect, etc.) read the live config instead. A runtime toggle from the live view, MQTT, or an active profile can turn a section off without touching yaml, and that override persists across restarts, so the Enable switch showed on while the warning said the feature wasn't enabled. This adds an "Overridden (Live)" badge to any field whose live value differs from what's saved, and swaps the affected warnings to runtime-specific wording when a runtime override is the actual cause instead of the config.
208 lines
6.3 KiB
TypeScript
208 lines
6.3 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";
|
|
import { resolveMessageKey } from "@/utils/runtimeOverrides";
|
|
|
|
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(resolveMessageKey(message, ctx), {
|
|
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;
|
|
}
|