From dab0d8583062b69a989eda6e3b05125194e8df8b Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:24:19 -0500 Subject: [PATCH] Add overflow menu to system tabs on mobile (#24543) * add overflow menu to system tabs on mobile * add default icon, observe measure div, fix tests --- web/e2e/helpers/system-tabs.ts | 11 + web/e2e/specs/auth.spec.ts | 5 +- web/e2e/specs/live.spec.ts | 19 ++ web/e2e/specs/system-health.spec.ts | 5 +- web/e2e/specs/system.spec.ts | 137 ++++++------- web/public/locales/en/views/system.json | 2 + .../components/filter/CameraGroupSelector.tsx | 192 +++--------------- web/src/components/mobile/OverflowStrip.tsx | 167 +++++++++++++++ web/src/pages/System.tsx | 93 ++++++--- web/src/views/live/LiveDashboardView.tsx | 2 +- 10 files changed, 361 insertions(+), 272 deletions(-) create mode 100644 web/e2e/helpers/system-tabs.ts create mode 100644 web/src/components/mobile/OverflowStrip.tsx diff --git a/web/e2e/helpers/system-tabs.ts b/web/e2e/helpers/system-tabs.ts new file mode 100644 index 0000000000..17862b1e5f --- /dev/null +++ b/web/e2e/helpers/system-tabs.ts @@ -0,0 +1,11 @@ +import type { FrigateApp } from "../fixtures/frigate-test"; + +// On mobile the System tabs sit in an OverflowStrip, which keeps an inert copy +// of every tab for measurement and hides the ones that do not fit behind a +// kebab. The selected tab always stays in the strip. + +export function systemTab(frigateApp: FrigateApp, name: string) { + return frigateApp.page + .locator(`[aria-label="Select ${name}" i]:not([inert] *)`) + .first(); +} diff --git a/web/e2e/specs/auth.spec.ts b/web/e2e/specs/auth.spec.ts index f0326a5d02..80aca00b99 100644 --- a/web/e2e/specs/auth.spec.ts +++ b/web/e2e/specs/auth.spec.ts @@ -7,12 +7,13 @@ */ import { test, expect } from "../fixtures/frigate-test"; +import { systemTab } from "../helpers/system-tabs"; import { viewerProfile } from "../fixtures/mock-data/profile"; test.describe("Auth — admin access @high", () => { test("admin /system renders general tab", async ({ frigateApp }) => { await frigateApp.goto("/system"); - await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({ + await expect(systemTab(frigateApp, "general")).toBeVisible({ timeout: 15_000, }); }); @@ -28,7 +29,7 @@ test.describe("Auth — admin access @high", () => { test("admin /logs renders frigate tab", async ({ frigateApp }) => { await frigateApp.goto("/logs"); - await expect(frigateApp.page.getByLabel("Select frigate")).toBeVisible({ + await expect(systemTab(frigateApp, "frigate")).toBeVisible({ timeout: 5_000, }); }); diff --git a/web/e2e/specs/live.spec.ts b/web/e2e/specs/live.spec.ts index af28f00307..20265072c5 100644 --- a/web/e2e/specs/live.spec.ts +++ b/web/e2e/specs/live.spec.ts @@ -287,3 +287,22 @@ test.describe("Live mobile layout @critical @mobile", () => { await expect(frigateApp.page.locator("body")).toBeVisible(); }); }); + +test.describe("Live camera groups @medium", () => { + test("a group with an invalid icon renders a fallback icon", async ({ + frigateApp, + }) => { + await frigateApp.installDefaults({ + config: { + camera_groups: { + outdoor: { cameras: ["front_door"], icon: "generic" }, + }, + }, + }); + await frigateApp.goto("/"); + const group = frigateApp.page + .locator('[aria-label="Camera Groups"]:not([inert] *)') + .first(); + await expect(group.locator("svg")).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/web/e2e/specs/system-health.spec.ts b/web/e2e/specs/system-health.spec.ts index b4de9839f7..0bb1cc1a6b 100644 --- a/web/e2e/specs/system-health.spec.ts +++ b/web/e2e/specs/system-health.spec.ts @@ -6,6 +6,7 @@ */ import { test, expect } from "../fixtures/frigate-test"; +import { systemTab } from "../helpers/system-tabs"; import { viewerProfile } from "../fixtures/mock-data/profile"; const NOW = Math.floor(Date.now() / 1000); @@ -55,7 +56,7 @@ test.describe("System — Health tab @medium", () => { }); await frigateApp.goto("/system"); - await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute( + await expect(systemTab(frigateApp, "health")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, @@ -801,7 +802,7 @@ test.describe("System — Health notices sources @medium", () => { stats: QUIET_STATS, }); await frigateApp.goto("/system#health"); - await expect(frigateApp.page.getByLabel("Select health")).toHaveAttribute( + await expect(systemTab(frigateApp, "health")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, diff --git a/web/e2e/specs/system.spec.ts b/web/e2e/specs/system.spec.ts index bd90ee2bfe..7823368c2b 100644 --- a/web/e2e/specs/system.spec.ts +++ b/web/e2e/specs/system.spec.ts @@ -6,42 +6,66 @@ * RestartDialog cancel flow. */ -import { test, expect } from "../fixtures/frigate-test"; +import { test, expect, FrigateApp } from "../fixtures/frigate-test"; import { expectBodyInteractive, waitForBodyInteractive, } from "../helpers/overlay-interaction"; +import { systemTab } from "../helpers/system-tabs"; + +async function selectTab(frigateApp: FrigateApp, name: string) { + const kebab = frigateApp.page.getByLabel("Show all tabs"); + + if (frigateApp.isMobile && (await kebab.isVisible())) { + await kebab.click(); + await frigateApp.page + .locator(`[aria-label="Select ${name}" i]:not([inert] *)`) + .last() + .click(); + return; + } + + await systemTab(frigateApp, name).click(); +} + +async function expectTabActive(frigateApp: FrigateApp, name: string) { + await expect(systemTab(frigateApp, name)).toHaveAttribute( + "data-state", + "on", + { + timeout: 5_000, + }, + ); +} test.describe("System — tabs @medium", () => { test("general tab is active by default via #general hash", async ({ frigateApp, }) => { await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + await expect(systemTab(frigateApp, "general")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, ); - await expect(frigateApp.page.getByLabel("Select storage")).toBeVisible(); - await expect(frigateApp.page.getByLabel("Select cameras")).toBeVisible(); + if (!frigateApp.isMobile) { + await expect(systemTab(frigateApp, "storage")).toBeVisible(); + await expect(systemTab(frigateApp, "cameras")).toBeVisible(); + } }); test("Storage tab activates and deactivates General", async ({ frigateApp, }) => { await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + await expect(systemTab(frigateApp, "general")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, ); - await frigateApp.page.getByLabel("Select storage").click(); - await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute( - "data-state", - "on", - { timeout: 5_000 }, - ); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + await selectTab(frigateApp, "storage"); + await expectTabActive(frigateApp, "storage"); + await expect(systemTab(frigateApp, "general")).toHaveAttribute( "data-state", "off", ); @@ -49,24 +73,20 @@ test.describe("System — tabs @medium", () => { test("Cameras tab activates", async ({ frigateApp }) => { await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + await expect(systemTab(frigateApp, "general")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, ); - await frigateApp.page.getByLabel("Select cameras").click(); - await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute( - "data-state", - "on", - { timeout: 5_000 }, - ); + await selectTab(frigateApp, "cameras"); + await expectTabActive(frigateApp, "cameras"); }); test("general tab shows version and last-refreshed", async ({ frigateApp, }) => { await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + await expect(systemTab(frigateApp, "general")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, @@ -87,17 +107,13 @@ test.describe("System — tabs @medium", () => { frigateApp, }) => { await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + await expect(systemTab(frigateApp, "general")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, ); - await frigateApp.page.getByLabel("Select storage").click(); - await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute( - "data-state", - "on", - { timeout: 5_000 }, - ); + await selectTab(frigateApp, "storage"); + await expectTabActive(frigateApp, "storage"); // On desktop, tab buttons render text labels so the word "storage" // always appears in #pageRoot after switching. On mobile, tabs are // icon-only, so we verify the general-tab content disappears instead @@ -112,25 +128,23 @@ test.describe("System — tabs @medium", () => { } else { // Mobile: tab activation (data-state "on") already asserted above. // Additionally confirm general tab is no longer the active tab. - await expect( - frigateApp.page.getByLabel("Select general"), - ).toHaveAttribute("data-state", "off", { timeout: 5_000 }); + await expect(systemTab(frigateApp, "general")).toHaveAttribute( + "data-state", + "off", + { timeout: 5_000 }, + ); } }); test("cameras tab renders each configured camera", async ({ frigateApp }) => { await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + await expect(systemTab(frigateApp, "general")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, ); - await frigateApp.page.getByLabel("Select cameras").click(); - await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute( - "data-state", - "on", - { timeout: 5_000 }, - ); + await selectTab(frigateApp, "cameras"); + await expectTabActive(frigateApp, "cameras"); // Cameras tab lists every camera from config/stats. The default // mock has front_door, backyard, garage. for (const cam of ["front_door", "backyard", "garage"]) { @@ -151,17 +165,13 @@ test.describe("System — tabs @medium", () => { config: { semantic_search: { enabled: true } }, }); await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + await expect(systemTab(frigateApp, "general")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, ); - const enrichTab = frigateApp.page.getByLabel(/select enrichments/i).first(); - await expect(enrichTab).toBeVisible({ timeout: 5_000 }); - await enrichTab.click(); - await expect(enrichTab).toHaveAttribute("data-state", "on", { - timeout: 5_000, - }); + await selectTab(frigateApp, "enrichments"); + await expectTabActive(frigateApp, "enrichments"); }); }); @@ -223,31 +233,27 @@ test.describe("System — mobile @medium @mobile", () => { test("tabs render at mobile viewport", async ({ frigateApp }) => { await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toBeVisible({ + await expect(systemTab(frigateApp, "general")).toBeVisible({ timeout: 15_000, }); }); test("switching tabs works at mobile viewport", async ({ frigateApp }) => { await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + await expect(systemTab(frigateApp, "general")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, ); - await frigateApp.page.getByLabel("Select storage").click(); - await expect(frigateApp.page.getByLabel("Select storage")).toHaveAttribute( - "data-state", - "on", - { timeout: 5_000 }, - ); + await selectTab(frigateApp, "storage"); + await expectTabActive(frigateApp, "storage"); }); test("header controls leave the logo uncovered on a narrow phone", async ({ frigateApp, }) => { await frigateApp.goto("/system#general"); - await expect(frigateApp.page.getByLabel("Select general")).toHaveAttribute( + await expect(systemTab(frigateApp, "general")).toHaveAttribute( "data-state", "on", { timeout: 15_000 }, @@ -255,9 +261,9 @@ test.describe("System — mobile @medium @mobile", () => { await frigateApp.page.setViewportSize({ width: 320, height: 740 }); const logo = frigateApp.page.locator("svg.fill-current").first(); - const tabs = frigateApp.page - .locator("[data-radix-scroll-area-viewport]") - .filter({ has: frigateApp.page.getByLabel("Select general") }); + const kebab = frigateApp.page.getByLabel("Show all tabs"); + await expect(kebab).toBeVisible(); + const tabs = kebab.locator(".."); const refreshed = frigateApp.page.getByText(/Just now|ago/); const logoBox = await logo.boundingBox(); @@ -267,20 +273,9 @@ test.describe("System — mobile @medium @mobile", () => { expect(tabsBox!.x + tabsBox!.width).toBeLessThanOrEqual(logoBox!.x + 1); expect(refreshedBox!.x).toBeGreaterThanOrEqual(logoBox!.x + logoBox!.width); - // the clipped tabs stay reachable by scrolling - const overflow = await tabs.evaluate((el) => ({ - scroll: el.scrollWidth, - client: el.clientWidth, - })); - expect(overflow.scroll).toBeGreaterThan(overflow.client); - await tabs.evaluate((el) => { - el.scrollLeft = el.scrollWidth; - }); - await frigateApp.page.getByLabel("Select cameras").click(); - await expect(frigateApp.page.getByLabel("Select cameras")).toHaveAttribute( - "data-state", - "on", - { timeout: 5_000 }, - ); + // the tabs that do not fit stay reachable through the kebab + await selectTab(frigateApp, "cameras"); + await expectTabActive(frigateApp, "cameras"); + await expect(frigateApp.page.getByLabel("Show less")).toHaveCount(0); }); }); diff --git a/web/public/locales/en/views/system.json b/web/public/locales/en/views/system.json index ad52d48d57..f4fb03e47d 100644 --- a/web/public/locales/en/views/system.json +++ b/web/public/locales/en/views/system.json @@ -14,6 +14,8 @@ }, "title": "System", "metrics": "System metrics", + "showAllTabs": "Show all tabs", + "showLessTabs": "Show less", "health": { "title": "Health", "notices": { diff --git a/web/src/components/filter/CameraGroupSelector.tsx b/web/src/components/filter/CameraGroupSelector.tsx index 2ebd6207b1..b75a34eb0a 100644 --- a/web/src/components/filter/CameraGroupSelector.tsx +++ b/web/src/components/filter/CameraGroupSelector.tsx @@ -8,18 +8,8 @@ import { isDesktop, isMobile } from "react-device-detect"; import useSWR from "swr"; import { MdHome } from "react-icons/md"; import { Button, buttonVariants } from "../ui/button"; -import { - useCallback, - useContext, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; -import { AnimatePresence, motion } from "framer-motion"; -import { HiDotsHorizontal } from "react-icons/hi"; -import { IoClose } from "react-icons/io5"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import OverflowStrip from "../mobile/OverflowStrip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; import { LuPencil, LuPlus } from "react-icons/lu"; import { @@ -156,80 +146,7 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { const [addGroup, setAddGroup] = useState(false); - // mobile overflow reveal - the group strip sits left of the logo and is - // clipped (not scrollable) when there are too many groups, so render only - // the buttons that fully fit and surface a kebab next to the last visible - // one that expands a panel revealing all of them - - const [expanded, setExpanded] = useState(false); - // null => all buttons fit, render them all with no kebab; a number => only - // that many fit alongside the kebab - const [visibleCount, setVisibleCount] = useState(null); - const wrapperRef = useRef(null); - const measureRef = useRef(null); - - useLayoutEffect(() => { - if (isDesktop) { - return; - } - - const wrapper = wrapperRef.current; - const measure = measureRef.current; - - if (!wrapper || !measure) { - return; - } - - const gap = 8; // gap-2 between buttons in the strip - const wrapperGap = 4; // gap-1 between the strip and the kebab - - const compute = () => { - const buttons = Array.from(measure.children) as HTMLElement[]; - - if (buttons.length === 0) { - return; - } - - // the trailing child of the measurement row is a kebab clone - const kebab = buttons[buttons.length - 1]; - const groupButtons = buttons.slice(0, -1); - const available = wrapper.clientWidth; - const fullWidth = - groupButtons.reduce((sum, el) => sum + el.offsetWidth, 0) + - Math.max(groupButtons.length - 1, 0) * gap; - - if (fullWidth <= available) { - setVisibleCount(null); - return; - } - - const budget = available - kebab.offsetWidth - wrapperGap; - let used = 0; - let count = 0; - - for (const el of groupButtons) { - const next = (count === 0 ? 0 : gap) + el.offsetWidth; - - if (used + next <= budget) { - used += next; - count += 1; - } else { - break; - } - } - - setVisibleCount(Math.max(count, 1)); - }; - - compute(); - - const observer = new ResizeObserver(compute); - observer.observe(wrapper); - - return () => observer.disconnect(); - }, [groups, isAdmin]); - - const groupButtons = (afterSelect?: () => void) => { + const groupButtons = () => { const buttons = [ )), ]; @@ -282,7 +202,6 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { size="sm" onClick={() => { setAddGroup(true); - afterSelect?.(); }} > @@ -350,12 +269,14 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { onMouseEnter={() => showTooltip(name)} onMouseLeave={() => showTooltip(undefined)} > - {config && config.icon && isValidIconName(config.icon) && ( - - )} + @@ -390,74 +311,13 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) { )} ) : ( -
-
- {visibleCount == null - ? groupButtons() - : groupButtons().slice(0, visibleCount)} -
- {visibleCount != null && ( - - )} - - {/* invisible row used only to measure natural button widths so we - can render exactly the buttons that fully fit */} -
-
- {groupButtons()} - -
-
- - {expanded && ( -
setExpanded(false)} - /> - )} - - {expanded && ( - -
- {groupButtons(() => setExpanded(false))} - -
-
- )} -
-
+ name == group) + 1} + showAllLabel={t("group.showAll")} + showLessLabel={t("group.showLess")} + /> )} ); diff --git a/web/src/components/mobile/OverflowStrip.tsx b/web/src/components/mobile/OverflowStrip.tsx new file mode 100644 index 0000000000..6775108449 --- /dev/null +++ b/web/src/components/mobile/OverflowStrip.tsx @@ -0,0 +1,167 @@ +import { ReactNode, useLayoutEffect, useRef, useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { HiDotsHorizontal } from "react-icons/hi"; +import { IoClose } from "react-icons/io5"; +import { Button } from "../ui/button"; +import { cn } from "@/lib/utils"; + +type OverflowStripProps = { + className?: string; + items: ReactNode[]; + activeIndex?: number; + gapClassName?: string; + showAllLabel: string; + showLessLabel: string; +}; + +// Renders only the items that fully fit and surfaces a kebab next to the last +// visible one. The kebab expands a panel over the nearest positioned ancestor +// that reveals every item. +export default function OverflowStrip({ + className, + items, + activeIndex = 0, + gapClassName = "gap-2", + showAllLabel, + showLessLabel, +}: OverflowStripProps) { + const [expanded, setExpanded] = useState(false); + // null => all items fit, render them all with no kebab; a number => only + // that many fit alongside the kebab + const [visibleCount, setVisibleCount] = useState(null); + const wrapperRef = useRef(null); + const measureRef = useRef(null); + + useLayoutEffect(() => { + const wrapper = wrapperRef.current; + const measure = measureRef.current; + + if (!wrapper || !measure) { + return; + } + + const wrapperGap = 4; // gap-1 between the strip and the kebab + + const compute = () => { + const children = Array.from(measure.children) as HTMLElement[]; + + if (children.length === 0) { + return; + } + + // the trailing child of the measurement row is a kebab clone + const kebab = children[children.length - 1]; + const start = children[0].offsetLeft; + const ends = children + .slice(0, -1) + .map((el) => el.offsetLeft + el.offsetWidth - start); + const available = wrapper.clientWidth; + + if (ends[ends.length - 1] <= available) { + setVisibleCount(null); + return; + } + + const budget = available - kebab.offsetWidth - wrapperGap; + const count = ends.filter((end) => end <= budget).length; + + setVisibleCount(Math.max(count, 1)); + }; + + compute(); + + const observer = new ResizeObserver(compute); + observer.observe(wrapper); + observer.observe(measure); + + return () => observer.disconnect(); + }, [items.length, gapClassName]); + + // a selected item past the cut takes the last visible slot + const visibleItems = + visibleCount == null + ? items + : activeIndex >= visibleCount + ? [...items.slice(0, visibleCount - 1), items[activeIndex]] + : items.slice(0, visibleCount); + + return ( +
+
+ {visibleItems} +
+ {visibleCount != null && ( + + )} + + {/* invisible row used only to measure natural item widths so we can + render exactly the items that fully fit */} +
+
+ {items} + +
+
+ + {expanded && ( +
setExpanded(false)} + /> + )} + + {expanded && ( + + {/* a tap on any item bubbles up and collapses the panel */} +
setExpanded(false)} + > + {items} + +
+
+ )} +
+
+ ); +} diff --git a/web/src/pages/System.tsx b/web/src/pages/System.tsx index ae65bb5a5f..82b8c73c80 100644 --- a/web/src/pages/System.tsx +++ b/web/src/pages/System.tsx @@ -1,8 +1,10 @@ import useSWR from "swr"; import { FrigateStats } from "@/types/stats"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { ReactNode, useEffect, useMemo, useRef, useState } from "react"; import TimeAgo from "@/components/dynamic/TimeAgo"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Toggle } from "@/components/ui/toggle"; +import OverflowStrip from "@/components/mobile/OverflowStrip"; import { isDesktop, isMobile } from "react-device-detect"; import GeneralMetrics from "@/views/system/GeneralMetrics"; import StorageMetrics from "@/views/system/StorageMetrics"; @@ -36,6 +38,14 @@ const allMetrics = [ ] as const; type SystemMetric = (typeof allMetrics)[number]; +const metricIcons: Record = { + health: , + general: , + enrichments: , + storage: , + cameras: , +}; + function System() { const { t } = useTranslation(["views/system"]); const { data: config } = useSWR("config", { @@ -98,43 +108,66 @@ function System() { {isMobile && ( )} - -
- { - if (value) { - setPageToggle(value); - } - }} // don't allow the severity to be unselected - > - {Object.values(metrics).map((item) => ( - + ( + setPageToggle(item)} aria-label={t("selectItem", { ns: "common", item: t(item + ".title"), })} > - {item == "health" && } - {item == "general" && } - {item == "enrichments" && } - {item == "storage" && } - {item == "cameras" && } - {isDesktop && ( -
{t(item + ".title")}
- )} -
+ {metricIcons[item]} + ))} -
- + activeIndex={metrics.indexOf(pageToggle)} + gapClassName="gap-0.5" + showAllLabel={t("showAllTabs")} + showLessLabel={t("showLessTabs")} + />
-
+ ) : ( + +
+ { + if (value) { + setPageToggle(value); + } + }} // don't allow the severity to be unselected + > + {Object.values(metrics).map((item) => ( + + {metricIcons[item]} +
{t(item + ".title")}
+
+ ))} +
+ +
+
+ )}
{pageToggle == "health" && ( diff --git a/web/src/views/live/LiveDashboardView.tsx b/web/src/views/live/LiveDashboardView.tsx index ab0cc2ca2d..6f91ed7b70 100644 --- a/web/src/views/live/LiveDashboardView.tsx +++ b/web/src/views/live/LiveDashboardView.tsx @@ -419,7 +419,7 @@ export default function LiveDashboardView({ {isMobile && (
-
+
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (