diff --git a/web/src/views/system/StorageMetrics.test.tsx b/web/src/views/system/StorageMetrics.test.tsx
new file mode 100644
index 000000000..3c5a12130
--- /dev/null
+++ b/web/src/views/system/StorageMetrics.test.tsx
@@ -0,0 +1,176 @@
+import type { ReactNode } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+
+import StorageMetrics from "./StorageMetrics";
+import { aggregateRecordingRoots } from "./storageMetricsUtil";
+
+vi.mock("@/components/graph/CombinedStorageGraph", () => ({
+ CombinedStorageGraph: () =>
COMBINED_STORAGE_GRAPH
,
+}));
+
+vi.mock("@/components/graph/StorageGraph", () => ({
+ StorageGraph: ({ used, total }: { used: number; total: number }) => (
+ {`STORAGE_GRAPH:${used}/${total}`}
+ ),
+}));
+
+vi.mock("@/components/storage/RecordingsRoots", () => ({
+ RecordingsRoots: () => RECORDING_ROOTS_SECTION
,
+}));
+
+vi.mock("@/hooks/use-date-utils", () => ({
+ useTimezone: () => "utc",
+ useFormattedTimestamp: () => "formatted-date",
+}));
+
+vi.mock("@/hooks/use-doc-domain", () => ({
+ useDocDomain: () => ({ getLocaleDocUrl: () => "https://docs.local" }),
+}));
+
+vi.mock("react-router-dom", () => ({
+ Link: ({ children }: { children: ReactNode }) => {children},
+}));
+
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({
+ t: (key: string) => {
+ if (key === "storage.overview") return "Overview";
+ if (key === "storage.recordings.roots") return "Recording Roots";
+ if (key === "storage.cameraStorage.title") return "Camera Storage";
+ if (key === "storage.recordings.title") return "Recordings";
+ if (key === "storage.recordings.earliestRecording") {
+ return "Earliest recording";
+ }
+ return key;
+ },
+ }),
+}));
+
+vi.mock("swr", () => ({
+ default: (key: string | [string, Record]) => {
+ if (key === "recordings/storage") {
+ return {
+ data: {
+ cameras: {
+ front_door: { bandwidth: 1, usage: 120, usage_percent: 60 },
+ back_yard: { bandwidth: 1, usage: 80, usage_percent: 40 },
+ },
+ recording_roots: [
+ {
+ path: "/media/frigate/recordings",
+ total: 1000,
+ used: 400,
+ free: 600,
+ usage_percent: 40,
+ recordings_size: 200,
+ cameras: ["front_door"],
+ camera_usages: {},
+ is_default: true,
+ filesystem: "ext4 • /dev/sda1",
+ },
+ {
+ path: "/mnt/recordings",
+ total: 1000,
+ used: 400,
+ free: 600,
+ usage_percent: 40,
+ recordings_size: 0,
+ cameras: ["back_yard"],
+ camera_usages: {},
+ is_default: false,
+ filesystem: "ext4 • /dev/sda1",
+ },
+ ],
+ },
+ };
+ }
+
+ if (key === "stats") {
+ return {
+ data: {
+ service: {
+ storage: {
+ "/media/frigate/recordings": { used: 500, total: 1000 },
+ "/tmp/cache": { used: 5, total: 10 },
+ "/dev/shm": { used: 1, total: 2, min_shm: 1 },
+ },
+ },
+ },
+ };
+ }
+
+ if (key === "config") {
+ return { data: { ui: { time_format: "24hour" } } };
+ }
+
+ if (Array.isArray(key) && key[0] === "recordings/summary") {
+ return { data: { "2024-01-01": true } };
+ }
+
+ return { data: undefined };
+ },
+}));
+
+describe("aggregateRecordingRoots", () => {
+ it("sums used/total and deduplicates by filesystem when present", () => {
+ const result = aggregateRecordingRoots([
+ {
+ path: "/media/frigate/recordings",
+ total: 1000,
+ used: 500,
+ free: 500,
+ usage_percent: 50,
+ recordings_size: 0,
+ cameras: [],
+ camera_usages: {},
+ is_default: true,
+ filesystem: "ext4 • /dev/sda1",
+ },
+ {
+ path: "/mnt/recordings",
+ total: 1000,
+ used: 500,
+ free: 500,
+ usage_percent: 50,
+ recordings_size: 0,
+ cameras: [],
+ camera_usages: {},
+ is_default: false,
+ filesystem: "ext4 • /dev/sda1",
+ },
+ {
+ path: "/video2",
+ total: 2000,
+ used: 1000,
+ free: 1000,
+ usage_percent: 50,
+ recordings_size: 0,
+ cameras: [],
+ camera_usages: {},
+ is_default: false,
+ },
+ ]);
+
+ expect(result).toEqual({ used: 1500, total: 3000 });
+ });
+});
+
+describe("StorageMetrics", () => {
+ it("renders sections in overview, recording roots, camera storage order and uses aggregated recordings totals", () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html.indexOf("Overview")).toBeLessThan(
+ html.indexOf("Recording Roots"),
+ );
+ expect(html.indexOf("Recording Roots")).toBeLessThan(
+ html.indexOf("Camera Storage"),
+ );
+
+ // Recordings graph should use deduped aggregate root totals (400/1000),
+ // not camera usage sum (200) or default root stat values from stats.
+ expect(html).toContain("STORAGE_GRAPH:400/1000");
+ });
+});
diff --git a/web/src/views/system/StorageMetrics.tsx b/web/src/views/system/StorageMetrics.tsx
index 7f12f46e0..b5f24158a 100644
--- a/web/src/views/system/StorageMetrics.tsx
+++ b/web/src/views/system/StorageMetrics.tsx
@@ -22,6 +22,7 @@ import { Link } from "react-router-dom";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { LuExternalLink } from "react-icons/lu";
import { FaExclamationTriangle } from "react-icons/fa";
+import { aggregateRecordingRoots } from "./storageMetricsUtil";
type CameraStorage = {
[key: string]: {
@@ -40,12 +41,12 @@ type RecordingsStorageResponse = CameraStorage & {
type StorageMetricsProps = {
setLastUpdated: (last: number) => void;
};
+
export default function StorageMetrics({
setLastUpdated,
}: StorageMetricsProps) {
- const { data: recordingsStorage } = useSWR(
- "recordings/storage",
- );
+ const { data: recordingsStorage } =
+ useSWR("recordings/storage");
const { data: stats } = useSWR("stats");
const { data: config } = useSWR("config", {
revalidateOnFocus: false,
@@ -71,10 +72,18 @@ export default function StorageMetrics({
}, [recordingsStorage]);
const recordingRoots = useMemo(
- () => recordingsStorage?.recording_roots ?? recordingsStorage?.__recording_roots ?? [],
+ () =>
+ recordingsStorage?.recording_roots ??
+ recordingsStorage?.__recording_roots ??
+ [],
[recordingsStorage],
);
+ const overviewRecordingStorage = useMemo(
+ () => aggregateRecordingRoots(recordingRoots),
+ [recordingRoots],
+ );
+
const totalStorage = useMemo(() => {
if (!cameraStorage || !stats) {
return undefined;
@@ -158,8 +167,8 @@ export default function StorageMetrics({
{earliestDate && (
@@ -226,6 +235,10 @@ export default function StorageMetrics({
/>
+
+ {t("storage.recordings.roots")}
+
+
{t("storage.cameraStorage.title")}
@@ -236,10 +249,6 @@ export default function StorageMetrics({
totalStorage={totalStorage}
/>
-
- {t("storage.recordings.roots")}
-
-
);
}
diff --git a/web/src/views/system/storageMetricsUtil.ts b/web/src/views/system/storageMetricsUtil.ts
new file mode 100644
index 000000000..f174800b1
--- /dev/null
+++ b/web/src/views/system/storageMetricsUtil.ts
@@ -0,0 +1,25 @@
+import { RecordingRootStorage } from "@/components/storage/RecordingsRoots";
+
+export function aggregateRecordingRoots(
+ recordingRoots: RecordingRootStorage[],
+): { used: number; total: number } {
+ const seen = new Set();
+
+ return recordingRoots.reduce(
+ (acc, root) => {
+ // Prefer filesystem metadata for deduplication when available.
+ // Fall back to path, which cannot deduplicate separate paths on the same mount.
+ const dedupeKey = root.filesystem || root.path;
+
+ if (seen.has(dedupeKey)) {
+ return acc;
+ }
+
+ seen.add(dedupeKey);
+ acc.used += root.used || 0;
+ acc.total += root.total || 0;
+ return acc;
+ },
+ { used: 0, total: 0 },
+ );
+}