diff --git a/Makefile b/Makefile index b8e80ec2..aa100da8 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,7 @@ help: # Config HOMESEC_CONFIG ?= config/config.yaml HOMESEC_LOG_LEVEL ?= INFO +DB_DSN ?= postgresql+asyncpg://homesec:homesec@localhost:5432/homesec DOCKER_IMAGE ?= homesec DOCKER_TAG ?= latest DOCKERHUB_USER ?= $(shell echo $${DOCKERHUB_USER:-}) @@ -61,8 +62,8 @@ docker-push: docker-build # Local dev run: @echo "Running database migrations..." - @uv run alembic -c alembic.ini upgrade head - uv run python -m homesec.cli run --config $(HOMESEC_CONFIG) --log_level $(HOMESEC_LOG_LEVEL) + @DB_DSN=$(DB_DSN) uv run alembic -c alembic.ini upgrade head + DB_DSN=$(DB_DSN) uv run python -m homesec.cli run --config $(HOMESEC_CONFIG) --log_level $(HOMESEC_LOG_LEVEL) db: docker compose up -d postgres diff --git a/src/homesec/api/errors.py b/src/homesec/api/errors.py index 0be43cd6..e6b083c6 100644 --- a/src/homesec/api/errors.py +++ b/src/homesec/api/errors.py @@ -29,6 +29,7 @@ class APIErrorCode(StrEnum): CAMERA_NOT_FOUND = "CAMERA_NOT_FOUND" CAMERA_ALREADY_EXISTS = "CAMERA_ALREADY_EXISTS" CAMERA_CONFIG_INVALID = "CAMERA_CONFIG_INVALID" + STORAGE_CONFIG_INVALID = "STORAGE_CONFIG_INVALID" CLIP_NOT_FOUND = "CLIP_NOT_FOUND" CLIP_MEDIA_UNAVAILABLE = "CLIP_MEDIA_UNAVAILABLE" CLIP_MEDIA_FETCH_FAILED = "CLIP_MEDIA_FETCH_FAILED" diff --git a/src/homesec/api/routes/__init__.py b/src/homesec/api/routes/__init__.py index c51efa01..d2cc246e 100644 --- a/src/homesec/api/routes/__init__.py +++ b/src/homesec/api/routes/__init__.py @@ -10,7 +10,18 @@ verify_api_key, verify_media_access, ) -from homesec.api.routes import cameras, clips, config, health, media, onvif, runtime, setup, stats +from homesec.api.routes import ( + cameras, + clips, + config, + health, + media, + onvif, + runtime, + setup, + stats, + storage, +) def register_routes(app: FastAPI) -> None: @@ -21,6 +32,10 @@ def register_routes(app: FastAPI) -> None: config.router, dependencies=[Depends(verify_api_key), Depends(require_normal_mode)], ) + app.include_router( + storage.router, + dependencies=[Depends(verify_api_key)], + ) app.include_router( cameras.router, dependencies=[Depends(verify_api_key), Depends(require_normal_mode)], diff --git a/src/homesec/api/routes/cameras.py b/src/homesec/api/routes/cameras.py index d74fe87e..7a08760a 100644 --- a/src/homesec/api/routes/cameras.py +++ b/src/homesec/api/routes/cameras.py @@ -11,6 +11,7 @@ from homesec.api.dependencies import get_homesec_app from homesec.api.errors import APIError, APIErrorCode from homesec.api.redaction import redact_config +from homesec.api.runtime_reload import RuntimeReloadResponse, reload_runtime_if_requested from homesec.config.errors import ( CameraAlreadyExistsError, CameraConfigInvalidError, @@ -18,7 +19,6 @@ CameraNotFoundError, ) from homesec.models.config import CameraConfig -from homesec.runtime.errors import RuntimeReloadConfigError if TYPE_CHECKING: from homesec.app import Application @@ -48,12 +48,6 @@ class CameraResponse(BaseModel): source_config: dict[str, object] -class RuntimeReloadResponse(BaseModel): - accepted: bool - message: str - target_generation: int - - class ConfigChangeResponse(BaseModel): restart_required: bool = True camera: CameraResponse | None = None @@ -116,38 +110,6 @@ def _map_camera_config_error(exc: CameraMutationError) -> APIError: ) -async def _reload_runtime_if_requested( - *, - apply_changes: bool, - app: Application, -) -> RuntimeReloadResponse | None: - if not apply_changes: - return None - - try: - request = await app.request_runtime_reload() - except RuntimeReloadConfigError as exc: - raise APIError( - str(exc), - status_code=exc.status_code, - error_code=exc.error_code, - ) from exc - - if not request.accepted: - raise APIError( - request.message, - status_code=status.HTTP_409_CONFLICT, - error_code=APIErrorCode.RELOAD_IN_PROGRESS, - extra={"target_generation": request.target_generation}, - ) - - return RuntimeReloadResponse( - accepted=True, - message="Runtime reload accepted", - target_generation=request.target_generation, - ) - - @router.get("/api/v1/cameras", response_model=list[CameraResponse]) async def list_cameras(app: Application = Depends(get_homesec_app)) -> list[CameraResponse]: """List all cameras.""" @@ -188,7 +150,7 @@ async def create_camera( config = await asyncio.to_thread(app.config_manager.get_config) camera = next((cam for cam in config.cameras if cam.name == payload.name), None) - runtime_reload = await _reload_runtime_if_requested(apply_changes=apply_changes, app=app) + runtime_reload = await reload_runtime_if_requested(apply_changes=apply_changes, app=app) return ConfigChangeResponse( restart_required=False if runtime_reload is not None else result.restart_required, camera=_camera_response(app, camera) if camera else None, @@ -223,7 +185,7 @@ async def update_camera( error_code=APIErrorCode.CAMERA_NOT_FOUND, ) - runtime_reload = await _reload_runtime_if_requested(apply_changes=apply_changes, app=app) + runtime_reload = await reload_runtime_if_requested(apply_changes=apply_changes, app=app) return ConfigChangeResponse( restart_required=False if runtime_reload is not None else result.restart_required, camera=_camera_response(app, camera), @@ -243,7 +205,7 @@ async def delete_camera( except CameraMutationError as exc: raise _map_camera_config_error(exc) from exc - runtime_reload = await _reload_runtime_if_requested(apply_changes=apply_changes, app=app) + runtime_reload = await reload_runtime_if_requested(apply_changes=apply_changes, app=app) return ConfigChangeResponse( restart_required=False if runtime_reload is not None else result.restart_required, camera=None, diff --git a/src/homesec/api/routes/runtime.py b/src/homesec/api/routes/runtime.py index 6692da8c..b9db8a2c 100644 --- a/src/homesec/api/routes/runtime.py +++ b/src/homesec/api/routes/runtime.py @@ -5,12 +5,11 @@ from datetime import datetime from typing import TYPE_CHECKING -from fastapi import APIRouter, Depends, status +from fastapi import APIRouter, Depends from pydantic import BaseModel from homesec.api.dependencies import get_homesec_app -from homesec.api.errors import APIError, APIErrorCode -from homesec.runtime.errors import RuntimeReloadConfigError +from homesec.api.runtime_reload import RuntimeReloadResponse, request_runtime_reload from homesec.runtime.models import RuntimeState if TYPE_CHECKING: @@ -28,12 +27,6 @@ class RuntimeStatusResponse(BaseModel): last_reload_error: str | None -class RuntimeReloadResponse(BaseModel): - accepted: bool - message: str - target_generation: int - - @router.get("/api/v1/runtime/status", response_model=RuntimeStatusResponse) async def get_runtime_status(app: Application = Depends(get_homesec_app)) -> RuntimeStatusResponse: """Return runtime-manager status.""" @@ -53,25 +46,4 @@ async def reload_runtime( app: Application = Depends(get_homesec_app), ) -> RuntimeReloadResponse: """Trigger runtime reload and return async acceptance outcome.""" - try: - request = await app.request_runtime_reload() - except RuntimeReloadConfigError as exc: - raise APIError( - str(exc), - status_code=exc.status_code, - error_code=exc.error_code, - ) from exc - - if not request.accepted: - raise APIError( - request.message, - status_code=status.HTTP_409_CONFLICT, - error_code=APIErrorCode.RELOAD_IN_PROGRESS, - extra={"target_generation": request.target_generation}, - ) - - return RuntimeReloadResponse( - accepted=True, - message="Runtime reload accepted", - target_generation=request.target_generation, - ) + return await request_runtime_reload(app) diff --git a/src/homesec/api/routes/storage.py b/src/homesec/api/routes/storage.py new file mode 100644 index 00000000..12769a07 --- /dev/null +++ b/src/homesec/api/routes/storage.py @@ -0,0 +1,173 @@ +"""Storage configuration endpoints.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, cast + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from homesec.api.dependencies import get_homesec_app, require_normal_mode +from homesec.api.errors import APIError, APIErrorCode +from homesec.api.redaction import is_sensitive_key, redact_config +from homesec.api.runtime_reload import RuntimeReloadResponse, reload_runtime_if_requested +from homesec.config.errors import StorageMutationError +from homesec.models.config import StorageConfig +from homesec.plugins import discover_all_plugins +from homesec.plugins.registry import PluginType, get_plugin_config_model, get_plugin_names + +if TYPE_CHECKING: + from homesec.app import Application + +router = APIRouter(tags=["storage"]) + + +class StorageResponse(BaseModel): + backend: str + config: dict[str, object] + paths: dict[str, object] + + +class StorageUpdate(BaseModel): + backend: str | None = None + config: dict[str, object] | None = None + + +class StorageFieldMetadata(BaseModel): + name: str + type: str + required: bool + description: str | None = None + default: object | None = None + secret: bool = False + + +class StorageBackendMetadata(BaseModel): + backend: str + label: str + description: str + config_schema: dict[str, object] + fields: list[StorageFieldMetadata] + secret_fields: list[str] + + +class StorageChangeResponse(BaseModel): + restart_required: bool = True + storage: StorageResponse | None = None + runtime_reload: RuntimeReloadResponse | None = None + + +def _storage_response(storage: StorageConfig) -> StorageResponse: + redacted_storage = redact_config(storage.config) + if not isinstance(redacted_storage, dict): + redacted_storage = {} + paths_payload = storage.paths.model_dump(mode="json") + return StorageResponse( + backend=storage.backend, + config=cast(dict[str, object], redacted_storage), + paths=cast(dict[str, object], paths_payload), + ) + + +def _field_type(property_schema: dict[str, object]) -> str: + schema_type = property_schema.get("type") + if isinstance(schema_type, str): + return schema_type + if isinstance(schema_type, list): + rendered = [item for item in schema_type if isinstance(item, str)] + if rendered: + return "|".join(rendered) + return "object" + + +def _backend_description(backend: str, schema: dict[str, object]) -> str: + raw_description = schema.get("description") + if isinstance(raw_description, str) and raw_description.strip(): + return raw_description.strip() + return f"{backend.replace('_', ' ').title()} storage backend." + + +def _backend_metadata(backend: str) -> StorageBackendMetadata: + config_model = get_plugin_config_model(PluginType.STORAGE, backend) + schema = config_model.model_json_schema() + properties_raw = schema.get("properties", {}) + properties = properties_raw if isinstance(properties_raw, dict) else {} + required_raw = schema.get("required", []) + required = {item for item in required_raw if isinstance(item, str)} + + fields: list[StorageFieldMetadata] = [] + for field_name, field_schema in properties.items(): + if not isinstance(field_name, str): + continue + parsed_schema = field_schema if isinstance(field_schema, dict) else {} + fields.append( + StorageFieldMetadata( + name=field_name, + type=_field_type(parsed_schema), + required=field_name in required, + description=cast(str | None, parsed_schema.get("description")), + default=parsed_schema.get("default"), + secret=is_sensitive_key(field_name), + ) + ) + + fields.sort(key=lambda field: field.name) + return StorageBackendMetadata( + backend=backend, + label=backend.replace("_", " ").title(), + description=_backend_description(backend, cast(dict[str, object], schema)), + config_schema=cast(dict[str, object], schema), + fields=fields, + secret_fields=[field.name for field in fields if field.secret], + ) + + +@router.get( + "/api/v1/storage", + response_model=StorageResponse, + dependencies=[Depends(require_normal_mode)], +) +async def get_storage(app: Application = Depends(get_homesec_app)) -> StorageResponse: + """Get active storage backend config with redacted secret values.""" + config = await asyncio.to_thread(app.config_manager.get_config) + return _storage_response(config.storage) + + +@router.patch( + "/api/v1/storage", + response_model=StorageChangeResponse, + dependencies=[Depends(require_normal_mode)], +) +async def patch_storage( + payload: StorageUpdate, + apply_changes: bool = False, + app: Application = Depends(get_homesec_app), +) -> StorageChangeResponse: + """Partially update storage config and optionally trigger runtime reload.""" + try: + result = await app.config_manager.update_storage( + storage_backend=payload.backend, + storage_config=payload.config, + ) + except StorageMutationError as exc: + raise APIError( + str(exc), + status_code=400, + error_code=APIErrorCode.STORAGE_CONFIG_INVALID, + ) from exc + + config = await asyncio.to_thread(app.config_manager.get_config) + runtime_reload = await reload_runtime_if_requested(apply_changes=apply_changes, app=app) + return StorageChangeResponse( + restart_required=False if runtime_reload is not None else result.restart_required, + storage=_storage_response(config.storage), + runtime_reload=runtime_reload, + ) + + +@router.get("/api/v1/storage/backends", response_model=list[StorageBackendMetadata]) +async def list_storage_backends() -> list[StorageBackendMetadata]: + """List available storage backends and their config schema metadata.""" + discover_all_plugins() + return [_backend_metadata(backend) for backend in get_plugin_names(PluginType.STORAGE)] diff --git a/src/homesec/api/runtime_reload.py b/src/homesec/api/runtime_reload.py new file mode 100644 index 00000000..ddbc8c17 --- /dev/null +++ b/src/homesec/api/runtime_reload.py @@ -0,0 +1,60 @@ +"""Shared API helpers for runtime reload requests.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fastapi import status +from pydantic import BaseModel + +from homesec.api.errors import APIError, APIErrorCode +from homesec.runtime.errors import RuntimeReloadConfigError + +if TYPE_CHECKING: + from homesec.app import Application + + +class RuntimeReloadResponse(BaseModel): + """API response payload for accepted runtime reload requests.""" + + accepted: bool + message: str + target_generation: int + + +async def request_runtime_reload(app: Application) -> RuntimeReloadResponse: + """Request a runtime reload and map domain/runtime errors into API errors.""" + try: + request = await app.request_runtime_reload() + except RuntimeReloadConfigError as exc: + raise APIError( + str(exc), + status_code=exc.status_code, + error_code=exc.error_code, + ) from exc + + if not request.accepted: + raise APIError( + request.message, + status_code=status.HTTP_409_CONFLICT, + error_code=APIErrorCode.RELOAD_IN_PROGRESS, + extra={"target_generation": request.target_generation}, + ) + + return RuntimeReloadResponse( + accepted=True, + message="Runtime reload accepted", + target_generation=request.target_generation, + ) + + +async def reload_runtime_if_requested( + *, + apply_changes: bool, + app: Application, +) -> RuntimeReloadResponse | None: + """Request a runtime reload only when a settings route opts into apply-now behavior.""" + if not apply_changes: + return None + + return await request_runtime_reload(app) diff --git a/src/homesec/config/errors.py b/src/homesec/config/errors.py index 690ff3ee..3717e6b1 100644 --- a/src/homesec/config/errors.py +++ b/src/homesec/config/errors.py @@ -26,3 +26,20 @@ class CameraConfigInvalidError(CameraMutationError): class CameraConfigRedactedPlaceholderError(CameraConfigInvalidError): """Raised when a source_config mutation attempts to persist redacted placeholders.""" + + +class StorageMutationError(RuntimeError): + """Base error for storage configuration mutations.""" + + def __init__(self, message: str, *, cause: Exception | None = None) -> None: + super().__init__(message) + if cause is not None: + self.__cause__ = cause + + +class StorageConfigInvalidError(StorageMutationError): + """Raised when a storage config mutation fails validation.""" + + +class StorageConfigRedactedPlaceholderError(StorageConfigInvalidError): + """Raised when a storage config mutation attempts to persist redacted placeholders.""" diff --git a/src/homesec/config/manager.py b/src/homesec/config/manager.py index af52ce02..8861182c 100644 --- a/src/homesec/config/manager.py +++ b/src/homesec/config/manager.py @@ -16,9 +16,11 @@ CameraConfigInvalidError, CameraConfigRedactedPlaceholderError, CameraNotFoundError, + StorageConfigInvalidError, + StorageConfigRedactedPlaceholderError, ) from homesec.config.loader import ConfigError, load_config, load_config_from_dict -from homesec.models.config import CameraConfig, CameraSourceConfig, Config +from homesec.models.config import CameraConfig, CameraSourceConfig, Config, StorageConfig _SENSITIVE_CONFIG_FILE_MODE = 0o600 _REDACTED_PLACEHOLDER = "***redacted***" @@ -62,7 +64,7 @@ def get_config(self) -> Config: return load_config(self._config_path) @staticmethod - def _to_source_config_dict(config: dict[str, object] | BaseModel) -> dict[str, object]: + def _to_config_dict(config: dict[str, object] | BaseModel) -> dict[str, object]: if isinstance(config, BaseModel): payload = config.model_dump(mode="json") return cast(dict[str, object], payload) @@ -158,7 +160,7 @@ async def update_camera( "omit unchanged fields or provide replacement values" ) - current_source_config = self._to_source_config_dict(camera.source.config) + current_source_config = self._to_config_dict(camera.source.config) next_backend = ( source_backend if source_backend is not None else camera.source.backend ) @@ -188,6 +190,62 @@ async def update_camera( await self._save_config(validated) return ConfigUpdateResult() + async def update_storage( + self, + *, + storage_backend: str | None, + storage_config: dict[str, object] | None, + ) -> ConfigUpdateResult: + """Partially update storage backend/config in the persisted config.""" + async with self._mutation_lock(): + config = await asyncio.to_thread(self.get_config) + + should_update_storage = storage_backend is not None or storage_config is not None + if not should_update_storage: + return ConfigUpdateResult() + + if storage_config is not None and self._contains_redacted_placeholder(storage_config): + raise StorageConfigRedactedPlaceholderError( + "storage config patch contains redacted placeholders; " + "omit unchanged fields or provide replacement values" + ) + + current_storage_config = self._to_config_dict(config.storage.config) + next_backend = ( + storage_backend if storage_backend is not None else config.storage.backend + ) + base_storage_config = ( + {} + if storage_backend is not None and storage_backend != config.storage.backend + else current_storage_config + ) + next_storage_config = ( + self._merge_source_config(base_storage_config, storage_config) + if storage_config is not None + else base_storage_config + ) + + try: + config.storage = StorageConfig( + backend=next_backend, + config=next_storage_config, + paths=config.storage.paths, + ) + except (ValidationError, ValueError, TypeError) as exc: + raise StorageConfigInvalidError( + "Invalid storage configuration", + cause=exc, + ) from exc + + payload = config.model_dump(mode="json") + try: + validated = await asyncio.to_thread(load_config_from_dict, payload) + except ConfigError as exc: + raise StorageConfigInvalidError(str(exc), cause=exc) from exc + + await self._save_config(validated) + return ConfigUpdateResult() + async def remove_camera( self, camera_name: str, diff --git a/src/homesec/plugins/__init__.py b/src/homesec/plugins/__init__.py index eddb4fc6..d79d2e34 100644 --- a/src/homesec/plugins/__init__.py +++ b/src/homesec/plugins/__init__.py @@ -3,10 +3,13 @@ import importlib import logging import pkgutil +import threading from homesec.plugins.utils import iter_entry_points logger = logging.getLogger(__name__) +_DISCOVERY_COMPLETED = False +_DISCOVERY_LOCK = threading.Lock() def discover_all_plugins() -> None: @@ -18,19 +21,30 @@ def discover_all_plugins() -> None: All plugins use decorators for registration, so importing modules triggers registration automatically. """ - # 1. Discover built-in plugins by importing all modules - plugin_types = ["filters", "analyzers", "storage", "notifiers", "alert_policies", "sources"] - - for plugin_type in plugin_types: - package = importlib.import_module(f"homesec.plugins.{plugin_type}") - for _, module_name, _ in pkgutil.iter_modules(package.__path__): - if module_name.startswith("_"): - continue # Skip private modules - importlib.import_module(f"homesec.plugins.{plugin_type}.{module_name}") - - # 2. Discover external plugins via entry points - for point in iter_entry_points("homesec.plugins"): - importlib.import_module(point.module) + global _DISCOVERY_COMPLETED + + if _DISCOVERY_COMPLETED: + return + + with _DISCOVERY_LOCK: + if _DISCOVERY_COMPLETED: + return + + # 1. Discover built-in plugins by importing all modules + plugin_types = ["filters", "analyzers", "storage", "notifiers", "alert_policies", "sources"] + + for plugin_type in plugin_types: + package = importlib.import_module(f"homesec.plugins.{plugin_type}") + for _, module_name, _ in pkgutil.iter_modules(package.__path__): + if module_name.startswith("_"): + continue # Skip private modules + importlib.import_module(f"homesec.plugins.{plugin_type}.{module_name}") + + # 2. Discover external plugins via entry points + for point in iter_entry_points("homesec.plugins"): + importlib.import_module(point.module) + + _DISCOVERY_COMPLETED = True __all__ = ["discover_all_plugins"] diff --git a/src/homesec/plugins/registry.py b/src/homesec/plugins/registry.py index 100610e4..57bacac1 100644 --- a/src/homesec/plugins/registry.py +++ b/src/homesec/plugins/registry.py @@ -185,3 +185,14 @@ def validate_plugin( def get_plugin_names(plugin_type: PluginType) -> list[str]: """Get list of registered plugin names for a given type.""" return sorted(_REGISTRIES[plugin_type].get_all().keys()) + + +def get_plugin_config_model(plugin_type: PluginType, name: str) -> type[BaseModel]: + """Get the config model class registered for a plugin.""" + registry = _REGISTRIES[plugin_type] + plugins = registry.get_all() + if name not in plugins: + available = ", ".join(sorted(plugins.keys())) + raise ValueError(f"Unknown {plugin_type} plugin: '{name}'. Available: {available}") + plugin_cls = plugins[name] + return plugin_cls.config_cls diff --git a/tests/homesec/conftest.py b/tests/homesec/conftest.py index 6cb48166..1dd294b8 100644 --- a/tests/homesec/conftest.py +++ b/tests/homesec/conftest.py @@ -1,5 +1,8 @@ """Shared pytest fixtures for HomeSec tests.""" +import os +import shutil +import subprocess import sys from datetime import datetime from pathlib import Path @@ -76,12 +79,26 @@ def sample_clip() -> Clip: ) -@pytest.fixture +@pytest.fixture(scope="session") def postgres_dsn() -> str: - """Return test Postgres DSN (requires local DB running).""" - import os + """Return test Postgres DSN or skip integration tests when unavailable.""" + dsn = os.getenv("TEST_DB_DSN", "postgresql://homesec:homesec@localhost:5432/homesec") + psql = shutil.which("psql") + if psql is None: + pytest.skip("Postgres integration tests require psql and a reachable TEST_DB_DSN target") + + result = subprocess.run( + [psql, dsn, "-Atqc", "SELECT 1"], + capture_output=True, + check=False, + text=True, + ) + if result.returncode != 0 or result.stdout.strip() != "1": + pytest.skip( + f"Postgres integration tests require a reachable TEST_DB_DSN target (current: {dsn})" + ) - return os.getenv("TEST_DB_DSN", "postgresql://homesec:homesec@localhost:5432/homesec") + return dsn @pytest.fixture diff --git a/tests/homesec/test_api_bootstrap_matrix.py b/tests/homesec/test_api_bootstrap_matrix.py index feb3da82..25f57957 100644 --- a/tests/homesec/test_api_bootstrap_matrix.py +++ b/tests/homesec/test_api_bootstrap_matrix.py @@ -316,6 +316,18 @@ def _send_request(client: TestClient, case: _MatrixCase, headers: dict[str, str] bootstrap_mode=True, expected_error_code="SETUP_REQUIRED", ), + _MatrixCase( + name="storage_backends_remain_available_in_bootstrap_mode", + method="GET", + path="/api/v1/storage/backends", + auth_enabled=False, + db_ok=False, + pipeline_running=False, + auth_header=None, + include_clip=False, + expected_status=200, + bootstrap_mode=True, + ), _MatrixCase( name="cameras_requires_api_key_when_auth_enabled", method="GET", diff --git a/tests/homesec/test_api_routes.py b/tests/homesec/test_api_routes.py index ef37ca2b..ca82f9a1 100644 --- a/tests/homesec/test_api_routes.py +++ b/tests/homesec/test_api_routes.py @@ -1082,6 +1082,239 @@ def test_get_config_returns_empty_mapping_when_redaction_result_is_not_mapping( assert response.json() == {"config": {}} +def test_get_storage_returns_active_storage_config(tmp_path) -> None: + """GET /storage should return active storage backend config.""" + # Given: A config with storage configuration + manager = _write_config(tmp_path, cameras=[]) + app = _StubApp(config_manager=manager, repository=_StubRepository(), storage=_StubStorage()) + client = _client(app) + + # When: Requesting storage config + response = client.get("/api/v1/storage") + + # Then: Route returns active storage backend and config + assert response.status_code == 200 + payload = response.json() + assert payload["backend"] == "dropbox" + assert payload["config"]["root"] == "/homecam" + assert payload["paths"]["clips_dir"] == "clips" + + +def test_get_storage_redacts_sensitive_url_credentials(tmp_path) -> None: + """GET /storage should redact URL credentials in storage config payload.""" + # Given: A storage config with URL credentials embedded in web_url_prefix + payload = { + "version": 1, + "cameras": [ + { + "name": "front", + "enabled": True, + "source": {"backend": "local_folder", "config": {"watch_dir": "/tmp"}}, + } + ], + "storage": { + "backend": "dropbox", + "config": { + "root": "/homecam", + "web_url_prefix": "https://user:pass@example.com/home", + }, + }, + "state_store": {"dsn": "postgresql://user:pass@localhost/db"}, + "notifiers": [{"backend": "mqtt", "config": {"host": "localhost"}}], + "filter": {"backend": "yolo", "config": {}}, + "vlm": { + "backend": "openai", + "config": {"api_key_env": "OPENAI_API_KEY", "model": "gpt-4o"}, + }, + "alert_policy": {"backend": "default", "config": {}}, + } + path = tmp_path / "config.yaml" + path.write_text(yaml.safe_dump(payload, sort_keys=False)) + manager = ConfigManager(path) + app = _StubApp(config_manager=manager, repository=_StubRepository(), storage=_StubStorage()) + client = _client(app) + + # When: Requesting storage config + response = client.get("/api/v1/storage") + + # Then: Credentials are redacted from URL-valued config fields + assert response.status_code == 200 + storage_config = response.json()["config"] + assert storage_config["web_url_prefix"] == "https://***redacted***@example.com/home" + + +def test_patch_storage_updates_storage_config(tmp_path) -> None: + """PATCH /storage should persist storage config updates.""" + # Given: A config with existing storage settings + manager = _write_config(tmp_path, cameras=[]) + app = _StubApp(config_manager=manager, repository=_StubRepository(), storage=_StubStorage()) + client = _client(app) + + # When: Patching storage root path + response = client.patch( + "/api/v1/storage", + json={"config": {"root": "/new-homecam"}}, + ) + + # Then: Update succeeds and persisted config reflects the patch + assert response.status_code == 200 + payload = response.json() + assert payload["restart_required"] is True + assert payload["storage"]["backend"] == "dropbox" + assert payload["storage"]["config"]["root"] == "/new-homecam" + + +def test_patch_storage_apply_changes_triggers_runtime_reload(tmp_path) -> None: + """PATCH /storage should optionally trigger runtime reload when apply_changes=true.""" + # Given: A storage update and runtime reload request accepted by app + manager = _write_config(tmp_path, cameras=[]) + app = _StubApp( + config_manager=manager, + repository=_StubRepository(), + storage=_StubStorage(), + runtime_reload_request=RuntimeReloadRequest( + accepted=True, + message="Runtime reload started", + target_generation=11, + ), + ) + client = _client(app) + + # When: Patching storage with apply_changes enabled + response = client.patch( + "/api/v1/storage?apply_changes=true", + json={"config": {"root": "/apply-now"}}, + ) + + # Then: Route returns runtime reload payload and no restart-required banner + assert response.status_code == 200 + payload = response.json() + assert payload["restart_required"] is False + assert payload["runtime_reload"]["accepted"] is True + assert payload["runtime_reload"]["target_generation"] == 11 + assert app.runtime_reload_calls == 1 + + +def test_patch_storage_apply_changes_returns_409_when_reload_not_accepted(tmp_path) -> None: + """PATCH /storage should surface reload-in-progress conflicts from runtime control.""" + # Given: A storage update that succeeds but runtime reload is currently busy + manager = _write_config(tmp_path, cameras=[]) + app = _StubApp( + config_manager=manager, + repository=_StubRepository(), + storage=_StubStorage(), + runtime_reload_request=RuntimeReloadRequest( + accepted=False, + message="Reload already in progress", + target_generation=12, + ), + ) + client = _client(app) + + # When: Patching storage with apply_changes enabled + response = client.patch( + "/api/v1/storage?apply_changes=true", + json={"config": {"root": "/apply-later"}}, + ) + + # Then: Route returns canonical reload conflict details from the runtime layer + assert response.status_code == 409 + payload = response.json() + assert payload["detail"] == "Reload already in progress" + assert payload["error_code"] == "RELOAD_IN_PROGRESS" + assert payload["target_generation"] == 12 + assert app.runtime_reload_calls == 1 + + +def test_patch_storage_apply_changes_surfaces_runtime_reload_config_errors(tmp_path) -> None: + """PATCH /storage should map runtime reload config failures into API errors.""" + # Given: A storage update that reaches a runtime reload config error + manager = _write_config(tmp_path, cameras=[]) + app = _StubApp( + config_manager=manager, + repository=_StubRepository(), + storage=_StubStorage(), + runtime_reload_error=RuntimeReloadConfigError( + "Runtime reload configuration is invalid", + status_code=422, + error_code="STORAGE_CONFIG_INVALID", + ), + ) + client = _client(app) + + # When: Patching storage with apply_changes enabled + response = client.patch( + "/api/v1/storage?apply_changes=true", + json={"config": {"root": "/reload-invalid"}}, + ) + + # Then: Route preserves the status code and canonical error code from the runtime error + assert response.status_code == 422 + payload = response.json() + assert payload["detail"] == "Runtime reload configuration is invalid" + assert payload["error_code"] == "STORAGE_CONFIG_INVALID" + assert app.runtime_reload_calls == 1 + + +def test_patch_storage_invalid_backend_returns_400(tmp_path) -> None: + """PATCH /storage should return canonical 400 for invalid backend updates.""" + # Given: A config with valid initial storage settings + manager = _write_config(tmp_path, cameras=[]) + app = _StubApp(config_manager=manager, repository=_StubRepository(), storage=_StubStorage()) + client = _client(app) + + # When: Switching storage backend to an unknown backend + response = client.patch( + "/api/v1/storage", + json={"backend": "unknown_storage"}, + ) + + # Then: Route returns canonical invalid storage config error + assert response.status_code == 400 + payload = response.json() + assert payload["error_code"] == "STORAGE_CONFIG_INVALID" + + +def test_patch_storage_redacted_placeholder_returns_400(tmp_path) -> None: + """PATCH /storage should reject redacted placeholders in config patch payloads.""" + # Given: A config with valid initial storage settings + manager = _write_config(tmp_path, cameras=[]) + app = _StubApp(config_manager=manager, repository=_StubRepository(), storage=_StubStorage()) + client = _client(app) + + # When: Sending a redacted placeholder in storage config patch + response = client.patch( + "/api/v1/storage", + json={"config": {"root": "***redacted***"}}, + ) + + # Then: Route returns canonical invalid storage config error + assert response.status_code == 400 + payload = response.json() + assert payload["error_code"] == "STORAGE_CONFIG_INVALID" + + +def test_get_storage_backends_lists_plugin_metadata(tmp_path) -> None: + """GET /storage/backends should list plugin-discovered storage backend metadata.""" + # Given: A running app with plugin discovery completed during app creation + manager = _write_config(tmp_path, cameras=[]) + app = _StubApp(config_manager=manager, repository=_StubRepository(), storage=_StubStorage()) + client = _client(app) + + # When: Requesting storage backend metadata + response = client.get("/api/v1/storage/backends") + + # Then: Route returns metadata entries for built-in storage backends + assert response.status_code == 200 + payload = response.json() + backend_ids = {entry["backend"] for entry in payload} + assert "local" in backend_ids + assert "dropbox" in backend_ids + local = next(entry for entry in payload if entry["backend"] == "local") + field_names = {field["name"] for field in local["fields"]} + assert "root" in field_names + + def test_cors_disables_credentials_for_wildcard_origins(tmp_path) -> None: """CORS should disable credentials when wildcard origins are configured.""" # Given a server config with wildcard origin diff --git a/tests/homesec/test_config_manager.py b/tests/homesec/test_config_manager.py index b668cd43..b8d294e3 100644 --- a/tests/homesec/test_config_manager.py +++ b/tests/homesec/test_config_manager.py @@ -14,6 +14,8 @@ CameraConfigInvalidError, CameraConfigRedactedPlaceholderError, CameraNotFoundError, + StorageConfigInvalidError, + StorageConfigRedactedPlaceholderError, ) from homesec.config.loader import load_config_from_dict from homesec.config.manager import ConfigManager @@ -349,6 +351,121 @@ async def test_config_manager_update_camera_allows_null_clear_for_optional_sourc assert updated.source.config["rtsp_url"] == "rtsp://user:pass@camera.local/stream" +@pytest.mark.asyncio +async def test_config_manager_update_storage_merges_patch_and_preserves_existing_keys( + tmp_path: Path, +) -> None: + """Storage updates should merge config patches instead of replacing full config.""" + # Given: A config with a dropbox storage backend and multiple config keys + config_path = tmp_path / "config.yaml" + manager = _write_config(config_path, cameras=[]) + raw = yaml.safe_load(config_path.read_text()) + raw["storage"] = { + "backend": "dropbox", + "config": { + "root": "/homecam", + "token_env": "DROPBOX_TOKEN", + "web_url_prefix": "https://www.dropbox.com/home", + }, + } + config_path.write_text(yaml.safe_dump(raw, sort_keys=False)) + + # When: Applying a partial storage patch that changes only root + await manager.update_storage( + storage_backend=None, + storage_config={"root": "/new-homecam"}, + ) + + # Then: Existing unrelated storage keys remain and only patched value changes + config = manager.get_config() + assert config.storage.backend == "dropbox" + assert config.storage.config["root"] == "/new-homecam" + assert config.storage.config["token_env"] == "DROPBOX_TOKEN" + assert config.storage.config["web_url_prefix"] == "https://www.dropbox.com/home" + + +@pytest.mark.asyncio +async def test_config_manager_update_storage_rejects_redacted_placeholder_values( + tmp_path: Path, +) -> None: + """Storage updates should reject redacted placeholders in config patches.""" + # Given: A config with persisted storage values + config_path = tmp_path / "config.yaml" + manager = _write_config(config_path, cameras=[]) + raw = yaml.safe_load(config_path.read_text()) + raw["storage"] = { + "backend": "dropbox", + "config": { + "root": "/homecam", + "token_env": "DROPBOX_TOKEN", + }, + } + config_path.write_text(yaml.safe_dump(raw, sort_keys=False)) + + # When/Then: Applying a patch containing redacted placeholder marker is rejected + with pytest.raises(StorageConfigRedactedPlaceholderError): + await manager.update_storage( + storage_backend=None, + storage_config={"root": "***redacted***"}, + ) + + # Then: Persisted storage config remains unchanged + config = manager.get_config() + assert config.storage.backend == "dropbox" + assert config.storage.config["root"] == "/homecam" + + +@pytest.mark.asyncio +async def test_config_manager_update_storage_supports_backend_switch_with_new_config( + tmp_path: Path, +) -> None: + """Storage updates should allow backend switches with valid replacement config.""" + # Given: A config with dropbox storage + config_path = tmp_path / "config.yaml" + manager = _write_config(config_path, cameras=[]) + + # When: Switching storage backend to local with a local storage config patch + await manager.update_storage( + storage_backend="local", + storage_config={"root": "./storage"}, + ) + + # Then: Backend and config are updated to the new storage type + config = manager.get_config() + assert config.storage.backend == "local" + assert config.storage.config["root"] == "./storage" + assert "token_env" not in config.storage.config + + +@pytest.mark.asyncio +async def test_config_manager_update_storage_rejects_invalid_backend_config_on_switch( + tmp_path: Path, +) -> None: + """Storage updates should fail when backend-specific validation rejects the new config.""" + # Given: A config currently using local storage + config_path = tmp_path / "config.yaml" + _ = _write_config(config_path, cameras=[]) + raw = yaml.safe_load(config_path.read_text()) + raw["storage"] = { + "backend": "local", + "config": {"root": "./storage"}, + } + config_path.write_text(yaml.safe_dump(raw, sort_keys=False)) + manager = ConfigManager(config_path) + + # When: Switching to Dropbox without providing the required root field + with pytest.raises(StorageConfigInvalidError): + await manager.update_storage( + storage_backend="dropbox", + storage_config={"token_env": "DROPBOX_TOKEN"}, + ) + + # Then: Persisted storage remains unchanged + config = manager.get_config() + assert config.storage.backend == "local" + assert config.storage.config["root"] == "./storage" + + @pytest.mark.asyncio async def test_config_manager_enforces_restrictive_file_modes_on_save(tmp_path: Path) -> None: """Config writes should enforce 0600 mode for config and backup files.""" diff --git a/tests/homesec/test_plugin_utils.py b/tests/homesec/test_plugin_utils.py index 3ae1411b..cf28bd09 100644 --- a/tests/homesec/test_plugin_utils.py +++ b/tests/homesec/test_plugin_utils.py @@ -3,6 +3,7 @@ from __future__ import annotations from importlib import metadata +from types import SimpleNamespace from typing import Any from unittest.mock import MagicMock @@ -195,3 +196,36 @@ def factory() -> DummyPluginSubclass: # Then: The subclass instance is accepted assert isinstance(result, DummyPlugin) assert result.name == "factory_subclass" + + +def test_discover_all_plugins_only_runs_once(monkeypatch: pytest.MonkeyPatch) -> None: + """Repeated discovery calls should no-op after the first successful pass.""" + import homesec.plugins as plugins_module + + import_calls: list[str] = [] + + def fake_import_module(name: str) -> object: + import_calls.append(name) + if name.startswith("homesec.plugins.") and name.count(".") == 2: + return SimpleNamespace(__path__=[]) + return object() + + monkeypatch.setattr(plugins_module, "_DISCOVERY_COMPLETED", False) + monkeypatch.setattr("homesec.plugins.importlib.import_module", fake_import_module) + monkeypatch.setattr("homesec.plugins.pkgutil.iter_modules", lambda path: []) + monkeypatch.setattr("homesec.plugins.iter_entry_points", lambda group: []) + + # Given: Plugin discovery has not yet run in this process + # When: Discovery is requested twice + plugins_module.discover_all_plugins() + plugins_module.discover_all_plugins() + + # Then: The second call is a no-op and does not repeat package imports + assert import_calls == [ + "homesec.plugins.filters", + "homesec.plugins.analyzers", + "homesec.plugins.storage", + "homesec.plugins.notifiers", + "homesec.plugins.alert_policies", + "homesec.plugins.sources", + ] diff --git a/tests/homesec/test_state_store.py b/tests/homesec/test_state_store.py index a5ca7a8b..34433309 100644 --- a/tests/homesec/test_state_store.py +++ b/tests/homesec/test_state_store.py @@ -1,6 +1,5 @@ """Tests for PostgresStateStore.""" -import os from datetime import datetime, timedelta, timezone import pytest @@ -16,21 +15,11 @@ from homesec.state import PostgresStateStore from homesec.state.postgres import Base, ClipState, _normalize_async_dsn -# Default DSN for local Docker Postgres (matches docker-compose.postgres.yml) -DEFAULT_DSN = "postgresql://homesec:homesec@localhost:5432/homesec" - - -def get_test_dsn() -> str: - """Get test database DSN from environment or use default.""" - return os.environ.get("TEST_DB_DSN", DEFAULT_DSN) - @pytest.fixture -async def state_store() -> PostgresStateStore: +async def state_store(postgres_dsn: str) -> PostgresStateStore: """Create and initialize a PostgresStateStore for testing.""" - dsn = get_test_dsn() - assert dsn is not None - store = PostgresStateStore(dsn) + store = PostgresStateStore(postgres_dsn) initialized = await store.initialize() assert initialized, "Failed to initialize state store" if store._engine is not None: diff --git a/ui/scripts/api_codegen.mjs b/ui/scripts/api_codegen.mjs index 99a07456..d172bb73 100644 --- a/ui/scripts/api_codegen.mjs +++ b/ui/scripts/api_codegen.mjs @@ -60,6 +60,10 @@ function buildTypesFile({ cameraCreateSchemaName, cameraUpdateSchemaName, configChangeSchemaName, + storageSchemaName, + storageUpdateSchemaName, + storageChangeSchemaName, + storageBackendSchemaName, setupStatusSchemaName, finalizeRequestSchemaName, finalizeResponseSchemaName, @@ -83,11 +87,11 @@ function buildTypesFile({ mediaProfileSchemaName, deviceInfoSchemaName, }) { - return `${GENERATED_HEADER}\nimport type { components, paths } from './schema'\n\nexport type OpenAPIComponents = components\nexport type OpenAPIPaths = paths\nexport type CameraResponse = components["schemas"]["${cameraSchemaName}"]\nexport type CameraListResponse = CameraResponse[]\nexport type CameraCreate = components["schemas"]["${cameraCreateSchemaName}"]\nexport type CameraUpdate = components["schemas"]["${cameraUpdateSchemaName}"]\nexport type ConfigChangeResponse = components["schemas"]["${configChangeSchemaName}"]\nexport type SetupStatusResponse = components["schemas"]["${setupStatusSchemaName}"]\nexport type FinalizeRequest = components["schemas"]["${finalizeRequestSchemaName}"]\nexport type FinalizeResponse = components["schemas"]["${finalizeResponseSchemaName}"]\nexport type PreflightCheckResponse = components["schemas"]["${preflightCheckSchemaName}"]\nexport type PreflightResponse = components["schemas"]["${preflightResponseSchemaName}"]\nexport type TestConnectionRequest = components["schemas"]["${testConnectionRequestSchemaName}"]\nexport type TestConnectionResponse = components["schemas"]["${testConnectionResponseSchemaName}"]\nexport type HealthResponse = components["schemas"]["${healthSchemaName}"]\nexport type StatsResponse = components["schemas"]["${statsSchemaName}"]\nexport type DiagnosticsResponse = components["schemas"]["${diagnosticsSchemaName}"]\nexport type RuntimeReloadResponse = components["schemas"]["${runtimeReloadSchemaName}"]\nexport type RuntimeState = components["schemas"]["${runtimeStateSchemaName}"]\nexport type RuntimeStatusResponse = components["schemas"]["${runtimeStatusSchemaName}"]\nexport type ClipListResponse = components["schemas"]["${clipListSchemaName}"]\nexport type ClipResponse = components["schemas"]["${clipSchemaName}"]\nexport type ClipStatus = components["schemas"]["${clipStatusSchemaName}"]\nexport type DiscoverRequest = components["schemas"]["${discoverRequestSchemaName}"]\nexport type DiscoveredCameraResponse = components["schemas"]["${discoveredCameraSchemaName}"]\nexport type ProbeRequest = components["schemas"]["${probeRequestSchemaName}"]\nexport type ProbeResponse = components["schemas"]["${probeResponseSchemaName}"]\nexport type MediaProfileResponse = components["schemas"]["${mediaProfileSchemaName}"]\nexport type DeviceInfoResponse = components["schemas"]["${deviceInfoSchemaName}"]\nexport type ListClipsQuery = NonNullable\n` + return `${GENERATED_HEADER}\nimport type { components, paths } from './schema'\n\nexport type OpenAPIComponents = components\nexport type OpenAPIPaths = paths\nexport type CameraResponse = components["schemas"]["${cameraSchemaName}"]\nexport type CameraListResponse = CameraResponse[]\nexport type CameraCreate = components["schemas"]["${cameraCreateSchemaName}"]\nexport type CameraUpdate = components["schemas"]["${cameraUpdateSchemaName}"]\nexport type ConfigChangeResponse = components["schemas"]["${configChangeSchemaName}"]\nexport type StorageResponse = components["schemas"]["${storageSchemaName}"]\nexport type StorageUpdate = components["schemas"]["${storageUpdateSchemaName}"]\nexport type StorageChangeResponse = components["schemas"]["${storageChangeSchemaName}"]\nexport type StorageBackendMetadata = components["schemas"]["${storageBackendSchemaName}"]\nexport type StorageBackendsResponse = StorageBackendMetadata[]\nexport type SetupStatusResponse = components["schemas"]["${setupStatusSchemaName}"]\nexport type FinalizeRequest = components["schemas"]["${finalizeRequestSchemaName}"]\nexport type FinalizeResponse = components["schemas"]["${finalizeResponseSchemaName}"]\nexport type PreflightCheckResponse = components["schemas"]["${preflightCheckSchemaName}"]\nexport type PreflightResponse = components["schemas"]["${preflightResponseSchemaName}"]\nexport type TestConnectionRequest = components["schemas"]["${testConnectionRequestSchemaName}"]\nexport type TestConnectionResponse = components["schemas"]["${testConnectionResponseSchemaName}"]\nexport type HealthResponse = components["schemas"]["${healthSchemaName}"]\nexport type StatsResponse = components["schemas"]["${statsSchemaName}"]\nexport type DiagnosticsResponse = components["schemas"]["${diagnosticsSchemaName}"]\nexport type RuntimeReloadResponse = components["schemas"]["${runtimeReloadSchemaName}"]\nexport type RuntimeState = components["schemas"]["${runtimeStateSchemaName}"]\nexport type RuntimeStatusResponse = components["schemas"]["${runtimeStatusSchemaName}"]\nexport type ClipListResponse = components["schemas"]["${clipListSchemaName}"]\nexport type ClipResponse = components["schemas"]["${clipSchemaName}"]\nexport type ClipStatus = components["schemas"]["${clipStatusSchemaName}"]\nexport type DiscoverRequest = components["schemas"]["${discoverRequestSchemaName}"]\nexport type DiscoveredCameraResponse = components["schemas"]["${discoveredCameraSchemaName}"]\nexport type ProbeRequest = components["schemas"]["${probeRequestSchemaName}"]\nexport type ProbeResponse = components["schemas"]["${probeResponseSchemaName}"]\nexport type MediaProfileResponse = components["schemas"]["${mediaProfileSchemaName}"]\nexport type DeviceInfoResponse = components["schemas"]["${deviceInfoSchemaName}"]\nexport type ListClipsQuery = NonNullable\n` } function buildClientFile() { - return `${GENERATED_HEADER}\nimport type {\n CameraCreate,\n CameraListResponse,\n CameraResponse,\n CameraUpdate,\n ClipListResponse,\n ClipResponse,\n ConfigChangeResponse,\n DiagnosticsResponse,\n DiscoverRequest,\n DiscoveredCameraResponse,\n FinalizeRequest,\n FinalizeResponse,\n HealthResponse,\n ListClipsQuery,\n PreflightResponse,\n TestConnectionRequest,\n TestConnectionResponse,\n ProbeRequest,\n ProbeResponse,\n RuntimeReloadResponse,\n RuntimeStatusResponse,\n SetupStatusResponse,\n StatsResponse,\n} from './types'\n\nexport interface ApiRequestOptions {\n signal?: AbortSignal\n apiKey?: string | null\n}\n\nexport interface CameraMutationOptions extends ApiRequestOptions {\n applyChanges?: boolean\n}\n\nexport type ApiResponseWithStatus = TPayload & { httpStatus: number }\n\nexport interface GeneratedHomeSecClient {\n getCameras(options?: ApiRequestOptions): Promise\n getCamera(name: string, options?: ApiRequestOptions): Promise\n createCamera(\n payload: CameraCreate,\n options?: CameraMutationOptions,\n ): Promise>\n updateCamera(\n name: string,\n payload: CameraUpdate,\n options?: CameraMutationOptions,\n ): Promise>\n deleteCamera(\n name: string,\n options?: CameraMutationOptions,\n ): Promise>\n getSetupStatus(options?: ApiRequestOptions): Promise>\n finalizeSetup(\n payload: FinalizeRequest,\n options?: ApiRequestOptions,\n ): Promise>\n runSetupPreflight(options?: ApiRequestOptions): Promise>\n runSetupTestConnection(\n payload: TestConnectionRequest,\n options?: ApiRequestOptions,\n ): Promise>\n getHealth(options?: ApiRequestOptions): Promise>\n getStats(options?: ApiRequestOptions): Promise>\n getDiagnostics(options?: ApiRequestOptions): Promise>\n reloadRuntime(options?: ApiRequestOptions): Promise>\n getRuntimeStatus(\n options?: ApiRequestOptions,\n ): Promise>\n discoverOnvifCameras(\n payload?: DiscoverRequest,\n options?: ApiRequestOptions,\n ): Promise\n probeOnvifCamera(payload: ProbeRequest, options?: ApiRequestOptions): Promise\n getClips(\n query?: ListClipsQuery,\n options?: ApiRequestOptions,\n ): Promise>\n getClip(clipId: string, options?: ApiRequestOptions): Promise>\n}\n` + return `${GENERATED_HEADER}\nimport type {\n CameraCreate,\n CameraListResponse,\n CameraResponse,\n CameraUpdate,\n ClipListResponse,\n ClipResponse,\n ConfigChangeResponse,\n StorageBackendsResponse,\n StorageChangeResponse,\n StorageResponse,\n StorageUpdate,\n DiagnosticsResponse,\n DiscoverRequest,\n DiscoveredCameraResponse,\n FinalizeRequest,\n FinalizeResponse,\n HealthResponse,\n ListClipsQuery,\n PreflightResponse,\n TestConnectionRequest,\n TestConnectionResponse,\n ProbeRequest,\n ProbeResponse,\n RuntimeReloadResponse,\n RuntimeStatusResponse,\n SetupStatusResponse,\n StatsResponse,\n} from './types'\n\nexport interface ApiRequestOptions {\n signal?: AbortSignal\n apiKey?: string | null\n}\n\nexport interface CameraMutationOptions extends ApiRequestOptions {\n applyChanges?: boolean\n}\n\nexport interface StorageMutationOptions extends ApiRequestOptions {\n applyChanges?: boolean\n}\n\nexport type ApiResponseWithStatus = TPayload & { httpStatus: number }\n\nexport interface GeneratedHomeSecClient {\n getCameras(options?: ApiRequestOptions): Promise\n getCamera(name: string, options?: ApiRequestOptions): Promise\n createCamera(\n payload: CameraCreate,\n options?: CameraMutationOptions,\n ): Promise>\n updateCamera(\n name: string,\n payload: CameraUpdate,\n options?: CameraMutationOptions,\n ): Promise>\n deleteCamera(\n name: string,\n options?: CameraMutationOptions,\n ): Promise>\n getStorage(options?: ApiRequestOptions): Promise\n listStorageBackends(options?: ApiRequestOptions): Promise\n updateStorage(\n payload: StorageUpdate,\n options?: StorageMutationOptions,\n ): Promise>\n getSetupStatus(options?: ApiRequestOptions): Promise>\n finalizeSetup(\n payload: FinalizeRequest,\n options?: ApiRequestOptions,\n ): Promise>\n runSetupPreflight(options?: ApiRequestOptions): Promise>\n runSetupTestConnection(\n payload: TestConnectionRequest,\n options?: ApiRequestOptions,\n ): Promise>\n getHealth(options?: ApiRequestOptions): Promise>\n getStats(options?: ApiRequestOptions): Promise>\n getDiagnostics(options?: ApiRequestOptions): Promise>\n reloadRuntime(options?: ApiRequestOptions): Promise>\n getRuntimeStatus(\n options?: ApiRequestOptions,\n ): Promise>\n discoverOnvifCameras(\n payload?: DiscoverRequest,\n options?: ApiRequestOptions,\n ): Promise\n probeOnvifCamera(payload: ProbeRequest, options?: ApiRequestOptions): Promise\n getClips(\n query?: ListClipsQuery,\n options?: ApiRequestOptions,\n ): Promise>\n getClip(clipId: string, options?: ApiRequestOptions): Promise>\n}\n` } function resolveResponseSchemaName(openapiSchema, { pathName, method, statuses, fallbackSchemaName }) { @@ -253,6 +257,29 @@ function generateOpenApiArtifacts(tempGeneratedDir) { statuses: ['201', '200', 'default'], fallbackSchemaName: 'ConfigChangeResponse', }) + const storageSchemaName = resolveResponseSchemaName(schema, { + pathName: '/api/v1/storage', + method: 'get', + statuses: ['200', 'default'], + fallbackSchemaName: 'StorageResponse', + }) + const storageUpdateSchemaName = resolveRequestBodySchemaName(schema, { + pathName: '/api/v1/storage', + method: 'patch', + fallbackSchemaName: 'StorageUpdate', + }) + const storageChangeSchemaName = resolveResponseSchemaName(schema, { + pathName: '/api/v1/storage', + method: 'patch', + statuses: ['200', 'default'], + fallbackSchemaName: 'StorageChangeResponse', + }) + const storageBackendSchemaName = resolveArrayItemResponseSchemaName(schema, { + pathName: '/api/v1/storage/backends', + method: 'get', + statuses: ['200', 'default'], + fallbackSchemaName: 'StorageBackendMetadata', + }) const setupStatusSchemaName = resolveResponseSchemaName(schema, { pathName: '/api/v1/setup/status', method: 'get', @@ -365,6 +392,10 @@ function generateOpenApiArtifacts(tempGeneratedDir) { cameraCreateSchemaName, cameraUpdateSchemaName, configChangeSchemaName, + storageSchemaName, + storageUpdateSchemaName, + storageChangeSchemaName, + storageBackendSchemaName, setupStatusSchemaName, finalizeRequestSchemaName, finalizeResponseSchemaName, diff --git a/ui/src/api/client.test.ts b/ui/src/api/client.test.ts index 18e1644d..ff0e0936 100644 --- a/ui/src/api/client.test.ts +++ b/ui/src/api/client.test.ts @@ -913,6 +913,139 @@ describe('HomeSecApiClient camera mutation methods', () => { }) }) +describe('HomeSecApiClient storage methods', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('fetches storage config and parses typed payload', async () => { + // Given: A storage endpoint returns backend/config payload + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + backend: 'local', + config: { + root: './storage', + }, + paths: { + clips_dir: 'clips', + backups_dir: 'backups', + artifacts_dir: 'artifacts', + }, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ), + ) + const client = new HomeSecApiClient('http://localhost:8081') + + // When: Requesting active storage configuration + const result = await client.getStorage() + + // Then: Client should call storage route and parse backend/config payload + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy.mock.calls[0]?.[0]).toBe('http://localhost:8081/api/v1/storage') + expect(result.backend).toBe('local') + expect(result.config).toMatchObject({ root: './storage' }) + }) + + it('requests storage backend metadata and parses response list', async () => { + // Given: Storage backends endpoint returns backend schema metadata + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify([ + { + backend: 'local', + label: 'Local', + description: 'Store clips on local disk.', + config_schema: {}, + fields: [ + { + name: 'root', + type: 'string', + required: true, + description: 'Root path', + default: './storage', + secret: false, + }, + ], + secret_fields: [], + }, + ]), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ), + ) + const client = new HomeSecApiClient('http://localhost:8081') + + // When: Requesting metadata for storage backend selector/forms + const result = await client.listStorageBackends() + + // Then: Client should parse metadata array and preserve field hints + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy.mock.calls[0]?.[0]).toBe('http://localhost:8081/api/v1/storage/backends') + expect(result).toHaveLength(1) + expect(result[0]?.backend).toBe('local') + expect(result[0]?.fields[0]?.name).toBe('root') + }) + + it('patches storage settings and passes apply_changes query parameter', async () => { + // Given: Storage update endpoint returns restart+reload metadata + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + restart_required: false, + storage: { + backend: 'dropbox', + config: { + root: '/homesec', + token_env: 'DROPBOX_TOKEN', + }, + paths: { + clips_dir: 'clips', + backups_dir: 'backups', + artifacts_dir: 'artifacts', + }, + }, + runtime_reload: { + accepted: true, + message: 'Runtime reload accepted', + target_generation: 12, + }, + }), + { + status: 200, + headers: { 'content-type': 'application/json' }, + }, + ), + ) + const client = new HomeSecApiClient('http://localhost:8081') + + // When: Patching storage config with immediate apply enabled + const result = await client.updateStorage( + { + backend: 'dropbox', + config: { root: '/homesec', token_env: 'DROPBOX_TOKEN' }, + }, + { applyChanges: true }, + ) + + // Then: Request contains apply_changes and parsed response includes runtime reload metadata + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy.mock.calls[0]?.[0]).toBe('http://localhost:8081/api/v1/storage?apply_changes=true') + expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ + method: 'PATCH', + }) + expect(result.restart_required).toBe(false) + expect(result.storage?.backend).toBe('dropbox') + expect(result.runtime_reload?.target_generation).toBe(12) + }) +}) + describe('HomeSecApiClient runtime methods', () => { afterEach(() => { vi.restoreAllMocks() diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index b9fcd76e..788c810c 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -1,6 +1,7 @@ import type { ApiRequestOptions, CameraMutationOptions, + StorageMutationOptions, GeneratedHomeSecClient, } from './generated/client' import type { @@ -11,6 +12,10 @@ import type { ClipListResponse, ClipResponse, ConfigChangeResponse, + StorageBackendsResponse, + StorageChangeResponse, + StorageResponse, + StorageUpdate, DiscoverRequest, DiscoveredCameraResponse, DiagnosticsResponse, @@ -38,6 +43,9 @@ import { parseClipMediaTokenResponse, parseClipResponse, parseConfigChangeResponse, + parseStorageBackendsResponse, + parseStorageChangeResponse, + parseStorageResponse, parseOnvifDiscoverResponse, parseOnvifProbeResponse, parseDiagnosticsResponse, @@ -61,6 +69,7 @@ export type DiagnosticsSnapshot = ApiSnapshot export type ClipListSnapshot = ApiSnapshot export type ClipSnapshot = ApiSnapshot export type ConfigChangeSnapshot = ApiSnapshot +export type StorageChangeSnapshot = ApiSnapshot export type RuntimeReloadSnapshot = ApiSnapshot export type RuntimeStatusSnapshot = ApiSnapshot export type SetupStatusSnapshot = ApiSnapshot @@ -171,6 +180,60 @@ export class HomeSecApiClient implements GeneratedHomeSecClient { } } + async getStorage(options: ApiRequestOptions = {}): Promise { + const response = await this.httpClient.requestJson('/api/v1/storage', options) + + try { + return parseStorageResponse(response.payload) + } catch { + throw new APIError( + 'Invalid storage response payload', + response.status, + response.payload, + null, + ) + } + } + + async listStorageBackends(options: ApiRequestOptions = {}): Promise { + const response = await this.httpClient.requestJson('/api/v1/storage/backends', options) + + try { + return parseStorageBackendsResponse(response.payload) + } catch { + throw new APIError( + 'Invalid storage backends response payload', + response.status, + response.payload, + null, + ) + } + } + + async updateStorage( + payload: StorageUpdate, + options: StorageMutationOptions = {}, + ): Promise { + const { applyChanges = false, ...requestOptions } = options + const response = await this.httpClient.requestJson('/api/v1/storage', { + ...requestOptions, + query: applyChanges ? { apply_changes: true } : undefined, + method: 'PATCH', + body: payload, + }) + + try { + return withHttpStatus(parseStorageChangeResponse(response.payload), response.status) + } catch { + throw new APIError( + 'Invalid update-storage response payload', + response.status, + response.payload, + null, + ) + } + } + async getSetupStatus(options: ApiRequestOptions = {}): Promise { const response = await this.httpClient.requestJson('/api/v1/setup/status', options) diff --git a/ui/src/api/generated/client.ts b/ui/src/api/generated/client.ts index ac3a2019..70fb50ce 100644 --- a/ui/src/api/generated/client.ts +++ b/ui/src/api/generated/client.ts @@ -11,6 +11,10 @@ import type { ClipListResponse, ClipResponse, ConfigChangeResponse, + StorageBackendsResponse, + StorageChangeResponse, + StorageResponse, + StorageUpdate, DiagnosticsResponse, DiscoverRequest, DiscoveredCameraResponse, @@ -38,6 +42,10 @@ export interface CameraMutationOptions extends ApiRequestOptions { applyChanges?: boolean } +export interface StorageMutationOptions extends ApiRequestOptions { + applyChanges?: boolean +} + export type ApiResponseWithStatus = TPayload & { httpStatus: number } export interface GeneratedHomeSecClient { @@ -56,6 +64,12 @@ export interface GeneratedHomeSecClient { name: string, options?: CameraMutationOptions, ): Promise> + getStorage(options?: ApiRequestOptions): Promise + listStorageBackends(options?: ApiRequestOptions): Promise + updateStorage( + payload: StorageUpdate, + options?: StorageMutationOptions, + ): Promise> getSetupStatus(options?: ApiRequestOptions): Promise> finalizeSetup( payload: FinalizeRequest, diff --git a/ui/src/api/generated/openapi.json b/ui/src/api/generated/openapi.json index 2cab4afc..90e12bdd 100644 --- a/ui/src/api/generated/openapi.json +++ b/ui/src/api/generated/openapi.json @@ -1320,6 +1320,82 @@ "title": "StatsResponse", "type": "object" }, + "StorageBackendMetadata": { + "properties": { + "backend": { + "title": "Backend", + "type": "string" + }, + "config_schema": { + "additionalProperties": true, + "title": "Config Schema", + "type": "object" + }, + "description": { + "title": "Description", + "type": "string" + }, + "fields": { + "items": { + "$ref": "#/components/schemas/StorageFieldMetadata" + }, + "title": "Fields", + "type": "array" + }, + "label": { + "title": "Label", + "type": "string" + }, + "secret_fields": { + "items": { + "type": "string" + }, + "title": "Secret Fields", + "type": "array" + } + }, + "required": [ + "backend", + "label", + "description", + "config_schema", + "fields", + "secret_fields" + ], + "title": "StorageBackendMetadata", + "type": "object" + }, + "StorageChangeResponse": { + "properties": { + "restart_required": { + "default": true, + "title": "Restart Required", + "type": "boolean" + }, + "runtime_reload": { + "anyOf": [ + { + "$ref": "#/components/schemas/RuntimeReloadResponse" + }, + { + "type": "null" + } + ] + }, + "storage": { + "anyOf": [ + { + "$ref": "#/components/schemas/StorageResponse" + }, + { + "type": "null" + } + ] + } + }, + "title": "StorageChangeResponse", + "type": "object" + }, "StorageConfig": { "additionalProperties": false, "description": "Storage backend configuration.", @@ -1348,6 +1424,54 @@ "title": "StorageConfig", "type": "object" }, + "StorageFieldMetadata": { + "properties": { + "default": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "title": "Default" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description" + }, + "name": { + "title": "Name", + "type": "string" + }, + "required": { + "title": "Required", + "type": "boolean" + }, + "secret": { + "default": false, + "title": "Secret", + "type": "boolean" + }, + "type": { + "title": "Type", + "type": "string" + } + }, + "required": [ + "name", + "type", + "required" + ], + "title": "StorageFieldMetadata", + "type": "object" + }, "StoragePathsConfig": { "description": "Logical storage paths for different artifact types.", "properties": { @@ -1370,6 +1494,60 @@ "title": "StoragePathsConfig", "type": "object" }, + "StorageResponse": { + "properties": { + "backend": { + "title": "Backend", + "type": "string" + }, + "config": { + "additionalProperties": true, + "title": "Config", + "type": "object" + }, + "paths": { + "additionalProperties": true, + "title": "Paths", + "type": "object" + } + }, + "required": [ + "backend", + "config", + "paths" + ], + "title": "StorageResponse", + "type": "object" + }, + "StorageUpdate": { + "properties": { + "backend": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Backend" + }, + "config": { + "anyOf": [ + { + "additionalProperties": true, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Config" + } + }, + "title": "StorageUpdate", + "type": "object" + }, "TestConnectionRequest": { "description": "Payload for generic setup test-connection endpoint.", "properties": { @@ -2529,6 +2707,106 @@ "stats" ] } + }, + "/api/v1/storage": { + "get": { + "description": "Get active storage backend config with redacted secret values.", + "operationId": "get_storage_api_v1_storage_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageResponse" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Get Storage", + "tags": [ + "storage" + ] + }, + "patch": { + "description": "Partially update storage config and optionally trigger runtime reload.", + "operationId": "patch_storage_api_v1_storage_patch", + "parameters": [ + { + "in": "query", + "name": "apply_changes", + "required": false, + "schema": { + "default": false, + "title": "Apply Changes", + "type": "boolean" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StorageChangeResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Patch Storage", + "tags": [ + "storage" + ] + } + }, + "/api/v1/storage/backends": { + "get": { + "description": "List available storage backends and their config schema metadata.", + "operationId": "list_storage_backends_api_v1_storage_backends_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/StorageBackendMetadata" + }, + "title": "Response List Storage Backends Api V1 Storage Backends Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List Storage Backends", + "tags": [ + "storage" + ] + } } } } diff --git a/ui/src/api/generated/schema.ts b/ui/src/api/generated/schema.ts index 414a04b2..57528303 100644 --- a/ui/src/api/generated/schema.ts +++ b/ui/src/api/generated/schema.ts @@ -383,6 +383,50 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/storage": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Storage + * @description Get active storage backend config with redacted secret values. + */ + get: operations["get_storage_api_v1_storage_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Patch Storage + * @description Partially update storage config and optionally trigger runtime reload. + */ + patch: operations["patch_storage_api_v1_storage_patch"]; + trace?: never; + }; + "/api/v1/storage/backends": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Storage Backends + * @description List available storage backends and their config schema metadata. + */ + get: operations["list_storage_backends_api_v1_storage_backends_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -899,6 +943,33 @@ export interface components { /** Uptime Seconds */ uptime_seconds: number; }; + /** StorageBackendMetadata */ + StorageBackendMetadata: { + /** Backend */ + backend: string; + /** Config Schema */ + config_schema: { + [key: string]: unknown; + }; + /** Description */ + description: string; + /** Fields */ + fields: components["schemas"]["StorageFieldMetadata"][]; + /** Label */ + label: string; + /** Secret Fields */ + secret_fields: string[]; + }; + /** StorageChangeResponse */ + StorageChangeResponse: { + /** + * Restart Required + * @default true + */ + restart_required: boolean; + runtime_reload?: components["schemas"]["RuntimeReloadResponse"] | null; + storage?: components["schemas"]["StorageResponse"] | null; + }; /** * StorageConfig * @description Storage backend configuration. @@ -915,6 +986,24 @@ export interface components { } | components["schemas"]["BaseModel"]; paths?: components["schemas"]["StoragePathsConfig"]; }; + /** StorageFieldMetadata */ + StorageFieldMetadata: { + /** Default */ + default?: unknown | null; + /** Description */ + description?: string | null; + /** Name */ + name: string; + /** Required */ + required: boolean; + /** + * Secret + * @default false + */ + secret: boolean; + /** Type */ + type: string; + }; /** * StoragePathsConfig * @description Logical storage paths for different artifact types. @@ -936,6 +1025,28 @@ export interface components { */ clips_dir: string; }; + /** StorageResponse */ + StorageResponse: { + /** Backend */ + backend: string; + /** Config */ + config: { + [key: string]: unknown; + }; + /** Paths */ + paths: { + [key: string]: unknown; + }; + }; + /** StorageUpdate */ + StorageUpdate: { + /** Backend */ + backend?: string | null; + /** Config */ + config?: { + [key: string]: unknown; + } | null; + }; /** * TestConnectionRequest * @description Payload for generic setup test-connection endpoint. @@ -1656,4 +1767,79 @@ export interface operations { }; }; }; + get_storage_api_v1_storage_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StorageResponse"]; + }; + }; + }; + }; + patch_storage_api_v1_storage_patch: { + parameters: { + query?: { + apply_changes?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["StorageUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StorageChangeResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_storage_backends_api_v1_storage_backends_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StorageBackendMetadata"][]; + }; + }; + }; + }; } diff --git a/ui/src/api/generated/types.ts b/ui/src/api/generated/types.ts index 4b95e517..ed90c8bc 100644 --- a/ui/src/api/generated/types.ts +++ b/ui/src/api/generated/types.ts @@ -12,6 +12,11 @@ export type CameraListResponse = CameraResponse[] export type CameraCreate = components["schemas"]["CameraCreate"] export type CameraUpdate = components["schemas"]["CameraUpdate"] export type ConfigChangeResponse = components["schemas"]["ConfigChangeResponse"] +export type StorageResponse = components["schemas"]["StorageResponse"] +export type StorageUpdate = components["schemas"]["StorageUpdate"] +export type StorageChangeResponse = components["schemas"]["StorageChangeResponse"] +export type StorageBackendMetadata = components["schemas"]["StorageBackendMetadata"] +export type StorageBackendsResponse = StorageBackendMetadata[] export type SetupStatusResponse = components["schemas"]["SetupStatusResponse"] export type FinalizeRequest = components["schemas"]["FinalizeRequest"] export type FinalizeResponse = components["schemas"]["FinalizeResponse"] diff --git a/ui/src/api/hooks/queryKeys.ts b/ui/src/api/hooks/queryKeys.ts index e1e10432..77b51ac2 100644 --- a/ui/src/api/hooks/queryKeys.ts +++ b/ui/src/api/hooks/queryKeys.ts @@ -2,6 +2,8 @@ import type { ListClipsQuery } from '../generated/types' export const QUERY_KEYS = { cameras: ['cameras'] as const, + storage: ['storage'] as const, + storageBackends: ['storage-backends'] as const, setupStatus: ['setup-status'] as const, runtimeStatus: ['runtime-status'] as const, health: ['health'] as const, diff --git a/ui/src/api/hooks/useStorageBackendsQuery.ts b/ui/src/api/hooks/useStorageBackendsQuery.ts new file mode 100644 index 00000000..1b864264 --- /dev/null +++ b/ui/src/api/hooks/useStorageBackendsQuery.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query' + +import { apiClient } from '../client' +import type { StorageBackendsResponse } from '../generated/types' +import { QUERY_KEYS } from './queryKeys' + +const STORAGE_BACKENDS_REFRESH_MS = 60_000 + +export function useStorageBackendsQuery() { + return useQuery({ + queryKey: QUERY_KEYS.storageBackends, + queryFn: ({ signal }) => apiClient.listStorageBackends({ signal }), + staleTime: STORAGE_BACKENDS_REFRESH_MS, + refetchInterval: STORAGE_BACKENDS_REFRESH_MS, + }) +} diff --git a/ui/src/api/hooks/useStorageMutation.ts b/ui/src/api/hooks/useStorageMutation.ts new file mode 100644 index 00000000..1e6f7b51 --- /dev/null +++ b/ui/src/api/hooks/useStorageMutation.ts @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import { apiClient } from '../client' +import type { StorageChangeResponse, StorageUpdate } from '../generated/types' +import { QUERY_KEYS } from './queryKeys' + +interface UpdateStorageInput { + payload: StorageUpdate + applyChanges?: boolean +} + +export function useUpdateStorageMutation() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ payload, applyChanges }) => + apiClient.updateStorage(payload, { applyChanges }), + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.storage }), + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.storageBackends }), + ]) + }, + }) +} diff --git a/ui/src/api/hooks/useStorageQuery.ts b/ui/src/api/hooks/useStorageQuery.ts new file mode 100644 index 00000000..dcff0ad2 --- /dev/null +++ b/ui/src/api/hooks/useStorageQuery.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query' + +import { apiClient } from '../client' +import type { StorageResponse } from '../generated/types' +import { QUERY_KEYS } from './queryKeys' + +const STORAGE_REFRESH_MS = 30_000 + +export function useStorageQuery() { + return useQuery({ + queryKey: QUERY_KEYS.storage, + queryFn: ({ signal }) => apiClient.getStorage({ signal }), + staleTime: STORAGE_REFRESH_MS, + refetchInterval: STORAGE_REFRESH_MS, + }) +} diff --git a/ui/src/api/parsing.ts b/ui/src/api/parsing.ts index 2db245cd..262035af 100644 --- a/ui/src/api/parsing.ts +++ b/ui/src/api/parsing.ts @@ -4,6 +4,10 @@ import type { ClipListResponse, ClipResponse, ConfigChangeResponse, + StorageBackendsResponse, + StorageBackendMetadata, + StorageChangeResponse, + StorageResponse, DeviceInfoResponse, DiscoveredCameraResponse, DiagnosticsResponse, @@ -216,6 +220,119 @@ export function parseConfigChangeResponse(payload: unknown): ConfigChangeRespons } } +function parseStorageFieldMetadata( + payload: unknown, + fieldName: string, +): StorageBackendMetadata['fields'][number] { + if (!isJsonObject(payload)) { + throw new Error(`${fieldName} must be an object`) + } + + const defaultValue = payload.default + if ( + defaultValue !== null + && defaultValue !== undefined + && typeof defaultValue !== 'string' + && typeof defaultValue !== 'number' + && typeof defaultValue !== 'boolean' + && !Array.isArray(defaultValue) + && !isJsonObject(defaultValue) + ) { + throw new Error(`${fieldName}.default must be a JSON-serializable value`) + } + + return { + name: expectString(payload.name, `${fieldName}.name`), + type: expectString(payload.type, `${fieldName}.type`), + required: expectBoolean(payload.required, `${fieldName}.required`), + description: expectNullableString(payload.description, `${fieldName}.description`), + default: (defaultValue ?? null) as StorageBackendMetadata['fields'][number]['default'], + secret: expectBoolean(payload.secret, `${fieldName}.secret`), + } +} + +export function parseStorageBackendMetadata(payload: unknown): StorageBackendMetadata { + if (!isJsonObject(payload)) { + throw new Error('Storage backend metadata must be a JSON object') + } + + const schema = payload.config_schema + if (!isJsonObject(schema)) { + throw new Error('config_schema must be an object') + } + const fields = payload.fields + if (!Array.isArray(fields)) { + throw new Error('fields must be an array') + } + const secretFields = expectStringArray(payload.secret_fields, 'secret_fields') + + return { + backend: expectString(payload.backend, 'backend'), + label: expectString(payload.label, 'label'), + description: expectString(payload.description, 'description'), + config_schema: schema, + fields: fields.map((field, index) => { + try { + return parseStorageFieldMetadata(field, `fields[${index}]`) + } catch (error) { + throw new Error(`fields[${index}] invalid: ${(error as Error).message}`) + } + }), + secret_fields: secretFields, + } +} + +export function parseStorageBackendsResponse(payload: unknown): StorageBackendsResponse { + if (!Array.isArray(payload)) { + throw new Error('Storage backends response must be an array') + } + + return payload.map((item, index) => { + try { + return parseStorageBackendMetadata(item) + } catch (error) { + throw new Error(`backends[${index}] invalid: ${(error as Error).message}`) + } + }) +} + +export function parseStorageResponse(payload: unknown): StorageResponse { + if (!isJsonObject(payload)) { + throw new Error('Storage response is not a JSON object') + } + const config = payload.config + if (!isJsonObject(config)) { + throw new Error('config must be an object') + } + const paths = payload.paths + if (!isJsonObject(paths)) { + throw new Error('paths must be an object') + } + + return { + backend: expectString(payload.backend, 'backend'), + config, + paths, + } +} + +export function parseStorageChangeResponse(payload: unknown): StorageChangeResponse { + if (!isJsonObject(payload)) { + throw new Error('Storage change response is not a JSON object') + } + + const storage = payload.storage + const runtimeReload = payload.runtime_reload + return { + restart_required: expectBoolean(payload.restart_required, 'restart_required'), + storage: storage === null || storage === undefined ? null : parseStorageResponse(storage), + runtime_reload: + runtimeReload === null || runtimeReload === undefined + ? null + : parseRuntimeReloadResponse(runtimeReload), + } +} + export function parseHealthResponse(payload: unknown): HealthResponse { if (!isJsonObject(payload)) { throw new Error('Health response is not a JSON object') diff --git a/ui/src/app/layout/AppShell.tsx b/ui/src/app/layout/AppShell.tsx index 5cbfc01f..3a83cb33 100644 --- a/ui/src/app/layout/AppShell.tsx +++ b/ui/src/app/layout/AppShell.tsx @@ -5,6 +5,7 @@ import { useTheme } from '../providers/theme-context' const NAV_LINKS = [ { to: '/', label: 'Dashboard' }, { to: '/cameras', label: 'Cameras' }, + { to: '/storage', label: 'Storage' }, { to: '/clips', label: 'Clips' }, { to: '/setup', label: 'Setup' }, ] diff --git a/ui/src/features/settings/storage/StorageConfigForm.test.tsx b/ui/src/features/settings/storage/StorageConfigForm.test.tsx index d77754e2..1f243656 100644 --- a/ui/src/features/settings/storage/StorageConfigForm.test.tsx +++ b/ui/src/features/settings/storage/StorageConfigForm.test.tsx @@ -14,7 +14,7 @@ describe('StorageConfigForm', () => { }) it('switches backend and resets config to backend defaults', async () => { - // Given: Form starts on local backend + // Given: Form starts on local backend with metadata-driven backend labels/defaults const onChange = vi.fn() const user = userEvent.setup() @@ -26,6 +26,50 @@ describe('StorageConfigForm', () => { return ( { onChange(nextValue) setValue(nextValue) @@ -37,9 +81,9 @@ describe('StorageConfigForm', () => { render() // When: Operator switches to Dropbox backend - await user.click(screen.getByRole('button', { name: 'Dropbox' })) + await user.click(screen.getByRole('button', { name: 'Dropbox Cloud' })) - // Then: Form switches backend and applies dropbox defaults + // Then: Form switches backend and applies metadata-aware defaults expect(onChange).toHaveBeenCalled() expect(onChange.mock.calls.at(-1)?.[0]).toMatchObject({ backend: 'dropbox', @@ -61,6 +105,7 @@ describe('StorageConfigForm', () => { return ( { onChange(nextValue) setValue(nextValue) diff --git a/ui/src/features/settings/storage/StorageConfigForm.tsx b/ui/src/features/settings/storage/StorageConfigForm.tsx index 6da1145b..85b08d58 100644 --- a/ui/src/features/settings/storage/StorageConfigForm.tsx +++ b/ui/src/features/settings/storage/StorageConfigForm.tsx @@ -1,20 +1,27 @@ +import type { StorageBackendsResponse } from '../../../api/generated/types' import { STORAGE_BACKENDS, STORAGE_BACKEND_ORDER } from './backends' +import { + buildSupportedStorageBackendOptions, + defaultConfigForBackend, +} from './editorModel' import type { StorageBackend, StorageFormState } from './types' interface StorageConfigFormProps { value: StorageFormState + backends?: StorageBackendsResponse | null onChange: (value: StorageFormState) => void } -export function StorageConfigForm({ value, onChange }: StorageConfigFormProps) { +export function StorageConfigForm({ value, backends = null, onChange }: StorageConfigFormProps) { const backendDef = STORAGE_BACKENDS[value.backend] const BackendComponent = backendDef.component + const backendOptions = buildSupportedStorageBackendOptions(backends) function handleSelectBackend(backend: StorageBackend): void { - const selected = STORAGE_BACKENDS[backend] + const selected = backendOptions.find((option) => option.backend === backend) onChange({ backend, - config: selected.defaultConfig, + config: defaultConfigForBackend(backend, selected?.metadata ?? null), }) } @@ -25,17 +32,24 @@ export function StorageConfigForm({ value, onChange }: StorageConfigFormProps) {
{STORAGE_BACKEND_ORDER.map((backendId) => { - const backend = STORAGE_BACKENDS[backendId] + const backend = + backendOptions.find((option) => option.backend === backendId) + ?? { + backend: backendId, + label: STORAGE_BACKENDS[backendId].label, + description: STORAGE_BACKENDS[backendId].description, + metadata: null, + } const selected = backendId === value.backend return ( + + + {unauthorized ? ( + + + + ) : null} + + {pageError ? ( + +

{pageError}

+
+ ) : null} + + + {storageQuery.isPending || !storageQuery.data ? ( +

Loading storage configuration...

+ ) : ( +
+
+ + {activeBackendOption?.label ?? storageQuery.data.backend} + +

Backend id: {storageQuery.data.backend}

+
+

+ Switching backend affects new uploads only. Existing clip URIs are unchanged. +

+
+ )} +
+ + + {!draft ? ( +

Loading form...

+ ) : ( +
+
+ {backendOptions.map((option) => { + const selected = option.backend === draft.backend + const supported = isSupportedStorageBackend(option.backend) + return ( + + ) + })} +
+ + {selectedBackendForm} + + {selectedSecretFields.length > 0 ? ( +
+

Secret updates

+

+ Secret fields are write-only. Leave blank to keep the current value unchanged. +

+ {selectedSecretFields.map((field) => ( + + ))} +
+ ) : null} + + {validationError ?

{validationError}

: null} + + + + { + setTestResult(result) + }} + idleLabel="Validate storage" + retryLabel="Retry validation" + pendingLabel="Validating..." + description="Run storage connectivity validation before applying updates." + /> + +
+ +
+
+ )} +
+ + + {hasPendingReload ? ( +
+

+ {pendingReloadMessage ?? 'Storage changes are pending runtime reload.'} +

+ +
+ ) : ( +

No pending storage reload tasks.

+ )} + + {actionFeedback ?

{actionFeedback}

: null} + + {activeRuntimeStatus ? ( +
+
+
State
+
+ + {runtimeStatusLabel(activeRuntimeStatus.state)} + +
+
+
+
Generation
+
{activeRuntimeStatus.generation}
+
+
+
Last reload
+
{formatRuntimeTimestamp(activeRuntimeStatus.last_reload_at)}
+
+
+
Last reload error
+
{activeRuntimeStatus.last_reload_error ?? 'none'}
+
+
+ ) : ( +

Runtime status unavailable.

+ )} +
+ + ) +} diff --git a/ui/src/routes/AppRouter.tsx b/ui/src/routes/AppRouter.tsx index 531bad64..1cd16d47 100644 --- a/ui/src/routes/AppRouter.tsx +++ b/ui/src/routes/AppRouter.tsx @@ -7,6 +7,7 @@ import { ClipsPage } from '../features/clips/ClipsPage' import { DashboardPage } from '../features/dashboard/DashboardPage' import { NotFoundPage } from '../features/not-found/NotFoundPage' import { SetupPage } from '../features/setup/SetupPage' +import { StoragePage } from '../features/storage/StoragePage' export function AppRouter() { return ( @@ -15,6 +16,7 @@ export function AppRouter() { }> } /> } /> + } /> } /> } /> } /> diff --git a/uv.lock b/uv.lock index 30008804..2d82d87e 100644 --- a/uv.lock +++ b/uv.lock @@ -1223,7 +1223,7 @@ wheels = [ [[package]] name = "homesec" -version = "1.6.0" +version = "1.8.0" source = { editable = "." } dependencies = [ { name = "aiohttp" },