Merge branch 'blakeblackshear:dev' into dev

This commit is contained in:
Remon Nashid 2024-04-06 11:11:26 -06:00 committed by GitHub
commit 21c932240a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 549 additions and 363 deletions

View File

@ -62,6 +62,7 @@ from frigate.stats.util import stats_init
from frigate.storage import StorageMaintainer from frigate.storage import StorageMaintainer
from frigate.timeline import TimelineProcessor from frigate.timeline import TimelineProcessor
from frigate.types import CameraMetricsTypes, PTZMetricsTypes from frigate.types import CameraMetricsTypes, PTZMetricsTypes
from frigate.util.builtin import save_default_config
from frigate.util.object import get_camera_regions_grid from frigate.util.object import get_camera_regions_grid
from frigate.version import VERSION from frigate.version import VERSION
from frigate.video import capture_camera, track_camera from frigate.video import capture_camera, track_camera
@ -120,6 +121,11 @@ class FrigateApp:
if os.path.isfile(config_file_yaml): if os.path.isfile(config_file_yaml):
config_file = config_file_yaml config_file = config_file_yaml
if not os.path.isfile(config_file):
print("No config file found, saving default config")
config_file = config_file_yaml
save_default_config(config_file)
user_config = FrigateConfig.parse_file(config_file) user_config = FrigateConfig.parse_file(config_file)
self.config = user_config.runtime_config(self.plus_api) self.config = user_config.runtime_config(self.plus_api)
@ -499,7 +505,7 @@ class FrigateApp:
) )
audio_process.daemon = True audio_process.daemon = True
audio_process.start() audio_process.start()
self.processes["audioDetector"] = audio_process.pid or 0 self.processes["audio_detector"] = audio_process.pid or 0
logger.info(f"Audio process started: {audio_process.pid}") logger.info(f"Audio process started: {audio_process.pid}")
def start_timeline_processor(self) -> None: def start_timeline_processor(self) -> None:

View File

@ -281,6 +281,32 @@ def find_by_key(dictionary, target_key):
return None return None
def save_default_config(location: str):
try:
with open(location, "w") as f:
f.write(
"""
mqtt:
enabled: False
cameras:
name_of_your_camera: # <------ Name the camera
enabled: True
ffmpeg:
inputs:
- path: rtsp://10.0.10.10:554/rtsp # <----- The stream you want to use for detection
roles:
- detect
detect:
enabled: False # <---- disable detection until you have a working camera feed
width: 1280
height: 720
"""
)
except PermissionError:
logger.error("Unable to write default config to /config")
def get_tomorrow_at_time(hour: int) -> datetime.datetime: def get_tomorrow_at_time(hour: int) -> datetime.datetime:
"""Returns the datetime of the following day at 2am.""" """Returns the datetime of the following day at 2am."""
try: try:

View File

@ -87,7 +87,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
className={ className={
group == "default" group == "default"
? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60" ? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
: "text-muted-foreground bg-secondary focus:text-muted-foreground focus:bg-secondary" : "text-secondary-foreground bg-secondary focus:text-secondary-foreground focus:bg-secondary"
} }
size="xs" size="xs"
onClick={() => (group ? setGroup("default", true) : null)} onClick={() => (group ? setGroup("default", true) : null)}
@ -109,7 +109,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
className={ className={
group == name group == name
? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60" ? "text-selected bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
: "text-muted-foreground bg-secondary" : "text-secondary-foreground bg-secondary"
} }
size="xs" size="xs"
onClick={() => setGroup(name, group != "default")} onClick={() => setGroup(name, group != "default")}

View File

