diff --git a/web/e2e/specs/command-menu.spec.ts b/web/e2e/specs/command-menu.spec.ts new file mode 100644 index 0000000000..da99eb92e7 --- /dev/null +++ b/web/e2e/specs/command-menu.spec.ts @@ -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((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); + }); +}); diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index 934e730c50..64289942ce 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -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" + } } } diff --git a/web/src/App.tsx b/web/src/App.tsx index 893819393d..07235db135 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() {
{isDesktop && } {isDesktop && } + {isDesktop && } {isMobile && }
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("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( + "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(() => { + 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 = "") => ( + run(command)} + className="cursor-pointer gap-2" + > + + {command.title} + {command.detail && {command.detail}} + + ); + + return ( + <> + + + + {t("commandMenu.empty")} + {search === "" && recentCommands.length > 0 && ( + + {recentCommands.map((command) => + renderCommand(command, "recent-"), + )} + + )} + {SECTIONS.map((section) => { + const inSection = commands.filter( + (command) => command.section === section, + ); + + if (inSection.length === 0) { + return null; + } + + return ( + + {inSection.map((command) => renderCommand(command))} + + ); + })} + +
+ {t("commandMenu.hint", { key: isMacOs ? "⌘K" : "Ctrl+K" })} +
+
+ setConfirmRestart(false)} + onRestart={() => sendRestart("restart")} + /> + + ); +} diff --git a/web/src/components/ui/command.tsx b/web/src/components/ui/command.tsx index 82183ef908..8ad002156a 100644 --- a/web/src/components/ui/command.tsx +++ b/web/src/components/ui/command.tsx @@ -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, @@ -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 ( - + button]:hidden", + className, + )} + > + {title} + {description} {children} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index e82b45b84a..6ed0bdff00 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -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 = { detect: "cameraDetect", diff --git a/web/src/types/settings.ts b/web/src/types/settings.ts new file mode 100644 index 0000000000..dcbe52a620 --- /dev/null +++ b/web/src/types/settings.ts @@ -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", +];