From 324953d3a5dde82f38961981b048479a58313599 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Thu, 12 Mar 2026 17:57:42 -0500
Subject: [PATCH] UI tweaks (#22405)
* add shm frame lifetime calculation and update UI for shared memory metrics
* consistent sizing on activity indicator in save buttons
* fix offline overlay overflowing on mobile when in grid mode
---
web/public/locales/en/views/system.json | 6 +-
.../components/filter/CameraGroupSelector.tsx | 2 +-
.../components/overlay/CreateRoleDialog.tsx | 2 +-
.../overlay/CreateTriggerDialog.tsx | 2 +-
.../components/overlay/CreateUserDialog.tsx | 2 +-
.../overlay/EditRoleCamerasDialog.tsx | 2 +-
.../components/overlay/SetPasswordDialog.tsx | 2 +-
.../overlay/detail/AnnotationSettingsPane.tsx | 2 +-
web/src/components/player/LivePlayer.tsx | 59 +++++---
.../settings/CameraStreamingDialog.tsx | 2 +-
.../settings/MotionMaskEditPane.tsx | 2 +-
.../settings/ObjectMaskEditPane.tsx | 2 +-
web/src/components/settings/ZoneEditPane.tsx | 2 +-
web/src/types/stats.ts | 1 +
.../settings/EnrichmentsSettingsView.tsx | 2 +-
web/src/views/settings/MotionTunerView.tsx | 2 +-
web/src/views/system/StorageMetrics.tsx | 126 +++++++++++++-----
17 files changed, 147 insertions(+), 71 deletions(-)
diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json
index faaff31c9..460a1d337 100644
--- a/web/public/locales/en/views/system.json
+++ b/web/public/locales/en/views/system.json
@@ -135,7 +135,11 @@
},
"shm": {
"title": "SHM (shared memory) allocation",
- "warning": "The current SHM size of {{total}}MB is too small. Increase it to at least {{min_shm}}MB."
+ "warning": "The current SHM size of {{total}}MB is too small. Increase it to at least {{min_shm}}MB.",
+ "frameLifetime": {
+ "title": "Frame lifetime",
+ "description": "Each camera has {{frames}} frame slots in shared memory. At the fastest camera's frame rate, each frame is available for approximately {{lifetime}}s before being overwritten."
+ }
},
"cameraStorage": {
"title": "Camera Storage",
diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx
index 14845fdb8..6d86c57f8 100644
--- a/web/src/components/filter/CameraGroupSelector.tsx
+++ b/web/src/components/filter/CameraGroupSelector.tsx
@@ -990,7 +990,7 @@ export function CameraGroupEdit({
>
{isLoading ? (
-
+
{t("button.saving", { ns: "common" })}
) : (
diff --git a/web/src/components/overlay/CreateRoleDialog.tsx b/web/src/components/overlay/CreateRoleDialog.tsx
index 0b10f1c9d..023d2b665 100644
--- a/web/src/components/overlay/CreateRoleDialog.tsx
+++ b/web/src/components/overlay/CreateRoleDialog.tsx
@@ -231,7 +231,7 @@ export default function CreateRoleDialog({
>
{isLoading ? (
-
+
{t("button.saving", { ns: "common" })}
) : (
diff --git a/web/src/components/overlay/CreateTriggerDialog.tsx b/web/src/components/overlay/CreateTriggerDialog.tsx
index ef30c649d..1db934e3c 100644
--- a/web/src/components/overlay/CreateTriggerDialog.tsx
+++ b/web/src/components/overlay/CreateTriggerDialog.tsx
@@ -454,7 +454,7 @@ export default function CreateTriggerDialog({
>
{isLoading ? (
-
+
{t("button.saving", { ns: "common" })}
) : (
diff --git a/web/src/components/overlay/CreateUserDialog.tsx b/web/src/components/overlay/CreateUserDialog.tsx
index 1ce32b61b..c0e17bb37 100644
--- a/web/src/components/overlay/CreateUserDialog.tsx
+++ b/web/src/components/overlay/CreateUserDialog.tsx
@@ -432,7 +432,7 @@ export default function CreateUserDialog({
>
{isLoading ? (
-
+
{t("button.saving", { ns: "common" })}
) : (
diff --git a/web/src/components/overlay/EditRoleCamerasDialog.tsx b/web/src/components/overlay/EditRoleCamerasDialog.tsx
index 059de4a78..872226318 100644
--- a/web/src/components/overlay/EditRoleCamerasDialog.tsx
+++ b/web/src/components/overlay/EditRoleCamerasDialog.tsx
@@ -177,7 +177,7 @@ export default function EditRoleCamerasDialog({
>
{isLoading ? (
-
+
{t("button.saving", { ns: "common" })}
) : (
diff --git a/web/src/components/overlay/SetPasswordDialog.tsx b/web/src/components/overlay/SetPasswordDialog.tsx
index 084a841e4..96c29ffdb 100644
--- a/web/src/components/overlay/SetPasswordDialog.tsx
+++ b/web/src/components/overlay/SetPasswordDialog.tsx
@@ -471,7 +471,7 @@ export default function SetPasswordDialog({
>
{isLoading ? (
-
+
{t("button.saving", { ns: "common" })}
) : (
diff --git a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx
index e6c4b19a2..06e0d80ce 100644
--- a/web/src/components/overlay/detail/AnnotationSettingsPane.tsx
+++ b/web/src/components/overlay/detail/AnnotationSettingsPane.tsx
@@ -192,7 +192,7 @@ export function AnnotationSettingsPane({
>
{isLoading ? (
-
+
{t("button.saving", { ns: "common" })}
) : (
diff --git a/web/src/components/player/LivePlayer.tsx b/web/src/components/player/LivePlayer.tsx
index f48a7d475..2e37f41a4 100644
--- a/web/src/components/player/LivePlayer.tsx
+++ b/web/src/components/player/LivePlayer.tsx
@@ -3,6 +3,7 @@ import { CameraConfig } from "@/types/frigateConfig";
import AutoUpdatingCameraImage from "../camera/AutoUpdatingCameraImage";
import ActivityIndicator from "../indicators/activity-indicator";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useResizeObserver } from "@/hooks/resize-observer";
import MSEPlayer from "./MsePlayer";
import JSMpegPlayer from "./JSMpegPlayer";
import { MdCircle } from "react-icons/md";
@@ -80,6 +81,7 @@ export default function LivePlayer({
const { t } = useTranslation(["components/player"]);
const internalContainerRef = useRef(null);
+ const overlayRef = useRef(null);
const cameraName = useCameraFriendlyName(cameraConfig);
@@ -87,6 +89,10 @@ export default function LivePlayer({
const inDashboard = containerRef?.current == null;
+ const [overlayDimensions] = useResizeObserver(overlayRef);
+ const isCompact =
+ overlayDimensions.width > 0 && overlayDimensions.width < 280;
+
// stats
const [stats, setStats] = useState({
@@ -317,7 +323,14 @@ export default function LivePlayer({
return (
{
+ overlayRef.current = node;
+ if (cameraRef) {
+ cameraRef(node);
+ } else {
+ internalContainerRef.current = node;
+ }
+ }}
data-camera={cameraConfig.name}
className={cn(
"relative flex w-full cursor-pointer justify-center outline",
@@ -428,16 +441,18 @@ export default function LivePlayer({
{t("streamOffline.title")}
-
-
- streamOffline.desc
-
-
+ {!isCompact && (
+
+
+ streamOffline.desc
+
+
+ )}
>
@@ -448,16 +463,18 @@ export default function LivePlayer({
{t("streamOffline.title")}
-
-
- streamOffline.desc
-
-
+ {!isCompact && (
+
+
+ streamOffline.desc
+
+
+ )}
)}
diff --git a/web/src/components/settings/CameraStreamingDialog.tsx b/web/src/components/settings/CameraStreamingDialog.tsx
index b7f1979ea..4cabd2d86 100644
--- a/web/src/components/settings/CameraStreamingDialog.tsx
+++ b/web/src/components/settings/CameraStreamingDialog.tsx
@@ -391,7 +391,7 @@ export function CameraStreamingDialog({
>
{isLoading ? (
-
+
{t("button.saving", { ns: "common" })}
) : (
diff --git a/web/src/components/settings/MotionMaskEditPane.tsx b/web/src/components/settings/MotionMaskEditPane.tsx
index 3857c4060..d613704cb 100644
--- a/web/src/components/settings/MotionMaskEditPane.tsx
+++ b/web/src/components/settings/MotionMaskEditPane.tsx
@@ -440,7 +440,7 @@ export default function MotionMaskEditPane({
>
{isLoading ? (
-
+
{t("button.saving", { ns: "common" })}
) : (
diff --git a/web/src/components/settings/ObjectMaskEditPane.tsx b/web/src/components/settings/ObjectMaskEditPane.tsx
index 380c40be1..134976396 100644
--- a/web/src/components/settings/ObjectMaskEditPane.tsx
+++ b/web/src/components/settings/ObjectMaskEditPane.tsx
@@ -461,7 +461,7 @@ export default function ObjectMaskEditPane({
>
{isLoading ? (
-
+
{t("button.saving", { ns: "common" })}
) : (
diff --git a/web/src/components/settings/ZoneEditPane.tsx b/web/src/components/settings/ZoneEditPane.tsx
index e52853972..20f4b097a 100644
--- a/web/src/components/settings/ZoneEditPane.tsx
+++ b/web/src/components/settings/ZoneEditPane.tsx
@@ -936,7 +936,7 @@ export default function ZoneEditPane({
>
{isLoading ? (
-
+
{t("button.saving", { ns: "common" })}
) : (
diff --git a/web/src/types/stats.ts b/web/src/types/stats.ts
index 8b22849be..1046e0b47 100644
--- a/web/src/types/stats.ts
+++ b/web/src/types/stats.ts
@@ -86,6 +86,7 @@ export type StorageStats = {
used: number;
mount_type: string;
min_shm?: number;
+ shm_frame_count?: number;
};
export type PotentialProblem = {
diff --git a/web/src/views/settings/EnrichmentsSettingsView.tsx b/web/src/views/settings/EnrichmentsSettingsView.tsx
index 6aba50dd3..f066f2df9 100644
--- a/web/src/views/settings/EnrichmentsSettingsView.tsx
+++ b/web/src/views/settings/EnrichmentsSettingsView.tsx
@@ -603,7 +603,7 @@ export default function EnrichmentsSettingsView({
>
{isLoading ? (
-
+
{t("button.saving", { ns: "common" })}
) : (
diff --git a/web/src/views/settings/MotionTunerView.tsx b/web/src/views/settings/MotionTunerView.tsx
index 25d5f1469..e95044ec1 100644
--- a/web/src/views/settings/MotionTunerView.tsx
+++ b/web/src/views/settings/MotionTunerView.tsx
@@ -299,7 +299,7 @@ export default function MotionTunerView({
>
{isLoading ? (
-
+
{t("button.saving", { ns: "common" })}
) : (
diff --git a/web/src/views/system/StorageMetrics.tsx b/web/src/views/system/StorageMetrics.tsx
index 2c222d6c3..d36200849 100644
--- a/web/src/views/system/StorageMetrics.tsx
+++ b/web/src/views/system/StorageMetrics.tsx
@@ -89,6 +89,35 @@ export default function StorageMetrics({
timezone,
);
+ const shmFrameLifetime = useMemo(() => {
+ if (!stats || !config) {
+ return undefined;
+ }
+
+ const shmFrameCount = stats.service.storage["/dev/shm"]?.shm_frame_count;
+
+ if (!shmFrameCount || shmFrameCount <= 0) {
+ return undefined;
+ }
+
+ let maxCameraFps = 0;
+
+ for (const [name, camStats] of Object.entries(stats.cameras)) {
+ if (config.cameras[name]?.enabled && camStats.camera_fps > 0) {
+ maxCameraFps = Math.max(maxCameraFps, camStats.camera_fps);
+ }
+ }
+
+ if (maxCameraFps === 0) {
+ return undefined;
+ }
+
+ return {
+ frames: shmFrameCount,
+ lifetime: Math.round((shmFrameCount / maxCameraFps) * 10) / 10,
+ };
+ }, [stats, config]);
+
if (!cameraStorage || !stats || !totalStorage || !config) {
return;
}
@@ -148,43 +177,68 @@ export default function StorageMetrics({
/dev/shm
- {stats.service.storage["/dev/shm"]["total"] <
- (stats.service.storage["/dev/shm"]["min_shm"] ?? 0) && (
-
-
-
-
-
-
- {t("storage.shm.warning", {
- total: stats.service.storage["/dev/shm"]["total"],
- min_shm: stats.service.storage["/dev/shm"]["min_shm"],
- })}
-
-
- {t("readTheDocumentation", { ns: "common" })}
-
-
+
+ {shmFrameLifetime && (
+
+
+
+
+
+
+ {t("storage.shm.frameLifetime.description", {
+ frames: shmFrameLifetime.frames,
+ lifetime: shmFrameLifetime.lifetime,
+ })}
-
-
-
- )}
+
+
+ )}
+ {stats.service.storage["/dev/shm"]["total"] <
+ (stats.service.storage["/dev/shm"]["min_shm"] ?? 0) && (
+
+
+
+
+
+
+ {t("storage.shm.warning", {
+ total: stats.service.storage["/dev/shm"]["total"],
+ min_shm: stats.service.storage["/dev/shm"]["min_shm"],
+ })}
+
+
+ {t("readTheDocumentation", { ns: "common" })}
+
+
+
+
+
+
+ )}
+