docs: Add docs Frigate UI mock view

This commit is contained in:
ZhaiSoul
2026-07-12 21:34:08 +08:00
parent da4037eb52
commit edca412856
16 changed files with 10616 additions and 248 deletions
+366
View File
@@ -0,0 +1,366 @@
#!/usr/bin/env node
/** Build the compact field catalog used by documentation config mocks. */
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, "../..");
const schemaPath = path.join(
repoRoot,
"web/e2e/fixtures/mock-data/config-schema.json",
);
const localeRoot = path.join(repoRoot, "web/public/locales/en/config");
const sectionConfigRoot = path.join(
repoRoot,
"web/src/components/config-form/section-configs",
);
const settingsSourcePath = path.join(repoRoot, "web/src/pages/Settings.tsx");
const settingsLocalePath = path.join(
repoRoot,
"web/public/locales/en/views/settings.json",
);
const outputPath = path.join(
repoRoot,
"docs/src/components/FrigateConfigMock/manifest.json",
);
const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
const translations = {
global: JSON.parse(
fs.readFileSync(path.join(localeRoot, "global.json"), "utf8"),
),
camera: JSON.parse(
fs.readFileSync(path.join(localeRoot, "cameras.json"), "utf8"),
),
groups: JSON.parse(
fs.readFileSync(path.join(localeRoot, "groups.json"), "utf8"),
),
};
const settingsTranslations = JSON.parse(
fs.readFileSync(settingsLocalePath, "utf8"),
);
function resolveNode(node) {
if (!node || typeof node !== "object") return {};
if (node.$ref) {
const refName = node.$ref.split("/").at(-1);
return {
...resolveNode(schema.$defs?.[refName]),
...node,
$ref: undefined,
};
}
const variants = node.anyOf ?? node.oneOf;
if (Array.isArray(variants)) {
const concrete = variants.find((variant) => variant.type !== "null");
return {
...resolveNode(concrete),
...node,
anyOf: undefined,
oneOf: undefined,
};
}
return node;
}
function translationAt(level, section, fieldPath) {
let current = translations[level]?.[section];
for (const segment of fieldPath) {
if (!current || typeof current !== "object") return {};
current = current[segment];
}
return current && typeof current === "object" ? current : {};
}
function inferWidget(node) {
if (Array.isArray(node.enum)) return "select";
if (node.type === "boolean") return "switch";
if (
["integer", "number"].includes(node.type) &&
node.minimum !== undefined &&
(node.maximum !== undefined || node.exclusiveMaximum !== undefined)
) {
return "range";
}
if (node.type === "integer" || node.type === "number") return "number";
if (node.type === "array") return "tags";
if (node.type === "object") return "object";
return "text";
}
function extractArray(source, key) {
const match = source.match(new RegExp(`${key}\\s*:\\s*\\[([\\s\\S]*?)\\]`));
return match
? [...match[1].matchAll(/["']([^"']+)["']/g)].map((item) => item[1])
: [];
}
function extractObjectBlock(source, key) {
const match = new RegExp(`\\b${key}\\s*:\\s*\\{`).exec(source);
if (!match) return "";
const start = source.indexOf("{", match.index);
let depth = 0;
let quote = null;
let escaped = false;
for (let index = start; index < source.length; index += 1) {
const character = source[index];
if (quote) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === quote) quote = null;
continue;
}
if (['"', "'", "`"].includes(character)) {
quote = character;
continue;
}
if (character === "{") depth += 1;
if (character === "}") {
depth -= 1;
if (depth === 0) return source.slice(start + 1, index);
}
}
return "";
}
function extractGroups(source) {
const fieldGroups = {};
const groupsBlock = extractObjectBlock(source, "fieldGroups");
for (const match of groupsBlock.matchAll(/(\w+)\s*:\s*\[([\s\S]*?)\]/g)) {
fieldGroups[match[1]] = [...match[2].matchAll(/["']([^"']+)["']/g)].map(
(item) => item[1],
);
}
return fieldGroups;
}
function loadSectionHints(section, level) {
const configPath = path.join(sectionConfigRoot, `${section}.ts`);
if (!fs.existsSync(configPath)) return {};
const source = fs.readFileSync(configPath, "utf8");
const base = extractObjectBlock(source, "base");
const override = extractObjectBlock(source, level);
const overrideHas = (key) => new RegExp(`\\b${key}\\s*:`).test(override);
return {
order: overrideHas("fieldOrder")
? extractArray(override, "fieldOrder")
: extractArray(base, "fieldOrder"),
hidden: [
...extractArray(base, "hiddenFields"),
...extractArray(override, "hiddenFields"),
],
advanced: overrideHas("advancedFields")
? extractArray(override, "advancedFields")
: extractArray(base, "advancedFields"),
groups: overrideHas("fieldGroups")
? extractGroups(override)
: extractGroups(base),
docs: base.match(/sectionDocs\s*:\s*["']([^"']+)["']/)?.[1] ?? null,
};
}
function groupLabel(level, section, group) {
const domain = level === "camera" ? "cameras" : "global";
return (
translations.groups?.[section]?.[domain]?.[group] ??
group.replaceAll("_", " ").replace(/^./, (value) => value.toUpperCase())
);
}
function collectFields(level, section, sectionNode, hints) {
const fields = {};
function visit(rawNode, fieldPath = []) {
const node = resolveNode(rawNode);
const properties = node.properties;
if (properties && typeof properties === "object") {
for (const [name, child] of Object.entries(properties)) {
visit(child, [...fieldPath, name]);
}
return;
}
if (fieldPath.length === 0) return;
const key = fieldPath.join(".");
const localized = translationAt(level, section, fieldPath);
fields[key] = {
label: localized.label ?? node.title ?? fieldPath.at(-1),
description: localized.description ?? node.description ?? "",
widget: inferWidget(node),
default: node.default ?? null,
enum: node.enum ?? null,
minimum: node.minimum ?? node.exclusiveMinimum ?? null,
maximum: node.maximum ?? node.exclusiveMaximum ?? null,
advanced: hints.advanced?.includes(key) ?? false,
};
}
visit(sectionNode);
return fields;
}
function buildLevel(level) {
const rootProperties =
level === "camera"
? resolveNode(schema.$defs.CameraConfig).properties
: schema.properties;
const result = {};
for (const [section, rawNode] of Object.entries(rootProperties ?? {})) {
const node = resolveNode(rawNode);
if (!node.properties) continue;
const hints = loadSectionHints(section, level);
const hidden = new Set(hints.hidden ?? []);
const fields = collectFields(level, section, node, hints);
for (const key of hidden) delete fields[key];
const localized = translations[level]?.[section] ?? {};
result[section] = {
label: localized.label ?? rawNode.title ?? node.title ?? section,
description: localized.description ?? rawNode.description ?? "",
order: hints.order ?? [],
groups: Object.entries(hints.groups ?? {}).map(([key, groupFields]) => ({
key,
label: groupLabel(level, section, key),
fields: groupFields,
})),
docs: hints.docs ?? null,
fields,
};
}
return result;
}
function parseSectionMapping(source, constantName, level) {
const match = source.match(
new RegExp(`const ${constantName}[^=]*=\\s*\\{([\\s\\S]*?)\\n\\};`),
);
if (!match) return [];
return [...match[1].matchAll(/(\w+)\s*:\s*"([^"]+)"/g)].map(
([, section, page]) => ({ section, page, level }),
);
}
function buildNavigation() {
const source = fs.readFileSync(settingsSourcePath, "utf8");
const settingsBlock = source.match(
/const settingsGroups\s*=\s*\[([\s\S]*?)\n\];/,
)?.[1];
if (!settingsBlock) return { groups: [], pages: {} };
const mappings = [
...parseSectionMapping(source, "GLOBAL_SECTION_MAPPING", "global"),
...parseSectionMapping(source, "CAMERA_SECTION_MAPPING", "camera"),
...parseSectionMapping(source, "ENRICHMENTS_SECTION_MAPPING", "global"),
...parseSectionMapping(source, "SYSTEM_SECTION_MAPPING", "global"),
];
const pages = Object.fromEntries(
mappings.map((mapping) => [mapping.page, mapping]),
);
const groupMatches = [...settingsBlock.matchAll(/\{\s*label:\s*"([^"]+)"/g)];
const groups = groupMatches.map((match, index) => {
const start = match.index ?? 0;
const end = groupMatches[index + 1]?.index ?? settingsBlock.length;
const sourceSlice = settingsBlock.slice(start, end);
const itemKeys = [...sourceSlice.matchAll(/key:\s*"([^"]+)"/g)].map(
(item) => item[1],
);
return {
key: match[1],
label: settingsTranslations.menu?.[match[1]] ?? match[1],
items: itemKeys.map((key) => ({
key,
label: settingsTranslations.menu?.[key] ?? key,
...(key === "masksAndZones"
? { section: key, page: key, level: "camera" }
: {}),
...(pages[key] ?? {}),
})),
};
});
return { groups, pages };
}
function buildDetectorTypes() {
const detectorTranslations = translations.global?.detectors ?? {};
const reserved = new Set([
"label",
"description",
"type",
"model",
"model_path",
]);
return Object.fromEntries(
Object.entries(detectorTranslations)
.filter(
([key, value]) =>
!reserved.has(key) &&
value &&
typeof value === "object" &&
typeof value.label === "string" &&
typeof value.description === "string",
)
.map(([type, value]) => [
type,
{
label: value.label,
description: value.description,
fields: Object.fromEntries(
Object.entries(value)
.filter(
([key, field]) =>
!["label", "description"].includes(key) &&
field &&
typeof field === "object" &&
typeof field.label === "string",
)
.map(([key, field]) => [
key,
{
label: field.label,
description: field.description ?? "",
},
]),
),
},
]),
);
}
const manifest = {
generatedFrom: path.relative(repoRoot, schemaPath).replaceAll("\\", "/"),
detectorTypes: buildDetectorTypes(),
levels: {
global: buildLevel("global"),
camera: buildLevel("camera"),
},
navigation: buildNavigation(),
};
const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
if (process.argv.includes("--check")) {
const current = fs.existsSync(outputPath)
? fs.readFileSync(outputPath, "utf8")
: "";
if (current !== serialized) {
console.error(
`${path.relative(repoRoot, outputPath)} is stale. Run npm run build:mock.`,
);
process.exit(1);
}
console.log(`Checked ${path.relative(repoRoot, outputPath)}`);
} else {
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, serialized);
console.log(`Generated ${path.relative(repoRoot, outputPath)}`);
}
+28 -8
View File
@@ -45,7 +45,7 @@ from lib.i18n_loader import load_i18n
from lib.nav_map import ALL_CONFIG_SECTIONS
from lib.schema_loader import load_schema
from lib.section_config_parser import load_section_configs
from lib.ui_generator import generate_ui_content, wrap_with_config_tabs
from lib.ui_generator import generate_mock_content, generate_ui_content, wrap_with_config_tabs
from lib.yaml_extractor import (
extract_config_tabs_blocks,
extract_yaml_blocks,
@@ -60,6 +60,7 @@ def process_file(
inject: bool = False,
verbose: bool = False,
outpath: Path | None = None,
mock: bool = False,
) -> dict:
"""Process a single markdown file for initial injection of bare YAML blocks.
@@ -114,7 +115,8 @@ def process_file(
continue
# Generate UI content
ui_content = generate_ui_content(
generator = generate_mock_content if mock else generate_ui_content
ui_content = generator(
block, schema, i18n, section_configs
)
@@ -188,6 +190,7 @@ def regenerate_file(
dry_run: bool = False,
verbose: bool = False,
outpath: Path | None = None,
mock: bool = False,
) -> dict:
"""Regenerate UI tabs in existing ConfigTabs blocks.
@@ -233,7 +236,8 @@ def regenerate_file(
continue
# Generate fresh UI content
new_ui = generate_ui_content(
generator = generate_mock_content if mock else generate_ui_content
new_ui = generator(
yaml_block, schema, i18n, section_configs
)
@@ -302,6 +306,7 @@ def check_file(
i18n: dict,
section_configs: dict,
verbose: bool = False,
mock: bool = False,
) -> dict:
"""Check for drift between existing UI tabs and what would be generated.
@@ -333,7 +338,8 @@ def check_file(
stats["skipped"] += 1
continue
new_ui = generate_ui_content(
generator = generate_mock_content if mock else generate_ui_content
new_ui = generator(
yaml_block, schema, i18n, section_configs
)
@@ -406,6 +412,10 @@ def _ensure_imports(content: str) -> str:
needed_imports.append(
'import NavPath from "@site/src/components/NavPath";'
)
if "<FrigateConfigMock" in content and 'import FrigateConfigMock' not in content:
needed_imports.append(
'import FrigateConfigMock from "@site/src/components/FrigateConfigMock";'
)
if not needed_imports:
return content
@@ -472,6 +482,11 @@ def main():
action="store_true",
help="Show detailed warnings and diagnostics",
)
parser.add_argument(
"--mock",
action="store_true",
help="Generate focused Frigate UI mocks instead of text instructions",
)
args = parser.parse_args()
# Collect files and determine base directory for relative path computation
@@ -525,23 +540,25 @@ def main():
print(f"Processing {len(files)} file(s)...\n", file=sys.stderr)
if args.check:
_run_check(files, schema, i18n, section_configs, args.verbose)
_run_check(files, schema, i18n, section_configs, args.verbose, args.mock)
elif args.regenerate:
_run_regenerate(
files, schema, i18n, section_configs,
args.dry_run, args.verbose, file_outpaths,
args.mock,
)
else:
_run_inject(
files, schema, i18n, section_configs,
args.inject, args.verbose, file_outpaths,
args.mock,
)
if outdir is not None:
print(f"\nOutput written to: {outdir}", file=sys.stderr)
def _run_inject(files, schema, i18n, section_configs, inject, verbose, file_outpaths):
def _run_inject(files, schema, i18n, section_configs, inject, verbose, file_outpaths, mock):
"""Run default mode: preview or inject bare YAML blocks."""
total_stats = {
"files": 0,
@@ -557,6 +574,7 @@ def _run_inject(files, schema, i18n, section_configs, inject, verbose, file_outp
filepath, schema, i18n, section_configs,
inject=inject, verbose=verbose,
outpath=file_outpaths.get(filepath),
mock=mock,
)
total_stats["files"] += 1
@@ -580,7 +598,7 @@ def _run_inject(files, schema, i18n, section_configs, inject, verbose, file_outp
print("=" * 60, file=sys.stderr)
def _run_regenerate(files, schema, i18n, section_configs, dry_run, verbose, file_outpaths):
def _run_regenerate(files, schema, i18n, section_configs, dry_run, verbose, file_outpaths, mock):
"""Run regenerate mode: update existing ConfigTabs blocks."""
total_stats = {
"files": 0,
@@ -595,6 +613,7 @@ def _run_regenerate(files, schema, i18n, section_configs, dry_run, verbose, file
filepath, schema, i18n, section_configs,
dry_run=dry_run, verbose=verbose,
outpath=file_outpaths.get(filepath),
mock=mock,
)
total_stats["files"] += 1
@@ -617,7 +636,7 @@ def _run_regenerate(files, schema, i18n, section_configs, dry_run, verbose, file
print("=" * 60, file=sys.stderr)
def _run_check(files, schema, i18n, section_configs, verbose):
def _run_check(files, schema, i18n, section_configs, verbose, mock):
"""Run check mode: detect drift without modifying files."""
total_stats = {
"files": 0,
@@ -630,6 +649,7 @@ def _run_check(files, schema, i18n, section_configs, verbose):
for filepath in files:
stats = check_file(
filepath, schema, i18n, section_configs, verbose=verbose,
mock=mock,
)
total_stats["files"] += 1
+76 -1
View File
@@ -1,5 +1,6 @@
"""Generate UI tab markdown content from parsed YAML blocks."""
"""Generate UI tab content from parsed YAML blocks."""
import json
from typing import Any
from .i18n_loader import get_field_description, get_field_label, get_value_label
@@ -9,6 +10,80 @@ from .section_config_parser import get_hidden_fields
from .yaml_extractor import YamlBlock, get_leaf_paths
def generate_mock_content(
block: YamlBlock,
schema: dict[str, Any],
i18n: dict[str, Any],
section_configs: dict[str, dict[str, Any]],
) -> str | None:
"""Generate a focused Frigate config mock for a YAML block."""
if block.section_key is None:
return None
if block.is_camera_level:
cameras = block.parsed.get("cameras", {})
camera_name = block.camera_name or next(iter(cameras), None)
if not camera_name or not isinstance(cameras.get(camera_name), dict):
return None
config = cameras[camera_name]
level = "camera"
else:
config = block.parsed
level = detect_level(block.section_key)
if level not in ("global", "camera"):
level = "global"
steps: list[dict[str, object]] = []
for section, section_data in config.items():
if section not in ALL_CONFIG_SECTIONS or not isinstance(
section_data, dict
):
continue
hidden = get_hidden_fields(section_configs, section, level)
values: dict[str, object] = {}
for path, value in get_leaf_paths(section_data):
path_parts = list(path)
if not _is_hidden(path_parts[-1], path_parts, hidden):
values[".".join(path_parts)] = value
if values:
steps.append(
{
"section": section,
"level": level,
"fields": list(values),
"values": values,
"focus": next(iter(values)),
}
)
if not steps:
return None
if len(steps) == 1:
step = steps[0]
return "\n".join(
[
"<FrigateConfigMock",
f' section="{step["section"]}"',
f' level="{step["level"]}"',
f" fields={{{json.dumps(step['fields'])}}}",
f" values={{{json.dumps(step['values'])}}}",
f' focus="{step["focus"]}"',
"/>",
]
)
return "\n".join(
[
"<FrigateConfigMock",
f" steps={{{json.dumps(steps)}}}",
"/>",
]
)
def _format_value(
value: object,
field_schema: dict[str, Any] | None,
+99
View File
@@ -0,0 +1,99 @@
"""Tests for focused Frigate configuration mock generation."""
import sys
import types
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
try:
import yaml # noqa: F401
except ModuleNotFoundError:
yaml_stub = types.ModuleType("yaml")
yaml_stub.YAMLError = ValueError
yaml_stub.safe_load = lambda _value: {}
sys.modules["yaml"] = yaml_stub
from lib.ui_generator import generate_mock_content
from lib.yaml_extractor import YamlBlock
def make_block(parsed: dict, section: str, camera: bool = False) -> YamlBlock:
"""Create a parsed YAML block for generator tests."""
return YamlBlock(
raw="",
parsed=parsed,
line_start=1,
line_end=1,
highlight=None,
has_comments=False,
inside_config_tabs=False,
section_key=section,
is_camera_level=camera,
camera_name="front_door" if camera else None,
config_keys=list(parsed),
)
class TestGenerateMockContent(unittest.TestCase):
def test_generates_focused_global_section(self):
content = generate_mock_content(
make_block({"motion": {"threshold": 30}}, "motion"),
{},
{},
{},
)
self.assertIn('section="motion"', content)
self.assertIn('level="global"', content)
self.assertIn('fields={["threshold"]}', content)
self.assertIn('values={{"threshold": 30}}', content)
self.assertIn('focus="threshold"', content)
def test_unwraps_camera_and_omits_hidden_fields(self):
content = generate_mock_content(
make_block(
{
"cameras": {
"front_door": {
"motion": {
"threshold": 20,
"raw_mask": "ignored",
}
}
}
},
"motion",
camera=True,
),
{},
{},
{"motion": {"hiddenFields": ["raw_mask"]}},
)
self.assertIn('level="camera"', content)
self.assertIn('fields={["threshold"]}', content)
self.assertNotIn("raw_mask", content)
def test_generates_steps_for_multiple_sections(self):
content = generate_mock_content(
make_block(
{
"record": {"enabled": True},
"snapshots": {"enabled": True},
},
"record",
),
{},
{},
{},
)
self.assertIn("steps={", content)
self.assertIn('"section": "record"', content)
self.assertIn('"section": "snapshots"', content)
if __name__ == "__main__":
unittest.main()