Compare commits

..
12 changed files with 410 additions and 325 deletions
+6 -5
View File
@@ -59,6 +59,7 @@ def build_watchdog(
MagicMock(),
)
watchdog.requestor = MagicMock()
return watchdog
@@ -107,8 +108,8 @@ class TestCameraWatchdogStreamHealth(unittest.TestCase):
def test_status_goes_to_the_matching_role_topic(self):
watchdog = self._build_watchdog()
watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
watchdog.record_status[STREAM_TYPE_SUB].send("offline", 100.0)
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
watchdog._send_record_status(STREAM_TYPE_SUB, "offline", 100.0)
watchdog.requestor.send_data.assert_any_call(
"front_door/status/record", "online"
@@ -120,9 +121,9 @@ class TestCameraWatchdogStreamHealth(unittest.TestCase):
def test_status_is_cached_per_stream(self):
watchdog = self._build_watchdog()
watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
watchdog.record_status[STREAM_TYPE_SUB].send("online", 100.0)
watchdog.record_status[STREAM_TYPE_MAIN].send("online", 100.0)
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
watchdog._send_record_status(STREAM_TYPE_SUB, "online", 100.0)
watchdog._send_record_status(STREAM_TYPE_MAIN, "online", 100.0)
assert watchdog.requestor.send_data.call_count == 2
+43 -48
View File
@@ -6,7 +6,6 @@ import subprocess as sp
import threading
import time
from collections import defaultdict, deque
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from multiprocessing import Queue, Value
from multiprocessing.synchronize import Event as MpEvent
@@ -109,27 +108,6 @@ def capture_frames(
frame_index = 0 if frame_index == shm_frame_count - 1 else frame_index + 1
@dataclass
class RoleStatus:
"""Publishes a role's status when it changes or the resend interval elapses."""
requestor: InterProcessRequestor
topic: str
resend_interval: float
last_status: str | None = None
last_update_time: float = 0.0
def send(self, status: str, now: float) -> None:
"""Publish a changed status or resend it after the configured interval."""
if (
status != self.last_status
or (now - self.last_update_time) >= self.resend_interval
):
self.requestor.send_data(self.topic, status)
self.last_status = status
self.last_update_time = now
class CameraWatchdog(threading.Thread):
def __init__(
self,
@@ -208,16 +186,33 @@ class CameraWatchdog(threading.Thread):
self._stall_active: bool = False
# Status caching to reduce message volume
self.detect_status = self._role_status("detect")
self.record_status = {
stream_type: self._role_status(role)
for stream_type, role in STREAM_TYPE_TO_ROLE.items()
}
self._last_detect_status: str | None = None
self._last_record_status: dict[str, str] = {}
self._last_detect_status_update_time: float = 0.0
self._last_record_status_update_time: dict[str, float] = defaultdict(float)
def _role_status(self, role: str) -> RoleStatus:
return RoleStatus(
self.requestor, f"{self.config.name}/status/{role}", self.sleeptime
)
def _send_detect_status(self, status: str, now: float) -> None:
"""Send detect status only if changed or retry_interval has elapsed."""
if (
status != self._last_detect_status
or (now - self._last_detect_status_update_time) >= self.sleeptime
):
self.requestor.send_data(f"{self.config.name}/status/detect", status)
self._last_detect_status = status
self._last_detect_status_update_time = now
def _send_record_status(self, stream_type: str, status: str, now: float) -> None:
"""Send a record stream's status only if changed or retry_interval has elapsed."""
if (
status != self._last_record_status.get(stream_type)
or (now - self._last_record_status_update_time[stream_type])
>= self.sleeptime
):
self.requestor.send_data(
f"{self.config.name}/status/{STREAM_TYPE_TO_ROLE[stream_type]}", status
)
self._last_record_status[stream_type] = status
self._last_record_status_update_time[stream_type] = now
def _send_roles_offline(self, roles: list[CameraRoleEnum], now: float) -> None:
"""Send offline status for each role of a restarted ffmpeg process."""
@@ -227,7 +222,7 @@ class CameraWatchdog(threading.Thread):
stream_type = ROLE_TO_STREAM_TYPE.get(role.value)
if stream_type is not None:
self.record_status[stream_type].send("offline", now)
self._send_record_status(stream_type, "offline", now)
else:
self.requestor.send_data(
f"{self.config.name}/status/{role.value}", "offline"
@@ -393,11 +388,11 @@ class CameraWatchdog(threading.Thread):
# update camera status
now = datetime.now().timestamp()
self.detect_status.send("disabled", now)
self.record_status[STREAM_TYPE_MAIN].send("disabled", now)
self._send_detect_status("disabled", now)
self._send_record_status(STREAM_TYPE_MAIN, "disabled", now)
# cameras without a sub stream never get a record_sub topic
if self.config.record.sub.enabled:
self.record_status[STREAM_TYPE_SUB].send("disabled", now)
self._send_record_status(STREAM_TYPE_SUB, "disabled", now)
self.was_enabled = enabled
continue
@@ -470,7 +465,7 @@ class CameraWatchdog(threading.Thread):
can_restart = time_since_last_restart >= self.sleeptime
if not self.capture_thread.is_alive():
self.detect_status.send("offline", now)
self._send_detect_status("offline", now)
self.camera_fps.value = 0
self.logger.error(
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
@@ -482,7 +477,7 @@ class CameraWatchdog(threading.Thread):
self.fps_overflow_count += 1
if self.fps_overflow_count == 3:
self.detect_status.send("offline", now)
self._send_detect_status("offline", now)
self.fps_overflow_count = 0
self.camera_fps.value = 0
self.logger.info(
@@ -492,7 +487,7 @@ class CameraWatchdog(threading.Thread):
self.reset_capture_thread(drain_output=False)
last_restart_time = now
elif now - self.capture_thread.current_frame.value > 20:
self.detect_status.send("offline", now)
self._send_detect_status("offline", now)
self.camera_fps.value = 0
self.logger.info(
f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..."
@@ -502,7 +497,7 @@ class CameraWatchdog(threading.Thread):
last_restart_time = now
else:
# process is running normally
self.detect_status.send("online", now)
self._send_detect_status("online", now)
self.fps_overflow_count = 0
for p in self.ffmpeg_other_processes:
@@ -545,7 +540,7 @@ class CameraWatchdog(threading.Thread):
elif stale_stream is None:
if poll is None:
for stream_type in recorded_streams:
self.record_status[stream_type].send("online", now)
self._send_record_status(stream_type, "online", now)
p["latest_segment_time"] = max(
self.latest_cache_segment_time[stream_type]
@@ -561,22 +556,22 @@ class CameraWatchdog(threading.Thread):
p["cmd"], self.logger, p["logpipe"], ffmpeg_process=p["process"]
)
if self.detect_process_records_sub and self.config.record.stream_enabled(
STREAM_TYPE_SUB
if (
self.detect_process_records_sub
and self.config.record.stream_enabled(STREAM_TYPE_SUB)
and self.capture_thread is not None
and self.capture_thread.is_alive()
):
now_utc = datetime.now().astimezone(UTC)
stale_reason = self._stream_staleness(STREAM_TYPE_SUB, now_utc)
if self.detect_status.last_status == "offline":
# the sub stream is down whenever the detect process is
self.record_status[STREAM_TYPE_SUB].send("offline", now)
elif stale_reason is None:
self.record_status[STREAM_TYPE_SUB].send("online", now)
if stale_reason is None:
self._send_record_status(STREAM_TYPE_SUB, "online", now)
elif can_restart:
self.logger.error(
f"{stale_reason} for {self.config.name} (sub, shared with detect) in the last {self.record_stale_threshold[STREAM_TYPE_SUB]}s. Restarting ffmpeg..."
)
self.record_status[STREAM_TYPE_SUB].send("offline", now)
self._send_record_status(STREAM_TYPE_SUB, "offline", now)
self.reset_capture_thread()
last_restart_time = now
+11
View File
@@ -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();
}
+3 -2
View File
@@ -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,
});
});
+19
View File
@@ -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 });
});
});
+3 -2
View File
@@ -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 },
+66 -71
View File
@@ -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);
});
});
+2
View File
@@ -14,6 +14,8 @@
},
"title": "System",
"metrics": "System metrics",
"showAllTabs": "Show all tabs",
"showLessTabs": "Show less",
"health": {
"title": "Health",
"notices": {
+26 -166
View File
@@ -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<number | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const measureRef = useRef<HTMLDivElement | null>(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 = [
<Button
key="default-group"
@@ -245,7 +162,6 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
if (group) {
setGroup("default", true);
}
afterSelect?.();
}}
>
<MdHome className="size-5" />
@@ -263,12 +179,16 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
size="sm"
onClick={() => {
setGroup(name, group != "default");
afterSelect?.();
}}
>
{config && config.icon && isValidIconName(config.icon) && (
<IconRenderer icon={LuIcons[config.icon]} className="size-5" />
)}
<IconRenderer
icon={
isValidIconName(config.icon)
? LuIcons[config.icon]
: LuIcons.LuFolder
}
className="size-5"
/>
</Button>
)),
];
@@ -282,7 +202,6 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
size="sm"
onClick={() => {
setAddGroup(true);
afterSelect?.();
}}
>
<LuPencil className="size-5 text-primary-variant" />
@@ -350,12 +269,14 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
onMouseEnter={() => showTooltip(name)}
onMouseLeave={() => showTooltip(undefined)}
>
{config && config.icon && isValidIconName(config.icon) && (
<IconRenderer
icon={LuIcons[config.icon]}
className="size-4"
/>
)}
<IconRenderer
icon={
isValidIconName(config.icon)
? LuIcons[config.icon]
: LuIcons.LuFolder
}
className="size-4"
/>
</Button>
</TooltipTrigger>
<TooltipPortal>
@@ -390,74 +311,13 @@ export function CameraGroupSelector({ className }: CameraGroupSelectorProps) {
)}
</div>
) : (
<div
ref={wrapperRef}
className={cn("flex min-w-0 items-center gap-1", className)}
>
<div className="flex min-w-0 items-center gap-2 overflow-hidden whitespace-nowrap">
{visibleCount == null
? groupButtons()
: groupButtons().slice(0, visibleCount)}
</div>
{visibleCount != null && (
<Button
variant="ghost"
size="sm"
className="shrink-0 px-2 text-secondary-foreground"
aria-label={t("group.showAll")}
onClick={() => setExpanded(true)}
>
<HiDotsHorizontal className="size-5" />
</Button>
)}
{/* invisible row used only to measure natural button widths so we
can render exactly the buttons that fully fit */}
<div
className="pointer-events-none absolute left-0 top-0 h-0 w-0 overflow-hidden"
aria-hidden
inert
>
<div ref={measureRef} className="flex w-max items-center gap-2">
{groupButtons()}
<Button variant="ghost" size="sm" className="px-2">
<HiDotsHorizontal className="size-5" />
</Button>
</div>
</div>
{expanded && (
<div
className="fixed inset-0 z-20"
onClick={() => setExpanded(false)}
/>
)}
<AnimatePresence>
{expanded && (
<motion.div
key="group-overlay"
className="absolute inset-x-0 top-0 z-30 bg-background py-1 shadow-lg"
initial={{ clipPath: "inset(0 100% 0 0)" }}
animate={{ clipPath: "inset(0 0% 0 0)" }}
exit={{ clipPath: "inset(0 100% 0 0)" }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
<div className="flex flex-wrap items-center gap-2">
{groupButtons(() => setExpanded(false))}
<Button
variant="ghost"
size="sm"
className="ml-auto shrink-0 px-2 text-secondary-foreground"
aria-label={t("group.showLess")}
onClick={() => setExpanded(false)}
>
<IoClose className="size-5" />
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<OverflowStrip
className={className}
items={groupButtons()}
activeIndex={groups.findIndex(([name]) => name == group) + 1}
showAllLabel={t("group.showAll")}
showLessLabel={t("group.showLess")}
/>
)}
</>
);
+167
View File
@@ -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<number | null>(null);
const wrapperRef = useRef<HTMLDivElement | null>(null);
const measureRef = useRef<HTMLDivElement | null>(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 (
<div
ref={wrapperRef}
className={cn("flex min-w-0 items-center gap-1", className)}
>
<div
className={cn(
"flex min-w-0 items-center overflow-hidden whitespace-nowrap",
gapClassName,
)}
>
{visibleItems}
</div>
{visibleCount != null && (
<Button
variant="ghost"
size="sm"
className="shrink-0 px-2 text-secondary-foreground"
aria-label={showAllLabel}
onClick={() => setExpanded(true)}
>
<HiDotsHorizontal className="size-5" />
</Button>
)}
{/* invisible row used only to measure natural item widths so we can
render exactly the items that fully fit */}
<div
className="pointer-events-none absolute left-0 top-0 h-0 w-0 overflow-hidden"
aria-hidden
inert
>
<div
ref={measureRef}
className={cn("flex w-max items-center", gapClassName)}
>
{items}
<Button variant="ghost" size="sm" className="px-2">
<HiDotsHorizontal className="size-5" />
</Button>
</div>
</div>
{expanded && (
<div
className="fixed inset-0 z-20"
onClick={() => setExpanded(false)}
/>
)}
<AnimatePresence>
{expanded && (
<motion.div
key="overflow-overlay"
className="absolute inset-x-0 top-0 z-30 bg-background py-1 shadow-lg"
initial={{ clipPath: "inset(0 100% 0 0)" }}
animate={{ clipPath: "inset(0 0% 0 0)" }}
exit={{ clipPath: "inset(0 100% 0 0)" }}
transition={{ duration: 0.2, ease: "easeInOut" }}
>
{/* a tap on any item bubbles up and collapses the panel */}
<div
className={cn("flex flex-wrap items-center", gapClassName)}
onClick={() => setExpanded(false)}
>
{items}
<Button
variant="ghost"
size="sm"
className="ml-auto shrink-0 px-2 text-secondary-foreground"
aria-label={showLessLabel}
>
<IoClose className="size-5" />
</Button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+63 -30
View File
@@ -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<SystemMetric, ReactNode> = {
health: <LuHeartPulse className="size-4" />,
general: <LuActivity className="size-4" />,
enrichments: <LuSearchCode className="size-4" />,
storage: <LuHardDrive className="size-4" />,
cameras: <FaVideo className="size-4" />,
};
function System() {
const { t } = useTranslation(["views/system"]);
const { data: config } = useSWR<FrigateConfig>("config", {
@@ -98,43 +108,66 @@ function System() {
{isMobile && (
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
)}
<ScrollArea className={cn("whitespace-nowrap", isMobile && "w-[45%]")}>
<div className="flex flex-row">
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SystemMetric) => {
if (value) {
setPageToggle(value);
}
}} // don't allow the severity to be unselected
>
{Object.values(metrics).map((item) => (
<ToggleGroupItem
{isMobile ? (
<div className="w-[calc(50%-1rem)]">
<OverflowStrip
items={metrics.map((item) => (
<Toggle
key={item}
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
className={cn(
"shrink-0 rounded-md px-3 py-4",
pageToggle != item && "*:text-muted-foreground",
)}
size="sm"
pressed={pageToggle == item}
onPressedChange={() => setPageToggle(item)}
aria-label={t("selectItem", {
ns: "common",
item: t(item + ".title"),
})}
>
{item == "health" && <LuHeartPulse className="size-4" />}
{item == "general" && <LuActivity className="size-4" />}
{item == "enrichments" && <LuSearchCode className="size-4" />}
{item == "storage" && <LuHardDrive className="size-4" />}
{item == "cameras" && <FaVideo className="size-4" />}
{isDesktop && (
<div className="smart-capitalize">{t(item + ".title")}</div>
)}
</ToggleGroupItem>
{metricIcons[item]}
</Toggle>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
activeIndex={metrics.indexOf(pageToggle)}
gapClassName="gap-0.5"
showAllLabel={t("showAllTabs")}
showLessLabel={t("showLessTabs")}
/>
</div>
</ScrollArea>
) : (
<ScrollArea className="whitespace-nowrap">
<div className="flex flex-row">
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
type="single"
size="sm"
value={pageToggle}
onValueChange={(value: SystemMetric) => {
if (value) {
setPageToggle(value);
}
}} // don't allow the severity to be unselected
>
{Object.values(metrics).map((item) => (
<ToggleGroupItem
key={item}
className={`flex items-center justify-between gap-2 ${pageToggle == item ? "" : "*:text-muted-foreground"}`}
value={item}
aria-label={t("selectItem", {
ns: "common",
item: t(item + ".title"),
})}
>
{metricIcons[item]}
<div className="smart-capitalize">{t(item + ".title")}</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
)}
<div className="flex h-full items-center">
{pageToggle == "health" && (
+1 -1
View File
@@ -419,7 +419,7 @@ export default function LiveDashboardView({
{isMobile && (
<div className="relative flex h-11 items-center justify-between">
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
<div className="w-[45%]">
<div className="w-[calc(50%-1rem)]">
<CameraGroupSelector />
</div>
{(!cameraGroup || cameraGroup == "default" || isMobileOnly) && (