Compare commits

..
2 Commits
14 changed files with 365 additions and 315 deletions
@@ -571,8 +571,6 @@ notifications:
enabled: False
# Optional: Email for push service to reach out to
# NOTE: This is required to use notifications
# NOTE: Email can be specified with an environment variable or docker secrets that must begin with 'FRIGATE_'.
# e.g. email: '{FRIGATE_NOTIFICATION_EMAIL}'
email: "admin@example.com"
# Optional: Cooldown time for notifications in seconds (default: shown below)
cooldown: 0
+3 -14
View File
@@ -311,12 +311,9 @@ def config(request: Request):
mode="json", warnings="none", exclude_none=True
)
is_admin = request.headers.get("remote-role") == "admin"
# hide environment_vars and the notification email from non-admin users
if not is_admin:
# remove environment_vars for non-admin users
if request.headers.get("remote-role") != "admin":
config.pop("environment_vars", None)
redact_credential(config["notifications"], "email")
# redact mqtt credentials
redact_credential(config["mqtt"], "password")
@@ -373,15 +370,7 @@ def config(request: Request):
camera_name
)
if base_sections:
# copy so redaction below can't alter the profile manager's cache
camera_dict["base_config"] = copy.deepcopy(base_sections)
# cameras inherit the global notification email
if not is_admin:
redact_credential(camera_dict["notifications"], "email")
redact_credential(
camera_dict.get("base_config", {}).get("notifications", {}), "email"
)
camera_dict["base_config"] = base_sections
# remove go2rtc stream passwords
go2rtc: dict[str, Any] = config_obj.go2rtc.model_dump(
+1 -2
View File
@@ -1,7 +1,6 @@
from pydantic import Field
from ..base import FrigateBaseModel
from ..env import EnvString
__all__ = ["NotificationConfig"]
@@ -12,7 +11,7 @@ class NotificationConfig(FrigateBaseModel):
title="Enable notifications",
description="Enable or disable notifications for all cameras; can be overridden per-camera.",
)
email: EnvString | None = Field(
email: str | None = Field(
default=None,
title="Notification email",
description="Email address used for push notifications or required by certain notification providers.",
-25
View File
@@ -4,7 +4,6 @@ from unittest.mock import Mock, patch
import frigate.genai
from frigate.config import GenAIProviderEnum
from frigate.config.env import FRIGATE_ENV_VARS
from frigate.const import MODEL_CACHE_DIR, REDACTED_CREDENTIAL_SENTINEL
from frigate.genai import GenAIClient
from frigate.models import Event, Recordings, ReviewSegment
@@ -112,30 +111,6 @@ class TestHttpApp(BaseTestHttp):
mqtt = response.json()["mqtt"]
assert mqtt["password"] == REDACTED_CREDENTIAL_SENTINEL
def test_config_response_hides_notification_email_from_viewers(self):
self.minimal_config["notifications"] = {"email": "{FRIGATE_TEST_EMAIL}"}
with patch.dict(FRIGATE_ENV_VARS, {"FRIGATE_TEST_EMAIL": "me@example.com"}):
app = super().create_app()
assert app.frigate_config.notifications.email == "me@example.com"
with AuthTestClient(app) as client:
response = client.get(
"/config",
headers={"remote-user": "viewer", "remote-role": "viewer"},
)
assert response.status_code == 200
config = response.json()
assert config["notifications"]["email"] == REDACTED_CREDENTIAL_SENTINEL
assert (
config["cameras"]["front_door"]["notifications"]["email"]
== REDACTED_CREDENTIAL_SENTINEL
)
response = client.get("/config")
assert response.json()["notifications"]["email"] == "me@example.com"
def test_config_response_keeps_plus_model_reference(self):
model_id = "test_plus_reference"
model_path = os.path.join(MODEL_CACHE_DIR, model_id)
+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": {
+22 -162
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]}
icon={
isValidIconName(config.icon)
? LuIcons[config.icon]
: LuIcons.LuFolder
}
className="size-4"
/>
)}
</Button>
</TooltipTrigger>
<TooltipPortal>
@@ -390,75 +311,14 @@ 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)}
<OverflowStrip
className={className}
items={groupButtons()}
activeIndex={groups.findIndex(([name]) => name == group) + 1}
showAllLabel={t("group.showAll")}
showLessLabel={t("group.showLess")}
/>
)}
<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>
)}
</>
);
}
+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>
);
}
+42 -9
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,7 +108,35 @@ function System() {
{isMobile && (
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
)}
<ScrollArea className={cn("whitespace-nowrap", isMobile && "w-[45%]")}>
{isMobile ? (
<div className="w-[calc(50%-1rem)]">
<OverflowStrip
items={metrics.map((item) => (
<Toggle
key={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"),
})}
>
{metricIcons[item]}
</Toggle>
))}
activeIndex={metrics.indexOf(pageToggle)}
gapClassName="gap-0.5"
showAllLabel={t("showAllTabs")}
showLessLabel={t("showLessTabs")}
/>
</div>
) : (
<ScrollArea className="whitespace-nowrap">
<div className="flex flex-row">
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
@@ -121,20 +159,15 @@ function System() {
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 && (
{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) && (