@ -35,7 +35,7 @@ export default function ReviewActionGroup({
}, [selectedReviews, setSelectedReviews, pullLatestData]); }, [selectedReviews, setSelectedReviews, pullLatestData]);
return ( return (
<div className="absolute inset-x-2 inset-y-0 md:left-auto md:right-2 p-2 flex gap-2 justify-between items-center bg-background"> <div className="absolute inset-x-2 inset-y-0 md:left-auto py-2 flex gap-2 justify-between items-center bg-background">
<div className="mx-1 flex justify-center items-center text-sm text-muted-foreground"> <div className="mx-1 flex justify-center items-center text-sm text-muted-foreground">
<div className="p-1">{`${selectedReviews.length} selected`}</div> <div className="p-1">{`${selectedReviews.length} selected`}</div>
<div className="p-1">{"|"}</div> <div className="p-1">{"|"}</div>
@ -58,7 +58,7 @@ export default function ReviewActionGroup({
}} }}
> >
<FaCompactDisc /> <FaCompactDisc />
{isDesktop && "Export"} {isDesktop && <div className="text-primary-foreground">Export</div>}
</Button> </Button>
)} )}
<Button <Button
@ -68,7 +68,9 @@ export default function ReviewActionGroup({
onClick={onMarkAsReviewed} onClick={onMarkAsReviewed}
> >
<FaCircleCheck /> <FaCircleCheck />
{isDesktop && "Mark as reviewed"} {isDesktop && (
<div className="text-primary-foreground">Mark as reviewed</div>
)}
</Button> </Button>
<Button <Button
className="p-2 flex items-center gap-1" className="p-2 flex items-center gap-1"
@ -77,7 +79,7 @@ export default function ReviewActionGroup({
onClick={onDelete} onClick={onDelete}
> >
<HiTrash /> <HiTrash />
{isDesktop && "Delete"} {isDesktop && <div className="text-primary-foreground">Delete</div>}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -223,8 +223,8 @@ function CamerasFilterButton({
variant="secondary" variant="secondary"
size="sm" size="sm"
> >
<FaVideo className="text-muted-foreground" /> <FaVideo className="text-secondary-foreground" />
<div className="hidden md:block"> <div className="hidden md:block text-primary-foreground">
{selectedCameras == undefined {selectedCameras == undefined
? "All Cameras" ? "All Cameras"
: `${selectedCameras.length} Cameras`} : `${selectedCameras.length} Cameras`}
@ -368,7 +368,7 @@ function ShowReviewFilter({
); );
return ( return (
<> <>
<div className="hidden h-9 md:flex p-2 justify-start items-center text-sm bg-secondary hover:bg-secondary/80 text-secondary-foreground rounded-md cursor-pointer"> <div className="hidden h-9 md:flex p-2 justify-start items-center text-sm bg-secondary hover:bg-secondary/80 text-primary-foreground rounded-md cursor-pointer">
<Switch <Switch
id="reviewed" id="reviewed"
checked={showReviewedSwitch == 1} checked={showReviewedSwitch == 1}
@ -412,8 +412,8 @@ function CalendarFilterButton({
const trigger = ( const trigger = (
<Button size="sm" className="flex items-center gap-2" variant="secondary"> <Button size="sm" className="flex items-center gap-2" variant="secondary">
<FaCalendarAlt className="text-muted-foreground" /> <FaCalendarAlt className="text-secondary-foreground" />
<div className="hidden md:block"> <div className="hidden md:block text-primary-foreground">
{day == undefined ? "Last 24 Hours" : selectedDate} {day == undefined ? "Last 24 Hours" : selectedDate}
</div> </div>
</Button> </Button>
@ -473,8 +473,8 @@ function GeneralFilterButton({
const trigger = ( const trigger = (
<Button size="sm" className="flex items-center gap-2" variant="secondary"> <Button size="sm" className="flex items-center gap-2" variant="secondary">
<FaFilter className="text-muted-foreground" /> <FaFilter className="text-secondary-foreground" />
<div className="hidden md:block">Filter</div> <div className="hidden md:block text-primary-foreground">Filter</div>
</Button> </Button>
); );
const content = ( const content = (
@ -546,7 +546,7 @@ export function GeneralFilterContent({
<div className="h-auto overflow-y-auto overflow-x-hidden"> <div className="h-auto overflow-y-auto overflow-x-hidden">
<div className="flex justify-between items-center my-2.5"> <div className="flex justify-between items-center my-2.5">
<Label <Label
className="mx-2 text-secondary-foreground cursor-pointer" className="mx-2 text-primary-foreground cursor-pointer"
htmlFor="allLabels" htmlFor="allLabels"
> >
All Labels All Labels
@ -653,7 +653,7 @@ function ShowMotionOnlyButton({
onCheckedChange={setMotionOnlyButton} onCheckedChange={setMotionOnlyButton}
/> />
<Label <Label
className="mx-2 text-secondary-foreground cursor-pointer" className="mx-2 text-primary-foreground cursor-pointer"
htmlFor="collapse-motion" htmlFor="collapse-motion"
> >
Motion only Motion only

View File

@ -3,6 +3,7 @@ import { FrigateConfig } from "@/types/frigateConfig";
import { Threshold } from "@/types/graph"; import { Threshold } from "@/types/graph";
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import Chart from "react-apexcharts"; import Chart from "react-apexcharts";
import { MdCircle } from "react-icons/md";
import useSWR from "swr"; import useSWR from "swr";
type ThresholdBarGraphProps = { type ThresholdBarGraphProps = {
@ -35,6 +36,10 @@ export function ThresholdBarGraph({
const formatTime = useCallback( const formatTime = useCallback(
(val: unknown) => { (val: unknown) => {
if (val == 0) {
return;
}
const date = new Date(updateTimes[Math.round(val as number)] * 1000); const date = new Date(updateTimes[Math.round(val as number)] * 1000);
return date.toLocaleTimeString([], { return date.toLocaleTimeString([], {
hour12: config?.ui.time_format != "24hour", hour12: config?.ui.time_format != "24hour",
@ -94,6 +99,7 @@ export function ThresholdBarGraph({
tickAmount: 4, tickAmount: 4,
tickPlacement: "on", tickPlacement: "on",
labels: { labels: {
offsetX: -30,
formatter: formatTime, formatter: formatTime,
}, },
axisBorder: { axisBorder: {
@ -181,7 +187,7 @@ export function StorageGraph({ graphId, used, total }: StorageGraphProps) {
}, },
}, },
tooltip: { tooltip: {
theme: systemTheme || theme, show: false,
}, },
xaxis: { xaxis: {
axisBorder: { axisBorder: {
@ -199,7 +205,7 @@ export function StorageGraph({ graphId, used, total }: StorageGraphProps) {
min: 0, min: 0,
max: 100, max: 100,
}, },
}; } as ApexCharts.ApexOptions;
}, [graphId, systemTheme, theme]); }, [graphId, systemTheme, theme]);
useEffect(() => { useEffect(() => {
@ -235,3 +241,135 @@ export function StorageGraph({ graphId, used, total }: StorageGraphProps) {
</div> </div>
); );
} }
const GRAPH_COLORS = ["#5C7CFA", "#ED5CFA", "#FAD75C"];
type CameraLineGraphProps = {
graphId: string;
unit: string;
dataLabels: string[];
updateTimes: number[];
data: ApexAxisChartSeries;
};
export function CameraLineGraph({
graphId,
unit,
dataLabels,
updateTimes,
data,
}: CameraLineGraphProps) {
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const lastValues = useMemo<number[] | undefined>(() => {
if (!dataLabels || !data || data.length == 0) {
return undefined;
}
return dataLabels.map(
(_, labelIdx) =>
// @ts-expect-error y is valid
data[labelIdx].data[data[labelIdx].data.length - 1]?.y ?? 0,
) as number[];
}, [data, dataLabels]);
const { theme, systemTheme } = useTheme();
const formatTime = useCallback(
(val: unknown) => {
if (val == 0) {
return;
}
const date = new Date(updateTimes[Math.round(val as number)] * 1000);
return date.toLocaleTimeString([], {
hour12: config?.ui.time_format != "24hour",
hour: "2-digit",
minute: "2-digit",
});
},
[config, updateTimes],
);
const options = useMemo(() => {
return {
chart: {
id: graphId,
selection: {
enabled: false,
},
toolbar: {
show: false,
},
zoom: {
enabled: false,
},
},
colors: GRAPH_COLORS,
grid: {
show: false,
},
legend: {
show: false,
},
dataLabels: {
enabled: false,
},
stroke: {
width: 1,
},
tooltip: {
theme: systemTheme || theme,
},
markers: {
size: 0,
},
xaxis: {
tickAmount: 4,
tickPlacement: "between",
labels: {
offsetX: -30,
formatter: formatTime,
},
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
},
yaxis: {
show: false,
min: 0,
},
} as ApexCharts.ApexOptions;
}, [graphId, systemTheme, theme, formatTime]);
useEffect(() => {
ApexCharts.exec(graphId, "updateOptions", options, true, true);
}, [graphId, options]);
return (
<div className="w-full flex flex-col">
{lastValues && (
<div className="flex items-center gap-2.5">
{dataLabels.map((label, labelIdx) => (
<div key={label} className="flex items-center gap-1">
<MdCircle
className="size-2"
style={{ color: GRAPH_COLORS[labelIdx] }}
/>
<div className="text-xs text-muted-foreground">{label}</div>
<div className="text-xs text-primary-foreground">
{lastValues[labelIdx]}
{unit}
</div>
</div>
))}
</div>
)}
<Chart type="line" options={options} series={data} height="120" />
</div>
);
}

View File

@ -1,101 +0,0 @@
import { GraphData } from "@/types/graph";
import Chart from "react-apexcharts";
type TimelineGraphProps = {
id: string;
data: GraphData[];
start: number;
end: number;
objects: number[];
};
/**
* A graph meant to be overlaid on top of a timeline
*/
export default function TimelineGraph({
id,
data,
start,
end,
objects,
}: TimelineGraphProps) {
return (
<Chart
type="bar"
options={{
colors: [
({ dataPointIndex }: { dataPointIndex: number }) => {
if (objects.includes(dataPointIndex)) {
return "#06b6d4";
} else {
return "#991b1b";
}
},
],
chart: {
id: id,
selection: {
enabled: false,
},
toolbar: {
show: false,
},
zoom: {
enabled: false,
},
},
dataLabels: { enabled: false },
grid: {
show: false,
padding: {
bottom: 2,
top: -12,
left: -20,
right: 0,
},
},
legend: {
show: false,
position: "top",
},
plotOptions: {
bar: {
columnWidth: "100%",
barHeight: "100%",
hideZeroBarsWhenGrouped: true,
},
},
stroke: {
width: 0,
},
tooltip: {
enabled: false,
},
xaxis: {
type: "datetime",
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
labels: {
show: false,
},
min: start,
max: end,
},
yaxis: {
axisBorder: {
show: false,
},
labels: {
show: false,
},
},
}}
series={data}
height="100%"
/>
);
}

View File

@ -32,10 +32,10 @@ export function LiveListIcon({ layout }: LiveIconProps) {
return ( return (
<div className="size-full flex flex-col gap-0.5 rounded-md overflow-hidden"> <div className="size-full flex flex-col gap-0.5 rounded-md overflow-hidden">
<div <div
className={`size-full ${layout == "list" ? "bg-selected" : "bg-muted-foreground"}`} className={`size-full ${layout == "list" ? "bg-selected" : "bg-secondary-foreground"}`}
/> />
<div <div
className={`size-full ${layout == "list" ? "bg-selected" : "bg-muted-foreground"}`} className={`size-full ${layout == "list" ? "bg-selected" : "bg-secondary-foreground"}`}
/> />
</div> </div>
); );

View File

@ -12,11 +12,11 @@ import { TooltipPortal } from "@radix-ui/react-tooltip";
const variants = { const variants = {
primary: { primary: {
active: "font-bold text-white bg-selected", active: "font-bold text-white bg-selected",
inactive: "text-muted-foreground bg-secondary", inactive: "text-secondary-foreground bg-secondary",
}, },
secondary: { secondary: {
active: "font-bold text-selected", active: "font-bold text-selected",
inactive: "text-muted-foreground", inactive: "text-secondary-foreground",
}, },
}; };

View File

@ -122,8 +122,8 @@ export default function ExportDialog({
setMode("select"); setMode("select");
}} }}
> >
<FaArrowDown className="p-1 fill-secondary bg-muted-foreground rounded-md" /> <FaArrowDown className="p-1 fill-secondary bg-secondary-foreground rounded-md" />
{isDesktop && "Export"} {isDesktop && <div className="text-primary-foreground">Export</div>}
</Button> </Button>
</Trigger> </Trigger>
<Content <Content

View File

@ -24,7 +24,7 @@ export default function MobileCameraDrawer({
<Drawer open={cameraDrawer} onOpenChange={setCameraDrawer}> <Drawer open={cameraDrawer} onOpenChange={setCameraDrawer}>
<DrawerTrigger asChild> <DrawerTrigger asChild>
<Button className="rounded-lg capitalize" size="sm" variant="secondary"> <Button className="rounded-lg capitalize" size="sm" variant="secondary">
<FaVideo className="text-muted-foreground" /> <FaVideo className="text-secondary-foreground" />
</Button> </Button>
</DrawerTrigger> </DrawerTrigger>
<DrawerContent className="max-h-[75dvh] px-4 mx-1 rounded-t-2xl overflow-hidden"> <DrawerContent className="max-h-[75dvh] px-4 mx-1 rounded-t-2xl overflow-hidden">

View File

@ -137,7 +137,7 @@ export default function MobileReviewSettingsDrawer({
className="w-full flex justify-center items-center gap-2" className="w-full flex justify-center items-center gap-2"
onClick={() => setDrawerMode("export")} onClick={() => setDrawerMode("export")}
> >
<FaArrowDown className="p-1 fill-secondary bg-muted-foreground rounded-md" /> <FaArrowDown className="p-1 fill-secondary bg-secondary-foreground rounded-md" />
Export Export
</Button> </Button>
)} )}
@ -146,7 +146,7 @@ export default function MobileReviewSettingsDrawer({
className="w-full flex justify-center items-center gap-2" className="w-full flex justify-center items-center gap-2"
onClick={() => setDrawerMode("calendar")} onClick={() => setDrawerMode("calendar")}
> >
<FaCalendarAlt className="fill-muted-foreground" /> <FaCalendarAlt className="fill-secondary-foreground" />
Calendar Calendar
</Button> </Button>
)} )}
@ -155,7 +155,7 @@ export default function MobileReviewSettingsDrawer({
className="w-full flex justify-center items-center gap-2" className="w-full flex justify-center items-center gap-2"
onClick={() => setDrawerMode("filter")} onClick={() => setDrawerMode("filter")}
> >
<FaFilter className="fill-muted-foreground" /> <FaFilter className="fill-secondary-foreground" />
Filter Filter
</Button> </Button>
)} )}
@ -282,7 +282,7 @@ export default function MobileReviewSettingsDrawer({
variant="secondary" variant="secondary"
onClick={() => setDrawerMode("select")} onClick={() => setDrawerMode("select")}
> >
<FaCog className="text-muted-foreground" /> <FaCog className="text-secondary-foreground" />
</Button> </Button>
</DrawerTrigger> </DrawerTrigger>
<DrawerContent className="max-h-[80dvh] overflow-hidden flex flex-col items-center gap-2 px-4 pb-4 mx-1 rounded-t-2xl"> <DrawerContent className="max-h-[80dvh] overflow-hidden flex flex-col items-center gap-2 px-4 pb-4 mx-1 rounded-t-2xl">

View File

@ -23,7 +23,7 @@ export default function MobileTimelineDrawer({
<Drawer open={drawer} onOpenChange={setDrawer}> <Drawer open={drawer} onOpenChange={setDrawer}>
<DrawerTrigger asChild> <DrawerTrigger asChild>
<Button className="rounded-lg capitalize" size="sm" variant="secondary"> <Button className="rounded-lg capitalize" size="sm" variant="secondary">
<FaFlag className="text-muted-foreground" /> <FaFlag className="text-secondary-foreground" />
</Button> </Button>
</DrawerTrigger> </DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden flex flex-col items-center gap-2 px-4 pb-4 mx-1 rounded-t-2xl"> <DrawerContent className="max-h-[75dvh] overflow-hidden flex flex-col items-center gap-2 px-4 pb-4 mx-1 rounded-t-2xl">

View File

@ -92,7 +92,7 @@ export default function HlsVideoPlayer({
return ( return (
<TransformWrapper minScale={1.0}> <TransformWrapper minScale={1.0}>
<div <div
className={`relative w-full ${className ?? ""} ${visible ? "visible" : "hidden"}`} className={`relative ${className ?? ""} ${visible ? "visible" : "hidden"}`}
onMouseOver={ onMouseOver={
isDesktop isDesktop
? () => { ? () => {
@ -112,9 +112,11 @@ export default function HlsVideoPlayer({
<TransformComponent <TransformComponent
wrapperStyle={{ wrapperStyle={{
width: "100%", width: "100%",
height: "100%",
}} }}
contentStyle={{ contentStyle={{
width: "100%", width: "100%",
height: isMobile ? "100%" : undefined,
}} }}
> >
<video <video

View File

@ -237,8 +237,8 @@ function PlusFilterGroup({
> >
<Trigger asChild> <Trigger asChild>
<Button size="sm" className="mx-1 capitalize" variant="secondary"> <Button size="sm" className="mx-1 capitalize" variant="secondary">
<FaVideo className="md:mr-[10px] text-muted-foreground" /> <FaVideo className="md:mr-[10px] text-secondary-foreground" />
<div className="hidden md:block"> <div className="hidden md:block text-primary-foreground">
{selectedCameras == undefined {selectedCameras == undefined
? "All Cameras" ? "All Cameras"
: `${selectedCameras.length} Cameras`} : `${selectedCameras.length} Cameras`}
@ -314,8 +314,8 @@ function PlusFilterGroup({
> >
<Trigger asChild> <Trigger asChild>
<Button size="sm" className="mx-1 capitalize" variant="secondary"> <Button size="sm" className="mx-1 capitalize" variant="secondary">
<FaList className="md:mr-[10px] text-muted-foreground" /> <FaList className="md:mr-[10px] text-secondary-foreground" />
<div className="hidden md:block"> <div className="hidden md:block text-primary-foreground">
{selectedLabels == undefined {selectedLabels == undefined
? "All Labels" ? "All Labels"
: `${selectedLabels.length} Labels`} : `${selectedLabels.length} Labels`}

View File

@ -10,6 +10,7 @@ import { LuActivity, LuHardDrive } from "react-icons/lu";
import { FaVideo } from "react-icons/fa"; import { FaVideo } from "react-icons/fa";
import Logo from "@/components/Logo"; import Logo from "@/components/Logo";
import useOptimisticState from "@/hooks/use-optimistic-state"; import useOptimisticState from "@/hooks/use-optimistic-state";
import CameraMetrics from "@/views/system/CameraMetrics";
const metrics = ["general", "storage", "cameras"] as const; const metrics = ["general", "storage", "cameras"] as const;
type SystemMetric = (typeof metrics)[number]; type SystemMetric = (typeof metrics)[number];
@ -82,127 +83,14 @@ function System() {
/> />
)} )}
{page == "storage" && <StorageMetrics setLastUpdated={setLastUpdated} />} {page == "storage" && <StorageMetrics setLastUpdated={setLastUpdated} />}
{page == "cameras" && (
<CameraMetrics
lastUpdated={lastUpdated}
setLastUpdated={setLastUpdated}
/>
)}
</div> </div>
); );
} }
export default System; export default System;
/**
* const cameraCpuSeries = useMemo(() => {
if (!statsHistory || statsHistory.length == 0) {
return {};
}
const series: {
[cam: string]: {
[key: string]: { name: string; data: { x: object; y: string }[] };
};
} = {};
statsHistory.forEach((stats, statsIdx) => {
if (!stats) {
return;
}
const statTime = new Date(stats.service.last_updated * 1000);
Object.entries(stats.cameras).forEach(([key, camStats]) => {
if (!config?.cameras[key].enabled) {
return;
}
if (!(key in series)) {
const camName = key.replaceAll("_", " ");
series[key] = {};
series[key]["ffmpeg"] = { name: `${camName} ffmpeg`, data: [] };
series[key]["capture"] = { name: `${camName} capture`, data: [] };
series[key]["detect"] = { name: `${camName} detect`, data: [] };
}
series[key]["ffmpeg"].data.push({
x: statsIdx,
y: stats.cpu_usages[camStats.ffmpeg_pid.toString()]?.cpu ?? 0.0,
});
series[key]["capture"].data.push({
x: statsIdx,
y: stats.cpu_usages[camStats.capture_pid?.toString()]?.cpu ?? 0,
});
series[key]["detect"].data.push({
x: statsIdx,
y: stats.cpu_usages[camStats.pid.toString()].cpu,
});
});
});
return series;
}, [statsHistory]);
const cameraFpsSeries = useMemo(() => {
if (!statsHistory) {
return {};
}
const series: {
[cam: string]: {
[key: string]: { name: string; data: { x: object; y: number }[] };
};
} = {};
statsHistory.forEach((stats, statsIdx) => {
if (!stats) {
return;
}
const statTime = new Date(stats.service.last_updated * 1000);
Object.entries(stats.cameras).forEach(([key, camStats]) => {
if (!(key in series)) {
const camName = key.replaceAll("_", " ");
series[key] = {};
series[key]["det"] = { name: `${camName} detections`, data: [] };
series[key]["skip"] = {
name: `${camName} skipped detections`,
data: [],
};
}
series[key]["det"].data.push({
x: statsIdx,
y: camStats.detection_fps,
});
series[key]["skip"].data.push({
x: statsIdx,
y: camStats.skipped_fps,
});
});
});
return series;
}, [statsHistory]);
*
* <div className="bg-primary rounded-2xl flex-col">
<Heading as="h4">Cameras</Heading>
<div className="grid grid-cols-1 sm:grid-cols-2">
{config &&
Object.values(config.cameras).map((camera) => {
if (camera.enabled) {
return (
<div key={camera.name} className="grid grid-cols-2">
<ThresholdBarGraph
graphId={`${camera.name}-cpu`}
title={`${camera.name.replaceAll("_", " ")} CPU`}
unit="%"
data={Object.values(cameraCpuSeries[camera.name] || {})}
/>
<ThresholdBarGraph
graphId={`${camera.name}-fps`}
title={`${camera.name.replaceAll("_", " ")} FPS`}
unit=""
data={Object.values(cameraFpsSeries[camera.name] || {})}
/>
</div>
);
}
return null;
})}
</div>
*/

View File

@ -42,6 +42,7 @@ import VideoControls from "@/components/player/VideoControls";
import { TimeRange } from "@/types/timeline"; import { TimeRange } from "@/types/timeline";
import { useCameraMotionNextTimestamp } from "@/hooks/use-camera-activity"; import { useCameraMotionNextTimestamp } from "@/hooks/use-camera-activity";
import useOptimisticState from "@/hooks/use-optimistic-state"; import useOptimisticState from "@/hooks/use-optimistic-state";
import { Skeleton } from "@/components/ui/skeleton";
type EventViewProps = { type EventViewProps = {
reviews?: ReviewSegment[]; reviews?: ReviewSegment[];
@ -837,8 +838,9 @@ function MotionReview({
const detectionType = getDetectionType(camera.name); const detectionType = getDetectionType(camera.name);
return ( return (
<div key={camera.name} className={`relative ${spans}`}> <div key={camera.name} className={`relative ${spans}`}>
{motionData ? (
<>
<PreviewPlayer <PreviewPlayer
key={camera.name}
className={`rounded-2xl ${spans} ${grow}`} className={`rounded-2xl ${spans} ${grow}`}
camera={camera.name} camera={camera.name}
timeRange={currentTimeRange} timeRange={currentTimeRange}
@ -851,20 +853,27 @@ function MotionReview({
onClick={() => onClick={() =>
onOpenRecording({ onOpenRecording({
camera: camera.name, camera: camera.name,
startTime: Math.min(currentTime, Date.now() / 1000 - 10), startTime: currentTime,
severity: "significant_motion", severity: "significant_motion",
}) })
} }
/> />
<div <div
className={`review-item-ring pointer-events-none z-10 absolute rounded-lg inset-0 size-full -outline-offset-[2.8px] outline outline-[3px] ${detectionType ? `outline-severity_${detectionType} shadow-severity_${detectionType}` : "outline-transparent duration-500"}`} className={`review-item-ring pointer-events-none z-20 absolute rounded-lg inset-0 size-full -outline-offset-[2.8px] outline outline-[3px] ${detectionType ? `outline-severity_${detectionType} shadow-severity_${detectionType}` : "outline-transparent duration-500"}`}
/> />
</>
) : (
<Skeleton
className={`rounded-2xl size-full ${spans} ${grow}`}
/>
)}
</div> </div>
); );
})} })}
</div> </div>
</div> </div>
<div className="w-[55px] md:w-[100px] overflow-y-auto no-scrollbar"> <div className="w-[55px] md:w-[100px] overflow-y-auto no-scrollbar">
{motionData ? (
<MotionReviewTimeline <MotionReviewTimeline
segmentDuration={segmentDuration} segmentDuration={segmentDuration}
timestampSpread={15} timestampSpread={15}
@ -887,8 +896,12 @@ function MotionReview({
}} }}
dense={isMobile} dense={isMobile}
/> />
) : (
<Skeleton className="size-full" />
)}
</div> </div>
{!scrubbing && (
<VideoControls <VideoControls
className="absolute bottom-16 left-1/2 -translate-x-1/2" className="absolute bottom-16 left-1/2 -translate-x-1/2"
features={{ features={{
@ -918,6 +931,7 @@ function MotionReview({
onSetPlaybackRate={setPlaybackRate} onSetPlaybackRate={setPlaybackRate}
show={currentTime < timeRange.before - 4} show={currentTime < timeRange.before - 4}
/> />
)}
</> </>
); );
} }

View File

@ -257,7 +257,7 @@ export function RecordingView({
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> >
<IoMdArrowRoundBack className="size-5" size="small" /> <IoMdArrowRoundBack className="size-5" size="small" />
{isDesktop && "Back"} {isDesktop && <div className="text-primary-foreground">Back</div>}
</Button> </Button>
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<MobileCameraDrawer <MobileCameraDrawer

View File

@ -133,7 +133,7 @@ export default function LiveBirdseyeView() {
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> >
<IoMdArrowBack className="size-5" /> <IoMdArrowBack className="size-5" />
{isDesktop && "Back"} {isDesktop && <div className="text-primary-foreground">Back</div>}
</Button> </Button>
) : ( ) : (
<div /> <div />

View File

@ -228,7 +228,9 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
onClick={() => navigate(-1)} onClick={() => navigate(-1)}
> >
<IoMdArrowRoundBack className="size-5" /> <IoMdArrowRoundBack className="size-5" />
{isDesktop && "Back"} {isDesktop && (
<div className="text-primary-foreground">Back</div>
)}
</Button> </Button>
<Button <Button
className="flex items-center gap-2.5 rounded-lg" className="flex items-center gap-2.5 rounded-lg"
@ -248,7 +250,9 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) {
}} }}
> >
<LuHistory className="size-5" /> <LuHistory className="size-5" />
{isDesktop && "History"} {isDesktop && (
<div className="text-primary-foreground">History</div>
)}
</Button> </Button>
</div> </div>
) : ( ) : (

View File

@ -139,7 +139,7 @@ export default function LiveDashboardView({
className={`p-1 ${ className={`p-1 ${
layout == "grid" layout == "grid"
? "bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60" ? "bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
: "bg-muted" : "bg-secondary"
}`} }`}
size="xs" size="xs"
onClick={() => setLayout("grid")} onClick={() => setLayout("grid")}
@ -150,7 +150,7 @@ export default function LiveDashboardView({
className={`p-1 ${ className={`p-1 ${
layout == "list" layout == "list"
? "bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60" ? "bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60"
: "bg-muted" : "bg-secondary"
}`} }`}
size="xs" size="xs"
onClick={() => setLayout("list")} onClick={() => setLayout("list")}

View File

@ -0,0 +1,203 @@
import { useFrigateStats } from "@/api/ws";
import { CameraLineGraph } from "@/components/graph/SystemGraph";
import { Skeleton } from "@/components/ui/skeleton";
import { FrigateConfig } from "@/types/frigateConfig";
import { FrigateStats } from "@/types/stats";
import { useEffect, useMemo, useState } from "react";
import useSWR from "swr";
type CameraMetricsProps = {
lastUpdated: number;
setLastUpdated: (last: number) => void;
};
export default function CameraMetrics({
lastUpdated,
setLastUpdated,
}: CameraMetricsProps) {
const { data: config } = useSWR<FrigateConfig>("config");
// stats
const { data: initialStats } = useSWR<FrigateStats[]>(
["stats/history", { keys: "cpu_usages,cameras,service" }],
{
revalidateOnFocus: false,
},
);
const [statsHistory, setStatsHistory] = useState<FrigateStats[]>([]);
const { payload: updatedStats } = useFrigateStats();
useEffect(() => {
if (initialStats == undefined || initialStats.length == 0) {
return;
}
if (statsHistory.length == 0) {
setStatsHistory(initialStats);
return;
}
if (!updatedStats) {
return;
}
if (updatedStats.service.last_updated > lastUpdated) {
setStatsHistory([...statsHistory, updatedStats]);
setLastUpdated(Date.now() / 1000);
}
}, [initialStats, updatedStats, statsHistory, lastUpdated, setLastUpdated]);
// timestamps
const updateTimes = useMemo(
() => statsHistory.map((stats) => stats.service.last_updated),
[statsHistory],
);
// stats data
const cameraCpuSeries = useMemo(() => {
if (!statsHistory || statsHistory.length == 0) {
return {};
}
const series: {
[cam: string]: {
[key: string]: { name: string; data: { x: number; y: string }[] };
};
} = {};
statsHistory.forEach((stats, statsIdx) => {
if (!stats) {
return;
}
Object.entries(stats.cameras).forEach(([key, camStats]) => {
if (!config?.cameras[key].enabled) {
return;
}
if (!(key in series)) {
const camName = key.replaceAll("_", " ");
series[key] = {};
series[key]["ffmpeg"] = { name: `${camName} ffmpeg`, data: [] };
series[key]["capture"] = { name: `${camName} capture`, data: [] };
series[key]["detect"] = { name: `${camName} detect`, data: [] };
}
series[key]["ffmpeg"].data.push({
x: statsIdx,
y: stats.cpu_usages[camStats.ffmpeg_pid.toString()]?.cpu ?? 0.0,
});
series[key]["capture"].data.push({
x: statsIdx,
y: stats.cpu_usages[camStats.capture_pid?.toString()]?.cpu ?? 0,
});
series[key]["detect"].data.push({
x: statsIdx,
y: stats.cpu_usages[camStats.pid.toString()].cpu,
});
});
});
return series;
}, [config, statsHistory]);
const cameraFpsSeries = useMemo(() => {
if (!statsHistory) {
return {};
}
const series: {
[cam: string]: {
[key: string]: { name: string; data: { x: number; y: number }[] };
};
} = {};
statsHistory.forEach((stats, statsIdx) => {
if (!stats) {
return;
}
Object.entries(stats.cameras).forEach(([key, camStats]) => {
if (!(key in series)) {
const camName = key.replaceAll("_", " ");
series[key] = {};
series[key]["det"] = {
name: `${camName} detections per second`,
data: [],
};
series[key]["skip"] = {
name: `${camName} skipped detections per second`,
data: [],
};
}
series[key]["det"].data.push({
x: statsIdx,
y: camStats.detection_fps,
});
series[key]["skip"].data.push({
x: statsIdx,
y: camStats.skipped_fps,
});
});
});
return series;
}, [statsHistory]);
return (
<div className="size-full mt-4 flex flex-col overflow-y-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{config &&
Object.values(config.cameras).map((camera) => {
if (camera.enabled) {
return (
<div className="w-full flex flex-col">
<div className="mb-6 capitalize">
{camera.name.replaceAll("_", " ")}
</div>
<div key={camera.name} className="grid sm:grid-cols-2 gap-2">
{Object.keys(cameraCpuSeries).includes(camera.name) ? (
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
<div className="mb-5">CPU</div>
<CameraLineGraph
graphId={`${camera.name}-cpu`}
unit="%"
dataLabels={["ffmpeg", "capture", "detect"]}
updateTimes={updateTimes}
data={Object.values(
cameraCpuSeries[camera.name] || {},
)}
/>
</div>
) : (
<Skeleton className="size-full aspect-video" />
)}
{Object.keys(cameraFpsSeries).includes(camera.name) ? (
<div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
<div className="mb-5">DPS</div>
<CameraLineGraph
graphId={`${camera.name}-dps`}
unit=" DPS"
dataLabels={["detect", "skipped"]}
updateTimes={updateTimes}
data={Object.values(
cameraFpsSeries[camera.name] || {},
)}
/>
</div>
) : (
<Skeleton className="size-full aspect-video" />
)}
</div>
</div>
);
}
return null;
})}
</div>
</div>
);
}

View File

@ -61,6 +61,16 @@ export default function GeneralMetrics({
} }
}, [initialStats, updatedStats, statsHistory, lastUpdated, setLastUpdated]); }, [initialStats, updatedStats, statsHistory, lastUpdated, setLastUpdated]);
const canGetGpuInfo = useMemo(
() =>
statsHistory.length > 0 &&
Object.keys(statsHistory[0]?.gpu_usages ?? {}).filter(
(key) =>
key == "amd-vaapi" || key == "intel-vaapi" || key == "intel-qsv",
).length > 0,
[statsHistory],
);
// timestamps // timestamps
const updateTimes = useMemo( const updateTimes = useMemo(
@ -274,8 +284,8 @@ export default function GeneralMetrics({
Detectors Detectors
</div> </div>
<div className="w-full mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2"> <div className="w-full mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
{detInferenceTimeSeries.length != 0 ? ( {statsHistory.length != 0 ? (
<div className="p-2.5 bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
<div className="mb-5">Detector Inference Speed</div> <div className="mb-5">Detector Inference Speed</div>
{detInferenceTimeSeries.map((series) => ( {detInferenceTimeSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph
@ -293,7 +303,7 @@ export default function GeneralMetrics({
<Skeleton className="w-full aspect-video" /> <Skeleton className="w-full aspect-video" />
)} )}
{statsHistory.length != 0 ? ( {statsHistory.length != 0 ? (
<div className="p-2.5 bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
<div className="mb-5">Detector CPU Usage</div> <div className="mb-5">Detector CPU Usage</div>
{detCpuSeries.map((series) => ( {detCpuSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph
@ -311,7 +321,7 @@ export default function GeneralMetrics({
<Skeleton className="w-full aspect-video" /> <Skeleton className="w-full aspect-video" />
)} )}
{statsHistory.length != 0 ? ( {statsHistory.length != 0 ? (
<div className="p-2.5 bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
<div className="mb-5">Detector Memory Usage</div> <div className="mb-5">Detector Memory Usage</div>
{detMemSeries.map((series) => ( {detMemSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph
@ -336,13 +346,7 @@ export default function GeneralMetrics({
<div className="text-muted-foreground text-sm font-medium"> <div className="text-muted-foreground text-sm font-medium">
GPUs GPUs
</div> </div>
{statsHistory.length > 0 && {canGetGpuInfo && (
Object.keys(statsHistory[0].gpu_usages ?? {}).filter(
(key) =>
key == "amd-vaapi" ||
key == "intel-vaapi" ||
key == "intel-qsv",
).length > 0 && (
<Button <Button
className="cursor-pointer" className="cursor-pointer"
variant="secondary" variant="secondary"
@ -355,7 +359,7 @@ export default function GeneralMetrics({
</div> </div>
<div className=" mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2"> <div className=" mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2">
{statsHistory.length != 0 ? ( {statsHistory.length != 0 ? (
<div className="p-2.5 bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
<div className="mb-5">GPU Usage</div> <div className="mb-5">GPU Usage</div>
{gpuSeries.map((series) => ( {gpuSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph
@ -373,7 +377,7 @@ export default function GeneralMetrics({
<Skeleton className="w-full aspect-video" /> <Skeleton className="w-full aspect-video" />
)} )}
{statsHistory.length != 0 ? ( {statsHistory.length != 0 ? (
<div className="p-2.5 bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
<div className="mb-5">GPU Memory</div> <div className="mb-5">GPU Memory</div>
{gpuMemSeries.map((series) => ( {gpuMemSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph
@ -399,7 +403,7 @@ export default function GeneralMetrics({
</div> </div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2"> <div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-2">
{statsHistory.length != 0 ? ( {statsHistory.length != 0 ? (
<div className="p-2.5 bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
<div className="mb-5">Process CPU Usage</div> <div className="mb-5">Process CPU Usage</div>
{otherProcessCpuSeries.map((series) => ( {otherProcessCpuSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph
@ -417,7 +421,7 @@ export default function GeneralMetrics({
<Skeleton className="w-full aspect-tall" /> <Skeleton className="w-full aspect-tall" />
)} )}
{statsHistory.length != 0 ? ( {statsHistory.length != 0 ? (
<div className="p-2.5 bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
<div className="mb-5">Process Memory Usage</div> <div className="mb-5">Process Memory Usage</div>
{otherProcessMemSeries.map((series) => ( {otherProcessMemSeries.map((series) => (
<ThresholdBarGraph <ThresholdBarGraph

View File

@ -47,7 +47,7 @@ export default function StorageMetrics({
General Storage General Storage
</div> </div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2"> <div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="p-2.5 bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
<div className="mb-5">Recordings</div> <div className="mb-5">Recordings</div>
<StorageGraph <StorageGraph
graphId="general-recordings" graphId="general-recordings"
@ -55,7 +55,7 @@ export default function StorageMetrics({
total={totalStorage.total} total={totalStorage.total}
/> />
</div> </div>
<div className="p-2.5 bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
<div className="mb-5">/tmp/cache</div> <div className="mb-5">/tmp/cache</div>
<StorageGraph <StorageGraph
graphId="general-cache" graphId="general-cache"
@ -63,7 +63,7 @@ export default function StorageMetrics({
total={stats.service.storage["/tmp/cache"]["total"]} total={stats.service.storage["/tmp/cache"]["total"]}
/> />
</div> </div>
<div className="p-2.5 bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
<div className="mb-5">/dev/shm</div> <div className="mb-5">/dev/shm</div>
<StorageGraph <StorageGraph
graphId="general-shared-memory" graphId="general-shared-memory"
@ -77,7 +77,7 @@ export default function StorageMetrics({
</div> </div>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2"> <div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-2">
{Object.keys(cameraStorage).map((camera) => ( {Object.keys(cameraStorage).map((camera) => (
<div className="p-2.5 bg-primary rounded-2xl flex-col"> <div className="p-2.5 bg-secondary dark:bg-primary rounded-2xl flex-col">
<div className="mb-5 capitalize">{camera.replaceAll("_", " ")}</div> <div className="mb-5 capitalize">{camera.replaceAll("_", " ")}</div>
<StorageGraph <StorageGraph
graphId={`${camera}-storage`} graphId={`${camera}-storage`}

View File

@ -27,8 +27,8 @@
--secondary: hsl(0, 0%, 96%); --secondary: hsl(0, 0%, 96%);
--secondary: 0 0% 96%; --secondary: 0 0% 96%;
--secondary-foreground: hsl(0, 0%, 32%); --secondary-foreground: hsl(0, 0%, 83%);
--secondary-foreground: 0 0% 32%; --secondary-foreground: 0 0% 83%;
--secondary-highlight: hsl(0, 0%, 94%); --secondary-highlight: hsl(0, 0%, 94%);
--secondary-highlight: 0 0% 94%; --secondary-highlight: 0 0% 94%;
@ -112,8 +112,8 @@
--secondary: hsl(0, 0%, 15%); --secondary: hsl(0, 0%, 15%);
--secondary: 0 0% 15%; --secondary: 0 0% 15%;
--secondary-foreground: hsl(0, 0%, 83%); --secondary-foreground: hsl(0, 0%, 32%);
--secondary-foreground: 0 0% 83%; --secondary-foreground: 0 0% 32%;
--secondary-highlight: hsl(0, 0%, 25%); --secondary-highlight: hsl(0, 0%, 25%);
--secondary-highlight: 0 0% 25%; --secondary-highlight: 0 0% 25%;