Add ability to update Frigate+ model to latest from UI (#17324)

* Add ability to update Frigate+ model to latest

* UI tweaks

* further UI tweaks

* UI tweaks: add width and height, fix select

* Add placeholder while API call in progress

* Fix Frigate+ enabled check

* Fix config change lost when reloading page

* Add persistent message requiring restart

* Drop down supported detectors and dimensions

* Add width and height to display

* Update FrigatePlusSettingsView.tsx

* Temp fix for Codespaces not loading

* Add i18n, format

* remove unneeded brackets

* missing colon

* Revert "Temp fix for Codespaces not loading"

This reverts commit 75b19674ce.
This commit is contained in:
leccelecce 2025-03-24 15:19:58 +00:00 committed by GitHub
parent c239721021
commit 05d39f79b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 356 additions and 13 deletions

View File

@ -640,6 +640,48 @@ def get_sub_labels(split_joined: Optional[int] = None):
return JSONResponse(content=sub_labels)
@router.get("/plus/models")
def plusModels(request: Request, filterByCurrentModelDetector: bool = False):
if not request.app.frigate_config.plus_api.is_active():
return JSONResponse(
content=({"success": False, "message": "Frigate+ is not enabled"}),
status_code=400,
)
models: dict[any, any] = request.app.frigate_config.plus_api.get_models()
if not models["list"]:
return JSONResponse(
content=({"success": False, "message": "No models found"}),
status_code=400,
)
modelList = models["list"]
# current model type
modelType = request.app.frigate_config.model.model_type
# current detectorType for comparing to supportedDetectors
detectorType = list(request.app.frigate_config.detectors.values())[0].type
validModels = []
for model in sorted(
filter(
lambda m: (
not filterByCurrentModelDetector
or (detectorType in m["supportedDetectors"] and modelType in m["type"])
),
modelList,
),
key=(lambda m: m["trainDate"]),
reverse=True,
):
validModels.append(model)
return JSONResponse(content=validModels)
@router.get("/recognized_license_plates")
def get_recognized_license_plates(split_joined: Optional[int] = None):
try:

View File

@ -234,3 +234,11 @@ class PlusApi:
raise Exception(r.text)
return r.json()
def get_models(self) -> Any:
r = self._get("model/list")
if not r.ok:
raise Exception(r.text)
return r.json()

View File

@ -540,14 +540,21 @@
},
"modelInfo": {
"title": "Model Information",
"modelId": "Model ID",
"modelType": "Model Type",
"trainDate": "Train Date",
"baseModel": "Base Model",
"supportedDetectors": "Supported Detectors",
"dimensions": "Dimensions",
"cameras": "Cameras",
"loading": "Loading model information...",
"error": "Failed to load model information"
"error": "Failed to load model information",
"availableModels": "Available Models",
"loadingAvailableModels": "Loading available models...",
"modelSelect": "Your available models on Frigate+ can be selected here. Note that only models compatible with your current detector configuration can be selected."
},
"toast": {
"success": "Frigate+ settings have been saved. Restart Frigate to apply changes.",
"error": "Failed to save config changes: {{errorMessage}}"
}
}
}

View File

