Compare commits

...

3 Commits

Author SHA1 Message Date
Chris
36856de47c
Merge 793906bb68 into 8e8346099e 2025-11-20 19:47:52 -08:00
Nicolas Mowen
8e8346099e
Miscellaneous Fixes (#20973)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
2025-11-20 17:50:17 -06:00
Chris Suich
793906bb68 feat: use persisted state for selected camera on settings page
Leverage the usePersistence() hook to store the selected camera so that
navigating away from the settings page and back will remember the
selected camera.
2025-11-15 11:26:59 -05:00
18 changed files with 663 additions and 371 deletions

View File

@ -320,6 +320,12 @@ http {
add_header Cache-Control "public";
}
location /fonts/ {
access_log off;
expires 1y;
add_header Cache-Control "public";
}
location /locales/ {
access_log off;
add_header Cache-Control "public";

View File

@ -70,7 +70,7 @@ You should have at least 8 GB of RAM available (or VRAM if running on GPU) to ru
genai:
provider: ollama
base_url: http://localhost:11434
model: llava:7b
model: qwen3-vl:4b
```
## Google Gemini

View File

@ -35,19 +35,18 @@ Each model is available in multiple parameter sizes (3b, 4b, 8b, etc.). Larger s
:::tip
If you are trying to use a single model for Frigate and HomeAssistant, it will need to support vision and tools calling. https://github.com/skye-harris/ollama-modelfiles contains optimized model configs for this task.
If you are trying to use a single model for Frigate and HomeAssistant, it will need to support vision and tools calling. qwen3-VL supports vision and tools simultaneously in Ollama.
:::
The following models are recommended:
| Model | Notes |
| ----------------- | ----------------------------------------------------------- |
| `qwen3-vl` | Strong visual and situational understanding |
| ----------------- | -------------------------------------------------------------------- |
| `qwen3-vl` | Strong visual and situational understanding, higher vram requirement |
| `Intern3.5VL` | Relatively fast with good vision comprehension |
| `gemma3` | Strong frame-to-frame understanding, slower inference times |
| `qwen2.5-vl` | Fast but capable model with good vision comprehension |
| `llava-phi3` | Lightweight and fast model with vision comprehension |
:::note

View File

@ -362,7 +362,7 @@ def stats_snapshot(
stats["embeddings"]["review_description_speed"] = round(
embeddings_metrics.review_desc_speed.value * 1000, 2
)
stats["embeddings"]["review_descriptions"] = round(
stats["embeddings"]["review_description_events_per_second"] = round(
embeddings_metrics.review_desc_dps.value, 2
)
@ -370,7 +370,7 @@ def stats_snapshot(
stats["embeddings"]["object_description_speed"] = round(
embeddings_metrics.object_desc_speed.value * 1000, 2
)
stats["embeddings"]["object_descriptions"] = round(
stats["embeddings"]["object_description_events_per_second"] = round(
embeddings_metrics.object_desc_dps.value, 2
)
@ -378,7 +378,7 @@ def stats_snapshot(
stats["embeddings"][f"{key}_classification_speed"] = round(
embeddings_metrics.classification_speeds[key].value * 1000, 2
)
stats["embeddings"][f"{key}_classification"] = round(
stats["embeddings"][f"{key}_classification_events_per_second"] = round(
embeddings_metrics.classification_cps[key].value, 2
)

View File

@ -177,6 +177,10 @@
"noCameras": {
"title": "No Cameras Configured",
"description": "Get started by connecting a camera to Frigate.",
"buttonText": "Add Camera"
"buttonText": "Add Camera",
"restricted": {
"title": "No Cameras Available",
"description": "You don't have permission to view any cameras in this group."
}
}
}

View File

@ -169,6 +169,7 @@
"enrichments": {
"title": "Enrichments",
"infPerSecond": "Inferences Per Second",
"averageInf": "Average Inference Time",
"embeddings": {
"image_embedding": "Image Embedding",
"text_embedding": "Text Embedding",
@ -180,7 +181,13 @@
"plate_recognition_speed": "Plate Recognition Speed",
"text_embedding_speed": "Text Embedding Speed",
"yolov9_plate_detection_speed": "YOLOv9 Plate Detection Speed",
"yolov9_plate_detection": "YOLOv9 Plate Detection"
"yolov9_plate_detection": "YOLOv9 Plate Detection",
"review_description": "Review Description",
"review_description_speed": "Review Description Speed",
"review_description_events_per_second": "Review Description",
"object_description": "Object Description",
"object_description_speed": "Object Description Speed",
"object_description_events_per_second": "Object Description"
}
}
}

View File

@ -9,7 +9,7 @@ import useSWR from "swr";
import { MdHome } from "react-icons/md";
import { usePersistedOverlayState } from "@/hooks/use-overlay-state";
import { Button, buttonVariants } from "../ui/button";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { LuPencil, LuPlus } from "react-icons/lu";
import {
@ -87,6 +87,8 @@ type CameraGroupSelectorProps = {
export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
const { t } = useTranslation(["components/camera"]);
const { data: config } = useSWR<FrigateConfig>("config");
const allowedCameras = useAllowedCameras();
const isCustomRole = useIsCustomRole();
// tooltip
@ -119,10 +121,22 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
return [];
}
return Object.entries(config.camera_groups).sort(
(a, b) => a[1].order - b[1].order,
const allGroups = Object.entries(config.camera_groups);
// If custom role, filter out groups where user has no accessible cameras
if (isCustomRole) {
return allGroups
.filter(([, groupConfig]) => {
// Check if user has access to at least one camera in this group
return groupConfig.cameras.some((cameraName) =>
allowedCameras.includes(cameraName),
);
}, [config]);
})
.sort((a, b) => a[1].order - b[1].order);
}
return allGroups.sort((a, b) => a[1].order - b[1].order);
}, [config, allowedCameras, isCustomRole]);
// add group
@ -139,6 +153,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
activeGroup={group}
setGroup={setGroup}
deleteGroup={deleteGroup}
isCustomRole={isCustomRole}
/>
<Scroller className={`${isMobile ? "whitespace-nowrap" : ""}`}>
<div
@ -206,6 +221,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
);
})}
{!isCustomRole && (
<Button
className="bg-secondary text-muted-foreground"
aria-label={t("group.add")}
@ -214,6 +230,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
>
<LuPlus className="size-4 text-primary" />
</Button>
)}
{isMobile && <ScrollBar orientation="horizontal" className="h-0" />}
</div>
</Scroller>
@ -228,6 +245,7 @@ type NewGroupDialogProps = {
activeGroup?: string;
setGroup: (value: string | undefined, replace?: boolean | undefined) => void;
deleteGroup: () => void;
isCustomRole?: boolean;
};
function NewGroupDialog({
open,
@ -236,6 +254,7 @@ function NewGroupDialog({
activeGroup,
setGroup,
deleteGroup,
isCustomRole,
}: NewGroupDialogProps) {
const { t } = useTranslation(["components/camera"]);
const { mutate: updateConfig } = useSWR<FrigateConfig>("config");
@ -261,6 +280,12 @@ function NewGroupDialog({
`${activeGroup}-draggable-layout`,
);
useEffect(() => {
if (!open) {
setEditState("none");
}
}, [open]);
// callbacks
const onDeleteGroup = useCallback(
@ -349,13 +374,7 @@ function NewGroupDialog({
position="top-center"
closeButton={true}
/>
<Overlay
open={open}
onOpenChange={(open) => {
setEditState("none");
setOpen(open);
}}
>
<Overlay open={open} onOpenChange={setOpen}>
<Content
className={cn(
"scrollbar-container overflow-y-auto",
@ -371,6 +390,7 @@ function NewGroupDialog({
>
<Title>{t("group.label")}</Title>
<Description className="sr-only">{t("group.edit")}</Description>
{!isCustomRole && (
<div
className={cn(
"absolute",
@ -393,6 +413,7 @@ function NewGroupDialog({
<LuPlus />
</Button>
</div>
)}
</Header>
<div className="flex flex-col gap-4 md:gap-3">
{currentGroups.map((group) => (
@ -401,6 +422,7 @@ function NewGroupDialog({
group={group}
onDeleteGroup={() => onDeleteGroup(group[0])}
onEditGroup={() => onEditGroup(group)}
isReadOnly={isCustomRole}
/>
))}
</div>
@ -512,12 +534,14 @@ type CameraGroupRowProps = {
group: [string, CameraGroupConfig];
onDeleteGroup: () => void;
onEditGroup: () => void;
isReadOnly?: boolean;
};
export function CameraGroupRow({
group,
onDeleteGroup,
onEditGroup,
isReadOnly,
}: CameraGroupRowProps) {
const { t } = useTranslation(["components/camera"]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@ -564,7 +588,7 @@ export function CameraGroupRow({
</AlertDialogContent>
</AlertDialog>
{isMobile && (
{isMobile && !isReadOnly && (
<>
<DropdownMenu modal={!isDesktop}>
<DropdownMenuTrigger>
@ -589,7 +613,7 @@ export function CameraGroupRow({
</DropdownMenu>
</>
)}
{!isMobile && (
{!isMobile && !isReadOnly && (
<div className="flex flex-row items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>

View File

@ -807,6 +807,15 @@ function ObjectDetailsTab({
}
}, [search]);
const isEventsKey = useCallback((key: unknown): boolean => {
const candidate = Array.isArray(key) ? key[0] : key;
const EVENTS_KEY_PATTERNS = ["events", "events/search", "events/explore"];
return (
typeof candidate === "string" &&
EVENTS_KEY_PATTERNS.some((p) => candidate.includes(p))
);
}, []);
const updateDescription = useCallback(() => {
if (!search) {
return;
@ -821,11 +830,7 @@ function ObjectDetailsTab({
});
}
mutate(
(key) =>
typeof key === "string" &&
(key.includes("events") ||
key.includes("events/search") ||
key.includes("events/explore")),
(key) => isEventsKey(key),
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
mapSearchResults(currentData, (event) =>
event.id === search.id
@ -838,6 +843,7 @@ function ObjectDetailsTab({
revalidate: false,
},
);
setSearch({ ...search, data: { ...search.data, description: desc } });
})
.catch((error) => {
const errorMessage =
@ -854,7 +860,7 @@ function ObjectDetailsTab({
);
setDesc(search.data.description);
});
}, [desc, search, mutate, t, mapSearchResults]);
}, [desc, search, mutate, t, mapSearchResults, isEventsKey, setSearch]);
const regenerateDescription = useCallback(
(source: "snapshot" | "thumbnails") => {
@ -921,11 +927,7 @@ function ObjectDetailsTab({
});
mutate(
(key) =>
typeof key === "string" &&
(key.includes("events") ||
key.includes("events/search") ||
key.includes("events/explore")),
(key) => isEventsKey(key),
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
mapSearchResults(currentData, (event) =>
event.id === search.id
@ -972,7 +974,7 @@ function ObjectDetailsTab({
);
});
},
[search, apiHost, mutate, setSearch, t, mapSearchResults],
[search, apiHost, mutate, setSearch, t, mapSearchResults, isEventsKey],
);
// recognized plate
@ -996,11 +998,7 @@ function ObjectDetailsTab({
});
mutate(
(key) =>
typeof key === "string" &&
(key.includes("events") ||
key.includes("events/search") ||
key.includes("events/explore")),
(key) => isEventsKey(key),
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
mapSearchResults(currentData, (event) =>
event.id === search.id
@ -1047,7 +1045,7 @@ function ObjectDetailsTab({
);
});
},
[search, apiHost, mutate, setSearch, t, mapSearchResults],
[search, apiHost, mutate, setSearch, t, mapSearchResults, isEventsKey],
);
// speech transcription
@ -1103,12 +1101,9 @@ function ObjectDetailsTab({
});
setState("submitted");
setSearch({ ...search, plus_id: "new_upload" });
mutate(
(key) =>
typeof key === "string" &&
(key.includes("events") ||
key.includes("events/search") ||
key.includes("events/explore")),
(key) => isEventsKey(key),
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
mapSearchResults(currentData, (event) =>
event.id === search.id
@ -1122,7 +1117,7 @@ function ObjectDetailsTab({
},
);
},
[search, mutate, mapSearchResults],
[search, mutate, mapSearchResults, setSearch, isEventsKey],
);
const popoverContainerRef = useRef<HTMLDivElement | null>(null);

View File

@ -6,31 +6,68 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Event } from "@/types/event";
import { isDesktop, isMobile } from "react-device-detect";
import { ObjectSnapshotTab } from "../detail/SearchDetailDialog";
import { isDesktop, isMobile, isSafari } from "react-device-detect";
import { cn } from "@/lib/utils";
import { useCallback, useEffect, useState } from "react";
import axios from "axios";
import { useTranslation, Trans } from "react-i18next";
import { Button } from "@/components/ui/button";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { FaCheckCircle } from "react-icons/fa";
import { Card, CardContent } from "@/components/ui/card";
import { TransformComponent, TransformWrapper } from "react-zoom-pan-pinch";
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
import { baseUrl } from "@/api/baseUrl";
import { getTranslatedLabel } from "@/utils/i18n";
import useImageLoaded from "@/hooks/use-image-loaded";
type FrigatePlusDialogProps = {
export type FrigatePlusDialogProps = {
upload?: Event;
dialog?: boolean;
onClose: () => void;
onEventUploaded: () => void;
};
export function FrigatePlusDialog({
upload,
dialog = true,
onClose,
onEventUploaded,
}: FrigatePlusDialogProps) {
if (!upload) {
return;
}
if (dialog) {
const { t, i18n } = useTranslation(["components/dialog"]);
type SubmissionState = "reviewing" | "uploading" | "submitted";
const [state, setState] = useState<SubmissionState>(
upload?.plus_id ? "submitted" : "reviewing",
);
useEffect(() => {
setState(upload?.plus_id ? "submitted" : "reviewing");
}, [upload?.plus_id]);
const onSubmitToPlus = useCallback(
async (falsePositive: boolean) => {
if (!upload) return;
falsePositive
? axios.put(`events/${upload.id}/false_positive`)
: axios.post(`events/${upload.id}/plus`, { include_annotation: 1 });
setState("submitted");
onEventUploaded();
},
[upload, onEventUploaded],
);
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
const showCard =
!!upload &&
upload.data.type === "object" &&
upload.plus_id !== "not_enabled" &&
upload.end_time &&
upload.label !== "on_demand";
if (!dialog || !upload) return null;
return (
<Dialog
open={upload != undefined}
onOpenChange={(open) => (!open ? onClose() : null)}
>
<Dialog open={true} onOpenChange={(open) => (!open ? onClose() : null)}>
<DialogContent
className={cn(
"scrollbar-container overflow-y-auto",
@ -45,12 +82,123 @@ export function FrigatePlusDialog({
Submit this snapshot to Frigate+
</DialogDescription>
</DialogHeader>
<ObjectSnapshotTab
search={upload}
onEventUploaded={onEventUploaded}
<div className="relative size-full">
<ImageLoadingIndicator
className="absolute inset-0 aspect-video min-h-[60dvh] w-full"
imgLoaded={imgLoaded}
/>
<div className={imgLoaded ? "visible" : "invisible"}>
<TransformWrapper minScale={1.0} wheel={{ smoothStep: 0.005 }}>
<div className="flex flex-col space-y-3">
<TransformComponent
wrapperStyle={{ width: "100%", height: "100%" }}
contentStyle={{
position: "relative",
width: "100%",
height: "100%",
}}
>
{upload.id && (
<div className="relative mx-auto">
<img
ref={imgRef}
className="mx-auto max-h-[60dvh] rounded-lg bg-black object-contain"
src={`${baseUrl}api/events/${upload.id}/snapshot.jpg`}
alt={`${upload.label}`}
loading={isSafari ? "eager" : "lazy"}
onLoad={onImgLoad}
/>
</div>
)}
</TransformComponent>
{showCard && (
<Card className="p-1 text-sm md:p-2">
<CardContent className="flex flex-col items-center justify-between gap-3 p-2 md:flex-row">
<div className="flex flex-col space-y-3">
<div className="text-lg leading-none">
{t("explore.plus.submitToPlus.label")}
</div>
<div className="text-sm text-muted-foreground">
{t("explore.plus.submitToPlus.desc")}
</div>
</div>
<div className="flex w-full flex-1 flex-col justify-center gap-2 md:ml-8 md:w-auto md:justify-end">
{state === "reviewing" && (
<>
<div>
{i18n.language === "en" ? (
/^[aeiou]/i.test(upload.label || "") ? (
<Trans
ns="components/dialog"
values={{ label: upload.label }}
>
explore.plus.review.question.ask_an
</Trans>
) : (
<Trans
ns="components/dialog"
values={{ label: upload.label }}
>
explore.plus.review.question.ask_a
</Trans>
)
) : (
<Trans
ns="components/dialog"
values={{
untranslatedLabel: upload.label,
translatedLabel: getTranslatedLabel(
upload.label,
),
}}
>
explore.plus.review.question.ask_full
</Trans>
)}
</div>
<div className="flex w-full flex-row gap-2">
<Button
className="flex-1 bg-success"
aria-label={t("button.yes", { ns: "common" })}
onClick={() => {
setState("uploading");
onSubmitToPlus(false);
}}
>
{t("button.yes", { ns: "common" })}
</Button>
<Button
className="flex-1 text-white"
aria-label={t("button.no", { ns: "common" })}
variant="destructive"
onClick={() => {
setState("uploading");
onSubmitToPlus(true);
}}
>
{t("button.no", { ns: "common" })}
</Button>
</div>
</>
)}
{state === "uploading" && <ActivityIndicator />}
{state === "submitted" && (
<div className="flex flex-row items-center justify-center gap-2">
<FaCheckCircle className="size-4 text-success" />
{t("explore.plus.review.state.submitted")}
</div>
)}
</div>
</CardContent>
</Card>
)}
</div>
</TransformWrapper>
</div>
</div>
</DialogContent>
</Dialog>
);
}
}

View File

@ -377,7 +377,7 @@ export default function Step1NameCamera({
);
return selectedBrand &&
selectedBrand.value != "other" ? (
<Popover>
<Popover modal={true}>
<PopoverTrigger asChild>
<Button
variant="ghost"

View File

@ -600,7 +600,7 @@ export default function Step3StreamConfig({
<Label className="text-sm font-medium text-primary-variant">
{t("cameraWizard.step3.roles")}
</Label>
<Popover>
<Popover modal={true}>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
<LuInfo className="size-3" />
@ -670,7 +670,7 @@ export default function Step3StreamConfig({
<Label className="text-sm font-medium text-primary-variant">
{t("cameraWizard.step3.featuresTitle")}
</Label>
<Popover>
<Popover modal={true}>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
<LuInfo className="size-3" />

View File

@ -93,19 +93,23 @@ function Live() {
const allowedCameras = useAllowedCameras();
const includesBirdseye = useMemo(() => {
// Restricted users should never have access to birdseye
if (isCustomRole) {
return false;
}
if (
config &&
Object.keys(config.camera_groups).length &&
cameraGroup &&
config.camera_groups[cameraGroup] &&
cameraGroup != "default" &&
(!isCustomRole || "birdseye" in allowedCameras)
cameraGroup != "default"
) {
return config.camera_groups[cameraGroup].cameras.includes("birdseye");
} else {
return false;
}
}, [config, cameraGroup, allowedCameras, isCustomRole]);
}, [config, cameraGroup, isCustomRole]);
const cameras = useMemo(() => {
if (!config) {

View File

@ -37,6 +37,7 @@ import EnrichmentsSettingsView from "@/views/settings/EnrichmentsSettingsView";
import UiSettingsView from "@/views/settings/UiSettingsView";
import FrigatePlusSettingsView from "@/views/settings/FrigatePlusSettingsView";
import { useSearchEffect } from "@/hooks/use-overlay-state";
import { usePersistence } from "@/hooks/use-persistence";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useInitialCameraState } from "@/api/ws";
import { useIsAdmin } from "@/hooks/use-is-admin";
@ -207,7 +208,21 @@ export default function Settings() {
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const [selectedCamera, setSelectedCamera] = useState<string>("");
const [persistedCamera, setPersistedCamera] = usePersistence(
"selectedCamera",
"",
);
const [selectedCamera, setSelectedCamera] = useState(persistedCamera);
useEffect(() => {
if (persistedCamera) {
setSelectedCamera(persistedCamera);
}
}, [persistedCamera]);
useEffect(() => {
if (selectedCamera) {
setPersistedCamera(selectedCamera);
}
}, [selectedCamera, setPersistedCamera]);
const { payload: allCameraStates } = useInitialCameraState(
cameras.length > 0 ? cameras[0].name : "",

View File

@ -39,6 +39,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import BlurredIconButton from "@/components/button/BlurredIconButton";
import { Skeleton } from "@/components/ui/skeleton";
const allModelTypes = ["objects", "states"] as const;
type ModelType = (typeof allModelTypes)[number];
@ -332,9 +333,7 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
<ImageShadowOverlay lowerClassName="h-[30%] z-0" />
</>
) : (
<div className="flex size-full items-center justify-center bg-background_alt">
<MdModelTraining className="size-16 text-muted-foreground" />
</div>
<Skeleton className="flex size-full items-center justify-center" />
)}
<div className="absolute bottom-2 left-3 text-lg text-white smart-capitalize">
{config.name}

View File

@ -20,7 +20,14 @@ import {
FrigateConfig,
} from "@/types/frigateConfig";
import { ReviewSegment } from "@/types/review";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
isDesktop,
isMobile,
@ -46,6 +53,8 @@ import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { useTranslation } from "react-i18next";
import { EmptyCard } from "@/components/card/EmptyCard";
import { BsFillCameraVideoOffFill } from "react-icons/bs";
import { AuthContext } from "@/context/auth-context";
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
type LiveDashboardViewProps = {
cameras: CameraConfig[];
@ -374,10 +383,6 @@ export default function LiveDashboardView({
onSaveMuting(true);
};
if (cameras.length == 0 && !includeBirdseye) {
return <NoCameraView />;
}
return (
<div
className="scrollbar-container size-full select-none overflow-y-auto px-1 pt-2 md:p-2"
@ -439,6 +444,10 @@ export default function LiveDashboardView({
</div>
)}
{cameras.length == 0 && !includeBirdseye ? (
<NoCameraView />
) : (
<>
{!fullscreen && events && events.length > 0 && (
<ScrollArea>
<TooltipProvider>
@ -494,7 +503,8 @@ export default function LiveDashboardView({
)}
{cameras.map((camera) => {
let grow;
const aspectRatio = camera.detect.width / camera.detect.height;
const aspectRatio =
camera.detect.width / camera.detect.height;
if (aspectRatio > 2) {
grow = `${mobileLayout == "grid" && "col-span-2"} aspect-wide`;
} else if (aspectRatio < 1) {
@ -503,10 +513,12 @@ export default function LiveDashboardView({
grow = "aspect-video";
}
const availableStreams = camera.live.streams || {};
const firstStreamEntry = Object.values(availableStreams)[0] || "";
const firstStreamEntry =
Object.values(availableStreams)[0] || "";
const streamNameFromSettings =
currentGroupStreamingSettings?.[camera.name]?.streamName || "";
currentGroupStreamingSettings?.[camera.name]?.streamName ||
"";
const streamExists =
streamNameFromSettings &&
Object.values(availableStreams).includes(
@ -535,7 +547,9 @@ export default function LiveDashboardView({
camera={camera.name}
cameraGroup={cameraGroup}
streamName={streamName}
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
preferredLiveMode={
preferredLiveModes[camera.name] ?? "mse"
}
isRestreamed={isRestreamedStates[camera.name]}
supportsAudio={
supportsAudioOutputStates[streamName]?.supportsAudio ??
@ -566,9 +580,13 @@ export default function LiveDashboardView({
windowVisible && visibleCameras.includes(camera.name)
}
cameraConfig={camera}
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
preferredLiveMode={
preferredLiveModes[camera.name] ?? "mse"
}
autoLive={autoLive ?? globalAutoLive}
showStillWithoutActivity={showStillWithoutActivity ?? true}
showStillWithoutActivity={
showStillWithoutActivity ?? true
}
alwaysShowCameraName={displayCameraNames}
useWebGL={useWebGL}
playInBackground={false}
@ -576,7 +594,9 @@ export default function LiveDashboardView({
streamName={streamName}
onClick={() => onSelectCamera(camera.name)}
onError={(e) => handleError(camera.name, e)}
onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
onResetLiveMode={() =>
resetPreferredLiveMode(camera.name)
}
playAudio={audioStates[camera.name] ?? false}
volume={volumeStates[camera.name]}
/>
@ -632,21 +652,34 @@ export default function LiveDashboardView({
toggleFullscreen={toggleFullscreen}
/>
)}
</>
)}
</div>
);
}
function NoCameraView() {
const { t } = useTranslation(["views/live"]);
const { auth } = useContext(AuthContext);
const isCustomRole = useIsCustomRole();
// Check if this is a restricted user with no cameras in this group
const isRestricted = isCustomRole && auth.isAuthenticated;
return (
<div className="flex size-full items-center justify-center">
<EmptyCard
icon={<BsFillCameraVideoOffFill className="size-8" />}
title={t("noCameras.title")}
description={t("noCameras.description")}
buttonText={t("noCameras.buttonText")}
link="/settings?page=cameraManagement"
title={
isRestricted ? t("noCameras.restricted.title") : t("noCameras.title")
}
description={
isRestricted
? t("noCameras.restricted.description")
: t("noCameras.description")
}
buttonText={!isRestricted ? t("noCameras.buttonText") : undefined}
link={!isRestricted ? "/settings?page=cameraManagement" : undefined}
/>
</div>
);

View File

@ -198,9 +198,9 @@ export default function TriggerView({
return axios
.put("config/set", configBody)
.then((configResponse) => {
.then(async (configResponse) => {
if (configResponse.status === 200) {
updateConfig();
await updateConfig();
const displayName =
friendly_name && friendly_name !== ""
? `${friendly_name} (${name})`
@ -353,9 +353,9 @@ export default function TriggerView({
return axios
.put("config/set", configBody)
.then((configResponse) => {
.then(async (configResponse) => {
if (configResponse.status === 200) {
updateConfig();
await updateConfig();
const friendly =
config?.cameras?.[selectedCamera]?.semantic_search
?.triggers?.[name]?.friendly_name;

View File

@ -67,13 +67,14 @@ export default function EnrichmentMetrics({
// features stats
const embeddingInferenceTimeSeries = useMemo(() => {
const groupedEnrichmentMetrics = useMemo(() => {
if (!statsHistory) {
return [];
}
const series: {
[key: string]: {
rawKey: string;
name: string;
metrics: Threshold;
data: { x: number; y: number }[];
@ -90,6 +91,7 @@ export default function EnrichmentMetrics({
if (!(key in series)) {
series[key] = {
rawKey,
name: t("enrichments.embeddings." + rawKey),
metrics: getThreshold(rawKey),
data: [],
@ -99,7 +101,57 @@ export default function EnrichmentMetrics({
series[key].data.push({ x: statsIdx + 1, y: stat });
});
});
return Object.values(series);
// Group series by category (extract base name from raw key)
const grouped: {
[category: string]: {
categoryName: string;
speedSeries?: {
name: string;
metrics: Threshold;
data: { x: number; y: number }[];
};
eventsSeries?: {
name: string;
metrics: Threshold;
data: { x: number; y: number }[];
};
};
} = {};
Object.values(series).forEach((s) => {
// Extract base category name from raw key
// All metrics follow the pattern: {base}_speed and {base}_events_per_second
let categoryKey = s.rawKey;
let isSpeed = false;
if (s.rawKey.endsWith("_speed")) {
categoryKey = s.rawKey.replace("_speed", "");
isSpeed = true;
} else if (s.rawKey.endsWith("_events_per_second")) {
categoryKey = s.rawKey.replace("_events_per_second", "");
isSpeed = false;
}
// Get translated category name
const categoryName = t("enrichments.embeddings." + categoryKey);
if (!(categoryKey in grouped)) {
grouped[categoryKey] = {
categoryName,
speedSeries: undefined,
eventsSeries: undefined,
};
}
if (isSpeed) {
grouped[categoryKey].speedSeries = s;
} else {
grouped[categoryKey].eventsSeries = s;
}
});
return Object.values(grouped);
}, [statsHistory, t, getThreshold]);
return (
@ -110,36 +162,43 @@ export default function EnrichmentMetrics({
</div>
<div
className={cn(
"mt-4 grid w-full grid-cols-1 gap-2 sm:grid-cols-3",
embeddingInferenceTimeSeries && "sm:grid-cols-4",
"mt-4 grid w-full grid-cols-1 gap-2 sm:grid-cols-2 md:grid-cols-4",
)}
>
{statsHistory.length != 0 ? (
<>
{embeddingInferenceTimeSeries.map((series) => (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5 smart-capitalize">{series.name}</div>
{series.name.endsWith("Speed") ? (
{groupedEnrichmentMetrics.map((group) => (
<div
key={group.categoryName}
className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl"
>
<div className="mb-5 smart-capitalize">
{group.categoryName}
</div>
<div className="space-y-4">
{group.speedSeries && (
<ThresholdBarGraph
key={series.name}
graphId={`${series.name}-inference`}
name={series.name}
key={`${group.categoryName}-speed`}
graphId={`${group.categoryName}-inference`}
name={t("enrichments.averageInf")}
unit="ms"
threshold={series.metrics}
threshold={group.speedSeries.metrics}
updateTimes={updateTimes}
data={[series]}
data={[group.speedSeries]}
/>
) : (
)}
{group.eventsSeries && (
<EventsPerSecondsLineGraph
key={series.name}
graphId={`${series.name}-fps`}
key={`${group.categoryName}-events`}
graphId={`${group.categoryName}-fps`}
unit=""
name={t("enrichments.infPerSecond")}
updateTimes={updateTimes}
data={[series]}
data={[group.eventsSeries]}
/>
)}
</div>
</div>
))}
</>
) : (

View File

@ -729,12 +729,9 @@ export default function GeneralMetrics({
) : (
<Skeleton className="aspect-video w-full" />
)}
</>
)}
{statsHistory[0]?.npu_usages && (
<div
className={cn("mt-4 grid grid-cols-1 gap-2 sm:grid-cols-2")}
>
<>
{statsHistory.length != 0 ? (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5">
@ -755,7 +752,9 @@ export default function GeneralMetrics({
) : (
<Skeleton className="aspect-video w-full" />
)}
</div>
</>
)}
</>
)}
</div>
</>