mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-31 12:24:55 +03:00
add custom validation and use it for ffmpeg input roles
This commit is contained in:
parent
718757fc60
commit
09ceff9bd8
@ -16,5 +16,6 @@
|
|||||||
"format": "Invalid format",
|
"format": "Invalid format",
|
||||||
"additionalProperties": "Unknown property is not allowed",
|
"additionalProperties": "Unknown property is not allowed",
|
||||||
"oneOf": "Must match exactly one of the allowed schemas",
|
"oneOf": "Must match exactly one of the allowed schemas",
|
||||||
"anyOf": "Must match at least one of the allowed schemas"
|
"anyOf": "Must match at least one of the allowed schemas",
|
||||||
|
"ffmpeg.inputs.rolesUnique": "Each role can only be assigned to one input stream."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
// ConfigForm - Main RJSF form wrapper component
|
// ConfigForm - Main RJSF form wrapper component
|
||||||
import Form from "@rjsf/shadcn";
|
import Form from "@rjsf/shadcn";
|
||||||
import validator from "@rjsf/validator-ajv8";
|
import validator from "@rjsf/validator-ajv8";
|
||||||
import type { RJSFSchema, UiSchema } from "@rjsf/utils";
|
import type { FormValidation, RJSFSchema, UiSchema } from "@rjsf/utils";
|
||||||
import type { IChangeEvent } from "@rjsf/core";
|
import type { IChangeEvent } from "@rjsf/core";
|
||||||
import { frigateTheme } from "./theme";
|
import { frigateTheme } from "./theme";
|
||||||
import { transformSchema } from "@/lib/config-schema";
|
import { transformSchema } from "@/lib/config-schema";
|
||||||
@ -182,6 +182,11 @@ export interface ConfigFormProps {
|
|||||||
formContext?: ConfigFormContext;
|
formContext?: ConfigFormContext;
|
||||||
/** i18n namespace for field labels */
|
/** i18n namespace for field labels */
|
||||||
i18nNamespace?: string;
|
i18nNamespace?: string;
|
||||||
|
/** Optional custom validation */
|
||||||
|
customValidate?: (
|
||||||
|
formData: unknown,
|
||||||
|
errors: FormValidation,
|
||||||
|
) => FormValidation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfigForm({
|
export function ConfigForm({
|
||||||
@ -202,6 +207,7 @@ export function ConfigForm({
|
|||||||
liveValidate = true,
|
liveValidate = true,
|
||||||
formContext,
|
formContext,
|
||||||
i18nNamespace,
|
i18nNamespace,
|
||||||
|
customValidate,
|
||||||
}: ConfigFormProps) {
|
}: ConfigFormProps) {
|
||||||
const { t, i18n } = useTranslation([
|
const { t, i18n } = useTranslation([
|
||||||
i18nNamespace || "common",
|
i18nNamespace || "common",
|
||||||
@ -319,6 +325,7 @@ export function ConfigForm({
|
|||||||
liveValidate={liveValidate}
|
liveValidate={liveValidate}
|
||||||
formContext={extendedFormContext}
|
formContext={extendedFormContext}
|
||||||
transformErrors={errorTransformer}
|
transformErrors={errorTransformer}
|
||||||
|
customValidate={customValidate}
|
||||||
{...frigateTheme}
|
{...frigateTheme}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
47
web/src/components/config-form/section-validations/ffmpeg.ts
Normal file
47
web/src/components/config-form/section-validations/ffmpeg.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import type { FormValidation } from "@rjsf/utils";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { isJsonObject } from "@/lib/utils";
|
||||||
|
import type { JsonObject } from "@/types/configForm";
|
||||||
|
|
||||||
|
export function validateFfmpegInputRoles(
|
||||||
|
formData: unknown,
|
||||||
|
errors: FormValidation,
|
||||||
|
t: TFunction,
|
||||||
|
): FormValidation {
|
||||||
|
if (!isJsonObject(formData as JsonObject)) {
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputs = (formData as JsonObject).inputs;
|
||||||
|
if (!Array.isArray(inputs)) {
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleCounts = new Map<string, number>();
|
||||||
|
inputs.forEach((input) => {
|
||||||
|
if (!isJsonObject(input) || !Array.isArray(input.roles)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
input.roles.forEach((role) => {
|
||||||
|
if (typeof role !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
roleCounts.set(role, (roleCounts.get(role) || 0) + 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasDuplicates = Array.from(roleCounts.values()).some(
|
||||||
|
(count) => count > 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasDuplicates) {
|
||||||
|
const inputsErrors = errors.inputs as {
|
||||||
|
addError?: (message: string) => void;
|
||||||
|
};
|
||||||
|
inputsErrors?.addError?.(
|
||||||
|
t("ffmpeg.inputs.rolesUnique", { ns: "config/validation" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
26
web/src/components/config-form/section-validations/index.ts
Normal file
26
web/src/components/config-form/section-validations/index.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import type { FormValidation } from "@rjsf/utils";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
import { validateFfmpegInputRoles } from "./ffmpeg";
|
||||||
|
|
||||||
|
export type SectionValidation = (
|
||||||
|
formData: unknown,
|
||||||
|
errors: FormValidation,
|
||||||
|
) => FormValidation;
|
||||||
|
|
||||||
|
type SectionValidationOptions = {
|
||||||
|
sectionPath: string;
|
||||||
|
level: "global" | "camera";
|
||||||
|
t: TFunction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getSectionValidation({
|
||||||
|
sectionPath,
|
||||||
|
level,
|
||||||
|
t,
|
||||||
|
}: SectionValidationOptions): SectionValidation | undefined {
|
||||||
|
if (sectionPath === "ffmpeg" && level === "camera") {
|
||||||
|
return (formData, errors) => validateFfmpegInputRoles(formData, errors, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@ -10,7 +10,8 @@ import sectionRenderers, {
|
|||||||
RendererComponent,
|
RendererComponent,
|
||||||
} from "@/components/config-form/sectionExtras/registry";
|
} from "@/components/config-form/sectionExtras/registry";
|
||||||
import { ConfigForm } from "../ConfigForm";
|
import { ConfigForm } from "../ConfigForm";
|
||||||
import type { UiSchema } from "@rjsf/utils";
|
import type { FormValidation, UiSchema } from "@rjsf/utils";
|
||||||
|
import { getSectionValidation } from "../section-validations";
|
||||||
import {
|
import {
|
||||||
useConfigOverride,
|
useConfigOverride,
|
||||||
normalizeConfigValue,
|
normalizeConfigValue,
|
||||||
@ -56,6 +57,11 @@ export interface SectionConfig {
|
|||||||
uiSchema?: UiSchema;
|
uiSchema?: UiSchema;
|
||||||
/** Optional per-section renderers usable by FieldTemplate `ui:before`/`ui:after` */
|
/** Optional per-section renderers usable by FieldTemplate `ui:before`/`ui:after` */
|
||||||
renderers?: Record<string, RendererComponent>;
|
renderers?: Record<string, RendererComponent>;
|
||||||
|
/** Optional custom validation for section data */
|
||||||
|
customValidate?: (
|
||||||
|
formData: unknown,
|
||||||
|
errors: FormValidation,
|
||||||
|
) => FormValidation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BaseSectionProps {
|
export interface BaseSectionProps {
|
||||||
@ -90,13 +96,6 @@ export interface CreateSectionOptions {
|
|||||||
defaultConfig: SectionConfig;
|
defaultConfig: SectionConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory function to create reusable config section components
|
|
||||||
*/
|
|
||||||
export function createConfigSection({
|
|
||||||
sectionPath,
|
|
||||||
defaultConfig,
|
|
||||||
}: CreateSectionOptions) {
|
|
||||||
const cameraUpdateTopicMap: Record<string, string> = {
|
const cameraUpdateTopicMap: Record<string, string> = {
|
||||||
detect: "detect",
|
detect: "detect",
|
||||||
record: "record",
|
record: "record",
|
||||||
@ -119,7 +118,11 @@ export function createConfigSection({
|
|||||||
ui: "ui",
|
ui: "ui",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ConfigSection = function ConfigSection({
|
export type ConfigSectionProps = BaseSectionProps & CreateSectionOptions;
|
||||||
|
|
||||||
|
export function ConfigSection({
|
||||||
|
sectionPath,
|
||||||
|
defaultConfig,
|
||||||
level,
|
level,
|
||||||
cameraName,
|
cameraName,
|
||||||
showOverrideIndicator = true,
|
showOverrideIndicator = true,
|
||||||
@ -131,7 +134,7 @@ export function createConfigSection({
|
|||||||
collapsible = false,
|
collapsible = false,
|
||||||
defaultCollapsed = false,
|
defaultCollapsed = false,
|
||||||
showTitle,
|
showTitle,
|
||||||
}: BaseSectionProps) {
|
}: ConfigSectionProps) {
|
||||||
const { t, i18n } = useTranslation([
|
const { t, i18n } = useTranslation([
|
||||||
level === "camera" ? "config/cameras" : "config/global",
|
level === "camera" ? "config/cameras" : "config/global",
|
||||||
"config/cameras",
|
"config/cameras",
|
||||||
@ -152,7 +155,6 @@ export function createConfigSection({
|
|||||||
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
|
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
|
||||||
: undefined
|
: undefined
|
||||||
: `config/${sectionPath}`;
|
: `config/${sectionPath}`;
|
||||||
|
|
||||||
// Default: show title for camera level (since it might be collapsible), hide for global
|
// Default: show title for camera level (since it might be collapsible), hide for global
|
||||||
const shouldShowTitle = showTitle ?? level === "camera";
|
const shouldShowTitle = showTitle ?? level === "camera";
|
||||||
|
|
||||||
@ -180,7 +182,7 @@ export function createConfigSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return get(config, sectionPath) || {};
|
return get(config, sectionPath) || {};
|
||||||
}, [config, level, cameraName]);
|
}, [config, level, cameraName, sectionPath]);
|
||||||
|
|
||||||
const sanitizeSectionData = useCallback(
|
const sanitizeSectionData = useCallback(
|
||||||
(data: ConfigSectionData) => {
|
(data: ConfigSectionData) => {
|
||||||
@ -336,7 +338,6 @@ export function createConfigSection({
|
|||||||
level === "camera" && cameraName
|
level === "camera" && cameraName
|
||||||
? `cameras.${cameraName}.${sectionPath}`
|
? `cameras.${cameraName}.${sectionPath}`
|
||||||
: sectionPath;
|
: sectionPath;
|
||||||
|
|
||||||
const rawData = sanitizeSectionData(rawFormData);
|
const rawData = sanitizeSectionData(rawFormData);
|
||||||
const overrides = buildOverrides(pendingData, rawData, schemaDefaults);
|
const overrides = buildOverrides(pendingData, rawData, schemaDefaults);
|
||||||
|
|
||||||
@ -352,7 +353,6 @@ export function createConfigSection({
|
|||||||
// [basePath]: overrides,
|
// [basePath]: overrides,
|
||||||
// },
|
// },
|
||||||
// });
|
// });
|
||||||
|
|
||||||
// log save to console for debugging
|
// log save to console for debugging
|
||||||
console.log("Saved config data:", {
|
console.log("Saved config data:", {
|
||||||
[basePath]: overrides,
|
[basePath]: overrides,
|
||||||
@ -409,6 +409,7 @@ export function createConfigSection({
|
|||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
sectionPath,
|
||||||
pendingData,
|
pendingData,
|
||||||
level,
|
level,
|
||||||
cameraName,
|
cameraName,
|
||||||
@ -431,9 +432,7 @@ export function createConfigSection({
|
|||||||
const basePath =
|
const basePath =
|
||||||
level === "camera" && cameraName
|
level === "camera" && cameraName
|
||||||
? `cameras.${cameraName}.${sectionPath}`
|
? `cameras.${cameraName}.${sectionPath}`
|
||||||
: sectionPath;
|
: sectionPath; // const configData = level === "global" ? schemaDefaults : "";
|
||||||
|
|
||||||
// const configData = level === "global" ? schemaDefaults : "";
|
|
||||||
|
|
||||||
// await axios.put("config/set", {
|
// await axios.put("config/set", {
|
||||||
// requires_restart: requiresRestart ? 0 : 1,
|
// requires_restart: requiresRestart ? 0 : 1,
|
||||||
@ -454,7 +453,6 @@ export function createConfigSection({
|
|||||||
requires_restart: requiresRestart ? 0 : 1,
|
requires_restart: requiresRestart ? 0 : 1,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
toast.success(
|
toast.success(
|
||||||
t("toast.resetSuccess", {
|
t("toast.resetSuccess", {
|
||||||
ns: "views/settings",
|
ns: "views/settings",
|
||||||
@ -475,7 +473,44 @@ export function createConfigSection({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [level, cameraName, requiresRestart, t, refreshConfig, updateTopic]);
|
}, [
|
||||||
|
sectionPath,
|
||||||
|
level,
|
||||||
|
cameraName,
|
||||||
|
requiresRestart,
|
||||||
|
t,
|
||||||
|
refreshConfig,
|
||||||
|
updateTopic,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sectionValidation = useMemo(
|
||||||
|
() => getSectionValidation({ sectionPath, level, t }),
|
||||||
|
[sectionPath, level, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const customValidate = useMemo(() => {
|
||||||
|
const validators: Array<
|
||||||
|
(formData: unknown, errors: FormValidation) => FormValidation
|
||||||
|
> = [];
|
||||||
|
|
||||||
|
if (sectionConfig.customValidate) {
|
||||||
|
validators.push(sectionConfig.customValidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sectionValidation) {
|
||||||
|
validators.push(sectionValidation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validators.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (formData: unknown, errors: FormValidation) =>
|
||||||
|
validators.reduce(
|
||||||
|
(currentErrors, validatorFn) => validatorFn(formData, currentErrors),
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
}, [sectionConfig.customValidate, sectionValidation]);
|
||||||
|
|
||||||
if (!sectionSchema) {
|
if (!sectionSchema) {
|
||||||
return null;
|
return null;
|
||||||
@ -519,6 +554,7 @@ export function createConfigSection({
|
|||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
showSubmit={false}
|
showSubmit={false}
|
||||||
i18nNamespace={configNamespace}
|
i18nNamespace={configNamespace}
|
||||||
|
customValidate={customValidate}
|
||||||
formContext={{
|
formContext={{
|
||||||
level,
|
level,
|
||||||
cameraName,
|
cameraName,
|
||||||
@ -613,8 +649,7 @@ export function createConfigSection({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{((level === "camera" && isOverridden) ||
|
{((level === "camera" && isOverridden) || level === "global") && (
|
||||||
level === "global") && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -654,9 +689,7 @@ export function createConfigSection({
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Heading as="h4">{title}</Heading>
|
<Heading as="h4">{title}</Heading>
|
||||||
{showOverrideIndicator &&
|
{showOverrideIndicator && level === "camera" && isOverridden && (
|
||||||
level === "camera" &&
|
|
||||||
isOverridden && (
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{t("overridden", {
|
{t("overridden", {
|
||||||
ns: "common",
|
ns: "common",
|
||||||
@ -725,7 +758,4 @@ export function createConfigSection({
|
|||||||
{sectionContent}
|
{sectionContent}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
|
||||||
|
|
||||||
return ConfigSection;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user