From 22910292aad2a88be5be433b7762cb6abe19b323 Mon Sep 17 00:00:00 2001 From: ibs0d <53568938+ibs0d@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:58:20 +1100 Subject: [PATCH] Update storage overview totals and section order --- web/src/views/system/StorageMetrics.test.tsx | 176 +++++++++++++++++++ web/src/views/system/StorageMetrics.tsx | 29 +-- web/src/views/system/storageMetricsUtil.ts | 25 +++ 3 files changed, 220 insertions(+), 10 deletions(-) create mode 100644 web/src/views/system/StorageMetrics.test.tsx create mode 100644 web/src/views/system/storageMetricsUtil.ts 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 }, + ); +}