mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-24 18:26:51 +03:00
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
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
Reference in New Issue
Block a user