mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 01:48:57 +03:00
* add a notice registry and System Health tab Problems Frigate detects on its own (ffmpeg crash loops, stuck detectors, failed model downloads, recordings deleted before their retention period) only ever existed as log lines. This adds a `NoticeRegistry` in the main process backed by two tables, an `update_notice` IPC topic so producers in other processes can reach it through the dispatcher, an admin-only API and websocket topic, and a Health tab that lists them. Kinds declare their own mode, severity, and category in one place: state notices are resolved by their producer, event notices are dismissed by the user. * treat a prerelease as behind its final release * fix notices clearing early * rename menu items and update docs * don't resolve the update notice on a failed version lookup
330 lines
10 KiB
TypeScript
330 lines
10 KiB
TypeScript
/**
|
|
* REST API mock using Playwright's page.route().
|
|
*
|
|
* Intercepts all /api/* requests and returns factory-generated responses.
|
|
* Must be installed BEFORE page.goto() to prevent auth redirects.
|
|
*/
|
|
|
|
import type { Page } from "@playwright/test";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve, dirname } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
BASE_CONFIG,
|
|
type DeepPartial,
|
|
configFactory,
|
|
} from "../fixtures/mock-data/config";
|
|
import { DETECTION_HARDWARE } from "../fixtures/mock-data/hardware";
|
|
import { adminProfile, type UserProfile } from "../fixtures/mock-data/profile";
|
|
import { BASE_STATS, statsFactory } from "../fixtures/mock-data/stats";
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const MOCK_DATA_DIR = resolve(__dirname, "../fixtures/mock-data");
|
|
|
|
function loadMockJson(filename: string): unknown {
|
|
return JSON.parse(readFileSync(resolve(MOCK_DATA_DIR, filename), "utf-8"));
|
|
}
|
|
|
|
// 1x1 transparent PNG
|
|
export const PLACEHOLDER_PNG = Buffer.from(
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
|
|
"base64",
|
|
);
|
|
|
|
export interface ApiMockOverrides {
|
|
config?: DeepPartial<typeof BASE_CONFIG>;
|
|
profile?: UserProfile;
|
|
stats?: DeepPartial<typeof BASE_STATS>;
|
|
reviews?: unknown[];
|
|
events?: unknown[];
|
|
exports?: unknown[];
|
|
cases?: unknown[];
|
|
faces?: Record<string, unknown>;
|
|
configRaw?: string;
|
|
configSchema?: Record<string, unknown>;
|
|
hardware?: unknown[];
|
|
hwaccel?: {
|
|
recommended: string;
|
|
available?: { key: string; presets: Record<string, string> }[];
|
|
};
|
|
users?: { username: string; role: string }[];
|
|
notices?: unknown[];
|
|
noticeStats?: unknown[];
|
|
}
|
|
|
|
export class ApiMocker {
|
|
private page: Page;
|
|
|
|
constructor(page: Page) {
|
|
this.page = page;
|
|
}
|
|
|
|
async install(overrides?: ApiMockOverrides) {
|
|
const config = configFactory(overrides?.config);
|
|
const profile = overrides?.profile ?? adminProfile();
|
|
const stats = statsFactory(overrides?.stats);
|
|
const reviews =
|
|
overrides?.reviews ?? (loadMockJson("reviews.json") as unknown[]);
|
|
const events =
|
|
overrides?.events ?? (loadMockJson("events.json") as unknown[]);
|
|
const exports =
|
|
overrides?.exports ?? (loadMockJson("exports.json") as unknown[]);
|
|
const cases = overrides?.cases ?? (loadMockJson("cases.json") as unknown[]);
|
|
const reviewSummary = loadMockJson("review-summary.json");
|
|
|
|
// Config endpoint
|
|
await this.page.route("**/api/config", (route) => {
|
|
if (route.request().method() === "GET") {
|
|
return route.fulfill({ json: config });
|
|
}
|
|
return route.fulfill({ json: { success: true } });
|
|
});
|
|
|
|
// Profile endpoint (AuthProvider fetches /profile directly via axios,
|
|
// which resolves to /api/profile due to axios.defaults.baseURL)
|
|
await this.page.route("**/profile", (route) =>
|
|
route.fulfill({ json: profile }),
|
|
);
|
|
|
|
// Stats endpoint
|
|
await this.page.route("**/api/stats", (route) =>
|
|
route.fulfill({ json: stats }),
|
|
);
|
|
|
|
// Reviews. The real backend exposes /review (singular) for the main
|
|
// list and /review/summary for the summary — the previous plural glob
|
|
// (**/api/reviews**) never matched either endpoint, so review-dependent
|
|
// tests silently ran without data. The POST mutations at /reviews/viewed
|
|
// and /reviews/delete (plural) still fall through to the generic
|
|
// mutation catch-all further down the file.
|
|
await this.page.route(/\/api\/review\/summary/, (route) =>
|
|
route.fulfill({ json: reviewSummary }),
|
|
);
|
|
await this.page.route(/\/api\/review(\?|$)/, (route) =>
|
|
route.fulfill({ json: reviews }),
|
|
);
|
|
|
|
// Export jobs. The Exports page polls this every 2s while any export
|
|
// is in_progress; without a mock route it falls through to the preview
|
|
// server which returns 500 and makes the page flap between loading and
|
|
// rendered state, breaking tests that navigate to /export.
|
|
await this.page.route("**/api/jobs/export", (route) =>
|
|
route.fulfill({ json: [] }),
|
|
);
|
|
|
|
// Recordings summary
|
|
await this.page.route("**/api/recordings/summary**", (route) =>
|
|
route.fulfill({ json: {} }),
|
|
);
|
|
|
|
// Previews (needed for review page event cards)
|
|
await this.page.route("**/api/preview/**", (route) =>
|
|
route.fulfill({ json: [] }),
|
|
);
|
|
|
|
// Sub-labels and attributes (for explore filters).
|
|
// Use trailing ** so query-string variants (e.g. ?split_joined=1) match.
|
|
await this.page.route("**/api/sub_labels**", (route) =>
|
|
route.fulfill({ json: [] }),
|
|
);
|
|
await this.page.route("**/api/labels**", (route) =>
|
|
route.fulfill({ json: ["person", "car"] }),
|
|
);
|
|
await this.page.route("**/api/*/attributes", (route) =>
|
|
route.fulfill({ json: [] }),
|
|
);
|
|
await this.page.route("**/api/recognized_license_plates", (route) =>
|
|
route.fulfill({ json: [] }),
|
|
);
|
|
|
|
// Events / search
|
|
await this.page.route("**/api/events**", (route) =>
|
|
route.fulfill({ json: events }),
|
|
);
|
|
|
|
// Exports
|
|
await this.page.route("**/api/export**", (route) =>
|
|
route.fulfill({ json: exports }),
|
|
);
|
|
|
|
// Cases
|
|
await this.page.route("**/api/cases", (route) =>
|
|
route.fulfill({ json: cases }),
|
|
);
|
|
|
|
// Faces
|
|
await this.page.route("**/api/faces", (route) =>
|
|
route.fulfill({ json: overrides?.faces ?? {} }),
|
|
);
|
|
|
|
// Logs
|
|
await this.page.route("**/api/logs/**", (route) =>
|
|
route.fulfill({
|
|
contentType: "text/plain",
|
|
body: "[2026-04-06 10:00:00] INFO: Frigate started\n[2026-04-06 10:00:01] INFO: Cameras loaded\n",
|
|
}),
|
|
);
|
|
|
|
// Config raw
|
|
await this.page.route("**/api/config/raw", (route) =>
|
|
route.fulfill({
|
|
contentType: "text/plain",
|
|
body:
|
|
overrides?.configRaw ??
|
|
"mqtt:\n host: mqtt\ncameras:\n front_door:\n enabled: true\n",
|
|
}),
|
|
);
|
|
|
|
// Config schema
|
|
await this.page.route("**/api/config/schema.json", (route) =>
|
|
route.fulfill({
|
|
json: overrides?.configSchema ?? { type: "object", properties: {} },
|
|
}),
|
|
);
|
|
|
|
// Config set (mutation)
|
|
await this.page.route("**/api/config/set", (route) =>
|
|
route.fulfill({ json: { success: true, require_restart: false } }),
|
|
);
|
|
|
|
// Detection hardware discovery
|
|
await this.page.route("**/api/hardware/probe**", (route) =>
|
|
route.fulfill({ json: overrides?.hardware ?? DETECTION_HARDWARE }),
|
|
);
|
|
|
|
// Hwaccel preset recommendation
|
|
await this.page.route("**/api/hardware/hwaccel**", (route) =>
|
|
route.fulfill({
|
|
json: {
|
|
recommended: "",
|
|
available: [],
|
|
...(overrides?.hwaccel ?? {}),
|
|
},
|
|
}),
|
|
);
|
|
|
|
// Notices. The stats route is registered after the list route so it wins
|
|
// for /api/notices/stats; the list glob does not match a sub-path anyway.
|
|
await this.page.route("**/api/notices", (route) =>
|
|
route.fulfill({ json: overrides?.notices ?? [] }),
|
|
);
|
|
await this.page.route("**/api/notices/stats", (route) =>
|
|
route.fulfill({ json: overrides?.noticeStats ?? [] }),
|
|
);
|
|
|
|
// Users. GET lists them; POST/PUT (create, password) just succeed, so
|
|
// tests assert on the intercepted request body instead of a response.
|
|
await this.page.route("**/api/users**", (route) =>
|
|
route.request().method() === "GET"
|
|
? route.fulfill({
|
|
json: overrides?.users ?? [{ username: "admin", role: "admin" }],
|
|
})
|
|
: route.fulfill({ json: { message: "ok" } }),
|
|
);
|
|
|
|
// Go2RTC streams
|
|
await this.page.route("**/api/go2rtc/streams**", (route) =>
|
|
route.fulfill({ json: {} }),
|
|
);
|
|
|
|
// Profiles
|
|
await this.page.route("**/api/profiles**", (route) =>
|
|
route.fulfill({
|
|
json: { profiles: [], active_profile: null, last_activated: {} },
|
|
}),
|
|
);
|
|
|
|
// Motion search
|
|
await this.page.route("**/api/motion_search**", (route) =>
|
|
route.fulfill({ json: { job_id: "test-job" } }),
|
|
);
|
|
|
|
// Region grid
|
|
await this.page.route("**/api/*/region_grid", (route) =>
|
|
route.fulfill({ json: {} }),
|
|
);
|
|
|
|
// Debug replay
|
|
await this.page.route("**/api/debug_replay/**", (route) =>
|
|
route.fulfill({ json: {} }),
|
|
);
|
|
|
|
// Generic mutation catch-all for remaining endpoints.
|
|
// Uses route.fallback() to defer to more specific routes registered above.
|
|
// Playwright matches routes in reverse registration order (last wins),
|
|
// so this catch-all must use fallback() to let specific routes take precedence.
|
|
await this.page.route("**/api/**", (route) => {
|
|
const method = route.request().method();
|
|
if (
|
|
method === "POST" ||
|
|
method === "PUT" ||
|
|
method === "PATCH" ||
|
|
method === "DELETE"
|
|
) {
|
|
return route.fulfill({ json: { success: true } });
|
|
}
|
|
// Fall through to more specific routes for GET requests
|
|
return route.fallback();
|
|
});
|
|
}
|
|
}
|
|
|
|
export class MediaMocker {
|
|
private page: Page;
|
|
|
|
constructor(page: Page) {
|
|
this.page = page;
|
|
}
|
|
|
|
async install() {
|
|
// Camera snapshots
|
|
await this.page.route("**/api/*/latest.jpg**", (route) =>
|
|
route.fulfill({
|
|
contentType: "image/png",
|
|
body: PLACEHOLDER_PNG,
|
|
}),
|
|
);
|
|
|
|
// Clips and thumbnails
|
|
await this.page.route("**/clips/**", (route) =>
|
|
route.fulfill({
|
|
contentType: "image/png",
|
|
body: PLACEHOLDER_PNG,
|
|
}),
|
|
);
|
|
|
|
// Event thumbnails. The explore grid and detail dialog request .webp,
|
|
// everything else requests .jpg.
|
|
await this.page.route("**/api/events/*/thumbnail.{jpg,webp}**", (route) =>
|
|
route.fulfill({
|
|
contentType: "image/png",
|
|
body: PLACEHOLDER_PNG,
|
|
}),
|
|
);
|
|
|
|
// Event snapshots
|
|
await this.page.route("**/api/events/*/snapshot.jpg**", (route) =>
|
|
route.fulfill({
|
|
contentType: "image/png",
|
|
body: PLACEHOLDER_PNG,
|
|
}),
|
|
);
|
|
|
|
// VOD / recordings
|
|
await this.page.route("**/vod/**", (route) =>
|
|
route.fulfill({
|
|
contentType: "application/vnd.apple.mpegurl",
|
|
body: "#EXTM3U\n#EXT-X-ENDLIST\n",
|
|
}),
|
|
);
|
|
|
|
// Live streams
|
|
await this.page.route("**/live/**", (route) =>
|
|
route.fulfill({
|
|
contentType: "application/vnd.apple.mpegurl",
|
|
body: "#EXTM3U\n#EXT-X-ENDLIST\n",
|
|
}),
|
|
);
|
|
}
|
|
}
|