mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-27 19:38:59 +03:00
Implement UI for managing multiple models (#24023)
* Implement hardware detection and UI management * Cleanup Frigate+ detection * Don't count model as changed * Fixes for audio map error * Add descriptions * Enforce that all model must exist * Fix hardware picking * Docs fixes * WebUI cleanup * Cleanup handling of scenes * UI refinement * Cleanup recommended UI * test fixews
This commit is contained in:
@@ -0,0 +1,460 @@
|
||||
import type {
|
||||
ErrorSchema,
|
||||
FieldProps,
|
||||
RJSFSchema,
|
||||
UiSchema,
|
||||
} from "@rjsf/utils";
|
||||
import { toFieldPathId } from "@rjsf/utils";
|
||||
import { cloneDeep } from "lodash";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LuChevronDown,
|
||||
LuChevronRight,
|
||||
LuPlus,
|
||||
LuTrash2,
|
||||
} from "react-icons/lu";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import useSWR from "swr";
|
||||
import { DetectionHardware } from "@/types/hardware";
|
||||
import { summarizeDevices } from "@/utils/detectionHardware";
|
||||
import { HardwarePicker } from "./HardwarePicker";
|
||||
import { ModelSourcePicker } from "./ModelSourcePicker";
|
||||
|
||||
type DetectionModel = {
|
||||
scene?: string;
|
||||
devices?: string[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
// scene and devices get dedicated controls; everything else is the model itself
|
||||
const CUSTOM_MODEL_FIELDS = [
|
||||
"path",
|
||||
"labelmap_path",
|
||||
"width",
|
||||
"height",
|
||||
"input_pixel_format",
|
||||
"input_tensor",
|
||||
"input_dtype",
|
||||
"model_type",
|
||||
];
|
||||
|
||||
/** The detector a model runs on, which is the prefix of its device strings. */
|
||||
const detectorForModel = (model: DetectionModel): string | undefined =>
|
||||
model.devices?.[0]?.split(":")[0];
|
||||
|
||||
const asModelList = (formData: unknown): DetectionModel[] => {
|
||||
if (!Array.isArray(formData)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return formData.filter(
|
||||
(item): item is DetectionModel => typeof item === "object" && item !== null,
|
||||
);
|
||||
};
|
||||
|
||||
const getItemSchema = (schema: RJSFSchema): RJSFSchema | undefined => {
|
||||
const items = schema.items;
|
||||
|
||||
if (!items || typeof items !== "object" || Array.isArray(items)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return items as RJSFSchema;
|
||||
};
|
||||
|
||||
const getItemProperties = (
|
||||
schema: RJSFSchema | undefined,
|
||||
): Record<string, RJSFSchema> => {
|
||||
if (!schema || typeof schema.properties !== "object" || !schema.properties) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return schema.properties as Record<string, RJSFSchema>;
|
||||
};
|
||||
|
||||
const getSceneOptions = (itemSchema: RJSFSchema | undefined): string[] => {
|
||||
const scene = getItemProperties(itemSchema).scene as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const values = scene?.enum;
|
||||
|
||||
return Array.isArray(values)
|
||||
? values.filter((v): v is string => typeof v === "string")
|
||||
: [];
|
||||
};
|
||||
|
||||
export function ModelsField(props: FieldProps) {
|
||||
const {
|
||||
schema,
|
||||
uiSchema,
|
||||
formData,
|
||||
onChange,
|
||||
fieldPathId,
|
||||
registry,
|
||||
idSchema,
|
||||
errorSchema,
|
||||
disabled,
|
||||
readonly,
|
||||
hideError,
|
||||
onBlur,
|
||||
onFocus,
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||
|
||||
const models = useMemo(() => asModelList(formData), [formData]);
|
||||
const itemSchema = useMemo(
|
||||
() => getItemSchema(schema as RJSFSchema),
|
||||
[schema],
|
||||
);
|
||||
const itemProperties = useMemo(
|
||||
() => getItemProperties(itemSchema),
|
||||
[itemSchema],
|
||||
);
|
||||
const itemUiSchema = useMemo(
|
||||
() =>
|
||||
((uiSchema as { items?: UiSchema } | undefined)?.items ?? {}) as UiSchema,
|
||||
[uiSchema],
|
||||
);
|
||||
const sceneOptions = useMemo(() => getSceneOptions(itemSchema), [itemSchema]);
|
||||
const SchemaField = registry.fields.SchemaField;
|
||||
|
||||
const [openByIndex, setOpenByIndex] = useState<Record<number, boolean>>({});
|
||||
|
||||
// shared with HardwarePicker through the SWR cache, so this is not a second
|
||||
// request
|
||||
const { data: hardware } = useSWR<DetectionHardware[]>("hardware/probe");
|
||||
|
||||
useEffect(() => {
|
||||
setOpenByIndex((previous) => {
|
||||
const next: Record<number, boolean> = {};
|
||||
for (let index = 0; index < models.length; index += 1) {
|
||||
next[index] = previous[index] ?? true;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [models.length]);
|
||||
|
||||
const cameras = formContext?.fullConfig?.cameras;
|
||||
const savedModels = formContext?.fullConfig?.models;
|
||||
|
||||
// `plus` is a readonly field stripped from the form data, so read it from the
|
||||
// full config. Match on scene rather than index, which shifts when a model is
|
||||
// added or removed.
|
||||
const savedPlusForScene = useCallback(
|
||||
(scene: string | undefined) =>
|
||||
savedModels?.find((saved) => saved.scene === scene)?.plus,
|
||||
[savedModels],
|
||||
);
|
||||
|
||||
// a model serves the cameras naming its scene, plus every camera that names
|
||||
// no scene at all when it is the "all" model
|
||||
const cameraCountForScene = useCallback(
|
||||
(scene: string | undefined): number => {
|
||||
if (!cameras) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Object.values(cameras).filter((camera) => {
|
||||
const cameraScene = camera?.detect?.scene;
|
||||
return cameraScene ? cameraScene === scene : scene === "all";
|
||||
}).length;
|
||||
},
|
||||
[cameras],
|
||||
);
|
||||
|
||||
const claimedByOtherModels = useCallback(
|
||||
(index: number): Record<string, string> => {
|
||||
const claimed: Record<string, string> = {};
|
||||
|
||||
models.forEach((model, currentIndex) => {
|
||||
if (currentIndex === index) {
|
||||
return;
|
||||
}
|
||||
|
||||
(model.devices ?? []).forEach((device) => {
|
||||
claimed[device] = model.scene ?? String(currentIndex + 1);
|
||||
});
|
||||
});
|
||||
|
||||
return claimed;
|
||||
},
|
||||
[models],
|
||||
);
|
||||
|
||||
const updateModel = useCallback(
|
||||
(index: number, partial: Partial<DetectionModel>) => {
|
||||
const next = cloneDeep(models);
|
||||
next[index] = { ...next[index], ...partial };
|
||||
onChange(next, fieldPathId.path);
|
||||
},
|
||||
[models, onChange, fieldPathId.path],
|
||||
);
|
||||
|
||||
const handleAddModel = useCallback(() => {
|
||||
const base = itemSchema
|
||||
? (applySchemaDefaults(itemSchema) as DetectionModel)
|
||||
: ({} as DetectionModel);
|
||||
const taken = new Set(models.map((model) => model.scene));
|
||||
const scene = sceneOptions.find((option) => !taken.has(option));
|
||||
|
||||
onChange([...models, { ...base, scene, devices: [] }], fieldPathId.path);
|
||||
setOpenByIndex((previous) => ({ ...previous, [models.length]: true }));
|
||||
}, [models, itemSchema, sceneOptions, onChange, fieldPathId.path]);
|
||||
|
||||
const handleRemoveModel = useCallback(
|
||||
(index: number) => {
|
||||
onChange(
|
||||
models.filter((_, currentIndex) => currentIndex !== index),
|
||||
fieldPathId.path,
|
||||
);
|
||||
|
||||
setOpenByIndex((previous) => {
|
||||
const next: Record<number, boolean> = {};
|
||||
Object.entries(previous).forEach(([key, value]) => {
|
||||
const current = Number(key);
|
||||
if (Number.isNaN(current) || current === index) {
|
||||
return;
|
||||
}
|
||||
next[current > index ? current - 1 : current] = value;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[models, onChange, fieldPathId.path],
|
||||
);
|
||||
|
||||
const renderField = useCallback(
|
||||
(index: number, fieldName: string) => {
|
||||
const fieldSchema = itemProperties[fieldName];
|
||||
|
||||
if (!SchemaField || !fieldSchema) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const itemFieldPathId = toFieldPathId(
|
||||
fieldName,
|
||||
registry.globalFormOptions,
|
||||
[...fieldPathId.path, index],
|
||||
);
|
||||
const itemErrors = (
|
||||
errorSchema as Record<string, ErrorSchema> | undefined
|
||||
)?.[index] as Record<string, ErrorSchema> | undefined;
|
||||
|
||||
return (
|
||||
<SchemaField
|
||||
key={fieldName}
|
||||
name={fieldName}
|
||||
schema={fieldSchema}
|
||||
uiSchema={(itemUiSchema[fieldName] as UiSchema | undefined) ?? {}}
|
||||
fieldPathId={itemFieldPathId}
|
||||
formData={(models[index] as Record<string, unknown>)?.[fieldName]}
|
||||
errorSchema={itemErrors?.[fieldName]}
|
||||
onChange={(nextValue: unknown) =>
|
||||
updateModel(index, { [fieldName]: nextValue })
|
||||
}
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
registry={registry}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
hideError={hideError}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
SchemaField,
|
||||
itemProperties,
|
||||
itemUiSchema,
|
||||
models,
|
||||
registry,
|
||||
fieldPathId.path,
|
||||
errorSchema,
|
||||
updateModel,
|
||||
onBlur,
|
||||
onFocus,
|
||||
disabled,
|
||||
readonly,
|
||||
hideError,
|
||||
],
|
||||
);
|
||||
|
||||
const baseId = idSchema?.$id ?? "models";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{models.map((model, index) => {
|
||||
const open = openByIndex[index] ?? true;
|
||||
const takenScenes = new Set(
|
||||
models
|
||||
.filter((_, currentIndex) => currentIndex !== index)
|
||||
.map((other) => other.scene),
|
||||
);
|
||||
|
||||
return (
|
||||
<Card key={`${baseId}-${index}`} className="w-full">
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) =>
|
||||
setOpenByIndex((previous) => ({
|
||||
...previous,
|
||||
[index]: nextOpen,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardTitle className="flex-1 cursor-pointer text-sm">
|
||||
<span>
|
||||
{t(`detectionModels.scenes.${model.scene ?? "all"}`)}
|
||||
</span>
|
||||
<span className="mt-1 block text-xs font-normal text-muted-foreground">
|
||||
{summarizeDevices(
|
||||
hardware ?? [],
|
||||
model.devices ?? [],
|
||||
) ?? t("detectionModels.hardware.none")}
|
||||
{" • "}
|
||||
{t("detectionModels.cameras", {
|
||||
count: cameraCountForScene(model.scene),
|
||||
})}
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CollapsibleTrigger>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{models.length > 1 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveModel(index)}
|
||||
disabled={disabled || readonly}
|
||||
aria-label={t("button.delete", { ns: "common" })}
|
||||
>
|
||||
<LuTrash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("button.delete", { ns: "common" })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t(
|
||||
open ? "button.collapse" : "button.expand",
|
||||
{ ns: "common" },
|
||||
)}
|
||||
>
|
||||
{open ? (
|
||||
<LuChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<LuChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-6 p-4 pt-0">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("detectionModels.scene.label")}</Label>
|
||||
<Select
|
||||
value={model.scene ?? ""}
|
||||
onValueChange={(scene) => updateModel(index, { scene })}
|
||||
disabled={disabled || readonly}
|
||||
>
|
||||
<SelectTrigger className="max-w-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sceneOptions.map((scene) => (
|
||||
<SelectItem
|
||||
key={scene}
|
||||
value={scene}
|
||||
disabled={takenScenes.has(scene)}
|
||||
>
|
||||
{t(`detectionModels.scenes.${scene}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("detectionModels.scene.description")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HardwarePicker
|
||||
idPrefix={`${baseId}-${index}`}
|
||||
devices={model.devices ?? []}
|
||||
claimedElsewhere={claimedByOtherModels(index)}
|
||||
cameraCount={cameraCountForScene(model.scene)}
|
||||
disabled={disabled || readonly}
|
||||
onChange={(devices) => updateModel(index, { devices })}
|
||||
/>
|
||||
|
||||
<ModelSourcePicker
|
||||
path={model.path}
|
||||
plus={savedPlusForScene(model.scene)}
|
||||
detector={detectorForModel(model)}
|
||||
disabled={disabled || readonly}
|
||||
onPathChange={(path) => updateModel(index, { path })}
|
||||
customFields={CUSTOM_MODEL_FIELDS.map((fieldName) =>
|
||||
renderField(index, fieldName),
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{models.length < sceneOptions.length ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddModel}
|
||||
disabled={disabled || readonly}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("detectionModels.addModel")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModelsField;
|
||||
Reference in New Issue
Block a user