Compare commits

..

No commits in common. "cfc220e083c9477c30fa1eefa331df367121f8ca" and "4bc74620125c9320a5d047163b3c5f6a14d491bf" have entirely different histories.

8 changed files with 266 additions and 329 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,14 +20,7 @@ import {
FrigateConfig, FrigateConfig,
} from "@/types/frigateConfig"; } from "@/types/frigateConfig";
import { ReviewSegment } from "@/types/review"; import { ReviewSegment } from "@/types/review";
import { import { useCallback, useEffect, useMemo, useRef, useState } from "react";
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { import {
isDesktop, isDesktop,
isMobile, isMobile,
@ -53,8 +46,6 @@ import { useStreamingSettings } from "@/context/streaming-settings-provider";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { EmptyCard } from "@/components/card/EmptyCard"; import { EmptyCard } from "@/components/card/EmptyCard";
import { BsFillCameraVideoOffFill } from "react-icons/bs"; import { BsFillCameraVideoOffFill } from "react-icons/bs";
import { AuthContext } from "@/context/auth-context";
import { useIsCustomRole } from "@/hooks/use-is-custom-role";
type LiveDashboardViewProps = { type LiveDashboardViewProps = {
cameras: CameraConfig[]; cameras: CameraConfig[];
@ -383,6 +374,10 @@ export default function LiveDashboardView({
onSaveMuting(true); onSaveMuting(true);
}; };
if (cameras.length == 0 && !includeBirdseye) {
return <NoCameraView />;
}
return ( return (
<div <div
className="scrollbar-container size-full select-none overflow-y-auto px-1 pt-2 md:p-2" className="scrollbar-container size-full select-none overflow-y-auto px-1 pt-2 md:p-2"
@ -444,215 +439,198 @@ export default function LiveDashboardView({
</div> </div>
)} )}
{cameras.length == 0 && !includeBirdseye ? ( {!fullscreen && events && events.length > 0 && (
<NoCameraView /> <ScrollArea>
) : ( <TooltipProvider>
<div className="flex items-center gap-2 px-1">
{events.map((event) => {
return (
<AnimatedEventCard
key={event.id}
event={event}
selectedGroup={cameraGroup}
updateEvents={updateEvents}
/>
);
})}
</div>
</TooltipProvider>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)}
{!cameraGroup || cameraGroup == "default" || isMobileOnly ? (
<> <>
{!fullscreen && events && events.length > 0 && ( <div
<ScrollArea> className={cn(
<TooltipProvider> "mt-2 grid grid-cols-1 gap-2 px-2 md:gap-4",
<div className="flex items-center gap-2 px-1"> mobileLayout == "grid" &&
{events.map((event) => { "grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4",
return ( isMobile && "px-0",
<AnimatedEventCard )}
key={event.id} >
event={event} {includeBirdseye && birdseyeConfig?.enabled && (
selectedGroup={cameraGroup}
updateEvents={updateEvents}
/>
);
})}
</div>
</TooltipProvider>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)}
{!cameraGroup || cameraGroup == "default" || isMobileOnly ? (
<>
<div <div
className={cn( className={(() => {
"mt-2 grid grid-cols-1 gap-2 px-2 md:gap-4",
mobileLayout == "grid" &&
"grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4",
isMobile && "px-0",
)}
>
{includeBirdseye && birdseyeConfig?.enabled && (
<div
className={(() => {
const aspectRatio =
birdseyeConfig.width / birdseyeConfig.height;
if (aspectRatio > 2) {
return `${mobileLayout == "grid" && "col-span-2"} aspect-wide`;
} else if (aspectRatio < 1) {
return `${mobileLayout == "grid" && "row-span-2 h-full"} aspect-tall`;
} else {
return "aspect-video";
}
})()}
ref={birdseyeContainerRef}
>
<BirdseyeLivePlayer
birdseyeConfig={birdseyeConfig}
liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
onClick={() => onSelectCamera("birdseye")}
containerRef={birdseyeContainerRef}
/>
</div>
)}
{cameras.map((camera) => {
let grow;
const aspectRatio = const aspectRatio =
camera.detect.width / camera.detect.height; birdseyeConfig.width / birdseyeConfig.height;
if (aspectRatio > 2) { if (aspectRatio > 2) {
grow = `${mobileLayout == "grid" && "col-span-2"} aspect-wide`; return `${mobileLayout == "grid" && "col-span-2"} aspect-wide`;
} else if (aspectRatio < 1) { } else if (aspectRatio < 1) {
grow = `${mobileLayout == "grid" && "row-span-2 h-full"} aspect-tall`; return `${mobileLayout == "grid" && "row-span-2 h-full"} aspect-tall`;
} else { } else {
grow = "aspect-video"; return "aspect-video";
} }
const availableStreams = camera.live.streams || {}; })()}
const firstStreamEntry = ref={birdseyeContainerRef}
Object.values(availableStreams)[0] || ""; >
<BirdseyeLivePlayer
const streamNameFromSettings = birdseyeConfig={birdseyeConfig}
currentGroupStreamingSettings?.[camera.name]?.streamName || liveMode={birdseyeConfig.restream ? "mse" : "jsmpeg"}
""; onClick={() => onSelectCamera("birdseye")}
const streamExists = containerRef={birdseyeContainerRef}
streamNameFromSettings && />
Object.values(availableStreams).includes(
streamNameFromSettings,
);
const streamName = streamExists
? streamNameFromSettings
: firstStreamEntry;
const streamType =
currentGroupStreamingSettings?.[camera.name]?.streamType;
const autoLive =
streamType !== undefined
? streamType !== "no-streaming"
: undefined;
const showStillWithoutActivity =
currentGroupStreamingSettings?.[camera.name]?.streamType !==
"continuous";
const useWebGL =
currentGroupStreamingSettings?.[camera.name]
?.compatibilityMode || false;
return (
<LiveContextMenu
className={grow}
key={camera.name}
camera={camera.name}
cameraGroup={cameraGroup}
streamName={streamName}
preferredLiveMode={
preferredLiveModes[camera.name] ?? "mse"
}
isRestreamed={isRestreamedStates[camera.name]}
supportsAudio={
supportsAudioOutputStates[streamName]?.supportsAudio ??
false
}
audioState={audioStates[camera.name]}
toggleAudio={() => toggleAudio(camera.name)}
statsState={statsStates[camera.name]}
toggleStats={() => toggleStats(camera.name)}
volumeState={volumeStates[camera.name] ?? 1}
setVolumeState={(value) =>
setVolumeStates({
[camera.name]: value,
})
}
muteAll={muteAll}
unmuteAll={unmuteAll}
resetPreferredLiveMode={() =>
resetPreferredLiveMode(camera.name)
}
config={config}
>
<LivePlayer
cameraRef={cameraRef}
key={camera.name}
className={`${grow} rounded-lg bg-black md:rounded-2xl`}
windowVisible={
windowVisible && visibleCameras.includes(camera.name)
}
cameraConfig={camera}
preferredLiveMode={
preferredLiveModes[camera.name] ?? "mse"
}
autoLive={autoLive ?? globalAutoLive}
showStillWithoutActivity={
showStillWithoutActivity ?? true
}
alwaysShowCameraName={displayCameraNames}
useWebGL={useWebGL}
playInBackground={false}
showStats={statsStates[camera.name]}
streamName={streamName}
onClick={() => onSelectCamera(camera.name)}
onError={(e) => handleError(camera.name, e)}
onResetLiveMode={() =>
resetPreferredLiveMode(camera.name)
}
playAudio={audioStates[camera.name] ?? false}
volume={volumeStates[camera.name]}
/>
</LiveContextMenu>
);
})}
</div> </div>
{isDesktop && ( )}
<div {cameras.map((camera) => {
className={cn( let grow;
"fixed", const aspectRatio = camera.detect.width / camera.detect.height;
isDesktop && "bottom-12 lg:bottom-9", if (aspectRatio > 2) {
isMobile && "bottom-12 lg:bottom-16", grow = `${mobileLayout == "grid" && "col-span-2"} aspect-wide`;
hasScrollbar && isDesktop ? "right-6" : "right-3", } else if (aspectRatio < 1) {
"z-50 flex flex-row gap-2", grow = `${mobileLayout == "grid" && "row-span-2 h-full"} aspect-tall`;
)} } else {
grow = "aspect-video";
}
const availableStreams = camera.live.streams || {};
const firstStreamEntry = Object.values(availableStreams)[0] || "";
const streamNameFromSettings =
currentGroupStreamingSettings?.[camera.name]?.streamName || "";
const streamExists =
streamNameFromSettings &&
Object.values(availableStreams).includes(
streamNameFromSettings,
);
const streamName = streamExists
? streamNameFromSettings
: firstStreamEntry;
const streamType =
currentGroupStreamingSettings?.[camera.name]?.streamType;
const autoLive =
streamType !== undefined
? streamType !== "no-streaming"
: undefined;
const showStillWithoutActivity =
currentGroupStreamingSettings?.[camera.name]?.streamType !==
"continuous";
const useWebGL =
currentGroupStreamingSettings?.[camera.name]
?.compatibilityMode || false;
return (
<LiveContextMenu
className={grow}
key={camera.name}
camera={camera.name}
cameraGroup={cameraGroup}
streamName={streamName}
preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
isRestreamed={isRestreamedStates[camera.name]}
supportsAudio={
supportsAudioOutputStates[streamName]?.supportsAudio ??
false
}
audioState={audioStates[camera.name]}
toggleAudio={() => toggleAudio(camera.name)}
statsState={statsStates[camera.name]}
toggleStats={() => toggleStats(camera.name)}
volumeState={volumeStates[camera.name] ?? 1}
setVolumeState={(value) =>
setVolumeStates({
[camera.name]: value,
})
}
muteAll={muteAll}
unmuteAll={unmuteAll}
resetPreferredLiveMode={() =>
resetPreferredLiveMode(camera.name)
}
config={config}
> >
<Tooltip> <LivePlayer
<TooltipTrigger asChild> cameraRef={cameraRef}
<div key={camera.name}
className="cursor-pointer rounded-lg bg-secondary text-secondary-foreground opacity-60 transition-all duration-300 hover:bg-muted hover:opacity-100" className={`${grow} rounded-lg bg-black md:rounded-2xl`}
onClick={toggleFullscreen} windowVisible={
> windowVisible && visibleCameras.includes(camera.name)
{fullscreen ? ( }
<FaCompress className="size-5 md:m-[6px]" /> cameraConfig={camera}
) : ( preferredLiveMode={preferredLiveModes[camera.name] ?? "mse"}
<FaExpand className="size-5 md:m-[6px]" /> autoLive={autoLive ?? globalAutoLive}
)} showStillWithoutActivity={showStillWithoutActivity ?? true}
</div> alwaysShowCameraName={displayCameraNames}
</TooltipTrigger> useWebGL={useWebGL}
<TooltipContent> playInBackground={false}
{fullscreen showStats={statsStates[camera.name]}
? t("button.exitFullscreen", { ns: "common" }) streamName={streamName}
: t("button.fullscreen", { ns: "common" })} onClick={() => onSelectCamera(camera.name)}
</TooltipContent> onError={(e) => handleError(camera.name, e)}
</Tooltip> onResetLiveMode={() => resetPreferredLiveMode(camera.name)}
</div> playAudio={audioStates[camera.name] ?? false}
volume={volumeStates[camera.name]}
/>
</LiveContextMenu>
);
})}
</div>
{isDesktop && (
<div
className={cn(
"fixed",
isDesktop && "bottom-12 lg:bottom-9",
isMobile && "bottom-12 lg:bottom-16",
hasScrollbar && isDesktop ? "right-6" : "right-3",
"z-50 flex flex-row gap-2",
)} )}
</> >
) : ( <Tooltip>
<DraggableGridLayout <TooltipTrigger asChild>
cameras={cameras} <div
cameraGroup={cameraGroup} className="cursor-pointer rounded-lg bg-secondary text-secondary-foreground opacity-60 transition-all duration-300 hover:bg-muted hover:opacity-100"
containerRef={containerRef} onClick={toggleFullscreen}
cameraRef={cameraRef} >
includeBirdseye={includeBirdseye} {fullscreen ? (
onSelectCamera={onSelectCamera} <FaCompress className="size-5 md:m-[6px]" />
windowVisible={windowVisible} ) : (
visibleCameras={visibleCameras} <FaExpand className="size-5 md:m-[6px]" />
isEditMode={isEditMode} )}
setIsEditMode={setIsEditMode} </div>
fullscreen={fullscreen} </TooltipTrigger>
toggleFullscreen={toggleFullscreen} <TooltipContent>
/> {fullscreen
? t("button.exitFullscreen", { ns: "common" })
: t("button.fullscreen", { ns: "common" })}
</TooltipContent>
</Tooltip>
</div>
)} )}
</> </>
) : (
<DraggableGridLayout
cameras={cameras}
cameraGroup={cameraGroup}
containerRef={containerRef}
cameraRef={cameraRef}
includeBirdseye={includeBirdseye}
onSelectCamera={onSelectCamera}
windowVisible={windowVisible}
visibleCameras={visibleCameras}
isEditMode={isEditMode}
setIsEditMode={setIsEditMode}
fullscreen={fullscreen}
toggleFullscreen={toggleFullscreen}
/>
)} )}
</div> </div>
); );
@ -660,26 +638,15 @@ export default function LiveDashboardView({
function NoCameraView() { function NoCameraView() {
const { t } = useTranslation(["views/live"]); 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 ( return (
<div className="flex size-full items-center justify-center"> <div className="flex size-full items-center justify-center">
<EmptyCard <EmptyCard
icon={<BsFillCameraVideoOffFill className="size-8" />} icon={<BsFillCameraVideoOffFill className="size-8" />}
title={ title={t("noCameras.title")}
isRestricted ? t("noCameras.restricted.title") : t("noCameras.title") description={t("noCameras.description")}
} buttonText={t("noCameras.buttonText")}
description={ link="/settings?page=cameraManagement"
isRestricted
? t("noCameras.restricted.description")
: t("noCameras.description")
}
buttonText={!isRestricted ? t("noCameras.buttonText") : undefined}
link={!isRestricted ? "/settings?page=cameraManagement" : undefined}
/> />
</div> </div>
); );

View File

@ -729,33 +729,34 @@ export default function GeneralMetrics({
) : ( ) : (
<Skeleton className="aspect-video w-full" /> <Skeleton className="aspect-video w-full" />
)} )}
{statsHistory[0]?.npu_usages && (
<>
{statsHistory.length != 0 ? (
<div className="rounded-lg bg-background_alt p-2.5 md:rounded-2xl">
<div className="mb-5">
{t("general.hardwareInfo.npuUsage")}
</div>
{npuSeries.map((series) => (
<ThresholdBarGraph
key={series.name}
graphId={`${series.name}-npu`}
name={series.name}
unit="%"
threshold={GPUUsageThreshold}
updateTimes={updateTimes}
data={[series]}
/>
))}
</div>
) : (
<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">
{t("general.hardwareInfo.npuUsage")}
</div>
{npuSeries.map((series) => (
<ThresholdBarGraph
key={series.name}
graphId={`${series.name}-npu`}
name={series.name}
unit="%"
threshold={GPUUsageThreshold}
updateTimes={updateTimes}
data={[series]}
/>
))}
</div>
) : (
<Skeleton className="aspect-video w-full" />
)}
</div>
)}
</div> </div>
</> </>
)} )}