add ability to render components before and after fields

This commit is contained in:
Josh Hawkins 2026-01-31 20:44:53 -06:00
parent e09928a7f0
commit 3c5298e304
10 changed files with 320 additions and 92 deletions

View File

@ -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",

View File

@ -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",

View File

@ -604,6 +604,11 @@ const sectionConfigs: Record<string, SectionConfigOverrides> = {
hiddenFields: [],
advancedFields: [],
overrideFields: [],
uiSchema: {
enabled: {
"ui:after": { render: "SemanticSearchReindex" },
},
},
},
global: {
fieldOrder: ["enabled", "reindex", "model", "model_size", "device"],

View File

@ -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 (
<>
<div className="flex flex-col space-y-1">
<div className="flex">
<Button
variant="default"
size="sm"
onClick={() => setIsDialogOpen(true)}
disabled={isLoading}
aria-label={t("enrichments.semanticSearch.reindexNow.label")}
>
{t("enrichments.semanticSearch.reindexNow.label")}
</Button>
</div>
<div className="mt-2 text-xs text-muted-foreground">
<Trans ns="views/settings">
enrichments.semanticSearch.reindexNow.desc
</Trans>
</div>
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("enrichments.semanticSearch.reindexNow.confirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
<Trans ns="views/settings">
enrichments.semanticSearch.reindexNow.confirmDesc
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setIsDialogOpen(false)}>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "select" })}
onClick={async () => {
await onReindex();
setIsDialogOpen(false);
}}
>
{t("enrichments.semanticSearch.reindexNow.confirmButton")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</>
);
}

View File

@ -0,0 +1,25 @@
import type { ComponentType } from "react";
import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
export type RendererComponent = ComponentType<
Record<string, unknown> | undefined
>;
export type SectionRenderers = Record<
string,
Record<string, RendererComponent>
>;
// 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;

View File

