mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-27 14:58:57 +03:00
Miscellaneous fixes (#24402)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* check for a valid frame before using its shape With the camera offline, no preview frame, and `camera-error.jpg` missing, `latest_frame` read `frame.shape` before its `frame is None` check, so it raised `AttributeError` and answered 500 with a traceback instead of the intended "Unable to get valid frame". The check now runs first. * fix the has_clip self-heal for events with no recordings `vod_event` looked for a `(body, 404)` tuple, but `vod_ts` returns a `JSONResponse`, so the check never matched and an old event whose recordings are gone kept offering a clip that can't play. It now checks the response status code. * return 403 for a snapshot or thumbnail on another camera The broad `except Exception` handlers in `event_snapshot` and `event_thumbnail` caught the `HTTPException` from `require_camera_access`, so a restricted user asking for another camera's snapshot got a 404 instead of a 403, and for an object still being tracked the snapshot was rendered before the check ran. Both endpoints now look up the event and check access in their own block, the way the other endpoints do, so a denial propagates. * find DST transitions to the second `get_dst_transitions` probed the offset once every 24 hours from the start time and reported a change at the first probe after it, up to a day late, so events, review items and recordings near a transition were grouped into days with the old offset. A transition after the last daily probe wasn't found at all. The end of the range is probed too now, and a probe that sees the offset change bisects the interval to the second of the transition. * don't run page shortcuts for keys a dialog already handled Radix dismisses a dialog on Escape from a capture-phase keydown listener and calls `preventDefault()` without stopping propagation, so `useKeyboardListener` still ran the page's Escape shortcut: cancelling the delete dialog in the face library or a classification model also cleared the whole selection. Keys another shortcut hook handled still get through, since their listener order changes with every render. * fix train image filtering for a class with a dash The backend writes a class with a `-` as `_` in train file names, since it splits those names on `-`, while a dataset folder keeps the dash. Filtering the Train grid by `half-open` compared it with `half_open` and hid every attempt. Both sides are normalized the same way now. * don't edit a chat message while a reply streams The edit button stayed active while a reply streamed. `submitConversation` returns early while loading, but the message bubble still closed its editor, so the edit was silently lost. The edit button is hidden while a reply streams, and an editor that's already open keeps its draft with send disabled until the reply ends. * fix restart failing under non-root restart_frigate() called psutil.Process(1).terminate() to signal s6-svscan, but s6-svscan runs as root while frigate runs as uid 1000, so the call raised AccessDenied. That exception escaped every caller: the UI restart button dropped its websocket client, MQTT restart and Save & Restart just logged and did nothing, and the watchdog crashed its own monitoring thread on a dead detector. This catches AccessDenied and falls through to the existing SIGINT branch, which exits the process for s6 to restart it. * show runtime overrides in the settings form The settings form read a camera section's saved config value, but its dependent warnings (audio transcription requiring audio detection, snapshots requiring detect, etc.) read the live config instead. A runtime toggle from the live view, MQTT, or an active profile can turn a section off without touching yaml, and that override persists across restarts, so the Enable switch showed on while the warning said the feature wasn't enabled. This adds an "Overridden (Live)" badge to any field whose live value differs from what's saved, and swaps the affected warnings to runtime-specific wording when a runtime override is the actual cause instead of the config. * fix mobile overflowing icons in system due to new health pane * fix genai settings keeping a stale model and dropping roles after save Switching a GenAI entry's provider left the previous provider's model selected, so saving wrote a model the new provider doesn't serve. llama.cpp can't find that model in `/v1/models`, so the backend reported every capability as false for the entry, and once the save refetched `genai/models` the roles widget stripped `transcribe` from the form on its own. The section showed unsaved changes right after saving, and saving again would have dropped the role. Switching provider now clears the model, and the roles widget only strips a role for a model or provider picked in the form, since the entry-level capability flags only describe the saved model. A selected role stays visible when the provider can't confirm it, so it can still be switched off. The llama.cpp model list also no longer repeats a model whose alias matches its id, which is what `--alias` produces. * close onvif sessions on shutdown `OnvifController.close()` only stopped its event loop, so the aiohttp sessions each `ONVIFCamera` holds and the `_poll_config_updates` task were left to be garbage collected during interpreter shutdown, when their warnings can no longer be logged. Every restart ended with a run of `Unclosed client session` and `Task was destroyed but it is pending!` logging errors, which only became visible once restart started exiting the process itself under non-root. `close()` now closes each camera's client and cancels the tasks on the loop before stopping it. * fixes * fixes
This commit is contained in:
@@ -133,7 +133,7 @@ export function MessageBubble({
|
||||
variant="select"
|
||||
size="icon"
|
||||
className="size-9 rounded-full"
|
||||
disabled={!draftContent.trim()}
|
||||
disabled={!draftContent.trim() || onEditSubmit == null}
|
||||
onClick={handleEditSubmit}
|
||||
aria-label={t("send")}
|
||||
>
|
||||
|
||||
@@ -9,6 +9,11 @@ const audioTranscription: SectionConfigOverrides = {
|
||||
health: (ctx) =>
|
||||
ctx.fullCameraConfig?.audio_transcription?.enabled === true,
|
||||
messageKey: "configMessages.audioTranscription.audioDetectionDisabled",
|
||||
runtimeOverride: {
|
||||
section: "audio",
|
||||
messageKey:
|
||||
"configMessages.audioTranscription.audioDetectionRuntimeDisabled",
|
||||
},
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||
|
||||
@@ -7,6 +7,11 @@ const birdseye: SectionConfigOverrides = {
|
||||
{
|
||||
key: "object-tracking-detect-disabled",
|
||||
messageKey: "configMessages.birdseye.objectTrackingDetectDisabled",
|
||||
runtimeOverride: {
|
||||
section: "detect",
|
||||
messageKey:
|
||||
"configMessages.birdseye.objectTrackingDetectRuntimeDisabled",
|
||||
},
|
||||
severity: "info",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
||||
|
||||
@@ -63,6 +63,10 @@ const objects: SectionConfigOverrides = {
|
||||
{
|
||||
key: "detect-disabled",
|
||||
messageKey: "configMessages.detect.disabled",
|
||||
runtimeOverride: {
|
||||
section: "detect",
|
||||
messageKey: "configMessages.detect.runtimeDisabled",
|
||||
},
|
||||
severity: "info",
|
||||
condition: (ctx) =>
|
||||
ctx.level === "camera" &&
|
||||
|
||||
@@ -7,6 +7,10 @@ const review: SectionConfigOverrides = {
|
||||
{
|
||||
key: "record-disabled",
|
||||
messageKey: "configMessages.review.recordDisabled",
|
||||
runtimeOverride: {
|
||||
section: "record",
|
||||
messageKey: "configMessages.review.recordRuntimeDisabled",
|
||||
},
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||
@@ -18,6 +22,10 @@ const review: SectionConfigOverrides = {
|
||||
{
|
||||
key: "detect-disabled",
|
||||
messageKey: "configMessages.review.detectDisabled",
|
||||
runtimeOverride: {
|
||||
section: "detect",
|
||||
messageKey: "configMessages.review.detectRuntimeDisabled",
|
||||
},
|
||||
severity: "info",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||
@@ -64,6 +72,11 @@ const review: SectionConfigOverrides = {
|
||||
field: "genai.image_source",
|
||||
messageKey:
|
||||
"configMessages.review.genaiImageSourceRecordingsRecordDisabled",
|
||||
runtimeOverride: {
|
||||
section: "record",
|
||||
messageKey:
|
||||
"configMessages.review.genaiImageSourceRecordingsRecordRuntimeDisabled",
|
||||
},
|
||||
severity: "warning",
|
||||
position: "after",
|
||||
condition: (ctx) => {
|
||||
|
||||
@@ -7,6 +7,10 @@ const snapshots: SectionConfigOverrides = {
|
||||
{
|
||||
key: "detect-disabled",
|
||||
messageKey: "configMessages.snapshots.detectDisabled",
|
||||
runtimeOverride: {
|
||||
section: "detect",
|
||||
messageKey: "configMessages.snapshots.detectRuntimeDisabled",
|
||||
},
|
||||
severity: "info",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
||||
|
||||
@@ -28,6 +28,17 @@ export type ConditionalMessage = {
|
||||
values?: Record<string, unknown>;
|
||||
/** Optional documentation path (e.g. "/configuration/object_detectors#model"). */
|
||||
docLink?: string;
|
||||
/**
|
||||
* Alternate wording for when the section this message depends on is enabled
|
||||
* in the config but turned off on the running camera. Without it the message
|
||||
* reads as a contradiction, since the form shows the saved config value.
|
||||
*/
|
||||
runtimeOverride?: {
|
||||
/** Camera section whose runtime state explains the message, e.g. "audio". */
|
||||
section: string;
|
||||
/** Translation key used in place of `messageKey`. */
|
||||
messageKey: string;
|
||||
};
|
||||
/**
|
||||
* Whether the Health tab evaluates this message against the saved config.
|
||||
* Absent or false: form only. true: shown whenever condition() holds. A
|
||||
|
||||
@@ -1022,6 +1022,7 @@ export function ConfigSection({
|
||||
formContext={{
|
||||
level: effectiveLevel,
|
||||
cameraName,
|
||||
sectionPath,
|
||||
globalValue,
|
||||
cameraValue,
|
||||
hasChanges,
|
||||
|
||||
@@ -19,6 +19,8 @@ import { LuExternalLink } from "react-icons/lu";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { requiresRestartForFieldPath } from "@/utils/configUtil";
|
||||
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
|
||||
import RuntimeOverrideIndicator from "@/components/indicators/RuntimeOverrideIndicator";
|
||||
import { getRuntimeOverride } from "@/utils/runtimeOverrides";
|
||||
import {
|
||||
buildTranslationPath,
|
||||
resolveConfigTranslation,
|
||||
@@ -211,6 +213,18 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
defaultRequiresRestart,
|
||||
);
|
||||
|
||||
// The form shows saved config values, so flag any field the running camera
|
||||
// currently disagrees with. Profile editing shows that profile's overrides
|
||||
// instead, where the comparison does not apply.
|
||||
const runtimeOverride =
|
||||
isCameraLevel && !formContext?.isProfile
|
||||
? getRuntimeOverride(
|
||||
formContext?.fullCameraConfig,
|
||||
formContext?.sectionPath,
|
||||
pathSegments.join("."),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Use schema title/description as primary source (from JSON Schema)
|
||||
const schemaTitle = schema.title;
|
||||
const schemaDescription = schema.description;
|
||||
@@ -502,6 +516,12 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
{finalLabel}
|
||||
{required && <span className="ml-1 text-destructive">*</span>}
|
||||
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
|
||||
{runtimeOverride && (
|
||||
<RuntimeOverrideIndicator
|
||||
runtimeValue={runtimeOverride.runtime}
|
||||
className="ml-2"
|
||||
/>
|
||||
)}
|
||||
</Label>
|
||||
);
|
||||
};
|
||||
@@ -519,6 +539,12 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
{finalLabel}
|
||||
{required && <span className="ml-1 text-destructive">*</span>}
|
||||
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
|
||||
{runtimeOverride && (
|
||||
<RuntimeOverrideIndicator
|
||||
runtimeValue={runtimeOverride.runtime}
|
||||
className="ml-2"
|
||||
/>
|
||||
)}
|
||||
</Label>
|
||||
);
|
||||
};
|
||||
@@ -540,6 +566,12 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
{finalLabel}
|
||||
{required && <span className="ml-1 text-destructive">*</span>}
|
||||
{fieldRequiresRestart && <RestartRequiredIndicator className="ml-2" />}
|
||||
{runtimeOverride && (
|
||||
<RuntimeOverrideIndicator
|
||||
runtimeValue={runtimeOverride.runtime}
|
||||
className="ml-2"
|
||||
/>
|
||||
)}
|
||||
</Label>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -59,20 +59,32 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
|
||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||
|
||||
// Build a fingerprint from the saved config's provider + base_url so the
|
||||
// SWR key changes (and models are refetched) whenever those fields are saved.
|
||||
const configFingerprint = useMemo(() => {
|
||||
if (!providerKey) return "";
|
||||
const savedEntry = useMemo<Record<string, unknown> | null>(() => {
|
||||
if (!providerKey) return null;
|
||||
const genai = (
|
||||
formContext?.fullConfig as Record<string, unknown> | undefined
|
||||
)?.genai;
|
||||
if (!genai || typeof genai !== "object" || Array.isArray(genai)) return "";
|
||||
if (!genai || typeof genai !== "object" || Array.isArray(genai)) {
|
||||
return null;
|
||||
}
|
||||
const entry = (genai as Record<string, unknown>)[providerKey];
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return "";
|
||||
const e = entry as Record<string, unknown>;
|
||||
return `${e.provider ?? ""}|${e.base_url ?? ""}`;
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return null;
|
||||
}
|
||||
return entry as Record<string, unknown>;
|
||||
}, [providerKey, formContext?.fullConfig]);
|
||||
|
||||
const savedProvider =
|
||||
typeof savedEntry?.provider === "string" ? savedEntry.provider : null;
|
||||
const savedModel =
|
||||
typeof savedEntry?.model === "string" ? savedEntry.model : "";
|
||||
|
||||
// Build a fingerprint from the saved config's provider + base_url so the
|
||||
// SWR key changes (and models are refetched) whenever those fields are saved.
|
||||
const configFingerprint = savedEntry
|
||||
? `${savedEntry.provider ?? ""}|${savedEntry.base_url ?? ""}`
|
||||
: "";
|
||||
|
||||
const { data: allModels, mutate: mutateModels } = useSWR<GenAIModelsResponse>(
|
||||
"genai/models",
|
||||
{
|
||||
@@ -148,6 +160,18 @@ export function GenAIModelWidget(props: WidgetProps) {
|
||||
typeof formEntry?.provider === "string" ? formEntry.provider : null;
|
||||
const canProbe = Boolean(formProvider) && !probing;
|
||||
|
||||
// A model name belongs to its provider, so switching provider clears it,
|
||||
// unless the form holds the saved provider and model together
|
||||
const prevFormProvider = useRef(formProvider);
|
||||
useEffect(() => {
|
||||
const previous = prevFormProvider.current;
|
||||
prevFormProvider.current = formProvider;
|
||||
|
||||
if (previous === formProvider) return;
|
||||
if (formProvider === savedProvider && value === savedModel) return;
|
||||
if (typeof value === "string" && value) onChange("");
|
||||
}, [formProvider, savedProvider, savedModel, value, onChange]);
|
||||
|
||||
const probe = async () => {
|
||||
if (!formEntry || !formProvider) return;
|
||||
if (probeSuccessTimerRef.current) {
|
||||
|
||||
@@ -25,6 +25,22 @@ function normalizeValue(value: unknown): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
function getString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value ? value : undefined;
|
||||
}
|
||||
|
||||
function getEntry(
|
||||
entries: unknown,
|
||||
providerKey: string | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!providerKey || !entries || typeof entries !== "object") return undefined;
|
||||
const entry = (entries as Record<string, unknown>)[providerKey];
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return undefined;
|
||||
}
|
||||
return entry as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getProviderKey(widgetId: string): string | undefined {
|
||||
const prefix = "root_";
|
||||
const suffix = "_roles";
|
||||
@@ -51,21 +67,26 @@ export function GenAIRolesWidget(props: WidgetProps) {
|
||||
// The model currently chosen in the form, which is what the roles have to
|
||||
// reflect. Reading the saved config instead would keep reporting the previous
|
||||
// model's capabilities until a save and a refetch.
|
||||
const selectedModel = useMemo(() => {
|
||||
if (!providerKey) return undefined;
|
||||
const formData = formContext?.formData as
|
||||
Record<string, unknown> | undefined;
|
||||
const entry = formData?.[providerKey];
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
return undefined;
|
||||
}
|
||||
const model = (entry as Record<string, unknown>).model;
|
||||
return typeof model === "string" && model ? model : undefined;
|
||||
}, [formContext?.formData, providerKey]);
|
||||
const formEntry = useMemo(
|
||||
() => getEntry(formContext?.formData, providerKey),
|
||||
[formContext?.formData, providerKey],
|
||||
);
|
||||
const savedEntry = useMemo(
|
||||
() => getEntry(formContext?.fullConfig?.genai, providerKey),
|
||||
[formContext?.fullConfig?.genai, providerKey],
|
||||
);
|
||||
|
||||
const selectedModel = getString(formEntry?.model);
|
||||
|
||||
// The entry-level capability flags describe the saved provider and model
|
||||
// only, so they apply while the form still matches the saved entry.
|
||||
const matchesSaved =
|
||||
savedEntry !== undefined &&
|
||||
getString(formEntry?.provider) === getString(savedEntry.provider) &&
|
||||
selectedModel === getString(savedEntry.model);
|
||||
|
||||
// Capabilities the provider reported for that specific model. Absent when the
|
||||
// provider cannot describe a model it has not loaded, in which case the
|
||||
// entry-level flags (which describe the saved model) are the best available.
|
||||
// provider cannot describe a model it has not loaded.
|
||||
const modelCapabilities: GenAIModelCapabilities | undefined = useMemo(() => {
|
||||
if (!providerKey || !selectedModel) return undefined;
|
||||
return genaiInfo?.[providerKey]?.model_capabilities?.[selectedModel];
|
||||
@@ -76,7 +97,7 @@ export function GenAIRolesWidget(props: WidgetProps) {
|
||||
): boolean => {
|
||||
const perModel = modelCapabilities?.[key];
|
||||
if (perModel !== undefined) return perModel;
|
||||
if (!providerKey) return true;
|
||||
if (!providerKey || !matchesSaved) return true;
|
||||
const info = genaiInfo?.[providerKey];
|
||||
// assume supported when nothing is known, so a role is never hidden on
|
||||
// missing information alone
|
||||
@@ -95,9 +116,13 @@ export function GenAIRolesWidget(props: WidgetProps) {
|
||||
return unsupported;
|
||||
}, [embeddingsSupported, transcriptionSupported]);
|
||||
|
||||
// a selected role stays visible so it can still be switched off
|
||||
const availableRoles = useMemo(
|
||||
() => GENAI_ROLES.filter((role) => !unsupportedRoles.has(role)),
|
||||
[unsupportedRoles],
|
||||
() =>
|
||||
GENAI_ROLES.filter(
|
||||
(role) => !unsupportedRoles.has(role) || selectedRoles.includes(role),
|
||||
),
|
||||
[unsupportedRoles, selectedRoles],
|
||||
);
|
||||
|
||||
const occupiedRoles = useMemo(() => {
|
||||
@@ -123,13 +148,16 @@ export function GenAIRolesWidget(props: WidgetProps) {
|
||||
return occupied;
|
||||
}, [formContext?.formData, providerKey]);
|
||||
|
||||
// strip every unsupported role in a single onChange; two effects each
|
||||
// rewriting the same value would race and lose one of the edits
|
||||
// Strip every unsupported role in a single onChange; two effects each
|
||||
// rewriting the same value would race and lose one of the edits. Only a
|
||||
// model or provider picked in the form can rule a role out, so capability
|
||||
// data arriving for the saved entry never edits the form on its own.
|
||||
useEffect(() => {
|
||||
if (matchesSaved) return;
|
||||
if (!selectedRoles.some((role) => unsupportedRoles.has(role))) return;
|
||||
|
||||
onChange(selectedRoles.filter((role) => !unsupportedRoles.has(role)));
|
||||
}, [unsupportedRoles, selectedRoles, onChange]);
|
||||
}, [matchesSaved, unsupportedRoles, selectedRoles, onChange]);
|
||||
|
||||
const toggleRole = (role: string, enabled: boolean) => {
|
||||
if (enabled) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Tooltip, TooltipContent } from "../ui/tooltip";
|
||||
import { TooltipTrigger } from "@radix-ui/react-tooltip";
|
||||
|
||||
type RuntimeOverrideIndicatorProps = {
|
||||
/** The value the camera is running with, which differs from the saved config. */
|
||||
runtimeValue: unknown;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Field-level companion to the section override badges. Marks a field the
|
||||
* running camera has drifted from, so the saved value on screen never reads as
|
||||
* the live one.
|
||||
*/
|
||||
export default function RuntimeOverrideIndicator({
|
||||
runtimeValue,
|
||||
className,
|
||||
}: RuntimeOverrideIndicatorProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
|
||||
const displayValue =
|
||||
typeof runtimeValue === "boolean"
|
||||
? t(runtimeValue ? "button.on" : "button.off", { ns: "common" })
|
||||
: Array.isArray(runtimeValue)
|
||||
? runtimeValue.join(", ")
|
||||
: String(runtimeValue);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"cursor-default border-2 border-selected text-center align-middle text-xs font-normal text-primary-variant",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{t("button.overriddenLive", { ns: "views/settings" })}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-72">
|
||||
<p>{t("button.overriddenLiveTooltip", { ns: "views/settings" })}</p>
|
||||
<p className="mt-1">
|
||||
{t("button.overriddenLiveValue", {
|
||||
ns: "views/settings",
|
||||
value: displayValue,
|
||||
})}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
FieldConditionalMessage,
|
||||
MessageConditionContext,
|
||||
} from "@/components/config-form/section-configs/types";
|
||||
import { resolveMessageKey } from "@/utils/runtimeOverrides";
|
||||
|
||||
export function useConfigMessages(
|
||||
messages: ConditionalMessage[] | undefined,
|
||||
@@ -15,12 +16,16 @@ export function useConfigMessages(
|
||||
} {
|
||||
const activeMessages = useMemo(() => {
|
||||
if (!messages || !context) return [];
|
||||
return messages.filter((msg) => msg.condition(context));
|
||||
return messages
|
||||
.filter((msg) => msg.condition(context))
|
||||
.map((msg) => ({ ...msg, messageKey: resolveMessageKey(msg, context) }));
|
||||
}, [messages, context]);
|
||||
|
||||
const activeFieldMessages = useMemo(() => {
|
||||
if (!fieldMessages || !context) return [];
|
||||
return fieldMessages.filter((msg) => msg.condition(context));
|
||||
return fieldMessages
|
||||
.filter((msg) => msg.condition(context))
|
||||
.map((msg) => ({ ...msg, messageKey: resolveMessageKey(msg, context) }));
|
||||
}, [fieldMessages, context]);
|
||||
|
||||
return { activeMessages, activeFieldMessages };
|
||||
|
||||
@@ -7,6 +7,16 @@ export type KeyModifiers = {
|
||||
shift: boolean;
|
||||
};
|
||||
|
||||
const handledByShortcut = new WeakSet<Event>();
|
||||
|
||||
// Radix dismisses a dialog or menu on Escape from a capture-phase listener and
|
||||
// calls preventDefault() without stopping propagation, so a page shortcut would
|
||||
// otherwise act on the same press. Keys another shortcut hook handled still get
|
||||
// through, since their listener order changes with every render.
|
||||
function handledElsewhere(event: KeyboardEvent): boolean {
|
||||
return event.defaultPrevented && !handledByShortcut.has(event);
|
||||
}
|
||||
|
||||
export default function useKeyboardListener(
|
||||
keys: string[],
|
||||
listener?: (key: string | null, modifiers: KeyModifiers) => boolean,
|
||||
@@ -27,6 +37,10 @@ export default function useKeyboardListener(
|
||||
return;
|
||||
}
|
||||
|
||||
if (handledElsewhere(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modifiers = {
|
||||
down: true,
|
||||
repeat: e.repeat,
|
||||
@@ -63,7 +77,10 @@ export default function useKeyboardListener(
|
||||
}
|
||||
} else if (keys.includes(e.key) && listener) {
|
||||
const preventDefault = listener(e.key, modifiers);
|
||||
if (preventDefault) e.preventDefault();
|
||||
if (preventDefault) {
|
||||
e.preventDefault();
|
||||
handledByShortcut.add(e);
|
||||
}
|
||||
} else if (
|
||||
listener &&
|
||||
(e.key === "Shift" || e.key === "Control" || e.key === "Meta")
|
||||
|
||||
@@ -362,7 +362,7 @@ export default function ChatPage() {
|
||||
role="user"
|
||||
content={msg.content}
|
||||
messageIndex={i}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
onEditSubmit={isLoading ? undefined : handleEditSubmit}
|
||||
isComplete
|
||||
showStats={showStats}
|
||||
/>
|
||||
|
||||
+39
-29
@@ -24,6 +24,8 @@ import HealthMetrics from "@/views/system/HealthMetrics";
|
||||
import NoticeFilterButton from "@/components/health/NoticeFilterButton";
|
||||
import { DEFAULT_NOTICE_FILTER, NoticeFilter } from "@/types/health";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const allMetrics = [
|
||||
"health",
|
||||
@@ -96,35 +98,43 @@ function System() {
|
||||
{isMobile && (
|
||||
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
|
||||
)}
|
||||
<ToggleGroup
|
||||
className="*:rounded-md *:px-3 *:py-4"
|
||||
type="single"
|
||||
size="sm"
|
||||
value={pageToggle}
|
||||
onValueChange={(value: SystemMetric) => {
|
||||
if (value) {
|
||||
setPageToggle(value);
|
||||
}
|
||||
}} // don't allow the severity to be unselected
|
||||
>
|
||||
{Object.values(metrics).map((item) => (
|
||||
<ToggleGroupItem
|
||||
key={item}
|
||||
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||
value={item}
|
||||
aria-label={`Select ${item}`}
|
||||
<ScrollArea className={cn("whitespace-nowrap", isMobile && "w-[45%]")}>
|
||||
<div className="flex flex-row">
|
||||
<ToggleGroup
|
||||
className="*:rounded-md *:px-3 *:py-4"
|
||||
type="single"
|
||||
size="sm"
|
||||
value={pageToggle}
|
||||
onValueChange={(value: SystemMetric) => {
|
||||
if (value) {
|
||||
setPageToggle(value);
|
||||
}
|
||||
}} // don't allow the severity to be unselected
|
||||
>
|
||||
{item == "health" && <LuHeartPulse className="size-4" />}
|
||||
{item == "general" && <LuActivity className="size-4" />}
|
||||
{item == "enrichments" && <LuSearchCode className="size-4" />}
|
||||
{item == "storage" && <LuHardDrive className="size-4" />}
|
||||
{item == "cameras" && <FaVideo className="size-4" />}
|
||||
{isDesktop && (
|
||||
<div className="smart-capitalize">{t(item + ".title")}</div>
|
||||
)}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
{Object.values(metrics).map((item) => (
|
||||
<ToggleGroupItem
|
||||
key={item}
|
||||
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
|
||||
value={item}
|
||||
aria-label={t("selectItem", {
|
||||
ns: "common",
|
||||
item: t(item + ".title"),
|
||||
})}
|
||||
>
|
||||
{item == "health" && <LuHeartPulse className="size-4" />}
|
||||
{item == "general" && <LuActivity className="size-4" />}
|
||||
{item == "enrichments" && <LuSearchCode className="size-4" />}
|
||||
{item == "storage" && <LuHardDrive className="size-4" />}
|
||||
{item == "cameras" && <FaVideo className="size-4" />}
|
||||
{isDesktop && (
|
||||
<div className="smart-capitalize">{t(item + ".title")}</div>
|
||||
)}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
<ScrollBar orientation="horizontal" className="h-0" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="flex h-full items-center">
|
||||
{pageToggle == "health" && (
|
||||
@@ -135,7 +145,7 @@ function System() {
|
||||
)}
|
||||
{lastUpdated && pageToggle != "health" && (
|
||||
<div className="h-full content-center text-sm text-muted-foreground">
|
||||
{t("lastRefreshed")}
|
||||
{isDesktop && t("lastRefreshed")}
|
||||
<TimeAgo time={lastUpdated * 1000} dense />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -30,6 +30,8 @@ export type HiddenFieldEntry = string | ((ctx: HiddenFieldContext) => string[]);
|
||||
export type ConfigFormContext = {
|
||||
level?: "global" | "camera";
|
||||
cameraName?: string;
|
||||
/** Config section being edited, e.g. "audio" or "review". */
|
||||
sectionPath?: string;
|
||||
globalValue?: JsonValue;
|
||||
cameraValue?: JsonValue;
|
||||
overrides?: JsonValue;
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { HealthProblem } from "@/types/health";
|
||||
import { getSectionConfig } from "@/utils/configUtil";
|
||||
import { activeCameras } from "@/utils/health";
|
||||
import { resolveMessageKey } from "@/utils/runtimeOverrides";
|
||||
|
||||
function healthMessages(
|
||||
section: string,
|
||||
@@ -47,7 +48,7 @@ function toProblem(
|
||||
severity: message.severity,
|
||||
scope,
|
||||
scopeIsCamera,
|
||||
text: t(message.messageKey, {
|
||||
text: t(resolveMessageKey(message, ctx), {
|
||||
ns: "views/settings",
|
||||
...(message.values ?? {}),
|
||||
}),
|
||||
|
||||
@@ -13,6 +13,7 @@ import set from "lodash/set";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
import { REDACTED_CREDENTIAL_SENTINEL } from "@/lib/const";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { applyConfiguredToggles } from "@/utils/runtimeOverrides";
|
||||
import { normalizeConfigValue } from "@/hooks/use-config-override";
|
||||
import {
|
||||
modifySchemaForSection,
|
||||
@@ -103,11 +104,13 @@ export const globalCameraDefaultSections = new Set([
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the base (pre-profile) value for a camera section.
|
||||
* Get the saved-config value for a camera section, which is what the settings
|
||||
* form edits.
|
||||
*
|
||||
* When a profile is active the API populates `base_config` with original
|
||||
* section values. This helper returns that value when available, falling
|
||||
* back to the top-level (effective) value otherwise.
|
||||
* Two things move the top-level (effective) value away from yaml. A profile
|
||||
* merges its overrides into it, and the API then populates `base_config` with
|
||||
* the originals. Runtime toggles from the live view, MQTT, or Home Assistant
|
||||
* change it in place, and `applyConfiguredToggles` puts those fields back.
|
||||
*/
|
||||
export function getBaseCameraSectionValue(
|
||||
config: FrigateConfig | undefined,
|
||||
@@ -118,7 +121,11 @@ export function getBaseCameraSectionValue(
|
||||
const cam = config.cameras?.[cameraName];
|
||||
if (!cam) return undefined;
|
||||
const base = cam.base_config?.[sectionPath];
|
||||
return base !== undefined ? base : get(cam, sectionPath);
|
||||
return applyConfiguredToggles(
|
||||
cam,
|
||||
sectionPath,
|
||||
base !== undefined ? base : get(cam, sectionPath),
|
||||
);
|
||||
}
|
||||
|
||||
// mergeWith customizer that replaces arrays wholesale instead of merging them
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import get from "lodash/get";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import set from "lodash/set";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import type { CameraConfig } from "@/types/frigateConfig";
|
||||
import type {
|
||||
ConditionalMessage,
|
||||
MessageConditionContext,
|
||||
} from "@/components/config-form/section-configs/types";
|
||||
|
||||
/**
|
||||
* Camera fields the dispatcher can change at runtime from the live view, MQTT,
|
||||
* or Home Assistant. Mirrors the camera command handlers in
|
||||
* frigate/comms/dispatcher.py. Runtime changes persist across restarts and win
|
||||
* over yaml until the field is saved again, so the settings form (which edits
|
||||
* yaml) has to show the config value and flag the divergence.
|
||||
*
|
||||
* Paths are relative to the section.
|
||||
*/
|
||||
export const RUNTIME_TOGGLEABLE_FIELDS: Record<string, string[]> = {
|
||||
audio: ["enabled"],
|
||||
birdseye: ["enabled", "modes"],
|
||||
detect: ["enabled"],
|
||||
motion: ["enabled", "improve_contrast", "threshold", "contour_area"],
|
||||
notifications: ["enabled"],
|
||||
objects: ["genai.enabled"],
|
||||
onvif: ["autotracking.enabled"],
|
||||
record: ["enabled"],
|
||||
review: ["alerts.enabled", "detections.enabled", "genai.enabled"],
|
||||
snapshots: ["enabled"],
|
||||
};
|
||||
|
||||
/**
|
||||
* The value a field holds in yaml, or undefined when the backend exposes no
|
||||
* config-side copy of it.
|
||||
*
|
||||
* Two sources carry it. `base_config` is the pre-profile snapshot, sent only
|
||||
* while a profile is active. The `<field>_in_config` siblings are always sent,
|
||||
* but only exist for the toggles the backend tracks that way (`detect`,
|
||||
* `snapshots`, and `birdseye` have none).
|
||||
*/
|
||||
export function getConfiguredFieldValue(
|
||||
cameraConfig: CameraConfig | undefined,
|
||||
sectionPath: string,
|
||||
fieldPath: string,
|
||||
): unknown {
|
||||
if (!cameraConfig) return undefined;
|
||||
|
||||
const inConfig = get(cameraConfig, `${sectionPath}.${fieldPath}_in_config`);
|
||||
if (inConfig !== undefined && inConfig !== null) {
|
||||
return inConfig;
|
||||
}
|
||||
|
||||
const base = cameraConfig.base_config?.[sectionPath];
|
||||
return base !== undefined ? get(base, fieldPath) : undefined;
|
||||
}
|
||||
|
||||
export type RuntimeOverride = {
|
||||
/** The value saved in yaml. */
|
||||
configured: unknown;
|
||||
/** The value the camera is running with right now. */
|
||||
runtime: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes a field whose live value has drifted from the saved config, or
|
||||
* undefined when the two agree or the config value can't be read.
|
||||
*/
|
||||
export function getRuntimeOverride(
|
||||
cameraConfig: CameraConfig | undefined,
|
||||
sectionPath: string | undefined,
|
||||
fieldPath: string,
|
||||
): RuntimeOverride | undefined {
|
||||
if (!cameraConfig || !sectionPath) return undefined;
|
||||
if (!RUNTIME_TOGGLEABLE_FIELDS[sectionPath]?.includes(fieldPath)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const configured = getConfiguredFieldValue(
|
||||
cameraConfig,
|
||||
sectionPath,
|
||||
fieldPath,
|
||||
);
|
||||
if (configured === undefined) return undefined;
|
||||
|
||||
const runtime = get(cameraConfig, `${sectionPath}.${fieldPath}`);
|
||||
if (runtime === undefined || isEqual(configured, runtime)) return undefined;
|
||||
|
||||
return { configured, runtime };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a section is enabled in yaml but turned off on the running camera.
|
||||
* This is the state that makes a dependent warning read as a contradiction,
|
||||
* since the form shows the section switch on.
|
||||
*/
|
||||
export function isSectionRuntimeDisabled(
|
||||
cameraConfig: CameraConfig | undefined,
|
||||
sectionPath: string,
|
||||
): boolean {
|
||||
const override = getRuntimeOverride(cameraConfig, sectionPath, "enabled");
|
||||
return override?.configured === true && override.runtime === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Overlays the saved config values onto a section so the form edits yaml
|
||||
* rather than live state. Returns the section unchanged when nothing drifted.
|
||||
*/
|
||||
export function applyConfiguredToggles(
|
||||
cameraConfig: CameraConfig | undefined,
|
||||
sectionPath: string,
|
||||
sectionValue: unknown,
|
||||
): unknown {
|
||||
const fields = RUNTIME_TOGGLEABLE_FIELDS[sectionPath];
|
||||
if (!fields || !sectionValue || typeof sectionValue !== "object") {
|
||||
return sectionValue;
|
||||
}
|
||||
|
||||
let result = sectionValue;
|
||||
for (const field of fields) {
|
||||
const override = getRuntimeOverride(cameraConfig, sectionPath, field);
|
||||
if (!override) continue;
|
||||
|
||||
if (result === sectionValue) {
|
||||
result = cloneDeep(sectionValue);
|
||||
}
|
||||
set(result as object, field, override.configured);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the wording for a message. A message that depends on another section
|
||||
* being on switches to its runtime wording when the config has that section
|
||||
* enabled but the running camera has it off.
|
||||
*/
|
||||
export function resolveMessageKey(
|
||||
message: Pick<ConditionalMessage, "messageKey" | "runtimeOverride">,
|
||||
ctx: MessageConditionContext | undefined,
|
||||
): string {
|
||||
const runtime = message.runtimeOverride;
|
||||
if (!runtime || !ctx || ctx.level !== "camera") return message.messageKey;
|
||||
|
||||
return isSectionRuntimeDisabled(ctx.fullCameraConfig, runtime.section)
|
||||
? runtime.messageKey
|
||||
: message.messageKey;
|
||||
}
|
||||
@@ -899,6 +899,14 @@ type TrainGridProps = {
|
||||
onRefresh: () => void;
|
||||
onDelete: (ids: string[]) => void;
|
||||
};
|
||||
|
||||
// the backend writes a class with a "-" as "_" in train file names, since it
|
||||
// splits those names on "-", so a dataset class may still carry the dash
|
||||
function matchesTrainClass(classes: string[], name: string): boolean {
|
||||
const target = name.replaceAll("-", "_");
|
||||
return classes.some((item) => item.replaceAll("-", "_") === target);
|
||||
}
|
||||
|
||||
function TrainGrid({
|
||||
model,
|
||||
contentRef,
|
||||
@@ -936,7 +944,10 @@ function TrainGrid({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (trainFilter.classes && !trainFilter.classes.includes(data.name)) {
|
||||
if (
|
||||
trainFilter.classes &&
|
||||
!matchesTrainClass(trainFilter.classes, data.name)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user