mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-13 02:02:48 +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",
|
"validation_errors": "Validation Errors",
|
||||||
"credentialField": {
|
"credentialField": {
|
||||||
"savedPlaceholder": "Saved — leave blank to keep current"
|
"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 { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import Wrapper from "@/components/Wrapper";
|
import Wrapper from "@/components/Wrapper";
|
||||||
import Sidebar from "@/components/navigation/Sidebar";
|
import Sidebar from "@/components/navigation/Sidebar";
|
||||||
|
import CommandMenu from "@/components/menu/CommandMenu";
|
||||||
|
|
||||||
import { isDesktop, isMobile } from "react-device-detect";
|
import { isDesktop, isMobile } from "react-device-detect";
|
||||||
import Statusbar from "./components/Statusbar";
|
import Statusbar from "./components/Statusbar";
|
||||||
@@ -110,6 +111,7 @@ function DefaultAppView() {
|
|||||||
<div className="size-full overflow-hidden">
|
<div className="size-full overflow-hidden">
|
||||||
<ChromeErrorBoundary>{isDesktop && <Sidebar />}</ChromeErrorBoundary>
|
<ChromeErrorBoundary>{isDesktop && <Sidebar />}</ChromeErrorBoundary>
|
||||||
<ChromeErrorBoundary>{isDesktop && <Statusbar />}</ChromeErrorBoundary>
|
<ChromeErrorBoundary>{isDesktop && <Statusbar />}</ChromeErrorBoundary>
|
||||||
|
<ChromeErrorBoundary>{isDesktop && <CommandMenu />}</ChromeErrorBoundary>
|
||||||
<ChromeErrorBoundary>{isMobile && <Bottombar />}</ChromeErrorBoundary>
|
<ChromeErrorBoundary>{isMobile && <Bottombar />}</ChromeErrorBoundary>
|
||||||
<div
|
<div
|
||||||
id="pageRoot"
|
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 { Search } from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
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<
|
const Command = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive>,
|
React.ElementRef<typeof CommandPrimitive>,
|
||||||
@@ -21,12 +26,30 @@ const Command = React.forwardRef<
|
|||||||
));
|
));
|
||||||
Command.displayName = CommandPrimitive.displayName;
|
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 (
|
return (
|
||||||
<Dialog {...props}>
|
<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">
|
<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}
|
{children}
|
||||||
</Command>
|
</Command>
|
||||||
|
|||||||
+75
-213
@@ -57,6 +57,12 @@ import {
|
|||||||
type SectionStatus,
|
type SectionStatus,
|
||||||
} from "@/views/settings/SingleSectionPage";
|
} from "@/views/settings/SingleSectionPage";
|
||||||
import { useSearchEffect } from "@/hooks/use-overlay-state";
|
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 { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { useInitialCameraState } from "@/api/ws";
|
import { useInitialCameraState } from "@/api/ws";
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
@@ -117,70 +123,6 @@ import {
|
|||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { TooltipPortal } from "@radix-ui/react-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) => {
|
const parsePendingDataKey = (pendingDataKey: string) => {
|
||||||
if (pendingDataKey.includes("::")) {
|
if (pendingDataKey.includes("::")) {
|
||||||
const idx = pendingDataKey.indexOf("::");
|
const idx = pendingDataKey.indexOf("::");
|
||||||
@@ -300,153 +242,75 @@ const CameraTimestampStyleSettingsPage = createSectionPage(
|
|||||||
"camera",
|
"camera",
|
||||||
);
|
);
|
||||||
|
|
||||||
const settingsGroups = [
|
// Every section key in `settingsViewGroups` maps to the view that renders it.
|
||||||
{
|
const SECTION_VIEWS = {
|
||||||
label: "general",
|
uiSettings: UiSettingsView,
|
||||||
items: [{ key: "uiSettings", component: UiSettingsView }],
|
profiles: ProfilesView,
|
||||||
},
|
cameraManagement: CameraManagementView,
|
||||||
{
|
globalDetect: GlobalDetectSettingsPage,
|
||||||
label: "globalConfig",
|
globalObjects: GlobalObjectsSettingsPage,
|
||||||
items: [
|
globalMotion: GlobalMotionSettingsPage,
|
||||||
{ key: "profiles", component: ProfilesView },
|
globalFfmpeg: GlobalFfmpegSettingsPage,
|
||||||
{ key: "cameraManagement", component: CameraManagementView },
|
globalRecording: GlobalRecordingSettingsPage,
|
||||||
{ key: "globalDetect", component: GlobalDetectSettingsPage },
|
globalSnapshots: GlobalSnapshotsSettingsPage,
|
||||||
{ key: "globalObjects", component: GlobalObjectsSettingsPage },
|
globalReview: GlobalReviewSettingsPage,
|
||||||
{ key: "globalMotion", component: GlobalMotionSettingsPage },
|
globalAudioEvents: GlobalAudioEventsSettingsPage,
|
||||||
{ key: "globalFfmpeg", component: GlobalFfmpegSettingsPage },
|
globalLivePlayback: GlobalLivePlaybackSettingsPage,
|
||||||
{ key: "globalRecording", component: GlobalRecordingSettingsPage },
|
globalTimestampStyle: GlobalTimestampStyleSettingsPage,
|
||||||
{ key: "globalSnapshots", component: GlobalSnapshotsSettingsPage },
|
cameraDetect: CameraDetectSettingsPage,
|
||||||
{ key: "globalReview", component: GlobalReviewSettingsPage },
|
cameraObjects: CameraObjectsSettingsPage,
|
||||||
{ key: "globalAudioEvents", component: GlobalAudioEventsSettingsPage },
|
cameraMotion: CameraMotionSettingsPage,
|
||||||
{
|
motionTuner: MotionTunerView,
|
||||||
key: "globalLivePlayback",
|
cameraFfmpeg: CameraFfmpegSettingsPage,
|
||||||
component: GlobalLivePlaybackSettingsPage,
|
cameraRecording: CameraRecordingSettingsPage,
|
||||||
},
|
cameraSnapshots: CameraSnapshotsSettingsPage,
|
||||||
{
|
masksAndZones: MasksAndZonesView,
|
||||||
key: "globalTimestampStyle",
|
cameraReview: CameraReviewSettingsPage,
|
||||||
component: GlobalTimestampStyleSettingsPage,
|
cameraAudioEvents: CameraAudioEventsSettingsPage,
|
||||||
},
|
cameraAudioTranscription: CameraAudioTranscriptionSettingsPage,
|
||||||
],
|
cameraBirdseye: CameraBirdseyeSettingsPage,
|
||||||
},
|
cameraLivePlayback: CameraLivePlaybackSettingsPage,
|
||||||
{
|
cameraNotifications: CameraNotificationsSettingsPage,
|
||||||
label: "cameras",
|
cameraFaceRecognition: CameraFaceRecognitionSettingsPage,
|
||||||
items: [
|
cameraLpr: CameraLprSettingsPage,
|
||||||
{ key: "cameraDetect", component: CameraDetectSettingsPage },
|
cameraOnvif: CameraOnvifSettingsPage,
|
||||||
{ key: "cameraObjects", component: CameraObjectsSettingsPage },
|
cameraMqttConfig: CameraMqttConfigSettingsPage,
|
||||||
{ key: "cameraMotion", component: CameraMotionSettingsPage },
|
cameraTimestampStyle: CameraTimestampStyleSettingsPage,
|
||||||
{ key: "motionTuner", component: MotionTunerView },
|
integrationSemanticSearch: IntegrationSemanticSearchSettingsPage,
|
||||||
{ key: "cameraFfmpeg", component: CameraFfmpegSettingsPage },
|
integrationGenerativeAi: IntegrationGenerativeAiSettingsPage,
|
||||||
{ key: "cameraRecording", component: CameraRecordingSettingsPage },
|
integrationFaceRecognition: IntegrationFaceRecognitionSettingsPage,
|
||||||
{ key: "cameraSnapshots", component: CameraSnapshotsSettingsPage },
|
integrationLpr: IntegrationLprSettingsPage,
|
||||||
{ key: "masksAndZones", component: MasksAndZonesView },
|
integrationObjectClassification: IntegrationObjectClassificationSettingsPage,
|
||||||
{ key: "cameraReview", component: CameraReviewSettingsPage },
|
triggers: TriggerView,
|
||||||
{ key: "cameraAudioEvents", component: CameraAudioEventsSettingsPage },
|
integrationAudioTranscription: IntegrationAudioTranscriptionSettingsPage,
|
||||||
{
|
systemGo2rtcStreams: Go2RtcStreamsSettingsView,
|
||||||
key: "cameraAudioTranscription",
|
systemDetectorsAndModel: SystemDetectionModelsPage,
|
||||||
component: CameraAudioTranscriptionSettingsPage,
|
systemDatabase: SystemDatabaseSettingsPage,
|
||||||
},
|
systemMqtt: SystemMqttSettingsPage,
|
||||||
{ key: "cameraBirdseye", component: CameraBirdseyeSettingsPage },
|
systemBirdseye: SystemBirdseyeSettingsPage,
|
||||||
{
|
systemTls: SystemTlsSettingsPage,
|
||||||
key: "cameraLivePlayback",
|
systemAuthentication: SystemAuthenticationSettingsPage,
|
||||||
component: CameraLivePlaybackSettingsPage,
|
systemNetworking: SystemNetworkingSettingsPage,
|
||||||
},
|
systemProxy: SystemProxySettingsPage,
|
||||||
{
|
systemUi: SystemUiSettingsPage,
|
||||||
key: "cameraNotifications",
|
systemLogging: SystemLoggingSettingsPage,
|
||||||
component: CameraNotificationsSettingsPage,
|
systemEnvironmentVariables: SystemEnvironmentVariablesSettingsPage,
|
||||||
},
|
systemTelemetry: SystemTelemetrySettingsPage,
|
||||||
{
|
users: UsersView,
|
||||||
key: "cameraFaceRecognition",
|
roles: RolesView,
|
||||||
component: CameraFaceRecognitionSettingsPage,
|
notifications: NotificationsSettingsPage,
|
||||||
},
|
frigateplus: FrigatePlusSettingsView,
|
||||||
{ key: "cameraLpr", component: CameraLprSettingsPage },
|
mediaSync: MediaSyncSettingsView,
|
||||||
{ key: "cameraOnvif", component: CameraOnvifSettingsPage },
|
regionGrid: RegionGridSettingsView,
|
||||||
{ key: "cameraMqttConfig", component: CameraMqttConfigSettingsPage },
|
};
|
||||||
{
|
|
||||||
key: "cameraTimestampStyle",
|
const settingsGroups = settingsViewGroups.map((group) => ({
|
||||||
component: CameraTimestampStyleSettingsPage,
|
label: group.label,
|
||||||
},
|
items: group.views.map((key) => ({
|
||||||
],
|
key,
|
||||||
},
|
component: SECTION_VIEWS[key],
|
||||||
{
|
})),
|
||||||
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 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const CAMERA_SELECT_BUTTON_PAGES = [
|
const CAMERA_SELECT_BUTTON_PAGES = [
|
||||||
"debug",
|
"debug",
|
||||||
@@ -473,8 +337,6 @@ const CAMERA_SELECT_BUTTON_PAGES = [
|
|||||||
"regionGrid",
|
"regionGrid",
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALLOWED_VIEWS_FOR_VIEWER = ["uiSettings", "notifications"];
|
|
||||||
|
|
||||||
// keys for camera sections
|
// keys for camera sections
|
||||||
const CAMERA_SECTION_MAPPING: Record<string, SettingsType> = {
|
const CAMERA_SECTION_MAPPING: Record<string, SettingsType> = {
|
||||||
detect: "cameraDetect",
|
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