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; };