This commit is contained in:
Josh Hawkins
2026-08-16 16:55:24 -05:00
parent 37b7e28372
commit f6707260bb
4 changed files with 183 additions and 89 deletions
+4
View File
@@ -148,6 +148,10 @@
"unused": { "unused": {
"title": "Unused", "title": "Unused",
"tips": "This value may not accurately represent the free space available to Frigate if you have other files stored on your drive beyond Frigate's recordings. Frigate does not track storage usage outside of its recordings." "tips": "This value may not accurately represent the free space available to Frigate if you have other files stored on your drive beyond Frigate's recordings. Frigate does not track storage usage outside of its recordings."
},
"subStream": {
"information": "Sub Stream Storage Information",
"tips": "Cameras that record a sub stream show their Original and Low quality usage separately, using the same names as the quality options in recording playback. Low quality recordings stay on disk until their retention period expires, so this breakdown can still appear for a camera after sub stream recording has been turned off."
} }
} }
}, },
+160 -80
View File
@@ -16,24 +16,28 @@ import {
PopoverTrigger, PopoverTrigger,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { getUnitSize } from "@/utils/storageUtil"; import { getUnitSize } from "@/utils/storageUtil";
import { CameraStorage, StreamStorage } from "@/types/stats";
import { CiCircleAlert } from "react-icons/ci"; import { CiCircleAlert } from "react-icons/ci";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type CameraStorage = {
[key: string]: {
bandwidth: number;
usage: number;
usage_percent: number;
};
};
type TotalStorage = { type TotalStorage = {
used: number; used: number;
camera: number; camera: number;
total: number; total: number;
}; };
type StorageRow = {
name: string;
usage: number;
bandwidth: number;
color: string;
streams?: {
main?: StreamStorage;
sub?: StreamStorage;
};
};
type CombinedStorageGraphProps = { type CombinedStorageGraphProps = {
graphId: string; graphId: string;
cameraStorage: CameraStorage; cameraStorage: CameraStorage;
@@ -44,46 +48,59 @@ export function CombinedStorageGraph({
cameraStorage, cameraStorage,
totalStorage, totalStorage,
}: CombinedStorageGraphProps) { }: CombinedStorageGraphProps) {
const { t } = useTranslation(["views/system"]); const { t } = useTranslation(["views/system", "components/player"]);
const { theme, systemTheme } = useTheme(); const { theme, systemTheme } = useTheme();
const isDark = (systemTheme || theme) == "dark";
const entities = Object.keys(cameraStorage); const rows: StorageRow[] = useMemo(() => {
const colors = generateColors(entities.length); const entities = Object.keys(cameraStorage);
const colors = generateColors(entities.length);
const series = entities.map((entity, index) => ({ return [
name: entity, ...entities.map((entity, index) => ({
data: [(cameraStorage[entity].usage / totalStorage.total) * 100], name: entity,
usage: cameraStorage[entity].usage, usage: cameraStorage[entity].usage ?? 0,
bandwidth: cameraStorage[entity].bandwidth, bandwidth: cameraStorage[entity].bandwidth,
color: colors[index], // Assign the corresponding color 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 const series = useMemo(
series.push({ () =>
name: "Other", rows.map((row) => ({
data: [ name: row.name,
((totalStorage.used - totalStorage.camera) / totalStorage.total) * 100, data: [(row.usage / totalStorage.total) * 100],
], usage: row.usage,
usage: totalStorage.used - totalStorage.camera, color: row.color,
bandwidth: 0, })),
color: (systemTheme || theme) == "dark" ? "#606060" : "#D5D5D5", [rows, totalStorage.total],
}); );
series.push({
name: "Unused", const hasSubStorage = useMemo(
data: [ () => rows.some((row) => row.streams?.sub),
((totalStorage.total - totalStorage.used) / totalStorage.total) * 100, [rows],
], );
usage: totalStorage.total - totalStorage.used,
bandwidth: 0,
color: (systemTheme || theme) == "dark" ? "#404040" : "#E5E5E5",
});
const options = useMemo(() => { const options = useMemo(() => {
return { return {
chart: { chart: {
id: graphId, id: graphId,
background: (systemTheme || theme) == "dark" ? "#404040" : "#E5E5E5", background: isDark ? "#404040" : "#E5E5E5",
selection: { selection: {
enabled: false, enabled: false,
}, },
@@ -164,7 +181,7 @@ export function CombinedStorageGraph({
max: 100, max: 100,
}, },
} as ApexCharts.ApexOptions; } as ApexCharts.ApexOptions;
}, [graphId, systemTheme, theme, series]); }, [graphId, isDark, systemTheme, theme, series]);
useEffect(() => { useEffect(() => {
ApexCharts.exec(graphId, "updateOptions", options, true, true); ApexCharts.exec(graphId, "updateOptions", options, true, true);
@@ -185,6 +202,34 @@ export function CombinedStorageGraph({
[t], [t],
); );
const getStreamSplit = useCallback(
(row: StorageRow, field: keyof StreamStorage) => {
const sub = row.streams?.sub;
if (!sub) {
return null;
}
return (
<div className="text-xs text-primary-variant">
<div>
<span className="text-muted-foreground">
{t("quality.main", { ns: "components/player" })}
</span>{" "}
{getUnitSize(row.streams?.main?.[field] ?? 0)}
</div>
<div>
<span className="text-muted-foreground">
{t("quality.sub", { ns: "components/player" })}
</span>{" "}
{getUnitSize(sub[field])}
</div>
</div>
);
},
[t],
);
return ( return (
<div className="flex w-full flex-col gap-2.5"> <div className="flex w-full flex-col gap-2.5">
<div className="flex w-full items-center justify-between gap-1"> <div className="flex w-full items-center justify-between gap-1">
@@ -206,7 +251,35 @@ export function CombinedStorageGraph({
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>{t("storage.cameraStorage.camera")}</TableHead> <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> <TableHead>
{t("storage.cameraStorage.percentageOfTotalUsed")} {t("storage.cameraStorage.percentageOfTotalUsed")}
</TableHead> </TableHead>
@@ -214,49 +287,56 @@ export function CombinedStorageGraph({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{series.map((item) => ( {rows.map((row) => {
<TableRow key={item.name}> const isAggregate = row.name == "Unused" || row.name == "Other";
<TableCell className="flex flex-row items-center gap-2 font-medium smart-capitalize">
{" "} return (
<div <TableRow key={row.name}>
className="size-3 rounded-md" <TableCell className="flex flex-row items-center gap-2 font-medium smart-capitalize">
style={{ backgroundColor: item.color }} <div
></div> className="size-3 rounded-md"
{getItemTitle(item.name)} style={{ backgroundColor: row.color }}
{(item.name === "Unused" || item.name == "Other") && ( ></div>
<Popover> {getItemTitle(row.name)}
<PopoverTrigger asChild> {isAggregate && (
<button <Popover>
className="focus:outline-none" <PopoverTrigger asChild>
aria-label={t( <button
"storage.cameraStorage.unusedStorageInformation", className="focus:outline-none"
)}
>
<CiCircleAlert
className="size-5"
aria-label={t( aria-label={t(
"storage.cameraStorage.unusedStorageInformation", "storage.cameraStorage.unusedStorageInformation",
)} )}
/> >
</button> <CiCircleAlert
</PopoverTrigger> className="size-5"
<PopoverContent className="w-80"> aria-label={t(
<div className="space-y-2"> "storage.cameraStorage.unusedStorageInformation",
{t("storage.cameraStorage.unused.tips")} )}
</div> />
</PopoverContent> </button>
</Popover> </PopoverTrigger>
)} <PopoverContent className="w-80">
</TableCell> <div className="space-y-2">
<TableCell>{getUnitSize(item.usage ?? 0)}</TableCell> {t("storage.cameraStorage.unused.tips")}
<TableCell>{item.data[0].toFixed(2)}%</TableCell> </div>
<TableCell> </PopoverContent>
{item.name === "Unused" || item.name == "Other" </Popover>
? "—" )}
: `${getUnitSize(item.bandwidth)} / hour`} </TableCell>
</TableCell> <TableCell>
</TableRow> {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> </TableBody>
</Table> </Table>
</div> </div>
+18
View File
@@ -101,6 +101,24 @@ export type StorageStats = {
shm_frame_count?: number; shm_frame_count?: number;
}; };
export type StreamStorage = {
usage: number;
bandwidth: number;
};
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 = { export type PotentialProblem = {
text: string; text: string;
color: string; color: string;
+1 -9
View File
@@ -1,6 +1,6 @@
import { CombinedStorageGraph } from "@/components/graph/CombinedStorageGraph"; import { CombinedStorageGraph } from "@/components/graph/CombinedStorageGraph";
import { StorageGraph } from "@/components/graph/StorageGraph"; import { StorageGraph } from "@/components/graph/StorageGraph";
import { FrigateStats } from "@/types/stats"; import { CameraStorage, FrigateStats } from "@/types/stats";
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { import {
Popover, Popover,
@@ -24,14 +24,6 @@ import { LuExternalLink } from "react-icons/lu";
import { FaExclamationTriangle } from "react-icons/fa"; import { FaExclamationTriangle } from "react-icons/fa";
import ActivityIndicator from "@/components/indicators/activity-indicator"; import ActivityIndicator from "@/components/indicators/activity-indicator";
type CameraStorage = {
[key: string]: {
bandwidth: number;
usage: number;
usage_percent: number;
};
};
type StorageMetricsProps = { type StorageMetricsProps = {
setLastUpdated: (last: number) => void; setLastUpdated: (last: number) => void;
}; };