Improve efficiency of log and metrics pages (#11622)

* Rework stats pages

* Handle limited data case

* Handle page and arrow keys

* Adjust sizing
This commit is contained in:
Nicolas Mowen 2024-05-29 12:05:39 -06:00 committed by GitHub
parent f1c0422d5e
commit 9245c5cb56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 523 additions and 423 deletions

View File

@ -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<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) => {
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 (
<div className="flex w-full 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">
{lastValues[labelIdx]}
{unit}
</div>
</div>
))}
</div>
)}
<Chart type="line" options={options} series={data} height="120" />
</div>
);
}

View File

@ -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 (
<div className="flex w-full flex-col gap-2.5">
<div className="flex w-full items-center justify-between gap-1">
<div className="flex items-center gap-1">
<div className="text-xs text-primary">{getUnitSize(used)}</div>
<div className="text-xs text-primary">/</div>
<div className="text-xs text-muted-foreground">
{getUnitSize(total)}
</div>
</div>
<div className="text-xs text-primary">
{Math.round((used / total) * 100)}%
</div>
</div>
<div className="h-5 overflow-hidden rounded-md">
<Chart
type="bar"
options={options}
series={[
{ data: [{ x: "storage", y: Math.round((used / total) * 100) }] },
]}
height="100%"
/>
</div>
</div>
);
}

View File

@ -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 (
<div className="flex w-full flex-col">
<div className="flex items-center gap-1">
@ -139,255 +163,7 @@ export function ThresholdBarGraph({
{unit}
</div>
</div>
<Chart type="bar" options={options} series={data} height="120" />
</div>
);
}
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 (
<div className="flex w-full flex-col gap-2.5">
<div className="flex w-full items-center justify-between gap-1">
<div className="flex items-center gap-1">
<div className="text-xs text-primary">{getUnitSize(used)}</div>
<div className="text-xs text-primary">/</div>
<div className="text-xs text-muted-foreground">
{getUnitSize(total)}
</div>
</div>
<div className="text-xs text-primary">
{Math.round((used / total) * 100)}%
</div>
</div>
<div className="h-5 overflow-hidden rounded-md">
<Chart
type="bar"
options={options}
series={[
{ data: [{ x: "storage", y: Math.round((used / total) * 100) }] },
]}
height="100%"
/>
</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) => {
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 (
<div className="flex w-full 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">
{lastValues[labelIdx]}
{unit}
</div>
</div>
))}
</div>
)}
<Chart type="line" options={options} series={data} height="120" />
<Chart type="bar" options={options} series={chartData} height="120" />
</div>
);
}

View File

@ -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<LogType>("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<LogRange>({ start: 0, end: 0 });
const [logs, setLogs] = useState<string[]>([]);
const [logLines, setLogLines] = useState<LogLine[]>([]);
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<LogLine[]>(() => {
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,9 +149,10 @@ function Logs() {
}
try {
startObserver.current = new IntersectionObserver((entries) => {
startObserver.current = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && logRange.start > 0) {
const start = Math.max(0, logRange.start - 100);
const start = Math.max(0, logRange.start - logPageSize);
axios
.get(`logs/${logService}?start=${start}&end=${logRange.start}`)
@ -277,6 +166,10 @@ function Logs() {
end: logRange.end,
});
setLogs([...data.lines, ...logs]);
setLogLines([
...parseLogLines(logService, data.lines),
...logLines,
]);
}
}
})
@ -285,7 +178,9 @@ function Logs() {
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<LogLine>();
// 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 (
<div className="flex size-full flex-col p-2">
<Toaster position="top-center" closeButton={true} />
@ -346,6 +275,7 @@ function Logs() {
onValueChange={(value: LogType) => {
if (value) {
setLogs([]);
setLogLines([]);
setFilterSeverity(undefined);
setLogService(value);
}

View File

@ -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];

133
web/src/utils/logUtil.ts Normal file
View File

@ -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 [];
}

View File

@ -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]);

View File

@ -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]);

View File

@ -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";