mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-02-09 00:17:00 +01:00
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:
parent
f1c0422d5e
commit
9245c5cb56
138
web/src/components/graph/CameraGraph.tsx
Normal file
138
web/src/components/graph/CameraGraph.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
120
web/src/components/graph/StorageGraph.tsx
Normal file
120
web/src/components/graph/StorageGraph.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -4,7 +4,6 @@ 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 { isMobileOnly } from "react-device-detect";
|
import { isMobileOnly } from "react-device-detect";
|
||||||
import { MdCircle } from "react-icons/md";
|
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
|
||||||
type ThresholdBarGraphProps = {
|
type ThresholdBarGraphProps = {
|
||||||
@ -37,7 +36,16 @@ export function ThresholdBarGraph({
|
|||||||
|
|
||||||
const formatTime = useCallback(
|
const formatTime = useCallback(
|
||||||
(val: unknown) => {
|
(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([], {
|
return date.toLocaleTimeString([], {
|
||||||
hour12: config?.ui.time_format != "24hour",
|
hour12: config?.ui.time_format != "24hour",
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
@ -130,6 +138,22 @@ export function ThresholdBarGraph({
|
|||||||
ApexCharts.exec(graphId, "updateOptions", options, true, true);
|
ApexCharts.exec(graphId, "updateOptions", options, true, true);
|
||||||
}, [graphId, options]);
|
}, [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 (
|
return (
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@ -139,255 +163,7 @@ export function ThresholdBarGraph({
|
|||||||
{unit}
|
{unit}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Chart type="bar" options={options} series={data} height="120" />
|
<Chart type="bar" options={options} series={chartData} 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" />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
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 copy from "copy-to-clipboard";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@ -10,25 +10,20 @@ import { LogLevelFilterButton } from "@/components/filter/LogLevelFilter";
|
|||||||
import { FaCopy } from "react-icons/fa6";
|
import { FaCopy } from "react-icons/fa6";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { toast } from "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 ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { MdVerticalAlignBottom } from "react-icons/md";
|
import { MdVerticalAlignBottom } from "react-icons/md";
|
||||||
|
import { parseLogLines } from "@/utils/logUtil";
|
||||||
const logTypes = ["frigate", "go2rtc", "nginx"] as const;
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
type LogType = (typeof logTypes)[number];
|
|
||||||
|
|
||||||
type LogRange = { start: number; end: number };
|
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() {
|
function Logs() {
|
||||||
const [logService, setLogService] = useState<LogType>("frigate");
|
const [logService, setLogService] = useState<LogType>("frigate");
|
||||||
|
|
||||||
@ -38,24 +33,38 @@ function Logs() {
|
|||||||
|
|
||||||
// log data handling
|
// 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 [logRange, setLogRange] = useState<LogRange>({ start: 0, end: 0 });
|
||||||
const [logs, setLogs] = useState<string[]>([]);
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
|
const [logLines, setLogLines] = useState<LogLine[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
axios
|
axios
|
||||||
.get(`logs/${logService}?start=-100`)
|
.get(`logs/${logService}?start=-${logPageSize}`)
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
if (resp.status == 200) {
|
if (resp.status == 200) {
|
||||||
const data = resp.data as LogData;
|
const data = resp.data as LogData;
|
||||||
setLogRange({
|
setLogRange({
|
||||||
start: Math.max(0, data.totalLines - 100),
|
start: Math.max(0, data.totalLines - logPageSize),
|
||||||
end: data.totalLines,
|
end: data.totalLines,
|
||||||
});
|
});
|
||||||
setLogs(data.lines);
|
setLogs(data.lines);
|
||||||
|
setLogLines(parseLogLines(logService, data.lines));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [logService]);
|
}, [logPageSize, logService]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!logs || logs.length == 0) {
|
if (!logs || logs.length == 0) {
|
||||||
@ -75,6 +84,10 @@ function Logs() {
|
|||||||
end: data.totalLines,
|
end: data.totalLines,
|
||||||
});
|
});
|
||||||
setLogs([...logs, ...data.lines]);
|
setLogs([...logs, ...data.lines]);
|
||||||
|
setLogLines([
|
||||||
|
...logLines,
|
||||||
|
...parseLogLines(logService, data.lines),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -86,137 +99,12 @@ function Logs() {
|
|||||||
clearTimeout(id);
|
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
|
// 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(() => {
|
const handleCopyLogs = useCallback(() => {
|
||||||
if (logs) {
|
if (logs) {
|
||||||
copy(logs.join("\n"));
|
copy(logs.join("\n"));
|
||||||
@ -261,9 +149,10 @@ function Logs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
startObserver.current = new IntersectionObserver((entries) => {
|
startObserver.current = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
if (entries[0].isIntersecting && logRange.start > 0) {
|
if (entries[0].isIntersecting && logRange.start > 0) {
|
||||||
const start = Math.max(0, logRange.start - 100);
|
const start = Math.max(0, logRange.start - logPageSize);
|
||||||
|
|
||||||
axios
|
axios
|
||||||
.get(`logs/${logService}?start=${start}&end=${logRange.start}`)
|
.get(`logs/${logService}?start=${start}&end=${logRange.start}`)
|
||||||
@ -277,6 +166,10 @@ function Logs() {
|
|||||||
end: logRange.end,
|
end: logRange.end,
|
||||||
});
|
});
|
||||||
setLogs([...data.lines, ...logs]);
|
setLogs([...data.lines, ...logs]);
|
||||||
|
setLogLines([
|
||||||
|
...parseLogLines(logService, data.lines),
|
||||||
|
...logLines,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -285,7 +178,9 @@ function Logs() {
|
|||||||
top: 10,
|
top: 10,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
{ rootMargin: `${10 * (isMobile ? 64 : 48)}px 0px 0px 0px` },
|
||||||
|
);
|
||||||
if (node) startObserver.current.observe(node);
|
if (node) startObserver.current.observe(node);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// no op
|
// no op
|
||||||
@ -332,6 +227,40 @@ function Logs() {
|
|||||||
|
|
||||||
const [selectedLog, setSelectedLog] = useState<LogLine>();
|
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 (
|
return (
|
||||||
<div className="flex size-full flex-col p-2">
|
<div className="flex size-full flex-col p-2">
|
||||||
<Toaster position="top-center" closeButton={true} />
|
<Toaster position="top-center" closeButton={true} />
|
||||||
@ -346,6 +275,7 @@ function Logs() {
|
|||||||
onValueChange={(value: LogType) => {
|
onValueChange={(value: LogType) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
setLogs([]);
|
setLogs([]);
|
||||||
|
setLogLines([]);
|
||||||
setFilterSeverity(undefined);
|
setFilterSeverity(undefined);
|
||||||
setLogService(value);
|
setLogService(value);
|
||||||
}
|
}
|
||||||
|
@ -11,3 +11,6 @@ export type LogLine = {
|
|||||||
section: string;
|
section: string;
|
||||||
content: 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
133
web/src/utils/logUtil.ts
Normal 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 [];
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
import { useFrigateStats } from "@/api/ws";
|
import { useFrigateStats } from "@/api/ws";
|
||||||
import { CameraLineGraph } from "@/components/graph/SystemGraph";
|
import { CameraLineGraph } from "@/components/graph/CameraGraph";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import { FrigateStats } from "@/types/stats";
|
import { FrigateStats } from "@/types/stats";
|
||||||
@ -43,7 +43,7 @@ export default function CameraMetrics({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (updatedStats.service.last_updated > lastUpdated) {
|
if (updatedStats.service.last_updated > lastUpdated) {
|
||||||
setStatsHistory([...statsHistory, updatedStats]);
|
setStatsHistory([...statsHistory.slice(1), updatedStats]);
|
||||||
setLastUpdated(Date.now() / 1000);
|
setLastUpdated(Date.now() / 1000);
|
||||||
}
|
}
|
||||||
}, [initialStats, updatedStats, statsHistory, lastUpdated, setLastUpdated]);
|
}, [initialStats, updatedStats, statsHistory, lastUpdated, setLastUpdated]);
|
||||||
|
@ -12,8 +12,8 @@ import {
|
|||||||
} from "@/types/graph";
|
} from "@/types/graph";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import VainfoDialog from "@/components/overlay/VainfoDialog";
|
import VainfoDialog from "@/components/overlay/VainfoDialog";
|
||||||
import { ThresholdBarGraph } from "@/components/graph/SystemGraph";
|
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { ThresholdBarGraph } from "@/components/graph/SystemGraph";
|
||||||
|
|
||||||
type GeneralMetricsProps = {
|
type GeneralMetricsProps = {
|
||||||
lastUpdated: number;
|
lastUpdated: number;
|
||||||
@ -57,7 +57,7 @@ export default function GeneralMetrics({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (updatedStats.service.last_updated > lastUpdated) {
|
if (updatedStats.service.last_updated > lastUpdated) {
|
||||||
setStatsHistory([...statsHistory, updatedStats]);
|
setStatsHistory([...statsHistory.slice(1), updatedStats]);
|
||||||
setLastUpdated(Date.now() / 1000);
|
setLastUpdated(Date.now() / 1000);
|
||||||
}
|
}
|
||||||
}, [initialStats, updatedStats, statsHistory, lastUpdated, setLastUpdated]);
|
}, [initialStats, updatedStats, statsHistory, lastUpdated, setLastUpdated]);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { StorageGraph } from "@/components/graph/SystemGraph";
|
import { StorageGraph } from "@/components/graph/StorageGraph";
|
||||||
import { FrigateStats } from "@/types/stats";
|
import { FrigateStats } from "@/types/stats";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
|
Loading…
Reference in New Issue
Block a user