mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-25 05:09:01 +03:00
Miscellaneous fixes (#23258)
* render orphaned filter entries as collapsibles instead of the Key/Value editor * Symlink for various AI files * change replay confg dialog to platform aware sheet * change agents title * fix test * tweak collapsible * remove camera ui section in settings no point to having it anymore with profiles and camera management settings * fix admin response cache leak to non-admin users via nginx proxy_cache * add model fetcher endpoint for genai config ui --------- Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
co-authored by
Nicolas Mowen
parent
03f4f76b72
commit
8ea46e7c6c
@@ -1288,11 +1288,6 @@ export function ConfigSection({
|
||||
<CollapsibleTrigger asChild>
|
||||
<div className="flex cursor-pointer items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{isOpen ? (
|
||||
<LuChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<LuChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<Heading as="h4">{title}</Heading>
|
||||
{showOverrideIndicator &&
|
||||
effectiveLevel === "camera" &&
|
||||
@@ -1323,12 +1318,17 @@ export function ConfigSection({
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
{isOpen ? (
|
||||
<LuChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<LuChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<div className="pl-7">{sectionContent}</div>
|
||||
<div className="pl-0">{sectionContent}</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
|
||||
@@ -171,7 +171,20 @@ function modifyObjectsSchema(
|
||||
ctx.fullConfig.objects?.track ??
|
||||
[];
|
||||
|
||||
if (track.length === 0) return schema;
|
||||
// Also promote any label that has a saved filter entry but isn't in
|
||||
// `track` (e.g. the user toggled an object off but left a customized
|
||||
// filter in YAML). Without this, RJSF falls back to the additional-
|
||||
// properties Key/Value editor for those orphans.
|
||||
const filtersSaved =
|
||||
(ctx.level !== "global"
|
||||
? ctx.fullCameraConfig?.objects?.filters
|
||||
: undefined) ??
|
||||
ctx.fullConfig.objects?.filters ??
|
||||
{};
|
||||
|
||||
if (track.length === 0 && Object.keys(filtersSaved).length === 0) {
|
||||
return schema;
|
||||
}
|
||||
|
||||
const schemaProperties = isJsonObject(
|
||||
(schema as { properties?: unknown }).properties,
|
||||
@@ -199,16 +212,27 @@ function modifyObjectsSchema(
|
||||
? (filtersSchema as { properties: Record<string, RJSFSchema> }).properties
|
||||
: {};
|
||||
|
||||
// Promote every tracked label to an explicit property entry so RJSF
|
||||
// renders it as a normal collapsible (no additionalProperties key/value
|
||||
// editor UI). Attribute labels get a restricted shape with only
|
||||
// `min_score`; non-attribute labels get the full FilterConfig. Sorted
|
||||
// alphabetically so the filter collapsibles match the order of the
|
||||
// sibling `track` switches.
|
||||
const sortedTrackedLabels = track
|
||||
.filter((label): label is string => typeof label === "string")
|
||||
.slice()
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
// Promote every tracked label (and any orphaned filter entry) to an
|
||||
// explicit property entry so RJSF renders it as a normal collapsible
|
||||
// (no additionalProperties key/value editor UI). Attribute labels get a
|
||||
// restricted shape with only `min_score`/`min_area`/`max_area`;
|
||||
// non-attribute labels get the full FilterConfig. Sorted alphabetically
|
||||
// so the filter collapsibles match the order of the sibling `track`
|
||||
// switches.
|
||||
const labelsToPromote = new Set<string>();
|
||||
for (const label of track) {
|
||||
if (typeof label === "string") labelsToPromote.add(label);
|
||||
}
|
||||
for (const key of Object.keys(filtersSaved)) {
|
||||
// Skip attribute labels that aren't tracked — those are hidden
|
||||
// entirely via hideAttributeFilters; promoting them would surface a
|
||||
// collapsible we then have to hide separately.
|
||||
if (attributeSet.has(key) && !labelsToPromote.has(key)) continue;
|
||||
labelsToPromote.add(key);
|
||||
}
|
||||
const sortedTrackedLabels = [...labelsToPromote].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
const updatedFilterProperties: Record<string, RJSFSchema> = {
|
||||
...existingProperties,
|
||||
};
|
||||
|
||||
@@ -4,8 +4,11 @@ import { useState, useMemo, useEffect, useRef } from "react";
|
||||
import type { WidgetProps } from "@rjsf/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import useSWR from "swr";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import axios from "axios";
|
||||
import { Check, ChevronsUpDown, Plus, RefreshCw } from "lucide-react";
|
||||
import { LuCheck } from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
@@ -19,9 +22,17 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
import type { ConfigFormContext, JsonObject } from "@/types/configForm";
|
||||
import { getSizedFieldClassName } from "../utils";
|
||||
|
||||
type ProbeResponse =
|
||||
| { success: true; models: string[] }
|
||||
| { success: false; message: string };
|
||||
|
||||
type ProbeStatus = "idle" | "probing" | "success" | "error";
|
||||
|
||||
const PROBE_SUCCESS_INDICATOR_MS = 3000;
|
||||
|
||||
/**
|
||||
* Extract the provider config entry name from the RJSF widget id.
|
||||
* Widget ids look like "root_myProvider_model".
|
||||
@@ -41,6 +52,7 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
const { id, value, disabled, readonly, onChange, options, registry } = props;
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
const fieldClassName = getSizedFieldClassName(options, "sm");
|
||||
const providerKey = useMemo(() => getProviderKey(id), [id]);
|
||||
@@ -77,78 +89,261 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
}
|
||||
}, [configFingerprint, mutateModels]);
|
||||
|
||||
const models = useMemo(() => {
|
||||
const fetchedModels = useMemo(() => {
|
||||
if (!allModels || !providerKey) return [];
|
||||
return allModels[providerKey] ?? [];
|
||||
}, [allModels, providerKey]);
|
||||
|
||||
const [probeStatus, setProbeStatus] = useState<ProbeStatus>("idle");
|
||||
const [probeError, setProbeError] = useState<string | null>(null);
|
||||
const [probedModels, setProbedModels] = useState<string[] | null>(null);
|
||||
const probeSuccessTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const probing = probeStatus === "probing";
|
||||
|
||||
// Reset probe results if the provider entry name changes
|
||||
useEffect(() => {
|
||||
setProbedModels(null);
|
||||
setProbeError(null);
|
||||
setProbeStatus("idle");
|
||||
if (probeSuccessTimerRef.current) {
|
||||
clearTimeout(probeSuccessTimerRef.current);
|
||||
probeSuccessTimerRef.current = null;
|
||||
}
|
||||
}, [providerKey]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (probeSuccessTimerRef.current) {
|
||||
clearTimeout(probeSuccessTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const models = probedModels ?? fetchedModels;
|
||||
|
||||
const trimmedSearch = searchValue.trim();
|
||||
const matchesFetched = useMemo(
|
||||
() => models.some((m) => m.toLowerCase() === trimmedSearch.toLowerCase()),
|
||||
[models, trimmedSearch],
|
||||
);
|
||||
const showCustomOption = trimmedSearch.length > 0 && !matchesFetched;
|
||||
|
||||
// Read the live form values for this provider so probe sends the user's
|
||||
// in-flight edits, not the saved config (which may not exist yet).
|
||||
const formEntry = useMemo<JsonObject | null>(() => {
|
||||
if (!providerKey) return null;
|
||||
const formData = formContext?.formData as JsonObject | undefined;
|
||||
const entry = formData?.[providerKey];
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return null;
|
||||
}
|
||||
return entry as JsonObject;
|
||||
}, [providerKey, formContext?.formData]);
|
||||
|
||||
const formProvider =
|
||||
typeof formEntry?.provider === "string" ? formEntry.provider : null;
|
||||
const canProbe = Boolean(formProvider) && !probing;
|
||||
|
||||
const probe = async () => {
|
||||
if (!formEntry || !formProvider) return;
|
||||
if (probeSuccessTimerRef.current) {
|
||||
clearTimeout(probeSuccessTimerRef.current);
|
||||
probeSuccessTimerRef.current = null;
|
||||
}
|
||||
setProbeStatus("probing");
|
||||
setProbeError(null);
|
||||
try {
|
||||
const res = await axios.post<ProbeResponse>("genai/probe", {
|
||||
provider: formProvider,
|
||||
api_key:
|
||||
typeof formEntry.api_key === "string" ? formEntry.api_key : null,
|
||||
base_url:
|
||||
typeof formEntry.base_url === "string" ? formEntry.base_url : null,
|
||||
provider_options:
|
||||
formEntry.provider_options &&
|
||||
typeof formEntry.provider_options === "object" &&
|
||||
!Array.isArray(formEntry.provider_options)
|
||||
? (formEntry.provider_options as JsonObject)
|
||||
: {},
|
||||
});
|
||||
if (res.data.success) {
|
||||
setProbedModels(res.data.models);
|
||||
setProbeStatus("success");
|
||||
probeSuccessTimerRef.current = setTimeout(() => {
|
||||
setProbeStatus("idle");
|
||||
probeSuccessTimerRef.current = null;
|
||||
}, PROBE_SUCCESS_INDICATOR_MS);
|
||||
} else {
|
||||
setProbedModels([]);
|
||||
setProbeError(res.data.message);
|
||||
setProbeStatus("error");
|
||||
}
|
||||
} catch {
|
||||
setProbedModels(null);
|
||||
setProbeError(
|
||||
t("configForm.genaiModel.probeFailed", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Failed to probe models",
|
||||
}),
|
||||
);
|
||||
setProbeStatus("error");
|
||||
}
|
||||
};
|
||||
|
||||
const commit = (next: string) => {
|
||||
onChange(next);
|
||||
setSearchValue("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const currentLabel = typeof value === "string" && value ? value : undefined;
|
||||
|
||||
const refreshLabel = t("configForm.genaiModel.refresh", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Refresh models",
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled || readonly}
|
||||
className={cn(
|
||||
"justify-between font-normal",
|
||||
!currentLabel && "text-muted-foreground",
|
||||
fieldClassName,
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) setSearchValue("");
|
||||
}}
|
||||
>
|
||||
{currentLabel ??
|
||||
t("configForm.genaiModel.placeholder", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Select model…",
|
||||
})}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("configForm.genaiModel.search", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Search models…",
|
||||
})}
|
||||
/>
|
||||
<CommandList>
|
||||
{models.length > 0 ? (
|
||||
<CommandGroup>
|
||||
{models.map((model) => (
|
||||
<CommandItem
|
||||
key={model}
|
||||
value={model}
|
||||
onSelect={() => {
|
||||
onChange(model);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === model ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{model}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{t("configForm.genaiModel.noModels", {
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
id={id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled || readonly}
|
||||
className={cn(
|
||||
"justify-between font-normal",
|
||||
!currentLabel && "text-muted-foreground",
|
||||
fieldClassName,
|
||||
)}
|
||||
>
|
||||
{currentLabel ??
|
||||
t("configForm.genaiModel.placeholder", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "No models available",
|
||||
defaultValue: "Select or enter a model…",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("configForm.genaiModel.search", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Search or enter a model…",
|
||||
})}
|
||||
value={searchValue}
|
||||
onValueChange={setSearchValue}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && showCustomOption) {
|
||||
e.preventDefault();
|
||||
commit(trimmedSearch);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
{showCustomOption && (
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value={trimmedSearch}
|
||||
onSelect={() => commit(trimmedSearch)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("configForm.genaiModel.useCustom", {
|
||||
ns: "views/settings",
|
||||
value: trimmedSearch,
|
||||
defaultValue: 'Use "{{value}}"',
|
||||
})}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
{models.length > 0 ? (
|
||||
<CommandGroup
|
||||
heading={t("configForm.genaiModel.available", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Available models",
|
||||
})}
|
||||
>
|
||||
{models.map((model) => (
|
||||
<CommandItem
|
||||
key={model}
|
||||
value={model}
|
||||
onSelect={() => commit(model)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === model ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{model}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
) : !showCustomOption ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
{t("configForm.genaiModel.noModels", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "No models available",
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
disabled={!canProbe || disabled || readonly}
|
||||
onClick={probe}
|
||||
title={refreshLabel}
|
||||
aria-label={refreshLabel}
|
||||
>
|
||||
{probing ? (
|
||||
<ActivityIndicator className="h-4 w-4" size={16} />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"flex items-center justify-start gap-1 text-xs transition-opacity duration-200",
|
||||
probeStatus === "idle" || probeStatus === "probing"
|
||||
? "opacity-0"
|
||||
: "opacity-100",
|
||||
)}
|
||||
>
|
||||
{probeStatus === "success" && (
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<LuCheck className="size-3.5" />
|
||||
{t("configForm.genaiModel.fetchedModels", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Successfully fetched model list",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{probeStatus === "error" && probeError && (
|
||||
<span className="text-destructive">{probeError}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user