Compare commits

..

8 Commits

Author SHA1 Message Date
Josh Hawkins
cfc220e083 fix popovers from being immediately dismissed on safari 2025-11-20 17:38:41 -06:00
Josh Hawkins
e20b788966 fix npu graph 2025-11-20 17:26:53 -06:00
Josh Hawkins
8de0b84227 move npu graph inside of gpu grid 2025-11-20 17:26:09 -06:00
Nicolas Mowen
4ef37df8bd Use skeleton instead of icon 2025-11-20 16:01:33 -07:00
Josh Hawkins
8122c31575 fix re-render crash in camera group mobile page
the callback only needs a single state update for the useeffect to fire
2025-11-20 16:54:38 -06:00
Josh Hawkins
7a7ab98888 always show camera group buttons on mobile so users don't get stuck 2025-11-20 16:39:36 -06:00
Josh Hawkins
bb31dd18a4 camera group changes for custom viewer roles
- hide camera groups with no accessible cameras
- hide camera group edit button
2025-11-20 16:36:26 -06:00
Josh Hawkins
5d3f31175d hide birdseye from custom viewer role users 2025-11-20 16:34:41 -06:00
8 changed files with 333 additions and 270 deletions

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

@ -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

@ -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

@ -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

@ -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>
</>