Compare commits

...

2 Commits

Author SHA1 Message Date
Josh Hawkins
1a1994ca17
Miscellaneous fixes (#23000)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* ensure classification wizard dialog is scrollable on mobile too

* add chat and features group to mobile menu

Co-authored-by: Copilot <copilot@github.com>

* Set min length for summary too

* Don't use orange for review item

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
2026-04-25 09:12:20 -06:00
Josh Hawkins
819e8de172
Revert modal changes (#23001)
* revert modal changes from #22963

* add test and lint
2026-04-25 07:21:13 -06:00
16 changed files with 176 additions and 42 deletions

View File

@ -8,8 +8,6 @@ class ReviewMetadata(BaseModel):
description="A short title characterizing what took place and where, under 10 words." description="A short title characterizing what took place and where, under 10 words."
) )
scene: str = Field( scene: str = Field(
min_length=120,
max_length=600,
description="A chronological narrative of what happens from start to finish.", description="A chronological narrative of what happens from start to finish.",
) )
shortSummary: str = Field( shortSummary: str = Field(

View File

@ -151,6 +151,18 @@ Each line represents a detection state, not necessarily unique individuals. The
if "other_concerns" in schema.get("required", []): if "other_concerns" in schema.get("required", []):
schema["required"].remove("other_concerns") schema["required"].remove("other_concerns")
# Length hints injected into the schema as suggestions to the model
# (enforced by grammar-based providers like llama.cpp) but kept off the
# Pydantic model so a non-compliant response does not fail validation.
length_hints = {
"scene": {"minLength": 120, "maxLength": 600},
"shortSummary": {"minLength": 70, "maxLength": 100},
}
for field, hints in length_hints.items():
prop = schema.get("properties", {}).get(field)
if prop is not None:
prop.update(hints)
# OpenAI strict mode requires additionalProperties: false on all objects # OpenAI strict mode requires additionalProperties: false on all objects
schema["additionalProperties"] = False schema["additionalProperties"] = False

View File

@ -1,4 +1,114 @@
import { test, expect } from "../fixtures/frigate-test"; import { test, expect } from "../fixtures/frigate-test";
import {
expectBodyInteractive,
waitForBodyInteractive,
} from "../helpers/overlay-interaction";
test.describe("Export Page - Delete race @high", () => {
// Empirical guard for radix-ui/primitives#3445: when a modal DropdownMenu
// opens an AlertDialog and the AlertDialog's confirm action causes the
// parent's optimistic cache update to unmount the card, we want to know
// whether the deduped react-dismissable-layer (1.1.11) handles the
// pointer-events stack cleanup or whether `modal={false}` is still
// required on the DropdownMenu. The classic "canonical" pattern, distinct
// from the FaceSelectionDialog auto-unmount race already covered by
// face-library.spec.ts.
test("deleting an export via dropdown→alert→confirm leaves body interactive", async ({
frigateApp,
}) => {
if (frigateApp.isMobile) {
test.skip();
return;
}
const initialExports = [
{
id: "export-race-001",
camera: "front_door",
name: "Race - Test Export",
date: 1775490731.3863528,
video_path: "/exports/export-race-001.mp4",
thumb_path: "/exports/export-race-001-thumb.jpg",
in_progress: false,
export_case_id: null,
},
];
let deleted = false;
await frigateApp.installDefaults({
exports: initialExports,
});
// Flip /api/export to empty after the delete POST is observed so the
// page's SWR mutate sees the export gone.
await frigateApp.page.route("**/api/export**", async (route) => {
const payload = deleted ? [] : initialExports;
await route.fulfill({ json: payload });
});
await frigateApp.page.route("**/api/exports/delete", async (route) => {
deleted = true;
const delayMs = Number(
(globalThis as { process?: { env?: Record<string, string> } }).process
?.env?.DELETE_DELAY_MS ?? "100",
);
if (delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
await route.fulfill({ json: { success: true } });
});
await frigateApp.goto("/export");
await expect(frigateApp.page.getByText("Race - Test Export")).toBeVisible({
timeout: 5_000,
});
// Open the kebab menu on the export card. The kebab uses the
// (misleading) aria-label "Edit name" from ExportCard's source — it
// wraps the FiMoreVertical icon. There is exactly one such button on
// the page once we have a single export rendered.
const kebab = frigateApp.page
.getByRole("button", { name: /edit name/i })
.first();
await expect(kebab).toBeVisible({ timeout: 5_000 });
await kebab.click();
const menu = frigateApp.page
.locator('[role="menu"], [data-radix-menu-content]')
.first();
await expect(menu).toBeVisible({ timeout: 3_000 });
// Delete Export
await menu
.getByRole("menuitem", { name: /delete export/i })
.first()
.click();
// AlertDialog at page level. The confirm button's accessible name is
// "Delete Export" (its aria-label), the visible text is just "Delete".
const confirm = frigateApp.page.getByRole("alertdialog");
await expect(confirm).toBeVisible({ timeout: 3_000 });
await confirm
.getByRole("button", { name: /^delete export$/i })
.first()
.click();
// The card optimistically disappears, the dialog closes, and body
// pointer-events must come unstuck.
await expect(
frigateApp.page.getByText("Race - Test Export"),
).not.toBeVisible({ timeout: 5_000 });
await waitForBodyInteractive(frigateApp.page, 5_000);
await expectBodyInteractive(frigateApp.page);
// Sanity: another page-level button still responds.
const newCase = frigateApp.page.getByRole("button", { name: /new case/i });
await expect(newCase).toBeVisible({ timeout: 3_000 });
await newCase.click();
await expect(
frigateApp.page.getByRole("dialog").filter({ hasText: /create case/i }),
).toBeVisible({ timeout: 3_000 });
});
});
test.describe("Export Page - Overview @high", () => { test.describe("Export Page - Overview @high", () => {
test("renders uncategorized exports and case cards from mock data", async ({ test("renders uncategorized exports and case cards from mock data", async ({

View File

@ -472,7 +472,9 @@ test.describe("FaceSelectionDialog @high", () => {
await groupedImage.locator("xpath=..").click(); await groupedImage.locator("xpath=..").click();
const dialog = frigateApp.page const dialog = frigateApp.page
.getByRole("dialog") .getByRole("dialog")
.filter({ has: frigateApp.page.locator('img[src*="clips/faces/train/"]') }) .filter({
has: frigateApp.page.locator('img[src*="clips/faces/train/"]'),
})
.first(); .first();
await expect(dialog).toBeVisible({ timeout: 5_000 }); await expect(dialog).toBeVisible({ timeout: 5_000 });

View File

@ -257,6 +257,7 @@
"export": "Export", "export": "Export",
"actions": "Actions", "actions": "Actions",
"uiPlayground": "UI Playground", "uiPlayground": "UI Playground",
"features": "Features",
"faceLibrary": "Face Library", "faceLibrary": "Face Library",
"classification": "Classification", "classification": "Classification",
"chat": "Chat", "chat": "Chat",

View File

@ -161,13 +161,13 @@ export function AnimatedEventCard({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
className={cn( className={cn(
"absolute left-2 top-1 z-40 transition-opacity", "absolute left-2 top-1 z-40 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 transition-opacity",
threatLevel === ThreatLevel.SECURITY_CONCERN && threatLevel === ThreatLevel.SECURITY_CONCERN &&
"pointer-events-auto bg-severity_alert opacity-100 hover:bg-severity_alert", "pointer-events-auto opacity-100",
threatLevel === ThreatLevel.NEEDS_REVIEW && threatLevel === ThreatLevel.NEEDS_REVIEW &&
"pointer-events-auto bg-severity_detection opacity-100 hover:bg-severity_detection", "pointer-events-auto opacity-100",
threatLevel === ThreatLevel.NORMAL && threatLevel === ThreatLevel.NORMAL &&
"pointer-events-none bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 opacity-0 group-hover:pointer-events-auto group-hover:opacity-100", "pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100",
)} )}
size="xs" size="xs"
aria-label={t("markAsReviewed")} aria-label={t("markAsReviewed")}

View File

@ -266,7 +266,7 @@ export function ExportCard({
)} )}
{!exportedRecording.in_progress && !selectionMode && ( {!exportedRecording.in_progress && !selectionMode && (
<div className="absolute bottom-2 right-3 z-40"> <div className="absolute bottom-2 right-3 z-40">
<DropdownMenu modal={false}> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<BlurredIconButton <BlurredIconButton
aria-label={t("tooltip.editName")} aria-label={t("tooltip.editName")}

View File

@ -275,7 +275,7 @@ export default function ReviewCard({
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<ContextMenu key={event.id} modal={false}> <ContextMenu key={event.id}>
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger> <ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
<ContextMenuItem> <ContextMenuItem>

View File

@ -14,7 +14,6 @@ import Step3ChooseExamples, {
Step3FormData, Step3FormData,
} from "./wizard/Step3ChooseExamples"; } from "./wizard/Step3ChooseExamples";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { isDesktop } from "react-device-detect";
import axios from "axios"; import axios from "axios";
const OBJECT_STEPS = [ const OBJECT_STEPS = [
@ -153,13 +152,9 @@ export default function ClassificationModelWizardDialog({
> >
<DialogContent <DialogContent
className={cn( className={cn(
"", "scrollbar-container max-h-[90%] overflow-y-auto",
isDesktop && wizardState.currentStep == 0 && "xl:max-h-[80%]",
wizardState.currentStep == 0 && wizardState.currentStep > 0 && "md:max-w-[70%] xl:max-h-[80%]",
"max-h-[90%] overflow-y-auto xl:max-h-[80%]",
isDesktop &&
wizardState.currentStep > 0 &&
"max-h-[90%] max-w-[70%] overflow-y-auto xl:max-h-[80%]",
)} )}
onInteractOutside={(e) => { onInteractOutside={(e) => {
e.preventDefault(); e.preventDefault();

View File

@ -6,6 +6,7 @@ import {
LuLifeBuoy, LuLifeBuoy,
LuList, LuList,
LuLogOut, LuLogOut,
LuMessageSquare,
LuMoon, LuMoon,
LuSquarePen, LuSquarePen,
LuScanFace, LuScanFace,
@ -482,8 +483,15 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
</Link> </Link>
</> </>
)} )}
{isAdmin && isMobile && config?.face_recognition.enabled && ( </DropdownMenuGroup>
{isMobile && isAdmin && (
<> <>
<DropdownMenuLabel className="mt-1">
{t("menu.features")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup className="flex flex-col">
{config?.face_recognition.enabled && (
<Link to="/faces"> <Link to="/faces">
<MenuItem <MenuItem
className="flex w-full items-center p-2 text-sm" className="flex w-full items-center p-2 text-sm"
@ -493,10 +501,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
<span>{t("menu.faceLibrary")}</span> <span>{t("menu.faceLibrary")}</span>
</MenuItem> </MenuItem>
</Link> </Link>
</>
)} )}
{isAdmin && isMobile && (
<>
<Link to="/classification"> <Link to="/classification">
<MenuItem <MenuItem
className="flex w-full items-center p-2 text-sm" className="flex w-full items-center p-2 text-sm"
@ -506,9 +511,20 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
<span>{t("menu.classification")}</span> <span>{t("menu.classification")}</span>
</MenuItem> </MenuItem>
</Link> </Link>
</> {config?.genai?.model !== "none" && (
<Link to="/chat">
<MenuItem
className="flex w-full items-center p-2 text-sm"
aria-label={t("menu.chat")}
>
<LuMessageSquare className="mr-2 size-4" />
<span>{t("menu.chat")}</span>
</MenuItem>
</Link>
)} )}
</DropdownMenuGroup> </DropdownMenuGroup>
</>
)}
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}> <DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
{t("menu.appearance")} {t("menu.appearance")}
</DropdownMenuLabel> </DropdownMenuLabel>

View File

@ -272,7 +272,7 @@ export default function LiveContextMenu({
return ( return (
<div className={cn("w-full", className)}> <div className={cn("w-full", className)}>
<ContextMenu key={camera} modal={false} onOpenChange={handleOpenChange}> <ContextMenu key={camera} onOpenChange={handleOpenChange}>
<ContextMenuTrigger>{children}</ContextMenuTrigger> <ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
<div className="flex flex-col items-start gap-1 py-1 pl-2"> <div className="flex flex-col items-start gap-1 py-1 pl-2">

View File

@ -258,13 +258,13 @@ export default function SearchResultActions({
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
{isContextMenu ? ( {isContextMenu ? (
<ContextMenu modal={false}> <ContextMenu>
<ContextMenuTrigger>{children}</ContextMenuTrigger> <ContextMenuTrigger>{children}</ContextMenuTrigger>
<ContextMenuContent>{menuItems}</ContextMenuContent> <ContextMenuContent>{menuItems}</ContextMenuContent>
</ContextMenu> </ContextMenu>
) : ( ) : (
<> <>
<DropdownMenu modal={false}> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<BlurredIconButton aria-label={t("itemMenu.more.aria")}> <BlurredIconButton aria-label={t("itemMenu.more.aria")}>
<FiMoreVertical className="size-5" /> <FiMoreVertical className="size-5" />

View File

@ -22,7 +22,7 @@ export default function ActionsDropdown({
const { t } = useTranslation(["components/dialog", "views/replay", "common"]); const { t } = useTranslation(["components/dialog", "views/replay", "common"]);
return ( return (
<DropdownMenu modal={false}> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
className="flex items-center gap-2" className="flex items-center gap-2"

View File

@ -594,7 +594,7 @@ function LibrarySelector({
forbiddenErrorMessage={t("description.nameCannotContainHash")} forbiddenErrorMessage={t("description.nameCannotContainHash")}
/> />
<DropdownMenu modal={false}> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button className="flex justify-between smart-capitalize"> <Button className="flex justify-between smart-capitalize">
{pageTitle} {pageTitle}

View File

@ -342,7 +342,7 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
{config.name} {config.name}
</div> </div>
<div className="absolute bottom-2 right-2 z-40"> <div className="absolute bottom-2 right-2 z-40">
<DropdownMenu modal={false}> <DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}> <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<BlurredIconButton> <BlurredIconButton>
<FiMoreVertical className="size-5 text-white" /> <FiMoreVertical className="size-5 text-white" />

View File

@ -698,7 +698,7 @@ function LibrarySelector({
regexErrorMessage={t("description.invalidName")} regexErrorMessage={t("description.invalidName")}
/> />
<DropdownMenu modal={false}> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button className="flex justify-between smart-capitalize"> <Button className="flex justify-between smart-capitalize">
{pageTitle} {pageTitle}