Show main and sub stream usage separately in Storage Metrics (#24015)

* backend

* frontend

* docs

* test

* report null instead of 0 for a stream with no cached bandwidth sample
This commit is contained in:
Josh Hawkins
2026-08-22 11:40:42 -05:00
parent af537b9479
commit 0f5ed8822d
7 changed files with 397 additions and 103 deletions
+163 -80
View File
@@ -16,24 +16,28 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { getUnitSize } from "@/utils/storageUtil";
import { CameraStorage, StreamStorage } from "@/types/stats";
import { CiCircleAlert } from "react-icons/ci";
import { useTranslation } from "react-i18next";
type CameraStorage = {
[key: string]: {
bandwidth: number;
usage: number;
usage_percent: number;
};
};
type TotalStorage = {
used: number;
camera: number;
total: number;
};
type StorageRow = {
name: string;
usage: number;
bandwidth: number;
color: string;
streams?: {
main?: StreamStorage;
sub?: StreamStorage;
};
};
type CombinedStorageGraphProps = {
graphId: string;
cameraStorage: CameraStorage;
@@ -44,46 +48,59 @@ export function CombinedStorageGraph({
cameraStorage,
totalStorage,
}: CombinedStorageGraphProps) {
const { t } = useTranslation(["views/system"]);
const { t } = useTranslation(["views/system", "components/player"]);
const { theme, systemTheme } = useTheme();
const isDark = (systemTheme || theme) == "dark";
const entities = Object.keys(cameraStorage);
const colors = generateColors(entities.length);
const rows: StorageRow[] = useMemo(() => {
const entities = Object.keys(cameraStorage);
const colors = generateColors(entities.length);
const series = entities.map((entity, index) => ({
name: entity,
data: [(cameraStorage[entity].usage / totalStorage.total) * 100],
usage: cameraStorage[entity].usage,
bandwidth: cameraStorage[entity].bandwidth,
color: colors[index], // Assign the corresponding color
}));
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]);
// Add the unused percentage to the series
series.push({
name: "Other",
data: [
((totalStorage.used - totalStorage.camera) / totalStorage.total) * 100,
],
usage: totalStorage.used - totalStorage.camera,
bandwidth: 0,
color: (systemTheme || theme) == "dark" ? "#606060" : "#D5D5D5",
});
series.push({
name: "Unused",
data: [
((totalStorage.total - totalStorage.used) / totalStorage.total) * 100,
],
usage: totalStorage.total - totalStorage.used,
bandwidth: 0,
color: (systemTheme || theme) == "dark" ? "#404040" : "#E5E5E5",
});
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],
);
const options = useMemo(() => {
return {
chart: {
id: graphId,
background: (systemTheme || theme) == "dark" ? "#404040" : "#E5E5E5",
background: isDark ? "#404040" : "#E5E5E5",
selection: {
enabled: false,
},
@@ -164,7 +181,7 @@ export function CombinedStorageGraph({
max: 100,
},
} as ApexCharts.ApexOptions;
}, [graphId, systemTheme, theme, series]);
}, [graphId, isDark, systemTheme, theme, series]);
useEffect(() => {
ApexCharts.exec(graphId, "updateOptions", options, true, true);
@@ -185,6 +202,37 @@ export function CombinedStorageGraph({
[t],
);
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],
);
return (
<div className="flex w-full flex-col gap-2.5">
<div className="flex w-full items-center justify-between gap-1">
@@ -206,7 +254,35 @@ export function CombinedStorageGraph({
<TableHeader>
<TableRow>
<TableHead>{t("storage.cameraStorage.camera")}</TableHead>
<TableHead>{t("storage.cameraStorage.storageUsed")}</TableHead>
<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>
<TableHead>
{t("storage.cameraStorage.percentageOfTotalUsed")}
</TableHead>
@@ -214,49 +290,56 @@ export function CombinedStorageGraph({
</TableRow>
</TableHeader>
<TableBody>
{series.map((item) => (
<TableRow key={item.name}>
<TableCell className="flex flex-row items-center gap-2 font-medium smart-capitalize">
{" "}
<div
className="size-3 rounded-md"
style={{ backgroundColor: item.color }}
></div>
{getItemTitle(item.name)}
{(item.name === "Unused" || item.name == "Other") && (
<Popover>
<PopoverTrigger asChild>
<button
className="focus:outline-none"
aria-label={t(
"storage.cameraStorage.unusedStorageInformation",
)}
>
<CiCircleAlert
className="size-5"
{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"
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(item.usage ?? 0)}</TableCell>
<TableCell>{item.data[0].toFixed(2)}%</TableCell>
<TableCell>
{item.name === "Unused" || item.name == "Other"
? "—"
: `${getUnitSize(item.bandwidth)} / hour`}
</TableCell>
</TableRow>
))}
>
<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>
);
})}
</TableBody>
</Table>
</div>
+18
View File
@@ -101,6 +101,24 @@ export type StorageStats = {
shm_frame_count?: number;
};
export type StreamStorage = {
usage: number;
bandwidth: number | null;
};
export type CameraStorage = {
[camera: string]: {
bandwidth: number;
usage: number;
usage_percent: number;
// absent for stream types with no segments on disk
streams?: {
main?: StreamStorage;
sub?: StreamStorage;
};
};
};
export type PotentialProblem = {
text: string;
color: string;
+1 -9
View File
@@ -1,6 +1,6 @@
import { CombinedStorageGraph } from "@/components/graph/CombinedStorageGraph";
import { StorageGraph } from "@/components/graph/StorageGraph";
import { FrigateStats } from "@/types/stats";
import { CameraStorage, FrigateStats } from "@/types/stats";
import { useEffect, useMemo } from "react";
import {
Popover,
@@ -24,14 +24,6 @@ import { LuExternalLink } from "react-icons/lu";
import { FaExclamationTriangle } from "react-icons/fa";
import ActivityIndicator from "@/components/indicators/activity-indicator";
type CameraStorage = {
[key: string]: {
bandwidth: number;
usage: number;
usage_percent: number;
};
};
type StorageMetricsProps = {
setLastUpdated: (last: number) => void;
};