diff --git a/web/src/components/graph/SystemGraph.tsx b/web/src/components/graph/SystemGraph.tsx index a6ca219b0..870f73ddc 100644 --- a/web/src/components/graph/SystemGraph.tsx +++ b/web/src/components/graph/SystemGraph.tsx @@ -3,6 +3,7 @@ import { FrigateConfig } from "@/types/frigateConfig"; import { Threshold } from "@/types/graph"; import { useCallback, useEffect, useMemo } from "react"; import Chart from "react-apexcharts"; +import { MdCircle } from "react-icons/md"; import useSWR from "swr"; type ThresholdBarGraphProps = { @@ -35,6 +36,10 @@ export function ThresholdBarGraph({ 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", @@ -94,6 +99,7 @@ export function ThresholdBarGraph({ tickAmount: 4, tickPlacement: "on", labels: { + offsetX: -30, formatter: formatTime, }, axisBorder: { @@ -235,3 +241,135 @@ export function StorageGraph({ graphId, used, total }: StorageGraphProps) { ); } + +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/pages/System.tsx b/web/src/pages/System.tsx index a0fd69b95..a38dc1b18 100644 --- a/web/src/pages/System.tsx +++ b/web/src/pages/System.tsx @@ -10,6 +10,7 @@ import { LuActivity, LuHardDrive } from "react-icons/lu"; import { FaVideo } from "react-icons/fa"; import Logo from "@/components/Logo"; import useOptimisticState from "@/hooks/use-optimistic-state"; +import CameraMetrics from "@/views/system/CameraMetrics"; const metrics = ["general", "storage", "cameras"] as const; type SystemMetric = (typeof metrics)[number]; @@ -82,127 +83,14 @@ function System() { /> )} {page == "storage" && } + {page == "cameras" && ( + + )} ); } 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]); - * - *
- Cameras -
- {config && - Object.values(config.cameras).map((camera) => { - if (camera.enabled) { - return ( -
- - -
- ); - } - - return null; - })} -
- */ diff --git a/web/src/views/system/CameraMetrics.tsx b/web/src/views/system/CameraMetrics.tsx new file mode 100644 index 000000000..621f910cd --- /dev/null +++ b/web/src/views/system/CameraMetrics.tsx @@ -0,0 +1,187 @@ +import { useFrigateStats } from "@/api/ws"; +import { CameraLineGraph } from "@/components/graph/SystemGraph"; +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`, 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]); + + return ( +
+
Cameras
+
+ {config && + Object.values(config.cameras).map((camera) => { + if (camera.enabled) { + return ( +
+
+
+ {camera.name.replaceAll("_", " ")} CPU +
+ +
+
+
+ {camera.name.replaceAll("_", " ")} FPS +
+ +
+
+ ); + } + + return null; + })} +
+
+ ); +}