@ -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<string, RendererComponent>;
}
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({
</Badge>
)}
</div>
{level === "camera" && isOverridden && (
{((level === "camera" && isOverridden) ||
level === "global") && (
<Button
variant="ghost"
size="sm"
@ -604,10 +625,15 @@ export function createConfigSection({
className="gap-2"
>
<LuRotateCcw className="h-4 w-4" />
{t("button.resetToGlobal", {
ns: "common",
defaultValue: "Reset to Global",
})}
{level === "global"
? t("button.resetToDefault", {
ns: "common",
defaultValue: "Reset to Default",
})
: t("button.resetToGlobal", {
ns: "common",
defaultValue: "Reset to Global",
})}
</Button>
)}
</div>
@ -650,7 +676,7 @@ export function createConfigSection({
</p>
)}
</div>
{level === "camera" && isOverridden && (
{((level === "camera" && isOverridden) || level === "global") && (
<Button
variant="ghost"
size="sm"
@ -658,32 +684,43 @@ export function createConfigSection({
className="gap-2"
>
<LuRotateCcw className="h-4 w-4" />
{t("button.resetToGlobal", {
ns: "common",
defaultValue: "Reset to Global",
})}
{level === "global"
? t("button.resetToDefault", {
ns: "common",
defaultValue: "Reset to Default",
})
: t("button.resetToGlobal", {
ns: "common",
defaultValue: "Reset to Global",
})}
</Button>
)}
</div>
)}
{/* Reset button when title is hidden but we're at camera level with override */}
{!shouldShowTitle && level === "camera" && isOverridden && (
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={handleResetToGlobal}
className="gap-2"
>
<LuRotateCcw className="h-4 w-4" />
{t("button.resetToGlobal", {
ns: "common",
defaultValue: "Reset to Global",
})}
</Button>
</div>
)}
{!shouldShowTitle &&
((level === "camera" && isOverridden) || level === "global") && (
<div className="flex justify-end">
<Button
variant="ghost"
size="sm"
onClick={handleResetToGlobal}
className="gap-2"
>
<LuRotateCcw className="h-4 w-4" />
{level === "global"
? t("button.resetToDefault", {
ns: "common",
defaultValue: "Reset to Default",
})
: t("button.resetToGlobal", {
ns: "common",
defaultValue: "Reset to Global",
})}
</Button>
</div>
)}
{sectionContent}
</div>

View File

@ -30,8 +30,8 @@ export function DescriptionFieldTemplate(props: DescriptionFieldProps) {
}
return (
<p id={id} className="text-sm text-muted-foreground">
<span id={id} className="text-sm text-muted-foreground">
{resolvedDescription}
</p>
</span>
);
}

View File

@ -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<unknown>
| {
render: string;
props?: Record<string, unknown>;
};
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 <span>{spec}</span>;
}
if (typeof spec === "function") {
const SpecComponent = spec as ComponentType<unknown>;
return <SpecComponent />;
}
if (typeof spec === "object" && "render" in spec) {
const renderKey = spec.render;
const renderers = formContext?.renderers;
const RenderComponent = renderers?.[renderKey];
if (RenderComponent) {
return <RenderComponent {...(spec.props ?? {})} />;
}
}
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}
>
<div
className={cn(
"space-y-1",
isAdvanced && "border-l-2 border-muted pl-4",
isBoolean && "flex items-center justify-between gap-4",
)}
data-field-id={translationPath}
>
{displayLabel &&
finalLabel &&
!isBoolean &&
!isMultiSchemaWrapper &&
!isObjectField &&
!isAdditionalProperty && (
<Label
htmlFor={id}
className={cn(
"text-sm font-medium",
errors &&
errors.props?.errors?.length > 0 &&
"text-destructive",
)}
>
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
<div className="flex flex-col space-y-6">
{beforeContent}
<div
className={cn(
"space-y-1",
isAdvanced && "border-l-2 border-muted pl-4",
isBoolean && "flex items-center justify-between gap-4",
)}
data-field-id={translationPath}
>
{displayLabel &&
finalLabel &&
!isBoolean &&
!isMultiSchemaWrapper &&
!isObjectField &&
!isAdditionalProperty && (
<Label
htmlFor={id}
className={cn(
"text-sm font-medium",
errors &&
errors.props?.errors?.length > 0 &&
"text-destructive",
)}
>
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
)}
{isBoolean ? (
<div className="flex w-full items-center justify-between gap-4">
<div className="space-y-0.5">
{displayLabel && finalLabel && (
<Label htmlFor={id} className="text-sm font-medium">
{finalLabel}
{required && <span className="ml-1 text-destructive">*</span>}
</Label>
)}
{finalDescription && !isMultiSchemaWrapper && (
{isBoolean ? (
<div className="flex w-full items-center justify-between gap-4">
<div className="space-y-0.5">
{displayLabel && finalLabel && (
<Label htmlFor={id} className="text-sm font-medium">
{finalLabel}
{required && (
<span className="ml-1 text-destructive">*</span>
)}
</Label>
)}
{finalDescription && !isMultiSchemaWrapper && (
<p className="text-xs text-muted-foreground">
{finalDescription}
</p>
)}
</div>
<div className="flex items-center gap-2">{children}</div>
</div>
) : (
<>
{children}
{finalDescription && !isMultiSchemaWrapper && !isObjectField && (
<p className="text-xs text-muted-foreground">
{finalDescription}
</p>
)}
</div>
{children}
</div>
) : (
<>
{children}
{finalDescription && !isMultiSchemaWrapper && !isObjectField && (
<p className="text-xs text-muted-foreground">
{finalDescription}
</p>
)}
</>
)}
</>
)}
{errors}
{help}
{errors}
{help}
</div>
{afterContent}
</div>
</WrapIfAdditionalTemplate>
);

View File

@ -221,7 +221,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
<div className="space-y-6">
{groups.map((group) => (
<div key={group.key} className="space-y-4">
<div className="text-sm font-medium text-muted-foreground">
<div className="text-md font-medium text-primary">
{group.label}
</div>
<div className="space-y-4">

View File

@ -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, unknown>) => string;
renderers?: Record<string, RendererComponent>;
};