From 12e05c123fce03cce0b9d7b4ebd42c98fee045ea Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 27 Feb 2026 17:35:46 -0500 Subject: [PATCH 1/8] feat: implement storage settings API and UI (#42) --- src/homesec/api/errors.py | 1 + src/homesec/api/routes/__init__.py | 6 +- src/homesec/api/routes/storage.py | 217 ++++++ src/homesec/config/errors.py | 17 + src/homesec/config/manager.py | 67 +- src/homesec/plugins/registry.py | 11 + tests/homesec/test_api_routes.py | 169 +++++ tests/homesec/test_config_manager.py | 87 +++ ui/scripts/api_codegen.mjs | 35 +- ui/src/api/client.test.ts | 133 ++++ ui/src/api/client.ts | 63 ++ ui/src/api/generated/client.ts | 14 + ui/src/api/generated/openapi.json | 278 ++++++++ ui/src/api/generated/schema.ts | 186 ++++++ ui/src/api/generated/types.ts | 5 + ui/src/api/hooks/queryKeys.ts | 2 + ui/src/api/hooks/useStorageBackendsQuery.ts | 16 + ui/src/api/hooks/useStorageMutation.ts | 24 + ui/src/api/hooks/useStorageQuery.ts | 16 + ui/src/api/parsing.ts | 117 ++++ ui/src/app/layout/AppShell.tsx | 1 + ui/src/features/storage/StoragePage.test.tsx | 400 +++++++++++ ui/src/features/storage/StoragePage.tsx | 656 +++++++++++++++++++ ui/src/routes/AppRouter.tsx | 2 + uv.lock | 2 +- 25 files changed, 2520 insertions(+), 5 deletions(-) create mode 100644 src/homesec/api/routes/storage.py create mode 100644 ui/src/api/hooks/useStorageBackendsQuery.ts create mode 100644 ui/src/api/hooks/useStorageMutation.ts create mode 100644 ui/src/api/hooks/useStorageQuery.ts create mode 100644 ui/src/features/storage/StoragePage.test.tsx create mode 100644 ui/src/features/storage/StoragePage.tsx 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..34885419 100644 --- a/src/homesec/api/routes/__init__.py +++ b/src/homesec/api/routes/__init__.py @@ -10,7 +10,7 @@ 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 +21,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), Depends(require_normal_mode)], + ) app.include_router( cameras.router, dependencies=[Depends(verify_api_key), Depends(require_normal_mode)], diff --git a/src/homesec/api/routes/storage.py b/src/homesec/api/routes/storage.py new file mode 100644 index 00000000..a989efc2 --- /dev/null +++ b/src/homesec/api/routes/storage.py @@ -0,0 +1,217 @@ +"""Storage configuration endpoints.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, cast + +from fastapi import APIRouter, Depends, status +from pydantic import BaseModel + +from homesec.api.dependencies import get_homesec_app +from homesec.api.errors import APIError, APIErrorCode +from homesec.api.redaction import is_sensitive_key, redact_config +from homesec.config.errors import StorageConfigInvalidError, 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 +from homesec.runtime.errors import RuntimeReloadConfigError + +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 RuntimeReloadResponse(BaseModel): + accepted: bool + message: str + target_generation: int + + +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 _map_storage_config_error(exc: StorageMutationError) -> APIError: + if isinstance(exc, StorageConfigInvalidError): + return APIError( + str(exc), + status_code=status.HTTP_400_BAD_REQUEST, + error_code=APIErrorCode.STORAGE_CONFIG_INVALID, + ) + return APIError( + str(exc), + status_code=status.HTTP_400_BAD_REQUEST, + error_code=APIErrorCode.STORAGE_CONFIG_INVALID, + ) + + +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, + ) + + +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) +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) +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 _map_storage_config_error(exc) 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/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..0dee2cc0 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***" @@ -68,6 +70,13 @@ def _to_source_config_dict(config: dict[str, object] | BaseModel) -> dict[str, o return cast(dict[str, object], payload) return dict(config) + @staticmethod + def _to_storage_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) + return dict(config) + @classmethod def _contains_redacted_placeholder(cls, value: object) -> bool: if isinstance(value, dict): @@ -188,6 +197,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_storage_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/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/test_api_routes.py b/tests/homesec/test_api_routes.py index ef37ca2b..09f402a9 100644 --- a/tests/homesec/test_api_routes.py +++ b/tests/homesec/test_api_routes.py @@ -1082,6 +1082,175 @@ 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_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..a112c04a 100644 --- a/tests/homesec/test_config_manager.py +++ b/tests/homesec/test_config_manager.py @@ -14,6 +14,7 @@ CameraConfigInvalidError, CameraConfigRedactedPlaceholderError, CameraNotFoundError, + StorageConfigRedactedPlaceholderError, ) from homesec.config.loader import load_config_from_dict from homesec.config.manager import ConfigManager @@ -349,6 +350,92 @@ 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_enforces_restrictive_file_modes_on_save(tmp_path: Path) -> None: """Config writes should enforce 0600 mode for config and backup files.""" 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/storage/StoragePage.test.tsx b/ui/src/features/storage/StoragePage.test.tsx new file mode 100644 index 00000000..425d9810 --- /dev/null +++ b/ui/src/features/storage/StoragePage.test.tsx @@ -0,0 +1,400 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import type { RuntimeStatusSnapshot } from '../../api/client' +import type { StorageBackendsResponse, StorageResponse } from '../../api/generated/types' +import type { useRuntimeStatusQuery } from '../../api/hooks/useRuntimeStatusQuery' +import type { useStorageBackendsQuery } from '../../api/hooks/useStorageBackendsQuery' +import type { useStorageQuery } from '../../api/hooks/useStorageQuery' +import type { useUpdateStorageMutation } from '../../api/hooks/useStorageMutation' +import { StoragePage } from './StoragePage' + +const useStorageQueryMock = vi.fn() +const useStorageBackendsQueryMock = vi.fn() +const useRuntimeStatusQueryMock = vi.fn() +const useUpdateStorageMutationMock = vi.fn() +const useRuntimeReloadMutationMock = vi.fn() +const setupTestConnectionMutateAsyncMock = vi.fn() + +vi.mock('../../api/hooks/useStorageQuery', () => ({ + useStorageQuery: () => useStorageQueryMock(), +})) + +vi.mock('../../api/hooks/useStorageBackendsQuery', () => ({ + useStorageBackendsQuery: () => useStorageBackendsQueryMock(), +})) + +vi.mock('../../api/hooks/useRuntimeStatusQuery', () => ({ + useRuntimeStatusQuery: () => useRuntimeStatusQueryMock(), +})) + +vi.mock('../../api/hooks/useStorageMutation', () => ({ + useUpdateStorageMutation: () => useUpdateStorageMutationMock(), +})) + +vi.mock('../../api/hooks/useRuntimeReloadMutation', () => ({ + useRuntimeReloadMutation: () => useRuntimeReloadMutationMock(), +})) + +vi.mock('../../api/hooks/useSetupTestConnectionMutation', () => ({ + useSetupTestConnectionMutation: () => ({ + mutateAsync: setupTestConnectionMutateAsyncMock, + isPending: false, + }), +})) + +function defaultStorageSnapshot(): StorageResponse { + return { + backend: 'local', + config: { root: './storage' }, + paths: { + clips_dir: 'clips', + backups_dir: 'backups', + artifacts_dir: 'artifacts', + }, + } +} + +function defaultStorageBackends(): StorageBackendsResponse { + return [ + { + backend: 'local', + label: 'Local FS', + description: 'Store on local disk', + config_schema: {}, + fields: [ + { + name: 'root', + type: 'string', + required: true, + description: 'Root path', + default: './storage', + secret: false, + }, + ], + secret_fields: [], + }, + { + backend: 'dropbox', + label: 'Dropbox', + description: 'Upload to Dropbox', + config_schema: {}, + fields: [ + { + name: 'root', + type: 'string', + required: true, + description: 'Dropbox root', + default: '/homesec', + secret: false, + }, + { + name: 'token_env', + type: 'string', + required: true, + description: 'Token env var', + default: 'DROPBOX_TOKEN', + secret: false, + }, + ], + secret_fields: [], + }, + ] +} + +function defaultRuntimeStatus(): RuntimeStatusSnapshot { + return { + httpStatus: 200, + state: 'idle', + generation: 3, + reload_in_progress: false, + active_config_version: 'cfg-v3', + last_reload_at: null, + last_reload_error: null, + } +} + +function setupPage({ + storage = defaultStorageSnapshot(), + backends = defaultStorageBackends(), + updateResponse = { + restart_required: true, + storage: { + backend: 'dropbox', + config: { + root: '/homesec', + token_env: 'DROPBOX_TOKEN', + }, + paths: { + clips_dir: 'clips', + backups_dir: 'backups', + artifacts_dir: 'artifacts', + }, + }, + runtime_reload: null, + }, +}: { + storage?: StorageResponse + backends?: StorageBackendsResponse + updateResponse?: { + restart_required: boolean + storage: { + backend: string + config: Record + paths: Record + } | null + runtime_reload: { + accepted: boolean + message: string + target_generation: number + } | null + } +} = {}) { + const storageRefetch = vi.fn().mockResolvedValue({ data: storage }) + const backendsRefetch = vi.fn().mockResolvedValue({ data: backends }) + const runtimeRefetch = vi.fn().mockResolvedValue({ data: defaultRuntimeStatus() }) + + useStorageQueryMock.mockReturnValue({ + data: storage, + isPending: false, + isFetching: false, + error: null, + refetch: storageRefetch, + } as unknown as ReturnType) + + useStorageBackendsQueryMock.mockReturnValue({ + data: backends, + isPending: false, + isFetching: false, + error: null, + refetch: backendsRefetch, + } as unknown as ReturnType) + + useRuntimeStatusQueryMock.mockReturnValue({ + data: defaultRuntimeStatus(), + isPending: false, + isFetching: false, + error: null, + refetch: runtimeRefetch, + } as unknown as ReturnType) + + const updateMutateAsync = vi.fn().mockResolvedValue(updateResponse) + useUpdateStorageMutationMock.mockReturnValue({ + mutateAsync: updateMutateAsync, + isPending: false, + error: null, + } as unknown as ReturnType) + + const reloadMutateAsync = vi.fn().mockResolvedValue({ + accepted: true, + message: 'Runtime reload accepted', + target_generation: 8, + httpStatus: 202, + }) + useRuntimeReloadMutationMock.mockReturnValue({ + mutateAsync: reloadMutateAsync, + isPending: false, + error: null, + }) + + setupTestConnectionMutateAsyncMock.mockResolvedValue({ + httpStatus: 200, + success: true, + message: 'Storage probe succeeded.', + latency_ms: 10.1, + details: null, + }) + + render() + + return { + updateMutateAsync, + reloadMutateAsync, + } +} + +describe('StoragePage', () => { + beforeEach(() => { + useStorageQueryMock.mockReset() + useStorageBackendsQueryMock.mockReset() + useRuntimeStatusQueryMock.mockReset() + useUpdateStorageMutationMock.mockReset() + useRuntimeReloadMutationMock.mockReset() + setupTestConnectionMutateAsyncMock.mockReset() + vi.restoreAllMocks() + }) + + afterEach(() => { + cleanup() + }) + + it('renders backend selector labels from backend metadata response', () => { + // Given: Storage backend metadata with custom labels is available + setupPage() + + // When: The storage page renders + const localButton = screen.getByRole('button', { name: 'Local FS' }) + const dropboxButton = screen.getByRole('button', { name: 'Dropbox' }) + + // Then: Selector cards use metadata-driven labels + expect(localButton).toBeTruthy() + expect(dropboxButton).toBeTruthy() + }) + + it('does not mutate when backend switch confirmation is cancelled', async () => { + // Given: A page where operator changes backend and declines switch confirmation + const harness = setupPage() + const user = userEvent.setup() + Object.defineProperty(window, 'confirm', { + configurable: true, + value: vi.fn(() => false), + }) + + // When: Operator switches to dropbox and clicks save + await user.click(screen.getByRole('button', { name: 'Dropbox' })) + await user.click(screen.getByRole('button', { name: 'Save storage settings' })) + + // Then: No storage update mutation is sent + expect(harness.updateMutateAsync).not.toHaveBeenCalled() + }) + + it('submits backend switch payload and surfaces pending runtime reload message', async () => { + // Given: A page where backend switch is confirmed and response requires runtime reload + const harness = setupPage() + const user = userEvent.setup() + Object.defineProperty(window, 'confirm', { + configurable: true, + value: vi.fn(() => true), + }) + + // When: Operator switches backend and saves settings + await user.click(screen.getByRole('button', { name: 'Dropbox' })) + await user.click(screen.getByRole('button', { name: 'Save storage settings' })) + + // Then: PATCH payload includes backend + config and page shows pending reload guidance + expect(harness.updateMutateAsync).toHaveBeenCalledTimes(1) + expect(harness.updateMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + backend: 'dropbox', + config: expect.objectContaining({ + root: '/homesec', + token_env: 'DROPBOX_TOKEN', + }), + }), + }), + ) + expect( + screen.getByText('Storage configuration updated. Apply runtime reload to activate changes.'), + ).toBeTruthy() + }) + + it('passes applyChanges=true and handles immediate runtime reload response', async () => { + // Given: Storage update responds with runtime reload acceptance and apply-immediately enabled + const harness = setupPage({ + updateResponse: { + restart_required: false, + storage: { + backend: 'local', + config: { + root: '/var/lib/homesec', + }, + paths: { + clips_dir: 'clips', + backups_dir: 'backups', + artifacts_dir: 'artifacts', + }, + }, + runtime_reload: { + accepted: true, + message: 'Runtime reload accepted', + target_generation: 10, + }, + }, + }) + const user = userEvent.setup() + + // When: Operator edits local root, enables immediate apply, and saves + await user.clear(screen.getByLabelText('Storage root directory')) + await user.type(screen.getByLabelText('Storage root directory'), '/var/lib/homesec') + await user.click(screen.getByLabelText('Apply changes immediately (runtime reload)')) + await user.click(screen.getByRole('button', { name: 'Save storage settings' })) + + // Then: Mutation includes applyChanges=true and success message reflects runtime reload + expect(harness.updateMutateAsync).toHaveBeenCalledTimes(1) + expect(harness.updateMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + applyChanges: true, + }), + ) + expect(screen.getByText('Storage configuration updated. Runtime reload accepted.')).toBeTruthy() + }) + + it('submits secret field patch values without requiring non-secret config edits', async () => { + // Given: Backend metadata includes a write-only secret field for local backend + const harness = setupPage({ + backends: [ + { + backend: 'local', + label: 'Local FS', + description: 'Store on local disk', + config_schema: {}, + fields: [ + { + name: 'root', + type: 'string', + required: true, + description: 'Root path', + default: './storage', + secret: false, + }, + { + name: 'access_token', + type: 'string', + required: false, + description: 'Optional token', + default: null, + secret: true, + }, + ], + secret_fields: ['access_token'], + }, + ], + updateResponse: { + restart_required: true, + storage: { + backend: 'local', + config: { + root: './storage', + }, + paths: { + clips_dir: 'clips', + backups_dir: 'backups', + artifacts_dir: 'artifacts', + }, + }, + runtime_reload: null, + }, + }) + const user = userEvent.setup() + + // When: Operator enters secret replacement value and saves + await user.type(screen.getByLabelText('access_token'), 'replace-me') + await user.click(screen.getByRole('button', { name: 'Save storage settings' })) + + // Then: Mutation payload includes write-only secret patch field + expect(harness.updateMutateAsync).toHaveBeenCalledTimes(1) + expect(harness.updateMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + config: expect.objectContaining({ + access_token: 'replace-me', + }), + }), + }), + ) + }) +}) diff --git a/ui/src/features/storage/StoragePage.tsx b/ui/src/features/storage/StoragePage.tsx new file mode 100644 index 00000000..db19706e --- /dev/null +++ b/ui/src/features/storage/StoragePage.tsx @@ -0,0 +1,656 @@ +import { useMemo, useState } from 'react' + +import { + clearApiKey, + isUnauthorizedAPIError, + saveApiKey, +} from '../../api/client' +import { useRuntimeReloadMutation } from '../../api/hooks/useRuntimeReloadMutation' +import { useRuntimeStatusQuery } from '../../api/hooks/useRuntimeStatusQuery' +import { useStorageBackendsQuery } from '../../api/hooks/useStorageBackendsQuery' +import { useUpdateStorageMutation } from '../../api/hooks/useStorageMutation' +import { useStorageQuery } from '../../api/hooks/useStorageQuery' +import type { + StorageBackendMetadata, + StorageUpdate, + TestConnectionResponse, +} from '../../api/generated/types' +import { ApiKeyGate } from '../../components/ui/ApiKeyGate' +import { Button } from '../../components/ui/Button' +import { Card } from '../../components/ui/Card' +import { StatusBadge } from '../../components/ui/StatusBadge' +import { TestConnectionButton } from '../shared/TestConnectionButton' +import { describeUnknownError } from '../shared/errorPresentation' +import { + STORAGE_BACKENDS, + STORAGE_BACKEND_ORDER, +} from '../settings/storage/backends' +import type { StorageBackend } from '../settings/storage/types' + +const REDACTED_PLACEHOLDER = '***redacted***' + +interface StorageDraft { + backend: string + config: Record +} + +interface BackendOption { + backend: string + label: string + description: string + metadata: StorageBackendMetadata | null +} + +function runtimeStatusTone(status: { + state: 'idle' | 'reloading' | 'failed' + reload_in_progress: boolean +}): 'degraded' | 'healthy' | 'unhealthy' { + if (status.state === 'failed') { + return 'unhealthy' + } + if (status.reload_in_progress || status.state === 'reloading') { + return 'degraded' + } + return 'healthy' +} + +function formatRuntimeTimestamp(value: string | null): string { + if (!value) { + return 'n/a' + } + const parsed = new Date(value) + if (Number.isNaN(parsed.valueOf())) { + return value + } + return parsed.toLocaleString() +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function cloneConfig(config: Record): Record { + return JSON.parse(JSON.stringify(config)) as Record +} + +function sameJsonValue(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right) +} + +function buildConfigPatch( + base: Record, + next: Record, +): Record { + const patch: Record = {} + const keys = new Set([...Object.keys(base), ...Object.keys(next)]) + + for (const key of keys) { + if (!(key in next)) { + patch[key] = null + continue + } + + const nextValue = next[key] + if (nextValue === REDACTED_PLACEHOLDER) { + continue + } + + const baseValue = base[key] + if (isRecord(baseValue) && isRecord(nextValue)) { + const nestedPatch = buildConfigPatch(baseValue, nextValue) + if (Object.keys(nestedPatch).length > 0) { + patch[key] = nestedPatch + } + continue + } + + if (!sameJsonValue(baseValue, nextValue)) { + patch[key] = nextValue + } + } + + return patch +} + +function isSupportedStorageBackend(backend: string): backend is StorageBackend { + return Object.prototype.hasOwnProperty.call(STORAGE_BACKENDS, backend) +} + +function defaultConfigForBackend(metadata: StorageBackendMetadata | null): Record { + if (!metadata) { + return {} + } + + const defaults: Record = {} + for (const field of metadata.fields) { + if (field.default === null || field.default === undefined) { + continue + } + defaults[field.name] = field.default + } + return defaults +} + +function runtimeStatusLabel(state: 'idle' | 'reloading' | 'failed'): string { + if (state === 'failed') { + return 'Failed' + } + if (state === 'reloading') { + return 'Reloading' + } + return 'Idle' +} + +export function StoragePage() { + const storageQuery = useStorageQuery() + const storageBackendsQuery = useStorageBackendsQuery() + const runtimeStatusQuery = useRuntimeStatusQuery() + const updateStorageMutation = useUpdateStorageMutation() + const runtimeReloadMutation = useRuntimeReloadMutation() + + const [editedDraft, setEditedDraft] = useState(null) + const [validationError, setValidationError] = useState(null) + const [actionFeedback, setActionFeedback] = useState(null) + const [hasPendingReload, setHasPendingReload] = useState(false) + const [pendingReloadMessage, setPendingReloadMessage] = useState(null) + const [applyChangesImmediately, setApplyChangesImmediately] = useState(false) + const [testResult, setTestResult] = useState(null) + const [secretInputs, setSecretInputs] = useState>({}) + + const baseline = useMemo(() => { + if (!storageQuery.data) { + return null + } + return { + backend: storageQuery.data.backend, + config: cloneConfig(storageQuery.data.config), + } + }, [storageQuery.data]) + + const draft = editedDraft ?? baseline + + const backendMetadataByName = useMemo(() => { + const map = new Map() + for (const backend of storageBackendsQuery.data ?? []) { + map.set(backend.backend, backend) + } + return map + }, [storageBackendsQuery.data]) + + const backendOptions = useMemo(() => { + if (storageBackendsQuery.data && storageBackendsQuery.data.length > 0) { + return storageBackendsQuery.data.map((backend) => ({ + backend: backend.backend, + label: backend.label, + description: backend.description, + metadata: backend, + })) + } + + return STORAGE_BACKEND_ORDER.map((backend) => ({ + backend, + label: STORAGE_BACKENDS[backend].label, + description: STORAGE_BACKENDS[backend].description, + metadata: null, + })) + }, [storageBackendsQuery.data]) + + const selectedMetadata = useMemo(() => { + if (!draft) { + return null + } + return backendMetadataByName.get(draft.backend) ?? null + }, [backendMetadataByName, draft]) + + const selectedSecretFields = useMemo(() => { + if (!selectedMetadata) { + return [] as string[] + } + return selectedMetadata.secret_fields + }, [selectedMetadata]) + + const unauthorized = + isUnauthorizedAPIError(storageQuery.error) + || isUnauthorizedAPIError(storageBackendsQuery.error) + || isUnauthorizedAPIError(runtimeStatusQuery.error) + || isUnauthorizedAPIError(updateStorageMutation.error) + || isUnauthorizedAPIError(runtimeReloadMutation.error) + + const isMutating = + updateStorageMutation.isPending || runtimeReloadMutation.isPending + + const pageError = useMemo(() => { + if (unauthorized) { + return null + } + + const errors = [ + storageQuery.error, + storageBackendsQuery.error, + runtimeStatusQuery.error, + updateStorageMutation.error, + runtimeReloadMutation.error, + ] + const firstError = errors.find((item) => item !== null) + return firstError ? describeUnknownError(firstError) : null + }, [ + runtimeReloadMutation.error, + runtimeStatusQuery.error, + storageBackendsQuery.error, + storageQuery.error, + unauthorized, + updateStorageMutation.error, + ]) + + function setStorageDraft(nextDraft: StorageDraft): void { + setEditedDraft(nextDraft) + setValidationError(null) + setActionFeedback(null) + setTestResult(null) + } + + const selectedBackendOption = useMemo(() => { + if (!draft) { + return null + } + return backendOptions.find((option) => option.backend === draft.backend) ?? null + }, [backendOptions, draft]) + const selectedBackendForm = useMemo(() => { + if (!draft) { + return null + } + if (!isSupportedStorageBackend(draft.backend)) { + return ( +

+ This storage backend is not supported by the guided editor. Select a supported backend. +

+ ) + } + + const BackendForm = STORAGE_BACKENDS[draft.backend].component + return ( + { + setStorageDraft({ + ...draft, + config: nextConfig, + }) + }} + /> + ) + }, [draft]) + + const activeRuntimeStatus = runtimeStatusQuery.data + + async function refreshAll(): Promise { + const [storageResult] = await Promise.all([ + storageQuery.refetch(), + storageBackendsQuery.refetch(), + runtimeStatusQuery.refetch(), + ]) + if (storageResult.data) { + setEditedDraft(null) + setSecretInputs({}) + setValidationError(null) + setTestResult(null) + } + } + + async function submitApiKey(apiKey: string): Promise { + saveApiKey(apiKey) + await refreshAll() + } + + async function clearStoredApiKey(): Promise { + clearApiKey() + await refreshAll() + } + + function markPendingReload(message: string): void { + setHasPendingReload(true) + setPendingReloadMessage(message) + } + + function clearPendingReload(): void { + setHasPendingReload(false) + setPendingReloadMessage(null) + } + + function handleSelectBackend(nextBackend: string): void { + if (!draft || nextBackend === draft.backend) { + return + } + + const nextMetadata = backendMetadataByName.get(nextBackend) ?? null + const defaultFromMetadata = defaultConfigForBackend(nextMetadata) + const nextConfig = + Object.keys(defaultFromMetadata).length > 0 + ? defaultFromMetadata + : isSupportedStorageBackend(nextBackend) + ? STORAGE_BACKENDS[nextBackend].defaultConfig + : {} + + setStorageDraft({ + backend: nextBackend, + config: cloneConfig(nextConfig), + }) + setSecretInputs({}) + } + + async function handleSaveChanges(): Promise { + if (!draft) { + return + } + + const isBackendSwitch = baseline !== null && draft.backend !== baseline.backend + + if (isSupportedStorageBackend(draft.backend)) { + const maybeError = STORAGE_BACKENDS[draft.backend].validate(draft.config) + if (maybeError) { + setValidationError(maybeError) + return + } + } + + if (isBackendSwitch) { + const confirmed = window.confirm( + 'Switch storage backend? This affects new uploads only. Existing clip URIs remain unchanged.', + ) + if (!confirmed) { + return + } + } + + const baseConfig = baseline?.config ?? {} + const nonSecretPatch = isBackendSwitch + ? cloneConfig(draft.config) + : buildConfigPatch(baseConfig, draft.config) + + const secretPatch: Record = {} + for (const [key, value] of Object.entries(secretInputs)) { + const trimmed = value.trim() + if (trimmed.length === 0) { + continue + } + secretPatch[key] = value + } + + const nextConfigPatch: Record = { + ...nonSecretPatch, + ...secretPatch, + } + + const payload: StorageUpdate = {} + if (isBackendSwitch) { + payload.backend = draft.backend + } + if (Object.keys(nextConfigPatch).length > 0) { + payload.config = nextConfigPatch + } + + if (!isBackendSwitch && payload.config === undefined) { + setActionFeedback('No storage changes to apply.') + return + } + + setActionFeedback(null) + setValidationError(null) + + try { + const response = await updateStorageMutation.mutateAsync({ + payload, + applyChanges: applyChangesImmediately, + }) + + if (response.storage) { + await storageQuery.refetch() + setEditedDraft(null) + } + setSecretInputs({}) + + if (response.restart_required) { + markPendingReload('Storage configuration updated. Runtime reload required.') + setActionFeedback('Storage configuration updated. Apply runtime reload to activate changes.') + return + } + + clearPendingReload() + if (response.runtime_reload) { + setActionFeedback(`Storage configuration updated. ${response.runtime_reload.message}.`) + await runtimeStatusQuery.refetch() + return + } + + setActionFeedback('Storage configuration updated.') + } catch (error) { + setActionFeedback(`Storage update failed: ${describeUnknownError(error)}`) + } + } + + async function handleApplyRuntimeReload(): Promise { + setActionFeedback(null) + try { + const response = await runtimeReloadMutation.mutateAsync() + clearPendingReload() + setActionFeedback(response.message) + await runtimeStatusQuery.refetch() + } catch (error) { + setActionFeedback(`Runtime reload failed: ${describeUnknownError(error)}`) + } + } + + return ( +
+
+
+

Storage

+

Configure storage backends and apply runtime reload safely.

+
+ +
+ + {unauthorized ? ( + + + + ) : null} + + {pageError ? ( + +

{pageError}

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

Loading storage configuration...

+ ) : ( +
+
+ + {selectedBackendOption?.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" }, From 555521b472a886cb24f4c7c93b5d79f74a77c4bc Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 3 Apr 2026 14:57:44 -0700 Subject: [PATCH 2/8] fix: resolve storage page review findings --- ui/src/features/storage/StoragePage.test.tsx | 143 ++++++++++++++++++- ui/src/features/storage/StoragePage.tsx | 65 +++++---- 2 files changed, 183 insertions(+), 25 deletions(-) diff --git a/ui/src/features/storage/StoragePage.test.tsx b/ui/src/features/storage/StoragePage.test.tsx index 425d9810..1294ea3c 100644 --- a/ui/src/features/storage/StoragePage.test.tsx +++ b/ui/src/features/storage/StoragePage.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import type { RuntimeStatusSnapshot } from '../../api/client' @@ -292,6 +292,79 @@ describe('StoragePage', () => { ).toBeTruthy() }) + it('preserves built-in backend defaults when metadata omits schema defaults', async () => { + // Given: Dropbox metadata omits a default for a required root field + const harness = setupPage({ + backends: [ + { + backend: 'local', + label: 'Local FS', + description: 'Store on local disk', + config_schema: {}, + fields: [ + { + name: 'root', + type: 'string', + required: true, + description: 'Root path', + default: './storage', + secret: false, + }, + ], + secret_fields: [], + }, + { + backend: 'dropbox', + label: 'Dropbox', + description: 'Upload to Dropbox', + config_schema: {}, + fields: [ + { + name: 'root', + type: 'string', + required: true, + description: 'Dropbox root', + default: null, + secret: false, + }, + { + name: 'token_env', + type: 'string', + required: true, + description: 'Token env var', + default: 'DROPBOX_TOKEN', + secret: false, + }, + ], + secret_fields: [], + }, + ], + }) + const user = userEvent.setup() + Object.defineProperty(window, 'confirm', { + configurable: true, + value: vi.fn(() => true), + }) + + // When: Operator switches to Dropbox and saves without manually retyping root + await user.click(screen.getByRole('button', { name: 'Dropbox' })) + await user.click(screen.getByRole('button', { name: 'Save storage settings' })) + + // Then: Payload still carries the built-in root default instead of an empty config + expect(harness.updateMutateAsync).toHaveBeenCalledTimes(1) + expect(harness.updateMutateAsync).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + backend: 'dropbox', + config: expect.objectContaining({ + root: '/homesec', + token_env: 'DROPBOX_TOKEN', + }), + }), + }), + ) + }) + it('passes applyChanges=true and handles immediate runtime reload response', async () => { // Given: Storage update responds with runtime reload acceptance and apply-immediately enabled const harness = setupPage({ @@ -397,4 +470,72 @@ describe('StoragePage', () => { }), ) }) + + it('includes pending secret inputs in storage validation requests', async () => { + // Given: Backend metadata exposes a write-only secret field + setupPage({ + backends: [ + { + backend: 'local', + label: 'Local FS', + description: 'Store on local disk', + config_schema: {}, + fields: [ + { + name: 'root', + type: 'string', + required: true, + description: 'Root path', + default: './storage', + secret: false, + }, + { + name: 'access_token', + type: 'string', + required: false, + description: 'Optional token', + default: null, + secret: true, + }, + ], + secret_fields: ['access_token'], + }, + ], + }) + const user = userEvent.setup() + + // When: Operator enters a replacement secret and validates storage + await user.type(screen.getByLabelText('access_token'), 'replace-me') + await user.click(screen.getByRole('button', { name: 'Validate storage' })) + + // Then: Validation uses the pending secret overlay instead of the stale persisted config + expect(setupTestConnectionMutateAsyncMock).toHaveBeenCalledTimes(1) + expect(setupTestConnectionMutateAsyncMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'storage', + config: expect.objectContaining({ + root: './storage', + access_token: 'replace-me', + }), + }), + ) + }) + + it('keeps the active-backend badge pinned to persisted state until save', async () => { + // Given: Local storage is persisted as the active backend + setupPage() + const user = userEvent.setup() + const activeCard = screen.getByRole('heading', { name: 'Active backend' }).closest('section') + + if (!activeCard) { + throw new Error('Active backend card not found') + } + + // When: Operator selects Dropbox but does not save + await user.click(screen.getByRole('button', { name: 'Dropbox' })) + + // Then: The active-backend summary still reflects persisted local storage + expect(within(activeCard).getByText('Local FS')).toBeTruthy() + expect(within(activeCard).getByText('Backend id: local')).toBeTruthy() + }) }) diff --git a/ui/src/features/storage/StoragePage.tsx b/ui/src/features/storage/StoragePage.tsx index db19706e..402ec7f6 100644 --- a/ui/src/features/storage/StoragePage.tsx +++ b/ui/src/features/storage/StoragePage.tsx @@ -112,16 +112,35 @@ function buildConfigPatch( return patch } +function buildSecretPatch(secretInputs: Record): Record { + const patch: Record = {} + + for (const [key, value] of Object.entries(secretInputs)) { + if (value.trim().length === 0) { + continue + } + patch[key] = value + } + + return patch +} + function isSupportedStorageBackend(backend: string): backend is StorageBackend { return Object.prototype.hasOwnProperty.call(STORAGE_BACKENDS, backend) } -function defaultConfigForBackend(metadata: StorageBackendMetadata | null): Record { +function defaultConfigForBackend( + backend: string, + metadata: StorageBackendMetadata | null, +): Record { + const defaults: Record = isSupportedStorageBackend(backend) + ? cloneConfig(STORAGE_BACKENDS[backend].defaultConfig) + : {} + if (!metadata) { - return {} + return defaults } - const defaults: Record = {} for (const field of metadata.fields) { if (field.default === null || field.default === undefined) { continue @@ -209,6 +228,16 @@ export function StoragePage() { return selectedMetadata.secret_fields }, [selectedMetadata]) + const validationConfig = useMemo(() => { + if (!draft) { + return null + } + return { + ...draft.config, + ...buildSecretPatch(secretInputs), + } + }, [draft, secretInputs]) + const unauthorized = isUnauthorizedAPIError(storageQuery.error) || isUnauthorizedAPIError(storageBackendsQuery.error) @@ -249,12 +278,12 @@ export function StoragePage() { setTestResult(null) } - const selectedBackendOption = useMemo(() => { - if (!draft) { + const activeBackendOption = useMemo(() => { + if (!storageQuery.data) { return null } - return backendOptions.find((option) => option.backend === draft.backend) ?? null - }, [backendOptions, draft]) + return backendOptions.find((option) => option.backend === storageQuery.data.backend) ?? null + }, [backendOptions, storageQuery.data]) const selectedBackendForm = useMemo(() => { if (!draft) { return null @@ -323,13 +352,7 @@ export function StoragePage() { } const nextMetadata = backendMetadataByName.get(nextBackend) ?? null - const defaultFromMetadata = defaultConfigForBackend(nextMetadata) - const nextConfig = - Object.keys(defaultFromMetadata).length > 0 - ? defaultFromMetadata - : isSupportedStorageBackend(nextBackend) - ? STORAGE_BACKENDS[nextBackend].defaultConfig - : {} + const nextConfig = defaultConfigForBackend(nextBackend, nextMetadata) setStorageDraft({ backend: nextBackend, @@ -367,14 +390,7 @@ export function StoragePage() { ? cloneConfig(draft.config) : buildConfigPatch(baseConfig, draft.config) - const secretPatch: Record = {} - for (const [key, value] of Object.entries(secretInputs)) { - const trimmed = value.trim() - if (trimmed.length === 0) { - continue - } - secretPatch[key] = value - } + const secretPatch = buildSecretPatch(secretInputs) const nextConfigPatch: Record = { ...nonSecretPatch, @@ -481,7 +497,7 @@ export function StoragePage() {
- {selectedBackendOption?.label ?? storageQuery.data.backend} + {activeBackendOption?.label ?? storageQuery.data.backend}

Backend id: {storageQuery.data.backend}

@@ -549,6 +565,7 @@ export function StoragePage() { [field]: event.target.value, })) setActionFeedback(null) + setTestResult(null) }} /> @@ -574,7 +591,7 @@ export function StoragePage() { request={{ type: 'storage', backend: draft.backend, - config: draft.config, + config: validationConfig ?? draft.config, }} result={testResult} onResult={(result) => { From 418bda19c4248dec39a3ab12c680770592fb4b71 Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Fri, 3 Apr 2026 15:03:16 -0700 Subject: [PATCH 3/8] fix: stabilize make check in local envs --- src/homesec/api/routes/__init__.py | 13 ++++++++++++- src/homesec/api/routes/storage.py | 6 +----- tests/homesec/conftest.py | 25 +++++++++++++++++++++---- tests/homesec/test_api_routes.py | 5 ++++- tests/homesec/test_state_store.py | 15 ++------------- 5 files changed, 40 insertions(+), 24 deletions(-) diff --git a/src/homesec/api/routes/__init__.py b/src/homesec/api/routes/__init__.py index 34885419..9721ba3d 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, storage +from homesec.api.routes import ( + cameras, + clips, + config, + health, + media, + onvif, + runtime, + setup, + stats, + storage, +) def register_routes(app: FastAPI) -> None: diff --git a/src/homesec/api/routes/storage.py b/src/homesec/api/routes/storage.py index a989efc2..e6be061e 100644 --- a/src/homesec/api/routes/storage.py +++ b/src/homesec/api/routes/storage.py @@ -146,11 +146,7 @@ def _backend_metadata(backend: str) -> StorageBackendMetadata: 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) - } + required = {item for item in required_raw if isinstance(item, str)} fields: list[StorageFieldMetadata] = [] for field_name, field_schema in properties.items(): 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_routes.py b/tests/homesec/test_api_routes.py index 09f402a9..2ad2f6c8 100644 --- a/tests/homesec/test_api_routes.py +++ b/tests/homesec/test_api_routes.py @@ -1122,7 +1122,10 @@ def test_get_storage_redacts_sensitive_url_credentials(tmp_path) -> None: "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"}}, + "vlm": { + "backend": "openai", + "config": {"api_key_env": "OPENAI_API_KEY", "model": "gpt-4o"}, + }, "alert_policy": {"backend": "default", "config": {}}, } path = tmp_path / "config.yaml" 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: From 7a325ebaf2ceed5c6befea26aa4620cb959b46ae Mon Sep 17 00:00:00 2001 From: Lev Neiman Date: Sat, 4 Apr 2026 19:50:07 -0700 Subject: [PATCH 4/8] refactor: share storage backend metadata across setup and settings --- src/homesec/api/routes/__init__.py | 2 +- src/homesec/api/routes/storage.py | 14 +++- tests/homesec/test_api_bootstrap_matrix.py | 12 ++++ .../storage/StorageConfigForm.test.tsx | 51 +++++++++++++- .../settings/storage/StorageConfigForm.tsx | 26 +++++-- .../features/settings/storage/editorModel.ts | 69 ++++++++++++++++++ .../features/setup/steps/StorageStep.test.tsx | 70 ++++++++++++++++++- ui/src/features/setup/steps/StorageStep.tsx | 41 +++++++++-- ui/src/features/storage/StoragePage.tsx | 32 ++------- 9 files changed, 268 insertions(+), 49 deletions(-) create mode 100644 ui/src/features/settings/storage/editorModel.ts diff --git a/src/homesec/api/routes/__init__.py b/src/homesec/api/routes/__init__.py index 9721ba3d..d2cc246e 100644 --- a/src/homesec/api/routes/__init__.py +++ b/src/homesec/api/routes/__init__.py @@ -34,7 +34,7 @@ def register_routes(app: FastAPI) -> None: ) app.include_router( storage.router, - dependencies=[Depends(verify_api_key), Depends(require_normal_mode)], + dependencies=[Depends(verify_api_key)], ) app.include_router( cameras.router, diff --git a/src/homesec/api/routes/storage.py b/src/homesec/api/routes/storage.py index e6be061e..aacab2ef 100644 --- a/src/homesec/api/routes/storage.py +++ b/src/homesec/api/routes/storage.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, status from pydantic import BaseModel -from homesec.api.dependencies import get_homesec_app +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.config.errors import StorageConfigInvalidError, StorageMutationError @@ -175,14 +175,22 @@ def _backend_metadata(backend: str) -> StorageBackendMetadata: ) -@router.get("/api/v1/storage", response_model=StorageResponse) +@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) +@router.patch( + "/api/v1/storage", + response_model=StorageChangeResponse, + dependencies=[Depends(require_normal_mode)], +) async def patch_storage( payload: StorageUpdate, apply_changes: bool = False, 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/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 (