mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-26 16:34:43 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f82c68f64 | ||
|
|
0ed5561553 |
@@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import queue
|
import queue
|
||||||
import random
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime
|
||||||
from multiprocessing import Queue
|
from multiprocessing import Queue
|
||||||
from multiprocessing.synchronize import Event as MpEvent
|
from multiprocessing.synchronize import Event as MpEvent
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -256,9 +255,7 @@ def process_frames(
|
|||||||
region_grid: list[list[dict[str, Any]]],
|
region_grid: list[list[dict[str, Any]]],
|
||||||
exit_on_empty: bool = False,
|
exit_on_empty: bool = False,
|
||||||
):
|
):
|
||||||
# cameras share one dispatcher, so stagger the daily grid rebuilds
|
next_region_update = get_tomorrow_at_time(2)
|
||||||
region_update_offset = timedelta(seconds=random.randrange(3600))
|
|
||||||
next_region_update = get_tomorrow_at_time(2) + region_update_offset
|
|
||||||
config_subscriber = CameraConfigUpdateSubscriber(
|
config_subscriber = CameraConfigUpdateSubscriber(
|
||||||
None,
|
None,
|
||||||
{camera_config.name: camera_config},
|
{camera_config.name: camera_config},
|
||||||
@@ -347,7 +344,7 @@ def process_frames(
|
|||||||
if updated_grid:
|
if updated_grid:
|
||||||
region_grid = updated_grid
|
region_grid = updated_grid
|
||||||
|
|
||||||
next_region_update = get_tomorrow_at_time(2) + region_update_offset
|
next_region_update = get_tomorrow_at_time(2)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if exit_on_empty:
|
if exit_on_empty:
|
||||||
|
|||||||
@@ -474,10 +474,10 @@ class CameraWatchdog(threading.Thread):
|
|||||||
if not self.capture_thread.is_alive():
|
if not self.capture_thread.is_alive():
|
||||||
self.detect_status.send("offline", now)
|
self.detect_status.send("offline", now)
|
||||||
self.camera_fps.value = 0
|
self.camera_fps.value = 0
|
||||||
if can_restart:
|
|
||||||
self.logger.error(
|
self.logger.error(
|
||||||
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
|
f"Ffmpeg process crashed unexpectedly for {self.config.name}."
|
||||||
)
|
)
|
||||||
|
if can_restart:
|
||||||
self.reset_capture_thread(terminate=False)
|
self.reset_capture_thread(terminate=False)
|
||||||
last_restart_time = now
|
last_restart_time = now
|
||||||
elif self.camera_fps.value >= (self.config.detect.fps + 10):
|
elif self.camera_fps.value >= (self.config.detect.fps + 10):
|
||||||
@@ -498,10 +498,10 @@ class CameraWatchdog(threading.Thread):
|
|||||||
elif now - self.capture_thread.current_frame.value > 20:
|
elif now - self.capture_thread.current_frame.value > 20:
|
||||||
self.detect_status.send("offline", now)
|
self.detect_status.send("offline", now)
|
||||||
self.camera_fps.value = 0
|
self.camera_fps.value = 0
|
||||||
if can_restart:
|
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..."
|
f"No frames received from {self.config.name} in 20 seconds. Exiting ffmpeg..."
|
||||||
)
|
)
|
||||||
|
if can_restart:
|
||||||
self.reset_capture_thread()
|
self.reset_capture_thread()
|
||||||
last_restart_time = now
|
last_restart_time = now
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
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,
|
||||||
@@ -989,97 +988,6 @@ 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", () => {
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
/**
|
|
||||||
* Add-camera wizard Step 1 validation -- MEDIUM tier.
|
|
||||||
*
|
|
||||||
* Continue validates every field, so an invalid ONVIF port or a non-RTSP
|
|
||||||
* custom URL must show its translated message and keep the wizard on Step 1.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect } from "../../fixtures/frigate-test";
|
|
||||||
import type { Page } from "@playwright/test";
|
|
||||||
|
|
||||||
const PORT_ERROR = "ONVIF port must be a whole number from 1 to 65535";
|
|
||||||
|
|
||||||
async function openWizard(page: Page) {
|
|
||||||
await page.getByRole("button", { name: /Add New Camera/i }).click();
|
|
||||||
const dialog = page.getByRole("dialog");
|
|
||||||
await expect(dialog).toBeVisible();
|
|
||||||
await dialog.getByPlaceholder(/front_door/i).fill("step1_camera");
|
|
||||||
return dialog;
|
|
||||||
}
|
|
||||||
|
|
||||||
test.describe("Camera wizard step 1 validation @medium @mobile", () => {
|
|
||||||
test.beforeEach(async ({ frigateApp }) => {
|
|
||||||
// not in the default mock; unmocked it 500s and trips the error collector
|
|
||||||
await frigateApp.page.route("**/api/config/raw_paths", (route) =>
|
|
||||||
route.fulfill({ json: {} }),
|
|
||||||
);
|
|
||||||
await frigateApp.page.route("**/api/onvif/probe**", (route) =>
|
|
||||||
route.fulfill({ json: { success: false } }),
|
|
||||||
);
|
|
||||||
await frigateApp.goto("/settings?page=cameraManagement");
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.getByRole("heading", { name: /Manage Cameras/i }),
|
|
||||||
).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an invalid ONVIF port blocks Continue with a translated message", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
const dialog = await openWizard(frigateApp.page);
|
|
||||||
await dialog.getByPlaceholder("192.168.1.100").fill("192.168.1.100");
|
|
||||||
const port = dialog.getByPlaceholder("80", { exact: true });
|
|
||||||
const next = dialog.getByRole("button", { name: /^Continue$/i });
|
|
||||||
|
|
||||||
for (const value of ["abc", "0", "70000", "80.5"]) {
|
|
||||||
await port.fill(value);
|
|
||||||
await next.click();
|
|
||||||
await expect(dialog.getByText(PORT_ERROR)).toBeVisible();
|
|
||||||
await expect(port).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
await port.fill("8080");
|
|
||||||
await next.click();
|
|
||||||
await expect(port).toBeHidden();
|
|
||||||
await expect(dialog.getByText(PORT_ERROR)).toBeHidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a custom URL must use RTSP", async ({ frigateApp }) => {
|
|
||||||
const dialog = await openWizard(frigateApp.page);
|
|
||||||
await dialog.getByText("Manual selection", { exact: true }).click();
|
|
||||||
await dialog.getByRole("combobox").click();
|
|
||||||
await frigateApp.page.getByRole("option", { name: "Other" }).click();
|
|
||||||
const url = dialog.getByPlaceholder(
|
|
||||||
"rtsp://username:password@host:port/path",
|
|
||||||
);
|
|
||||||
const next = dialog.getByRole("button", { name: /^Continue$/i });
|
|
||||||
|
|
||||||
await url.fill("http://192.168.1.100/stream");
|
|
||||||
await next.click();
|
|
||||||
await expect(
|
|
||||||
dialog.getByText(/Custom URLs must begin with "rtsp:\/\/"/),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
await url.fill("rtsp://192.168.1.100/stream");
|
|
||||||
await next.click();
|
|
||||||
await expect(url).toBeHidden();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -121,14 +121,9 @@ async function confirmImport(page: Page) {
|
|||||||
// route change, so #pageRoot is already present and waiting for it
|
// route change, so #pageRoot is already present and waiting for it
|
||||||
// alone can race the navigation. Wait for the "load" event first so
|
// alone can race the navigation. Wait for the "load" event first so
|
||||||
// the evaluate() calls below run against the post-reload document.
|
// the evaluate() calls below run against the post-reload document.
|
||||||
// scoped to the dialog: until it opens, a page-wide "Import" match is the
|
|
||||||
// Import Settings button, which only reopens the file chooser
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
page.waitForEvent("load"),
|
page.waitForEvent("load"),
|
||||||
page
|
page.getByRole("button", { name: "Import" }).click(),
|
||||||
.getByRole("dialog")
|
|
||||||
.getByRole("button", { name: "Import", exact: true })
|
|
||||||
.click(),
|
|
||||||
]);
|
]);
|
||||||
await page.waitForSelector("#pageRoot");
|
await page.waitForSelector("#pageRoot");
|
||||||
}
|
}
|
||||||
@@ -275,9 +270,6 @@ test.describe("UI settings import/export @medium", () => {
|
|||||||
// naturalAspectLayout preference must not override that.
|
// naturalAspectLayout preference must not override that.
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
await frigateApp.goto("/settings?page=uiSettings");
|
||||||
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": true });
|
await writeIdb(frigateApp.page, { "naturalAspectLayout:admin": true });
|
||||||
// the view reads the setting once on mount, so load it again after the
|
|
||||||
// write
|
|
||||||
await frigateApp.goto("/settings?page=uiSettings");
|
|
||||||
|
|
||||||
await chooseImportFile(
|
await chooseImportFile(
|
||||||
frigateApp.page,
|
frigateApp.page,
|
||||||
|
|||||||
@@ -248,37 +248,4 @@ test.describe("zone rename and delete @medium @mobile", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("inertia below 1 blocks saving and a valid value saves as a number", async ({
|
|
||||||
frigateApp,
|
|
||||||
}) => {
|
|
||||||
const requests = await installRoutes(frigateApp.page);
|
|
||||||
await frigateApp.goto(SETTINGS_URL);
|
|
||||||
|
|
||||||
await openZoneAction(frigateApp.page, frigateApp.isMobile, "Edit");
|
|
||||||
const inertia = frigateApp.page.getByRole("textbox", {
|
|
||||||
name: "Inertia",
|
|
||||||
exact: true,
|
|
||||||
});
|
|
||||||
const save = frigateApp.page.getByRole("button", { name: "Save" });
|
|
||||||
|
|
||||||
await inertia.fill("0");
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.getByText("Inertia must be above 0."),
|
|
||||||
).toBeVisible();
|
|
||||||
await expect(save).toBeDisabled();
|
|
||||||
|
|
||||||
await inertia.fill("5");
|
|
||||||
await expect(
|
|
||||||
frigateApp.page.getByText("Inertia must be above 0."),
|
|
||||||
).toBeHidden();
|
|
||||||
await save.click();
|
|
||||||
|
|
||||||
await expect.poll(() => requests.length).toBe(1);
|
|
||||||
expect(requests[0].body).toMatchObject({
|
|
||||||
config_data: {
|
|
||||||
cameras: { front_door: { zones: { driveway: { inertia: 5 } } } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+32
-4
@@ -83,7 +83,7 @@
|
|||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"virtua": "^0.53.6",
|
"virtua": "^0.53.6",
|
||||||
"vite-plugin-monaco-editor": "^1.1.0",
|
"vite-plugin-monaco-editor": "^1.1.0",
|
||||||
"zod": "^4.6.7"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
@@ -3131,6 +3131,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3148,6 +3151,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3165,6 +3171,9 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3182,6 +3191,9 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3199,6 +3211,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3216,6 +3231,9 @@
|
|||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -5097,6 +5115,16 @@
|
|||||||
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
|
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eslint-plugin-react-hooks/node_modules/zod": {
|
||||||
|
"version": "4.6.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
|
||||||
|
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eslint-plugin-react-hooks/node_modules/zod-validation-error": {
|
"node_modules/eslint-plugin-react-hooks/node_modules/zod-validation-error": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
|
||||||
@@ -10627,9 +10655,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "4.6.7",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
+1
-1
@@ -95,7 +95,7 @@
|
|||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"virtua": "^0.53.6",
|
"virtua": "^0.53.6",
|
||||||
"vite-plugin-monaco-editor": "^1.1.0",
|
"vite-plugin-monaco-editor": "^1.1.0",
|
||||||
"zod": "^4.6.7"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|||||||
@@ -66,8 +66,8 @@
|
|||||||
"newCaseNamePlaceholder": "New case name",
|
"newCaseNamePlaceholder": "New case name",
|
||||||
"newCaseDescriptionPlaceholder": "Case description",
|
"newCaseDescriptionPlaceholder": "Case description",
|
||||||
"label": "Case",
|
"label": "Case",
|
||||||
"placeholder": "Select a case",
|
"nonAdminHelp": "A new case will be created for these exports.",
|
||||||
"nameRequired": "Case name is required"
|
"placeholder": "Select a case"
|
||||||
},
|
},
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
@@ -91,11 +91,9 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -367,8 +367,7 @@
|
|||||||
"nameLength": "Camera name must be 64 characters or less",
|
"nameLength": "Camera name must be 64 characters or less",
|
||||||
"invalidCharacters": "Camera name contains invalid characters",
|
"invalidCharacters": "Camera name contains invalid characters",
|
||||||
"nameExists": "Camera name already exists",
|
"nameExists": "Camera name already exists",
|
||||||
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\" or \"rtsps://\". Manual configuration is required for non-RTSP camera streams.",
|
"customUrlRtspRequired": "Custom URLs must begin with \"rtsp://\" or \"rtsps://\". Manual configuration is required for non-RTSP camera streams."
|
||||||
"onvifPortInvalid": "ONVIF port must be a whole number from 1 to 65535"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"step2": {
|
"step2": {
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export default function ClassificationModelEditDialog({
|
|||||||
const sharedFields = {
|
const sharedFields = {
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
saveAttempts: z.coerce
|
saveAttempts: z.coerce
|
||||||
.number<number>({ message: t("edit.errors.saveAttemptsInvalid") })
|
.number({ message: t("edit.errors.saveAttemptsInvalid") })
|
||||||
.int(t("edit.errors.saveAttemptsInvalid"))
|
.int(t("edit.errors.saveAttemptsInvalid"))
|
||||||
.min(0, t("edit.errors.saveAttemptsInvalid")),
|
.min(0, t("edit.errors.saveAttemptsInvalid")),
|
||||||
};
|
};
|
||||||
@@ -136,7 +136,7 @@ export default function ClassificationModelEditDialog({
|
|||||||
(val) =>
|
(val) =>
|
||||||
val === "" || val === null || val === undefined ? undefined : val,
|
val === "" || val === null || val === undefined ? undefined : val,
|
||||||
z.coerce
|
z.coerce
|
||||||
.number<number>({ message: t("edit.errors.intervalInvalid") })
|
.number({ message: t("edit.errors.intervalInvalid") })
|
||||||
.int(t("edit.errors.intervalInvalid"))
|
.int(t("edit.errors.intervalInvalid"))
|
||||||
.positive(t("edit.errors.intervalInvalid"))
|
.positive(t("edit.errors.intervalInvalid"))
|
||||||
.optional(),
|
.optional(),
|
||||||
@@ -171,6 +171,7 @@ export default function ClassificationModelEditDialog({
|
|||||||
resolver: zodResolver(
|
resolver: zodResolver(
|
||||||
formSchema as z.ZodType<
|
formSchema as z.ZodType<
|
||||||
ObjectFormData | StateFormData,
|
ObjectFormData | StateFormData,
|
||||||
|
z.ZodTypeDef,
|
||||||
ObjectFormData | StateFormData
|
ObjectFormData | StateFormData
|
||||||
>,
|
>,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -151,11 +151,6 @@ 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(":");
|
||||||
@@ -222,11 +217,6 @@ 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,8 +65,6 @@ 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";
|
||||||
|
|
||||||
@@ -443,7 +441,6 @@ 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");
|
||||||
@@ -666,25 +663,12 @@ 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 =
|
||||||
!batchRangeError &&
|
Boolean(range && range.before > range.after) &&
|
||||||
!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) => {
|
||||||
@@ -981,17 +965,6 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -1159,11 +1132,6 @@ 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">
|
||||||
@@ -1347,11 +1315,6 @@ 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">
|
||||||
@@ -1404,11 +1367,6 @@ 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,7 +68,9 @@ 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 assign exports to a case, so non-admins skip the case list
|
// Only admins can attach exports to an existing case (enforced server-side
|
||||||
|
// 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);
|
||||||
@@ -282,6 +284,9 @@ 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
|
||||||
|
|||||||
@@ -332,9 +332,7 @@ function PreviewVideoPlayer({
|
|||||||
{t("noPreviewFoundFor", { cameraName: cameraName })}
|
{t("noPreviewFoundFor", { cameraName: cameraName })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{firstLoad && (
|
{firstLoad && <Skeleton className="absolute aspect-video size-full" />}
|
||||||
<Skeleton className="absolute aspect-video size-full rounded-lg md:rounded-2xl" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -557,9 +555,7 @@ function PreviewFramesPlayer({
|
|||||||
{t("noPreviewFoundFor", { cameraName: cameraName })}
|
{t("noPreviewFoundFor", { cameraName: cameraName })}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{firstLoad && (
|
{firstLoad && <Skeleton className="absolute aspect-video size-full" />}
|
||||||
<Skeleton className="absolute aspect-video size-full rounded-lg md:rounded-2xl" />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,13 +200,13 @@ export default function ZoneEditPane({
|
|||||||
),
|
),
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
inertia: z.coerce
|
inertia: z.coerce
|
||||||
.number<number>()
|
.number()
|
||||||
.min(1, {
|
.min(1, {
|
||||||
message: t("masksAndZones.form.inertia.error.mustBeAboveZero"),
|
message: t("masksAndZones.form.inertia.error.mustBeAboveZero"),
|
||||||
})
|
})
|
||||||
.or(z.literal("")),
|
.or(z.literal("")),
|
||||||
loitering_time: z.coerce
|
loitering_time: z.coerce
|
||||||
.number<number>()
|
.number()
|
||||||
.min(0, {
|
.min(0, {
|
||||||
message: t(
|
message: t(
|
||||||
"masksAndZones.form.loiteringTime.error.mustBeGreaterOrEqualZero",
|
"masksAndZones.form.loiteringTime.error.mustBeGreaterOrEqualZero",
|
||||||
@@ -222,35 +222,35 @@ export default function ZoneEditPane({
|
|||||||
review_detections: z.boolean().default(false).optional(),
|
review_detections: z.boolean().default(false).optional(),
|
||||||
speedEstimation: z.boolean().default(false),
|
speedEstimation: z.boolean().default(false),
|
||||||
lineA: z.coerce
|
lineA: z.coerce
|
||||||
.number<number>()
|
.number()
|
||||||
.min(0.1, {
|
.min(0.1, {
|
||||||
message: t("masksAndZones.form.distance.error.text"),
|
message: t("masksAndZones.form.distance.error.text"),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal("")),
|
.or(z.literal("")),
|
||||||
lineB: z.coerce
|
lineB: z.coerce
|
||||||
.number<number>()
|
.number()
|
||||||
.min(0.1, {
|
.min(0.1, {
|
||||||
message: t("masksAndZones.form.distance.error.text"),
|
message: t("masksAndZones.form.distance.error.text"),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal("")),
|
.or(z.literal("")),
|
||||||
lineC: z.coerce
|
lineC: z.coerce
|
||||||
.number<number>()
|
.number()
|
||||||
.min(0.1, {
|
.min(0.1, {
|
||||||
message: t("masksAndZones.form.distance.error.text"),
|
message: t("masksAndZones.form.distance.error.text"),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal("")),
|
.or(z.literal("")),
|
||||||
lineD: z.coerce
|
lineD: z.coerce
|
||||||
.number<number>()
|
.number()
|
||||||
.min(0.1, {
|
.min(0.1, {
|
||||||
message: t("masksAndZones.form.distance.error.text"),
|
message: t("masksAndZones.form.distance.error.text"),
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal("")),
|
.or(z.literal("")),
|
||||||
speed_threshold: z.coerce
|
speed_threshold: z.coerce
|
||||||
.number<number>()
|
.number()
|
||||||
.min(0.1, {
|
.min(0.1, {
|
||||||
message: t("masksAndZones.form.speed.error.mustBeGreaterOrEqualTo"),
|
message: t("masksAndZones.form.speed.error.mustBeGreaterOrEqualTo"),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -81,14 +81,7 @@ export default function Step1NameCamera({
|
|||||||
username: z.string().optional(),
|
username: z.string().optional(),
|
||||||
password: z.string().optional(),
|
password: z.string().optional(),
|
||||||
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
|
brandTemplate: z.enum(CAMERA_BRAND_VALUES).optional(),
|
||||||
onvifPort: z.coerce
|
onvifPort: z.coerce.number().int().min(1).max(65535).optional(),
|
||||||
.number<number>({
|
|
||||||
message: t("cameraWizard.step1.errors.onvifPortInvalid"),
|
|
||||||
})
|
|
||||||
.int(t("cameraWizard.step1.errors.onvifPortInvalid"))
|
|
||||||
.min(1, t("cameraWizard.step1.errors.onvifPortInvalid"))
|
|
||||||
.max(65535, t("cameraWizard.step1.errors.onvifPortInvalid"))
|
|
||||||
.optional(),
|
|
||||||
useDigestAuth: z.boolean().optional(),
|
useDigestAuth: z.boolean().optional(),
|
||||||
customUrl: z
|
customUrl: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
Reference in New Issue
Block a user