@ -278,7 +278,9 @@ export default function Settings() {
{page == "notifications" && (
<NotificationView setUnsavedChanges={setUnsavedChanges} />
)}
{page == "frigateplus" && <FrigatePlusSettingsView />}
{page == "frigateplus" && (
<FrigatePlusSettingsView setUnsavedChanges={setUnsavedChanges} />
)}
</div>
{confirmationDialogOpen && (
<AlertDialog

View File

@ -397,6 +397,8 @@ export interface FrigateConfig {
trainDate: string;
baseModel: string;
supportedDetectors: string[];
width: number;
height: number;
};
};

View File

@ -1,19 +1,189 @@
import Heading from "@/components/ui/heading";
import { Label } from "@/components/ui/label";
import { useEffect } from "react";
import { useCallback, useContext, useEffect, useState } from "react";
import { Toaster } from "sonner";
import { Separator } from "../../components/ui/separator";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { toast } from "sonner";
import useSWR from "swr";
import axios from "axios";
import { FrigateConfig } from "@/types/frigateConfig";
import { CheckCircle2, XCircle } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { IoIosWarning } from "react-icons/io";
import { Button } from "@/components/ui/button";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
export default function FrigatePlusSettingsView() {
const { data: config } = useSWR<FrigateConfig>("config");
type FrigatePlusModel = {
id: string;
type: string;
supportedDetectors: string[];
trainDate: string;
baseModel: string;
width: number;
height: number;
};
type FrigatePlusSettings = {
model: {
id?: string;
};
};
type FrigateSettingsViewProps = {
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
};
export default function FrigatePlusSettingsView({
setUnsavedChanges,
}: FrigateSettingsViewProps) {
const { t } = useTranslation("views/settings");
const { data: config, mutate: updateConfig } =
useSWR<FrigateConfig>("config");
const [changedValue, setChangedValue] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
const [frigatePlusSettings, setFrigatePlusSettings] =
useState<FrigatePlusSettings>({
model: {
id: undefined,
},
});
const [origPlusSettings, setOrigPlusSettings] = useState<FrigatePlusSettings>(
{
model: {
id: undefined,
},
},
);
const { data: availableModels = {} } = useSWR<
Record<string, FrigatePlusModel>
>("/plus/models", {
fallbackData: {},
fetcher: async (url) => {
const res = await axios.get(url, { withCredentials: true });
return res.data.reduce(
(obj: Record<string, FrigatePlusModel>, model: FrigatePlusModel) => {
obj[model.id] = model;
return obj;
},
{},
);
},
});
useEffect(() => {
if (config) {
if (frigatePlusSettings?.model.id == undefined) {
setFrigatePlusSettings({
model: {
id: config.model.plus?.id,
},
});
}
setOrigPlusSettings({
model: {
id: config.model.plus?.id,
},
});
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
const handleFrigatePlusConfigChange = (
newConfig: Partial<FrigatePlusSettings>,
) => {
setFrigatePlusSettings((prevConfig) => ({
model: {
...prevConfig.model,
...newConfig.model,
},
}));
setUnsavedChanges(true);
setChangedValue(true);
};
const saveToConfig = useCallback(async () => {
setIsLoading(true);
axios
.put(`config/set?model.path=plus://${frigatePlusSettings.model.id}`, {
requires_restart: 0,
})
.then((res) => {
if (res.status === 200) {
toast.success(t("frigatePlus.toast.success"), {
position: "top-center",
});
setChangedValue(false);
addMessage(
"plus_restart",
"Restart required (Frigate+ model changed)",
undefined,
"plus_restart",
);
updateConfig();
} else {
toast.error(
t("frigatePlus.toast.error", { errorMessage: res.statusText }),
{
position: "top-center",
},
);
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(
t("toast.save.error.title", { errorMessage, ns: "common" }),
{
position: "top-center",
},
);
})
.finally(() => {
setIsLoading(false);
});
}, [updateConfig, addMessage, frigatePlusSettings, t]);
const onCancel = useCallback(() => {
setFrigatePlusSettings(origPlusSettings);
setChangedValue(false);
removeMessage("plus_settings", "plus_settings");
}, [origPlusSettings, removeMessage]);
useEffect(() => {
if (changedValue) {
addMessage(
"plus_settings",
`Unsaved Frigate+ settings changes`,
undefined,
"plus_settings",
);
} else {
removeMessage("plus_settings", "plus_settings");
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [changedValue]);
useEffect(() => {
document.title = t("documentTitle.frigatePlus");
@ -28,6 +198,10 @@ export default function FrigatePlusSettingsView() {
);
};
if (!config) {
return <ActivityIndicator />;
}
return (
<>
<div className="flex size-full flex-col md:flex-row">
@ -101,7 +275,13 @@ export default function FrigatePlusSettingsView() {
<Label className="text-muted-foreground">
{t("frigatePlus.modelInfo.modelType")}
</Label>
<p>{config.model.plus.name}</p>
<p>
{config.model.plus.name} (
{config.model.plus.width +
"x" +
config.model.plus.height}
)
</p>
</div>
<div>
<Label className="text-muted-foreground">
@ -113,12 +293,6 @@ export default function FrigatePlusSettingsView() {
).toLocaleString()}
</p>
</div>
<div className="col-span-2 md:col-span-1">
<Label className="text-muted-foreground">
{t("frigatePlus.modelInfo.modelId")}
</Label>
<p>{config.model.plus.id}</p>
</div>
<div>
<Label className="text-muted-foreground">
{t("frigatePlus.modelInfo.baseModel")}
@ -133,6 +307,86 @@ export default function FrigatePlusSettingsView() {
{config.model.plus.supportedDetectors.join(", ")}
</p>
</div>
<div className="col-span-2">
<div className="space-y-2">
<div className="text-md">
{t("frigatePlus.modelInfo.availableModels")}
</div>
<div className="space-y-3 text-sm text-muted-foreground">
<p>
<Trans ns="views/settings">
frigatePlus.modelInfo.modelSelect
</Trans>
</p>
</div>
</div>
<Select
value={frigatePlusSettings.model.id}
onValueChange={(value) =>
handleFrigatePlusConfigChange({
model: { id: value as string },
})
}
>
<SelectTrigger>
{frigatePlusSettings.model.id &&
availableModels?.[frigatePlusSettings.model.id]
? new Date(
availableModels[
frigatePlusSettings.model.id
].trainDate,
).toLocaleString() +
" (" +
availableModels[frigatePlusSettings.model.id]
.width +
"x" +
availableModels[frigatePlusSettings.model.id]
.height +
")"
: t(
"frigatePlus.modelInfo.loadingAvailableModels",
)}
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(availableModels || {}).map(
([id, model]) => (
<SelectItem
key={id}
className="cursor-pointer"
value={id}
disabled={
model.type != config.model.model_type ||
!model.supportedDetectors.includes(
Object.values(config.detectors)[0]
.type,
)
}
>
{new Date(
model.trainDate,
).toLocaleString()}{" "}
({model.baseModel})
<div>
{t(
"frigatePlus.modelInfo.supportedDetectors",
)}
: {model.supportedDetectors.join(", ")}
</div>
<div>
{t("frigatePlus.modelInfo.dimensions")}:{" "}
{model.width + "x" + model.height}
</div>
<div className="text-xs text-muted-foreground">
{id}
</div>
</SelectItem>
),
)}
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
)}
</div>
@ -227,6 +481,34 @@ export default function FrigatePlusSettingsView() {
)}
</div>
</div>
<Separator className="my-2 flex bg-secondary" />
<div className="flex w-full flex-row items-center gap-2 pt-2 md:w-[25%]">
<Button
className="flex flex-1"
aria-label={t("button.reset", { ns: "common" })}
onClick={onCancel}
>
{t("button.reset", { ns: "common" })}
</Button>
<Button
variant="select"
disabled={!changedValue || isLoading}
className="flex flex-1"
aria-label="Save"
onClick={saveToConfig}
>
{isLoading ? (
<div className="flex flex-row items-center gap-2">
<ActivityIndicator />
<span>{t("button.saving", { ns: "common" })}</span>
</div>
) : (
t("button.save", { ns: "common" })
)}
</Button>
</div>
</div>
</div>
</div>