mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-25 16:04:41 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61fc628059 | ||
|
|
ea0d92ea55 | ||
|
|
4340da41f4 |
@@ -87,7 +87,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||||
uses: actions/setup-python@v7.1.0
|
uses: actions/setup-python@v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||||
- name: Install requirements
|
- name: Install requirements
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
|
import { test, expect, type FrigateApp } from "../fixtures/frigate-test";
|
||||||
|
import { viewerProfile } from "../fixtures/mock-data/profile";
|
||||||
import {
|
import {
|
||||||
expectBodyInteractive,
|
expectBodyInteractive,
|
||||||
waitForBodyInteractive,
|
waitForBodyInteractive,
|
||||||
@@ -988,6 +989,97 @@ test.describe("Multi-Camera Export from History @high", () => {
|
|||||||
"Front gate follow-up",
|
"Front gate follow-up",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a blank case name shows an error and disables export", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.installDefaults();
|
||||||
|
const form = await openMultiCameraTab(frigateApp);
|
||||||
|
|
||||||
|
const caseName = form.getByPlaceholder(/new case name/i);
|
||||||
|
await expect(caseName).toHaveValue(/^Multi-camera export - /, {
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
await expect(form.getByText("Case name is required")).toBeHidden();
|
||||||
|
|
||||||
|
await caseName.fill("");
|
||||||
|
|
||||||
|
await expect(form.getByText("Case name is required")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
form.getByRole("button", { name: /^export \d+ cameras?$/i }),
|
||||||
|
).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clearing a time field keeps the previous valid range", async ({
|
||||||
|
frigateApp,
|
||||||
|
}) => {
|
||||||
|
await frigateApp.installDefaults();
|
||||||
|
|
||||||
|
let capturedPayload: { items: { end_time: number }[] } | null = null;
|
||||||
|
await frigateApp.page.route("**/api/exports/batch", async (route) => {
|
||||||
|
capturedPayload = route.request().postDataJSON();
|
||||||
|
await route.fulfill({
|
||||||
|
status: 202,
|
||||||
|
json: { export_case_id: null, export_ids: [], results: [] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = await openMultiCameraTab(frigateApp);
|
||||||
|
|
||||||
|
const firstCamera = form.locator("button[aria-pressed]").first();
|
||||||
|
await expect(firstCamera).toBeVisible({ timeout: 5_000 });
|
||||||
|
if ((await firstCamera.getAttribute("aria-pressed")) === "false") {
|
||||||
|
await firstCamera.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// the seeded end can equal the picker's fallback, so move it off that
|
||||||
|
// value before clearing
|
||||||
|
await form.getByRole("button", { name: /^end time$/i }).click();
|
||||||
|
const endInput = frigateApp.page.locator("#endTime");
|
||||||
|
const [hour, minute, second] = (await endInput.inputValue()).split(":");
|
||||||
|
const chosen = `${hour}:${minute}:${String((Number(second) + 30) % 60).padStart(2, "0")}`;
|
||||||
|
await endInput.fill(chosen);
|
||||||
|
await endInput.fill("");
|
||||||
|
await expect(endInput).toHaveValue(chosen);
|
||||||
|
await frigateApp.page.keyboard.press("Escape");
|
||||||
|
|
||||||
|
await form.getByRole("button", { name: /^export \d+ cameras?$/i }).click();
|
||||||
|
|
||||||
|
await expect.poll(() => capturedPayload).not.toBeNull();
|
||||||
|
const submittedClock = await frigateApp.page.evaluate(
|
||||||
|
(endTime) => new Date(endTime * 1000).toTimeString().slice(0, 8),
|
||||||
|
capturedPayload!.items[0].end_time,
|
||||||
|
);
|
||||||
|
expect(submittedClock).toBe(chosen);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("viewers can export without a case", async ({ frigateApp }) => {
|
||||||
|
await frigateApp.installDefaults({ profile: viewerProfile() });
|
||||||
|
|
||||||
|
let capturedPayload: Record<string, unknown> | null = null;
|
||||||
|
await frigateApp.page.route("**/api/exports/batch", async (route) => {
|
||||||
|
capturedPayload = route.request().postDataJSON();
|
||||||
|
await route.fulfill({
|
||||||
|
status: 202,
|
||||||
|
json: { export_case_id: null, export_ids: [], results: [] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = await openMultiCameraTab(frigateApp);
|
||||||
|
|
||||||
|
const firstCamera = form.locator("button[aria-pressed]").first();
|
||||||
|
await expect(firstCamera).toBeVisible({ timeout: 5_000 });
|
||||||
|
if ((await firstCamera.getAttribute("aria-pressed")) === "false") {
|
||||||
|
await firstCamera.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(form.getByPlaceholder(/new case name/i)).toBeHidden();
|
||||||
|
await form.getByRole("button", { name: /^export \d+ cameras?$/i }).click();
|
||||||
|
|
||||||
|
await expect.poll(() => capturedPayload).not.toBeNull();
|
||||||
|
expect(capturedPayload).not.toHaveProperty("new_case_name");
|
||||||
|
expect(capturedPayload).not.toHaveProperty("export_case_id");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe("Export Page - Active Job Progress @medium", () => {
|
test.describe("Export Page - Active Job Progress @medium", () => {
|
||||||
|
|||||||
@@ -66,8 +66,8 @@
|
|||||||
"newCaseNamePlaceholder": "New case name",
|
"newCaseNamePlaceholder": "New case name",
|
||||||
"newCaseDescriptionPlaceholder": "Case description",
|
"newCaseDescriptionPlaceholder": "Case description",
|
||||||
"label": "Case",
|
"label": "Case",
|
||||||
"nonAdminHelp": "A new case will be created for these exports.",
|
"placeholder": "Select a case",
|
||||||
"placeholder": "Select a case"
|
"nameRequired": "Case name is required"
|
||||||
},
|
},
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
@@ -91,9 +91,11 @@
|
|||||||
"selectedCount": "{{selected}} / {{total}} selected",
|
"selectedCount": "{{selected}} / {{total}} selected",
|
||||||
"checkingActivity": "Checking camera activity...",
|
"checkingActivity": "Checking camera activity...",
|
||||||
"noCameras": "No cameras available",
|
"noCameras": "No cameras available",
|
||||||
|
"noCamerasSelected": "Select at least one camera",
|
||||||
"detectionCount_one": "1 tracked object",
|
"detectionCount_one": "1 tracked object",
|
||||||
"detectionCount_other": "{{count}} tracked objects",
|
"detectionCount_other": "{{count}} tracked objects",
|
||||||
"nameLabel": "Export name",
|
"nameLabel": "Export name",
|
||||||
|
"caseNameDefault": "Multi-camera export - {{date}}",
|
||||||
"namePlaceholder": "Optional base name for these exports",
|
"namePlaceholder": "Optional base name for these exports",
|
||||||
"queueingButton": "Queueing Exports...",
|
"queueingButton": "Queueing Exports...",
|
||||||
"exportButton_one": "Export 1 Camera",
|
"exportButton_one": "Export 1 Camera",
|
||||||
|
|||||||
@@ -151,6 +151,11 @@ export function CustomTimeSelector({
|
|||||||
step={isIOS ? "60" : "1"}
|
step={isIOS ? "60" : "1"}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const clock = e.target.value;
|
const clock = e.target.value;
|
||||||
|
|
||||||
|
if (!clock) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const [hour, minute, second] = isIOS
|
const [hour, minute, second] = isIOS
|
||||||
? [...clock.split(":"), "00"]
|
? [...clock.split(":"), "00"]
|
||||||
: clock.split(":");
|
: clock.split(":");
|
||||||
@@ -217,6 +222,11 @@ export function CustomTimeSelector({
|
|||||||
step={isIOS ? "60" : "1"}
|
step={isIOS ? "60" : "1"}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const clock = e.target.value;
|
const clock = e.target.value;
|
||||||
|
|
||||||
|
if (!clock) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const [hour, minute, second] = isIOS
|
const [hour, minute, second] = isIOS
|
||||||
? [...clock.split(":"), "00"]
|
? [...clock.split(":"), "00"]
|
||||||
: clock.split(":");
|
: clock.split(":");
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
||||||
import { Textarea } from "../ui/textarea";
|
import { Textarea } from "../ui/textarea";
|
||||||
import { useIsAdmin } from "@/hooks/use-is-admin";
|
import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||||
|
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||||
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
||||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||||
import { isValidIconName } from "@/utils/iconUtil";
|
import { isValidIconName } from "@/utils/iconUtil";
|
||||||
|
|
||||||
@@ -441,6 +443,7 @@ export function ExportContent({
|
|||||||
}: ExportContentProps) {
|
}: ExportContentProps) {
|
||||||
const { t } = useTranslation(["components/dialog"]);
|
const { t } = useTranslation(["components/dialog"]);
|
||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
|
const locale = useDateLocale();
|
||||||
const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
|
const [selectedOption, setSelectedOption] = useState<ExportOption>("1");
|
||||||
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
|
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
@@ -663,12 +666,25 @@ export function ExportContent({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const selectedCameraCount = selectedCameraIds.length;
|
const selectedCameraCount = selectedCameraIds.length;
|
||||||
|
|
||||||
|
const batchRangeError = !range
|
||||||
|
? t("export.toast.error.noValidTimeSelected")
|
||||||
|
: range.before <= range.after
|
||||||
|
? t("export.toast.error.endTimeMustAfterStartTime")
|
||||||
|
: undefined;
|
||||||
|
const batchCameraError =
|
||||||
|
!isEventsLoading && selectedCameraCount === 0
|
||||||
|
? t("export.multiCamera.noCamerasSelected")
|
||||||
|
: undefined;
|
||||||
|
const batchCaseNameError =
|
||||||
|
isAdmin && batchCaseSelection === "new" && newCaseName.trim().length === 0
|
||||||
|
? t("export.case.nameRequired")
|
||||||
|
: undefined;
|
||||||
const canStartBatchExport =
|
const canStartBatchExport =
|
||||||
Boolean(range && range.before > range.after) &&
|
!batchRangeError &&
|
||||||
|
!batchCaseNameError &&
|
||||||
selectedCameraCount > 0 &&
|
selectedCameraCount > 0 &&
|
||||||
!isStartingBatchExport &&
|
!isStartingBatchExport;
|
||||||
(batchCaseSelection !== "new" || newCaseName.trim().length > 0) &&
|
|
||||||
batchCaseSelection.length > 0;
|
|
||||||
|
|
||||||
const onSelectTime = useCallback(
|
const onSelectTime = useCallback(
|
||||||
(option: ExportOption) => {
|
(option: ExportOption) => {
|
||||||
@@ -965,6 +981,17 @@ export function ExportContent({
|
|||||||
if (tab === "multi") {
|
if (tab === "multi") {
|
||||||
setRange(buildRangeAroundCurrentTime(MULTI_CAMERA_RANGE_SECONDS));
|
setRange(buildRangeAroundCurrentTime(MULTI_CAMERA_RANGE_SECONDS));
|
||||||
setBatchCaseSelection(selectedCaseId ?? "new");
|
setBatchCaseSelection(selectedCaseId ?? "new");
|
||||||
|
if (newCaseName.trim().length === 0) {
|
||||||
|
setNewCaseName(
|
||||||
|
t("export.multiCamera.caseNameDefault", {
|
||||||
|
date: formatUnixTimestampToDateTime(Date.now() / 1000, {
|
||||||
|
date_style: "medium",
|
||||||
|
time_style: "short",
|
||||||
|
locale,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
onSelectTime(selectedOption);
|
onSelectTime(selectedOption);
|
||||||
}
|
}
|
||||||
@@ -1132,6 +1159,11 @@ export function ExportContent({
|
|||||||
</TooltipPortal>
|
</TooltipPortal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
{batchRangeError && (
|
||||||
|
<p className="text-sm font-medium text-destructive">
|
||||||
|
{batchRangeError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1315,6 +1347,11 @@ export function ExportContent({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{batchCameraError && (
|
||||||
|
<p className="text-sm font-medium text-destructive">
|
||||||
|
{batchCameraError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -1367,6 +1404,11 @@ export function ExportContent({
|
|||||||
value={newCaseName}
|
value={newCaseName}
|
||||||
onChange={(event) => setNewCaseName(event.target.value)}
|
onChange={(event) => setNewCaseName(event.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{batchCaseNameError && (
|
||||||
|
<p className="text-sm font-medium text-destructive">
|
||||||
|
{batchCaseNameError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
|
placeholder={t("export.case.newCaseDescriptionPlaceholder")}
|
||||||
value={newCaseDescription}
|
value={newCaseDescription}
|
||||||
|
|||||||
@@ -68,9 +68,7 @@ export default function MultiExportDialog({
|
|||||||
const isAdmin = useIsAdmin();
|
const isAdmin = useIsAdmin();
|
||||||
|
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
// Only admins can attach exports to an existing case (enforced server-side
|
// Only admins can assign exports to a case, so non-admins skip the case list
|
||||||
// by POST /exports/batch). Skip fetching the case list entirely for
|
|
||||||
// non-admins — they can only ever use the "Create new case" branch.
|
|
||||||
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
|
const { data: cases } = useSWR<ExportCase[]>(isAdmin ? "cases" : null);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -284,9 +282,6 @@ export default function MultiExportDialog({
|
|||||||
t,
|
t,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// New-case inputs: rendered below the Select when caseSelection === "new",
|
|
||||||
// or rendered standalone for non-admins (who never see the Select since
|
|
||||||
// they cannot attach to an existing case).
|
|
||||||
const newCaseInputs = (
|
const newCaseInputs = (
|
||||||
<div className="space-y-2 pt-1">
|
<div className="space-y-2 pt-1">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
Reference in New Issue
Block a user