Add frontend tests (#22783)

* basic e2e frontend test framework

* improve mock data generation and add test cases

* more cases

* add e2e tests to PR template

* don't generate mock data in PR CI

* satisfy codeql check

* fix flaky system page tab tests by guarding against crashes from incomplete mock stats

* reduce local test runs to 4 workers to match CI
This commit is contained in:
Josh Hawkins
2026-04-06 16:33:28 -06:00
committed by GitHub
parent ed3bebc967
commit c750372586
34 changed files with 2790 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
/* eslint-disable react-hooks/rules-of-hooks */
/**
* Extended Playwright test fixture with FrigateApp.
*
* Every test imports `test` and `expect` from this file instead of
* @playwright/test directly. The `frigateApp` fixture provides a
* fully mocked Frigate frontend ready for interaction.
*
* CRITICAL: All route/WS handlers are registered before page.goto()
* to prevent AuthProvider from redirecting to login.html.
*/
import { test as base, expect, type Page } from "@playwright/test";
import {
ApiMocker,
MediaMocker,
type ApiMockOverrides,
} from "../helpers/api-mocker";
import { WsMocker } from "../helpers/ws-mocker";
export class FrigateApp {
public api: ApiMocker;
public media: MediaMocker;
public ws: WsMocker;
public page: Page;
private isDesktop: boolean;
constructor(page: Page, projectName: string) {
this.page = page;
this.api = new ApiMocker(page);
this.media = new MediaMocker(page);
this.ws = new WsMocker();
this.isDesktop = projectName === "desktop";
}
get isMobile() {
return !this.isDesktop;
}
/** Install all mocks with default data. Call before goto(). */
async installDefaults(overrides?: ApiMockOverrides) {
// Mock i18n locale files to prevent 404s
await this.page.route("**/locales/**", async (route) => {
// Let the request through to the built files
return route.fallback();
});
await this.ws.install(this.page);
await this.media.install();
await this.api.install(overrides);
}
/** Navigate to a page. Always call installDefaults() first. */
async goto(path: string) {
await this.page.goto(path);
// Wait for the app to render past the loading indicator
await this.page.waitForSelector("#pageRoot", { timeout: 10_000 });
}
/** Navigate to a page that may show a loading indicator */
async gotoAndWait(path: string, selector: string) {
await this.page.goto(path);
await this.page.waitForSelector(selector, { timeout: 10_000 });
}
}
type FrigateFixtures = {
frigateApp: FrigateApp;
};
export const test = base.extend<FrigateFixtures>({
frigateApp: async ({ page }, use, testInfo) => {
const app = new FrigateApp(page, testInfo.project.name);
await app.installDefaults();
await use(app);
},
});
export { expect };
@@ -0,0 +1,77 @@
/**
* Camera activity WebSocket payload factory.
*
* The camera_activity topic payload is double-serialized:
* the WS message contains { topic: "camera_activity", payload: JSON.stringify(activityMap) }
*/
export interface CameraActivityState {
config: {
enabled: boolean;
detect: boolean;
record: boolean;
snapshots: boolean;
audio: boolean;
audio_transcription: boolean;
notifications: boolean;
notifications_suspended: number;
autotracking: boolean;
alerts: boolean;
detections: boolean;
object_descriptions: boolean;
review_descriptions: boolean;
};
motion: boolean;
objects: Array<{
label: string;
score: number;
box: [number, number, number, number];
area: number;
ratio: number;
region: [number, number, number, number];
current_zones: string[];
id: string;
}>;
audio_detections: Array<{
label: string;
score: number;
}>;
}
function defaultCameraActivity(): CameraActivityState {
return {
config: {
enabled: true,
detect: true,
record: true,
snapshots: true,
audio: false,
audio_transcription: false,
notifications: false,
notifications_suspended: 0,
autotracking: false,
alerts: true,
detections: true,
object_descriptions: false,
review_descriptions: false,
},
motion: false,
objects: [],
audio_detections: [],
};
}
export function cameraActivityPayload(
cameras: string[],
overrides?: Partial<Record<string, Partial<CameraActivityState>>>,
): string {
const activity: Record<string, CameraActivityState> = {};
for (const name of cameras) {
activity[name] = {
...defaultCameraActivity(),
...overrides?.[name],
} as CameraActivityState;
}
// Double-serialize: the WS payload is a JSON string
return JSON.stringify(activity);
}
+1
View File
@@ -0,0 +1 @@
[{"id": "case-001", "name": "Package Theft Investigation", "description": "Review of suspicious activity near the front porch", "created_at": 1775407931.3863528, "updated_at": 1775483531.3863528}]
File diff suppressed because one or more lines are too long
+76
View File
@@ -0,0 +1,76 @@
/**
* FrigateConfig factory for E2E tests.
*
* Uses a real config snapshot generated from the Python backend's FrigateConfig
* model. This guarantees all fields are present and match what the app expects.
* Tests override specific fields via DeepPartial.
*/
import { readFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const configSnapshot = JSON.parse(
readFileSync(resolve(__dirname, "config-snapshot.json"), "utf-8"),
);
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
function deepMerge<T extends Record<string, unknown>>(
base: T,
overrides?: DeepPartial<T>,
): T {
if (!overrides) return base;
const result = { ...base };
for (const key of Object.keys(overrides) as (keyof T)[]) {
const val = overrides[key];
if (
val !== undefined &&
typeof val === "object" &&
val !== null &&
!Array.isArray(val) &&
typeof base[key] === "object" &&
base[key] !== null &&
!Array.isArray(base[key])
) {
result[key] = deepMerge(
base[key] as Record<string, unknown>,
val as DeepPartial<Record<string, unknown>>,
) as T[keyof T];
} else if (val !== undefined) {
result[key] = val as T[keyof T];
}
}
return result;
}
// The base config is a real snapshot from the Python backend.
// Apply test-specific overrides: friendly names, camera groups, version.
export const BASE_CONFIG = {
...configSnapshot,
version: "0.15.0-test",
cameras: {
...configSnapshot.cameras,
front_door: {
...configSnapshot.cameras.front_door,
friendly_name: "Front Door",
},
backyard: {
...configSnapshot.cameras.backyard,
friendly_name: "Backyard",
},
garage: {
...configSnapshot.cameras.garage,
friendly_name: "Garage",
},
},
};
export function configFactory(
overrides?: DeepPartial<typeof BASE_CONFIG>,
): typeof BASE_CONFIG {
return deepMerge(BASE_CONFIG, overrides);
}
+1
View File
@@ -0,0 +1 @@
[{"id": "event-person-001", "label": "person", "sub_label": null, "camera": "front_door", "start_time": 1775487131.3863528, "end_time": 1775487161.3863528, "false_positive": false, "zones": ["front_yard"], "thumbnail": null, "has_clip": true, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "abc123", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.92, "score": 0.92, "region": [0.1, 0.1, 0.5, 0.8], "box": [0.2, 0.15, 0.45, 0.75], "area": 0.18, "ratio": 0.6, "type": "object", "description": "A person walking toward the front door", "average_estimated_speed": 1.2, "velocity_angle": 45.0, "path_data": [[[0.2, 0.5], 0.0], [[0.3, 0.5], 1.0]]}}, {"id": "event-car-001", "label": "car", "sub_label": null, "camera": "backyard", "start_time": 1775483531.3863528, "end_time": 1775483576.3863528, "false_positive": false, "zones": ["driveway"], "thumbnail": null, "has_clip": true, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "def456", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.87, "score": 0.87, "region": [0.3, 0.2, 0.9, 0.7], "box": [0.35, 0.25, 0.85, 0.65], "area": 0.2, "ratio": 1.25, "type": "object", "description": "A car parked in the driveway", "average_estimated_speed": 0.0, "velocity_angle": 0.0, "path_data": []}}, {"id": "event-person-002", "label": "person", "sub_label": null, "camera": "garage", "start_time": 1775479931.3863528, "end_time": 1775479951.3863528, "false_positive": false, "zones": [], "thumbnail": null, "has_clip": false, "has_snapshot": true, "retain_indefinitely": false, "plus_id": null, "model_hash": "ghi789", "detector_type": "cpu", "model_type": "ssd", "data": {"top_score": 0.78, "score": 0.78, "region": [0.0, 0.0, 0.6, 0.9], "box": [0.1, 0.05, 0.5, 0.85], "area": 0.32, "ratio": 0.5, "type": "object", "description": null, "average_estimated_speed": 0.5, "velocity_angle": 90.0, "path_data": [[[0.1, 0.4], 0.0]]}}]
+1
View File
@@ -0,0 +1 @@
[{"id": "export-001", "camera": "front_door", "name": "Front Door - Person Alert", "date": 1775490731.3863528, "video_path": "/exports/export-001.mp4", "thumb_path": "/exports/export-001-thumb.jpg", "in_progress": false, "export_case_id": null}, {"id": "export-002", "camera": "backyard", "name": "Backyard - Car Detection", "date": 1775483531.3863528, "video_path": "/exports/export-002.mp4", "thumb_path": "/exports/export-002-thumb.jpg", "in_progress": false, "export_case_id": "case-001"}, {"id": "export-003", "camera": "garage", "name": "Garage - In Progress", "date": 1775492531.3863528, "video_path": "/exports/export-003.mp4", "thumb_path": "/exports/export-003-thumb.jpg", "in_progress": true, "export_case_id": null}]
@@ -0,0 +1,426 @@
#!/usr/bin/env python3
"""Generate E2E mock data from backend Pydantic and Peewee models.
Run from the repo root:
PYTHONPATH=/workspace/frigate python3 web/e2e/fixtures/mock-data/generate-mock-data.py
Strategy:
- FrigateConfig: instantiate the Pydantic config model, then model_dump()
- API responses: instantiate Pydantic response models (ReviewSegmentResponse,
EventResponse, ExportModel, ExportCaseModel) to validate all required fields
- If the backend adds a required field, this script fails at instantiation time
- The Peewee model field list is checked to detect new columns that would
appear in .dicts() API responses but aren't in our mock data
"""
import json
import sys
import time
import warnings
from datetime import datetime, timedelta
from pathlib import Path
warnings.filterwarnings("ignore")
OUTPUT_DIR = Path(__file__).parent
NOW = time.time()
HOUR = 3600
CAMERAS = ["front_door", "backyard", "garage"]
def check_pydantic_fields(pydantic_class, mock_keys, model_name):
"""Verify mock data covers all fields declared in the Pydantic response model.
The Pydantic response model is what the frontend actually receives.
Peewee models may have extra legacy columns that are filtered out by
FastAPI's response_model validation.
"""
required_fields = set()
for name, field_info in pydantic_class.model_fields.items():
required_fields.add(name)
missing = required_fields - mock_keys
if missing:
print(
f" ERROR: {model_name} response model has fields not in mock data: {missing}",
file=sys.stderr,
)
print(
f" Add these fields to the mock data in this script.",
file=sys.stderr,
)
sys.exit(1)
extra = mock_keys - required_fields
if extra:
print(
f" NOTE: {model_name} mock data has extra fields (not in response model): {extra}",
)
def generate_config():
"""Generate FrigateConfig from the Python backend model."""
from frigate.config import FrigateConfig
config = FrigateConfig.model_validate_json(
json.dumps(
{
"mqtt": {"host": "mqtt"},
"cameras": {
cam: {
"ffmpeg": {
"inputs": [
{
"path": f"rtsp://10.0.0.{i+1}:554/video",
"roles": ["detect"],
}
]
},
"detect": {"height": 720, "width": 1280, "fps": 5},
}
for i, cam in enumerate(CAMERAS)
},
"camera_groups": {
"default": {
"cameras": CAMERAS,
"icon": "generic",
"order": 0,
},
"outdoor": {
"cameras": ["front_door", "backyard"],
"icon": "generic",
"order": 1,
},
},
}
)
)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
snapshot = config.model_dump()
# Runtime-computed fields not in the Pydantic dump
all_attrs = set()
for attrs in snapshot.get("model", {}).get("attributes_map", {}).values():
all_attrs.update(attrs)
snapshot["model"]["all_attributes"] = sorted(all_attrs)
snapshot["model"]["colormap"] = {}
return snapshot
def generate_reviews():
"""Generate ReviewSegmentResponse[] validated against Pydantic + Peewee."""
from frigate.api.defs.response.review_response import ReviewSegmentResponse
reviews = [
ReviewSegmentResponse(
id="review-alert-001",
camera="front_door",
severity="alert",
start_time=datetime.fromtimestamp(NOW - 2 * HOUR),
end_time=datetime.fromtimestamp(NOW - 2 * HOUR + 30),
has_been_reviewed=False,
thumb_path="/clips/front_door/review-alert-001-thumb.jpg",
data=json.dumps(
{
"audio": [],
"detections": ["person-abc123"],
"objects": ["person"],
"sub_labels": [],
"significant_motion_areas": [],
"zones": ["front_yard"],
}
),
),
ReviewSegmentResponse(
id="review-alert-002",
camera="backyard",
severity="alert",
start_time=datetime.fromtimestamp(NOW - 3 * HOUR),
end_time=datetime.fromtimestamp(NOW - 3 * HOUR + 45),
has_been_reviewed=True,
thumb_path="/clips/backyard/review-alert-002-thumb.jpg",
data=json.dumps(
{
"audio": [],
"detections": ["car-def456"],
"objects": ["car"],
"sub_labels": [],
"significant_motion_areas": [],
"zones": ["driveway"],
}
),
),
ReviewSegmentResponse(
id="review-detect-001",
camera="garage",
severity="detection",
start_time=datetime.fromtimestamp(NOW - 4 * HOUR),
end_time=datetime.fromtimestamp(NOW - 4 * HOUR + 20),
has_been_reviewed=False,
thumb_path="/clips/garage/review-detect-001-thumb.jpg",
data=json.dumps(
{
"audio": [],
"detections": ["person-ghi789"],
"objects": ["person"],
"sub_labels": [],
"significant_motion_areas": [],
"zones": [],
}
),
),
ReviewSegmentResponse(
id="review-detect-002",
camera="front_door",
severity="detection",
start_time=datetime.fromtimestamp(NOW - 5 * HOUR),
end_time=datetime.fromtimestamp(NOW - 5 * HOUR + 15),
has_been_reviewed=False,
thumb_path="/clips/front_door/review-detect-002-thumb.jpg",
data=json.dumps(
{
"audio": [],
"detections": ["car-jkl012"],
"objects": ["car"],
"sub_labels": [],
"significant_motion_areas": [],
"zones": ["front_yard"],
}
),
),
]
result = [r.model_dump(mode="json") for r in reviews]
# Verify mock data covers all Pydantic response model fields
check_pydantic_fields(
ReviewSegmentResponse, set(result[0].keys()), "ReviewSegment"
)
return result
def generate_events():
"""Generate EventResponse[] validated against Pydantic + Peewee."""
from frigate.api.defs.response.event_response import EventResponse
events = [
EventResponse(
id="event-person-001",
label="person",
sub_label=None,
camera="front_door",
start_time=NOW - 2 * HOUR,
end_time=NOW - 2 * HOUR + 30,
false_positive=False,
zones=["front_yard"],
thumbnail=None,
has_clip=True,
has_snapshot=True,
retain_indefinitely=False,
plus_id=None,
model_hash="abc123",
detector_type="cpu",
model_type="ssd",
data={
"top_score": 0.92,
"score": 0.92,
"region": [0.1, 0.1, 0.5, 0.8],
"box": [0.2, 0.15, 0.45, 0.75],
"area": 0.18,
"ratio": 0.6,
"type": "object",
"description": "A person walking toward the front door",
"average_estimated_speed": 1.2,
"velocity_angle": 45.0,
"path_data": [[[0.2, 0.5], 0.0], [[0.3, 0.5], 1.0]],
},
),
EventResponse(
id="event-car-001",
label="car",
sub_label=None,
camera="backyard",
start_time=NOW - 3 * HOUR,
end_time=NOW - 3 * HOUR + 45,
false_positive=False,
zones=["driveway"],
thumbnail=None,
has_clip=True,
has_snapshot=True,
retain_indefinitely=False,
plus_id=None,
model_hash="def456",
detector_type="cpu",
model_type="ssd",
data={
"top_score": 0.87,
"score": 0.87,
"region": [0.3, 0.2, 0.9, 0.7],
"box": [0.35, 0.25, 0.85, 0.65],
"area": 0.2,
"ratio": 1.25,
"type": "object",
"description": "A car parked in the driveway",
"average_estimated_speed": 0.0,
"velocity_angle": 0.0,
"path_data": [],
},
),
EventResponse(
id="event-person-002",
label="person",
sub_label=None,
camera="garage",
start_time=NOW - 4 * HOUR,
end_time=NOW - 4 * HOUR + 20,
false_positive=False,
zones=[],
thumbnail=None,
has_clip=False,
has_snapshot=True,
retain_indefinitely=False,
plus_id=None,
model_hash="ghi789",
detector_type="cpu",
model_type="ssd",
data={
"top_score": 0.78,
"score": 0.78,
"region": [0.0, 0.0, 0.6, 0.9],
"box": [0.1, 0.05, 0.5, 0.85],
"area": 0.32,
"ratio": 0.5,
"type": "object",
"description": None,
"average_estimated_speed": 0.5,
"velocity_angle": 90.0,
"path_data": [[[0.1, 0.4], 0.0]],
},
),
]
result = [e.model_dump(mode="json") for e in events]
check_pydantic_fields(EventResponse, set(result[0].keys()), "Event")
return result
def generate_exports():
"""Generate ExportModel[] validated against Pydantic + Peewee."""
from frigate.api.defs.response.export_response import ExportModel
exports = [
ExportModel(
id="export-001",
camera="front_door",
name="Front Door - Person Alert",
date=NOW - 1 * HOUR,
video_path="/exports/export-001.mp4",
thumb_path="/exports/export-001-thumb.jpg",
in_progress=False,
export_case_id=None,
),
ExportModel(
id="export-002",
camera="backyard",
name="Backyard - Car Detection",
date=NOW - 3 * HOUR,
video_path="/exports/export-002.mp4",
thumb_path="/exports/export-002-thumb.jpg",
in_progress=False,
export_case_id="case-001",
),
ExportModel(
id="export-003",
camera="garage",
name="Garage - In Progress",
date=NOW - 0.5 * HOUR,
video_path="/exports/export-003.mp4",
thumb_path="/exports/export-003-thumb.jpg",
in_progress=True,
export_case_id=None,
),
]
result = [e.model_dump(mode="json") for e in exports]
check_pydantic_fields(ExportModel, set(result[0].keys()), "Export")
return result
def generate_cases():
"""Generate ExportCaseModel[] validated against Pydantic + Peewee."""
from frigate.api.defs.response.export_case_response import ExportCaseModel
cases = [
ExportCaseModel(
id="case-001",
name="Package Theft Investigation",
description="Review of suspicious activity near the front porch",
created_at=NOW - 24 * HOUR,
updated_at=NOW - 3 * HOUR,
),
]
result = [c.model_dump(mode="json") for c in cases]
check_pydantic_fields(ExportCaseModel, set(result[0].keys()), "ExportCase")
return result
def generate_review_summary():
"""Generate ReviewSummary for the calendar filter."""
today = datetime.now().strftime("%Y-%m-%d")
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
return {
today: {
"day": today,
"reviewed_alert": 1,
"reviewed_detection": 0,
"total_alert": 2,
"total_detection": 2,
},
yesterday: {
"day": yesterday,
"reviewed_alert": 3,
"reviewed_detection": 2,
"total_alert": 3,
"total_detection": 4,
},
}
def write_json(filename, data):
path = OUTPUT_DIR / filename
path.write_text(json.dumps(data, default=str))
print(f" {path.name} ({path.stat().st_size} bytes)")
def main():
print("Generating E2E mock data from backend models...")
print(" Validating against Pydantic response models + Peewee DB columns")
print()
write_json("config-snapshot.json", generate_config())
write_json("reviews.json", generate_reviews())
write_json("events.json", generate_events())
write_json("exports.json", generate_exports())
write_json("cases.json", generate_cases())
write_json("review-summary.json", generate_review_summary())
print()
print("All mock data validated against backend schemas.")
print("If this script fails, update the mock data to match the new schema.")
if __name__ == "__main__":
main()
+39
View File
@@ -0,0 +1,39 @@
/**
* User profile factories for E2E tests.
*/
export interface UserProfile {
username: string;
role: string;
allowed_cameras: string[] | null;
}
export function adminProfile(overrides?: Partial<UserProfile>): UserProfile {
return {
username: "admin",
role: "admin",
allowed_cameras: null,
...overrides,
};
}
export function viewerProfile(overrides?: Partial<UserProfile>): UserProfile {
return {
username: "viewer",
role: "viewer",
allowed_cameras: null,
...overrides,
};
}
export function restrictedProfile(
cameras: string[],
overrides?: Partial<UserProfile>,
): UserProfile {
return {
username: "restricted",
role: "viewer",
allowed_cameras: cameras,
...overrides,
};
}
@@ -0,0 +1 @@
{"2026-04-06": {"day": "2026-04-06", "reviewed_alert": 1, "reviewed_detection": 0, "total_alert": 2, "total_detection": 2}, "2026-04-05": {"day": "2026-04-05", "reviewed_alert": 3, "reviewed_detection": 2, "total_alert": 3, "total_detection": 4}}
+1
View File
@@ -0,0 +1 @@
[{"id": "review-alert-001", "camera": "front_door", "start_time": "2026-04-06T09:52:11.386353", "end_time": "2026-04-06T09:52:41.386353", "has_been_reviewed": false, "severity": "alert", "thumb_path": "/clips/front_door/review-alert-001-thumb.jpg", "data": {"audio": [], "detections": ["person-abc123"], "objects": ["person"], "sub_labels": [], "significant_motion_areas": [], "zones": ["front_yard"]}}, {"id": "review-alert-002", "camera": "backyard", "start_time": "2026-04-06T08:52:11.386353", "end_time": "2026-04-06T08:52:56.386353", "has_been_reviewed": true, "severity": "alert", "thumb_path": "/clips/backyard/review-alert-002-thumb.jpg", "data": {"audio": [], "detections": ["car-def456"], "objects": ["car"], "sub_labels": [], "significant_motion_areas": [], "zones": ["driveway"]}}, {"id": "review-detect-001", "camera": "garage", "start_time": "2026-04-06T07:52:11.386353", "end_time": "2026-04-06T07:52:31.386353", "has_been_reviewed": false, "severity": "detection", "thumb_path": "/clips/garage/review-detect-001-thumb.jpg", "data": {"audio": [], "detections": ["person-ghi789"], "objects": ["person"], "sub_labels": [], "significant_motion_areas": [], "zones": []}}, {"id": "review-detect-002", "camera": "front_door", "start_time": "2026-04-06T06:52:11.386353", "end_time": "2026-04-06T06:52:26.386353", "has_been_reviewed": false, "severity": "detection", "thumb_path": "/clips/front_door/review-detect-002-thumb.jpg", "data": {"audio": [], "detections": ["car-jkl012"], "objects": ["car"], "sub_labels": [], "significant_motion_areas": [], "zones": ["front_yard"]}}]
+76
View File
@@ -0,0 +1,76 @@
/**
* FrigateStats factory for E2E tests.
*/
import type { DeepPartial } from "./config";
function cameraStats(_name: string) {
return {
audio_dBFPS: 0,
audio_rms: 0,
camera_fps: 5.0,
capture_pid: 100,
detection_enabled: 1,
detection_fps: 5.0,
ffmpeg_pid: 101,
pid: 102,
process_fps: 5.0,
skipped_fps: 0,
connection_quality: "excellent" as const,
expected_fps: 5,
reconnects_last_hour: 0,
stalls_last_hour: 0,
};
}
export const BASE_STATS = {
cameras: {
front_door: cameraStats("front_door"),
backyard: cameraStats("backyard"),
garage: cameraStats("garage"),
},
cpu_usages: {
"1": { cmdline: "frigate.app", cpu: "5.0", cpu_average: "4.5", mem: "2.1" },
},
detectors: {
cpu: {
detection_start: 0,
inference_speed: 75.5,
pid: 200,
},
},
gpu_usages: {},
npu_usages: {},
processes: {},
service: {
last_updated: Date.now() / 1000,
storage: {
"/media/frigate/recordings": {
free: 50000000000,
total: 100000000000,
used: 50000000000,
mount_type: "ext4",
},
"/tmp/cache": {
free: 500000000,
total: 1000000000,
used: 500000000,
mount_type: "tmpfs",
},
},
uptime: 86400,
latest_version: "0.15.0",
version: "0.15.0-test",
},
camera_fps: 15.0,
process_fps: 15.0,
skipped_fps: 0,
detection_fps: 15.0,
};
export function statsFactory(
overrides?: DeepPartial<typeof BASE_STATS>,
): typeof BASE_STATS {
if (!overrides) return BASE_STATS;
return { ...BASE_STATS, ...overrides } as typeof BASE_STATS;
}