diff --git a/web/src/components/graph/CameraGraph.tsx b/web/src/components/graph/CameraGraph.tsx new file mode 100644 index 000000000..31fef288b --- /dev/null +++ b/web/src/components/graph/CameraGraph.tsx @@ -0,0 +1,138 @@ +import { useTheme } from "@/context/theme-provider"; +import { FrigateConfig } from "@/types/frigateConfig"; +import { useCallback, useEffect, useMemo } from "react"; +import Chart from "react-apexcharts"; +import { isMobileOnly } from "react-device-detect"; +import { MdCircle } from "react-icons/md"; +import useSWR from "swr"; + +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) => { + 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: isMobileOnly ? 3 : 4, + tickPlacement: "on", + labels: { + rotate: 0, + formatter: formatTime, + }, + axisBorder: { + show: false, + }, + axisTicks: { + show: false, + }, + }, + yaxis: { + show: true, + labels: { + formatter: (val: number) => Math.ceil(val).toString(), + }, + 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/StorageGraph.tsx b/web/src/components/graph/StorageGraph.tsx new file mode 100644 index 000000000..3f9bdbf31 --- /dev/null +++ b/web/src/components/graph/StorageGraph.tsx @@ -0,0 +1,120 @@ +import { useTheme } from "@/context/theme-provider"; +import { useEffect, useMemo } from "react"; +import Chart from "react-apexcharts"; + +const getUnitSize = (MB: number) => { + if (MB === null || isNaN(MB) || MB < 0) return "Invalid number"; + if (MB < 1024) return `${MB.toFixed(2)} MiB`; + if (MB < 1048576) return `${(MB / 1024).toFixed(2)} GiB`; + + return `${(MB / 1048576).toFixed(2)} TiB`; +}; + +type StorageGraphProps = { + graphId: string; + used: number; + total: number; +}; +export function StorageGraph({ graphId, used, total }: StorageGraphProps) { + const { theme, systemTheme } = useTheme(); + + const options = useMemo(() => { + return { + chart: { + id: graphId, + background: (systemTheme || theme) == "dark" ? "#404040" : "#E5E5E5", + selection: { + enabled: false, + }, + toolbar: { + show: false, + }, + zoom: { + enabled: false, + }, + }, + grid: { + show: false, + padding: { + bottom: -40, + top: -60, + left: -20, + right: 0, + }, + }, + legend: { + show: false, + }, + dataLabels: { + enabled: false, + }, + plotOptions: { + bar: { + horizontal: true, + }, + }, + states: { + active: { + filter: { + type: "none", + }, + }, + hover: { + filter: { + type: "none", + }, + }, + }, + tooltip: { + enabled: false, + }, + xaxis: { + axisBorder: { + show: false, + }, + axisTicks: { + show: false, + }, + labels: { + show: false, + }, + }, + yaxis: { + show: false, + min: 0, + max: 100, + }, + } as ApexCharts.ApexOptions; + }, [graphId, systemTheme, theme]); + + useEffect(() => { + ApexCharts.exec(graphId, "updateOptions", options, true, true); + }, [graphId, options]); + + return ( +
+
+
+
{getUnitSize(used)}
+
/
+
+ {getUnitSize(total)} +
+
+
+ {Math.round((used / total) * 100)}% +
+
+
+ +
+
+ ); +} diff --git a/web/src/components/graph/SystemGraph.tsx b/web/src/components/graph/SystemGraph.tsx index 987d5d889..3f5bfead3 100644 --- a/web/src/components/graph/SystemGraph.tsx +++ b/web/src/components/graph/SystemGraph.tsx @@ -4,7 +4,6 @@ import { Threshold } from "@/types/graph"; import { useCallback, useEffect, useMemo } from "react"; import Chart from "react-apexcharts"; import { isMobileOnly } from "react-device-detect"; -import { MdCircle } from "react-icons/md"; import useSWR from "swr"; type ThresholdBarGraphProps = { @@ -37,7 +36,16 @@ export function ThresholdBarGraph({ const formatTime = useCallback( (val: unknown) => { - const date = new Date(updateTimes[Math.round(val as number) - 1] * 1000); + const dateIndex = Math.round(val as number); + + let timeOffset = 0; + if (dateIndex < 0) { + timeOffset = 5000 * Math.abs(dateIndex); + } + + const date = new Date( + updateTimes[Math.max(1, dateIndex) - 1] * 1000 - timeOffset, + ); return date.toLocaleTimeString([], { hour12: config?.ui.time_format != "24hour", hour: "2-digit", @@ -130,6 +138,22 @@ export function ThresholdBarGraph({ ApexCharts.exec(graphId, "updateOptions", options, true, true); }, [graphId, options]); + const chartData = useMemo(() => { + if (data.length > 0 && data[0].data.length >= 30) { + return data; + } + + const copiedData = [...data]; + const fakeData = []; + for (let i = data.length; i < 30; i++) { + fakeData.push({ x: i - 30, y: 0 }); + } + + // @ts-expect-error data types are not obvious + copiedData[0].data = [...fakeData, ...data[0].data]; + return copiedData; + }, [data]); + return (
@@ -139,255 +163,7 @@ export function ThresholdBarGraph({ {unit}
- - - ); -} - -const getUnitSize = (MB: number) => { - if (MB === null || isNaN(MB) || MB < 0) return "Invalid number"; - if (MB < 1024) return `${MB.toFixed(2)} MiB`; - if (MB < 1048576) return `${(MB / 1024).toFixed(2)} GiB`; - - return `${(MB / 1048576).toFixed(2)} TiB`; -}; - -type StorageGraphProps = { - graphId: string; - used: number; - total: number; -}; -export function StorageGraph({ graphId, used, total }: StorageGraphProps) { - const { theme, systemTheme } = useTheme(); - - const options = useMemo(() => { - return { - chart: { - id: graphId, - background: (systemTheme || theme) == "dark" ? "#404040" : "#E5E5E5", - selection: { - enabled: false, - }, - toolbar: { - show: false, - }, - zoom: { - enabled: false, - }, - }, - grid: { - show: false, - padding: { - bottom: -40, - top: -60, - left: -20, - right: 0, - }, - }, - legend: { - show: false, - }, - dataLabels: { - enabled: false, - }, - plotOptions: { - bar: { - horizontal: true, - }, - }, - states: { - active: { - filter: { - type: "none", - }, - }, - hover: { - filter: { - type: "none", - }, - }, - }, - tooltip: { - enabled: false, - }, - xaxis: { - axisBorder: { - show: false, - }, - axisTicks: { - show: false, - }, - labels: { - show: false, - }, - }, - yaxis: { - show: false, - min: 0, - max: 100, - }, - } as ApexCharts.ApexOptions; - }, [graphId, systemTheme, theme]); - - useEffect(() => { - ApexCharts.exec(graphId, "updateOptions", options, true, true); - }, [graphId, options]); - - return ( -
-
-
-
{getUnitSize(used)}
-
/
-
- {getUnitSize(total)} -
-
-
- {Math.round((used / total) * 100)}% -
-
-
- -
-
- ); -} - -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) => { - 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: isMobileOnly ? 3 : 4, - tickPlacement: "on", - labels: { - rotate: 0, - formatter: formatTime, - }, - axisBorder: { - show: false, - }, - axisTicks: { - show: false, - }, - }, - yaxis: { - show: true, - labels: { - formatter: (val: number) => Math.ceil(val).toString(), - }, - 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/Logs.tsx b/web/src/pages/Logs.tsx index ebdcb7232..e01c897ef 100644 --- a/web/src/pages/Logs.tsx +++ b/web/src/pages/Logs.tsx @@ -1,6 +1,6 @@ import { Button } from "@/components/ui/button"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import { LogData, LogLine, LogSeverity } from "@/types/log"; +import { LogData, LogLine, LogSeverity, LogType, logTypes } from "@/types/log"; import copy from "copy-to-clipboard"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import axios from "axios"; @@ -10,25 +10,20 @@ import { LogLevelFilterButton } from "@/components/filter/LogLevelFilter"; import { FaCopy } from "react-icons/fa6"; import { Toaster } from "@/components/ui/sonner"; import { toast } from "sonner"; -import { isDesktop } from "react-device-detect"; +import { + isDesktop, + isMobile, + isMobileOnly, + isTablet, +} from "react-device-detect"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { cn } from "@/lib/utils"; import { MdVerticalAlignBottom } from "react-icons/md"; - -const logTypes = ["frigate", "go2rtc", "nginx"] as const; -type LogType = (typeof logTypes)[number]; +import { parseLogLines } from "@/utils/logUtil"; +import useKeyboardListener from "@/hooks/use-keyboard-listener"; type LogRange = { start: number; end: number }; -const frigateDateStamp = /\[[\d\s-:]*]/; -const frigateSeverity = /(DEBUG)|(INFO)|(WARNING)|(ERROR)/; -const frigateSection = /[\w.]*/; - -const goSeverity = /(DEB )|(INF )|(WRN )|(ERR )/; -const goSection = /\[[\w]*]/; - -const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/; - function Logs() { const [logService, setLogService] = useState("frigate"); @@ -38,24 +33,38 @@ function Logs() { // log data handling + const logPageSize = useMemo(() => { + if (isMobileOnly) { + return 15; + } + + if (isTablet) { + return 25; + } + + return 40; + }, []); + const [logRange, setLogRange] = useState({ start: 0, end: 0 }); const [logs, setLogs] = useState([]); + const [logLines, setLogLines] = useState([]); useEffect(() => { axios - .get(`logs/${logService}?start=-100`) + .get(`logs/${logService}?start=-${logPageSize}`) .then((resp) => { if (resp.status == 200) { const data = resp.data as LogData; setLogRange({ - start: Math.max(0, data.totalLines - 100), + start: Math.max(0, data.totalLines - logPageSize), end: data.totalLines, }); setLogs(data.lines); + setLogLines(parseLogLines(logService, data.lines)); } }) .catch(() => {}); - }, [logService]); + }, [logPageSize, logService]); useEffect(() => { if (!logs || logs.length == 0) { @@ -75,6 +84,10 @@ function Logs() { end: data.totalLines, }); setLogs([...logs, ...data.lines]); + setLogLines([ + ...logLines, + ...parseLogLines(logService, data.lines), + ]); } } }) @@ -86,137 +99,12 @@ function Logs() { clearTimeout(id); } }; - }, [logs, logService, logRange]); + // we need to listen on the current range of visible items + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [logLines, logService, logRange]); // convert to log data - const logLines = useMemo(() => { - if (!logs) { - return []; - } - - if (logService == "frigate") { - return logs - .map((line) => { - const match = frigateDateStamp.exec(line); - - if (!match) { - const infoIndex = line.indexOf("[INFO]"); - - if (infoIndex != -1) { - return { - dateStamp: line.substring(0, 19), - severity: "info", - section: "startup", - content: line.substring(infoIndex + 6).trim(), - }; - } - - return { - dateStamp: line.substring(0, 19), - severity: "unknown", - section: "unknown", - content: line.substring(30).trim(), - }; - } - - const sectionMatch = frigateSection.exec( - line.substring(match.index + match[0].length).trim(), - ); - - if (!sectionMatch) { - return null; - } - - return { - dateStamp: match.toString().slice(1, -1), - severity: frigateSeverity - .exec(line) - ?.at(0) - ?.toString() - ?.toLowerCase() as LogSeverity, - section: sectionMatch.toString(), - content: line - .substring(line.indexOf(":", match.index + match[0].length) + 2) - .trim(), - }; - }) - .filter((value) => value != null) as LogLine[]; - } else if (logService == "go2rtc") { - return logs - .map((line) => { - if (line.length == 0) { - return null; - } - - const severity = goSeverity.exec(line); - - let section = - goSection.exec(line)?.toString()?.slice(1, -1) ?? "startup"; - - if (frigateSeverity.exec(section)) { - section = "startup"; - } - - let contentStart; - - if (section == "startup") { - if (severity) { - contentStart = severity.index + severity[0].length; - } else { - contentStart = line.lastIndexOf("]") + 1; - } - } else { - contentStart = line.indexOf(section) + section.length + 2; - } - - let severityCat: LogSeverity; - switch (severity?.at(0)?.toString().trim()) { - case "INF": - severityCat = "info"; - break; - case "WRN": - severityCat = "warning"; - break; - case "ERR": - severityCat = "error"; - break; - case "DBG": - case "TRC": - severityCat = "debug"; - break; - default: - severityCat = "info"; - } - - return { - dateStamp: line.substring(0, 19), - severity: severityCat, - section: section, - content: line.substring(contentStart).trim(), - }; - }) - .filter((value) => value != null) as LogLine[]; - } else if (logService == "nginx") { - return logs - .map((line) => { - if (line.length == 0) { - return null; - } - - return { - dateStamp: line.substring(0, 19), - severity: "info", - section: ngSeverity.exec(line)?.at(0)?.toString() ?? "META", - content: line.substring(line.indexOf(" ", 20)).trim(), - }; - }) - .filter((value) => value != null) as LogLine[]; - } else { - return []; - } - }, [logs, logService]); - const handleCopyLogs = useCallback(() => { if (logs) { copy(logs.join("\n")); @@ -261,31 +149,38 @@ function Logs() { } try { - startObserver.current = new IntersectionObserver((entries) => { - if (entries[0].isIntersecting && logRange.start > 0) { - const start = Math.max(0, logRange.start - 100); + startObserver.current = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && logRange.start > 0) { + const start = Math.max(0, logRange.start - logPageSize); - axios - .get(`logs/${logService}?start=${start}&end=${logRange.start}`) - .then((resp) => { - if (resp.status == 200) { - const data = resp.data as LogData; + axios + .get(`logs/${logService}?start=${start}&end=${logRange.start}`) + .then((resp) => { + if (resp.status == 200) { + const data = resp.data as LogData; - if (data.lines.length > 0) { - setLogRange({ - start: start, - end: logRange.end, - }); - setLogs([...data.lines, ...logs]); + if (data.lines.length > 0) { + setLogRange({ + start: start, + end: logRange.end, + }); + setLogs([...data.lines, ...logs]); + setLogLines([ + ...parseLogLines(logService, data.lines), + ...logLines, + ]); + } } - } - }) - .catch(() => {}); - contentRef.current?.scrollBy({ - top: 10, - }); - } - }); + }) + .catch(() => {}); + contentRef.current?.scrollBy({ + top: 10, + }); + } + }, + { rootMargin: `${10 * (isMobile ? 64 : 48)}px 0px 0px 0px` }, + ); if (node) startObserver.current.observe(node); } catch (e) { // no op @@ -332,6 +227,40 @@ function Logs() { const [selectedLog, setSelectedLog] = useState(); + // interaction + + useKeyboardListener( + ["PageDown", "PageUp", "ArrowDown", "ArrowUp"], + (key, down, _) => { + if (!down) { + return; + } + + switch (key) { + case "PageDown": + contentRef.current?.scrollBy({ + top: 480, + }); + break; + case "PageUp": + contentRef.current?.scrollBy({ + top: -480, + }); + break; + case "ArrowDown": + contentRef.current?.scrollBy({ + top: 48, + }); + break; + case "ArrowUp": + contentRef.current?.scrollBy({ + top: -48, + }); + break; + } + }, + ); + return (
@@ -346,6 +275,7 @@ function Logs() { onValueChange={(value: LogType) => { if (value) { setLogs([]); + setLogLines([]); setFilterSeverity(undefined); setLogService(value); } diff --git a/web/src/types/log.ts b/web/src/types/log.ts index a9a4342ff..407f67e6d 100644 --- a/web/src/types/log.ts +++ b/web/src/types/log.ts @@ -11,3 +11,6 @@ export type LogLine = { section: string; content: string; }; + +export const logTypes = ["frigate", "go2rtc", "nginx"] as const; +export type LogType = (typeof logTypes)[number]; diff --git a/web/src/utils/logUtil.ts b/web/src/utils/logUtil.ts new file mode 100644 index 000000000..a481e145e --- /dev/null +++ b/web/src/utils/logUtil.ts @@ -0,0 +1,133 @@ +import { LogLine, LogSeverity, LogType } from "@/types/log"; + +const frigateDateStamp = /\[[\d\s-:]*]/; +const frigateSeverity = /(DEBUG)|(INFO)|(WARNING)|(ERROR)/; +const frigateSection = /[\w.]*/; + +const goSeverity = /(DEB )|(INF )|(WRN )|(ERR )/; +const goSection = /\[[\w]*]/; + +const ngSeverity = /(GET)|(POST)|(PUT)|(PATCH)|(DELETE)/; + +export function parseLogLines(logService: LogType, logs: string[]) { + if (logService == "frigate") { + return logs + .map((line) => { + const match = frigateDateStamp.exec(line); + + if (!match) { + const infoIndex = line.indexOf("[INFO]"); + + if (infoIndex != -1) { + return { + dateStamp: line.substring(0, 19), + severity: "info", + section: "startup", + content: line.substring(infoIndex + 6).trim(), + }; + } + + return { + dateStamp: line.substring(0, 19), + severity: "unknown", + section: "unknown", + content: line.substring(30).trim(), + }; + } + + const sectionMatch = frigateSection.exec( + line.substring(match.index + match[0].length).trim(), + ); + + if (!sectionMatch) { + return null; + } + + return { + dateStamp: match.toString().slice(1, -1), + severity: frigateSeverity + .exec(line) + ?.at(0) + ?.toString() + ?.toLowerCase() as LogSeverity, + section: sectionMatch.toString(), + content: line + .substring(line.indexOf(":", match.index + match[0].length) + 2) + .trim(), + }; + }) + .filter((value) => value != null) as LogLine[]; + } else if (logService == "go2rtc") { + return logs + .map((line) => { + if (line.length == 0) { + return null; + } + + const severity = goSeverity.exec(line); + + let section = + goSection.exec(line)?.toString()?.slice(1, -1) ?? "startup"; + + if (frigateSeverity.exec(section)) { + section = "startup"; + } + + let contentStart; + + if (section == "startup") { + if (severity) { + contentStart = severity.index + severity[0].length; + } else { + contentStart = line.lastIndexOf("]") + 1; + } + } else { + contentStart = line.indexOf(section) + section.length + 2; + } + + let severityCat: LogSeverity; + switch (severity?.at(0)?.toString().trim()) { + case "INF": + severityCat = "info"; + break; + case "WRN": + severityCat = "warning"; + break; + case "ERR": + severityCat = "error"; + break; + case "DBG": + case "TRC": + severityCat = "debug"; + break; + default: + severityCat = "info"; + } + + return { + dateStamp: line.substring(0, 19), + severity: severityCat, + section: section, + content: line.substring(contentStart).trim(), + }; + }) + .filter((value) => value != null) as LogLine[]; + } else if (logService == "nginx") { + return logs + .map((line) => { + if (line.length == 0) { + return null; + } + + return { + dateStamp: line.substring(0, 19), + severity: "info", + section: ngSeverity.exec(line)?.at(0)?.toString() ?? "META", + content: line.substring(line.indexOf(" ", 20)).trim(), + }; + }) + .filter((value) => value != null) as LogLine[]; + } + + return []; +} diff --git a/web/src/views/system/CameraMetrics.tsx b/web/src/views/system/CameraMetrics.tsx index 2e9b6a72a..7e4a315be 100644 --- a/web/src/views/system/CameraMetrics.tsx +++ b/web/src/views/system/CameraMetrics.tsx @@ -1,5 +1,5 @@ import { useFrigateStats } from "@/api/ws"; -import { CameraLineGraph } from "@/components/graph/SystemGraph"; +import { CameraLineGraph } from "@/components/graph/CameraGraph"; import { Skeleton } from "@/components/ui/skeleton"; import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateStats } from "@/types/stats"; @@ -43,7 +43,7 @@ export default function CameraMetrics({ } if (updatedStats.service.last_updated > lastUpdated) { - setStatsHistory([...statsHistory, updatedStats]); + setStatsHistory([...statsHistory.slice(1), updatedStats]); setLastUpdated(Date.now() / 1000); } }, [initialStats, updatedStats, statsHistory, lastUpdated, setLastUpdated]); diff --git a/web/src/views/system/GeneralMetrics.tsx b/web/src/views/system/GeneralMetrics.tsx index 4f962fb9b..be083af5c 100644 --- a/web/src/views/system/GeneralMetrics.tsx +++ b/web/src/views/system/GeneralMetrics.tsx @@ -12,8 +12,8 @@ import { } from "@/types/graph"; import { Button } from "@/components/ui/button"; import VainfoDialog from "@/components/overlay/VainfoDialog"; -import { ThresholdBarGraph } from "@/components/graph/SystemGraph"; import { Skeleton } from "@/components/ui/skeleton"; +import { ThresholdBarGraph } from "@/components/graph/SystemGraph"; type GeneralMetricsProps = { lastUpdated: number; @@ -57,7 +57,7 @@ export default function GeneralMetrics({ } if (updatedStats.service.last_updated > lastUpdated) { - setStatsHistory([...statsHistory, updatedStats]); + setStatsHistory([...statsHistory.slice(1), updatedStats]); setLastUpdated(Date.now() / 1000); } }, [initialStats, updatedStats, statsHistory, lastUpdated, setLastUpdated]); diff --git a/web/src/views/system/StorageMetrics.tsx b/web/src/views/system/StorageMetrics.tsx index 38014ae0a..f2c2a5269 100644 --- a/web/src/views/system/StorageMetrics.tsx +++ b/web/src/views/system/StorageMetrics.tsx @@ -1,4 +1,4 @@ -import { StorageGraph } from "@/components/graph/SystemGraph"; +import { StorageGraph } from "@/components/graph/StorageGraph"; import { FrigateStats } from "@/types/stats"; import { useMemo } from "react"; import useSWR from "swr";