From 3c5298e3045be8693b0bd4245cd6f1e9b9ec1aa2 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Sat, 31 Jan 2026 20:44:53 -0600
Subject: [PATCH] add ability to render components before and after fields
---
web/public/locales/en/common.json | 3 +-
web/public/locales/en/views/settings.json | 2 +-
.../components/config-form/sectionConfigs.ts | 5 +
.../sectionExtras/SemanticSearchReindex.tsx | 106 ++++++++++++
.../config-form/sectionExtras/registry.ts | 25 +++
.../config-form/sections/BaseSection.tsx | 109 +++++++++----
.../templates/DescriptionFieldTemplate.tsx | 4 +-
.../theme/templates/FieldTemplate.tsx | 154 ++++++++++++------
.../theme/templates/ObjectFieldTemplate.tsx | 2 +-
web/src/types/configForm.ts | 2 +
10 files changed, 320 insertions(+), 92 deletions(-)
create mode 100644 web/src/components/config-form/sectionExtras/SemanticSearchReindex.tsx
create mode 100644 web/src/components/config-form/sectionExtras/registry.ts
diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json
index 4771d4945..48e46f5e5 100644
--- a/web/public/locales/en/common.json
+++ b/web/public/locales/en/common.json
@@ -153,7 +153,8 @@
"continue": "Continue",
"modified": "Modified",
"overridden": "Overridden",
- "resetToGlobal": "Reset to Global"
+ "resetToGlobal": "Reset to Global",
+ "resetToDefault": "Reset to Default"
},
"menu": {
"system": "System",
diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json
index 639d1a322..45c331db5 100644
--- a/web/public/locales/en/views/settings.json
+++ b/web/public/locales/en/views/settings.json
@@ -114,7 +114,7 @@
"desc": "Semantic Search in Frigate allows you to find tracked objects within your review items using either the image itself, a user-defined text description, or an automatically generated one.",
"reindexNow": {
"label": "Reindex Now",
- "desc": "Reindexing will regenerate embeddings for all tracked object. This process runs in the background and may max out your CPU and take a fair amount of time depending on the number of tracked objects you have.",
+ "desc": "Reindexing will regenerate embeddings for all tracked objects. This process runs in the background and may max out your CPU and take a fair amount of time depending on the number of tracked objects you have.",
"confirmTitle": "Confirm Reindexing",
"confirmDesc": "Are you sure you want to reindex all tracked object embeddings? This process will run in the background but it may max out your CPU and take a fair amount of time. You can watch the progress on the Explore page.",
"confirmButton": "Reindex",
diff --git a/web/src/components/config-form/sectionConfigs.ts b/web/src/components/config-form/sectionConfigs.ts
index 1c9e87f69..e482089b0 100644
--- a/web/src/components/config-form/sectionConfigs.ts
+++ b/web/src/components/config-form/sectionConfigs.ts
@@ -604,6 +604,11 @@ const sectionConfigs: Record = {
hiddenFields: [],
advancedFields: [],
overrideFields: [],
+ uiSchema: {
+ enabled: {
+ "ui:after": { render: "SemanticSearchReindex" },
+ },
+ },
},
global: {
fieldOrder: ["enabled", "reindex", "model", "model_size", "device"],
diff --git a/web/src/components/config-form/sectionExtras/SemanticSearchReindex.tsx b/web/src/components/config-form/sectionExtras/SemanticSearchReindex.tsx
new file mode 100644
index 000000000..f44bcd8e1
--- /dev/null
+++ b/web/src/components/config-form/sectionExtras/SemanticSearchReindex.tsx
@@ -0,0 +1,106 @@
+import { useState } from "react";
+import axios from "axios";
+import { Button, buttonVariants } from "@/components/ui/button";
+import { Trans, useTranslation } from "react-i18next";
+import { toast } from "sonner";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
+
+export default function SemanticSearchReindex() {
+ const { t } = useTranslation("views/settings");
+ const [isLoading, setIsLoading] = useState(false);
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+
+ const onReindex = async () => {
+ setIsLoading(true);
+ try {
+ const res = await axios.put("/reindex");
+ if (res.status === 202) {
+ toast.success(t("enrichments.semanticSearch.reindexNow.success"), {
+ position: "top-center",
+ });
+ } else {
+ toast.error(
+ t("enrichments.semanticSearch.reindexNow.error", {
+ errorMessage: res.statusText,
+ }),
+ { position: "top-center" },
+ );
+ }
+ } catch (caught) {
+ const error = caught as {
+ response?: { data?: { message?: string; detail?: string } };
+ };
+ const errorMessage =
+ error.response?.data?.message || error.response?.data?.detail || "";
+ toast.error(
+ t("enrichments.semanticSearch.reindexNow.error", {
+ errorMessage: errorMessage || undefined,
+ }),
+ { position: "top-center" },
+ );
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+ enrichments.semanticSearch.reindexNow.desc
+
+
+
+
+
+
+
+ {t("enrichments.semanticSearch.reindexNow.confirmTitle")}
+
+
+
+ enrichments.semanticSearch.reindexNow.confirmDesc
+
+
+
+
+ setIsDialogOpen(false)}>
+ {t("button.cancel", { ns: "common" })}
+
+ {
+ await onReindex();
+ setIsDialogOpen(false);
+ }}
+ >
+ {t("enrichments.semanticSearch.reindexNow.confirmButton")}
+
+
+
+
+
+ >
+ );
+}
diff --git a/web/src/components/config-form/sectionExtras/registry.ts b/web/src/components/config-form/sectionExtras/registry.ts
new file mode 100644
index 000000000..c5dd739a7
--- /dev/null
+++ b/web/src/components/config-form/sectionExtras/registry.ts
@@ -0,0 +1,25 @@
+import type { ComponentType } from "react";
+import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
+
+export type RendererComponent = ComponentType<
+ Record | undefined
+>;
+
+export type SectionRenderers = Record<
+ string,
+ Record
+>;
+
+// Section renderers registry
+// Used to register custom renderer components for specific config sections.
+// Maps a section key (e.g., `semantic_search`) to a mapping of renderer
+// names to React components. These names are referenced from `uiSchema`
+// descriptors (e.g., `{ "ui:after": { render: "SemanticSearchReindex" } }`) and
+// are resolved by `FieldTemplate` through `formContext.renderers`.
+export const sectionRenderers: SectionRenderers = {
+ semantic_search: {
+ SemanticSearchReindex,
+ },
+};
+
+export default sectionRenderers;
diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx
index 116731d43..36fa07d4c 100644
--- a/web/src/components/config-form/sections/BaseSection.tsx
+++ b/web/src/components/config-form/sections/BaseSection.tsx
@@ -6,6 +6,9 @@ import useSWR from "swr";
import axios from "axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
+import sectionRenderers, {
+ RendererComponent,
+} from "@/components/config-form/sectionExtras/registry";
import { ConfigForm } from "../ConfigForm";
import type { UiSchema } from "@rjsf/utils";
import {
@@ -51,6 +54,8 @@ export interface SectionConfig {
liveValidate?: boolean;
/** Additional uiSchema overrides */
uiSchema?: UiSchema;
+ /** Optional per-section renderers usable by FieldTemplate `ui:before`/`ui:after` */
+ renderers?: Record;
}
export interface BaseSectionProps {
@@ -418,32 +423,45 @@ export function createConfigSection({
updateTopic,
]);
- // Handle reset to global - removes camera-level override by deleting the section
+ // Handle reset to global/defaults - removes camera-level override or resets global to defaults
const handleResetToGlobal = useCallback(async () => {
- if (level !== "camera" || !cameraName) return;
+ if (level === "camera" && !cameraName) return;
try {
- const basePath = `cameras.${cameraName}.${sectionPath}`;
+ const basePath =
+ level === "camera" && cameraName
+ ? `cameras.${cameraName}.${sectionPath}`
+ : sectionPath;
+
+ // const configData = level === "global" ? schemaDefaults : "";
- // Send empty string to delete the key from config (see update_yaml in backend)
// await axios.put("config/set", {
// requires_restart: requiresRestart ? 0 : 1,
// update_topic: updateTopic,
// config_data: {
- // [basePath]: "",
+ // [basePath]: configData,
// },
// });
// log reset to console for debugging
- console.log("Reset to global config for path:", basePath, {
- update_topic: updateTopic,
- requires_restart: requiresRestart ? 0 : 1,
- });
+ console.log(
+ level === "global"
+ ? "Reset to defaults for path:"
+ : "Reset to global config for path:",
+ basePath,
+ {
+ update_topic: updateTopic,
+ requires_restart: requiresRestart ? 0 : 1,
+ },
+ );
toast.success(
t("toast.resetSuccess", {
ns: "views/settings",
- defaultValue: "Reset to global defaults",
+ defaultValue:
+ level === "global"
+ ? "Reset to defaults"
+ : "Reset to global defaults",
}),
);
@@ -519,6 +537,8 @@ export function createConfigSection({
// section prefix to templates so they can attempt `${section}.${field}` lookups.
sectionI18nPrefix: sectionPath,
t,
+ renderers:
+ sectionConfig?.renderers ?? sectionRenderers?.[sectionPath],
}}
/>
@@ -593,7 +613,8 @@ export function createConfigSection({
)}
- {level === "camera" && isOverridden && (
+ {((level === "camera" && isOverridden) ||
+ level === "global") && (
)}
@@ -650,7 +676,7 @@ export function createConfigSection({
)}
- {level === "camera" && isOverridden && (
+ {((level === "camera" && isOverridden) || level === "global") && (
)}
)}
{/* Reset button when title is hidden but we're at camera level with override */}
- {!shouldShowTitle && level === "camera" && isOverridden && (
-
-
-
- )}
+ {!shouldShowTitle &&
+ ((level === "camera" && isOverridden) || level === "global") && (
+
+
+
+ )}
{sectionContent}
diff --git a/web/src/components/config-form/theme/templates/DescriptionFieldTemplate.tsx b/web/src/components/config-form/theme/templates/DescriptionFieldTemplate.tsx
index ae173df9e..a57c90645 100644
--- a/web/src/components/config-form/theme/templates/DescriptionFieldTemplate.tsx
+++ b/web/src/components/config-form/theme/templates/DescriptionFieldTemplate.tsx
@@ -30,8 +30,8 @@ export function DescriptionFieldTemplate(props: DescriptionFieldProps) {
}
return (
-
+
{resolvedDescription}
-
+
);
}
diff --git a/web/src/components/config-form/theme/templates/FieldTemplate.tsx b/web/src/components/config-form/theme/templates/FieldTemplate.tsx
index b2cc1ecc6..1c1161967 100644
--- a/web/src/components/config-form/theme/templates/FieldTemplate.tsx
+++ b/web/src/components/config-form/theme/templates/FieldTemplate.tsx
@@ -5,6 +5,8 @@ import {
getUiOptions,
ADDITIONAL_PROPERTY_FLAG,
} from "@rjsf/utils";
+import { ComponentType, ReactNode } from "react";
+import { isValidElement } from "react";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
@@ -52,6 +54,14 @@ function humanizeKey(value: string): string {
.join(" ");
}
+type FieldRenderSpec =
+ | ReactNode
+ | ComponentType
+ | {
+ render: string;
+ props?: Record;
+ };
+
export function FieldTemplate(props: FieldTemplateProps) {
const {
id,
@@ -264,6 +274,41 @@ export function FieldTemplate(props: FieldTemplateProps) {
}
const uiOptions = getUiOptions(uiSchema);
+ const beforeSpec = uiSchema?.["ui:before"] as FieldRenderSpec | undefined;
+ const afterSpec = uiSchema?.["ui:after"] as FieldRenderSpec | undefined;
+
+ const renderCustom = (spec: FieldRenderSpec | undefined) => {
+ if (spec === undefined || spec === null) {
+ return null;
+ }
+
+ if (isValidElement(spec) || typeof spec === "string") {
+ return spec;
+ }
+
+ if (typeof spec === "number") {
+ return {spec};
+ }
+
+ if (typeof spec === "function") {
+ const SpecComponent = spec as ComponentType;
+ return ;
+ }
+
+ if (typeof spec === "object" && "render" in spec) {
+ const renderKey = spec.render;
+ const renderers = formContext?.renderers;
+ const RenderComponent = renderers?.[renderKey];
+ if (RenderComponent) {
+ return ;
+ }
+ }
+
+ return null;
+ };
+
+ const beforeContent = renderCustom(beforeSpec);
+ const afterContent = renderCustom(afterSpec);
const WrapIfAdditionalTemplate = getTemplate(
"WrapIfAdditionalTemplate",
registry,
@@ -290,64 +335,71 @@ export function FieldTemplate(props: FieldTemplateProps) {
rawErrors={rawErrors}
hideError={false}
>
-
- {displayLabel &&
- finalLabel &&
- !isBoolean &&
- !isMultiSchemaWrapper &&
- !isObjectField &&
- !isAdditionalProperty && (
-
+
+ {beforeContent}
+
+ {displayLabel &&
+ finalLabel &&
+ !isBoolean &&
+ !isMultiSchemaWrapper &&
+ !isObjectField &&
+ !isAdditionalProperty && (
+
+ )}
- {isBoolean ? (
-
-
- {displayLabel && finalLabel && (
-
- )}
- {finalDescription && !isMultiSchemaWrapper && (
+ {isBoolean ? (
+
+
+ {displayLabel && finalLabel && (
+
+ )}
+ {finalDescription && !isMultiSchemaWrapper && (
+
+ {finalDescription}
+
+ )}
+
+
{children}
+
+ ) : (
+ <>
+ {children}
+
+ {finalDescription && !isMultiSchemaWrapper && !isObjectField && (
{finalDescription}
)}
-
- {children}
-
- ) : (
- <>
- {children}
- {finalDescription && !isMultiSchemaWrapper && !isObjectField && (
-
- {finalDescription}
-
- )}
- >
- )}
+ >
+ )}
- {errors}
- {help}
+ {errors}
+ {help}
+
+ {afterContent}
);
diff --git a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx
index 99900f3d7..258c3e325 100644
--- a/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx
+++ b/web/src/components/config-form/theme/templates/ObjectFieldTemplate.tsx
@@ -221,7 +221,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
{groups.map((group) => (
-
+
{group.label}
diff --git a/web/src/types/configForm.ts b/web/src/types/configForm.ts
index 60a98d174..b8408eec9 100644
--- a/web/src/types/configForm.ts
+++ b/web/src/types/configForm.ts
@@ -1,3 +1,4 @@
+import type { RendererComponent } from "@/components/config-form/sectionExtras/registry";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
export type JsonPrimitive = string | number | boolean | null;
@@ -22,4 +23,5 @@ export type ConfigFormContext = {
i18nNamespace?: string;
sectionI18nPrefix?: string;
t?: (key: string, options?: Record) => string;
+ renderers?: Record;
};