2024-08-30 08:56:55 -05:00
|
|
|
import { useTheme } from "@/context/theme-provider";
|
|
|
|
|
import { generateColors } from "@/utils/colorUtil";
|
2025-07-08 21:06:06 -05:00
|
|
|
import { useCallback, useEffect, useMemo } from "react";
|
2024-08-30 08:56:55 -05:00
|
|
|
import Chart from "react-apexcharts";
|
|
|
|
|
import {
|
|
|
|
|
Table,
|
|
|
|
|
TableBody,
|
|
|
|
|
TableCell,
|
|
|
|
|
TableHead,
|
|
|
|
|
TableHeader,
|
|
|
|
|
TableRow,
|
|
|
|
|
} from "@/components/ui/table";
|
2024-09-28 08:24:14 -05:00
|
|
|
import {
|
|
|
|
|
Popover,
|
|
|
|
|
PopoverContent,
|
|
|
|
|
PopoverTrigger,
|
|
|
|
|
} from "@/components/ui/popover";
|
2024-08-30 08:56:55 -05:00
|
|
|
import { getUnitSize } from "@/utils/storageUtil";
|
2026-08-18 08:25:38 -05:00
|
|
|
import { CameraStorage, StreamStorage } from "@/types/stats";
|
2025-03-16 23:36:20 +08:00
|
|
|
|
2025-03-06 09:50:37 -07:00
|
|
|
import { CiCircleAlert } from "react-icons/ci";
|
2025-03-16 23:36:20 +08:00
|
|
|
import { useTranslation } from "react-i18next";
|
2024-08-30 08:56:55 -05:00
|
|
|
|
|
|
|
|
type TotalStorage = {
|
|
|
|
|
used: number;
|
2025-07-08 21:06:06 -05:00
|
|
|
camera: number;
|
2024-08-30 08:56:55 -05:00
|
|
|
total: number;
|
|
|
|
|
};
|
|
|
|
|
|
2026-08-18 08:25:38 -05:00
|
|
|
type StorageRow = {
|
|
|
|
|
name: string;
|
|
|
|
|
usage: number;
|
|
|
|
|
bandwidth: number;
|
|
|
|
|
color: string;
|
|
|
|
|
streams?: {
|
|
|
|
|
main?: StreamStorage;
|
|
|
|
|
sub?: StreamStorage;
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2024-08-30 08:56:55 -05:00
|
|
|
type CombinedStorageGraphProps = {
|
|
|
|
|
graphId: string;
|
|
|
|
|
cameraStorage: CameraStorage;
|
|
|
|
|
totalStorage: TotalStorage;
|
|
|
|
|
};
|
|
|
|
|
export function CombinedStorageGraph({
|
|
|
|
|
graphId,
|
|
|
|
|
cameraStorage,
|
|
|
|
|
totalStorage,
|
|
|
|
|
}: CombinedStorageGraphProps) {
|
2026-08-18 08:25:38 -05:00
|
|
|
const { t } = useTranslation(["views/system", "components/player"]);
|
2025-03-16 23:36:20 +08:00
|
|
|
|
2024-08-30 08:56:55 -05:00
|
|
|
const { theme, systemTheme } = useTheme();
|
2026-08-18 08:25:38 -05:00
|
|
|
const isDark = (systemTheme || theme) == "dark";
|
2024-08-30 08:56:55 -05:00
|
|
|
|
2026-08-18 08:25:38 -05:00
|
|
|
const rows: StorageRow[] = useMemo(() => {
|
|
|
|
|
const entities = Object.keys(cameraStorage);
|
|
|
|
|
const colors = generateColors(entities.length);
|
2024-08-30 08:56:55 -05:00
|
|
|
|
2026-08-18 08:25:38 -05:00
|
|
|
return [
|
|
|
|
|
...entities.map((entity, index) => ({
|
|
|
|
|
name: entity,
|
|
|
|
|
usage: cameraStorage[entity].usage ?? 0,
|
|
|
|
|
bandwidth: cameraStorage[entity].bandwidth,
|
|
|
|
|
color: colors[index],
|
|
|
|
|
streams: cameraStorage[entity].streams,
|
|
|
|
|
})),
|
|
|
|
|
{
|
|
|
|
|
name: "Other",
|
|
|
|
|
usage: totalStorage.used - totalStorage.camera,
|
|
|
|
|
bandwidth: 0,
|
|
|
|
|
color: isDark ? "#606060" : "#D5D5D5",
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
name: "Unused",
|
|
|
|
|
usage: totalStorage.total - totalStorage.used,
|
|
|
|
|
bandwidth: 0,
|
|
|
|
|
color: isDark ? "#404040" : "#E5E5E5",
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
}, [cameraStorage, totalStorage, isDark]);
|
2024-08-30 08:56:55 -05:00
|
|
|
|
2026-08-18 08:25:38 -05:00
|
|
|
const series = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
rows.map((row) => ({
|
|
|
|
|
name: row.name,
|
|
|
|
|
data: [(row.usage / totalStorage.total) * 100],
|
|
|
|
|
usage: row.usage,
|
|
|
|
|
color: row.color,
|
|
|
|
|
})),
|
|
|
|
|
[rows, totalStorage.total],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const hasSubStorage = useMemo(
|
|
|
|
|
() => rows.some((row) => row.streams?.sub),
|
|
|
|
|
[rows],
|
|
|
|
|
);
|
2024-08-30 08:56:55 -05:00
|
|
|
|
|
|
|
|
const options = useMemo(() => {
|
|
|
|
|
return {
|
|
|
|
|
chart: {
|
|
|
|
|
id: graphId,
|
2026-08-18 08:25:38 -05:00
|
|
|
background: isDark ? "#404040" : "#E5E5E5",
|
2024-08-30 08:56:55 -05:00
|
|
|
selection: {
|
|
|
|
|
enabled: false,
|
|
|
|
|
},
|
|
|
|
|
toolbar: {
|
|
|
|
|
show: false,
|
|
|
|
|
},
|
|
|
|
|
zoom: {
|
|
|
|
|
enabled: false,
|
|
|
|
|
},
|
|
|
|
|
stacked: true,
|
|
|
|
|
stackType: "100%",
|
|
|
|
|
},
|
|
|
|
|
grid: {
|
|
|
|
|
show: false,
|
|
|
|
|
padding: {
|
|
|
|
|
bottom: -45,
|
|
|
|
|
top: -40,
|
|
|
|
|
left: -20,
|
|
|
|
|
right: -20,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
legend: {
|
|
|
|
|
show: false,
|
|
|
|
|
},
|
|
|
|
|
dataLabels: {
|
|
|
|
|
enabled: false,
|
|
|
|
|
},
|
|
|
|
|
plotOptions: {
|
|
|
|
|
bar: {
|
|
|
|
|
horizontal: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
states: {
|
|
|
|
|
active: {
|
|
|
|
|
filter: {
|
|
|
|
|
type: "none",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
hover: {
|
|
|
|
|
filter: {
|
|
|
|
|
type: "none",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
tooltip: {
|
|
|
|
|
enabled: false,
|
|
|
|
|
x: {
|
|
|
|
|
show: false,
|
|
|
|
|
},
|
|
|
|
|
y: {
|
|
|
|
|
formatter: function (val, { seriesIndex }) {
|
|
|
|
|
if (series[seriesIndex]) {
|
|
|
|
|
const usage = series[seriesIndex].usage;
|
|
|
|
|
return `${getUnitSize(usage)} (${val.toFixed(2)}%)`;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
theme: systemTheme || theme,
|
|
|
|
|
},
|
|
|
|
|
xaxis: {
|
|
|
|
|
axisBorder: {
|
|
|
|
|
show: false,
|
|
|
|
|
},
|
|
|
|
|
axisTicks: {
|
|
|
|
|
show: false,
|
|
|
|
|
},
|
|
|
|
|
labels: {
|
|
|
|
|
formatter: function (val) {
|
|
|
|
|
return val + "%";
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
min: 0,
|
|
|
|
|
max: 100,
|
|
|
|
|
},
|
|
|
|
|
yaxis: {
|
|
|
|
|
show: false,
|
|
|
|
|
min: 0,
|
|
|
|
|
max: 100,
|
|
|
|
|
},
|
|
|
|
|
} as ApexCharts.ApexOptions;
|
2026-08-18 08:25:38 -05:00
|
|
|
}, [graphId, isDark, systemTheme, theme, series]);
|
2024-08-30 08:56:55 -05:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
ApexCharts.exec(graphId, "updateOptions", options, true, true);
|
|
|
|
|
}, [graphId, options]);
|
|
|
|
|
|
2025-07-08 21:06:06 -05:00
|
|
|
// convenience
|
|
|
|
|
|
|
|
|
|
const getItemTitle = useCallback(
|
|
|
|
|
(name: string) => {
|
|
|
|
|
if (name == "Unused") {
|
|
|
|
|
return t("storage.cameraStorage.unused.title");
|
|
|
|
|
} else if (name == "Other") {
|
|
|
|
|
return t("label.other", { ns: "common" });
|
|
|
|
|
} else {
|
|
|
|
|
return name.replaceAll("_", " ");
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[t],
|
|
|
|
|
);
|
|
|
|
|
|
2026-08-18 08:25:38 -05:00
|
|
|
const getStreamSplit = useCallback(
|
|
|
|
|
(row: StorageRow, field: keyof StreamStorage) => {
|
|
|
|
|
const mainValue = row.streams?.main?.[field];
|
|
|
|
|
const subValue = row.streams?.sub?.[field];
|
|
|
|
|
|
|
|
|
|
// omit the split entirely when either side is unknown, rather than
|
|
|
|
|
// showing a zero that would read as "this stream costs nothing"
|
|
|
|
|
if (mainValue == null || subValue == null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="text-xs text-primary-variant">
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-muted-foreground">
|
|
|
|
|
{t("quality.main", { ns: "components/player" })}
|
|
|
|
|
</span>{" "}
|
|
|
|
|
{getUnitSize(mainValue)}
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<span className="text-muted-foreground">
|
|
|
|
|
{t("quality.sub", { ns: "components/player" })}
|
|
|
|
|
</span>{" "}
|
|
|
|
|
{getUnitSize(subValue)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
[t],
|
|
|
|
|
);
|
|
|
|
|
|
2024-08-30 08:56:55 -05:00
|
|
|
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">
|
2025-07-08 21:06:06 -05:00
|
|
|
{getUnitSize(totalStorage.camera)}
|
2024-08-30 08:56:55 -05:00
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-primary">/</div>
|
|
|
|
|
<div className="text-xs text-muted-foreground">
|
|
|
|
|
{getUnitSize(totalStorage.total)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="h-5 overflow-hidden rounded-md">
|
|
|
|
|
<Chart type="bar" options={options} series={series} height="100%" />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="custom-legend">
|
|
|
|
|
<Table>
|
|
|
|
|
<TableHeader>
|
|
|
|
|
<TableRow>
|
2025-03-16 23:36:20 +08:00
|
|
|
<TableHead>{t("storage.cameraStorage.camera")}</TableHead>
|
2026-08-18 08:25:38 -05:00
|
|
|
<TableHead>
|
|
|
|
|
<div className="flex flex-row items-center gap-1">
|
|
|
|
|
{t("storage.cameraStorage.storageUsed")}
|
|
|
|
|
{hasSubStorage && (
|
|
|
|
|
<Popover>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
className="focus:outline-none"
|
|
|
|
|
aria-label={t(
|
|
|
|
|
"storage.cameraStorage.subStream.information",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<CiCircleAlert
|
|
|
|
|
className="size-5"
|
|
|
|
|
aria-label={t(
|
|
|
|
|
"storage.cameraStorage.subStream.information",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
</button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent className="w-80">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{t("storage.cameraStorage.subStream.tips")}
|
|
|
|
|
</div>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</TableHead>
|
2025-03-16 23:36:20 +08:00
|
|
|
<TableHead>
|
|
|
|
|
{t("storage.cameraStorage.percentageOfTotalUsed")}
|
|
|
|
|
</TableHead>
|
|
|
|
|
<TableHead>{t("storage.cameraStorage.bandwidth")}</TableHead>
|
2024-08-30 08:56:55 -05:00
|
|
|
</TableRow>
|
|
|
|
|
</TableHeader>
|
|
|
|
|
<TableBody>
|
2026-08-18 08:25:38 -05:00
|
|
|
{rows.map((row) => {
|
|
|
|
|
const isAggregate = row.name == "Unused" || row.name == "Other";
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<TableRow key={row.name}>
|
|
|
|
|
<TableCell className="flex flex-row items-center gap-2 font-medium smart-capitalize">
|
|
|
|
|
<div
|
|
|
|
|
className="size-3 rounded-md"
|
|
|
|
|
style={{ backgroundColor: row.color }}
|
|
|
|
|
></div>
|
|
|
|
|
{getItemTitle(row.name)}
|
|
|
|
|
{isAggregate && (
|
|
|
|
|
<Popover>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
className="focus:outline-none"
|
2025-03-16 23:36:20 +08:00
|
|
|
aria-label={t(
|
|
|
|
|
"storage.cameraStorage.unusedStorageInformation",
|
|
|
|
|
)}
|
2026-08-18 08:25:38 -05:00
|
|
|
>
|
|
|
|
|
<CiCircleAlert
|
|
|
|
|
className="size-5"
|
|
|
|
|
aria-label={t(
|
|
|
|
|
"storage.cameraStorage.unusedStorageInformation",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
</button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
<PopoverContent className="w-80">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
{t("storage.cameraStorage.unused.tips")}
|
|
|
|
|
</div>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
)}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>
|
|
|
|
|
{getUnitSize(row.usage)}
|
|
|
|
|
{getStreamSplit(row, "usage")}
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>
|
|
|
|
|
{((row.usage / totalStorage.total) * 100).toFixed(2)}%
|
|
|
|
|
</TableCell>
|
|
|
|
|
<TableCell>
|
|
|
|
|
{isAggregate ? "—" : `${getUnitSize(row.bandwidth)} / hour`}
|
|
|
|
|
{getStreamSplit(row, "bandwidth")}
|
|
|
|
|
</TableCell>
|
|
|
|
|
</TableRow>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2024-08-30 08:56:55 -05:00
|
|
|
</TableBody>
|
|
|
|
|
</Table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|