Add command menu to frontend (#24256)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s

* add command menu to quickly jump between pages, cameras, settings, and quick actions

* access tweaks
This commit is contained in:
Josh Hawkins
2026-09-12 10:57:17 -06:00
committed by GitHub
parent 4861a668b1
commit d59c28e53a
7 changed files with 897 additions and 217 deletions
+284
View File
@@ -0,0 +1,284 @@
/**
* Command menu tests -- HIGH tier.
*
* Ctrl+K and "/" open a search box over pages, cameras, camera groups,
* settings sections and actions. Text fields keep their own keys, recent
* commands survive a reload, and viewers never see admin entries.
* The menu is desktop only.
*/
import { test, expect } from "../fixtures/frigate-test";
import type { FrigateApp } from "../fixtures/frigate-test";
import { getMonacoVisibleText } from "../helpers/monaco";
import {
restrictedProfile,
viewerProfile,
} from "../fixtures/mock-data/profile";
const SAMPLE_CONFIG = "mqtt:\n host: mqtt\n";
function menu(app: FrigateApp) {
return app.page.locator("[cmdk-root]");
}
function commands(app: FrigateApp, text: string | RegExp) {
return app.page.locator("[cmdk-item]", { hasText: text });
}
/**
* Reads the recent command ids straight out of idb-keyval's store, which is
* where useUserPersistence writes under a key namespaced by username. The
* write is fire and forget, so a test that reloads has to wait for it first.
*/
function storedRecents(app: FrigateApp) {
return app.page.evaluate(
() =>
new Promise<string[]>((resolve) => {
const request = indexedDB.open("keyval-store");
request.onerror = () => resolve([]);
request.onsuccess = () => {
const read = request.result
.transaction("keyval", "readonly")
.objectStore("keyval")
.get("command-menu-recent:admin");
read.onerror = () => resolve([]);
read.onsuccess = () => resolve(read.result ?? []);
};
}),
);
}
async function openMenu(app: FrigateApp) {
await app.page.keyboard.press("Control+k");
await expect(menu(app)).toBeVisible();
}
test.describe("Command menu - opening @high", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"The command menu is desktop only",
);
test("Ctrl+K opens the menu and closes it again", async ({ frigateApp }) => {
await frigateApp.goto("/");
await openMenu(frigateApp);
await expect(menu(frigateApp).locator("[cmdk-group-heading]")).toHaveText([
"Pages",
"Cameras",
"Camera groups",
"Settings",
"Actions",
]);
await frigateApp.page.keyboard.press("Control+k");
await expect(menu(frigateApp)).toBeHidden();
});
test("slash opens the menu without typing itself", async ({ frigateApp }) => {
await frigateApp.goto("/review");
await frigateApp.page.keyboard.press("/");
await expect(menu(frigateApp)).toBeVisible();
await expect(frigateApp.page.locator("[cmdk-input]")).toHaveValue("");
});
test("the config editor keeps Ctrl+K and slash", async ({ frigateApp }) => {
await frigateApp.installDefaults({ configRaw: SAMPLE_CONFIG });
await frigateApp.goto("/config");
await expect(frigateApp.page.locator(".monaco-editor").first()).toBeVisible(
{ timeout: 15_000 },
);
await frigateApp.page.locator(".monaco-editor").first().click();
await frigateApp.page.keyboard.type("/comment");
await expect
.poll(() => getMonacoVisibleText(frigateApp.page), { timeout: 10_000 })
.toContain("/comment");
await expect(menu(frigateApp)).toHaveCount(0);
// Ctrl+K opens a chord in Monaco, so the menu leaves it alone. Escape
// cancels the pending chord, which would otherwise eat the next key.
await frigateApp.page.keyboard.press("Control+k");
await expect(menu(frigateApp)).toHaveCount(0);
await frigateApp.page.keyboard.press("Escape");
});
});
test.describe("Command menu - navigating @high", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"The command menu is desktop only",
);
test("a camera entry jumps to its live view", async ({ frigateApp }) => {
await frigateApp.goto("/review");
await openMenu(frigateApp);
await frigateApp.page.keyboard.type("backy");
await expect(commands(frigateApp, "Garage")).toHaveCount(0);
await commands(frigateApp, "Backyard").filter({ hasText: "Live" }).click();
await expect(frigateApp.page).toHaveURL(/\/#backyard$/);
await expect(menu(frigateApp)).toBeHidden();
});
test("a camera entry deep links into review", async ({ frigateApp }) => {
await frigateApp.goto("/");
await openMenu(frigateApp);
await frigateApp.page.keyboard.type("front door");
await commands(frigateApp, "Front Door")
.filter({ hasText: "Review" })
.click();
await expect(frigateApp.page).toHaveURL(/\/review\?cameras=front_door$/);
});
test("a camera group opens the live grid filtered to it", async ({
frigateApp,
}) => {
await frigateApp.goto("/review");
await openMenu(frigateApp);
await frigateApp.page.keyboard.type("outdoor");
await commands(frigateApp, "outdoor").click();
await expect(frigateApp.page).toHaveURL(/\/\?group=outdoor$/);
});
test("repeated settings names are told apart by their group", async ({
frigateApp,
}) => {
await frigateApp.goto("/");
await openMenu(frigateApp);
await frigateApp.page.keyboard.type("object detection");
const matches = commands(frigateApp, "Object detection");
await expect(matches).toHaveCount(2);
await expect(
matches.filter({ hasText: "Global configuration" }),
).toHaveCount(1);
await expect(
matches.filter({ hasText: "Camera configuration" }),
).toHaveCount(1);
await matches.filter({ hasText: "Camera configuration" }).click();
await expect(frigateApp.page).toHaveURL(/\/settings\?page=cameraDetect$/);
});
test("a command run once comes back under Recent", async ({ frigateApp }) => {
await frigateApp.goto("/");
await openMenu(frigateApp);
await frigateApp.page.keyboard.type("export");
await commands(frigateApp, "Export").first().click();
await expect(frigateApp.page).toHaveURL(/\/export$/);
await expect
.poll(() => storedRecents(frigateApp))
.toEqual(["page-/export"]);
// Recents live in IndexedDB, so they outlive a full reload.
await frigateApp.goto("/");
await openMenu(frigateApp);
await expect(
menu(frigateApp).locator("[cmdk-group-heading]").first(),
).toHaveText("Recent");
await expect(
frigateApp.page.locator('[cmdk-item][data-value^="recent-"]'),
).toHaveText(["Export"]);
});
});
test.describe("Command menu - actions @high", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"The command menu is desktop only",
);
test("the theme action flips the root class", async ({ frigateApp }) => {
await frigateApp.goto("/");
const isDark = () =>
frigateApp.page.evaluate(() =>
document.documentElement.classList.contains("dark"),
);
const before = await isDark();
await openMenu(frigateApp);
await frigateApp.page.keyboard.type("toggle light");
await commands(frigateApp, "Toggle light and dark mode").click();
await expect.poll(isDark).toBe(!before);
});
test("restart asks for confirmation first", async ({ frigateApp }) => {
await frigateApp.goto("/");
await openMenu(frigateApp);
await frigateApp.page.keyboard.type("restart");
await commands(frigateApp, "Restart Frigate").click();
const confirm = frigateApp.page.getByRole("alertdialog");
await expect(confirm).toBeVisible();
await confirm.getByRole("button", { name: /cancel/i }).click();
await expect(confirm).toBeHidden();
});
});
test.describe("Command menu - permissions @high", () => {
test.skip(
({ frigateApp }) => frigateApp.isMobile,
"The command menu is desktop only",
);
test("viewers get cameras but no admin entries", async ({ frigateApp }) => {
await frigateApp.installDefaults({ profile: viewerProfile() });
await frigateApp.goto("/");
await openMenu(frigateApp);
await expect(commands(frigateApp, "Front Door").first()).toBeVisible();
await expect(commands(frigateApp, "UI settings")).toHaveCount(1);
await expect(commands(frigateApp, "Restart Frigate")).toHaveCount(0);
await expect(commands(frigateApp, "Configuration Editor")).toHaveCount(0);
await expect(commands(frigateApp, "Motion tuner")).toHaveCount(0);
});
test("a custom role only reaches groups holding its cameras", async ({
frigateApp,
}) => {
await frigateApp.installDefaults({
profile: restrictedProfile(["garage"], { role: "restricted" }),
});
await frigateApp.goto("/");
await openMenu(frigateApp);
const input = frigateApp.page.locator("[cmdk-input]");
const group = (name: string) =>
frigateApp.page.locator(`[cmdk-item][data-value="group-${name}"]`);
await input.fill("garage");
await expect(commands(frigateApp, "Garage").first()).toBeVisible();
await expect(group("default")).toHaveCount(1);
// "outdoor" holds no camera this role may see
await input.fill("outdoor");
await expect(group("outdoor")).toHaveCount(0);
// nor may a hidden camera's name pull a group up through its search terms
await input.fill("backyard");
await expect(group("default")).toHaveCount(0);
await expect(commands(frigateApp, "Backyard")).toHaveCount(0);
});
});
test.describe("Command menu - mobile @high @mobile", () => {
test.skip(
({ frigateApp }) => !frigateApp.isMobile,
"Desktop is covered above",
);
test("the menu is not mounted on a phone", async ({ frigateApp }) => {
await frigateApp.goto("/");
await frigateApp.page.keyboard.press("Control+k");
await expect(menu(frigateApp)).toHaveCount(0);
await frigateApp.page.keyboard.press("/");
await expect(menu(frigateApp)).toHaveCount(0);
});
});
+22
View File
@@ -333,5 +333,27 @@
"validation_errors": "Validation Errors",
"credentialField": {
"savedPlaceholder": "Saved — leave blank to keep current"
},
"commandMenu": {
"title": "Command menu",
"description": "Search pages, cameras, settings, and actions",
"placeholder": "Search pages, cameras, settings, and actions",
"empty": "No matches",
"hint": "{{key}} or / to open, Enter to run",
"section": {
"recent": "Recent",
"pages": "Pages",
"cameras": "Cameras",
"cameraGroups": "Camera groups",
"settings": "Settings",
"actions": "Actions"
},
"camera": {
"live": "Live",
"review": "Review"
},
"action": {
"toggleTheme": "Toggle light and dark mode"
}
}
}
+2
View File
@@ -2,6 +2,7 @@ import Providers from "@/context/providers";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Wrapper from "@/components/Wrapper";
import Sidebar from "@/components/navigation/Sidebar";
import CommandMenu from "@/components/menu/CommandMenu";
import { isDesktop, isMobile } from "react-device-detect";
import Statusbar from "./components/Statusbar";
@@ -110,6 +111,7 @@ function DefaultAppView() {
<div className="size-full overflow-hidden">
<ChromeErrorBoundary>{isDesktop && <Sidebar />}</ChromeErrorBoundary>
<ChromeErrorBoundary>{isDesktop && <Statusbar />}</ChromeErrorBoundary>
<ChromeErrorBoundary>{isDesktop && <CommandMenu />}</ChromeErrorBoundary>
<ChromeErrorBoundary>{isMobile && <Bottombar />}</ChromeErrorBoundary>
<div
id="pageRoot"
+366
View File
@@ -0,0 +1,366 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { isMacOs } from "react-device-detect";
import type { IconType } from "react-icons";
import { FaVideo } from "react-icons/fa";
import { MdVideoLibrary } from "react-icons/md";
import {
LuActivity,
LuFileCode,
LuLayers,
LuList,
LuRotateCw,
LuSettings,
LuSunMoon,
} from "react-icons/lu";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from "@/components/ui/command";
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
import { useRestart } from "@/api/ws";
import { useTheme } from "@/context/theme-provider";
import { useAllowedCameras } from "@/hooks/use-allowed-cameras";
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
import { useIsAdmin } from "@/hooks/use-is-admin";
import useNavigation from "@/hooks/use-navigation";
import { useHasFullCameraAccess } from "@/hooks/use-has-full-camera-access";
import { useUserPersistence } from "@/hooks/use-user-persistence";
import { FrigateConfig } from "@/types/frigateConfig";
import { settingsViewGroups, ALLOWED_VIEWS_FOR_VIEWER } from "@/types/settings";
const SECTIONS = [
"pages",
"cameras",
"cameraGroups",
"settings",
"actions",
] as const;
type CommandSection = (typeof SECTIONS)[number];
type MenuCommand = {
id: string;
section: CommandSection;
title: string;
/** Trailing text that tells two commands with the same title apart. */
detail?: string;
terms: string[];
Icon: IconType;
onRun: () => void;
};
type MenuPage = {
icon: IconType;
/** Key in the `common` namespace. */
title: string;
url: string;
adminOnly?: boolean;
};
// Pages the sidebar reaches through its settings menu rather than useNavigation.
const MENU_PAGES: MenuPage[] = [
{ icon: LuSettings, title: "menu.settings", url: "/settings" },
{
icon: LuActivity,
title: "menu.systemMetrics",
url: "/system#general",
adminOnly: true,
},
{
icon: LuFileCode,
title: "menu.configurationEditor",
url: "/config",
adminOnly: true,
},
{ icon: LuList, title: "menu.systemLogs", url: "/logs", adminOnly: true },
];
const RECENT_LIMIT = 6;
const NO_RECENT: string[] = [];
/**
* Search box over pages, cameras, camera groups, settings sections and a few
* actions. Mounted once for the whole app and opened with the keyboard.
*/
export default function CommandMenu() {
const { t } = useTranslation(["common", "views/settings"]);
const navigate = useNavigate();
const isAdmin = useIsAdmin();
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const allowedCameras = useAllowedCameras();
const hasFullCameraAccess = useHasFullCameraAccess();
const navPages = useNavigation();
const { theme, systemTheme, setTheme } = useTheme();
const { send: sendRestart } = useRestart();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [confirmRestart, setConfirmRestart] = useState(false);
const [recent, setRecent] = useUserPersistence<string[]>(
"command-menu-recent",
NO_RECENT,
);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.altKey) {
return;
}
const target = event.target;
const editing =
target instanceof HTMLElement &&
(target.isContentEditable ||
["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName));
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
// Monaco reads Ctrl+K as the start of a chord, and every other text
// field may want it too, so only claim it outside of one.
if (editing && !open) {
return;
}
event.preventDefault();
setOpen(!open);
} else if (
event.key === "/" &&
!event.metaKey &&
!event.ctrlKey &&
!editing &&
!open
) {
event.preventDefault();
setOpen(true);
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open]);
const toggleTheme = useCallback(() => {
const active = theme === "system" ? systemTheme : theme;
setTheme(active === "dark" ? "light" : "dark");
}, [theme, systemTheme, setTheme]);
const commands = useMemo<MenuCommand[]>(() => {
const goTo = (url: string) => () => navigate(url);
const built: MenuCommand[] = [];
const pages: MenuPage[] = [
...navPages.filter((page) => page.enabled !== false),
...MENU_PAGES.filter((page) => !page.adminOnly || isAdmin),
];
for (const page of pages) {
built.push({
id: `page-${page.url}`,
section: "pages",
title: t(page.title),
terms: [page.url],
Icon: page.icon,
onRun: goTo(page.url),
});
}
for (const camera of allowedCameras) {
const title = resolveCameraName(config, camera);
built.push({
id: `camera-live-${camera}`,
section: "cameras",
title,
detail: t("commandMenu.camera.live"),
terms: [camera],
Icon: FaVideo,
onRun: goTo(`/#${camera}`),
});
built.push({
id: `camera-review-${camera}`,
section: "cameras",
title,
detail: t("commandMenu.camera.review"),
terms: [camera],
Icon: MdVideoLibrary,
onRun: goTo(`/review?cameras=${encodeURIComponent(camera)}`),
});
}
for (const [group, groupConfig] of Object.entries(
config?.camera_groups ?? {},
)) {
// A custom role only gets groups it can actually open, and only the
// cameras it may see become search terms.
const groupCameras = hasFullCameraAccess
? groupConfig.cameras
: groupConfig.cameras.filter((camera) =>
allowedCameras.includes(camera),
);
if (groupCameras.length === 0) {
continue;
}
built.push({
id: `group-${group}`,
section: "cameraGroups",
title: group,
terms: groupCameras,
Icon: LuLayers,
onRun: goTo(`/?group=${encodeURIComponent(group)}`),
});
}
for (const group of settingsViewGroups) {
for (const view of group.views) {
if (!isAdmin && !ALLOWED_VIEWS_FOR_VIEWER.includes(view)) {
continue;
}
built.push({
id: `settings-${view}`,
section: "settings",
// Section names repeat across groups, so the group name comes along
// to tell "Object detection" under cameras from the global one.
title: t(`menu.${view}`, { ns: "views/settings" }),
detail: t(`menu.${group.label}`, { ns: "views/settings" }),
terms: [view],
Icon: LuSettings,
onRun: goTo(`/settings?page=${view}`),
});
}
}
built.push({
id: "theme",
section: "actions",
title: t("commandMenu.action.toggleTheme"),
terms: ["dark", "light", "appearance"],
Icon: LuSunMoon,
onRun: toggleTheme,
});
if (isAdmin) {
built.push({
id: "restart",
section: "actions",
title: t("menu.restart"),
terms: ["reboot"],
Icon: LuRotateCw,
onRun: () => setConfirmRestart(true),
});
}
return built;
}, [
t,
navigate,
isAdmin,
config,
allowedCameras,
hasFullCameraAccess,
navPages,
toggleTheme,
]);
const recentCommands = useMemo(
() =>
(recent ?? NO_RECENT)
.map((id) => commands.find((command) => command.id === id))
.filter((command): command is MenuCommand => command !== undefined),
[recent, commands],
);
const run = useCallback(
(command: MenuCommand) => {
setRecent(
[
command.id,
...(recent ?? NO_RECENT).filter((id) => id !== command.id),
].slice(0, RECENT_LIMIT),
);
setOpen(false);
setSearch("");
command.onRun();
},
[recent, setRecent],
);
const renderCommand = (command: MenuCommand, idPrefix = "") => (
<CommandItem
key={idPrefix + command.id}
value={idPrefix + command.id}
keywords={[command.title, ...command.terms]}
onSelect={() => run(command)}
className="cursor-pointer gap-2"
>
<command.Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{command.title}</span>
{command.detail && <CommandShortcut>{command.detail}</CommandShortcut>}
</CommandItem>
);
return (
<>
<CommandDialog
open={open}
onOpenChange={setOpen}
title={t("commandMenu.title")}
description={t("commandMenu.description")}
className="top-[15%] translate-y-0 sm:max-w-lg"
>
<CommandInput
value={search}
onValueChange={setSearch}
placeholder={t("commandMenu.placeholder")}
/>
<CommandList className="max-h-[50vh]">
<CommandEmpty>{t("commandMenu.empty")}</CommandEmpty>
{search === "" && recentCommands.length > 0 && (
<CommandGroup heading={t("commandMenu.section.recent")}>
{recentCommands.map((command) =>
renderCommand(command, "recent-"),
)}
</CommandGroup>
)}
{SECTIONS.map((section) => {
const inSection = commands.filter(
(command) => command.section === section,
);
if (inSection.length === 0) {
return null;
}
return (
<CommandGroup
key={section}
heading={t(`commandMenu.section.${section}`)}
>
{inSection.map((command) => renderCommand(command))}
</CommandGroup>
);
})}
</CommandList>
<div className="border-t px-3 py-2 text-xs text-muted-foreground">
{t("commandMenu.hint", { key: isMacOs ? "⌘K" : "Ctrl+K" })}
</div>
</CommandDialog>
<RestartDialog
isOpen={confirmRestart}
onClose={() => setConfirmRestart(false)}
onRestart={() => sendRestart("restart")}
/>
</>
);
}
+27 -4
View File
@@ -4,7 +4,12 @@ import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "@/components/ui/dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
@@ -21,12 +26,30 @@ const Command = React.forwardRef<
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
interface CommandDialogProps extends DialogProps {
/** Announced to screen readers; the dialog renders no visible heading. */
title: string;
description: string;
className?: string;
}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
const CommandDialog = ({
title,
description,
className,
children,
...props
}: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<DialogContent
className={cn(
"overflow-hidden p-0 shadow-lg [&>button]:hidden",
className,
)}
>
<DialogTitle className="sr-only">{title}</DialogTitle>
<DialogDescription className="sr-only">{description}</DialogDescription>
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
+75 -213
View File
@@ -57,6 +57,12 @@ import {
type SectionStatus,
} from "@/views/settings/SingleSectionPage";
import { useSearchEffect } from "@/hooks/use-overlay-state";
import {
allSettingsViews,
settingsViewGroups,
ALLOWED_VIEWS_FOR_VIEWER,
type SettingsType,
} from "@/types/settings";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useInitialCameraState } from "@/api/ws";
import { useIsAdmin } from "@/hooks/use-is-admin";
@@ -117,70 +123,6 @@ import {
} from "@/components/ui/tooltip";
import { TooltipPortal } from "@radix-ui/react-tooltip";
const allSettingsViews = [
"uiSettings",
"profiles",
"globalDetect",
"globalRecording",
"globalSnapshots",
"globalFfmpeg",
"globalMotion",
"globalObjects",
"globalReview",
"globalAudioEvents",
"globalLivePlayback",
"globalTimestampStyle",
"systemDatabase",
"systemTls",
"systemAuthentication",
"systemNetworking",
"systemProxy",
"systemUi",
"systemLogging",
"systemEnvironmentVariables",
"systemTelemetry",
"systemBirdseye",
"systemDetectorsAndModel",
"systemMqtt",
"systemGo2rtcStreams",
"integrationSemanticSearch",
"integrationGenerativeAi",
"integrationFaceRecognition",
"integrationLpr",
"integrationObjectClassification",
"integrationAudioTranscription",
"cameraDetect",
"cameraFfmpeg",
"cameraRecording",
"cameraSnapshots",
"cameraMotion",
"cameraObjects",
"cameraReview",
"cameraAudioEvents",
"cameraAudioTranscription",
"cameraNotifications",
"cameraLivePlayback",
"cameraBirdseye",
"cameraFaceRecognition",
"cameraLpr",
"cameraMqttConfig",
"cameraOnvif",
"cameraTimestampStyle",
"cameraManagement",
"masksAndZones",
"motionTuner",
"enrichments",
"triggers",
"debug",
"users",
"roles",
"notifications",
"frigateplus",
"mediaSync",
"regionGrid",
] as const;
type SettingsType = (typeof allSettingsViews)[number];
const parsePendingDataKey = (pendingDataKey: string) => {
if (pendingDataKey.includes("::")) {
const idx = pendingDataKey.indexOf("::");
@@ -300,153 +242,75 @@ const CameraTimestampStyleSettingsPage = createSectionPage(
"camera",
);
const settingsGroups = [
{
label: "general",
items: [{ key: "uiSettings", component: UiSettingsView }],
},
{
label: "globalConfig",
items: [
{ key: "profiles", component: ProfilesView },
{ key: "cameraManagement", component: CameraManagementView },
{ key: "globalDetect", component: GlobalDetectSettingsPage },
{ key: "globalObjects", component: GlobalObjectsSettingsPage },
{ key: "globalMotion", component: GlobalMotionSettingsPage },
{ key: "globalFfmpeg", component: GlobalFfmpegSettingsPage },
{ key: "globalRecording", component: GlobalRecordingSettingsPage },
{ key: "globalSnapshots", component: GlobalSnapshotsSettingsPage },
{ key: "globalReview", component: GlobalReviewSettingsPage },
{ key: "globalAudioEvents", component: GlobalAudioEventsSettingsPage },
{
key: "globalLivePlayback",
component: GlobalLivePlaybackSettingsPage,
},
{
key: "globalTimestampStyle",
component: GlobalTimestampStyleSettingsPage,
},
],
},
{
label: "cameras",
items: [
{ key: "cameraDetect", component: CameraDetectSettingsPage },
{ key: "cameraObjects", component: CameraObjectsSettingsPage },
{ key: "cameraMotion", component: CameraMotionSettingsPage },
{ key: "motionTuner", component: MotionTunerView },
{ key: "cameraFfmpeg", component: CameraFfmpegSettingsPage },
{ key: "cameraRecording", component: CameraRecordingSettingsPage },
{ key: "cameraSnapshots", component: CameraSnapshotsSettingsPage },
{ key: "masksAndZones", component: MasksAndZonesView },
{ key: "cameraReview", component: CameraReviewSettingsPage },
{ key: "cameraAudioEvents", component: CameraAudioEventsSettingsPage },
{
key: "cameraAudioTranscription",
component: CameraAudioTranscriptionSettingsPage,
},
{ key: "cameraBirdseye", component: CameraBirdseyeSettingsPage },
{
key: "cameraLivePlayback",
component: CameraLivePlaybackSettingsPage,
},
{
key: "cameraNotifications",
component: CameraNotificationsSettingsPage,
},
{
key: "cameraFaceRecognition",
component: CameraFaceRecognitionSettingsPage,
},
{ key: "cameraLpr", component: CameraLprSettingsPage },
{ key: "cameraOnvif", component: CameraOnvifSettingsPage },
{ key: "cameraMqttConfig", component: CameraMqttConfigSettingsPage },
{
key: "cameraTimestampStyle",
component: CameraTimestampStyleSettingsPage,
},
],
},
{
label: "enrichments",
items: [
{
key: "integrationSemanticSearch",
component: IntegrationSemanticSearchSettingsPage,
},
{
key: "integrationGenerativeAi",
component: IntegrationGenerativeAiSettingsPage,
},
{
key: "integrationFaceRecognition",
component: IntegrationFaceRecognitionSettingsPage,
},
{ key: "integrationLpr", component: IntegrationLprSettingsPage },
{
key: "integrationObjectClassification",
component: IntegrationObjectClassificationSettingsPage,
},
{ key: "triggers", component: TriggerView },
{
key: "integrationAudioTranscription",
component: IntegrationAudioTranscriptionSettingsPage,
},
],
},
{
label: "system",
items: [
{
key: "systemGo2rtcStreams",
component: Go2RtcStreamsSettingsView,
},
{
key: "systemDetectorsAndModel",
component: SystemDetectionModelsPage,
},
{ key: "systemDatabase", component: SystemDatabaseSettingsPage },
{ key: "systemMqtt", component: SystemMqttSettingsPage },
{ key: "systemBirdseye", component: SystemBirdseyeSettingsPage },
{ key: "systemTls", component: SystemTlsSettingsPage },
{
key: "systemAuthentication",
component: SystemAuthenticationSettingsPage,
},
{ key: "systemNetworking", component: SystemNetworkingSettingsPage },
{ key: "systemProxy", component: SystemProxySettingsPage },
{ key: "systemUi", component: SystemUiSettingsPage },
{ key: "systemLogging", component: SystemLoggingSettingsPage },
{
key: "systemEnvironmentVariables",
component: SystemEnvironmentVariablesSettingsPage,
},
{ key: "systemTelemetry", component: SystemTelemetrySettingsPage },
],
},
{
label: "users",
items: [
{ key: "users", component: UsersView },
{ key: "roles", component: RolesView },
],
},
{
label: "notifications",
items: [{ key: "notifications", component: NotificationsSettingsPage }],
},
{
label: "frigateplus",
items: [{ key: "frigateplus", component: FrigatePlusSettingsView }],
},
{
label: "maintenance",
items: [
{ key: "mediaSync", component: MediaSyncSettingsView },
{ key: "regionGrid", component: RegionGridSettingsView },
],
},
];
// Every section key in `settingsViewGroups` maps to the view that renders it.
const SECTION_VIEWS = {
uiSettings: UiSettingsView,
profiles: ProfilesView,
cameraManagement: CameraManagementView,
globalDetect: GlobalDetectSettingsPage,
globalObjects: GlobalObjectsSettingsPage,
globalMotion: GlobalMotionSettingsPage,
globalFfmpeg: GlobalFfmpegSettingsPage,
globalRecording: GlobalRecordingSettingsPage,
globalSnapshots: GlobalSnapshotsSettingsPage,
globalReview: GlobalReviewSettingsPage,
globalAudioEvents: GlobalAudioEventsSettingsPage,
globalLivePlayback: GlobalLivePlaybackSettingsPage,
globalTimestampStyle: GlobalTimestampStyleSettingsPage,
cameraDetect: CameraDetectSettingsPage,
cameraObjects: CameraObjectsSettingsPage,
cameraMotion: CameraMotionSettingsPage,
motionTuner: MotionTunerView,
cameraFfmpeg: CameraFfmpegSettingsPage,
cameraRecording: CameraRecordingSettingsPage,
cameraSnapshots: CameraSnapshotsSettingsPage,
masksAndZones: MasksAndZonesView,
cameraReview: CameraReviewSettingsPage,
cameraAudioEvents: CameraAudioEventsSettingsPage,
cameraAudioTranscription: CameraAudioTranscriptionSettingsPage,
cameraBirdseye: CameraBirdseyeSettingsPage,
cameraLivePlayback: CameraLivePlaybackSettingsPage,
cameraNotifications: CameraNotificationsSettingsPage,
cameraFaceRecognition: CameraFaceRecognitionSettingsPage,
cameraLpr: CameraLprSettingsPage,
cameraOnvif: CameraOnvifSettingsPage,
cameraMqttConfig: CameraMqttConfigSettingsPage,
cameraTimestampStyle: CameraTimestampStyleSettingsPage,
integrationSemanticSearch: IntegrationSemanticSearchSettingsPage,
integrationGenerativeAi: IntegrationGenerativeAiSettingsPage,
integrationFaceRecognition: IntegrationFaceRecognitionSettingsPage,
integrationLpr: IntegrationLprSettingsPage,
integrationObjectClassification: IntegrationObjectClassificationSettingsPage,
triggers: TriggerView,
integrationAudioTranscription: IntegrationAudioTranscriptionSettingsPage,
systemGo2rtcStreams: Go2RtcStreamsSettingsView,
systemDetectorsAndModel: SystemDetectionModelsPage,
systemDatabase: SystemDatabaseSettingsPage,
systemMqtt: SystemMqttSettingsPage,
systemBirdseye: SystemBirdseyeSettingsPage,
systemTls: SystemTlsSettingsPage,
systemAuthentication: SystemAuthenticationSettingsPage,
systemNetworking: SystemNetworkingSettingsPage,
systemProxy: SystemProxySettingsPage,
systemUi: SystemUiSettingsPage,
systemLogging: SystemLoggingSettingsPage,
systemEnvironmentVariables: SystemEnvironmentVariablesSettingsPage,
systemTelemetry: SystemTelemetrySettingsPage,
users: UsersView,
roles: RolesView,
notifications: NotificationsSettingsPage,
frigateplus: FrigatePlusSettingsView,
mediaSync: MediaSyncSettingsView,
regionGrid: RegionGridSettingsView,
};
const settingsGroups = settingsViewGroups.map((group) => ({
label: group.label,
items: group.views.map((key) => ({
key,
component: SECTION_VIEWS[key],
})),
}));
const CAMERA_SELECT_BUTTON_PAGES = [
"debug",
@@ -473,8 +337,6 @@ const CAMERA_SELECT_BUTTON_PAGES = [
"regionGrid",
];
const ALLOWED_VIEWS_FOR_VIEWER = ["uiSettings", "notifications"];
// keys for camera sections
const CAMERA_SECTION_MAPPING: Record<string, SettingsType> = {
detect: "cameraDetect",
+121
View File
@@ -0,0 +1,121 @@
/**
* The Settings page section index.
*
* This is the single source for which sections exist, what order they appear
* in, and which group each one belongs to. It lives outside
* `pages/Settings.tsx` so that callers which only need to link to a section
* (the command menu) do not pull every settings view into their bundle.
* `pages/Settings.tsx` maps these keys onto the components that render them.
*/
export const settingsViewGroups = [
{
label: "general",
views: ["uiSettings"],
},
{
label: "globalConfig",
views: [
"profiles",
"cameraManagement",
"globalDetect",
"globalObjects",
"globalMotion",
"globalFfmpeg",
"globalRecording",
"globalSnapshots",
"globalReview",
"globalAudioEvents",
"globalLivePlayback",
"globalTimestampStyle",
],
},
{
label: "cameras",
views: [
"cameraDetect",
"cameraObjects",
"cameraMotion",
"motionTuner",
"cameraFfmpeg",
"cameraRecording",
"cameraSnapshots",
"masksAndZones",
"cameraReview",
"cameraAudioEvents",
"cameraAudioTranscription",
"cameraBirdseye",
"cameraLivePlayback",
"cameraNotifications",
"cameraFaceRecognition",
"cameraLpr",
"cameraOnvif",
"cameraMqttConfig",
"cameraTimestampStyle",
],
},
{
label: "enrichments",
views: [
"integrationSemanticSearch",
"integrationGenerativeAi",
"integrationFaceRecognition",
"integrationLpr",
"integrationObjectClassification",
"triggers",
"integrationAudioTranscription",
],
},
{
label: "system",
views: [
"systemGo2rtcStreams",
"systemDetectorsAndModel",
"systemDatabase",
"systemMqtt",
"systemBirdseye",
"systemTls",
"systemAuthentication",
"systemNetworking",
"systemProxy",
"systemUi",
"systemLogging",
"systemEnvironmentVariables",
"systemTelemetry",
],
},
{
label: "users",
views: ["users", "roles"],
},
{
label: "notifications",
views: ["notifications"],
},
{
label: "frigateplus",
views: ["frigateplus"],
},
{
label: "maintenance",
views: ["mediaSync", "regionGrid"],
},
] as const;
/** `enrichments` and `debug` are reachable in the UI but have no menu entry. */
export type SettingsType =
| (typeof settingsViewGroups)[number]["views"][number]
| "enrichments"
| "debug";
export const allSettingsViews: SettingsType[] = [
...settingsViewGroups.flatMap((group) => group.views),
"enrichments",
"debug",
];
/** Sections a viewer may open. Everything else is admin only. */
export const ALLOWED_VIEWS_FOR_VIEWER: SettingsType[] = [
"uiSettings",
"notifications",
];