diff --git a/frigate/app.py b/frigate/app.py index 78a2b0132..3c173a37a 100644 --- a/frigate/app.py +++ b/frigate/app.py @@ -499,7 +499,7 @@ class FrigateApp: ) audio_process.daemon = True 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}") def start_timeline_processor(self) -> None: diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index b8ed05b7b..ec0ba89f4 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -87,7 +87,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { className={ group == "default" ? "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" onClick={() => (group ? setGroup("default", true) : null)} @@ -109,7 +109,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { className={ group == name ? "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" onClick={() => setGroup(name, group != "default")} diff --git a/web/src/components/filter/ReviewActionGroup.tsx b/web/src/components/filter/ReviewActionGroup.tsx index bc1951f75..3ab8684f4 100644 --- a/web/src/components/filter/ReviewActionGroup.tsx +++ b/web/src/components/filter/ReviewActionGroup.tsx @@ -35,7 +35,7 @@ export default function ReviewActionGroup({ }, [selectedReviews, setSelectedReviews, pullLatestData]); return ( -
+
{`${selectedReviews.length} selected`}
{"|"}
@@ -58,7 +58,7 @@ export default function ReviewActionGroup({ }} > - {isDesktop && "Export"} + {isDesktop &&
Export
} )}
diff --git a/web/src/components/filter/ReviewFilterGroup.tsx b/web/src/components/filter/ReviewFilterGroup.tsx index f5b09836b..a3c3fadbc 100644 --- a/web/src/components/filter/ReviewFilterGroup.tsx +++ b/web/src/components/filter/ReviewFilterGroup.tsx @@ -223,8 +223,8 @@ function CamerasFilterButton({ variant="secondary" size="sm" > - -
+ +
{selectedCameras == undefined ? "All Cameras" : `${selectedCameras.length} Cameras`} @@ -368,7 +368,7 @@ function ShowReviewFilter({ ); return ( <> -
+
- -
+ +
{day == undefined ? "Last 24 Hours" : selectedDate}
@@ -473,8 +473,8 @@ function GeneralFilterButton({ const trigger = ( ); const content = ( @@ -546,7 +546,7 @@ export function GeneralFilterContent({
); } + +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("config", { + revalidateOnFocus: false, + }); + + const lastValues = useMemo(() => { + 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 ( +
+ {lastValues && ( +
+ {dataLabels.map((label, labelIdx) => ( +
+ +
{label}
+
+ {lastValues[labelIdx]} + {unit} +
+
+ ))} +
+ )} + +
+ ); +} diff --git a/web/src/components/graph/TimelineGraph.tsx b/web/src/components/graph/TimelineGraph.tsx deleted file mode 100644 index 73b9b5fb6..000000000 --- a/web/src/components/graph/TimelineGraph.tsx +++ /dev/null @@ -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 ( - { - 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%" - /> - ); -} diff --git a/web/src/components/icons/LiveIcons.tsx b/web/src/components/icons/LiveIcons.tsx index dc491b612..fbac271ef 100644 --- a/web/src/components/icons/LiveIcons.tsx +++ b/web/src/components/icons/LiveIcons.tsx @@ -32,10 +32,10 @@ export function LiveListIcon({ layout }: LiveIconProps) { return (
); diff --git a/web/src/components/navigation/NavItem.tsx b/web/src/components/navigation/NavItem.tsx index 37436ffde..1b42e5f7b 100644 --- a/web/src/components/navigation/NavItem.tsx +++ b/web/src/components/navigation/NavItem.tsx @@ -12,11 +12,11 @@ import { TooltipPortal } from "@radix-ui/react-tooltip"; const variants = { primary: { active: "font-bold text-white bg-selected", - inactive: "text-muted-foreground bg-secondary", + inactive: "text-secondary-foreground bg-secondary", }, secondary: { active: "font-bold text-selected", - inactive: "text-muted-foreground", + inactive: "text-secondary-foreground", }, }; diff --git a/web/src/components/overlay/ExportDialog.tsx b/web/src/components/overlay/ExportDialog.tsx index 5475dd00c..6468c2d16 100644 --- a/web/src/components/overlay/ExportDialog.tsx +++ b/web/src/components/overlay/ExportDialog.tsx @@ -122,8 +122,8 @@ export default function ExportDialog({ setMode("select"); }} > - - {isDesktop && "Export"} + + {isDesktop &&
Export
} diff --git a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx index 0a3319918..26c6d9bbc 100644 --- a/web/src/components/overlay/MobileReviewSettingsDrawer.tsx +++ b/web/src/components/overlay/MobileReviewSettingsDrawer.tsx @@ -137,7 +137,7 @@ export default function MobileReviewSettingsDrawer({ className="w-full flex justify-center items-center gap-2" onClick={() => setDrawerMode("export")} > - + Export )} @@ -146,7 +146,7 @@ export default function MobileReviewSettingsDrawer({ className="w-full flex justify-center items-center gap-2" onClick={() => setDrawerMode("calendar")} > - + Calendar )} @@ -155,7 +155,7 @@ export default function MobileReviewSettingsDrawer({ className="w-full flex justify-center items-center gap-2" onClick={() => setDrawerMode("filter")} > - + Filter )} @@ -282,7 +282,7 @@ export default function MobileReviewSettingsDrawer({ variant="secondary" onClick={() => setDrawerMode("select")} > - + diff --git a/web/src/components/overlay/MobileTimelineDrawer.tsx b/web/src/components/overlay/MobileTimelineDrawer.tsx index b29fde559..806651f1a 100644 --- a/web/src/components/overlay/MobileTimelineDrawer.tsx +++ b/web/src/components/overlay/MobileTimelineDrawer.tsx @@ -23,7 +23,7 @@ export default function MobileTimelineDrawer({ diff --git a/web/src/pages/SubmitPlus.tsx b/web/src/pages/SubmitPlus.tsx index bf9f344f8..39e0646e4 100644 --- a/web/src/pages/SubmitPlus.tsx +++ b/web/src/pages/SubmitPlus.tsx @@ -237,8 +237,8 @@ function PlusFilterGroup({ >
navigate(-1)} > - {isDesktop && "Back"} + {isDesktop &&
Back
} ) : (
diff --git a/web/src/views/live/LiveCameraView.tsx b/web/src/views/live/LiveCameraView.tsx index 9480da644..08a624581 100644 --- a/web/src/views/live/LiveCameraView.tsx +++ b/web/src/views/live/LiveCameraView.tsx @@ -228,7 +228,9 @@ export default function LiveCameraView({ camera }: LiveCameraViewProps) { onClick={() => navigate(-1)} > - {isDesktop && "Back"} + {isDesktop && ( +
Back
+ )}
) : ( diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index 65e06d185..c9f775da7 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -139,7 +139,7 @@ export default function LiveDashboardView({ className={`p-1 ${ layout == "grid" ? "bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60" - : "bg-muted" + : "bg-secondary" }`} size="xs" onClick={() => setLayout("grid")} @@ -150,7 +150,7 @@ export default function LiveDashboardView({ className={`p-1 ${ layout == "list" ? "bg-blue-900 focus:bg-blue-900 bg-opacity-60 focus:bg-opacity-60" - : "bg-muted" + : "bg-secondary" }`} size="xs" onClick={() => setLayout("list")} diff --git a/web/src/views/system/CameraMetrics.tsx b/web/src/views/system/CameraMetrics.tsx new file mode 100644 index 000000000..b9b8475a9 --- /dev/null +++ b/web/src/views/system/CameraMetrics.tsx @@ -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("config"); + + // stats + + const { data: initialStats } = useSWR( + ["stats/history", { keys: "cpu_usages,cameras,service" }], + { + revalidateOnFocus: false, + }, + ); + + const [statsHistory, setStatsHistory] = useState([]); + 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 ( +
+
+ {config && + Object.values(config.cameras).map((camera) => { + if (camera.enabled) { + return ( +
+
+ {camera.name.replaceAll("_", " ")} +
+
+ {Object.keys(cameraCpuSeries).includes(camera.name) ? ( +
+
CPU
+ +
+ ) : ( + + )} + {Object.keys(cameraFpsSeries).includes(camera.name) ? ( +
+
DPS
+ +
+ ) : ( + + )} +
+
+ ); + } + + return null; + })} +
+
+ ); +} diff --git a/web/src/views/system/GeneralMetrics.tsx b/web/src/views/system/GeneralMetrics.tsx index ba486b2e6..52eaa42f5 100644 --- a/web/src/views/system/GeneralMetrics.tsx +++ b/web/src/views/system/GeneralMetrics.tsx @@ -61,6 +61,16 @@ export default function GeneralMetrics({ } }, [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 const updateTimes = useMemo( @@ -274,8 +284,8 @@ export default function GeneralMetrics({ Detectors
- {detInferenceTimeSeries.length != 0 ? ( -
+ {statsHistory.length != 0 ? ( +
Detector Inference Speed
{detInferenceTimeSeries.map((series) => ( )} {statsHistory.length != 0 ? ( -
+
Detector CPU Usage
{detCpuSeries.map((series) => ( )} {statsHistory.length != 0 ? ( -
+
Detector Memory Usage
{detMemSeries.map((series) => ( GPUs
- {statsHistory.length > 0 && - Object.keys(statsHistory[0].gpu_usages ?? {}).filter( - (key) => - key == "amd-vaapi" || - key == "intel-vaapi" || - key == "intel-qsv", - ).length > 0 && ( - - )} + {canGetGpuInfo && ( + + )}
{statsHistory.length != 0 ? ( -
+
GPU Usage
{gpuSeries.map((series) => ( )} {statsHistory.length != 0 ? ( -
+
GPU Memory
{gpuMemSeries.map((series) => (
{statsHistory.length != 0 ? ( -
+
Process CPU Usage
{otherProcessCpuSeries.map((series) => ( )} {statsHistory.length != 0 ? ( -
+
Process Memory Usage
{otherProcessMemSeries.map((series) => (
-
+
Recordings
-
+
/tmp/cache
-
+
/dev/shm
{Object.keys(cameraStorage).map((camera) => ( -
+
{camera.replaceAll("_", " ")}