mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 15:05:26 +03:00
Compare commits
22 Commits
ef0e21b3bf
...
539f27d3a9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
539f27d3a9 | ||
|
|
c28a30ea0f | ||
|
|
5828fe6251 | ||
|
|
18138b727f | ||
|
|
636d8a0e33 | ||
|
|
6a9c3f4509 | ||
|
|
86c065b322 | ||
|
|
c7b3762e1f | ||
|
|
4d355144d3 | ||
|
|
cc96e9a9c2 | ||
|
|
58c93d649a | ||
|
|
1eafd8fa09 | ||
|
|
8dbd0e93a3 | ||
|
|
98a41282f0 | ||
|
|
7c1ae5492e | ||
|
|
82074fad09 | ||
|
|
343a457e4d | ||
|
|
8bddf02bf4 | ||
|
|
4674d33901 | ||
|
|
1c03e55b94 | ||
|
|
1a1994ca17 | ||
|
|
819e8de172 |
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 ({
|
||||||
|
|||||||
@ -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 });
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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")}
|
||||||
|
|||||||
@ -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")}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
LuLifeBuoy,
|
LuLifeBuoy,
|
||||||
LuList,
|
LuList,
|
||||||
LuLogOut,
|
LuLogOut,
|
||||||
|
LuMessageSquare,
|
||||||
LuMoon,
|
LuMoon,
|
||||||
LuSquarePen,
|
LuSquarePen,
|
||||||
LuScanFace,
|
LuScanFace,
|
||||||
@ -482,21 +483,25 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{isAdmin && isMobile && config?.face_recognition.enabled && (
|
</DropdownMenuGroup>
|
||||||
<>
|
{isMobile && isAdmin && (
|
||||||
<Link to="/faces">
|
<>
|
||||||
<MenuItem
|
<DropdownMenuLabel className="mt-1">
|
||||||
className="flex w-full items-center p-2 text-sm"
|
{t("menu.features")}
|
||||||
aria-label={t("menu.faceLibrary")}
|
</DropdownMenuLabel>
|
||||||
>
|
<DropdownMenuSeparator />
|
||||||
<LuScanFace className="mr-2 size-4" />
|
<DropdownMenuGroup className="flex flex-col">
|
||||||
<span>{t("menu.faceLibrary")}</span>
|
{config?.face_recognition.enabled && (
|
||||||
</MenuItem>
|
<Link to="/faces">
|
||||||
</Link>
|
<MenuItem
|
||||||
</>
|
className="flex w-full items-center p-2 text-sm"
|
||||||
)}
|
aria-label={t("menu.faceLibrary")}
|
||||||
{isAdmin && isMobile && (
|
>
|
||||||
<>
|
<LuScanFace className="mr-2 size-4" />
|
||||||
|
<span>{t("menu.faceLibrary")}</span>
|
||||||
|
</MenuItem>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<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">
|
||||||
</DropdownMenuGroup>
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
||||||
{t("menu.appearance")}
|
{t("menu.appearance")}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user