Publish curated LumaOps source from c85ee9ef8f1454f5c305398902afbb9e6738de40
Managed validation / full (push) Failing after 8s

This commit is contained in:
Public Source Publisher committed 2026-09-30 00:26:45 +00:00
1 parent 91f8b3e3c1
commit def7920856
2271 files changed
+501225 -501178

No files matched your search

+6 -6
View File
@@ -1,6 +1,6 @@
# LumaOps backend
FastAPI backend and connector runtime for LumaOps. The package is licensed
under GPL-2.0-or-later and is built into the single production image from the
repository root.
# LumaOps backend
FastAPI backend and connector runtime for LumaOps. The package is licensed
under GPL-2.0-or-later and is built into the single production image from the
repository root.
+56 -56
View File
@@ -1,56 +1,56 @@
[build-system]
requires = ["setuptools==80.9.0", "wheel==0.45.1"]
build-backend = "setuptools.build_meta"
[project]
name = "lumaops-backend"
version = "0.1.0"
description = "Local-first OpenRGB control plane for LumaOps"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "GPL-2.0-or-later" }
dependencies = [
"cryptography==45.0.3",
"fastapi==0.115.12",
"httpx==0.28.1",
"pydantic==2.11.5",
"pydantic-settings==2.9.1",
"python-multipart==0.0.20",
"tzdata==2026.3",
"uvicorn[standard]==0.34.3",
]
[project.optional-dependencies]
dev = [
"mypy==1.16.0",
"pytest==8.4.0",
"pytest-asyncio==1.0.0",
"ruff==0.11.12",
]
[project.scripts]
lumaops = "lumaops_backend.main:run"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
lumaops_backend = ["migrations/*.sql"]
[tool.pytest.ini_options]
addopts = "-q"
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "ASYNC", "S"]
ignore = ["E501", "S101", "S104", "S608"]
[tool.mypy]
python_version = "3.12"
strict = true
packages = ["lumaops_backend"]
[build-system]
requires = ["setuptools==80.9.0", "wheel==0.45.1"]
build-backend = "setuptools.build_meta"
[project]
name = "lumaops-backend"
version = "0.1.0"
description = "Local-first OpenRGB control plane for LumaOps"
readme = "README.md"
requires-python = ">=3.12"
license = { text = "GPL-2.0-or-later" }
dependencies = [
"cryptography==45.0.3",
"fastapi==0.115.12",
"httpx==0.28.1",
"pydantic==2.11.5",
"pydantic-settings==2.9.1",
"python-multipart==0.0.20",
"tzdata==2026.3",
"uvicorn[standard]==0.34.3",
]
[project.optional-dependencies]
dev = [
"mypy==1.16.0",
"pytest==8.4.0",
"pytest-asyncio==1.0.0",
"ruff==0.11.12",
]
[project.scripts]
lumaops = "lumaops_backend.main:run"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
lumaops_backend = ["migrations/*.sql"]
[tool.pytest.ini_options]
addopts = "-q"
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "ASYNC", "S"]
ignore = ["E501", "S101", "S104", "S608"]
[tool.mypy]
python_version = "3.12"
strict = true
packages = ["lumaops_backend"]
@@ -1,3 +1,3 @@
"""LumaOps backend package."""
__version__ = "0.1.0"
"""LumaOps backend package."""
__version__ = "0.1.0"
@@ -1 +1 @@
"""LumaOps API package."""
"""LumaOps API package."""
File diff suppressed because it is too large. Load diff
+100 -100
View File
@@ -1,100 +1,100 @@
"""Application dependency container and lifecycle orchestration."""
from __future__ import annotations
from dataclasses import dataclass
from .auth import AuthManager
from .config import Settings
from .connectors.mock import MockOpenRGBAdapter
from .connectors.openrgb import OpenRGBAdapter
from .connectors.registry import ConnectorRegistry
from .database import Database
from .events import EventBus
from .repository import Repository
from .secrets import SecretStore
from .services.automations import AutomationService
from .services.backups import BackupService
from .services.commands import CommandService
from .services.diagnostics import DiagnosticsService
from .services.health import HealthService
from .services.inventory import InventoryService
from .services.resources import ResourceService
from .services.scenes import SceneService
from .services.setup import SetupService
@dataclass(slots=True)
class AppContext:
settings: Settings
database: Database
repository: Repository
secrets: SecretStore
auth: AuthManager
events: EventBus
registry: ConnectorRegistry
inventory: InventoryService
commands: CommandService
resources: ResourceService
scenes: SceneService
automations: AutomationService
setup: SetupService
health: HealthService
backups: BackupService
diagnostics: DiagnosticsService
async def start(self) -> None:
self.database.initialize()
await self.inventory.initialize()
# Perform one complete inventory and desired-state reconciliation before the
# connector monitor starts. Starting both paths concurrently can produce
# duplicate restores and briefly expose stale hardware state as ready.
await self.inventory.sync_all()
await self.registry.start()
await self.automations.start()
async def stop(self) -> None:
await self.automations.stop()
await self.registry.stop()
def build_context(settings: Settings) -> AppContext:
for path in (
settings.config_dir,
settings.openrgb_config_dir,
settings.data_dir,
settings.logs_dir,
):
path.mkdir(parents=True, exist_ok=True)
database = Database(settings)
database.initialize()
connector = (
MockOpenRGBAdapter() if settings.connector_mode == "mock" else OpenRGBAdapter(settings)
)
registry = ConnectorRegistry([connector])
events = EventBus()
inventory = InventoryService(database, registry, events)
commands = CommandService(settings, database, registry, inventory, events)
inventory.set_reconcile_callback(commands.restore_connector)
resources = ResourceService(database)
scenes = SceneService(database, commands, inventory, resources)
automations = AutomationService(database, commands, scenes)
health = HealthService(settings, database, registry)
return AppContext(
settings=settings,
database=database,
repository=Repository(database),
secrets=SecretStore(settings),
auth=AuthManager(settings),
events=events,
registry=registry,
inventory=inventory,
commands=commands,
resources=resources,
scenes=scenes,
automations=automations,
setup=SetupService(settings, database, registry),
health=health,
backups=BackupService(database),
diagnostics=DiagnosticsService(settings, database, health, inventory),
)
"""Application dependency container and lifecycle orchestration."""
from __future__ import annotations
from dataclasses import dataclass
from .auth import AuthManager
from .config import Settings
from .connectors.mock import MockOpenRGBAdapter
from .connectors.openrgb import OpenRGBAdapter
from .connectors.registry import ConnectorRegistry
from .database import Database
from .events import EventBus
from .repository import Repository
from .secrets import SecretStore
from .services.automations import AutomationService
from .services.backups import BackupService
from .services.commands import CommandService
from .services.diagnostics import DiagnosticsService
from .services.health import HealthService
from .services.inventory import InventoryService
from .services.resources import ResourceService
from .services.scenes import SceneService
from .services.setup import SetupService
@dataclass(slots=True)
class AppContext:
settings: Settings
database: Database
repository: Repository
secrets: SecretStore
auth: AuthManager
events: EventBus
registry: ConnectorRegistry
inventory: InventoryService
commands: CommandService
resources: ResourceService
scenes: SceneService
automations: AutomationService
setup: SetupService
health: HealthService
backups: BackupService
diagnostics: DiagnosticsService
async def start(self) -> None:
self.database.initialize()
await self.inventory.initialize()
# Perform one complete inventory and desired-state reconciliation before the
# connector monitor starts. Starting both paths concurrently can produce
# duplicate restores and briefly expose stale hardware state as ready.
await self.inventory.sync_all()
await self.registry.start()
await self.automations.start()
async def stop(self) -> None:
await self.automations.stop()
await self.registry.stop()
def build_context(settings: Settings) -> AppContext:
for path in (
settings.config_dir,
settings.openrgb_config_dir,
settings.data_dir,
settings.logs_dir,
):
path.mkdir(parents=True, exist_ok=True)
database = Database(settings)
database.initialize()
connector = (
MockOpenRGBAdapter() if settings.connector_mode == "mock" else OpenRGBAdapter(settings)
)
registry = ConnectorRegistry([connector])
events = EventBus()
inventory = InventoryService(database, registry, events)
commands = CommandService(settings, database, registry, inventory, events)
inventory.set_reconcile_callback(commands.restore_connector)
resources = ResourceService(database)
scenes = SceneService(database, commands, inventory, resources)
automations = AutomationService(database, commands, scenes)
health = HealthService(settings, database, registry)
return AppContext(
settings=settings,
database=database,
repository=Repository(database),
secrets=SecretStore(settings),
auth=AuthManager(settings),
events=events,
registry=registry,
inventory=inventory,
commands=commands,
resources=resources,
scenes=scenes,
automations=automations,
setup=SetupService(settings, database, registry),
health=health,
backups=BackupService(database),
diagnostics=DiagnosticsService(settings, database, health, inventory),
)
+106 -106
View File
@@ -1,106 +1,106 @@
"""Optional local-admin authentication with signed cookies and CSRF checks."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import secrets
import time
from typing import Any
from fastapi import Request, Response
from .config import Settings
from .errors import LumaOpsError
SESSION_COOKIE = "lumaops_session"
CSRF_COOKIE = "lumaops_csrf"
SESSION_MAX_AGE = 12 * 60 * 60
class AuthManager:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self._key = (settings.admin_token or "auth-disabled").encode("utf-8")
def require(self, request: Request) -> str:
if not self.settings.auth_enabled:
return "local-admin"
authorization = request.headers.get("authorization", "")
if authorization.startswith("Bearer ") and self._valid_token(authorization[7:]):
return "api-token"
session = request.cookies.get(SESSION_COOKIE)
if not session or not self._verify_session(session):
raise LumaOpsError("authentication_required", "Aanmelding is vereist.", 401)
if request.method not in {"GET", "HEAD", "OPTIONS"}:
csrf_cookie = request.cookies.get(CSRF_COOKIE, "")
csrf_header = request.headers.get("x-csrf-token", "")
if not csrf_cookie or not hmac.compare_digest(csrf_cookie, csrf_header):
raise LumaOpsError("csrf_failed", "CSRF-validatie is mislukt.", 403)
return "local-admin"
def login(self, token: str, response: Response) -> dict[str, Any]:
if not self.settings.auth_enabled:
return {"authenticated": True, "auth_enabled": False}
if not self._valid_token(token):
raise LumaOpsError("invalid_credentials", "Ongeldige beheertoken.", 401)
csrf = secrets.token_urlsafe(32)
session = self._create_session()
response.set_cookie(
SESSION_COOKIE,
session,
max_age=SESSION_MAX_AGE,
httponly=True,
secure=self.settings.secure_cookies,
samesite="strict",
path="/",
)
response.set_cookie(
CSRF_COOKIE,
csrf,
max_age=SESSION_MAX_AGE,
httponly=False,
secure=self.settings.secure_cookies,
samesite="strict",
path="/",
)
return {"authenticated": True, "auth_enabled": True, "csrf_token": csrf}
@staticmethod
def logout(response: Response) -> None:
response.delete_cookie(SESSION_COOKIE, path="/")
response.delete_cookie(CSRF_COOKIE, path="/")
def _valid_token(self, candidate: str) -> bool:
configured = self.settings.admin_token
return configured is not None and hmac.compare_digest(
candidate.encode("utf-8"), configured.encode("utf-8")
)
def _create_session(self) -> str:
payload = json.dumps(
{"sub": "local-admin", "iat": int(time.time()), "nonce": secrets.token_hex(16)},
separators=(",", ":"),
).encode("utf-8")
encoded = base64.urlsafe_b64encode(payload).rstrip(b"=")
signature = hmac.new(self._key, encoded, hashlib.sha256).digest()
return f"{encoded.decode()}.{base64.urlsafe_b64encode(signature).decode().rstrip('=')}"
def _verify_session(self, value: str) -> bool:
try:
encoded, signature = value.split(".", 1)
expected = hmac.new(self._key, encoded.encode("ascii"), hashlib.sha256).digest()
actual = base64.urlsafe_b64decode(signature + "=" * (-len(signature) % 4))
if not hmac.compare_digest(expected, actual):
return False
payload = json.loads(
base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)).decode("utf-8")
)
return (
payload.get("sub") == "local-admin"
and int(payload["iat"]) + SESSION_MAX_AGE > time.time()
)
except (ValueError, KeyError, TypeError, json.JSONDecodeError):
return False
"""Optional local-admin authentication with signed cookies and CSRF checks."""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import secrets
import time
from typing import Any
from fastapi import Request, Response
from .config import Settings
from .errors import LumaOpsError
SESSION_COOKIE = "lumaops_session"
CSRF_COOKIE = "lumaops_csrf"
SESSION_MAX_AGE = 12 * 60 * 60
class AuthManager:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self._key = (settings.admin_token or "auth-disabled").encode("utf-8")
def require(self, request: Request) -> str:
if not self.settings.auth_enabled:
return "local-admin"
authorization = request.headers.get("authorization", "")
if authorization.startswith("Bearer ") and self._valid_token(authorization[7:]):
return "api-token"
session = request.cookies.get(SESSION_COOKIE)
if not session or not self._verify_session(session):
raise LumaOpsError("authentication_required", "Aanmelding is vereist.", 401)
if request.method not in {"GET", "HEAD", "OPTIONS"}:
csrf_cookie = request.cookies.get(CSRF_COOKIE, "")
csrf_header = request.headers.get("x-csrf-token", "")
if not csrf_cookie or not hmac.compare_digest(csrf_cookie, csrf_header):
raise LumaOpsError("csrf_failed", "CSRF-validatie is mislukt.", 403)
return "local-admin"
def login(self, token: str, response: Response) -> dict[str, Any]:
if not self.settings.auth_enabled:
return {"authenticated": True, "auth_enabled": False}
if not self._valid_token(token):
raise LumaOpsError("invalid_credentials", "Ongeldige beheertoken.", 401)
csrf = secrets.token_urlsafe(32)
session = self._create_session()
response.set_cookie(
SESSION_COOKIE,
session,
max_age=SESSION_MAX_AGE,
httponly=True,
secure=self.settings.secure_cookies,
samesite="strict",
path="/",
)
response.set_cookie(
CSRF_COOKIE,
csrf,
max_age=SESSION_MAX_AGE,
httponly=False,
secure=self.settings.secure_cookies,
samesite="strict",
path="/",
)
return {"authenticated": True, "auth_enabled": True, "csrf_token": csrf}
@staticmethod
def logout(response: Response) -> None:
response.delete_cookie(SESSION_COOKIE, path="/")
response.delete_cookie(CSRF_COOKIE, path="/")
def _valid_token(self, candidate: str) -> bool:
configured = self.settings.admin_token
return configured is not None and hmac.compare_digest(
candidate.encode("utf-8"), configured.encode("utf-8")
)
def _create_session(self) -> str:
payload = json.dumps(
{"sub": "local-admin", "iat": int(time.time()), "nonce": secrets.token_hex(16)},
separators=(",", ":"),
).encode("utf-8")
encoded = base64.urlsafe_b64encode(payload).rstrip(b"=")
signature = hmac.new(self._key, encoded, hashlib.sha256).digest()
return f"{encoded.decode()}.{base64.urlsafe_b64encode(signature).decode().rstrip('=')}"
def _verify_session(self, value: str) -> bool:
try:
encoded, signature = value.split(".", 1)
expected = hmac.new(self._key, encoded.encode("ascii"), hashlib.sha256).digest()
actual = base64.urlsafe_b64decode(signature + "=" * (-len(signature) % 4))
if not hmac.compare_digest(expected, actual):
return False
payload = json.loads(
base64.urlsafe_b64decode(encoded + "=" * (-len(encoded) % 4)).decode("utf-8")
)
return (
payload.get("sub") == "local-admin"
and int(payload["iat"]) + SESSION_MAX_AGE > time.time()
)
except (ValueError, KeyError, TypeError, json.JSONDecodeError):
return False
+117 -117
View File
@@ -1,117 +1,117 @@
"""Application configuration with deployment-safe validation."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic import Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Runtime configuration sourced from environment variables."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
app_name: str = "LumaOps"
app_version: str = "0.1.0"
app_host: str = "0.0.0.0"
app_port: int = Field(default=8080, ge=1, le=65535)
app_base_url: str = ""
environment: Literal["development", "test", "production"] = Field(
default="production", alias="LUMAOPS_ENV"
)
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
timezone: str = Field(default="Europe/Brussels", alias="TZ")
openrgb_host: str = "127.0.0.1"
openrgb_port: int = Field(default=6742, ge=1, le=65535)
openrgb_config_dir: Path = Path("/config/openrgb")
openrgb_connect_timeout: float = Field(default=2.0, gt=0, le=30)
openrgb_command_timeout: float = Field(default=5.0, gt=0, le=60)
openrgb_max_packet_size: int = Field(default=16 * 1024 * 1024, ge=1024, le=64 * 1024 * 1024)
config_dir: Path = Path("/config/lumaops")
data_dir: Path = Path("/data")
logs_dir: Path = Path("/logs")
static_dir: Path = Path("/opt/lumaops/static")
database_url: str = "sqlite:////data/lumaops.db"
secret_key: str | None = Field(default=None, alias="LUMAOPS_SECRET_KEY")
enable_network_discovery: bool = True
enable_home_assistant: bool = False
enable_wled: bool = False
connector_mode: Literal["openrgb", "mock"] = "openrgb"
auth_enabled: bool = True
admin_token: str | None = Field(default=None, alias="LUMAOPS_ADMIN_TOKEN")
external_access: bool = False
trusted_proxies: str = ""
cors_origins: str = ""
secure_cookies: bool = True
command_rate_per_second: float = Field(default=10.0, gt=0, le=60)
realtime_rate_per_second: float = Field(default=30.0, gt=0, le=120)
command_timeout_seconds: float = Field(default=10.0, gt=0, le=120)
health_retention_days: int = Field(default=30, ge=1, le=3650)
@field_validator("openrgb_host")
@classmethod
def validate_openrgb_host(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("OPENRGB_HOST mag niet leeg zijn")
return value
@model_validator(mode="after")
def validate_security_invariants(self) -> Settings:
if self.environment == "production" and self.connector_mode == "mock":
raise ValueError("MockOpenRGBAdapter is verboden in productie")
if self.external_access and not self.auth_enabled:
raise ValueError("AUTH_ENABLED is verplicht wanneer EXTERNAL_ACCESS=true")
if self.environment == "production" and self.app_host not in {
"127.0.0.1",
"::1",
"localhost",
} and not self.auth_enabled:
raise ValueError("AUTH_ENABLED is verplicht bij een niet-lokale productiebind")
if self.auth_enabled:
token = (self.admin_token or "").strip()
placeholders = {
"replace-with-a-long-random-token",
"change-me",
"changeme",
}
if len(token) < 32 or token.lower() in placeholders:
raise ValueError(
"LUMAOPS_ADMIN_TOKEN moet minstens 32 tekens lang en niet voorspelbaar zijn"
)
return self
@property
def database_path(self) -> Path:
prefix = "sqlite:///"
if not self.database_url.startswith(prefix):
raise ValueError("LumaOps MVP ondersteunt uitsluitend sqlite:/// DATABASE_URL waarden")
raw = self.database_url[len(prefix) :]
return Path(raw if raw else "lumaops.db").expanduser().resolve()
@property
def trusted_proxy_list(self) -> tuple[str, ...]:
return tuple(part.strip() for part in self.trusted_proxies.split(",") if part.strip())
@property
def cors_origin_list(self) -> tuple[str, ...]:
return tuple(part.strip() for part in self.cors_origins.split(",") if part.strip())
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
"""Application configuration with deployment-safe validation."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Literal
from pydantic import Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Runtime configuration sourced from environment variables."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
app_name: str = "LumaOps"
app_version: str = "0.1.0"
app_host: str = "0.0.0.0"
app_port: int = Field(default=8080, ge=1, le=65535)
app_base_url: str = ""
environment: Literal["development", "test", "production"] = Field(
default="production", alias="LUMAOPS_ENV"
)
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
timezone: str = Field(default="Europe/Brussels", alias="TZ")
openrgb_host: str = "127.0.0.1"
openrgb_port: int = Field(default=6742, ge=1, le=65535)
openrgb_config_dir: Path = Path("/config/openrgb")
openrgb_connect_timeout: float = Field(default=2.0, gt=0, le=30)
openrgb_command_timeout: float = Field(default=5.0, gt=0, le=60)
openrgb_max_packet_size: int = Field(default=16 * 1024 * 1024, ge=1024, le=64 * 1024 * 1024)
config_dir: Path = Path("/config/lumaops")
data_dir: Path = Path("/data")
logs_dir: Path = Path("/logs")
static_dir: Path = Path("/opt/lumaops/static")
database_url: str = "sqlite:////data/lumaops.db"
secret_key: str | None = Field(default=None, alias="LUMAOPS_SECRET_KEY")
enable_network_discovery: bool = True
enable_home_assistant: bool = False
enable_wled: bool = False
connector_mode: Literal["openrgb", "mock"] = "openrgb"
auth_enabled: bool = True
admin_token: str | None = Field(default=None, alias="LUMAOPS_ADMIN_TOKEN")
external_access: bool = False
trusted_proxies: str = ""
cors_origins: str = ""
secure_cookies: bool = True
command_rate_per_second: float = Field(default=10.0, gt=0, le=60)
realtime_rate_per_second: float = Field(default=30.0, gt=0, le=120)
command_timeout_seconds: float = Field(default=10.0, gt=0, le=120)
health_retention_days: int = Field(default=30, ge=1, le=3650)
@field_validator("openrgb_host")
@classmethod
def validate_openrgb_host(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("OPENRGB_HOST mag niet leeg zijn")
return value
@model_validator(mode="after")
def validate_security_invariants(self) -> Settings:
if self.environment == "production" and self.connector_mode == "mock":
raise ValueError("MockOpenRGBAdapter is verboden in productie")
if self.external_access and not self.auth_enabled:
raise ValueError("AUTH_ENABLED is verplicht wanneer EXTERNAL_ACCESS=true")
if self.environment == "production" and self.app_host not in {
"127.0.0.1",
"::1",
"localhost",
} and not self.auth_enabled:
raise ValueError("AUTH_ENABLED is verplicht bij een niet-lokale productiebind")
if self.auth_enabled:
token = (self.admin_token or "").strip()
placeholders = {
"replace-with-a-long-random-token",
"change-me",
"changeme",
}
if len(token) < 32 or token.lower() in placeholders:
raise ValueError(
"LUMAOPS_ADMIN_TOKEN moet minstens 32 tekens lang en niet voorspelbaar zijn"
)
return self
@property
def database_path(self) -> Path:
prefix = "sqlite:///"
if not self.database_url.startswith(prefix):
raise ValueError("LumaOps MVP ondersteunt uitsluitend sqlite:/// DATABASE_URL waarden")
raw = self.database_url[len(prefix) :]
return Path(raw if raw else "lumaops.db").expanduser().resolve()
@property
def trusted_proxy_list(self) -> tuple[str, ...]:
return tuple(part.strip() for part in self.trusted_proxies.split(",") if part.strip())
@property
def cors_origin_list(self) -> tuple[str, ...]:
return tuple(part.strip() for part in self.cors_origins.split(",") if part.strip())
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings()
@@ -1 +1 @@
"""Connector implementations and normalized contracts."""
"""Connector implementations and normalized contracts."""
@@ -1,165 +1,165 @@
"""Brand-neutral connector contract used by every application service."""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable
from datetime import datetime
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, Field, field_validator
from ..errors import ConnectorError
class HealthStatus(StrEnum):
HEALTHY = "healthy"
DEGRADED = "degraded"
UNHEALTHY = "unhealthy"
UNKNOWN = "unknown"
class ConnectorHealth(BaseModel):
status: HealthStatus
message: str
connected: bool = False
latency_ms: float | None = None
last_success_at: datetime | None = None
details: dict[str, Any] = Field(default_factory=dict)
class DeviceCapabilities(BaseModel):
power: bool = False
restore: bool = False
rgb: bool = False
brightness: bool = False
color_temperature: bool = False
effect: bool = False
speed: bool = False
direction: bool = False
multiple_colors: bool = False
per_zone: bool = False
per_segment: bool = False
per_led: bool = False
profiles: bool = False
readable_state: bool = True
max_leds: int = 0
min_brightness: int = 0
max_brightness: int = 100
min_speed: int | None = None
max_speed: int | None = None
class RGBColor(BaseModel):
red: int = Field(ge=0, le=255)
green: int = Field(ge=0, le=255)
blue: int = Field(ge=0, le=255)
@classmethod
def from_hex(cls, value: str) -> RGBColor:
value = value.removeprefix("#")
if len(value) != 6:
raise ValueError("Een RGB-kleur vereist exact zes hexadecimale tekens")
try:
return cls(red=int(value[0:2], 16), green=int(value[2:4], 16), blue=int(value[4:6], 16))
except ValueError as exc:
raise ValueError("Ongeldige hexadecimale RGB-kleur") from exc
def to_hex(self) -> str:
return f"#{self.red:02X}{self.green:02X}{self.blue:02X}"
class DeviceState(BaseModel):
power: bool | None = None
brightness: int | None = Field(default=None, ge=0, le=100)
colors: list[RGBColor] | None = None
mode: str | None = None
mode_index: int | None = Field(default=None, ge=0)
speed: int | None = Field(default=None, ge=0)
direction: int | None = Field(default=None, ge=0, le=5)
zone_index: int | None = Field(default=None, ge=0)
led_index: int | None = Field(default=None, ge=0)
transition_ms: int | None = Field(default=None, ge=0, le=60_000)
@field_validator("colors")
@classmethod
def limit_colors(cls, value: list[RGBColor] | None) -> list[RGBColor] | None:
if value is not None and len(value) > 65_535:
raise ValueError("Te veel kleuren in één opdracht")
return value
class ConnectorDevice(BaseModel):
external_id: str
fingerprint: str
name: str
vendor: str | None = None
model: str | None = None
serial: str | None = None
location: str | None = None
ip_address: str | None = None
firmware_version: str | None = None
controller_index: int | None = None
source: str
device_type: str = "unknown"
capabilities: DeviceCapabilities
state: DeviceState = Field(default_factory=DeviceState)
zones: list[dict[str, Any]] = Field(default_factory=list)
modes: list[dict[str, Any]] = Field(default_factory=list)
led_count: int = 0
online: bool = True
experimental: bool = False
metadata: dict[str, Any] = Field(default_factory=dict)
InventoryCallback = Callable[[str], Awaitable[Any]]
class Connector(ABC):
id: str
kind: str
@abstractmethod
async def start(self) -> None: ...
@abstractmethod
async def stop(self) -> None: ...
@abstractmethod
async def test_connection(self) -> ConnectorHealth: ...
@abstractmethod
async def health(self) -> ConnectorHealth: ...
@abstractmethod
async def discover(self) -> list[ConnectorDevice]: ...
@abstractmethod
async def inventory(self) -> list[ConnectorDevice]: ...
async def rescan(self) -> None:
"""Request fresh discovery when a connector has no specialized rescan command."""
await self.inventory()
@abstractmethod
async def get_state(self, external_id: str) -> DeviceState: ...
@abstractmethod
async def set_state(self, external_id: str, state: DeviceState) -> DeviceState: ...
async def resize_zone(self, external_id: str, zone_index: int, new_size: int) -> None:
"""Resize an addressable zone when the connector supports it."""
del external_id, zone_index, new_size
raise ConnectorError(
"zone_resize_unsupported",
"Deze connector ondersteunt geen wijzigbare zones.",
status_code=409,
retryable=False,
)
@abstractmethod
def configuration_schema(self) -> dict[str, Any]: ...
@abstractmethod
def set_inventory_callback(self, callback: InventoryCallback | None) -> None: ...
"""Brand-neutral connector contract used by every application service."""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable
from datetime import datetime
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, Field, field_validator
from ..errors import ConnectorError
class HealthStatus(StrEnum):
HEALTHY = "healthy"
DEGRADED = "degraded"
UNHEALTHY = "unhealthy"
UNKNOWN = "unknown"
class ConnectorHealth(BaseModel):
status: HealthStatus
message: str
connected: bool = False
latency_ms: float | None = None
last_success_at: datetime | None = None
details: dict[str, Any] = Field(default_factory=dict)
class DeviceCapabilities(BaseModel):
power: bool = False
restore: bool = False
rgb: bool = False
brightness: bool = False
color_temperature: bool = False
effect: bool = False
speed: bool = False
direction: bool = False
multiple_colors: bool = False
per_zone: bool = False
per_segment: bool = False
per_led: bool = False
profiles: bool = False
readable_state: bool = True
max_leds: int = 0
min_brightness: int = 0
max_brightness: int = 100
min_speed: int | None = None
max_speed: int | None = None
class RGBColor(BaseModel):
red: int = Field(ge=0, le=255)
green: int = Field(ge=0, le=255)
blue: int = Field(ge=0, le=255)
@classmethod
def from_hex(cls, value: str) -> RGBColor:
value = value.removeprefix("#")
if len(value) != 6:
raise ValueError("Een RGB-kleur vereist exact zes hexadecimale tekens")
try:
return cls(red=int(value[0:2], 16), green=int(value[2:4], 16), blue=int(value[4:6], 16))
except ValueError as exc:
raise ValueError("Ongeldige hexadecimale RGB-kleur") from exc
def to_hex(self) -> str:
return f"#{self.red:02X}{self.green:02X}{self.blue:02X}"
class DeviceState(BaseModel):
power: bool | None = None
brightness: int | None = Field(default=None, ge=0, le=100)
colors: list[RGBColor] | None = None
mode: str | None = None
mode_index: int | None = Field(default=None, ge=0)
speed: int | None = Field(default=None, ge=0)
direction: int | None = Field(default=None, ge=0, le=5)
zone_index: int | None = Field(default=None, ge=0)
led_index: int | None = Field(default=None, ge=0)
transition_ms: int | None = Field(default=None, ge=0, le=60_000)
@field_validator("colors")
@classmethod
def limit_colors(cls, value: list[RGBColor] | None) -> list[RGBColor] | None:
if value is not None and len(value) > 65_535:
raise ValueError("Te veel kleuren in één opdracht")
return value
class ConnectorDevice(BaseModel):
external_id: str
fingerprint: str
name: str
vendor: str | None = None
model: str | None = None
serial: str | None = None
location: str | None = None
ip_address: str | None = None
firmware_version: str | None = None
controller_index: int | None = None
source: str
device_type: str = "unknown"
capabilities: DeviceCapabilities
state: DeviceState = Field(default_factory=DeviceState)
zones: list[dict[str, Any]] = Field(default_factory=list)
modes: list[dict[str, Any]] = Field(default_factory=list)
led_count: int = 0
online: bool = True
experimental: bool = False
metadata: dict[str, Any] = Field(default_factory=dict)
InventoryCallback = Callable[[str], Awaitable[Any]]
class Connector(ABC):
id: str
kind: str
@abstractmethod
async def start(self) -> None: ...
@abstractmethod
async def stop(self) -> None: ...
@abstractmethod
async def test_connection(self) -> ConnectorHealth: ...
@abstractmethod
async def health(self) -> ConnectorHealth: ...
@abstractmethod
async def discover(self) -> list[ConnectorDevice]: ...
@abstractmethod
async def inventory(self) -> list[ConnectorDevice]: ...
async def rescan(self) -> None:
"""Request fresh discovery when a connector has no specialized rescan command."""
await self.inventory()
@abstractmethod
async def get_state(self, external_id: str) -> DeviceState: ...
@abstractmethod
async def set_state(self, external_id: str, state: DeviceState) -> DeviceState: ...
async def resize_zone(self, external_id: str, zone_index: int, new_size: int) -> None:
"""Resize an addressable zone when the connector supports it."""
del external_id, zone_index, new_size
raise ConnectorError(
"zone_resize_unsupported",
"Deze connector ondersteunt geen wijzigbare zones.",
status_code=409,
retryable=False,
)
@abstractmethod
def configuration_schema(self) -> dict[str, Any]: ...
@abstractmethod
def set_inventory_callback(self, callback: InventoryCallback | None) -> None: ...
@@ -1,214 +1,214 @@
"""Deterministic connector for tests and explicit non-production demos."""
from __future__ import annotations
from copy import deepcopy
from datetime import UTC, datetime
from typing import Any
from ..errors import ConnectorError
from .base import (
Connector,
ConnectorDevice,
ConnectorHealth,
DeviceCapabilities,
DeviceState,
HealthStatus,
InventoryCallback,
RGBColor,
)
class MockOpenRGBAdapter(Connector):
id = "openrgb-mock"
kind = "mock-openrgb"
def __init__(self) -> None:
self._started = False
self._callback: InventoryCallback | None = None
self._devices = {
"mock-mainboard": ConnectorDevice(
external_id="mock-mainboard",
fingerprint="mock-mainboard",
name="Aurora Mainboard",
vendor="LumaOps Lab",
model="Virtual RGB Controller",
serial="MOCK-001",
location="mock://mainboard",
firmware_version="1.0",
controller_index=0,
source="mock",
device_type="motherboard",
capabilities=DeviceCapabilities(
power=True,
restore=True,
rgb=True,
brightness=True,
effect=True,
speed=True,
multiple_colors=True,
per_zone=True,
per_led=True,
profiles=True,
max_leds=12,
min_speed=1,
max_speed=10,
),
state=DeviceState(
power=True,
brightness=72,
colors=[RGBColor(red=86, green=92, blue=255)] * 12,
mode="Static",
mode_index=0,
speed=5,
),
zones=[
{
"index": 0,
"name": "Mainboard",
"type": 1,
"led_count": 8,
"leds_min": 8,
"leds_max": 8,
},
{
"index": 1,
"name": "ARGB header",
"type": 1,
"led_count": 4,
"leds_min": 0,
"leds_max": 120,
},
],
modes=[
{"index": 0, "name": "Static", "brightness": True},
{"index": 1, "name": "Breathing", "speed_min": 1, "speed_max": 10},
{"index": 2, "name": "Rainbow", "speed_min": 1, "speed_max": 10},
],
led_count=12,
),
"mock-desk-light": ConnectorDevice(
external_id="mock-desk-light",
fingerprint="mock-desk-light",
name="Bureaulamp",
vendor="WLED",
model="ESP32 strip",
serial="AA:BB:CC:DD:EE:01",
location="mock://desk",
ip_address="192.0.2.42",
firmware_version="0.15.0",
controller_index=1,
source="mock",
device_type="light",
capabilities=DeviceCapabilities(
power=True,
restore=True,
rgb=True,
brightness=True,
effect=True,
multiple_colors=True,
per_zone=True,
max_leds=30,
),
state=DeviceState(
power=False,
brightness=40,
colors=[RGBColor(red=255, green=154, blue=92)],
mode="Solid",
),
zones=[
{
"index": 0,
"name": "Desk",
"type": 1,
"led_count": 30,
"leds_min": 30,
"leds_max": 30,
}
],
modes=[{"index": 0, "name": "Solid"}, {"index": 1, "name": "Colorloop"}],
led_count=30,
),
}
async def start(self) -> None:
self._started = True
async def stop(self) -> None:
self._started = False
def set_inventory_callback(self, callback: InventoryCallback | None) -> None:
self._callback = callback
async def test_connection(self) -> ConnectorHealth:
return await self.health()
async def health(self) -> ConnectorHealth:
return ConnectorHealth(
status=HealthStatus.HEALTHY if self._started else HealthStatus.DEGRADED,
message="Mockadapter actief — geen echte hardware."
if self._started
else "Mockadapter gestopt.",
connected=self._started,
last_success_at=datetime.now(UTC) if self._started else None,
details={"mock": True, "device_count": len(self._devices)},
)
async def discover(self) -> list[ConnectorDevice]:
return await self.inventory()
async def inventory(self) -> list[ConnectorDevice]:
return [deepcopy(device) for device in self._devices.values()]
async def get_state(self, external_id: str) -> DeviceState:
return deepcopy(self._devices[external_id].state)
async def set_state(self, external_id: str, state: DeviceState) -> DeviceState:
current = self._devices[external_id].state
patch = state.model_dump(exclude_none=True)
merged = current.model_dump()
merged.update(patch)
updated = DeviceState.model_validate(merged)
if updated.power is False:
updated.colors = [RGBColor(red=0, green=0, blue=0)] * max(
1, self._devices[external_id].led_count
)
elif updated.power is True and not updated.colors:
updated.colors = [RGBColor(red=255, green=255, blue=255)]
self._devices[external_id].state = updated
return deepcopy(updated)
async def resize_zone(self, external_id: str, zone_index: int, new_size: int) -> None:
device = self._devices[external_id]
if not 0 <= zone_index < len(device.zones):
raise ConnectorError(
"zone_index_invalid",
"OpenRGB-zoneindex valt buiten bereik.",
status_code=400,
)
zone = device.zones[zone_index]
lower = int(zone.get("leds_min", zone["led_count"]))
upper = int(zone.get("leds_max", zone["led_count"]))
if not min(lower, upper) <= new_size <= max(lower, upper):
raise ConnectorError(
"zone_size_invalid",
"Nieuwe OpenRGB-zonegrootte valt buiten bereik.",
status_code=400,
details={"minimum": lower, "maximum": upper},
)
zone["led_count"] = new_size
device.led_count = sum(int(item["led_count"]) for item in device.zones)
color = (
device.state.colors[0]
if device.state.colors
else RGBColor(red=255, green=255, blue=255)
)
device.state.colors = [color] * device.led_count
def configuration_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {},
"secret_fields": [],
"warning": "TESTMODUS: opdrachten bereiken geen echte hardware.",
}
"""Deterministic connector for tests and explicit non-production demos."""
from __future__ import annotations
from copy import deepcopy
from datetime import UTC, datetime
from typing import Any
from ..errors import ConnectorError
from .base import (
Connector,
ConnectorDevice,
ConnectorHealth,
DeviceCapabilities,
DeviceState,
HealthStatus,
InventoryCallback,
RGBColor,
)
class MockOpenRGBAdapter(Connector):
id = "openrgb-mock"
kind = "mock-openrgb"
def __init__(self) -> None:
self._started = False
self._callback: InventoryCallback | None = None
self._devices = {
"mock-mainboard": ConnectorDevice(
external_id="mock-mainboard",
fingerprint="mock-mainboard",
name="Aurora Mainboard",
vendor="LumaOps Lab",
model="Virtual RGB Controller",
serial="MOCK-001",
location="mock://mainboard",
firmware_version="1.0",
controller_index=0,
source="mock",
device_type="motherboard",
capabilities=DeviceCapabilities(
power=True,
restore=True,
rgb=True,
brightness=True,
effect=True,
speed=True,
multiple_colors=True,
per_zone=True,
per_led=True,
profiles=True,
max_leds=12,
min_speed=1,
max_speed=10,
),
state=DeviceState(
power=True,
brightness=72,
colors=[RGBColor(red=86, green=92, blue=255)] * 12,
mode="Static",
mode_index=0,
speed=5,
),
zones=[
{
"index": 0,
"name": "Mainboard",
"type": 1,
"led_count": 8,
"leds_min": 8,
"leds_max": 8,
},
{
"index": 1,
"name": "ARGB header",
"type": 1,
"led_count": 4,
"leds_min": 0,
"leds_max": 120,
},
],
modes=[
{"index": 0, "name": "Static", "brightness": True},
{"index": 1, "name": "Breathing", "speed_min": 1, "speed_max": 10},
{"index": 2, "name": "Rainbow", "speed_min": 1, "speed_max": 10},
],
led_count=12,
),
"mock-desk-light": ConnectorDevice(
external_id="mock-desk-light",
fingerprint="mock-desk-light",
name="Bureaulamp",
vendor="WLED",
model="ESP32 strip",
serial="AA:BB:CC:DD:EE:01",
location="mock://desk",
ip_address="192.0.2.42",
firmware_version="0.15.0",
controller_index=1,
source="mock",
device_type="light",
capabilities=DeviceCapabilities(
power=True,
restore=True,
rgb=True,
brightness=True,
effect=True,
multiple_colors=True,
per_zone=True,
max_leds=30,
),
state=DeviceState(
power=False,
brightness=40,
colors=[RGBColor(red=255, green=154, blue=92)],
mode="Solid",
),
zones=[
{
"index": 0,
"name": "Desk",
"type": 1,
"led_count": 30,
"leds_min": 30,
"leds_max": 30,
}
],
modes=[{"index": 0, "name": "Solid"}, {"index": 1, "name": "Colorloop"}],
led_count=30,
),
}
async def start(self) -> None:
self._started = True
async def stop(self) -> None:
self._started = False
def set_inventory_callback(self, callback: InventoryCallback | None) -> None:
self._callback = callback
async def test_connection(self) -> ConnectorHealth:
return await self.health()
async def health(self) -> ConnectorHealth:
return ConnectorHealth(
status=HealthStatus.HEALTHY if self._started else HealthStatus.DEGRADED,
message="Mockadapter actief — geen echte hardware."
if self._started
else "Mockadapter gestopt.",
connected=self._started,
last_success_at=datetime.now(UTC) if self._started else None,
details={"mock": True, "device_count": len(self._devices)},
)
async def discover(self) -> list[ConnectorDevice]:
return await self.inventory()
async def inventory(self) -> list[ConnectorDevice]:
return [deepcopy(device) for device in self._devices.values()]
async def get_state(self, external_id: str) -> DeviceState:
return deepcopy(self._devices[external_id].state)
async def set_state(self, external_id: str, state: DeviceState) -> DeviceState:
current = self._devices[external_id].state
patch = state.model_dump(exclude_none=True)
merged = current.model_dump()
merged.update(patch)
updated = DeviceState.model_validate(merged)
if updated.power is False:
updated.colors = [RGBColor(red=0, green=0, blue=0)] * max(
1, self._devices[external_id].led_count
)
elif updated.power is True and not updated.colors:
updated.colors = [RGBColor(red=255, green=255, blue=255)]
self._devices[external_id].state = updated
return deepcopy(updated)
async def resize_zone(self, external_id: str, zone_index: int, new_size: int) -> None:
device = self._devices[external_id]
if not 0 <= zone_index < len(device.zones):
raise ConnectorError(
"zone_index_invalid",
"OpenRGB-zoneindex valt buiten bereik.",
status_code=400,
)
zone = device.zones[zone_index]
lower = int(zone.get("leds_min", zone["led_count"]))
upper = int(zone.get("leds_max", zone["led_count"]))
if not min(lower, upper) <= new_size <= max(lower, upper):
raise ConnectorError(
"zone_size_invalid",
"Nieuwe OpenRGB-zonegrootte valt buiten bereik.",
status_code=400,
details={"minimum": lower, "maximum": upper},
)
zone["led_count"] = new_size
device.led_count = sum(int(item["led_count"]) for item in device.zones)
color = (
device.state.colors[0]
if device.state.colors
else RGBColor(red=255, green=255, blue=255)
)
device.state.colors = [color] * device.led_count
def configuration_schema(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {},
"secret_fields": [],
"warning": "TESTMODUS: opdrachten bereiken geen echte hardware.",
}
@@ -1,6 +1,6 @@
"""OpenRGB SDK protocol 5 connector."""
from .adapter import OpenRGBAdapter
__all__ = ["OpenRGBAdapter"]
"""OpenRGB SDK protocol 5 connector."""
from .adapter import OpenRGBAdapter
__all__ = ["OpenRGBAdapter"]
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,51 +1,51 @@
"""Connector lifecycle and lookup registry."""
from __future__ import annotations
import asyncio
from collections.abc import Iterable
from ..errors import NotFoundError
from .base import Connector, ConnectorHealth
class ConnectorRegistry:
def __init__(self, connectors: Iterable[Connector]) -> None:
self._connectors = {connector.id: connector for connector in connectors}
async def start(self) -> None:
await asyncio.gather(*(connector.start() for connector in self._connectors.values()))
async def stop(self) -> None:
await asyncio.gather(
*(connector.stop() for connector in self._connectors.values()),
return_exceptions=True,
)
def get(self, connector_id: str) -> Connector:
try:
return self._connectors[connector_id]
except KeyError as exc:
raise NotFoundError("Connector", connector_id) from exc
def all(self) -> tuple[Connector, ...]:
return tuple(self._connectors.values())
async def health(self) -> dict[str, ConnectorHealth]:
results = await asyncio.gather(
*(connector.health() for connector in self._connectors.values()),
return_exceptions=True,
)
health: dict[str, ConnectorHealth] = {}
for connector, result in zip(self._connectors.values(), results, strict=True):
if isinstance(result, BaseException):
from .base import HealthStatus
health[connector.id] = ConnectorHealth(
status=HealthStatus.UNHEALTHY,
message=str(result),
connected=False,
)
else:
health[connector.id] = result
return health
"""Connector lifecycle and lookup registry."""
from __future__ import annotations
import asyncio
from collections.abc import Iterable
from ..errors import NotFoundError
from .base import Connector, ConnectorHealth
class ConnectorRegistry:
def __init__(self, connectors: Iterable[Connector]) -> None:
self._connectors = {connector.id: connector for connector in connectors}
async def start(self) -> None:
await asyncio.gather(*(connector.start() for connector in self._connectors.values()))
async def stop(self) -> None:
await asyncio.gather(
*(connector.stop() for connector in self._connectors.values()),
return_exceptions=True,
)
def get(self, connector_id: str) -> Connector:
try:
return self._connectors[connector_id]
except KeyError as exc:
raise NotFoundError("Connector", connector_id) from exc
def all(self) -> tuple[Connector, ...]:
return tuple(self._connectors.values())
async def health(self) -> dict[str, ConnectorHealth]:
results = await asyncio.gather(
*(connector.health() for connector in self._connectors.values()),
return_exceptions=True,
)
health: dict[str, ConnectorHealth] = {}
for connector, result in zip(self._connectors.values(), results, strict=True):
if isinstance(result, BaseException):
from .base import HealthStatus
health[connector.id] = ConnectorHealth(
status=HealthStatus.UNHEALTHY,
message=str(result),
connected=False,
)
else:
health[connector.id] = result
return health
+112 -112
View File
@@ -1,112 +1,112 @@
"""SQLite connection management and ordered migration runner."""
from __future__ import annotations
import contextlib
import logging
import shutil
import sqlite3
from collections.abc import Iterator
from datetime import UTC, datetime
from pathlib import Path
from .config import Settings
LOGGER = logging.getLogger(__name__)
class Database:
def __init__(self, settings: Settings) -> None:
self.path = settings.database_path
self.backup_dir = settings.data_dir / "backups"
self.migrations_dir = Path(__file__).with_name("migrations")
def initialize(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.backup_dir.mkdir(parents=True, exist_ok=True)
with self.connection() as conn:
conn.execute(
"CREATE TABLE IF NOT EXISTS schema_migrations "
"(version TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"
)
applied = {
row["version"] for row in conn.execute("SELECT version FROM schema_migrations")
}
for migration in sorted(self.migrations_dir.glob("*.sql")):
version = migration.stem
if version in applied:
continue
sql = migration.read_text(encoding="utf-8")
if "-- destructive: true" in sql.lower() and self.path.exists():
self.backup("pre-migration")
LOGGER.info("Applying database migration %s", version)
conn.executescript(sql)
conn.execute(
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
(version, utc_now()),
)
conn.commit()
@contextlib.contextmanager
def connection(self) -> Iterator[sqlite3.Connection]:
conn = sqlite3.connect(self.path, timeout=5, isolation_level=None, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA synchronous = NORMAL")
conn.execute("PRAGMA busy_timeout = 5000")
try:
yield conn
finally:
conn.close()
@contextlib.contextmanager
def transaction(self) -> Iterator[sqlite3.Connection]:
with self.connection() as conn:
conn.execute("BEGIN IMMEDIATE")
try:
yield conn
except Exception:
conn.rollback()
raise
else:
conn.commit()
def backup(self, reason: str = "manual") -> Path:
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
target = self.backup_dir / f"lumaops-{timestamp}-{safe_filename(reason)}.db"
with self.connection() as source, sqlite3.connect(target) as destination:
source.backup(destination)
return target
def restore(self, backup_path: Path) -> Path:
resolved = backup_path.resolve()
if self.backup_dir.resolve() not in resolved.parents:
raise ValueError("Backupbestand valt buiten de beheerde back-upmap")
if not resolved.is_file():
raise FileNotFoundError(resolved)
safety = self.backup("pre-restore")
temp = self.path.with_suffix(".restore.tmp")
shutil.copy2(resolved, temp)
with sqlite3.connect(temp) as candidate:
result = candidate.execute("PRAGMA integrity_check").fetchone()
if result is None or result[0] != "ok":
temp.unlink(missing_ok=True)
raise ValueError("De back-up slaagt niet voor de SQLite-integriteitscontrole")
temp.replace(self.path)
return safety
def ping(self) -> bool:
try:
with self.connection() as conn:
return bool(conn.execute("SELECT 1").fetchone()[0] == 1)
except sqlite3.Error:
return False
def utc_now() -> str:
return datetime.now(UTC).isoformat()
def safe_filename(value: str) -> str:
return "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in value)[:48]
"""SQLite connection management and ordered migration runner."""
from __future__ import annotations
import contextlib
import logging
import shutil
import sqlite3
from collections.abc import Iterator
from datetime import UTC, datetime
from pathlib import Path
from .config import Settings
LOGGER = logging.getLogger(__name__)
class Database:
def __init__(self, settings: Settings) -> None:
self.path = settings.database_path
self.backup_dir = settings.data_dir / "backups"
self.migrations_dir = Path(__file__).with_name("migrations")
def initialize(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.backup_dir.mkdir(parents=True, exist_ok=True)
with self.connection() as conn:
conn.execute(
"CREATE TABLE IF NOT EXISTS schema_migrations "
"(version TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"
)
applied = {
row["version"] for row in conn.execute("SELECT version FROM schema_migrations")
}
for migration in sorted(self.migrations_dir.glob("*.sql")):
version = migration.stem
if version in applied:
continue
sql = migration.read_text(encoding="utf-8")
if "-- destructive: true" in sql.lower() and self.path.exists():
self.backup("pre-migration")
LOGGER.info("Applying database migration %s", version)
conn.executescript(sql)
conn.execute(
"INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)",
(version, utc_now()),
)
conn.commit()
@contextlib.contextmanager
def connection(self) -> Iterator[sqlite3.Connection]:
conn = sqlite3.connect(self.path, timeout=5, isolation_level=None, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA synchronous = NORMAL")
conn.execute("PRAGMA busy_timeout = 5000")
try:
yield conn
finally:
conn.close()
@contextlib.contextmanager
def transaction(self) -> Iterator[sqlite3.Connection]:
with self.connection() as conn:
conn.execute("BEGIN IMMEDIATE")
try:
yield conn
except Exception:
conn.rollback()
raise
else:
conn.commit()
def backup(self, reason: str = "manual") -> Path:
timestamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
target = self.backup_dir / f"lumaops-{timestamp}-{safe_filename(reason)}.db"
with self.connection() as source, sqlite3.connect(target) as destination:
source.backup(destination)
return target
def restore(self, backup_path: Path) -> Path:
resolved = backup_path.resolve()
if self.backup_dir.resolve() not in resolved.parents:
raise ValueError("Backupbestand valt buiten de beheerde back-upmap")
if not resolved.is_file():
raise FileNotFoundError(resolved)
safety = self.backup("pre-restore")
temp = self.path.with_suffix(".restore.tmp")
shutil.copy2(resolved, temp)
with sqlite3.connect(temp) as candidate:
result = candidate.execute("PRAGMA integrity_check").fetchone()
if result is None or result[0] != "ok":
temp.unlink(missing_ok=True)
raise ValueError("De back-up slaagt niet voor de SQLite-integriteitscontrole")
temp.replace(self.path)
return safety
def ping(self) -> bool:
try:
with self.connection() as conn:
return bool(conn.execute("SELECT 1").fetchone()[0] == 1)
except sqlite3.Error:
return False
def utc_now() -> str:
return datetime.now(UTC).isoformat()
def safe_filename(value: str) -> str:
return "".join(ch if ch.isalnum() or ch in "-_" else "-" for ch in value)[:48]
+48 -48
View File
@@ -1,48 +1,48 @@
"""Normalized application errors."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(slots=True)
class LumaOpsError(Exception):
code: str
message: str
status_code: int = 400
details: dict[str, Any] = field(default_factory=dict)
recovery: list[str] = field(default_factory=list)
def __str__(self) -> str:
return self.message
class NotFoundError(LumaOpsError):
def __init__(self, resource: str, identifier: str) -> None:
super().__init__(
code="not_found",
message=f"{resource} is niet gevonden.",
status_code=404,
details={"resource": resource, "id": identifier},
)
class ConflictError(LumaOpsError):
def __init__(self, message: str, **details: Any) -> None:
super().__init__("conflict", message, 409, details)
class ConnectorError(LumaOpsError):
def __init__(
self,
code: str,
message: str,
*,
status_code: int = 503,
retryable: bool = False,
details: dict[str, Any] | None = None,
) -> None:
merged = dict(details or {})
merged["retryable"] = retryable
super().__init__(code, message, status_code, merged)
"""Normalized application errors."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(slots=True)
class LumaOpsError(Exception):
code: str
message: str
status_code: int = 400
details: dict[str, Any] = field(default_factory=dict)
recovery: list[str] = field(default_factory=list)
def __str__(self) -> str:
return self.message
class NotFoundError(LumaOpsError):
def __init__(self, resource: str, identifier: str) -> None:
super().__init__(
code="not_found",
message=f"{resource} is niet gevonden.",
status_code=404,
details={"resource": resource, "id": identifier},
)
class ConflictError(LumaOpsError):
def __init__(self, message: str, **details: Any) -> None:
super().__init__("conflict", message, 409, details)
class ConnectorError(LumaOpsError):
def __init__(
self,
code: str,
message: str,
*,
status_code: int = 503,
retryable: bool = False,
details: dict[str, Any] | None = None,
) -> None:
merged = dict(details or {})
merged["retryable"] = retryable
super().__init__(code, message, status_code, merged)
+35 -35
View File
@@ -1,35 +1,35 @@
"""In-process event fan-out for Server-Sent Events clients."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from typing import Any
class EventBus:
def __init__(self) -> None:
self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set()
async def publish(self, event_type: str, data: dict[str, Any]) -> None:
event = {"type": event_type, "data": data}
dead: list[asyncio.Queue[dict[str, Any]]] = []
for queue in self._subscribers:
try:
queue.put_nowait(event)
except asyncio.QueueFull:
dead.append(queue)
for queue in dead:
self._subscribers.discard(queue)
async def subscribe(self) -> AsyncIterator[dict[str, Any]]:
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=100)
self._subscribers.add(queue)
try:
while True:
try:
yield await asyncio.wait_for(queue.get(), timeout=20)
except TimeoutError:
yield {"type": "heartbeat", "data": {}}
finally:
self._subscribers.discard(queue)
"""In-process event fan-out for Server-Sent Events clients."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from typing import Any
class EventBus:
def __init__(self) -> None:
self._subscribers: set[asyncio.Queue[dict[str, Any]]] = set()
async def publish(self, event_type: str, data: dict[str, Any]) -> None:
event = {"type": event_type, "data": data}
dead: list[asyncio.Queue[dict[str, Any]]] = []
for queue in self._subscribers:
try:
queue.put_nowait(event)
except asyncio.QueueFull:
dead.append(queue)
for queue in dead:
self._subscribers.discard(queue)
async def subscribe(self) -> AsyncIterator[dict[str, Any]]:
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=100)
self._subscribers.add(queue)
try:
while True:
try:
yield await asyncio.wait_for(queue.get(), timeout=20)
except TimeoutError:
yield {"type": "heartbeat", "data": {}}
finally:
self._subscribers.discard(queue)
@@ -1,57 +1,57 @@
"""Structured logging with conservative secret redaction."""
from __future__ import annotations
import json
import logging
import re
from contextvars import ContextVar
from datetime import UTC, datetime
from typing import Any
request_id_context: ContextVar[str] = ContextVar("request_id", default="-")
_SECRET_KEYS = re.compile(
r"(authorization|cookie|password|passwd|secret|token|api[_-]?key|client[_-]?key)", re.I
)
def redact(value: Any, key: str = "") -> Any:
if _SECRET_KEYS.search(key):
return "[REDACTED]"
if isinstance(value, dict):
return {str(k): redact(v, str(k)) for k, v in value.items()}
if isinstance(value, list | tuple):
return [redact(item) for item in value]
return value
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
safe_record = logging.makeLogRecord(record.__dict__)
if isinstance(record.args, tuple):
safe_record.args = tuple(redact(list(record.args)))
elif isinstance(record.args, dict):
safe_record.args = redact(record.args)
payload: dict[str, Any] = {
"timestamp": datetime.now(UTC).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": safe_record.getMessage(),
"request_id": request_id_context.get(),
}
for key in ("connector", "device_uuid", "command_id", "duration_ms", "error_category"):
if hasattr(record, key):
payload[key] = getattr(record, key)
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(redact(payload), ensure_ascii=False, default=str)
def configure_logging(level: str) -> None:
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(level)
"""Structured logging with conservative secret redaction."""
from __future__ import annotations
import json
import logging
import re
from contextvars import ContextVar
from datetime import UTC, datetime
from typing import Any
request_id_context: ContextVar[str] = ContextVar("request_id", default="-")
_SECRET_KEYS = re.compile(
r"(authorization|cookie|password|passwd|secret|token|api[_-]?key|client[_-]?key)", re.I
)
def redact(value: Any, key: str = "") -> Any:
if _SECRET_KEYS.search(key):
return "[REDACTED]"
if isinstance(value, dict):
return {str(k): redact(v, str(k)) for k, v in value.items()}
if isinstance(value, list | tuple):
return [redact(item) for item in value]
return value
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
safe_record = logging.makeLogRecord(record.__dict__)
if isinstance(record.args, tuple):
safe_record.args = tuple(redact(list(record.args)))
elif isinstance(record.args, dict):
safe_record.args = redact(record.args)
payload: dict[str, Any] = {
"timestamp": datetime.now(UTC).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": safe_record.getMessage(),
"request_id": request_id_context.get(),
}
for key in ("connector", "device_uuid", "command_id", "duration_ms", "error_category"):
if hasattr(record, key):
payload[key] = getattr(record, key)
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(redact(payload), ensure_ascii=False, default=str)
def configure_logging(level: str) -> None:
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(level)
+260 -260
View File
@@ -1,260 +1,260 @@
"""FastAPI application entry point."""
from __future__ import annotations
import logging
import uuid
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from typing import Any
import uvicorn
from fastapi import FastAPI, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from . import __version__
from .api.router import api
from .application import AppContext, build_context
from .config import Settings, get_settings
from .errors import LumaOpsError
from .logging_config import configure_logging, request_id_context
LOGGER = logging.getLogger(__name__)
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or get_settings()
configure_logging(settings.log_level)
app_context = build_context(settings)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.state.context = app_context
await app_context.start()
try:
yield
finally:
await app_context.stop()
app = FastAPI(
title="LumaOps API",
description="Lokaal OpenRGB- en smart-lightingbeheer",
version=__version__,
docs_url="/api/docs",
redoc_url=None,
openapi_url="/api/openapi.json",
lifespan=lifespan,
)
app.state.context = app_context
if settings.cors_origin_list:
app.add_middleware(
CORSMiddleware,
allow_origins=list(settings.cors_origin_list),
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=[
"Authorization",
"Content-Type",
"Idempotency-Key",
"X-CSRF-Token",
"X-Request-ID",
],
)
@app.middleware("http")
async def request_context(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
supplied = request.headers.get("x-request-id", "")
request_id = supplied if supplied and len(supplied) <= 128 else str(uuid.uuid4())
request.state.request_id = request_id
token = request_id_context.set(request_id)
try:
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
)
if request.method in {"POST", "PUT", "PATCH", "DELETE"} and request.url.path.startswith(
"/api/v1/"
):
try:
_audit_api_mutation(app_context, request, response, request_id)
except Exception:
LOGGER.exception("mutation_audit_failed")
return response
finally:
request_id_context.reset(token)
@app.exception_handler(LumaOpsError)
async def lumaops_error(request: Request, exc: LumaOpsError) -> JSONResponse:
return error_response(
request,
status_code=exc.status_code,
code=exc.code,
message=exc.message,
details=exc.details,
recovery=exc.recovery,
)
@app.exception_handler(RequestValidationError)
async def validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
return error_response(
request,
status_code=422,
code="validation_error",
message="De aanvraag bevat ongeldige gegevens.",
details={"fields": serializable_validation_errors(exc)},
)
@app.exception_handler(ValueError)
async def value_error(request: Request, exc: ValueError) -> JSONResponse:
return error_response(
request,
status_code=422,
code="invalid_value",
message=str(exc),
)
@app.exception_handler(Exception)
async def unhandled_error(request: Request, exc: Exception) -> JSONResponse:
LOGGER.exception("unhandled_request_error", extra={"error_category": type(exc).__name__})
return error_response(
request,
status_code=500,
code="internal_error",
message="Er ging intern iets mis.",
recovery=["Probeer opnieuw en gebruik de request-id bij diagnostiek."],
)
@app.get("/health/live", include_in_schema=False)
async def live() -> dict[str, str]:
return {"status": "alive"}
@app.get("/health/ready", include_in_schema=False)
async def ready(request: Request) -> JSONResponse:
context: AppContext = request.app.state.context
is_ready = context.health.ready()
return JSONResponse(
{"status": "ready" if is_ready else "not_ready"}, status_code=200 if is_ready else 503
)
@app.get("/health/container", include_in_schema=False)
async def container_health(request: Request) -> JSONResponse:
context: AppContext = request.app.state.context
report = await context.health.detailed()
status_code = 503 if report["status"] == "unhealthy" else 200
return JSONResponse(report, status_code=status_code)
app.include_router(api)
assets = settings.static_dir / "assets"
if assets.is_dir():
app.mount("/assets", StaticFiles(directory=assets), name="assets")
@app.get("/{full_path:path}", include_in_schema=False, response_model=None)
async def spa(full_path: str) -> FileResponse | JSONResponse:
del full_path
index = settings.static_dir / "index.html"
if index.is_file():
return FileResponse(index)
return JSONResponse(
{
"name": "LumaOps",
"status": "backend-only",
"message": "De frontendbuild is niet aanwezig in STATIC_DIR.",
"api_docs": "/api/docs",
},
status_code=503,
)
return app
def _audit_api_mutation(
context: AppContext, request: Request, response: Response, request_id: str
) -> None:
"""Record every state-changing API request without persisting request bodies or secrets."""
segments = [segment for segment in request.url.path.split("/") if segment]
resource_type = segments[2] if len(segments) > 2 else "api"
resource_id = segments[3] if len(segments) > 3 else None
route = request.scope.get("route")
route_name = getattr(route, "name", None)
actor_name = "anonymous"
try:
actor_name = context.auth.require(request)
except LumaOpsError:
actor_name = "anonymous"
context.repository.audit(
request_id=request_id,
actor=actor_name,
action=f"api.{route_name or request.method.lower()}",
resource_type=resource_type,
resource_id=resource_id,
outcome="succeeded" if response.status_code < 400 else "failed",
detail={
"method": request.method,
"path": getattr(route, "path", request.url.path),
"status_code": response.status_code,
},
)
def error_response(
request: Request,
*,
status_code: int,
code: str,
message: str,
details: dict[str, Any] | None = None,
recovery: list[str] | None = None,
) -> JSONResponse:
request_id = getattr(request.state, "request_id", "-")
return JSONResponse(
{
"error": {
"code": code,
"message": message,
"request_id": request_id,
"details": details or {},
"recovery": recovery or [],
}
},
status_code=status_code,
headers={"X-Request-ID": request_id},
)
def serializable_validation_errors(exc: RequestValidationError) -> list[dict[str, Any]]:
fields: list[dict[str, Any]] = []
for error in exc.errors():
field = dict(error)
if context := field.get("ctx"):
field["ctx"] = {key: str(value) for key, value in context.items()}
fields.append(field)
return fields
def run() -> None:
settings = get_settings()
uvicorn.run(
"lumaops_backend.main:create_app",
factory=True,
host=settings.app_host,
port=settings.app_port,
log_config=None,
proxy_headers=bool(settings.trusted_proxy_list),
forwarded_allow_ips=",".join(settings.trusted_proxy_list),
)
if __name__ == "__main__":
run()
"""FastAPI application entry point."""
from __future__ import annotations
import logging
import uuid
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from typing import Any
import uvicorn
from fastapi import FastAPI, Request, Response
from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from . import __version__
from .api.router import api
from .application import AppContext, build_context
from .config import Settings, get_settings
from .errors import LumaOpsError
from .logging_config import configure_logging, request_id_context
LOGGER = logging.getLogger(__name__)
def create_app(settings: Settings | None = None) -> FastAPI:
settings = settings or get_settings()
configure_logging(settings.log_level)
app_context = build_context(settings)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.state.context = app_context
await app_context.start()
try:
yield
finally:
await app_context.stop()
app = FastAPI(
title="LumaOps API",
description="Lokaal OpenRGB- en smart-lightingbeheer",
version=__version__,
docs_url="/api/docs",
redoc_url=None,
openapi_url="/api/openapi.json",
lifespan=lifespan,
)
app.state.context = app_context
if settings.cors_origin_list:
app.add_middleware(
CORSMiddleware,
allow_origins=list(settings.cors_origin_list),
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=[
"Authorization",
"Content-Type",
"Idempotency-Key",
"X-CSRF-Token",
"X-Request-ID",
],
)
@app.middleware("http")
async def request_context(
request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
supplied = request.headers.get("x-request-id", "")
request_id = supplied if supplied and len(supplied) <= 128 else str(uuid.uuid4())
request.state.request_id = request_id
token = request_id_context.set(request_id)
try:
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
response.headers["Content-Security-Policy"] = (
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
)
if request.method in {"POST", "PUT", "PATCH", "DELETE"} and request.url.path.startswith(
"/api/v1/"
):
try:
_audit_api_mutation(app_context, request, response, request_id)
except Exception:
LOGGER.exception("mutation_audit_failed")
return response
finally:
request_id_context.reset(token)
@app.exception_handler(LumaOpsError)
async def lumaops_error(request: Request, exc: LumaOpsError) -> JSONResponse:
return error_response(
request,
status_code=exc.status_code,
code=exc.code,
message=exc.message,
details=exc.details,
recovery=exc.recovery,
)
@app.exception_handler(RequestValidationError)
async def validation_error(request: Request, exc: RequestValidationError) -> JSONResponse:
return error_response(
request,
status_code=422,
code="validation_error",
message="De aanvraag bevat ongeldige gegevens.",
details={"fields": serializable_validation_errors(exc)},
)
@app.exception_handler(ValueError)
async def value_error(request: Request, exc: ValueError) -> JSONResponse:
return error_response(
request,
status_code=422,
code="invalid_value",
message=str(exc),
)
@app.exception_handler(Exception)
async def unhandled_error(request: Request, exc: Exception) -> JSONResponse:
LOGGER.exception("unhandled_request_error", extra={"error_category": type(exc).__name__})
return error_response(
request,
status_code=500,
code="internal_error",
message="Er ging intern iets mis.",
recovery=["Probeer opnieuw en gebruik de request-id bij diagnostiek."],
)
@app.get("/health/live", include_in_schema=False)
async def live() -> dict[str, str]:
return {"status": "alive"}
@app.get("/health/ready", include_in_schema=False)
async def ready(request: Request) -> JSONResponse:
context: AppContext = request.app.state.context
is_ready = context.health.ready()
return JSONResponse(
{"status": "ready" if is_ready else "not_ready"}, status_code=200 if is_ready else 503
)
@app.get("/health/container", include_in_schema=False)
async def container_health(request: Request) -> JSONResponse:
context: AppContext = request.app.state.context
report = await context.health.detailed()
status_code = 503 if report["status"] == "unhealthy" else 200
return JSONResponse(report, status_code=status_code)
app.include_router(api)
assets = settings.static_dir / "assets"
if assets.is_dir():
app.mount("/assets", StaticFiles(directory=assets), name="assets")
@app.get("/{full_path:path}", include_in_schema=False, response_model=None)
async def spa(full_path: str) -> FileResponse | JSONResponse:
del full_path
index = settings.static_dir / "index.html"
if index.is_file():
return FileResponse(index)
return JSONResponse(
{
"name": "LumaOps",
"status": "backend-only",
"message": "De frontendbuild is niet aanwezig in STATIC_DIR.",
"api_docs": "/api/docs",
},
status_code=503,
)
return app
def _audit_api_mutation(
context: AppContext, request: Request, response: Response, request_id: str
) -> None:
"""Record every state-changing API request without persisting request bodies or secrets."""
segments = [segment for segment in request.url.path.split("/") if segment]
resource_type = segments[2] if len(segments) > 2 else "api"
resource_id = segments[3] if len(segments) > 3 else None
route = request.scope.get("route")
route_name = getattr(route, "name", None)
actor_name = "anonymous"
try:
actor_name = context.auth.require(request)
except LumaOpsError:
actor_name = "anonymous"
context.repository.audit(
request_id=request_id,
actor=actor_name,
action=f"api.{route_name or request.method.lower()}",
resource_type=resource_type,
resource_id=resource_id,
outcome="succeeded" if response.status_code < 400 else "failed",
detail={
"method": request.method,
"path": getattr(route, "path", request.url.path),
"status_code": response.status_code,
},
)
def error_response(
request: Request,
*,
status_code: int,
code: str,
message: str,
details: dict[str, Any] | None = None,
recovery: list[str] | None = None,
) -> JSONResponse:
request_id = getattr(request.state, "request_id", "-")
return JSONResponse(
{
"error": {
"code": code,
"message": message,
"request_id": request_id,
"details": details or {},
"recovery": recovery or [],
}
},
status_code=status_code,
headers={"X-Request-ID": request_id},
)
def serializable_validation_errors(exc: RequestValidationError) -> list[dict[str, Any]]:
fields: list[dict[str, Any]] = []
for error in exc.errors():
field = dict(error)
if context := field.get("ctx"):
field["ctx"] = {key: str(value) for key, value in context.items()}
fields.append(field)
return fields
def run() -> None:
settings = get_settings()
uvicorn.run(
"lumaops_backend.main:create_app",
factory=True,
host=settings.app_host,
port=settings.app_port,
log_config=None,
proxy_headers=bool(settings.trusted_proxy_list),
forwarded_allow_ips=",".join(settings.trusted_proxy_list),
)
if __name__ == "__main__":
run()
@@ -1,287 +1,287 @@
-- LumaOps initial SQLite schema.
-- destructive: false
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value_json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE connectors (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
config_json TEXT NOT NULL DEFAULT '{}',
health TEXT NOT NULL DEFAULT 'unknown',
last_error TEXT,
last_seen_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE TABLE connector_secrets (
connector_id TEXT NOT NULL REFERENCES connectors(id) ON DELETE CASCADE,
key TEXT NOT NULL,
ciphertext BLOB NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (connector_id, key)
);
CREATE TABLE rooms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE TABLE devices (
id TEXT PRIMARY KEY,
connector_id TEXT NOT NULL REFERENCES connectors(id),
external_id TEXT NOT NULL,
fingerprint TEXT NOT NULL,
source TEXT NOT NULL,
owner TEXT NOT NULL DEFAULT 'openrgb',
name TEXT NOT NULL,
alias TEXT,
vendor TEXT,
model TEXT,
serial TEXT,
location TEXT,
ip_address TEXT,
firmware_version TEXT,
controller_index INTEGER,
capabilities_json TEXT NOT NULL DEFAULT '{}',
state_json TEXT NOT NULL DEFAULT '{}',
zones_json TEXT NOT NULL DEFAULT '[]',
modes_json TEXT NOT NULL DEFAULT '[]',
led_count INTEGER NOT NULL DEFAULT 0,
online INTEGER NOT NULL DEFAULT 0 CHECK (online IN (0, 1)),
hidden INTEGER NOT NULL DEFAULT 0 CHECK (hidden IN (0, 1)),
favorite INTEGER NOT NULL DEFAULT 0 CHECK (favorite IN (0, 1)),
exclude_global INTEGER NOT NULL DEFAULT 0 CHECK (exclude_global IN (0, 1)),
read_only INTEGER NOT NULL DEFAULT 0 CHECK (read_only IN (0, 1)),
blocked INTEGER NOT NULL DEFAULT 0 CHECK (blocked IN (0, 1)),
experimental INTEGER NOT NULL DEFAULT 0 CHECK (experimental IN (0, 1)),
room_id TEXT REFERENCES rooms(id) ON DELETE SET NULL,
error_status TEXT,
last_detected_at TEXT,
last_command_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
UNIQUE(connector_id, external_id)
);
CREATE INDEX idx_devices_fingerprint ON devices(connector_id, fingerprint);
CREATE INDEX idx_devices_room ON devices(room_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_devices_online ON devices(online) WHERE deleted_at IS NULL;
CREATE TABLE device_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
value TEXT NOT NULL,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
UNIQUE(device_id, kind, value)
);
CREATE TABLE tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
color TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE device_tags (
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (device_id, tag_id)
);
CREATE TABLE device_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
dynamic_query_json TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE TABLE group_members (
group_id TEXT NOT NULL REFERENCES device_groups(id) ON DELETE CASCADE,
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
excluded INTEGER NOT NULL DEFAULT 0 CHECK (excluded IN (0, 1)),
PRIMARY KEY (group_id, device_id)
);
CREATE TABLE scenes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
favorite INTEGER NOT NULL DEFAULT 0 CHECK (favorite IN (0, 1)),
version INTEGER NOT NULL DEFAULT 1,
last_applied_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE TABLE scene_items (
id TEXT PRIMARY KEY,
scene_id TEXT NOT NULL REFERENCES scenes(id) ON DELETE CASCADE,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
state_json TEXT NOT NULL,
required INTEGER NOT NULL DEFAULT 1 CHECK (required IN (0, 1)),
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_scene_items_scene ON scene_items(scene_id, sort_order);
CREATE TABLE automations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
trigger_json TEXT NOT NULL,
actions_json TEXT NOT NULL,
timezone TEXT NOT NULL,
cooldown_seconds INTEGER NOT NULL DEFAULT 0,
conflict_key TEXT,
last_run_at TEXT,
next_run_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE TABLE automation_runs (
id TEXT PRIMARY KEY,
automation_id TEXT NOT NULL REFERENCES automations(id),
status TEXT NOT NULL,
trigger_json TEXT NOT NULL,
result_json TEXT,
error TEXT,
started_at TEXT NOT NULL,
finished_at TEXT
);
CREATE TABLE commands (
id TEXT PRIMARY KEY,
request_id TEXT NOT NULL,
actor TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
action TEXT NOT NULL,
desired_state_json TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT
);
CREATE INDEX idx_commands_created ON commands(created_at DESC);
CREATE TABLE command_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
command_id TEXT NOT NULL REFERENCES commands(id) ON DELETE CASCADE,
device_id TEXT,
connector_id TEXT,
status TEXT NOT NULL,
prior_state_json TEXT,
applied_state_json TEXT,
error_code TEXT,
error_message TEXT,
duration_ms REAL,
created_at TEXT NOT NULL
);
CREATE TABLE audit_events (
id TEXT PRIMARY KEY,
request_id TEXT NOT NULL,
actor TEXT NOT NULL,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT,
outcome TEXT NOT NULL,
detail_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE INDEX idx_audit_created ON audit_events(created_at DESC);
CREATE TABLE activity (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
severity TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
resource_type TEXT,
resource_id TEXT,
detail_json TEXT NOT NULL DEFAULT '{}',
acknowledged_at TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE health_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
component TEXT NOT NULL,
status TEXT NOT NULL,
detail_json TEXT NOT NULL DEFAULT '{}',
sampled_at TEXT NOT NULL
);
CREATE INDEX idx_health_sampled ON health_history(sampled_at DESC);
CREATE TABLE discovery_runs (
id TEXT PRIMARY KEY,
connector_id TEXT,
kind TEXT NOT NULL,
status TEXT NOT NULL,
found_count INTEGER NOT NULL DEFAULT 0,
inaccessible_count INTEGER NOT NULL DEFAULT 0,
detail_json TEXT NOT NULL DEFAULT '{}',
started_at TEXT NOT NULL,
finished_at TEXT
);
CREATE TABLE ignored_devices (
id TEXT PRIMARY KEY,
connector_id TEXT,
fingerprint TEXT NOT NULL,
reason TEXT,
created_at TEXT NOT NULL,
UNIQUE(connector_id, fingerprint)
);
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
response_status INTEGER,
response_json TEXT,
resource_id TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE TABLE setup_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
completed INTEGER NOT NULL DEFAULT 0 CHECK (completed IN (0, 1)),
current_step TEXT NOT NULL DEFAULT 'welcome',
report_json TEXT NOT NULL DEFAULT '{}',
completed_at TEXT,
updated_at TEXT NOT NULL
);
INSERT INTO setup_state(singleton, completed, current_step, report_json, updated_at)
VALUES (1, 0, 'welcome', '{}', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
-- LumaOps initial SQLite schema.
-- destructive: false
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value_json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE connectors (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
config_json TEXT NOT NULL DEFAULT '{}',
health TEXT NOT NULL DEFAULT 'unknown',
last_error TEXT,
last_seen_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE TABLE connector_secrets (
connector_id TEXT NOT NULL REFERENCES connectors(id) ON DELETE CASCADE,
key TEXT NOT NULL,
ciphertext BLOB NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (connector_id, key)
);
CREATE TABLE rooms (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE TABLE devices (
id TEXT PRIMARY KEY,
connector_id TEXT NOT NULL REFERENCES connectors(id),
external_id TEXT NOT NULL,
fingerprint TEXT NOT NULL,
source TEXT NOT NULL,
owner TEXT NOT NULL DEFAULT 'openrgb',
name TEXT NOT NULL,
alias TEXT,
vendor TEXT,
model TEXT,
serial TEXT,
location TEXT,
ip_address TEXT,
firmware_version TEXT,
controller_index INTEGER,
capabilities_json TEXT NOT NULL DEFAULT '{}',
state_json TEXT NOT NULL DEFAULT '{}',
zones_json TEXT NOT NULL DEFAULT '[]',
modes_json TEXT NOT NULL DEFAULT '[]',
led_count INTEGER NOT NULL DEFAULT 0,
online INTEGER NOT NULL DEFAULT 0 CHECK (online IN (0, 1)),
hidden INTEGER NOT NULL DEFAULT 0 CHECK (hidden IN (0, 1)),
favorite INTEGER NOT NULL DEFAULT 0 CHECK (favorite IN (0, 1)),
exclude_global INTEGER NOT NULL DEFAULT 0 CHECK (exclude_global IN (0, 1)),
read_only INTEGER NOT NULL DEFAULT 0 CHECK (read_only IN (0, 1)),
blocked INTEGER NOT NULL DEFAULT 0 CHECK (blocked IN (0, 1)),
experimental INTEGER NOT NULL DEFAULT 0 CHECK (experimental IN (0, 1)),
room_id TEXT REFERENCES rooms(id) ON DELETE SET NULL,
error_status TEXT,
last_detected_at TEXT,
last_command_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
UNIQUE(connector_id, external_id)
);
CREATE INDEX idx_devices_fingerprint ON devices(connector_id, fingerprint);
CREATE INDEX idx_devices_room ON devices(room_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_devices_online ON devices(online) WHERE deleted_at IS NULL;
CREATE TABLE device_identities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
value TEXT NOT NULL,
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
UNIQUE(device_id, kind, value)
);
CREATE TABLE tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
color TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE device_tags (
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
tag_id TEXT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (device_id, tag_id)
);
CREATE TABLE device_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
dynamic_query_json TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE TABLE group_members (
group_id TEXT NOT NULL REFERENCES device_groups(id) ON DELETE CASCADE,
device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
excluded INTEGER NOT NULL DEFAULT 0 CHECK (excluded IN (0, 1)),
PRIMARY KEY (group_id, device_id)
);
CREATE TABLE scenes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
favorite INTEGER NOT NULL DEFAULT 0 CHECK (favorite IN (0, 1)),
version INTEGER NOT NULL DEFAULT 1,
last_applied_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE TABLE scene_items (
id TEXT PRIMARY KEY,
scene_id TEXT NOT NULL REFERENCES scenes(id) ON DELETE CASCADE,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
state_json TEXT NOT NULL,
required INTEGER NOT NULL DEFAULT 1 CHECK (required IN (0, 1)),
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_scene_items_scene ON scene_items(scene_id, sort_order);
CREATE TABLE automations (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
trigger_json TEXT NOT NULL,
actions_json TEXT NOT NULL,
timezone TEXT NOT NULL,
cooldown_seconds INTEGER NOT NULL DEFAULT 0,
conflict_key TEXT,
last_run_at TEXT,
next_run_at TEXT,
last_error TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE TABLE automation_runs (
id TEXT PRIMARY KEY,
automation_id TEXT NOT NULL REFERENCES automations(id),
status TEXT NOT NULL,
trigger_json TEXT NOT NULL,
result_json TEXT,
error TEXT,
started_at TEXT NOT NULL,
finished_at TEXT
);
CREATE TABLE commands (
id TEXT PRIMARY KEY,
request_id TEXT NOT NULL,
actor TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
action TEXT NOT NULL,
desired_state_json TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT
);
CREATE INDEX idx_commands_created ON commands(created_at DESC);
CREATE TABLE command_results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
command_id TEXT NOT NULL REFERENCES commands(id) ON DELETE CASCADE,
device_id TEXT,
connector_id TEXT,
status TEXT NOT NULL,
prior_state_json TEXT,
applied_state_json TEXT,
error_code TEXT,
error_message TEXT,
duration_ms REAL,
created_at TEXT NOT NULL
);
CREATE TABLE audit_events (
id TEXT PRIMARY KEY,
request_id TEXT NOT NULL,
actor TEXT NOT NULL,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT,
outcome TEXT NOT NULL,
detail_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE INDEX idx_audit_created ON audit_events(created_at DESC);
CREATE TABLE activity (
id TEXT PRIMARY KEY,
category TEXT NOT NULL,
severity TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
resource_type TEXT,
resource_id TEXT,
detail_json TEXT NOT NULL DEFAULT '{}',
acknowledged_at TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE health_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
component TEXT NOT NULL,
status TEXT NOT NULL,
detail_json TEXT NOT NULL DEFAULT '{}',
sampled_at TEXT NOT NULL
);
CREATE INDEX idx_health_sampled ON health_history(sampled_at DESC);
CREATE TABLE discovery_runs (
id TEXT PRIMARY KEY,
connector_id TEXT,
kind TEXT NOT NULL,
status TEXT NOT NULL,
found_count INTEGER NOT NULL DEFAULT 0,
inaccessible_count INTEGER NOT NULL DEFAULT 0,
detail_json TEXT NOT NULL DEFAULT '{}',
started_at TEXT NOT NULL,
finished_at TEXT
);
CREATE TABLE ignored_devices (
id TEXT PRIMARY KEY,
connector_id TEXT,
fingerprint TEXT NOT NULL,
reason TEXT,
created_at TEXT NOT NULL,
UNIQUE(connector_id, fingerprint)
);
CREATE TABLE idempotency_keys (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
response_status INTEGER,
response_json TEXT,
resource_id TEXT,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE TABLE setup_state (
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
completed INTEGER NOT NULL DEFAULT 0 CHECK (completed IN (0, 1)),
current_step TEXT NOT NULL DEFAULT 'welcome',
report_json TEXT NOT NULL DEFAULT '{}',
completed_at TEXT,
updated_at TEXT NOT NULL
);
INSERT INTO setup_state(singleton, completed, current_step, report_json, updated_at)
VALUES (1, 0, 'welcome', '{}', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
@@ -1,4 +1,4 @@
-- Persist optional automation descriptions accepted by the public schema.
-- destructive: false
ALTER TABLE automations ADD COLUMN description TEXT;
-- Persist optional automation descriptions accepted by the public schema.
-- destructive: false
ALTER TABLE automations ADD COLUMN description TEXT;
@@ -1,16 +1,16 @@
-- Persist the last intended state so hardware can be reconciled after OpenRGB restarts.
-- destructive: false
ALTER TABLE devices ADD COLUMN desired_state_json TEXT;
UPDATE devices
SET desired_state_json = (
SELECT cr.applied_state_json
FROM command_results AS cr
JOIN commands AS c ON c.id = cr.command_id
WHERE cr.device_id = devices.id
AND cr.status = 'succeeded'
AND cr.applied_state_json IS NOT NULL
ORDER BY COALESCE(c.finished_at, c.created_at) DESC, cr.id DESC
LIMIT 1
);
-- Persist the last intended state so hardware can be reconciled after OpenRGB restarts.
-- destructive: false
ALTER TABLE devices ADD COLUMN desired_state_json TEXT;
UPDATE devices
SET desired_state_json = (
SELECT cr.applied_state_json
FROM command_results AS cr
JOIN commands AS c ON c.id = cr.command_id
WHERE cr.device_id = devices.id
AND cr.status = 'succeeded'
AND cr.applied_state_json IS NOT NULL
ORDER BY COALESCE(c.finished_at, c.created_at) DESC, cr.id DESC
LIMIT 1
);
@@ -1,7 +1,7 @@
-- Persist connector classification so physical modules can be presented as device families.
-- destructive: false
ALTER TABLE devices ADD COLUMN device_type TEXT NOT NULL DEFAULT 'unknown';
ALTER TABLE devices ADD COLUMN metadata_json TEXT NOT NULL DEFAULT '{}';
CREATE INDEX idx_devices_type ON devices(device_type) WHERE deleted_at IS NULL;
-- Persist connector classification so physical modules can be presented as device families.
-- destructive: false
ALTER TABLE devices ADD COLUMN device_type TEXT NOT NULL DEFAULT 'unknown';
ALTER TABLE devices ADD COLUMN metadata_json TEXT NOT NULL DEFAULT '{}';
CREATE INDEX idx_devices_type ON devices(device_type) WHERE deleted_at IS NULL;
+159 -159
View File
@@ -1,159 +1,159 @@
"""Small transactional repository helpers for the SQLite data model."""
from __future__ import annotations
import json
import sqlite3
import uuid
from typing import Any
from .database import Database, utc_now
from .errors import NotFoundError
JSON_FIELDS = {
"config_json",
"capabilities_json",
"state_json",
"zones_json",
"modes_json",
"metadata_json",
"dynamic_query_json",
"trigger_json",
"actions_json",
"desired_state_json",
"prior_state_json",
"applied_state_json",
"detail_json",
"report_json",
"result_json",
}
def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
if row is None:
return None
result = dict(row)
for key in JSON_FIELDS & result.keys():
if result[key] is not None:
result[key.removesuffix("_json")] = json.loads(result.pop(key))
for key in (
"enabled",
"online",
"hidden",
"favorite",
"exclude_global",
"read_only",
"blocked",
"experimental",
"required",
"excluded",
"completed",
):
if key in result:
result[key] = bool(result[key])
return result
class Repository:
def __init__(self, database: Database) -> None:
self.database = database
def list_rows(
self,
table: str,
*,
where: str = "1=1",
params: tuple[Any, ...] = (),
order_by: str = "created_at DESC",
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict[str, Any]], int]:
checked_table = safe_identifier(table)
with self.database.connection() as conn:
total = conn.execute(
f"SELECT COUNT(*) FROM {checked_table} WHERE {where}",
params, # noqa: S608
).fetchone()[0]
rows = conn.execute(
f"SELECT * FROM {checked_table} WHERE {where} ORDER BY {order_by} LIMIT ? OFFSET ?", # noqa: S608
(*params, limit, offset),
).fetchall()
return [row_to_dict(row) or {} for row in rows], total
def get(self, table: str, identifier: str) -> dict[str, Any]:
checked_table = safe_identifier(table)
with self.database.connection() as conn:
row = conn.execute(
f"SELECT * FROM {checked_table} WHERE id = ? AND deleted_at IS NULL", # noqa: S608
(identifier,),
).fetchone()
result = row_to_dict(row)
if result is None:
raise NotFoundError(table, identifier)
return result
def audit(
self,
*,
request_id: str,
actor: str,
action: str,
resource_type: str,
resource_id: str | None,
outcome: str,
detail: dict[str, Any] | None = None,
) -> str:
event_id = str(uuid.uuid4())
with self.database.connection() as conn:
conn.execute(
"INSERT INTO audit_events(id, request_id, actor, action, resource_type, "
"resource_id, outcome, detail_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
event_id,
request_id,
actor,
action,
resource_type,
resource_id,
outcome,
json.dumps(detail or {}, separators=(",", ":")),
utc_now(),
),
)
return event_id
def activity(
self,
title: str,
message: str,
*,
category: str = "system",
severity: str = "info",
resource_type: str | None = None,
resource_id: str | None = None,
detail: dict[str, Any] | None = None,
) -> str:
event_id = str(uuid.uuid4())
with self.database.connection() as conn:
conn.execute(
"INSERT INTO activity(id, category, severity, title, message, resource_type, "
"resource_id, detail_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
event_id,
category,
severity,
title,
message,
resource_type,
resource_id,
json.dumps(detail or {}, separators=(",", ":")),
utc_now(),
),
)
return event_id
def safe_identifier(value: str) -> str:
if not value.replace("_", "").isalnum():
raise ValueError("Ongeldige SQL-identifier")
return value
"""Small transactional repository helpers for the SQLite data model."""
from __future__ import annotations
import json
import sqlite3
import uuid
from typing import Any
from .database import Database, utc_now
from .errors import NotFoundError
JSON_FIELDS = {
"config_json",
"capabilities_json",
"state_json",
"zones_json",
"modes_json",
"metadata_json",
"dynamic_query_json",
"trigger_json",
"actions_json",
"desired_state_json",
"prior_state_json",
"applied_state_json",
"detail_json",
"report_json",
"result_json",
}
def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
if row is None:
return None
result = dict(row)
for key in JSON_FIELDS & result.keys():
if result[key] is not None:
result[key.removesuffix("_json")] = json.loads(result.pop(key))
for key in (
"enabled",
"online",
"hidden",
"favorite",
"exclude_global",
"read_only",
"blocked",
"experimental",
"required",
"excluded",
"completed",
):
if key in result:
result[key] = bool(result[key])
return result
class Repository:
def __init__(self, database: Database) -> None:
self.database = database
def list_rows(
self,
table: str,
*,
where: str = "1=1",
params: tuple[Any, ...] = (),
order_by: str = "created_at DESC",
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict[str, Any]], int]:
checked_table = safe_identifier(table)
with self.database.connection() as conn:
total = conn.execute(
f"SELECT COUNT(*) FROM {checked_table} WHERE {where}",
params, # noqa: S608
).fetchone()[0]
rows = conn.execute(
f"SELECT * FROM {checked_table} WHERE {where} ORDER BY {order_by} LIMIT ? OFFSET ?", # noqa: S608
(*params, limit, offset),
).fetchall()
return [row_to_dict(row) or {} for row in rows], total
def get(self, table: str, identifier: str) -> dict[str, Any]:
checked_table = safe_identifier(table)
with self.database.connection() as conn:
row = conn.execute(
f"SELECT * FROM {checked_table} WHERE id = ? AND deleted_at IS NULL", # noqa: S608
(identifier,),
).fetchone()
result = row_to_dict(row)
if result is None:
raise NotFoundError(table, identifier)
return result
def audit(
self,
*,
request_id: str,
actor: str,
action: str,
resource_type: str,
resource_id: str | None,
outcome: str,
detail: dict[str, Any] | None = None,
) -> str:
event_id = str(uuid.uuid4())
with self.database.connection() as conn:
conn.execute(
"INSERT INTO audit_events(id, request_id, actor, action, resource_type, "
"resource_id, outcome, detail_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
event_id,
request_id,
actor,
action,
resource_type,
resource_id,
outcome,
json.dumps(detail or {}, separators=(",", ":")),
utc_now(),
),
)
return event_id
def activity(
self,
title: str,
message: str,
*,
category: str = "system",
severity: str = "info",
resource_type: str | None = None,
resource_id: str | None = None,
detail: dict[str, Any] | None = None,
) -> str:
event_id = str(uuid.uuid4())
with self.database.connection() as conn:
conn.execute(
"INSERT INTO activity(id, category, severity, title, message, resource_type, "
"resource_id, detail_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
event_id,
category,
severity,
title,
message,
resource_type,
resource_id,
json.dumps(detail or {}, separators=(",", ":")),
utc_now(),
),
)
return event_id
def safe_identifier(value: str) -> str:
if not value.replace("_", "").isalnum():
raise ValueError("Ongeldige SQL-identifier")
return value
+177 -177
View File
@@ -1,177 +1,177 @@
"""Versioned API request/response schemas."""
from __future__ import annotations
import re
from typing import Any, Literal
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel, Field, field_validator, model_validator
from .connectors.base import DeviceState
class Page(BaseModel):
items: list[dict[str, Any]]
total: int
limit: int
offset: int
class DevicePatch(BaseModel):
alias: str | None = Field(default=None, max_length=120)
room_id: str | None = None
hidden: bool | None = None
favorite: bool | None = None
exclude_global: bool | None = None
read_only: bool | None = None
blocked: bool | None = None
owner: Literal["openrgb", "native", "home_assistant", "unmanaged"] | None = None
tags: list[str] | None = Field(default=None, max_length=100)
@field_validator("tags")
@classmethod
def normalize_tags(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return None
normalized = [tag.strip() for tag in value if tag.strip()]
if any(len(tag) > 64 for tag in normalized):
raise ValueError("Tags mogen maximaal 64 tekens bevatten")
return list(dict.fromkeys(normalized))
class DeviceCommandRequest(BaseModel):
state: DeviceState
class ZoneResizeRequest(BaseModel):
led_count: int = Field(ge=0, le=65_535)
class NamedResourceCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
description: str | None = Field(default=None, max_length=1000)
@field_validator("name")
@classmethod
def strip_name(cls, value: str) -> str:
if not (value := value.strip()):
raise ValueError("Naam mag niet leeg zijn")
return value
class RoomCreate(NamedResourceCreate):
sort_order: int = 0
class GroupCreate(NamedResourceCreate):
device_ids: list[str] = Field(default_factory=list, max_length=4096)
dynamic_query: dict[str, Any] | None = None
sort_order: int = 0
class SceneItemInput(BaseModel):
target_type: Literal["device", "group"]
target_id: str
state: DeviceState
required: bool = True
sort_order: int = 0
class SceneCreate(NamedResourceCreate):
favorite: bool = False
items: list[SceneItemInput] = Field(default_factory=list, max_length=4096)
class SceneImportRequest(BaseModel):
format: Literal["lumaops-scene-v1"]
scene: SceneCreate
class SceneApplyRequest(BaseModel):
rollback_on_failure: bool = True
class AutomationCreate(NamedResourceCreate):
enabled: bool = True
trigger: dict[str, Any]
actions: list[dict[str, Any]] = Field(min_length=1, max_length=100)
timezone: str = "Europe/Brussels"
cooldown_seconds: int = Field(default=0, ge=0, le=604800)
conflict_key: str | None = Field(default=None, max_length=120)
@field_validator("trigger")
@classmethod
def validate_trigger(cls, value: dict[str, Any]) -> dict[str, Any]:
if value.get("type") not in {"time", "weekday"}:
raise ValueError("Alleen tijdgestuurde automations worden ondersteund")
at = value.get("at")
if not isinstance(at, str) or not re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", at):
raise ValueError("Een automationtijd moet HH:MM tussen 00:00 en 23:59 zijn")
weekdays = value.get("weekdays")
if not isinstance(weekdays, list) or not weekdays:
raise ValueError("Selecteer minstens één weekdag")
if any(
isinstance(day, bool) or not isinstance(day, int) or day < 0 or day > 6
for day in weekdays
):
raise ValueError("Weekdagen moeten unieke waarden van 0 tot en met 6 zijn")
if len(set(weekdays)) != len(weekdays):
raise ValueError("Weekdagen mogen niet dubbel voorkomen")
return value
@field_validator("actions")
@classmethod
def validate_actions(cls, value: list[dict[str, Any]]) -> list[dict[str, Any]]:
for action in value:
kind = action.get("type")
if kind == "scene":
if not isinstance(action.get("scene_id"), str) or not action["scene_id"].strip():
raise ValueError("Een scèneactie vereist een geldige scene_id")
elif kind == "device":
if not isinstance(action.get("device_id"), str) or not action["device_id"].strip():
raise ValueError("Een apparaat-actie vereist een geldige device_id")
if not isinstance(action.get("state"), dict):
raise ValueError("Een apparaat-actie vereist een geldige state")
DeviceState.model_validate(action["state"])
elif kind == "notification":
if "title" in action and not isinstance(action["title"], str):
raise ValueError("De titel van een notificatie moet tekst zijn")
if "message" in action and not isinstance(action["message"], str):
raise ValueError("Het bericht van een notificatie moet tekst zijn")
elif kind != "all_off":
raise ValueError(f"Onbekende automationactie: {kind}")
return value
@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str) -> str:
value = value.strip()
try:
ZoneInfo(value)
except ZoneInfoNotFoundError as exc:
raise ValueError("Gebruik een geldige IANA-tijdzone") from exc
return value
class SetupCompleteRequest(BaseModel):
appdata_confirmed: bool
backup_location_confirmed: bool | None = None
backup_confirmed: bool | None = None
@model_validator(mode="after")
def _normalize_backup(self) -> SetupCompleteRequest:
if self.backup_location_confirmed is None and self.backup_confirmed is not None:
self.backup_location_confirmed = self.backup_confirmed
if self.backup_location_confirmed is None:
raise ValueError("Veld 'backup_location_confirmed' of 'backup_confirmed' is verplicht.")
return self
class SettingUpdate(BaseModel):
value: Any
class ConnectorSecretInput(BaseModel):
key: str = Field(pattern=r"^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$")
value: str = Field(min_length=1, max_length=16384)
"""Versioned API request/response schemas."""
from __future__ import annotations
import re
from typing import Any, Literal
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel, Field, field_validator, model_validator
from .connectors.base import DeviceState
class Page(BaseModel):
items: list[dict[str, Any]]
total: int
limit: int
offset: int
class DevicePatch(BaseModel):
alias: str | None = Field(default=None, max_length=120)
room_id: str | None = None
hidden: bool | None = None
favorite: bool | None = None
exclude_global: bool | None = None
read_only: bool | None = None
blocked: bool | None = None
owner: Literal["openrgb", "native", "home_assistant", "unmanaged"] | None = None
tags: list[str] | None = Field(default=None, max_length=100)
@field_validator("tags")
@classmethod
def normalize_tags(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return None
normalized = [tag.strip() for tag in value if tag.strip()]
if any(len(tag) > 64 for tag in normalized):
raise ValueError("Tags mogen maximaal 64 tekens bevatten")
return list(dict.fromkeys(normalized))
class DeviceCommandRequest(BaseModel):
state: DeviceState
class ZoneResizeRequest(BaseModel):
led_count: int = Field(ge=0, le=65_535)
class NamedResourceCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
description: str | None = Field(default=None, max_length=1000)
@field_validator("name")
@classmethod
def strip_name(cls, value: str) -> str:
if not (value := value.strip()):
raise ValueError("Naam mag niet leeg zijn")
return value
class RoomCreate(NamedResourceCreate):
sort_order: int = 0
class GroupCreate(NamedResourceCreate):
device_ids: list[str] = Field(default_factory=list, max_length=4096)
dynamic_query: dict[str, Any] | None = None
sort_order: int = 0
class SceneItemInput(BaseModel):
target_type: Literal["device", "group"]
target_id: str
state: DeviceState
required: bool = True
sort_order: int = 0
class SceneCreate(NamedResourceCreate):
favorite: bool = False
items: list[SceneItemInput] = Field(default_factory=list, max_length=4096)
class SceneImportRequest(BaseModel):
format: Literal["lumaops-scene-v1"]
scene: SceneCreate
class SceneApplyRequest(BaseModel):
rollback_on_failure: bool = True
class AutomationCreate(NamedResourceCreate):
enabled: bool = True
trigger: dict[str, Any]
actions: list[dict[str, Any]] = Field(min_length=1, max_length=100)
timezone: str = "Europe/Brussels"
cooldown_seconds: int = Field(default=0, ge=0, le=604800)
conflict_key: str | None = Field(default=None, max_length=120)
@field_validator("trigger")
@classmethod
def validate_trigger(cls, value: dict[str, Any]) -> dict[str, Any]:
if value.get("type") not in {"time", "weekday"}:
raise ValueError("Alleen tijdgestuurde automations worden ondersteund")
at = value.get("at")
if not isinstance(at, str) or not re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d", at):
raise ValueError("Een automationtijd moet HH:MM tussen 00:00 en 23:59 zijn")
weekdays = value.get("weekdays")
if not isinstance(weekdays, list) or not weekdays:
raise ValueError("Selecteer minstens één weekdag")
if any(
isinstance(day, bool) or not isinstance(day, int) or day < 0 or day > 6
for day in weekdays
):
raise ValueError("Weekdagen moeten unieke waarden van 0 tot en met 6 zijn")
if len(set(weekdays)) != len(weekdays):
raise ValueError("Weekdagen mogen niet dubbel voorkomen")
return value
@field_validator("actions")
@classmethod
def validate_actions(cls, value: list[dict[str, Any]]) -> list[dict[str, Any]]:
for action in value:
kind = action.get("type")
if kind == "scene":
if not isinstance(action.get("scene_id"), str) or not action["scene_id"].strip():
raise ValueError("Een scèneactie vereist een geldige scene_id")
elif kind == "device":
if not isinstance(action.get("device_id"), str) or not action["device_id"].strip():
raise ValueError("Een apparaat-actie vereist een geldige device_id")
if not isinstance(action.get("state"), dict):
raise ValueError("Een apparaat-actie vereist een geldige state")
DeviceState.model_validate(action["state"])
elif kind == "notification":
if "title" in action and not isinstance(action["title"], str):
raise ValueError("De titel van een notificatie moet tekst zijn")
if "message" in action and not isinstance(action["message"], str):
raise ValueError("Het bericht van een notificatie moet tekst zijn")
elif kind != "all_off":
raise ValueError(f"Onbekende automationactie: {kind}")
return value
@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str) -> str:
value = value.strip()
try:
ZoneInfo(value)
except ZoneInfoNotFoundError as exc:
raise ValueError("Gebruik een geldige IANA-tijdzone") from exc
return value
class SetupCompleteRequest(BaseModel):
appdata_confirmed: bool
backup_location_confirmed: bool | None = None
backup_confirmed: bool | None = None
@model_validator(mode="after")
def _normalize_backup(self) -> SetupCompleteRequest:
if self.backup_location_confirmed is None and self.backup_confirmed is not None:
self.backup_location_confirmed = self.backup_confirmed
if self.backup_location_confirmed is None:
raise ValueError("Veld 'backup_location_confirmed' of 'backup_confirmed' is verplicht.")
return self
class SettingUpdate(BaseModel):
value: Any
class ConnectorSecretInput(BaseModel):
key: str = Field(pattern=r"^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$")
value: str = Field(min_length=1, max_length=16384)
+71 -71
View File
@@ -1,71 +1,71 @@
"""Authenticated encryption for connector secrets."""
from __future__ import annotations
import os
from cryptography.fernet import Fernet, InvalidToken
from .config import Settings
from .errors import LumaOpsError
class SecretStore:
def __init__(self, settings: Settings) -> None:
self.path = settings.config_dir / "secret.key"
key = (
settings.secret_key.encode("ascii")
if settings.secret_key
else self._load_or_create_key()
)
try:
self._fernet = Fernet(key)
except (ValueError, TypeError) as exc:
raise LumaOpsError(
"invalid_secret_key",
"De persistente LumaOps-encryptiesleutel is ongeldig.",
500,
recovery=["Herstel secret.key uit de appdata-back-up"],
) from exc
def _load_or_create_key(self) -> bytes:
self.path.parent.mkdir(parents=True, exist_ok=True)
if self.path.exists():
return self.path.read_bytes().strip()
key = Fernet.generate_key()
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
descriptor = os.open(self.path, flags, 0o600)
try:
os.write(descriptor, key + b"\n")
finally:
os.close(descriptor)
with contextlib_suppress_on_windows():
self.path.chmod(0o600)
return key
def encrypt(self, value: str) -> bytes:
return self._fernet.encrypt(value.encode("utf-8"))
def decrypt(self, value: bytes) -> str:
try:
return self._fernet.decrypt(value).decode("utf-8")
except InvalidToken as exc:
raise LumaOpsError(
"secret_decryption_failed",
"Een connectorsecret kan niet worden ontsleuteld.",
500,
recovery=[
"Herstel de bijbehorende secret.key",
"Voer het connectorsecret opnieuw in",
],
) from exc
class contextlib_suppress_on_windows:
"""Ignore chmod limitations without hiding key I/O failures."""
def __enter__(self) -> None:
return None
def __exit__(self, exc_type: object, exc: object, traceback: object) -> bool:
return isinstance(exc, PermissionError)
"""Authenticated encryption for connector secrets."""
from __future__ import annotations
import os
from cryptography.fernet import Fernet, InvalidToken
from .config import Settings
from .errors import LumaOpsError
class SecretStore:
def __init__(self, settings: Settings) -> None:
self.path = settings.config_dir / "secret.key"
key = (
settings.secret_key.encode("ascii")
if settings.secret_key
else self._load_or_create_key()
)
try:
self._fernet = Fernet(key)
except (ValueError, TypeError) as exc:
raise LumaOpsError(
"invalid_secret_key",
"De persistente LumaOps-encryptiesleutel is ongeldig.",
500,
recovery=["Herstel secret.key uit de appdata-back-up"],
) from exc
def _load_or_create_key(self) -> bytes:
self.path.parent.mkdir(parents=True, exist_ok=True)
if self.path.exists():
return self.path.read_bytes().strip()
key = Fernet.generate_key()
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
descriptor = os.open(self.path, flags, 0o600)
try:
os.write(descriptor, key + b"\n")
finally:
os.close(descriptor)
with contextlib_suppress_on_windows():
self.path.chmod(0o600)
return key
def encrypt(self, value: str) -> bytes:
return self._fernet.encrypt(value.encode("utf-8"))
def decrypt(self, value: bytes) -> str:
try:
return self._fernet.decrypt(value).decode("utf-8")
except InvalidToken as exc:
raise LumaOpsError(
"secret_decryption_failed",
"Een connectorsecret kan niet worden ontsleuteld.",
500,
recovery=[
"Herstel de bijbehorende secret.key",
"Voer het connectorsecret opnieuw in",
],
) from exc
class contextlib_suppress_on_windows:
"""Ignore chmod limitations without hiding key I/O failures."""
def __enter__(self) -> None:
return None
def __exit__(self, exc_type: object, exc: object, traceback: object) -> bool:
return isinstance(exc, PermissionError)
@@ -1 +1 @@
"""Application service layer."""
"""Application service layer."""
@@ -1,219 +1,219 @@
"""Extensible MVP automation engine with cooldown and conflict locks."""
from __future__ import annotations
import asyncio
import contextlib
import json
import uuid
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from ..connectors.base import DeviceState
from ..database import Database, utc_now
from ..errors import NotFoundError
from ..repository import Repository
from ..schemas import AutomationCreate
from .commands import CommandService
from .scenes import SceneService
class AutomationService:
def __init__(self, database: Database, commands: CommandService, scenes: SceneService) -> None:
self.database = database
self.commands = commands
self.scenes = scenes
self.repo = Repository(database)
self._task: asyncio.Task[None] | None = None
self._locks: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
self._stopping = False
async def start(self) -> None:
self._stopping = False
self._task = asyncio.create_task(self._loop(), name="automation-scheduler")
async def stop(self) -> None:
self._stopping = True
if self._task:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
async def _loop(self) -> None:
while not self._stopping:
now = utc_now()
with self.database.connection() as conn:
rows = conn.execute(
"SELECT id FROM automations WHERE enabled=1 AND deleted_at IS NULL "
"AND next_run_at IS NOT NULL AND next_run_at <= ?",
(now,),
).fetchall()
for row in rows:
asyncio.create_task(self.run(row["id"], {"type": "schedule"}))
await asyncio.sleep(15)
def create(self, data: AutomationCreate) -> dict[str, Any]:
identifier = str(uuid.uuid4())
now = utc_now()
next_run = self._next_run(data.trigger, data.timezone)
with self.database.connection() as conn:
conn.execute(
"INSERT INTO automations(id, name, description, enabled, trigger_json, actions_json, timezone, "
"cooldown_seconds, conflict_key, next_run_at, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
identifier,
data.name,
data.description,
int(data.enabled),
json.dumps(data.trigger, separators=(",", ":")),
json.dumps(data.actions, separators=(",", ":")),
data.timezone,
data.cooldown_seconds,
data.conflict_key,
next_run,
now,
now,
),
)
return self.get(identifier)
def list(self, limit: int = 100, offset: int = 0) -> tuple[list[dict[str, Any]], int]:
return self.repo.list_rows(
"automations",
where="deleted_at IS NULL",
order_by="name COLLATE NOCASE",
limit=limit,
offset=offset,
)
def get(self, identifier: str) -> dict[str, Any]:
return self.repo.get("automations", identifier)
def update(self, identifier: str, data: AutomationCreate) -> dict[str, Any]:
next_run = self._next_run(data.trigger, data.timezone)
with self.database.connection() as conn:
cursor = conn.execute(
"UPDATE automations SET name=?, description=?, enabled=?, trigger_json=?, actions_json=?, timezone=?, "
"cooldown_seconds=?, conflict_key=?, next_run_at=?, updated_at=? "
"WHERE id=? AND deleted_at IS NULL",
(
data.name,
data.description,
int(data.enabled),
json.dumps(data.trigger, separators=(",", ":")),
json.dumps(data.actions, separators=(",", ":")),
data.timezone,
data.cooldown_seconds,
data.conflict_key,
next_run,
utc_now(),
identifier,
),
)
if not cursor.rowcount:
raise NotFoundError("Automation", identifier)
return self.get(identifier)
def delete(self, identifier: str) -> None:
with self.database.connection() as conn:
cursor = conn.execute(
"UPDATE automations SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL",
(utc_now(), utc_now(), identifier),
)
if not cursor.rowcount:
raise NotFoundError("Automation", identifier)
async def run(self, identifier: str, trigger: dict[str, Any] | None = None) -> dict[str, Any]:
automation = self.get(identifier)
key = automation["conflict_key"] or identifier
if self._locks[key].locked():
return self._record_skipped(automation, "conflict")
if automation["last_run_at"] and automation["cooldown_seconds"]:
last = datetime.fromisoformat(automation["last_run_at"])
if datetime.now(UTC) < last + timedelta(seconds=automation["cooldown_seconds"]):
return self._record_skipped(automation, "cooldown")
run_id = str(uuid.uuid4())
started = utc_now()
with self.database.connection() as conn:
conn.execute(
"INSERT INTO automation_runs(id, automation_id, status, trigger_json, started_at) "
"VALUES (?, ?, 'running', ?, ?)",
(run_id, identifier, json.dumps(trigger or {"type": "manual"}), started),
)
results: list[dict[str, Any]] = []
status = "succeeded"
error: str | None = None
async with self._locks[key]:
try:
for action in automation["actions"]:
results.append(await self._execute_action(action))
except Exception as exc:
status = "failed"
error = str(exc)
finished = utc_now()
next_run = self._next_run(automation["trigger"], automation["timezone"])
with self.database.transaction() as conn:
conn.execute(
"UPDATE automation_runs SET status=?, result_json=?, error=?, finished_at=? WHERE id=?",
(status, json.dumps(results, default=str), error, finished, run_id),
)
conn.execute(
"UPDATE automations SET last_run_at=?, next_run_at=?, last_error=?, updated_at=? WHERE id=?",
(finished, next_run, error, finished, identifier),
)
return {"id": run_id, "status": status, "results": results, "error": error}
async def _execute_action(self, action: dict[str, Any]) -> dict[str, Any]:
kind = action.get("type")
if kind == "scene":
return await self.scenes.apply(str(action["scene_id"]))
if kind == "device":
return await self.commands.execute_device(
str(action["device_id"]), DeviceState.model_validate(action["state"])
)
if kind == "all_off":
return await self.commands.all_off()
if kind == "notification":
event_id = self.repo.activity(
str(action.get("title", "Automation")),
str(action.get("message", "Automation uitgevoerd.")),
category="automation",
)
return {"status": "created", "activity_id": event_id}
raise ValueError(f"Onbekende automationactie: {kind}")
def _record_skipped(self, automation: dict[str, Any], reason: str) -> dict[str, Any]:
run_id = str(uuid.uuid4())
now = utc_now()
with self.database.connection() as conn:
conn.execute(
"INSERT INTO automation_runs(id, automation_id, status, trigger_json, result_json, "
"started_at, finished_at) VALUES (?, ?, 'skipped', ?, ?, ?, ?)",
(
run_id,
automation["id"],
json.dumps({"type": "manual"}),
json.dumps({"reason": reason}),
now,
now,
),
)
return {"id": run_id, "status": "skipped", "reason": reason}
@staticmethod
def _next_run(trigger: dict[str, Any], timezone: str) -> str | None:
if trigger.get("type") not in {"time", "weekday"}:
return None
hour, minute = map(int, str(trigger.get("at", "00:00")).split(":"))
zone = ZoneInfo(timezone)
now = datetime.now(zone)
candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
weekdays = set(trigger.get("weekdays", range(7)))
for _ in range(8):
if candidate > now and candidate.weekday() in weekdays:
return candidate.astimezone(UTC).isoformat()
candidate += timedelta(days=1)
return None
"""Extensible MVP automation engine with cooldown and conflict locks."""
from __future__ import annotations
import asyncio
import contextlib
import json
import uuid
from collections import defaultdict
from datetime import UTC, datetime, timedelta
from typing import Any
from zoneinfo import ZoneInfo
from ..connectors.base import DeviceState
from ..database import Database, utc_now
from ..errors import NotFoundError
from ..repository import Repository
from ..schemas import AutomationCreate
from .commands import CommandService
from .scenes import SceneService
class AutomationService:
def __init__(self, database: Database, commands: CommandService, scenes: SceneService) -> None:
self.database = database
self.commands = commands
self.scenes = scenes
self.repo = Repository(database)
self._task: asyncio.Task[None] | None = None
self._locks: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
self._stopping = False
async def start(self) -> None:
self._stopping = False
self._task = asyncio.create_task(self._loop(), name="automation-scheduler")
async def stop(self) -> None:
self._stopping = True
if self._task:
self._task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await self._task
async def _loop(self) -> None:
while not self._stopping:
now = utc_now()
with self.database.connection() as conn:
rows = conn.execute(
"SELECT id FROM automations WHERE enabled=1 AND deleted_at IS NULL "
"AND next_run_at IS NOT NULL AND next_run_at <= ?",
(now,),
).fetchall()
for row in rows:
asyncio.create_task(self.run(row["id"], {"type": "schedule"}))
await asyncio.sleep(15)
def create(self, data: AutomationCreate) -> dict[str, Any]:
identifier = str(uuid.uuid4())
now = utc_now()
next_run = self._next_run(data.trigger, data.timezone)
with self.database.connection() as conn:
conn.execute(
"INSERT INTO automations(id, name, description, enabled, trigger_json, actions_json, timezone, "
"cooldown_seconds, conflict_key, next_run_at, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
identifier,
data.name,
data.description,
int(data.enabled),
json.dumps(data.trigger, separators=(",", ":")),
json.dumps(data.actions, separators=(",", ":")),
data.timezone,
data.cooldown_seconds,
data.conflict_key,
next_run,
now,
now,
),
)
return self.get(identifier)
def list(self, limit: int = 100, offset: int = 0) -> tuple[list[dict[str, Any]], int]:
return self.repo.list_rows(
"automations",
where="deleted_at IS NULL",
order_by="name COLLATE NOCASE",
limit=limit,
offset=offset,
)
def get(self, identifier: str) -> dict[str, Any]:
return self.repo.get("automations", identifier)
def update(self, identifier: str, data: AutomationCreate) -> dict[str, Any]:
next_run = self._next_run(data.trigger, data.timezone)
with self.database.connection() as conn:
cursor = conn.execute(
"UPDATE automations SET name=?, description=?, enabled=?, trigger_json=?, actions_json=?, timezone=?, "
"cooldown_seconds=?, conflict_key=?, next_run_at=?, updated_at=? "
"WHERE id=? AND deleted_at IS NULL",
(
data.name,
data.description,
int(data.enabled),
json.dumps(data.trigger, separators=(",", ":")),
json.dumps(data.actions, separators=(",", ":")),
data.timezone,
data.cooldown_seconds,
data.conflict_key,
next_run,
utc_now(),
identifier,
),
)
if not cursor.rowcount:
raise NotFoundError("Automation", identifier)
return self.get(identifier)
def delete(self, identifier: str) -> None:
with self.database.connection() as conn:
cursor = conn.execute(
"UPDATE automations SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL",
(utc_now(), utc_now(), identifier),
)
if not cursor.rowcount:
raise NotFoundError("Automation", identifier)
async def run(self, identifier: str, trigger: dict[str, Any] | None = None) -> dict[str, Any]:
automation = self.get(identifier)
key = automation["conflict_key"] or identifier
if self._locks[key].locked():
return self._record_skipped(automation, "conflict")
if automation["last_run_at"] and automation["cooldown_seconds"]:
last = datetime.fromisoformat(automation["last_run_at"])
if datetime.now(UTC) < last + timedelta(seconds=automation["cooldown_seconds"]):
return self._record_skipped(automation, "cooldown")
run_id = str(uuid.uuid4())
started = utc_now()
with self.database.connection() as conn:
conn.execute(
"INSERT INTO automation_runs(id, automation_id, status, trigger_json, started_at) "
"VALUES (?, ?, 'running', ?, ?)",
(run_id, identifier, json.dumps(trigger or {"type": "manual"}), started),
)
results: list[dict[str, Any]] = []
status = "succeeded"
error: str | None = None
async with self._locks[key]:
try:
for action in automation["actions"]:
results.append(await self._execute_action(action))
except Exception as exc:
status = "failed"
error = str(exc)
finished = utc_now()
next_run = self._next_run(automation["trigger"], automation["timezone"])
with self.database.transaction() as conn:
conn.execute(
"UPDATE automation_runs SET status=?, result_json=?, error=?, finished_at=? WHERE id=?",
(status, json.dumps(results, default=str), error, finished, run_id),
)
conn.execute(
"UPDATE automations SET last_run_at=?, next_run_at=?, last_error=?, updated_at=? WHERE id=?",
(finished, next_run, error, finished, identifier),
)
return {"id": run_id, "status": status, "results": results, "error": error}
async def _execute_action(self, action: dict[str, Any]) -> dict[str, Any]:
kind = action.get("type")
if kind == "scene":
return await self.scenes.apply(str(action["scene_id"]))
if kind == "device":
return await self.commands.execute_device(
str(action["device_id"]), DeviceState.model_validate(action["state"])
)
if kind == "all_off":
return await self.commands.all_off()
if kind == "notification":
event_id = self.repo.activity(
str(action.get("title", "Automation")),
str(action.get("message", "Automation uitgevoerd.")),
category="automation",
)
return {"status": "created", "activity_id": event_id}
raise ValueError(f"Onbekende automationactie: {kind}")
def _record_skipped(self, automation: dict[str, Any], reason: str) -> dict[str, Any]:
run_id = str(uuid.uuid4())
now = utc_now()
with self.database.connection() as conn:
conn.execute(
"INSERT INTO automation_runs(id, automation_id, status, trigger_json, result_json, "
"started_at, finished_at) VALUES (?, ?, 'skipped', ?, ?, ?, ?)",
(
run_id,
automation["id"],
json.dumps({"type": "manual"}),
json.dumps({"reason": reason}),
now,
now,
),
)
return {"id": run_id, "status": "skipped", "reason": reason}
@staticmethod
def _next_run(trigger: dict[str, Any], timezone: str) -> str | None:
if trigger.get("type") not in {"time", "weekday"}:
return None
hour, minute = map(int, str(trigger.get("at", "00:00")).split(":"))
zone = ZoneInfo(timezone)
now = datetime.now(zone)
candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
weekdays = set(trigger.get("weekdays", range(7)))
for _ in range(8):
if candidate > now and candidate.weekday() in weekdays:
return candidate.astimezone(UTC).isoformat()
candidate += timedelta(days=1)
return None
@@ -1,34 +1,34 @@
"""Managed backup listing, creation, and guarded restore."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from ..database import Database
class BackupService:
def __init__(self, database: Database) -> None:
self.database = database
def list(self) -> list[dict[str, Any]]:
return [
{
"name": path.name,
"size": path.stat().st_size,
"modified_at": path.stat().st_mtime,
}
for path in sorted(self.database.backup_dir.glob("*.db"), reverse=True)
if path.is_file()
]
def create(self) -> dict[str, Any]:
path = self.database.backup("manual")
return {"name": path.name, "size": path.stat().st_size}
def restore(self, name: str) -> dict[str, Any]:
if Path(name).name != name:
raise ValueError("Ongeldige back-upnaam")
safety = self.database.restore(self.database.backup_dir / name)
return {"status": "restored", "safety_backup": safety.name}
"""Managed backup listing, creation, and guarded restore."""
from __future__ import annotations
from pathlib import Path
from typing import Any
from ..database import Database
class BackupService:
def __init__(self, database: Database) -> None:
self.database = database
def list(self) -> list[dict[str, Any]]:
return [
{
"name": path.name,
"size": path.stat().st_size,
"modified_at": path.stat().st_mtime,
}
for path in sorted(self.database.backup_dir.glob("*.db"), reverse=True)
if path.is_file()
]
def create(self) -> dict[str, Any]:
path = self.database.backup("manual")
return {"name": path.name, "size": path.stat().st_size}
def restore(self, name: str) -> dict[str, Any]:
if Path(name).name != name:
raise ValueError("Ongeldige back-upnaam")
safety = self.database.restore(self.database.backup_dir / name)
return {"status": "restored", "safety_backup": safety.name}
@@ -1,445 +1,445 @@
"""Validated, rate-limited and audited hardware command execution."""
from __future__ import annotations
import asyncio
import json
import logging
import time
import uuid
from collections import defaultdict
from dataclasses import dataclass
from typing import Any
from ..config import Settings
from ..connectors.base import DeviceCapabilities, DeviceState, RGBColor
from ..connectors.registry import ConnectorRegistry
from ..database import Database, utc_now
from ..errors import ConflictError, LumaOpsError
from ..events import EventBus
from ..logging_config import request_id_context
from ..repository import Repository
from .inventory import InventoryService
LOGGER = logging.getLogger(__name__)
@dataclass(slots=True)
class TokenBucket:
rate: float
capacity: float
tokens: float
updated_at: float
async def acquire(self) -> None:
while True:
now = time.monotonic()
self.tokens = min(self.capacity, self.tokens + (now - self.updated_at) * self.rate)
self.updated_at = now
if self.tokens >= 1:
self.tokens -= 1
return
await asyncio.sleep((1 - self.tokens) / self.rate)
class CommandService:
def __init__(
self,
settings: Settings,
database: Database,
registry: ConnectorRegistry,
inventory: InventoryService,
events: EventBus,
) -> None:
self.settings = settings
self.database = database
self.registry = registry
self.inventory = inventory
self.events = events
self.repo = Repository(database)
self._locks: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
self._buckets: dict[str, TokenBucket] = {}
self._emergency_stop = False
async def execute_device(
self,
device_id: str,
state: DeviceState,
*,
actor: str = "local-admin",
request_id: str | None = None,
bypass_emergency: bool = False,
) -> dict[str, Any]:
request_id = request_id or request_id_context.get()
device = self.inventory.get_device(device_id)
effective_state = self._effective_state(device, state)
self._validate_device(device, effective_state, bypass_emergency)
command_id = str(uuid.uuid4())
desired_json = json.dumps(
effective_state.model_dump(mode="json", exclude_none=True), separators=(",", ":")
)
now = utc_now()
with self.database.connection() as conn:
conn.execute(
"INSERT INTO commands(id, request_id, actor, target_type, target_id, action, "
"desired_state_json, status, created_at) VALUES (?, ?, ?, 'device', ?, 'set_state', ?, 'queued', ?)",
(command_id, request_id, actor, device_id, desired_json, now),
)
await self.events.publish(
"command.queued", {"command_id": command_id, "device_id": device_id}
)
started = time.perf_counter()
prior_state: DeviceState | None = None
connector = self.registry.get(device["connector_id"])
try:
async with self._locks[device_id]:
await self._bucket(device_id).acquire()
with self.database.connection() as conn:
conn.execute(
"UPDATE commands SET status='running', started_at=? WHERE id=?",
(utc_now(), command_id),
)
prior_state = await asyncio.wait_for(
connector.get_state(device["external_id"]),
timeout=self.settings.command_timeout_seconds,
)
applied = await asyncio.wait_for(
connector.set_state(device["external_id"], effective_state),
timeout=self.settings.command_timeout_seconds,
)
desired = self._desired_snapshot(device, effective_state, applied)
duration = (time.perf_counter() - started) * 1000
finished = utc_now()
with self.database.transaction() as conn:
conn.execute(
"UPDATE commands SET status='succeeded', finished_at=? WHERE id=?",
(finished, command_id),
)
conn.execute(
"INSERT INTO command_results(command_id, device_id, connector_id, status, "
"prior_state_json, applied_state_json, duration_ms, created_at) "
"VALUES (?, ?, ?, 'succeeded', ?, ?, ?, ?)",
(
command_id,
device_id,
device["connector_id"],
json.dumps(prior_state.model_dump(mode="json"), separators=(",", ":")),
json.dumps(applied.model_dump(mode="json"), separators=(",", ":")),
duration,
finished,
),
)
conn.execute(
"UPDATE devices SET state_json=?, desired_state_json=?, last_command_at=?, "
"error_status=NULL, updated_at=? WHERE id=?",
(
json.dumps(applied.model_dump(mode="json"), separators=(",", ":")),
json.dumps(desired.model_dump(mode="json"), separators=(",", ":")),
finished,
finished,
device_id,
),
)
self.repo.audit(
request_id=request_id,
actor=actor,
action="device.set_state",
resource_type="device",
resource_id=device_id,
outcome="succeeded",
detail={
"command_id": command_id,
"state": effective_state.model_dump(mode="json", exclude_none=True),
},
)
await self.events.publish(
"command.completed",
{"command_id": command_id, "device_id": device_id, "status": "succeeded"},
)
return {
"id": command_id,
"status": "succeeded",
"device_id": device_id,
"state": applied.model_dump(mode="json"),
"duration_ms": duration,
}
except Exception as exc:
duration = (time.perf_counter() - started) * 1000
finished = utc_now()
code = exc.code if isinstance(exc, LumaOpsError) else "command_failed"
with self.database.transaction() as conn:
conn.execute(
"UPDATE commands SET status='failed', finished_at=? WHERE id=?",
(finished, command_id),
)
conn.execute(
"INSERT INTO command_results(command_id, device_id, connector_id, status, "
"prior_state_json, error_code, error_message, duration_ms, created_at) "
"VALUES (?, ?, ?, 'failed', ?, ?, ?, ?, ?)",
(
command_id,
device_id,
device["connector_id"],
json.dumps(prior_state.model_dump(mode="json")) if prior_state else None,
code,
str(exc),
duration,
finished,
),
)
conn.execute(
"UPDATE devices SET error_status=?, updated_at=? WHERE id=?",
(code, finished, device_id),
)
self.repo.audit(
request_id=request_id,
actor=actor,
action="device.set_state",
resource_type="device",
resource_id=device_id,
outcome="failed",
detail={"command_id": command_id, "error_code": code},
)
await self.events.publish(
"command.completed",
{
"command_id": command_id,
"device_id": device_id,
"status": "failed",
"error": code,
},
)
raise
async def restore_connector(self, connector_id: str) -> dict[str, Any]:
devices, _ = self.inventory.list_devices(
limit=4096,
online=True,
include_hidden=True,
)
candidates = [device for device in devices if device["connector_id"] == connector_id]
restored = 0
unchanged = 0
skipped = 0
failed = 0
for device in candidates:
desired_payload = device.get("desired_state")
capabilities = DeviceCapabilities.model_validate(device["capabilities"])
if (
not desired_payload
or not capabilities.restore
or device["blocked"]
or device["read_only"]
or device["owner"] == "unmanaged"
):
skipped += 1
continue
desired = DeviceState.model_validate(desired_payload)
actual = DeviceState.model_validate(device["state"])
if self._state_matches(actual, desired):
unchanged += 1
continue
try:
await self.execute_device(
device["id"],
desired,
actor="system-restore",
request_id=f"restore-{uuid.uuid4()}",
)
restored += 1
except Exception as exc:
failed += 1
LOGGER.warning(
"Desired state restore failed: connector=%s device=%s error=%s",
connector_id,
device["id"],
exc,
)
result = {
"status": "completed" if not failed else "degraded",
"connector_id": connector_id,
"restored": restored,
"unchanged": unchanged,
"skipped": skipped,
"failed": failed,
}
LOGGER.info("Desired state reconciliation: %s", result)
return result
@staticmethod
def _effective_state(device: dict[str, Any], requested: DeviceState) -> DeviceState:
prior_payload = device.get("desired_state")
explicit_control_fields = (
"mode",
"mode_index",
"brightness",
"speed",
"direction",
"zone_index",
"led_index",
"transition_ms",
)
direct_color_mode = CommandService._is_direct_color_mode(device, requested)
if (
requested.power is not True
or requested.colors
or not prior_payload
or (
any(getattr(requested, field) is not None for field in explicit_control_fields)
and not direct_color_mode
)
):
return requested
prior = DeviceState.model_validate(prior_payload)
if not prior.colors or not any(
color.red or color.green or color.blue for color in prior.colors
):
return requested
effective = requested.model_dump(mode="json")
effective["colors"] = [color.model_dump(mode="json") for color in prior.colors]
for field in ("mode", "mode_index", "brightness", "speed", "direction"):
if effective[field] is None:
effective[field] = getattr(prior, field)
return DeviceState.model_validate(effective)
@staticmethod
def _is_direct_color_mode(device: dict[str, Any], requested: DeviceState) -> bool:
if requested.mode is not None:
return requested.mode.casefold() in {"direct", "custom"}
if requested.mode_index is None:
return False
return any(
int(mode.get("index", -1)) == requested.mode_index
and str(mode.get("name", "")).casefold() in {"direct", "custom"}
for mode in device.get("modes", [])
)
@staticmethod
def _desired_snapshot(
device: dict[str, Any], requested: DeviceState, applied: DeviceState
) -> DeviceState:
payload = applied.model_dump(mode="json")
prior_payload = device.get("desired_state")
if requested.power is False and not requested.colors and prior_payload:
prior = DeviceState.model_validate(prior_payload)
if prior.colors and any(
color.red or color.green or color.blue for color in prior.colors
):
payload["colors"] = [color.model_dump(mode="json") for color in prior.colors]
payload["mode"] = prior.mode
payload["mode_index"] = prior.mode_index
payload["brightness"] = prior.brightness
payload["speed"] = prior.speed
payload["direction"] = prior.direction
return DeviceState.model_validate(payload)
@staticmethod
def _state_matches(actual: DeviceState, desired: DeviceState) -> bool:
if desired.power is False:
return actual.power is False
if desired.power is not None and actual.power is not desired.power:
return False
if desired.mode_index is not None and actual.mode_index != desired.mode_index:
return False
if desired.mode_index is None and desired.mode is not None and actual.mode != desired.mode:
return False
for field in ("brightness", "speed", "direction"):
expected = getattr(desired, field)
if expected is not None and getattr(actual, field) != expected:
return False
if desired.colors:
if not actual.colors:
return False
expected_colors = desired.colors
if len(expected_colors) == 1 and len(actual.colors) > 1:
expected_colors = expected_colors * len(actual.colors)
if expected_colors != actual.colors:
return False
return True
def _validate_device(
self, device: dict[str, Any], state: DeviceState, bypass_emergency: bool
) -> None:
if self._emergency_stop and not bypass_emergency:
raise ConflictError("De globale noodstop is actief.", device_id=device["id"])
if not device["online"]:
raise LumaOpsError("device_offline", "Het apparaat is offline.", 409)
if device["blocked"]:
raise LumaOpsError("device_blocked", "Het apparaat is volledig geblokkeerd.", 403)
if device["read_only"]:
raise LumaOpsError("device_read_only", "Het apparaat staat in alleen-lezenmodus.", 403)
if device["owner"] == "unmanaged":
raise LumaOpsError(
"device_unmanaged", "Het apparaat heeft geen beheerder geselecteerd.", 409
)
capabilities = DeviceCapabilities.model_validate(device["capabilities"])
requirements = {
"power": state.power is not None,
"brightness": state.brightness is not None,
"rgb": state.colors is not None,
"effect": state.mode is not None or state.mode_index is not None,
"speed": state.speed is not None,
"per_zone": state.zone_index is not None,
"per_led": state.led_index is not None,
}
unsupported = [
name
for name, required in requirements.items()
if required and not getattr(capabilities, name)
]
if unsupported:
raise LumaOpsError(
"unsupported_capability",
"Het apparaat ondersteunt niet alle gevraagde eigenschappen.",
422,
details={"unsupported": unsupported},
)
def _bucket(self, device_id: str) -> TokenBucket:
if device_id not in self._buckets:
rate = self.settings.command_rate_per_second
self._buckets[device_id] = TokenBucket(
rate, max(1.0, rate), max(1.0, rate), time.monotonic()
)
return self._buckets[device_id]
async def identify(self, device_id: str) -> dict[str, Any]:
device = self.inventory.get_device(device_id)
connector = self.registry.get(device["connector_id"])
prior = await connector.get_state(device["external_id"])
flash = DeviceState(power=True, colors=[RGBColor(red=96, green=160, blue=255)])
async with self._locks[device_id]:
for _ in range(2):
await connector.set_state(device["external_id"], flash)
await asyncio.sleep(0.35)
await connector.set_state(device["external_id"], DeviceState(power=False))
await asyncio.sleep(0.35)
await connector.set_state(device["external_id"], prior)
return {"status": "completed", "device_id": device_id}
async def all_off(self, *, emergency: bool = False) -> dict[str, Any]:
if emergency:
self._emergency_stop = True
devices, _ = self.inventory.list_devices(limit=4096, online=True, include_hidden=True)
results = []
for device in devices:
if device["blocked"] or device["read_only"]:
results.append({"device_id": device["id"], "status": "skipped"})
continue
try:
result = await self.execute_device(
device["id"],
DeviceState(power=False),
bypass_emergency=emergency,
)
results.append(result)
except Exception as exc:
results.append({"device_id": device["id"], "status": "failed", "error": str(exc)})
return {"status": "completed", "emergency_stop": self._emergency_stop, "results": results}
def clear_emergency_stop(self) -> None:
self._emergency_stop = False
@property
def emergency_stop(self) -> bool:
return self._emergency_stop
"""Validated, rate-limited and audited hardware command execution."""
from __future__ import annotations
import asyncio
import json
import logging
import time
import uuid
from collections import defaultdict
from dataclasses import dataclass
from typing import Any
from ..config import Settings
from ..connectors.base import DeviceCapabilities, DeviceState, RGBColor
from ..connectors.registry import ConnectorRegistry
from ..database import Database, utc_now
from ..errors import ConflictError, LumaOpsError
from ..events import EventBus
from ..logging_config import request_id_context
from ..repository import Repository
from .inventory import InventoryService
LOGGER = logging.getLogger(__name__)
@dataclass(slots=True)
class TokenBucket:
rate: float
capacity: float
tokens: float
updated_at: float
async def acquire(self) -> None:
while True:
now = time.monotonic()
self.tokens = min(self.capacity, self.tokens + (now - self.updated_at) * self.rate)
self.updated_at = now
if self.tokens >= 1:
self.tokens -= 1
return
await asyncio.sleep((1 - self.tokens) / self.rate)
class CommandService:
def __init__(
self,
settings: Settings,
database: Database,
registry: ConnectorRegistry,
inventory: InventoryService,
events: EventBus,
) -> None:
self.settings = settings
self.database = database
self.registry = registry
self.inventory = inventory
self.events = events
self.repo = Repository(database)
self._locks: defaultdict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
self._buckets: dict[str, TokenBucket] = {}
self._emergency_stop = False
async def execute_device(
self,
device_id: str,
state: DeviceState,
*,
actor: str = "local-admin",
request_id: str | None = None,
bypass_emergency: bool = False,
) -> dict[str, Any]:
request_id = request_id or request_id_context.get()
device = self.inventory.get_device(device_id)
effective_state = self._effective_state(device, state)
self._validate_device(device, effective_state, bypass_emergency)
command_id = str(uuid.uuid4())
desired_json = json.dumps(
effective_state.model_dump(mode="json", exclude_none=True), separators=(",", ":")
)
now = utc_now()
with self.database.connection() as conn:
conn.execute(
"INSERT INTO commands(id, request_id, actor, target_type, target_id, action, "
"desired_state_json, status, created_at) VALUES (?, ?, ?, 'device', ?, 'set_state', ?, 'queued', ?)",
(command_id, request_id, actor, device_id, desired_json, now),
)
await self.events.publish(
"command.queued", {"command_id": command_id, "device_id": device_id}
)
started = time.perf_counter()
prior_state: DeviceState | None = None
connector = self.registry.get(device["connector_id"])
try:
async with self._locks[device_id]:
await self._bucket(device_id).acquire()
with self.database.connection() as conn:
conn.execute(
"UPDATE commands SET status='running', started_at=? WHERE id=?",
(utc_now(), command_id),
)
prior_state = await asyncio.wait_for(
connector.get_state(device["external_id"]),
timeout=self.settings.command_timeout_seconds,
)
applied = await asyncio.wait_for(
connector.set_state(device["external_id"], effective_state),
timeout=self.settings.command_timeout_seconds,
)
desired = self._desired_snapshot(device, effective_state, applied)
duration = (time.perf_counter() - started) * 1000
finished = utc_now()
with self.database.transaction() as conn:
conn.execute(
"UPDATE commands SET status='succeeded', finished_at=? WHERE id=?",
(finished, command_id),
)
conn.execute(
"INSERT INTO command_results(command_id, device_id, connector_id, status, "
"prior_state_json, applied_state_json, duration_ms, created_at) "
"VALUES (?, ?, ?, 'succeeded', ?, ?, ?, ?)",
(
command_id,
device_id,
device["connector_id"],
json.dumps(prior_state.model_dump(mode="json"), separators=(",", ":")),
json.dumps(applied.model_dump(mode="json"), separators=(",", ":")),
duration,
finished,
),
)
conn.execute(
"UPDATE devices SET state_json=?, desired_state_json=?, last_command_at=?, "
"error_status=NULL, updated_at=? WHERE id=?",
(
json.dumps(applied.model_dump(mode="json"), separators=(",", ":")),
json.dumps(desired.model_dump(mode="json"), separators=(",", ":")),
finished,
finished,
device_id,
),
)
self.repo.audit(
request_id=request_id,
actor=actor,
action="device.set_state",
resource_type="device",
resource_id=device_id,
outcome="succeeded",
detail={
"command_id": command_id,
"state": effective_state.model_dump(mode="json", exclude_none=True),
},
)
await self.events.publish(
"command.completed",
{"command_id": command_id, "device_id": device_id, "status": "succeeded"},
)
return {
"id": command_id,
"status": "succeeded",
"device_id": device_id,
"state": applied.model_dump(mode="json"),
"duration_ms": duration,
}
except Exception as exc:
duration = (time.perf_counter() - started) * 1000
finished = utc_now()
code = exc.code if isinstance(exc, LumaOpsError) else "command_failed"
with self.database.transaction() as conn:
conn.execute(
"UPDATE commands SET status='failed', finished_at=? WHERE id=?",
(finished, command_id),
)
conn.execute(
"INSERT INTO command_results(command_id, device_id, connector_id, status, "
"prior_state_json, error_code, error_message, duration_ms, created_at) "
"VALUES (?, ?, ?, 'failed', ?, ?, ?, ?, ?)",
(
command_id,
device_id,
device["connector_id"],
json.dumps(prior_state.model_dump(mode="json")) if prior_state else None,
code,
str(exc),
duration,
finished,
),
)
conn.execute(
"UPDATE devices SET error_status=?, updated_at=? WHERE id=?",
(code, finished, device_id),
)
self.repo.audit(
request_id=request_id,
actor=actor,
action="device.set_state",
resource_type="device",
resource_id=device_id,
outcome="failed",
detail={"command_id": command_id, "error_code": code},
)
await self.events.publish(
"command.completed",
{
"command_id": command_id,
"device_id": device_id,
"status": "failed",
"error": code,
},
)
raise
async def restore_connector(self, connector_id: str) -> dict[str, Any]:
devices, _ = self.inventory.list_devices(
limit=4096,
online=True,
include_hidden=True,
)
candidates = [device for device in devices if device["connector_id"] == connector_id]
restored = 0
unchanged = 0
skipped = 0
failed = 0
for device in candidates:
desired_payload = device.get("desired_state")
capabilities = DeviceCapabilities.model_validate(device["capabilities"])
if (
not desired_payload
or not capabilities.restore
or device["blocked"]
or device["read_only"]
or device["owner"] == "unmanaged"
):
skipped += 1
continue
desired = DeviceState.model_validate(desired_payload)
actual = DeviceState.model_validate(device["state"])
if self._state_matches(actual, desired):
unchanged += 1
continue
try:
await self.execute_device(
device["id"],
desired,
actor="system-restore",
request_id=f"restore-{uuid.uuid4()}",
)
restored += 1
except Exception as exc:
failed += 1
LOGGER.warning(
"Desired state restore failed: connector=%s device=%s error=%s",
connector_id,
device["id"],
exc,
)
result = {
"status": "completed" if not failed else "degraded",
"connector_id": connector_id,
"restored": restored,
"unchanged": unchanged,
"skipped": skipped,
"failed": failed,
}
LOGGER.info("Desired state reconciliation: %s", result)
return result
@staticmethod
def _effective_state(device: dict[str, Any], requested: DeviceState) -> DeviceState:
prior_payload = device.get("desired_state")
explicit_control_fields = (
"mode",
"mode_index",
"brightness",
"speed",
"direction",
"zone_index",
"led_index",
"transition_ms",
)
direct_color_mode = CommandService._is_direct_color_mode(device, requested)
if (
requested.power is not True
or requested.colors
or not prior_payload
or (
any(getattr(requested, field) is not None for field in explicit_control_fields)
and not direct_color_mode
)
):
return requested
prior = DeviceState.model_validate(prior_payload)
if not prior.colors or not any(
color.red or color.green or color.blue for color in prior.colors
):
return requested
effective = requested.model_dump(mode="json")
effective["colors"] = [color.model_dump(mode="json") for color in prior.colors]
for field in ("mode", "mode_index", "brightness", "speed", "direction"):
if effective[field] is None:
effective[field] = getattr(prior, field)
return DeviceState.model_validate(effective)
@staticmethod
def _is_direct_color_mode(device: dict[str, Any], requested: DeviceState) -> bool:
if requested.mode is not None:
return requested.mode.casefold() in {"direct", "custom"}
if requested.mode_index is None:
return False
return any(
int(mode.get("index", -1)) == requested.mode_index
and str(mode.get("name", "")).casefold() in {"direct", "custom"}
for mode in device.get("modes", [])
)
@staticmethod
def _desired_snapshot(
device: dict[str, Any], requested: DeviceState, applied: DeviceState
) -> DeviceState:
payload = applied.model_dump(mode="json")
prior_payload = device.get("desired_state")
if requested.power is False and not requested.colors and prior_payload:
prior = DeviceState.model_validate(prior_payload)
if prior.colors and any(
color.red or color.green or color.blue for color in prior.colors
):
payload["colors"] = [color.model_dump(mode="json") for color in prior.colors]
payload["mode"] = prior.mode
payload["mode_index"] = prior.mode_index
payload["brightness"] = prior.brightness
payload["speed"] = prior.speed
payload["direction"] = prior.direction
return DeviceState.model_validate(payload)
@staticmethod
def _state_matches(actual: DeviceState, desired: DeviceState) -> bool:
if desired.power is False:
return actual.power is False
if desired.power is not None and actual.power is not desired.power:
return False
if desired.mode_index is not None and actual.mode_index != desired.mode_index:
return False
if desired.mode_index is None and desired.mode is not None and actual.mode != desired.mode:
return False
for field in ("brightness", "speed", "direction"):
expected = getattr(desired, field)
if expected is not None and getattr(actual, field) != expected:
return False
if desired.colors:
if not actual.colors:
return False
expected_colors = desired.colors
if len(expected_colors) == 1 and len(actual.colors) > 1:
expected_colors = expected_colors * len(actual.colors)
if expected_colors != actual.colors:
return False
return True
def _validate_device(
self, device: dict[str, Any], state: DeviceState, bypass_emergency: bool
) -> None:
if self._emergency_stop and not bypass_emergency:
raise ConflictError("De globale noodstop is actief.", device_id=device["id"])
if not device["online"]:
raise LumaOpsError("device_offline", "Het apparaat is offline.", 409)
if device["blocked"]:
raise LumaOpsError("device_blocked", "Het apparaat is volledig geblokkeerd.", 403)
if device["read_only"]:
raise LumaOpsError("device_read_only", "Het apparaat staat in alleen-lezenmodus.", 403)
if device["owner"] == "unmanaged":
raise LumaOpsError(
"device_unmanaged", "Het apparaat heeft geen beheerder geselecteerd.", 409
)
capabilities = DeviceCapabilities.model_validate(device["capabilities"])
requirements = {
"power": state.power is not None,
"brightness": state.brightness is not None,
"rgb": state.colors is not None,
"effect": state.mode is not None or state.mode_index is not None,
"speed": state.speed is not None,
"per_zone": state.zone_index is not None,
"per_led": state.led_index is not None,
}
unsupported = [
name
for name, required in requirements.items()
if required and not getattr(capabilities, name)
]
if unsupported:
raise LumaOpsError(
"unsupported_capability",
"Het apparaat ondersteunt niet alle gevraagde eigenschappen.",
422,
details={"unsupported": unsupported},
)
def _bucket(self, device_id: str) -> TokenBucket:
if device_id not in self._buckets:
rate = self.settings.command_rate_per_second
self._buckets[device_id] = TokenBucket(
rate, max(1.0, rate), max(1.0, rate), time.monotonic()
)
return self._buckets[device_id]
async def identify(self, device_id: str) -> dict[str, Any]:
device = self.inventory.get_device(device_id)
connector = self.registry.get(device["connector_id"])
prior = await connector.get_state(device["external_id"])
flash = DeviceState(power=True, colors=[RGBColor(red=96, green=160, blue=255)])
async with self._locks[device_id]:
for _ in range(2):
await connector.set_state(device["external_id"], flash)
await asyncio.sleep(0.35)
await connector.set_state(device["external_id"], DeviceState(power=False))
await asyncio.sleep(0.35)
await connector.set_state(device["external_id"], prior)
return {"status": "completed", "device_id": device_id}
async def all_off(self, *, emergency: bool = False) -> dict[str, Any]:
if emergency:
self._emergency_stop = True
devices, _ = self.inventory.list_devices(limit=4096, online=True, include_hidden=True)
results = []
for device in devices:
if device["blocked"] or device["read_only"]:
results.append({"device_id": device["id"], "status": "skipped"})
continue
try:
result = await self.execute_device(
device["id"],
DeviceState(power=False),
bypass_emergency=emergency,
)
results.append(result)
except Exception as exc:
results.append({"device_id": device["id"], "status": "failed", "error": str(exc)})
return {"status": "completed", "emergency_stop": self._emergency_stop, "results": results}
def clear_emergency_stop(self) -> None:
self._emergency_stop = False
@property
def emergency_stop(self) -> bool:
return self._emergency_stop
@@ -1,84 +1,84 @@
"""Allowlisted and redacted diagnostic bundle generation."""
from __future__ import annotations
import io
import json
import platform
import zipfile
from typing import Any
from ..config import Settings
from ..database import Database, utc_now
from ..repository import row_to_dict
from .health import HealthService
from .inventory import InventoryService
class DiagnosticsService:
def __init__(
self,
settings: Settings,
database: Database,
health: HealthService,
inventory: InventoryService,
) -> None:
self.settings = settings
self.database = database
self.health = health
self.inventory = inventory
async def build(self) -> bytes:
health = await self.health.detailed()
devices, _ = self.inventory.list_devices(limit=4096, include_hidden=True)
sanitized_devices = [
{
"id": device["id"],
"source": device["source"],
"vendor": device["vendor"],
"model": device["model"],
"online": device["online"],
"capabilities": device["capabilities"],
"error_status": device["error_status"],
}
for device in devices
]
with self.database.connection() as conn:
error_rows = conn.execute(
"SELECT id, category, severity, title, message, created_at FROM activity "
"WHERE severity IN ('warning', 'error') ORDER BY created_at DESC LIMIT 100"
).fetchall()
migration_rows = conn.execute(
"SELECT version, applied_at FROM schema_migrations ORDER BY version"
).fetchall()
info: dict[str, Any] = {
"generated_at": utc_now(),
"lumaops_version": self.settings.app_version,
"openrgb_required_version": "1.0rc3",
"sdk_protocol": 5,
"python": platform.python_version(),
"platform": platform.platform(),
"environment": self.settings.environment,
"features": {
"network_discovery": self.settings.enable_network_discovery,
"wled": self.settings.enable_wled,
"home_assistant": self.settings.enable_home_assistant,
},
}
output = io.BytesIO()
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
self._json(archive, "system.json", info)
self._json(archive, "health.json", health)
self._json(archive, "devices-redacted.json", sanitized_devices)
self._json(archive, "recent-errors.json", [row_to_dict(row) for row in error_rows])
self._json(archive, "migrations.json", [dict(row) for row in migration_rows])
archive.writestr(
"README.txt",
"LumaOps diagnostic bundle. Secrets, raw environment variables, database files, "
"cookies, tokens, IP addresses and device serial numbers are deliberately excluded.\n",
)
return output.getvalue()
@staticmethod
def _json(archive: zipfile.ZipFile, name: str, value: Any) -> None:
archive.writestr(name, json.dumps(value, ensure_ascii=False, indent=2, default=str))
"""Allowlisted and redacted diagnostic bundle generation."""
from __future__ import annotations
import io
import json
import platform
import zipfile
from typing import Any
from ..config import Settings
from ..database import Database, utc_now
from ..repository import row_to_dict
from .health import HealthService
from .inventory import InventoryService
class DiagnosticsService:
def __init__(
self,
settings: Settings,
database: Database,
health: HealthService,
inventory: InventoryService,
) -> None:
self.settings = settings
self.database = database
self.health = health
self.inventory = inventory
async def build(self) -> bytes:
health = await self.health.detailed()
devices, _ = self.inventory.list_devices(limit=4096, include_hidden=True)
sanitized_devices = [
{
"id": device["id"],
"source": device["source"],
"vendor": device["vendor"],
"model": device["model"],
"online": device["online"],
"capabilities": device["capabilities"],
"error_status": device["error_status"],
}
for device in devices
]
with self.database.connection() as conn:
error_rows = conn.execute(
"SELECT id, category, severity, title, message, created_at FROM activity "
"WHERE severity IN ('warning', 'error') ORDER BY created_at DESC LIMIT 100"
).fetchall()
migration_rows = conn.execute(
"SELECT version, applied_at FROM schema_migrations ORDER BY version"
).fetchall()
info: dict[str, Any] = {
"generated_at": utc_now(),
"lumaops_version": self.settings.app_version,
"openrgb_required_version": "1.0rc3",
"sdk_protocol": 5,
"python": platform.python_version(),
"platform": platform.platform(),
"environment": self.settings.environment,
"features": {
"network_discovery": self.settings.enable_network_discovery,
"wled": self.settings.enable_wled,
"home_assistant": self.settings.enable_home_assistant,
},
}
output = io.BytesIO()
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
self._json(archive, "system.json", info)
self._json(archive, "health.json", health)
self._json(archive, "devices-redacted.json", sanitized_devices)
self._json(archive, "recent-errors.json", [row_to_dict(row) for row in error_rows])
self._json(archive, "migrations.json", [dict(row) for row in migration_rows])
archive.writestr(
"README.txt",
"LumaOps diagnostic bundle. Secrets, raw environment variables, database files, "
"cookies, tokens, IP addresses and device serial numbers are deliberately excluded.\n",
)
return output.getvalue()
@staticmethod
def _json(archive: zipfile.ZipFile, name: str, value: Any) -> None:
archive.writestr(name, json.dumps(value, ensure_ascii=False, indent=2, default=str))
@@ -1,94 +1,94 @@
"""Aggregated system health without making hardware a liveness dependency."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from ..config import Settings
from ..connectors.base import HealthStatus
from ..connectors.registry import ConnectorRegistry
from ..database import Database, utc_now
class HealthService:
def __init__(self, settings: Settings, database: Database, registry: ConnectorRegistry) -> None:
self.settings = settings
self.database = database
self.registry = registry
async def detailed(self) -> dict[str, Any]:
connectors = await self.registry.health()
components: dict[str, dict[str, Any]] = {
"database": {
"status": "healthy" if self.database.ping() else "unhealthy",
"message": "SQLite is bereikbaar."
if self.database.ping()
else "SQLite is niet bereikbaar.",
},
"storage": self._storage_health(),
"openrgb_process": self._process_health(),
}
components.update(
{
connector_id: health.model_dump(mode="json")
for connector_id, health in connectors.items()
}
)
statuses = {component["status"] for component in components.values()}
if "unhealthy" in statuses:
aggregate = (
"unhealthy" if components["database"]["status"] == "unhealthy" else "degraded"
)
elif "degraded" in statuses or "unknown" in statuses:
aggregate = "degraded"
else:
aggregate = "healthy"
return {
"status": aggregate,
"components": components,
"checked_at": utc_now(),
}
def ready(self) -> bool:
return self.database.ping()
def _storage_health(self) -> dict[str, Any]:
paths = [self.settings.config_dir, self.settings.data_dir, self.settings.logs_dir]
failures = [
str(path) for path in paths if not path.exists() or not os.access(path, os.W_OK)
]
return {
"status": "degraded" if failures else "healthy",
"message": "Persistente opslag is schrijfbaar."
if not failures
else "Niet alle opslagpaden zijn schrijfbaar.",
"details": {"failed_paths": failures},
}
@staticmethod
def _process_health() -> dict[str, Any]:
pid_file = Path("/run/lumaops/openrgb.pid")
if not pid_file.exists():
return {
"status": "unknown",
"message": "Geen supervisor-PID-bestand beschikbaar.",
}
try:
pid = int(pid_file.read_text(encoding="ascii").strip())
os.kill(pid, 0)
return {
"status": "healthy",
"message": "OpenRGB-proces draait.",
"details": {"pid": pid},
}
except (OSError, ValueError):
return {"status": "unhealthy", "message": "OpenRGB-proces draait niet."}
def connector_health_status(value: str) -> HealthStatus:
try:
return HealthStatus(value)
except ValueError:
return HealthStatus.UNKNOWN
"""Aggregated system health without making hardware a liveness dependency."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from ..config import Settings
from ..connectors.base import HealthStatus
from ..connectors.registry import ConnectorRegistry
from ..database import Database, utc_now
class HealthService:
def __init__(self, settings: Settings, database: Database, registry: ConnectorRegistry) -> None:
self.settings = settings
self.database = database
self.registry = registry
async def detailed(self) -> dict[str, Any]:
connectors = await self.registry.health()
components: dict[str, dict[str, Any]] = {
"database": {
"status": "healthy" if self.database.ping() else "unhealthy",
"message": "SQLite is bereikbaar."
if self.database.ping()
else "SQLite is niet bereikbaar.",
},
"storage": self._storage_health(),
"openrgb_process": self._process_health(),
}
components.update(
{
connector_id: health.model_dump(mode="json")
for connector_id, health in connectors.items()
}
)
statuses = {component["status"] for component in components.values()}
if "unhealthy" in statuses:
aggregate = (
"unhealthy" if components["database"]["status"] == "unhealthy" else "degraded"
)
elif "degraded" in statuses or "unknown" in statuses:
aggregate = "degraded"
else:
aggregate = "healthy"
return {
"status": aggregate,
"components": components,
"checked_at": utc_now(),
}
def ready(self) -> bool:
return self.database.ping()
def _storage_health(self) -> dict[str, Any]:
paths = [self.settings.config_dir, self.settings.data_dir, self.settings.logs_dir]
failures = [
str(path) for path in paths if not path.exists() or not os.access(path, os.W_OK)
]
return {
"status": "degraded" if failures else "healthy",
"message": "Persistente opslag is schrijfbaar."
if not failures
else "Niet alle opslagpaden zijn schrijfbaar.",
"details": {"failed_paths": failures},
}
@staticmethod
def _process_health() -> dict[str, Any]:
pid_file = Path("/run/lumaops/openrgb.pid")
if not pid_file.exists():
return {
"status": "unknown",
"message": "Geen supervisor-PID-bestand beschikbaar.",
}
try:
pid = int(pid_file.read_text(encoding="ascii").strip())
os.kill(pid, 0)
return {
"status": "healthy",
"message": "OpenRGB-proces draait.",
"details": {"pid": pid},
}
except (OSError, ValueError):
return {"status": "unhealthy", "message": "OpenRGB-proces draait niet."}
def connector_health_status(value: str) -> HealthStatus:
try:
return HealthStatus(value)
except ValueError:
return HealthStatus.UNKNOWN
@@ -1,446 +1,446 @@
"""Stable device identity reconciliation and inventory queries."""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import uuid
from collections.abc import Awaitable, Callable
from typing import Any
from ..connectors.base import ConnectorDevice
from ..connectors.registry import ConnectorRegistry
from ..database import Database, utc_now
from ..errors import NotFoundError
from ..events import EventBus
from ..repository import row_to_dict
from ..schemas import DevicePatch
LOGGER = logging.getLogger(__name__)
ReconcileCallback = Callable[[str], Awaitable[dict[str, Any]]]
GROUPED_DEVICE_TYPES = frozenset({"dram"})
class InventoryService:
def __init__(self, database: Database, registry: ConnectorRegistry, events: EventBus) -> None:
self.database = database
self.registry = registry
self.events = events
self._sync_lock = asyncio.Lock()
self._reconcile_callback: ReconcileCallback | None = None
for connector in registry.all():
connector.set_inventory_callback(self.sync_connector)
def set_reconcile_callback(self, callback: ReconcileCallback | None) -> None:
self._reconcile_callback = callback
async def initialize(self) -> None:
now = utc_now()
with self.database.connection() as conn:
for connector in self.registry.all():
conn.execute(
"INSERT INTO connectors(id, kind, name, enabled, config_json, created_at, updated_at) "
"VALUES (?, ?, ?, 1, '{}', ?, ?) ON CONFLICT(id) DO UPDATE SET "
"kind=excluded.kind, updated_at=excluded.updated_at, deleted_at=NULL",
(connector.id, connector.kind, connector.id, now, now),
)
async def sync_all(self) -> dict[str, Any]:
results: dict[str, Any] = {}
for connector in self.registry.all():
try:
results[connector.id] = await self.sync_connector(connector.id)
except Exception as exc:
LOGGER.warning("Inventory sync failed for %s: %s", connector.id, exc)
results[connector.id] = {"status": "failed", "error": str(exc)}
return results
async def sync_connector(self, connector_id: str, *, reconcile: bool = True) -> dict[str, Any]:
async with self._sync_lock:
connector = self.registry.get(connector_id)
devices = await connector.inventory()
now = utc_now()
seen: set[str] = set()
with self.database.transaction() as conn:
conn.execute(
"UPDATE devices SET online=0, updated_at=? WHERE connector_id=? AND deleted_at IS NULL",
(now, connector_id),
)
for device in devices:
device_id = self._upsert(conn, connector_id, device, now)
seen.add(device_id)
conn.execute(
"UPDATE connectors SET health='healthy', last_error=NULL, last_seen_at=?, updated_at=? WHERE id=?",
(now, now, connector_id),
)
await self.events.publish(
"inventory.updated", {"connector_id": connector_id, "device_count": len(seen)}
)
reconciliation: dict[str, Any] = {"status": "disabled"}
if reconcile and self._reconcile_callback:
try:
reconciliation = await self._reconcile_callback(connector_id)
except Exception as exc:
LOGGER.exception("Desired state reconciliation failed for %s", connector_id)
reconciliation = {"status": "failed", "error": str(exc)}
return {
"status": "completed",
"connector_id": connector_id,
"device_count": len(seen),
"reconciliation": reconciliation,
}
def _upsert(self, conn: Any, connector_id: str, device: ConnectorDevice, now: str) -> str:
row = conn.execute(
"SELECT id FROM devices WHERE connector_id=? AND fingerprint=? AND deleted_at IS NULL",
(connector_id, device.fingerprint),
).fetchone()
if row is None:
row = conn.execute(
"SELECT d.id FROM devices d JOIN device_identities i ON i.device_id=d.id "
"WHERE d.connector_id=? AND i.kind='external_id' AND i.value=? AND d.deleted_at IS NULL",
(connector_id, device.external_id),
).fetchone()
device_id = row["id"] if row else str(uuid.uuid4())
payload = (
device_id,
connector_id,
device.external_id,
device.fingerprint,
device.source,
device.device_type,
device.name,
device.vendor,
device.model,
device.serial,
device.location,
device.ip_address,
device.firmware_version,
device.controller_index,
json.dumps(device.capabilities.model_dump(mode="json"), separators=(",", ":")),
json.dumps(device.state.model_dump(mode="json"), separators=(",", ":")),
json.dumps(device.zones, separators=(",", ":")),
json.dumps(device.modes, separators=(",", ":")),
json.dumps(device.metadata, separators=(",", ":")),
device.led_count,
int(device.online),
int(device.experimental),
now,
now,
now,
)
conn.execute(
"INSERT INTO devices(id, connector_id, external_id, fingerprint, source, device_type, name, vendor, model, "
"serial, location, ip_address, firmware_version, controller_index, capabilities_json, state_json, "
"zones_json, modes_json, metadata_json, led_count, online, experimental, last_detected_at, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET external_id=excluded.external_id, fingerprint=excluded.fingerprint, "
"source=excluded.source, device_type=excluded.device_type, name=excluded.name, vendor=excluded.vendor, model=excluded.model, "
"serial=excluded.serial, location=excluded.location, ip_address=excluded.ip_address, "
"firmware_version=excluded.firmware_version, controller_index=excluded.controller_index, "
"capabilities_json=excluded.capabilities_json, state_json=excluded.state_json, "
"zones_json=excluded.zones_json, modes_json=excluded.modes_json, metadata_json=excluded.metadata_json, led_count=excluded.led_count, "
"online=excluded.online, experimental=excluded.experimental, last_detected_at=excluded.last_detected_at, "
"updated_at=excluded.updated_at, deleted_at=NULL",
payload,
)
identities = {
"external_id": device.external_id,
"fingerprint": device.fingerprint,
"serial": device.serial,
"location": device.location,
}
for kind, value in identities.items():
if value:
conn.execute(
"INSERT INTO device_identities(device_id, kind, value, first_seen_at, last_seen_at) "
"VALUES (?, ?, ?, ?, ?) ON CONFLICT(device_id, kind, value) DO UPDATE SET "
"last_seen_at=excluded.last_seen_at",
(device_id, kind, value, now, now),
)
return device_id
def list_devices(
self,
*,
limit: int = 100,
offset: int = 0,
online: bool | None = None,
room_id: str | None = None,
source: str | None = None,
include_hidden: bool = False,
sort: str = "name",
) -> tuple[list[dict[str, Any]], int]:
clauses = ["d.deleted_at IS NULL"]
params: list[Any] = []
if not include_hidden:
clauses.append("d.hidden=0")
if online is not None:
clauses.append("d.online=?")
params.append(int(online))
if room_id:
clauses.append("d.room_id=?")
params.append(room_id)
if source:
clauses.append("d.source=?")
params.append(source)
order_map = {
"name": "COALESCE(d.alias, d.name) COLLATE NOCASE ASC",
"-name": "COALESCE(d.alias, d.name) COLLATE NOCASE DESC",
"online": "d.online DESC, COALESCE(d.alias, d.name) ASC",
"recent": "d.last_detected_at DESC",
}
order = order_map.get(sort, order_map["name"])
where = " AND ".join(clauses)
with self.database.connection() as conn:
total = conn.execute(
f"SELECT COUNT(*) FROM devices d WHERE {where}",
tuple(params), # noqa: S608
).fetchone()[0]
rows = conn.execute(
f"SELECT d.*, r.name AS room_name FROM devices d LEFT JOIN rooms r ON r.id=d.room_id " # noqa: S608
f"WHERE {where} ORDER BY {order} LIMIT ? OFFSET ?",
(*params, limit, offset),
).fetchall()
devices = [row_to_dict(row) or {} for row in rows]
self._attach_tags(conn, devices)
return devices, total
def get_device(self, device_id: str) -> dict[str, Any]:
with self.database.connection() as conn:
row = conn.execute(
"SELECT d.*, r.name AS room_name FROM devices d LEFT JOIN rooms r ON r.id=d.room_id "
"WHERE d.id=? AND d.deleted_at IS NULL",
(device_id,),
).fetchone()
result = row_to_dict(row)
if result is None:
raise NotFoundError("Apparaat", device_id)
with self.database.connection() as conn:
self._attach_tags(conn, [result])
return result
def list_device_groups(self) -> list[dict[str, Any]]:
"""Return stable physical-device families that should be presented as one device."""
devices, _ = self.list_devices(limit=4096, include_hidden=False, sort="name")
buckets: dict[tuple[str, str, str, str, str], list[dict[str, Any]]] = {}
for device in devices:
device_type = str(device.get("device_type") or "unknown")
if device_type not in GROUPED_DEVICE_TYPES:
continue
key = (
str(device.get("connector_id") or ""),
device_type,
str(device.get("vendor") or ""),
str(device.get("model") or ""),
str(device.get("name") or ""),
)
buckets.setdefault(key, []).append(device)
groups = [
self._device_group(key, members) for key, members in buckets.items() if len(members) > 1
]
return sorted(groups, key=lambda item: (str(item["name"]).casefold(), str(item["id"])))
def get_device_group(self, group_id: str) -> dict[str, Any]:
group = next((item for item in self.list_device_groups() if item["id"] == group_id), None)
if group is None:
raise NotFoundError("Apparaatgroep", group_id)
return group
@staticmethod
def _device_group(
key: tuple[str, str, str, str, str], members: list[dict[str, Any]]
) -> dict[str, Any]:
connector_id, device_type, vendor, model, name = key
ordered = sorted(
members,
key=lambda item: (
item.get("controller_index") is None,
item.get("controller_index") or 0,
str(item["id"]),
),
)
digest = hashlib.sha256("\x1f".join(key).encode()).hexdigest()[:16]
first = ordered[0]
states = [item.get("state") or {} for item in ordered]
state_fields = (
"power",
"brightness",
"colors",
"mode",
"mode_index",
"speed",
"direction",
)
aggregate_state = {
field: states[0].get(field)
if all(state.get(field) == states[0].get(field) for state in states[1:])
else None
for field in state_fields
}
mixed = any(
any(state.get(field) != states[0].get(field) for state in states[1:])
for field in state_fields
if field in states[0]
)
boolean_capabilities = (
"power",
"restore",
"rgb",
"brightness",
"color_temperature",
"effect",
"speed",
"direction",
"multiple_colors",
"per_zone",
"per_segment",
"per_led",
"profiles",
"readable_state",
)
capabilities: dict[str, Any] = {
field: all(bool((item.get("capabilities") or {}).get(field)) for item in ordered)
for field in boolean_capabilities
}
capabilities.update(
{
"max_leds": sum(int(item.get("led_count") or 0) for item in ordered),
"min_brightness": max(
int((item.get("capabilities") or {}).get("min_brightness") or 0)
for item in ordered
),
"max_brightness": min(
int((item.get("capabilities") or {}).get("max_brightness") or 100)
for item in ordered
),
"min_speed": InventoryService._shared_limit(ordered, "min_speed", max),
"max_speed": InventoryService._shared_limit(ordered, "max_speed", min),
}
)
modes = []
for mode in first.get("modes") or []:
name_key = str(mode.get("name") or "").casefold()
matches = [
next(
(
candidate
for candidate in item.get("modes") or []
if str(candidate.get("name") or "").casefold() == name_key
),
None,
)
for item in ordered[1:]
]
signature = {
field: mode.get(field)
for field in (
"flags",
"speed_min",
"speed_max",
"brightness",
"colors_min",
"colors_max",
)
}
if all(
match is not None
and all(match.get(field) == value for field, value in signature.items())
for match in matches
):
modes.append(mode)
return {
"id": f"{device_type}-{digest}",
"kind": "device-family",
"device_type": device_type,
"connector_id": connector_id,
"name": name,
"vendor": vendor or None,
"model": model or None,
"online": all(bool(item.get("online")) for item in ordered),
"online_count": sum(bool(item.get("online")) for item in ordered),
"module_count": len(ordered),
"led_count": sum(int(item.get("led_count") or 0) for item in ordered),
"capabilities": capabilities,
"state": aggregate_state,
"modes": modes,
"mixed": mixed,
"devices": ordered,
}
@staticmethod
def _shared_limit(
devices: list[dict[str, Any]], field: str, reducer: Callable[[list[int]], int]
) -> int | None:
values = [
int(value)
for item in devices
if (value := (item.get("capabilities") or {}).get(field)) is not None
]
return reducer(values) if len(values) == len(devices) else None
def patch_device(self, device_id: str, patch: DevicePatch) -> dict[str, Any]:
values = patch.model_dump(exclude_unset=True)
if not values:
return self.get_device(device_id)
tags = values.pop("tags", None)
allowed = {
"alias",
"room_id",
"hidden",
"favorite",
"exclude_global",
"read_only",
"blocked",
"owner",
}
columns: list[str] = []
params: list[Any] = []
for key, value in values.items():
if key not in allowed:
continue
columns.append(f"{key}=?")
params.append(int(value) if isinstance(value, bool) else value)
columns.append("updated_at=?")
params.extend([utc_now(), device_id])
with self.database.transaction() as conn:
cursor = conn.execute(
f"UPDATE devices SET {', '.join(columns)} WHERE id=? AND deleted_at IS NULL", # noqa: S608
tuple(params),
)
if not cursor.rowcount:
raise NotFoundError("Apparaat", device_id)
if tags is not None:
conn.execute("DELETE FROM device_tags WHERE device_id=?", (device_id,))
for name in tags:
tag_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO tags(id, name, created_at) VALUES (?, ?, ?) "
"ON CONFLICT(name) DO NOTHING",
(tag_id, name, utc_now()),
)
row = conn.execute("SELECT id FROM tags WHERE name=?", (name,)).fetchone()
conn.execute(
"INSERT INTO device_tags(device_id, tag_id) VALUES (?, ?)",
(device_id, row["id"]),
)
return self.get_device(device_id)
@staticmethod
def _attach_tags(conn: Any, devices: list[dict[str, Any]]) -> None:
if not devices:
return
identifiers = [device["id"] for device in devices]
placeholders = ",".join("?" for _ in identifiers)
rows = conn.execute(
f"SELECT dt.device_id, t.name FROM device_tags dt JOIN tags t ON t.id=dt.tag_id " # noqa: S608
f"WHERE dt.device_id IN ({placeholders}) ORDER BY t.name COLLATE NOCASE",
identifiers,
).fetchall()
tags: dict[str, list[str]] = {identifier: [] for identifier in identifiers}
for row in rows:
tags[row["device_id"]].append(row["name"])
for device in devices:
device["tags"] = tags[device["id"]]
"""Stable device identity reconciliation and inventory queries."""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import uuid
from collections.abc import Awaitable, Callable
from typing import Any
from ..connectors.base import ConnectorDevice
from ..connectors.registry import ConnectorRegistry
from ..database import Database, utc_now
from ..errors import NotFoundError
from ..events import EventBus
from ..repository import row_to_dict
from ..schemas import DevicePatch
LOGGER = logging.getLogger(__name__)
ReconcileCallback = Callable[[str], Awaitable[dict[str, Any]]]
GROUPED_DEVICE_TYPES = frozenset({"dram"})
class InventoryService:
def __init__(self, database: Database, registry: ConnectorRegistry, events: EventBus) -> None:
self.database = database
self.registry = registry
self.events = events
self._sync_lock = asyncio.Lock()
self._reconcile_callback: ReconcileCallback | None = None
for connector in registry.all():
connector.set_inventory_callback(self.sync_connector)
def set_reconcile_callback(self, callback: ReconcileCallback | None) -> None:
self._reconcile_callback = callback
async def initialize(self) -> None:
now = utc_now()
with self.database.connection() as conn:
for connector in self.registry.all():
conn.execute(
"INSERT INTO connectors(id, kind, name, enabled, config_json, created_at, updated_at) "
"VALUES (?, ?, ?, 1, '{}', ?, ?) ON CONFLICT(id) DO UPDATE SET "
"kind=excluded.kind, updated_at=excluded.updated_at, deleted_at=NULL",
(connector.id, connector.kind, connector.id, now, now),
)
async def sync_all(self) -> dict[str, Any]:
results: dict[str, Any] = {}
for connector in self.registry.all():
try:
results[connector.id] = await self.sync_connector(connector.id)
except Exception as exc:
LOGGER.warning("Inventory sync failed for %s: %s", connector.id, exc)
results[connector.id] = {"status": "failed", "error": str(exc)}
return results
async def sync_connector(self, connector_id: str, *, reconcile: bool = True) -> dict[str, Any]:
async with self._sync_lock:
connector = self.registry.get(connector_id)
devices = await connector.inventory()
now = utc_now()
seen: set[str] = set()
with self.database.transaction() as conn:
conn.execute(
"UPDATE devices SET online=0, updated_at=? WHERE connector_id=? AND deleted_at IS NULL",
(now, connector_id),
)
for device in devices:
device_id = self._upsert(conn, connector_id, device, now)
seen.add(device_id)
conn.execute(
"UPDATE connectors SET health='healthy', last_error=NULL, last_seen_at=?, updated_at=? WHERE id=?",
(now, now, connector_id),
)
await self.events.publish(
"inventory.updated", {"connector_id": connector_id, "device_count": len(seen)}
)
reconciliation: dict[str, Any] = {"status": "disabled"}
if reconcile and self._reconcile_callback:
try:
reconciliation = await self._reconcile_callback(connector_id)
except Exception as exc:
LOGGER.exception("Desired state reconciliation failed for %s", connector_id)
reconciliation = {"status": "failed", "error": str(exc)}
return {
"status": "completed",
"connector_id": connector_id,
"device_count": len(seen),
"reconciliation": reconciliation,
}
def _upsert(self, conn: Any, connector_id: str, device: ConnectorDevice, now: str) -> str:
row = conn.execute(
"SELECT id FROM devices WHERE connector_id=? AND fingerprint=? AND deleted_at IS NULL",
(connector_id, device.fingerprint),
).fetchone()
if row is None:
row = conn.execute(
"SELECT d.id FROM devices d JOIN device_identities i ON i.device_id=d.id "
"WHERE d.connector_id=? AND i.kind='external_id' AND i.value=? AND d.deleted_at IS NULL",
(connector_id, device.external_id),
).fetchone()
device_id = row["id"] if row else str(uuid.uuid4())
payload = (
device_id,
connector_id,
device.external_id,
device.fingerprint,
device.source,
device.device_type,
device.name,
device.vendor,
device.model,
device.serial,
device.location,
device.ip_address,
device.firmware_version,
device.controller_index,
json.dumps(device.capabilities.model_dump(mode="json"), separators=(",", ":")),
json.dumps(device.state.model_dump(mode="json"), separators=(",", ":")),
json.dumps(device.zones, separators=(",", ":")),
json.dumps(device.modes, separators=(",", ":")),
json.dumps(device.metadata, separators=(",", ":")),
device.led_count,
int(device.online),
int(device.experimental),
now,
now,
now,
)
conn.execute(
"INSERT INTO devices(id, connector_id, external_id, fingerprint, source, device_type, name, vendor, model, "
"serial, location, ip_address, firmware_version, controller_index, capabilities_json, state_json, "
"zones_json, modes_json, metadata_json, led_count, online, experimental, last_detected_at, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
"ON CONFLICT(id) DO UPDATE SET external_id=excluded.external_id, fingerprint=excluded.fingerprint, "
"source=excluded.source, device_type=excluded.device_type, name=excluded.name, vendor=excluded.vendor, model=excluded.model, "
"serial=excluded.serial, location=excluded.location, ip_address=excluded.ip_address, "
"firmware_version=excluded.firmware_version, controller_index=excluded.controller_index, "
"capabilities_json=excluded.capabilities_json, state_json=excluded.state_json, "
"zones_json=excluded.zones_json, modes_json=excluded.modes_json, metadata_json=excluded.metadata_json, led_count=excluded.led_count, "
"online=excluded.online, experimental=excluded.experimental, last_detected_at=excluded.last_detected_at, "
"updated_at=excluded.updated_at, deleted_at=NULL",
payload,
)
identities = {
"external_id": device.external_id,
"fingerprint": device.fingerprint,
"serial": device.serial,
"location": device.location,
}
for kind, value in identities.items():
if value:
conn.execute(
"INSERT INTO device_identities(device_id, kind, value, first_seen_at, last_seen_at) "
"VALUES (?, ?, ?, ?, ?) ON CONFLICT(device_id, kind, value) DO UPDATE SET "
"last_seen_at=excluded.last_seen_at",
(device_id, kind, value, now, now),
)
return device_id
def list_devices(
self,
*,
limit: int = 100,
offset: int = 0,
online: bool | None = None,
room_id: str | None = None,
source: str | None = None,
include_hidden: bool = False,
sort: str = "name",
) -> tuple[list[dict[str, Any]], int]:
clauses = ["d.deleted_at IS NULL"]
params: list[Any] = []
if not include_hidden:
clauses.append("d.hidden=0")
if online is not None:
clauses.append("d.online=?")
params.append(int(online))
if room_id:
clauses.append("d.room_id=?")
params.append(room_id)
if source:
clauses.append("d.source=?")
params.append(source)
order_map = {
"name": "COALESCE(d.alias, d.name) COLLATE NOCASE ASC",
"-name": "COALESCE(d.alias, d.name) COLLATE NOCASE DESC",
"online": "d.online DESC, COALESCE(d.alias, d.name) ASC",
"recent": "d.last_detected_at DESC",
}
order = order_map.get(sort, order_map["name"])
where = " AND ".join(clauses)
with self.database.connection() as conn:
total = conn.execute(
f"SELECT COUNT(*) FROM devices d WHERE {where}",
tuple(params), # noqa: S608
).fetchone()[0]
rows = conn.execute(
f"SELECT d.*, r.name AS room_name FROM devices d LEFT JOIN rooms r ON r.id=d.room_id " # noqa: S608
f"WHERE {where} ORDER BY {order} LIMIT ? OFFSET ?",
(*params, limit, offset),
).fetchall()
devices = [row_to_dict(row) or {} for row in rows]
self._attach_tags(conn, devices)
return devices, total
def get_device(self, device_id: str) -> dict[str, Any]:
with self.database.connection() as conn:
row = conn.execute(
"SELECT d.*, r.name AS room_name FROM devices d LEFT JOIN rooms r ON r.id=d.room_id "
"WHERE d.id=? AND d.deleted_at IS NULL",
(device_id,),
).fetchone()
result = row_to_dict(row)
if result is None:
raise NotFoundError("Apparaat", device_id)
with self.database.connection() as conn:
self._attach_tags(conn, [result])
return result
def list_device_groups(self) -> list[dict[str, Any]]:
"""Return stable physical-device families that should be presented as one device."""
devices, _ = self.list_devices(limit=4096, include_hidden=False, sort="name")
buckets: dict[tuple[str, str, str, str, str], list[dict[str, Any]]] = {}
for device in devices:
device_type = str(device.get("device_type") or "unknown")
if device_type not in GROUPED_DEVICE_TYPES:
continue
key = (
str(device.get("connector_id") or ""),
device_type,
str(device.get("vendor") or ""),
str(device.get("model") or ""),
str(device.get("name") or ""),
)
buckets.setdefault(key, []).append(device)
groups = [
self._device_group(key, members) for key, members in buckets.items() if len(members) > 1
]
return sorted(groups, key=lambda item: (str(item["name"]).casefold(), str(item["id"])))
def get_device_group(self, group_id: str) -> dict[str, Any]:
group = next((item for item in self.list_device_groups() if item["id"] == group_id), None)
if group is None:
raise NotFoundError("Apparaatgroep", group_id)
return group
@staticmethod
def _device_group(
key: tuple[str, str, str, str, str], members: list[dict[str, Any]]
) -> dict[str, Any]:
connector_id, device_type, vendor, model, name = key
ordered = sorted(
members,
key=lambda item: (
item.get("controller_index") is None,
item.get("controller_index") or 0,
str(item["id"]),
),
)
digest = hashlib.sha256("\x1f".join(key).encode()).hexdigest()[:16]
first = ordered[0]
states = [item.get("state") or {} for item in ordered]
state_fields = (
"power",
"brightness",
"colors",
"mode",
"mode_index",
"speed",
"direction",
)
aggregate_state = {
field: states[0].get(field)
if all(state.get(field) == states[0].get(field) for state in states[1:])
else None
for field in state_fields
}
mixed = any(
any(state.get(field) != states[0].get(field) for state in states[1:])
for field in state_fields
if field in states[0]
)
boolean_capabilities = (
"power",
"restore",
"rgb",
"brightness",
"color_temperature",
"effect",
"speed",
"direction",
"multiple_colors",
"per_zone",
"per_segment",
"per_led",
"profiles",
"readable_state",
)
capabilities: dict[str, Any] = {
field: all(bool((item.get("capabilities") or {}).get(field)) for item in ordered)
for field in boolean_capabilities
}
capabilities.update(
{
"max_leds": sum(int(item.get("led_count") or 0) for item in ordered),
"min_brightness": max(
int((item.get("capabilities") or {}).get("min_brightness") or 0)
for item in ordered
),
"max_brightness": min(
int((item.get("capabilities") or {}).get("max_brightness") or 100)
for item in ordered
),
"min_speed": InventoryService._shared_limit(ordered, "min_speed", max),
"max_speed": InventoryService._shared_limit(ordered, "max_speed", min),
}
)
modes = []
for mode in first.get("modes") or []:
name_key = str(mode.get("name") or "").casefold()
matches = [
next(
(
candidate
for candidate in item.get("modes") or []
if str(candidate.get("name") or "").casefold() == name_key
),
None,
)
for item in ordered[1:]
]
signature = {
field: mode.get(field)
for field in (
"flags",
"speed_min",
"speed_max",
"brightness",
"colors_min",
"colors_max",
)
}
if all(
match is not None
and all(match.get(field) == value for field, value in signature.items())
for match in matches
):
modes.append(mode)
return {
"id": f"{device_type}-{digest}",
"kind": "device-family",
"device_type": device_type,
"connector_id": connector_id,
"name": name,
"vendor": vendor or None,
"model": model or None,
"online": all(bool(item.get("online")) for item in ordered),
"online_count": sum(bool(item.get("online")) for item in ordered),
"module_count": len(ordered),
"led_count": sum(int(item.get("led_count") or 0) for item in ordered),
"capabilities": capabilities,
"state": aggregate_state,
"modes": modes,
"mixed": mixed,
"devices": ordered,
}
@staticmethod
def _shared_limit(
devices: list[dict[str, Any]], field: str, reducer: Callable[[list[int]], int]
) -> int | None:
values = [
int(value)
for item in devices
if (value := (item.get("capabilities") or {}).get(field)) is not None
]
return reducer(values) if len(values) == len(devices) else None
def patch_device(self, device_id: str, patch: DevicePatch) -> dict[str, Any]:
values = patch.model_dump(exclude_unset=True)
if not values:
return self.get_device(device_id)
tags = values.pop("tags", None)
allowed = {
"alias",
"room_id",
"hidden",
"favorite",
"exclude_global",
"read_only",
"blocked",
"owner",
}
columns: list[str] = []
params: list[Any] = []
for key, value in values.items():
if key not in allowed:
continue
columns.append(f"{key}=?")
params.append(int(value) if isinstance(value, bool) else value)
columns.append("updated_at=?")
params.extend([utc_now(), device_id])
with self.database.transaction() as conn:
cursor = conn.execute(
f"UPDATE devices SET {', '.join(columns)} WHERE id=? AND deleted_at IS NULL", # noqa: S608
tuple(params),
)
if not cursor.rowcount:
raise NotFoundError("Apparaat", device_id)
if tags is not None:
conn.execute("DELETE FROM device_tags WHERE device_id=?", (device_id,))
for name in tags:
tag_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO tags(id, name, created_at) VALUES (?, ?, ?) "
"ON CONFLICT(name) DO NOTHING",
(tag_id, name, utc_now()),
)
row = conn.execute("SELECT id FROM tags WHERE name=?", (name,)).fetchone()
conn.execute(
"INSERT INTO device_tags(device_id, tag_id) VALUES (?, ?)",
(device_id, row["id"]),
)
return self.get_device(device_id)
@staticmethod
def _attach_tags(conn: Any, devices: list[dict[str, Any]]) -> None:
if not devices:
return
identifiers = [device["id"] for device in devices]
placeholders = ",".join("?" for _ in identifiers)
rows = conn.execute(
f"SELECT dt.device_id, t.name FROM device_tags dt JOIN tags t ON t.id=dt.tag_id " # noqa: S608
f"WHERE dt.device_id IN ({placeholders}) ORDER BY t.name COLLATE NOCASE",
identifiers,
).fetchall()
tags: dict[str, list[str]] = {identifier: [] for identifier in identifiers}
for row in rows:
tags[row["device_id"]].append(row["name"])
for device in devices:
device["tags"] = tags[device["id"]]
@@ -1,178 +1,178 @@
"""Transactional room and group resource operations."""
from __future__ import annotations
import json
import uuid
from typing import Any
from ..database import Database, utc_now
from ..errors import NotFoundError
from ..repository import Repository, row_to_dict
from ..schemas import GroupCreate, RoomCreate
class ResourceService:
def __init__(self, database: Database) -> None:
self.database = database
self.repo = Repository(database)
def create_room(self, data: RoomCreate) -> dict[str, Any]:
identifier = str(uuid.uuid4())
now = utc_now()
with self.database.connection() as conn:
conn.execute(
"INSERT INTO rooms(id, name, description, sort_order, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(identifier, data.name, data.description, data.sort_order, now, now),
)
return self.repo.get("rooms", identifier)
def list_rooms(self) -> list[dict[str, Any]]:
with self.database.connection() as conn:
rows = conn.execute(
"SELECT r.*, COUNT(d.id) AS device_count FROM rooms r LEFT JOIN devices d "
"ON d.room_id=r.id AND d.deleted_at IS NULL WHERE r.deleted_at IS NULL "
"GROUP BY r.id ORDER BY r.sort_order, r.name COLLATE NOCASE"
).fetchall()
return [row_to_dict(row) or {} for row in rows]
def update_room(self, identifier: str, data: RoomCreate) -> dict[str, Any]:
with self.database.connection() as conn:
cursor = conn.execute(
"UPDATE rooms SET name=?, description=?, sort_order=?, updated_at=? "
"WHERE id=? AND deleted_at IS NULL",
(data.name, data.description, data.sort_order, utc_now(), identifier),
)
if not cursor.rowcount:
raise NotFoundError("Kamer", identifier)
return self.repo.get("rooms", identifier)
def delete_room(self, identifier: str) -> None:
with self.database.transaction() as conn:
cursor = conn.execute(
"UPDATE rooms SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL",
(utc_now(), utc_now(), identifier),
)
if not cursor.rowcount:
raise NotFoundError("Kamer", identifier)
conn.execute(
"UPDATE devices SET room_id=NULL, updated_at=? WHERE room_id=? AND deleted_at IS NULL",
(utc_now(), identifier),
)
def create_group(self, data: GroupCreate) -> dict[str, Any]:
identifier = str(uuid.uuid4())
now = utc_now()
with self.database.transaction() as conn:
conn.execute(
"INSERT INTO device_groups(id, name, description, dynamic_query_json, sort_order, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
identifier,
data.name,
data.description,
json.dumps(data.dynamic_query) if data.dynamic_query else None,
data.sort_order,
now,
now,
),
)
for index, device_id in enumerate(dict.fromkeys(data.device_ids)):
conn.execute(
"INSERT INTO group_members(group_id, device_id, sort_order) VALUES (?, ?, ?)",
(identifier, device_id, index),
)
return self.get_group(identifier)
def list_groups(self) -> list[dict[str, Any]]:
with self.database.connection() as conn:
rows = conn.execute(
"SELECT g.*, COUNT(m.device_id) AS device_count FROM device_groups g "
"LEFT JOIN group_members m ON m.group_id=g.id WHERE g.deleted_at IS NULL "
"GROUP BY g.id ORDER BY g.sort_order, g.name COLLATE NOCASE"
).fetchall()
groups = [row_to_dict(row) or {} for row in rows]
for group in groups:
group["device_count"] = len(self.get_group(group["id"])["devices"])
return groups
def get_group(self, identifier: str) -> dict[str, Any]:
with self.database.connection() as conn:
group_row = conn.execute(
"SELECT * FROM device_groups WHERE id=? AND deleted_at IS NULL", (identifier,)
).fetchone()
members = conn.execute(
"SELECT d.*, m.sort_order AS membership_order, m.excluded FROM group_members m "
"JOIN devices d ON d.id=m.device_id WHERE m.group_id=? AND d.deleted_at IS NULL "
"ORDER BY m.sort_order",
(identifier,),
).fetchall()
group = row_to_dict(group_row)
if group is None:
raise NotFoundError("Groep", identifier)
devices = [row_to_dict(row) or {} for row in members]
dynamic = group.get("dynamic_query") or {}
tag_names = [str(tag).strip() for tag in dynamic.get("tags", []) if str(tag).strip()]
if tag_names:
placeholders = ",".join("?" for _ in tag_names)
required = len(tag_names) if dynamic.get("match", "all") == "all" else 1
with self.database.connection() as conn:
dynamic_rows = conn.execute(
f"SELECT d.* FROM devices d JOIN device_tags dt ON dt.device_id=d.id " # noqa: S608
"JOIN tags t ON t.id=dt.tag_id WHERE d.deleted_at IS NULL "
f"AND t.name IN ({placeholders}) GROUP BY d.id " # noqa: S608
"HAVING COUNT(DISTINCT t.name) >= ? ORDER BY COALESCE(d.alias, d.name)",
(*tag_names, required),
).fetchall()
known = {device["id"] for device in devices}
for row in dynamic_rows:
device = row_to_dict(row) or {}
if device["id"] not in known:
device["membership_order"] = 100_000 + len(devices)
device["excluded"] = False
devices.append(device)
group["devices"] = devices
return group
def list_tags(self) -> list[dict[str, Any]]:
with self.database.connection() as conn:
rows = conn.execute(
"SELECT t.*, COUNT(dt.device_id) AS device_count FROM tags t "
"LEFT JOIN device_tags dt ON dt.tag_id=t.id GROUP BY t.id "
"ORDER BY t.name COLLATE NOCASE"
).fetchall()
return [row_to_dict(row) or {} for row in rows]
def update_group(self, identifier: str, data: GroupCreate) -> dict[str, Any]:
with self.database.transaction() as conn:
cursor = conn.execute(
"UPDATE device_groups SET name=?, description=?, dynamic_query_json=?, sort_order=?, updated_at=? "
"WHERE id=? AND deleted_at IS NULL",
(
data.name,
data.description,
json.dumps(data.dynamic_query) if data.dynamic_query else None,
data.sort_order,
utc_now(),
identifier,
),
)
if not cursor.rowcount:
raise NotFoundError("Groep", identifier)
conn.execute("DELETE FROM group_members WHERE group_id=?", (identifier,))
for index, device_id in enumerate(dict.fromkeys(data.device_ids)):
conn.execute(
"INSERT INTO group_members(group_id, device_id, sort_order) VALUES (?, ?, ?)",
(identifier, device_id, index),
)
return self.get_group(identifier)
def delete_group(self, identifier: str) -> None:
with self.database.connection() as conn:
cursor = conn.execute(
"UPDATE device_groups SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL",
(utc_now(), utc_now(), identifier),
)
if not cursor.rowcount:
raise NotFoundError("Groep", identifier)
"""Transactional room and group resource operations."""
from __future__ import annotations
import json
import uuid
from typing import Any
from ..database import Database, utc_now
from ..errors import NotFoundError
from ..repository import Repository, row_to_dict
from ..schemas import GroupCreate, RoomCreate
class ResourceService:
def __init__(self, database: Database) -> None:
self.database = database
self.repo = Repository(database)
def create_room(self, data: RoomCreate) -> dict[str, Any]:
identifier = str(uuid.uuid4())
now = utc_now()
with self.database.connection() as conn:
conn.execute(
"INSERT INTO rooms(id, name, description, sort_order, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(identifier, data.name, data.description, data.sort_order, now, now),
)
return self.repo.get("rooms", identifier)
def list_rooms(self) -> list[dict[str, Any]]:
with self.database.connection() as conn:
rows = conn.execute(
"SELECT r.*, COUNT(d.id) AS device_count FROM rooms r LEFT JOIN devices d "
"ON d.room_id=r.id AND d.deleted_at IS NULL WHERE r.deleted_at IS NULL "
"GROUP BY r.id ORDER BY r.sort_order, r.name COLLATE NOCASE"
).fetchall()
return [row_to_dict(row) or {} for row in rows]
def update_room(self, identifier: str, data: RoomCreate) -> dict[str, Any]:
with self.database.connection() as conn:
cursor = conn.execute(
"UPDATE rooms SET name=?, description=?, sort_order=?, updated_at=? "
"WHERE id=? AND deleted_at IS NULL",
(data.name, data.description, data.sort_order, utc_now(), identifier),
)
if not cursor.rowcount:
raise NotFoundError("Kamer", identifier)
return self.repo.get("rooms", identifier)
def delete_room(self, identifier: str) -> None:
with self.database.transaction() as conn:
cursor = conn.execute(
"UPDATE rooms SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL",
(utc_now(), utc_now(), identifier),
)
if not cursor.rowcount:
raise NotFoundError("Kamer", identifier)
conn.execute(
"UPDATE devices SET room_id=NULL, updated_at=? WHERE room_id=? AND deleted_at IS NULL",
(utc_now(), identifier),
)
def create_group(self, data: GroupCreate) -> dict[str, Any]:
identifier = str(uuid.uuid4())
now = utc_now()
with self.database.transaction() as conn:
conn.execute(
"INSERT INTO device_groups(id, name, description, dynamic_query_json, sort_order, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
identifier,
data.name,
data.description,
json.dumps(data.dynamic_query) if data.dynamic_query else None,
data.sort_order,
now,
now,
),
)
for index, device_id in enumerate(dict.fromkeys(data.device_ids)):
conn.execute(
"INSERT INTO group_members(group_id, device_id, sort_order) VALUES (?, ?, ?)",
(identifier, device_id, index),
)
return self.get_group(identifier)
def list_groups(self) -> list[dict[str, Any]]:
with self.database.connection() as conn:
rows = conn.execute(
"SELECT g.*, COUNT(m.device_id) AS device_count FROM device_groups g "
"LEFT JOIN group_members m ON m.group_id=g.id WHERE g.deleted_at IS NULL "
"GROUP BY g.id ORDER BY g.sort_order, g.name COLLATE NOCASE"
).fetchall()
groups = [row_to_dict(row) or {} for row in rows]
for group in groups:
group["device_count"] = len(self.get_group(group["id"])["devices"])
return groups
def get_group(self, identifier: str) -> dict[str, Any]:
with self.database.connection() as conn:
group_row = conn.execute(
"SELECT * FROM device_groups WHERE id=? AND deleted_at IS NULL", (identifier,)
).fetchone()
members = conn.execute(
"SELECT d.*, m.sort_order AS membership_order, m.excluded FROM group_members m "
"JOIN devices d ON d.id=m.device_id WHERE m.group_id=? AND d.deleted_at IS NULL "
"ORDER BY m.sort_order",
(identifier,),
).fetchall()
group = row_to_dict(group_row)
if group is None:
raise NotFoundError("Groep", identifier)
devices = [row_to_dict(row) or {} for row in members]
dynamic = group.get("dynamic_query") or {}
tag_names = [str(tag).strip() for tag in dynamic.get("tags", []) if str(tag).strip()]
if tag_names:
placeholders = ",".join("?" for _ in tag_names)
required = len(tag_names) if dynamic.get("match", "all") == "all" else 1
with self.database.connection() as conn:
dynamic_rows = conn.execute(
f"SELECT d.* FROM devices d JOIN device_tags dt ON dt.device_id=d.id " # noqa: S608
"JOIN tags t ON t.id=dt.tag_id WHERE d.deleted_at IS NULL "
f"AND t.name IN ({placeholders}) GROUP BY d.id " # noqa: S608
"HAVING COUNT(DISTINCT t.name) >= ? ORDER BY COALESCE(d.alias, d.name)",
(*tag_names, required),
).fetchall()
known = {device["id"] for device in devices}
for row in dynamic_rows:
device = row_to_dict(row) or {}
if device["id"] not in known:
device["membership_order"] = 100_000 + len(devices)
device["excluded"] = False
devices.append(device)
group["devices"] = devices
return group
def list_tags(self) -> list[dict[str, Any]]:
with self.database.connection() as conn:
rows = conn.execute(
"SELECT t.*, COUNT(dt.device_id) AS device_count FROM tags t "
"LEFT JOIN device_tags dt ON dt.tag_id=t.id GROUP BY t.id "
"ORDER BY t.name COLLATE NOCASE"
).fetchall()
return [row_to_dict(row) or {} for row in rows]
def update_group(self, identifier: str, data: GroupCreate) -> dict[str, Any]:
with self.database.transaction() as conn:
cursor = conn.execute(
"UPDATE device_groups SET name=?, description=?, dynamic_query_json=?, sort_order=?, updated_at=? "
"WHERE id=? AND deleted_at IS NULL",
(
data.name,
data.description,
json.dumps(data.dynamic_query) if data.dynamic_query else None,
data.sort_order,
utc_now(),
identifier,
),
)
if not cursor.rowcount:
raise NotFoundError("Groep", identifier)
conn.execute("DELETE FROM group_members WHERE group_id=?", (identifier,))
for index, device_id in enumerate(dict.fromkeys(data.device_ids)):
conn.execute(
"INSERT INTO group_members(group_id, device_id, sort_order) VALUES (?, ?, ?)",
(identifier, device_id, index),
)
return self.get_group(identifier)
def delete_group(self, identifier: str) -> None:
with self.database.connection() as conn:
cursor = conn.execute(
"UPDATE device_groups SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL",
(utc_now(), utc_now(), identifier),
)
if not cursor.rowcount:
raise NotFoundError("Groep", identifier)
@@ -1,221 +1,221 @@
"""LumaOps scene persistence, capture, apply, and compensating rollback."""
from __future__ import annotations
import json
import uuid
from typing import Any
from ..connectors.base import DeviceState
from ..database import Database, utc_now
from ..errors import NotFoundError
from ..repository import row_to_dict
from ..schemas import SceneCreate, SceneItemInput
from .commands import CommandService
from .inventory import InventoryService
from .resources import ResourceService
class SceneService:
def __init__(
self,
database: Database,
commands: CommandService,
inventory: InventoryService,
resources: ResourceService,
) -> None:
self.database = database
self.commands = commands
self.inventory = inventory
self.resources = resources
def create(self, data: SceneCreate) -> dict[str, Any]:
identifier = str(uuid.uuid4())
now = utc_now()
with self.database.transaction() as conn:
conn.execute(
"INSERT INTO scenes(id, name, description, favorite, version, created_at, updated_at) "
"VALUES (?, ?, ?, ?, 1, ?, ?)",
(identifier, data.name, data.description, int(data.favorite), now, now),
)
self._replace_items(conn, identifier, data.items)
return self.get(identifier)
def update(self, identifier: str, data: SceneCreate) -> dict[str, Any]:
with self.database.transaction() as conn:
cursor = conn.execute(
"UPDATE scenes SET name=?, description=?, favorite=?, version=version+1, updated_at=? "
"WHERE id=? AND deleted_at IS NULL",
(data.name, data.description, int(data.favorite), utc_now(), identifier),
)
if not cursor.rowcount:
raise NotFoundError("Scène", identifier)
self._replace_items(conn, identifier, data.items)
return self.get(identifier)
def _replace_items(self, conn: Any, scene_id: str, items: list[SceneItemInput]) -> None:
conn.execute("DELETE FROM scene_items WHERE scene_id=?", (scene_id,))
for item in items:
conn.execute(
"INSERT INTO scene_items(id, scene_id, target_type, target_id, state_json, required, sort_order) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
str(uuid.uuid4()),
scene_id,
item.target_type,
item.target_id,
json.dumps(
item.state.model_dump(mode="json", exclude_none=True), separators=(",", ":")
),
int(item.required),
item.sort_order,
),
)
def list_scenes(self, limit: int = 100, offset: int = 0) -> tuple[list[dict[str, Any]], int]:
with self.database.connection() as conn:
total = conn.execute("SELECT COUNT(*) FROM scenes WHERE deleted_at IS NULL").fetchone()[
0
]
rows = conn.execute(
"SELECT s.*, COUNT(i.id) AS item_count FROM scenes s LEFT JOIN scene_items i ON i.scene_id=s.id "
"WHERE s.deleted_at IS NULL GROUP BY s.id ORDER BY s.favorite DESC, s.name COLLATE NOCASE "
"LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
return [row_to_dict(row) or {} for row in rows], total
def get(self, identifier: str) -> dict[str, Any]:
with self.database.connection() as conn:
row = conn.execute(
"SELECT * FROM scenes WHERE id=? AND deleted_at IS NULL", (identifier,)
).fetchone()
item_rows = conn.execute(
"SELECT * FROM scene_items WHERE scene_id=? ORDER BY sort_order", (identifier,)
).fetchall()
scene = row_to_dict(row)
if scene is None:
raise NotFoundError("Scène", identifier)
scene["items"] = [row_to_dict(item) or {} for item in item_rows]
return scene
def delete(self, identifier: str) -> None:
with self.database.connection() as conn:
cursor = conn.execute(
"UPDATE scenes SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL",
(utc_now(), utc_now(), identifier),
)
if not cursor.rowcount:
raise NotFoundError("Scène", identifier)
def capture(self, name: str, device_ids: list[str] | None = None) -> dict[str, Any]:
devices, _ = self.inventory.list_devices(limit=4096, online=True, include_hidden=False)
selected: set[str] = set(device_ids or [])
items = [
SceneItemInput(
target_type="device",
target_id=device["id"],
state=DeviceState.model_validate(device["state"]),
required=True,
sort_order=index,
)
for index, device in enumerate(devices)
if not selected or device["id"] in selected
]
return self.create(SceneCreate(name=name, items=items))
def duplicate(self, identifier: str, name: str | None = None) -> dict[str, Any]:
source = self.get(identifier)
items = [SceneItemInput.model_validate(item) for item in source["items"]]
return self.create(
SceneCreate(
name=name or f"{source['name']} (kopie)",
description=source.get("description"),
favorite=False,
items=items,
)
)
async def apply(self, identifier: str, rollback_on_failure: bool = True) -> dict[str, Any]:
scene = self.get(identifier)
plan = self._resolve_items(scene["items"])
applied: list[dict[str, Any]] = []
failed: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
prior: dict[str, DeviceState] = {}
for item in plan:
device_id = item["device_id"]
device = self.inventory.get_device(device_id)
if device["exclude_global"]:
skipped.append({"device_id": device_id, "reason": "excluded"})
continue
try:
prior[device_id] = DeviceState.model_validate(device["state"])
result = await self.commands.execute_device(device_id, item["state"])
applied.append(result)
except Exception as exc:
failed.append({"device_id": device_id, "error": str(exc)})
if item["required"]:
break
rolled_back: list[dict[str, Any]] = []
if failed and rollback_on_failure:
for result in reversed(applied):
device_id = result["device_id"]
try:
rollback = await self.commands.execute_device(device_id, prior[device_id])
rolled_back.append({"device_id": device_id, "status": rollback["status"]})
except Exception as exc:
rolled_back.append(
{"device_id": device_id, "status": "failed", "error": str(exc)}
)
if not failed:
with self.database.connection() as conn:
conn.execute(
"UPDATE scenes SET last_applied_at=?, updated_at=? WHERE id=?",
(utc_now(), utc_now(), identifier),
)
return {
"scene_id": identifier,
"status": "failed" if failed else "succeeded",
"applied": applied,
"failed": failed,
"skipped": skipped,
"rolled_back": rolled_back,
}
def preview(self, identifier: str) -> dict[str, Any]:
scene = self.get(identifier)
plan = self._resolve_items(scene["items"])
return {
"scene_id": identifier,
"device_count": len({item["device_id"] for item in plan}),
"commands": [
{
"device_id": item["device_id"],
"required": item["required"],
"state": item["state"].model_dump(mode="json", exclude_none=True),
}
for item in plan
],
}
def _resolve_items(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
plan: dict[str, dict[str, Any]] = {}
for item in items:
state = DeviceState.model_validate(item["state"])
if item["target_type"] == "device":
plan[item["target_id"]] = {
"device_id": item["target_id"],
"state": state,
"required": item["required"],
}
elif item["target_type"] == "group":
group = self.resources.get_group(item["target_id"])
for device in group["devices"]:
if not device["excluded"]:
plan[device["id"]] = {
"device_id": device["id"],
"state": state,
"required": item["required"],
}
return list(plan.values())
"""LumaOps scene persistence, capture, apply, and compensating rollback."""
from __future__ import annotations
import json
import uuid
from typing import Any
from ..connectors.base import DeviceState
from ..database import Database, utc_now
from ..errors import NotFoundError
from ..repository import row_to_dict
from ..schemas import SceneCreate, SceneItemInput
from .commands import CommandService
from .inventory import InventoryService
from .resources import ResourceService
class SceneService:
def __init__(
self,
database: Database,
commands: CommandService,
inventory: InventoryService,
resources: ResourceService,
) -> None:
self.database = database
self.commands = commands
self.inventory = inventory
self.resources = resources
def create(self, data: SceneCreate) -> dict[str, Any]:
identifier = str(uuid.uuid4())
now = utc_now()
with self.database.transaction() as conn:
conn.execute(
"INSERT INTO scenes(id, name, description, favorite, version, created_at, updated_at) "
"VALUES (?, ?, ?, ?, 1, ?, ?)",
(identifier, data.name, data.description, int(data.favorite), now, now),
)
self._replace_items(conn, identifier, data.items)
return self.get(identifier)
def update(self, identifier: str, data: SceneCreate) -> dict[str, Any]:
with self.database.transaction() as conn:
cursor = conn.execute(
"UPDATE scenes SET name=?, description=?, favorite=?, version=version+1, updated_at=? "
"WHERE id=? AND deleted_at IS NULL",
(data.name, data.description, int(data.favorite), utc_now(), identifier),
)
if not cursor.rowcount:
raise NotFoundError("Scène", identifier)
self._replace_items(conn, identifier, data.items)
return self.get(identifier)
def _replace_items(self, conn: Any, scene_id: str, items: list[SceneItemInput]) -> None:
conn.execute("DELETE FROM scene_items WHERE scene_id=?", (scene_id,))
for item in items:
conn.execute(
"INSERT INTO scene_items(id, scene_id, target_type, target_id, state_json, required, sort_order) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(
str(uuid.uuid4()),
scene_id,
item.target_type,
item.target_id,
json.dumps(
item.state.model_dump(mode="json", exclude_none=True), separators=(",", ":")
),
int(item.required),
item.sort_order,
),
)
def list_scenes(self, limit: int = 100, offset: int = 0) -> tuple[list[dict[str, Any]], int]:
with self.database.connection() as conn:
total = conn.execute("SELECT COUNT(*) FROM scenes WHERE deleted_at IS NULL").fetchone()[
0
]
rows = conn.execute(
"SELECT s.*, COUNT(i.id) AS item_count FROM scenes s LEFT JOIN scene_items i ON i.scene_id=s.id "
"WHERE s.deleted_at IS NULL GROUP BY s.id ORDER BY s.favorite DESC, s.name COLLATE NOCASE "
"LIMIT ? OFFSET ?",
(limit, offset),
).fetchall()
return [row_to_dict(row) or {} for row in rows], total
def get(self, identifier: str) -> dict[str, Any]:
with self.database.connection() as conn:
row = conn.execute(
"SELECT * FROM scenes WHERE id=? AND deleted_at IS NULL", (identifier,)
).fetchone()
item_rows = conn.execute(
"SELECT * FROM scene_items WHERE scene_id=? ORDER BY sort_order", (identifier,)
).fetchall()
scene = row_to_dict(row)
if scene is None:
raise NotFoundError("Scène", identifier)
scene["items"] = [row_to_dict(item) or {} for item in item_rows]
return scene
def delete(self, identifier: str) -> None:
with self.database.connection() as conn:
cursor = conn.execute(
"UPDATE scenes SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL",
(utc_now(), utc_now(), identifier),
)
if not cursor.rowcount:
raise NotFoundError("Scène", identifier)
def capture(self, name: str, device_ids: list[str] | None = None) -> dict[str, Any]:
devices, _ = self.inventory.list_devices(limit=4096, online=True, include_hidden=False)
selected: set[str] = set(device_ids or [])
items = [
SceneItemInput(
target_type="device",
target_id=device["id"],
state=DeviceState.model_validate(device["state"]),
required=True,
sort_order=index,
)
for index, device in enumerate(devices)
if not selected or device["id"] in selected
]
return self.create(SceneCreate(name=name, items=items))
def duplicate(self, identifier: str, name: str | None = None) -> dict[str, Any]:
source = self.get(identifier)
items = [SceneItemInput.model_validate(item) for item in source["items"]]
return self.create(
SceneCreate(
name=name or f"{source['name']} (kopie)",
description=source.get("description"),
favorite=False,
items=items,
)
)
async def apply(self, identifier: str, rollback_on_failure: bool = True) -> dict[str, Any]:
scene = self.get(identifier)
plan = self._resolve_items(scene["items"])
applied: list[dict[str, Any]] = []
failed: list[dict[str, Any]] = []
skipped: list[dict[str, Any]] = []
prior: dict[str, DeviceState] = {}
for item in plan:
device_id = item["device_id"]
device = self.inventory.get_device(device_id)
if device["exclude_global"]:
skipped.append({"device_id": device_id, "reason": "excluded"})
continue
try:
prior[device_id] = DeviceState.model_validate(device["state"])
result = await self.commands.execute_device(device_id, item["state"])
applied.append(result)
except Exception as exc:
failed.append({"device_id": device_id, "error": str(exc)})
if item["required"]:
break
rolled_back: list[dict[str, Any]] = []
if failed and rollback_on_failure:
for result in reversed(applied):
device_id = result["device_id"]
try:
rollback = await self.commands.execute_device(device_id, prior[device_id])
rolled_back.append({"device_id": device_id, "status": rollback["status"]})
except Exception as exc:
rolled_back.append(
{"device_id": device_id, "status": "failed", "error": str(exc)}
)
if not failed:
with self.database.connection() as conn:
conn.execute(
"UPDATE scenes SET last_applied_at=?, updated_at=? WHERE id=?",
(utc_now(), utc_now(), identifier),
)
return {
"scene_id": identifier,
"status": "failed" if failed else "succeeded",
"applied": applied,
"failed": failed,
"skipped": skipped,
"rolled_back": rolled_back,
}
def preview(self, identifier: str) -> dict[str, Any]:
scene = self.get(identifier)
plan = self._resolve_items(scene["items"])
return {
"scene_id": identifier,
"device_count": len({item["device_id"] for item in plan}),
"commands": [
{
"device_id": item["device_id"],
"required": item["required"],
"state": item["state"].model_dump(mode="json", exclude_none=True),
}
for item in plan
],
}
def _resolve_items(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
plan: dict[str, dict[str, Any]] = {}
for item in items:
state = DeviceState.model_validate(item["state"])
if item["target_type"] == "device":
plan[item["target_id"]] = {
"device_id": item["target_id"],
"state": state,
"required": item["required"],
}
elif item["target_type"] == "group":
group = self.resources.get_group(item["target_id"])
for device in group["devices"]:
if not device["excluded"]:
plan[device["id"]] = {
"device_id": device["id"],
"state": state,
"required": item["required"],
}
return list(plan.values())
@@ -1,129 +1,129 @@
"""Read-only first-run diagnostics and setup completion state."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from ..config import Settings
from ..connectors.registry import ConnectorRegistry
from ..database import Database, utc_now
from ..repository import row_to_dict
class SetupService:
def __init__(self, settings: Settings, database: Database, registry: ConnectorRegistry) -> None:
self.settings = settings
self.database = database
self.registry = registry
def state(self) -> dict[str, Any]:
with self.database.connection() as conn:
return (
row_to_dict(conn.execute("SELECT * FROM setup_state WHERE singleton=1").fetchone())
or {}
)
async def inspect(self, include_network: bool = False) -> dict[str, Any]:
del include_network
paths = [
self.settings.config_dir,
self.settings.openrgb_config_dir,
self.settings.data_dir,
self.settings.logs_dir,
]
storage = [self._path_status(path) for path in paths]
usb = self._device_nodes(Path("/dev/bus/usb"), "*")
i2c = self._device_nodes(Path("/dev"), "i2c-*")
serial = self._device_nodes(Path("/dev"), "ttyUSB*") + self._device_nodes(
Path("/dev"), "ttyACM*"
)
connector_health = {
key: value.model_dump(mode="json")
for key, value in (await self.registry.health()).items()
}
openrgb = next(
(value for key, value in connector_health.items() if key.startswith("openrgb")), None
)
recovery: list[dict[str, str]] = []
if any(not item["writable"] for item in storage):
recovery.append(
{
"area": "storage",
"message": "Controleer de Unraid appdata-mounts en UID/GID-rechten voor /config, /data en /logs.",
}
)
if not usb:
recovery.append(
{
"area": "usb",
"message": "Map /dev/bus/usb in de container en controleer host-udev/hidraw-permissies.",
}
)
if not i2c:
recovery.append(
{
"area": "i2c",
"message": "Laad i2c-dev plus de chipsetdriver op Unraid en map alleen de benodigde /dev/i2c-* nodes.",
}
)
if openrgb and not openrgb["connected"]:
recovery.append(
{
"area": "sdk",
"message": "Controleer openrgb.log en bevestig dat OpenRGB op 127.0.0.1:6742 gestart is.",
}
)
report = {
"storage": storage,
"openrgb": openrgb,
"connectors": connector_health,
"usb_devices": usb,
"i2c_devices": i2c,
"serial_devices": serial,
"recovery": recovery,
"checked_at": utc_now(),
}
with self.database.connection() as conn:
conn.execute(
"UPDATE setup_state SET current_step='review', report_json=?, updated_at=? WHERE singleton=1",
(__import__("json").dumps(report, separators=(",", ":")), utc_now()),
)
return report
def complete(self, appdata_confirmed: bool, backup_location_confirmed: bool) -> dict[str, Any]:
if not appdata_confirmed or not backup_location_confirmed:
raise ValueError("Bevestig zowel appdata als back-uplocatie om setup af te ronden")
now = utc_now()
with self.database.connection() as conn:
conn.execute(
"UPDATE setup_state SET completed=1, current_step='complete', completed_at=?, updated_at=? "
"WHERE singleton=1",
(now, now),
)
return self.state()
@staticmethod
def _path_status(path: Path) -> dict[str, Any]:
path.mkdir(parents=True, exist_ok=True)
return {
"path": str(path),
"exists": path.exists(),
"readable": os.access(path, os.R_OK),
"writable": os.access(path, os.W_OK),
}
@staticmethod
def _device_nodes(root: Path, pattern: str) -> list[dict[str, Any]]:
if not root.exists():
return []
return [
{
"path": str(path),
"readable": os.access(path, os.R_OK),
"writable": os.access(path, os.W_OK),
}
for path in sorted(root.glob(pattern))[:4096]
if path.is_file() or path.is_char_device()
]
"""Read-only first-run diagnostics and setup completion state."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
from ..config import Settings
from ..connectors.registry import ConnectorRegistry
from ..database import Database, utc_now
from ..repository import row_to_dict
class SetupService:
def __init__(self, settings: Settings, database: Database, registry: ConnectorRegistry) -> None:
self.settings = settings
self.database = database
self.registry = registry
def state(self) -> dict[str, Any]:
with self.database.connection() as conn:
return (
row_to_dict(conn.execute("SELECT * FROM setup_state WHERE singleton=1").fetchone())
or {}
)
async def inspect(self, include_network: bool = False) -> dict[str, Any]:
del include_network
paths = [
self.settings.config_dir,
self.settings.openrgb_config_dir,
self.settings.data_dir,
self.settings.logs_dir,
]
storage = [self._path_status(path) for path in paths]
usb = self._device_nodes(Path("/dev/bus/usb"), "*")
i2c = self._device_nodes(Path("/dev"), "i2c-*")
serial = self._device_nodes(Path("/dev"), "ttyUSB*") + self._device_nodes(
Path("/dev"), "ttyACM*"
)
connector_health = {
key: value.model_dump(mode="json")
for key, value in (await self.registry.health()).items()
}
openrgb = next(
(value for key, value in connector_health.items() if key.startswith("openrgb")), None
)
recovery: list[dict[str, str]] = []
if any(not item["writable"] for item in storage):
recovery.append(
{
"area": "storage",
"message": "Controleer de Unraid appdata-mounts en UID/GID-rechten voor /config, /data en /logs.",
}
)
if not usb:
recovery.append(
{
"area": "usb",
"message": "Map /dev/bus/usb in de container en controleer host-udev/hidraw-permissies.",
}
)
if not i2c:
recovery.append(
{
"area": "i2c",
"message": "Laad i2c-dev plus de chipsetdriver op Unraid en map alleen de benodigde /dev/i2c-* nodes.",
}
)
if openrgb and not openrgb["connected"]:
recovery.append(
{
"area": "sdk",
"message": "Controleer openrgb.log en bevestig dat OpenRGB op 127.0.0.1:6742 gestart is.",
}
)
report = {
"storage": storage,
"openrgb": openrgb,
"connectors": connector_health,
"usb_devices": usb,
"i2c_devices": i2c,
"serial_devices": serial,
"recovery": recovery,
"checked_at": utc_now(),
}
with self.database.connection() as conn:
conn.execute(
"UPDATE setup_state SET current_step='review', report_json=?, updated_at=? WHERE singleton=1",
(__import__("json").dumps(report, separators=(",", ":")), utc_now()),
)
return report
def complete(self, appdata_confirmed: bool, backup_location_confirmed: bool) -> dict[str, Any]:
if not appdata_confirmed or not backup_location_confirmed:
raise ValueError("Bevestig zowel appdata als back-uplocatie om setup af te ronden")
now = utc_now()
with self.database.connection() as conn:
conn.execute(
"UPDATE setup_state SET completed=1, current_step='complete', completed_at=?, updated_at=? "
"WHERE singleton=1",
(now, now),
)
return self.state()
@staticmethod
def _path_status(path: Path) -> dict[str, Any]:
path.mkdir(parents=True, exist_ok=True)
return {
"path": str(path),
"exists": path.exists(),
"readable": os.access(path, os.R_OK),
"writable": os.access(path, os.W_OK),
}
@staticmethod
def _device_nodes(root: Path, pattern: str) -> list[dict[str, Any]]:
if not root.exists():
return []
return [
{
"path": str(path),
"readable": os.access(path, os.R_OK),
"writable": os.access(path, os.W_OK),
}
for path in sorted(root.glob(pattern))[:4096]
if path.is_file() or path.is_char_device()
]
+32 -32
View File
@@ -1,32 +1,32 @@
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from lumaops_backend.config import Settings
from lumaops_backend.main import create_app
@pytest.fixture
def settings(tmp_path: Path) -> Settings:
return Settings(
LUMAOPS_ENV="test",
connector_mode="mock",
auth_enabled=False,
config_dir=tmp_path / "config",
openrgb_config_dir=tmp_path / "openrgb",
data_dir=tmp_path / "data",
logs_dir=tmp_path / "logs",
static_dir=tmp_path / "static",
database_url=f"sqlite:///{(tmp_path / 'data' / 'lumaops.db').as_posix()}",
log_level="ERROR",
)
@pytest.fixture
def client(settings: Settings) -> Iterator[TestClient]:
with TestClient(create_app(settings)) as test_client:
yield test_client
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from lumaops_backend.config import Settings
from lumaops_backend.main import create_app
@pytest.fixture
def settings(tmp_path: Path) -> Settings:
return Settings(
LUMAOPS_ENV="test",
connector_mode="mock",
auth_enabled=False,
config_dir=tmp_path / "config",
openrgb_config_dir=tmp_path / "openrgb",
data_dir=tmp_path / "data",
logs_dir=tmp_path / "logs",
static_dir=tmp_path / "static",
database_url=f"sqlite:///{(tmp_path / 'data' / 'lumaops.db').as_posix()}",
log_level="ERROR",
)
@pytest.fixture
def client(settings: Settings) -> Iterator[TestClient]:
with TestClient(create_app(settings)) as test_client:
yield test_client
+403 -403
View File
@@ -1,403 +1,403 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from lumaops_backend.connectors.base import DeviceState, RGBColor
from lumaops_backend.connectors.mock import MockOpenRGBAdapter
from lumaops_backend.main import create_app
from lumaops_backend.services.commands import CommandService
def test_direct_mode_without_explicit_color_preserves_last_nonzero_color() -> None:
color = RGBColor(red=86, green=96, blue=255)
effective = CommandService._effective_state(
{
"desired_state": DeviceState(power=True, colors=[color]).model_dump(mode="json"),
"modes": [{"index": 0, "name": "Direct"}],
},
DeviceState(power=True, mode_index=0),
)
assert effective.colors == [color]
assert effective.mode_index == 0
def test_main_device_command_flow(client: TestClient) -> None:
discovery = client.post("/api/v1/discovery")
assert discovery.status_code == 200
assert discovery.json()["found_count"] == 2
devices_response = client.get("/api/v1/devices")
assert devices_response.status_code == 200
devices = devices_response.json()["items"]
assert len(devices) == 2
device = next(item for item in devices if item["fingerprint"] == "mock-mainboard")
request = {"state": {"power": True, "colors": [{"red": 20, "green": 40, "blue": 60}]}}
command = client.post(
f"/api/v1/devices/{device['id']}/state",
json=request,
headers={"Idempotency-Key": "main-flow-1"},
)
assert command.status_code == 200
assert command.json()["status"] == "succeeded"
duplicate = client.post(
f"/api/v1/devices/{device['id']}/state",
json=request,
headers={"Idempotency-Key": "main-flow-1"},
)
assert duplicate.json()["id"] == command.json()["id"]
detail = client.get(f"/api/v1/devices/{device['id']}").json()
assert detail["state"]["colors"][0] == {"red": 20, "green": 40, "blue": 60}
resized = client.put(
f"/api/v1/devices/{device['id']}/zones/1",
json={"led_count": 24},
)
assert resized.status_code == 200
assert resized.json()["zones"][1]["led_count"] == 24
assert resized.json()["led_count"] == 32
zone_color = {"red": 10, "green": 80, "blue": 160}
zone_command = client.post(
f"/api/v1/devices/{device['id']}/zones/1/state",
json={"state": {"colors": [zone_color]}},
)
assert zone_command.status_code == 200
assert zone_command.json()["zone_index"] == 1
assert zone_command.json()["led_count"] == 120
assert client.get(f"/api/v1/devices/{device['id']}").json()["zones"][1]["led_count"] == 120
commands = client.get("/api/v1/commands").json()
audit = client.get("/api/v1/audit").json()
assert commands["total"] == 2
assert audit["total"] >= 1
assert any(item["action"] == "device.resize_zone" for item in audit["items"])
def test_desired_state_survives_connector_restart_and_power_cycle(client: TestClient) -> None:
client.post("/api/v1/discovery")
device = next(
item
for item in client.get("/api/v1/devices").json()["items"]
if item["fingerprint"] == "mock-mainboard"
)
target = f"/api/v1/devices/{device['id']}"
color = {"red": 81, "green": 109, "blue": 245}
applied = client.post(
f"{target}/state",
json={"state": {"power": True, "colors": [color]}},
)
assert applied.status_code == 200
detail = client.get(target).json()
assert detail["desired_state"]["colors"][0] == color
connector = client.app.state.context.registry.get("openrgb-mock")
assert isinstance(connector, MockOpenRGBAdapter)
connector._devices["mock-mainboard"].state = DeviceState( # noqa: SLF001
power=False,
brightness=72,
colors=[RGBColor(red=0, green=0, blue=0)] * 12,
mode="Direct",
mode_index=0,
speed=5,
)
commands_before = client.get("/api/v1/commands").json()["total"]
discovery = client.post("/api/v1/discovery")
assert discovery.status_code == 200
restored = client.get(target).json()
assert restored["state"]["power"] is True
assert restored["state"]["colors"]
assert all(entry == color for entry in restored["state"]["colors"])
commands_after = client.get("/api/v1/commands").json()
assert commands_after["total"] == commands_before + 1
assert commands_after["items"][0]["actor"] == "system-restore"
assert client.post("/api/v1/discovery").status_code == 200
assert client.get("/api/v1/commands").json()["total"] == commands_after["total"]
assert client.post(f"{target}/state", json={"state": {"power": False}}).status_code == 200
powered_off = client.get(target).json()
assert powered_off["state"]["power"] is False
assert powered_off["desired_state"]["colors"]
assert all(entry == color for entry in powered_off["desired_state"]["colors"])
assert client.post(f"{target}/state", json={"state": {"power": True}}).status_code == 200
powered_on = client.get(target).json()
assert powered_on["state"]["power"] is True
assert powered_on["state"]["colors"]
assert all(entry == color for entry in powered_on["state"]["colors"])
def test_explicit_colorless_effect_is_not_replaced_by_power_restore(
client: TestClient,
) -> None:
device = client.get("/api/v1/devices").json()["items"][0]
client.post(
f"/api/v1/devices/{device['id']}/state",
json={"state": {"power": True, "colors": [{"red": 24, "green": 48, "blue": 96}]}},
)
effect = client.post(
f"/api/v1/devices/{device['id']}/state",
json={"state": {"power": True, "mode_index": 2}},
)
assert effect.status_code == 200
assert effect.json()["state"]["mode_index"] == 2
def test_startup_reconciles_desired_state_once_before_ready(settings) -> None:
color = {"red": 24, "green": 96, "blue": 192}
device_id = ""
with TestClient(create_app(settings)) as first_client:
device = next(
item
for item in first_client.get("/api/v1/devices").json()["items"]
if item["fingerprint"] == "mock-mainboard"
)
device_id = device["id"]
applied = first_client.post(
f"/api/v1/devices/{device_id}/state",
json={"state": {"power": True, "colors": [color]}},
)
assert applied.status_code == 200
with TestClient(create_app(settings)) as restarted_client:
assert restarted_client.get("/health/ready").json() == {"status": "ready"}
restored = restarted_client.get(f"/api/v1/devices/{device_id}").json()
assert restored["state"]["colors"]
assert all(entry == color for entry in restored["state"]["colors"])
commands = restarted_client.get("/api/v1/commands?limit=100").json()["items"]
restores = [
command
for command in commands
if command["actor"] == "system-restore" and command["target_id"] == device_id
]
assert len(restores) == 1
assert restores[0]["status"] == "succeeded"
def test_rooms_groups_and_scenes(client: TestClient) -> None:
client.post("/api/v1/discovery")
devices = client.get("/api/v1/devices").json()["items"]
room = client.post("/api/v1/rooms", json={"name": "Werkplek", "sort_order": 1})
assert room.status_code == 201
first = devices[0]
assert (
client.patch(
f"/api/v1/devices/{first['id']}", json={"room_id": room.json()["id"]}
).status_code
== 200
)
group = client.post(
"/api/v1/groups",
json={"name": "Bureau", "device_ids": [item["id"] for item in devices]},
)
assert group.status_code == 201
tagged = client.patch(f"/api/v1/devices/{devices[1]['id']}", json={"tags": ["bureau", "rgb"]})
assert tagged.status_code == 200
assert tagged.json()["tags"] == ["bureau", "rgb"]
dynamic_group = client.post(
"/api/v1/groups",
json={"name": "Met tag", "dynamic_query": {"tags": ["bureau"], "match": "all"}},
)
assert dynamic_group.status_code == 201
assert [item["id"] for item in dynamic_group.json()["devices"]] == [devices[1]["id"]]
assert client.get("/api/v1/tags").json()[0]["device_count"] == 1
scene = client.post(
"/api/v1/scenes",
json={
"name": "Focus",
"favorite": True,
"items": [
{
"target_type": "group",
"target_id": group.json()["id"],
"state": {"power": True, "colors": [{"red": 42, "green": 80, "blue": 180}]},
}
],
},
)
assert scene.status_code == 201
preview = client.get(f"/api/v1/scenes/{scene.json()['id']}/preview").json()
assert preview["device_count"] == 2
applied = client.post(
f"/api/v1/scenes/{scene.json()['id']}/apply", json={"rollback_on_failure": True}
)
assert applied.status_code == 200
assert applied.json()["status"] == "succeeded"
duplicate = client.post(f"/api/v1/scenes/{scene.json()['id']}/duplicate", json={})
assert duplicate.status_code == 201
assert duplicate.json()["name"] == "Focus (kopie)"
assert len(duplicate.json()["items"]) == 1
exported = client.get(f"/api/v1/scenes/{scene.json()['id']}/export")
assert exported.status_code == 200
assert exported.json()["format"] == "lumaops-scene-v1"
imported = client.post("/api/v1/scenes/import", json=exported.json())
assert imported.status_code == 201
assert imported.json()["name"] == "Focus"
assert client.get("/api/v1/scenes").json()["total"] == 3
def test_physical_dram_family_groups_modules_and_supports_group_commands(
client: TestClient,
) -> None:
devices = client.get("/api/v1/devices").json()["items"]
device_ids = [device["id"] for device in devices]
with client.app.state.context.database.connection() as conn:
conn.execute(
"UPDATE devices SET device_type='dram', name='Corsair Vengeance RGB Pro SL DDR4', "
"vendor='Corsair', model='Corsair DRAM RGB Device', state_json=(SELECT state_json "
"FROM devices ORDER BY controller_index LIMIT 1) WHERE id IN (?, ?)",
tuple(device_ids),
)
groups = client.get("/api/v1/device-groups")
assert groups.status_code == 200
assert len(groups.json()) == 1
group = groups.json()[0]
assert group["device_type"] == "dram"
assert group["module_count"] == 2
assert group["mixed"] is False
assert [device["controller_index"] for device in group["devices"]] == [0, 1]
color = {"red": 34, "green": 102, "blue": 204}
applied = client.post(
f"/api/v1/device-groups/{group['id']}/state",
json={"state": {"power": True, "colors": [color]}},
)
assert applied.status_code == 200
assert applied.json()["status"] == "succeeded"
assert [result["status"] for result in applied.json()["results"]] == [
"succeeded",
"succeeded",
]
for device_id in device_ids:
detail = client.get(f"/api/v1/devices/{device_id}").json()
assert detail["desired_state"]["colors"][0] == color
def test_setup_health_backup_and_diagnostics(client: TestClient) -> None:
setup = client.post("/api/v1/setup/inspect")
assert setup.status_code == 200
assert "storage" in setup.json()
complete = client.post(
"/api/v1/setup/complete",
json={"appdata_confirmed": True, "backup_location_confirmed": True},
)
assert complete.json()["completed"] is True
assert client.get("/health/live").status_code == 200
assert client.get("/health/ready").status_code == 200
health = client.get("/api/v1/health")
assert health.status_code == 200
backup = client.post("/api/v1/backups")
assert backup.status_code == 201
assert backup.json()["name"].endswith(".db")
diagnostic = client.get("/api/v1/diagnostics/export")
assert diagnostic.status_code == 200
assert diagnostic.content.startswith(b"PK")
def test_setup_complete_accepts_legacy_backup_field(client: TestClient) -> None:
complete = client.post(
"/api/v1/setup/complete",
json={"appdata_confirmed": True, "backup_confirmed": True},
)
assert complete.status_code == 200
assert complete.json()["completed"] is True
def test_resource_lifecycle_group_capabilities_and_automation_history(
client: TestClient,
) -> None:
client.post("/api/v1/discovery")
devices = client.get("/api/v1/devices").json()["items"]
room = client.post("/api/v1/rooms", json={"name": "Studio", "description": "Boven"}).json()
updated_room = client.put(
f"/api/v1/rooms/{room['id']}",
json={"name": "Werkstudio", "description": "Bovenverdieping"},
)
assert updated_room.status_code == 200
assert updated_room.json()["description"] == "Bovenverdieping"
client.patch(f"/api/v1/devices/{devices[0]['id']}", json={"room_id": room["id"]})
assert client.delete(f"/api/v1/rooms/{room['id']}").status_code == 204
assert client.get(f"/api/v1/devices/{devices[0]['id']}").json()["room_id"] is None
group = client.post(
"/api/v1/groups",
json={"name": "Alles", "device_ids": [device["id"] for device in devices]},
).json()
group_result = client.post(
f"/api/v1/groups/{group['id']}/state",
json={"state": {"speed": 3}},
)
assert group_result.status_code == 200
assert {item["status"] for item in group_result.json()["results"]} == {
"succeeded",
"skipped",
}
scene = client.post("/api/v1/scenes", json={"name": "Leeg", "items": []}).json()
automation = client.post(
"/api/v1/automations",
json={
"name": "Avond",
"description": "Dagelijkse rustige scène",
"trigger": {"type": "time", "at": "20:00", "weekdays": [0, 2, 4]},
"actions": [{"type": "scene", "scene_id": scene["id"]}],
"timezone": "Europe/Brussels",
"cooldown_seconds": 0,
"conflict_key": "woonkamer",
},
)
assert automation.status_code == 201
assert automation.json()["description"] == "Dagelijkse rustige scène"
run = client.post(f"/api/v1/automations/{automation.json()['id']}/run")
assert run.status_code == 200
assert run.json()["status"] == "succeeded"
history = client.get(f"/api/v1/automations/{automation.json()['id']}/runs").json()
assert history["total"] == 1
assert history["items"][0]["status"] == "succeeded"
audit = client.get("/api/v1/audit?limit=500").json()["items"]
assert any(item["action"] == "api.create_room" for item in audit)
assert any(item["action"] == "api.create_automation" for item in audit)
def test_automation_requests_reject_invalid_schedules_and_actions(client: TestClient) -> None:
base = {
"name": "Ongeldig",
"description": "Validatiecontrole",
"trigger": {"type": "time", "at": "20:00", "weekdays": [0]},
"actions": [{"type": "scene", "scene_id": "scene-id"}],
"timezone": "Europe/Brussels",
}
invalid_time = client.post(
"/api/v1/automations",
json={**base, "trigger": {"type": "time", "at": "25:90", "weekdays": [0]}},
)
assert invalid_time.status_code == 422
assert invalid_time.json()["error"]["code"] == "validation_error"
missing_scene = client.post(
"/api/v1/automations",
json={**base, "actions": [{"type": "scene", "scene_id": None}]},
)
assert missing_scene.status_code == 422
invalid_timezone = client.post(
"/api/v1/automations",
json={**base, "timezone": "Mars/Olympus_Mons"},
)
assert invalid_timezone.status_code == 422
from __future__ import annotations
from fastapi.testclient import TestClient
from lumaops_backend.connectors.base import DeviceState, RGBColor
from lumaops_backend.connectors.mock import MockOpenRGBAdapter
from lumaops_backend.main import create_app
from lumaops_backend.services.commands import CommandService
def test_direct_mode_without_explicit_color_preserves_last_nonzero_color() -> None:
color = RGBColor(red=86, green=96, blue=255)
effective = CommandService._effective_state(
{
"desired_state": DeviceState(power=True, colors=[color]).model_dump(mode="json"),
"modes": [{"index": 0, "name": "Direct"}],
},
DeviceState(power=True, mode_index=0),
)
assert effective.colors == [color]
assert effective.mode_index == 0
def test_main_device_command_flow(client: TestClient) -> None:
discovery = client.post("/api/v1/discovery")
assert discovery.status_code == 200
assert discovery.json()["found_count"] == 2
devices_response = client.get("/api/v1/devices")
assert devices_response.status_code == 200
devices = devices_response.json()["items"]
assert len(devices) == 2
device = next(item for item in devices if item["fingerprint"] == "mock-mainboard")
request = {"state": {"power": True, "colors": [{"red": 20, "green": 40, "blue": 60}]}}
command = client.post(
f"/api/v1/devices/{device['id']}/state",
json=request,
headers={"Idempotency-Key": "main-flow-1"},
)
assert command.status_code == 200
assert command.json()["status"] == "succeeded"
duplicate = client.post(
f"/api/v1/devices/{device['id']}/state",
json=request,
headers={"Idempotency-Key": "main-flow-1"},
)
assert duplicate.json()["id"] == command.json()["id"]
detail = client.get(f"/api/v1/devices/{device['id']}").json()
assert detail["state"]["colors"][0] == {"red": 20, "green": 40, "blue": 60}
resized = client.put(
f"/api/v1/devices/{device['id']}/zones/1",
json={"led_count": 24},
)
assert resized.status_code == 200
assert resized.json()["zones"][1]["led_count"] == 24
assert resized.json()["led_count"] == 32
zone_color = {"red": 10, "green": 80, "blue": 160}
zone_command = client.post(
f"/api/v1/devices/{device['id']}/zones/1/state",
json={"state": {"colors": [zone_color]}},
)
assert zone_command.status_code == 200
assert zone_command.json()["zone_index"] == 1
assert zone_command.json()["led_count"] == 120
assert client.get(f"/api/v1/devices/{device['id']}").json()["zones"][1]["led_count"] == 120
commands = client.get("/api/v1/commands").json()
audit = client.get("/api/v1/audit").json()
assert commands["total"] == 2
assert audit["total"] >= 1
assert any(item["action"] == "device.resize_zone" for item in audit["items"])
def test_desired_state_survives_connector_restart_and_power_cycle(client: TestClient) -> None:
client.post("/api/v1/discovery")
device = next(
item
for item in client.get("/api/v1/devices").json()["items"]
if item["fingerprint"] == "mock-mainboard"
)
target = f"/api/v1/devices/{device['id']}"
color = {"red": 81, "green": 109, "blue": 245}
applied = client.post(
f"{target}/state",
json={"state": {"power": True, "colors": [color]}},
)
assert applied.status_code == 200
detail = client.get(target).json()
assert detail["desired_state"]["colors"][0] == color
connector = client.app.state.context.registry.get("openrgb-mock")
assert isinstance(connector, MockOpenRGBAdapter)
connector._devices["mock-mainboard"].state = DeviceState( # noqa: SLF001
power=False,
brightness=72,
colors=[RGBColor(red=0, green=0, blue=0)] * 12,
mode="Direct",
mode_index=0,
speed=5,
)
commands_before = client.get("/api/v1/commands").json()["total"]
discovery = client.post("/api/v1/discovery")
assert discovery.status_code == 200
restored = client.get(target).json()
assert restored["state"]["power"] is True
assert restored["state"]["colors"]
assert all(entry == color for entry in restored["state"]["colors"])
commands_after = client.get("/api/v1/commands").json()
assert commands_after["total"] == commands_before + 1
assert commands_after["items"][0]["actor"] == "system-restore"
assert client.post("/api/v1/discovery").status_code == 200
assert client.get("/api/v1/commands").json()["total"] == commands_after["total"]
assert client.post(f"{target}/state", json={"state": {"power": False}}).status_code == 200
powered_off = client.get(target).json()
assert powered_off["state"]["power"] is False
assert powered_off["desired_state"]["colors"]
assert all(entry == color for entry in powered_off["desired_state"]["colors"])
assert client.post(f"{target}/state", json={"state": {"power": True}}).status_code == 200
powered_on = client.get(target).json()
assert powered_on["state"]["power"] is True
assert powered_on["state"]["colors"]
assert all(entry == color for entry in powered_on["state"]["colors"])
def test_explicit_colorless_effect_is_not_replaced_by_power_restore(
client: TestClient,
) -> None:
device = client.get("/api/v1/devices").json()["items"][0]
client.post(
f"/api/v1/devices/{device['id']}/state",
json={"state": {"power": True, "colors": [{"red": 24, "green": 48, "blue": 96}]}},
)
effect = client.post(
f"/api/v1/devices/{device['id']}/state",
json={"state": {"power": True, "mode_index": 2}},
)
assert effect.status_code == 200
assert effect.json()["state"]["mode_index"] == 2
def test_startup_reconciles_desired_state_once_before_ready(settings) -> None:
color = {"red": 24, "green": 96, "blue": 192}
device_id = ""
with TestClient(create_app(settings)) as first_client:
device = next(
item
for item in first_client.get("/api/v1/devices").json()["items"]
if item["fingerprint"] == "mock-mainboard"
)
device_id = device["id"]
applied = first_client.post(
f"/api/v1/devices/{device_id}/state",
json={"state": {"power": True, "colors": [color]}},
)
assert applied.status_code == 200
with TestClient(create_app(settings)) as restarted_client:
assert restarted_client.get("/health/ready").json() == {"status": "ready"}
restored = restarted_client.get(f"/api/v1/devices/{device_id}").json()
assert restored["state"]["colors"]
assert all(entry == color for entry in restored["state"]["colors"])
commands = restarted_client.get("/api/v1/commands?limit=100").json()["items"]
restores = [
command
for command in commands
if command["actor"] == "system-restore" and command["target_id"] == device_id
]
assert len(restores) == 1
assert restores[0]["status"] == "succeeded"
def test_rooms_groups_and_scenes(client: TestClient) -> None:
client.post("/api/v1/discovery")
devices = client.get("/api/v1/devices").json()["items"]
room = client.post("/api/v1/rooms", json={"name": "Werkplek", "sort_order": 1})
assert room.status_code == 201
first = devices[0]
assert (
client.patch(
f"/api/v1/devices/{first['id']}", json={"room_id": room.json()["id"]}
).status_code
== 200
)
group = client.post(
"/api/v1/groups",
json={"name": "Bureau", "device_ids": [item["id"] for item in devices]},
)
assert group.status_code == 201
tagged = client.patch(f"/api/v1/devices/{devices[1]['id']}", json={"tags": ["bureau", "rgb"]})
assert tagged.status_code == 200
assert tagged.json()["tags"] == ["bureau", "rgb"]
dynamic_group = client.post(
"/api/v1/groups",
json={"name": "Met tag", "dynamic_query": {"tags": ["bureau"], "match": "all"}},
)
assert dynamic_group.status_code == 201
assert [item["id"] for item in dynamic_group.json()["devices"]] == [devices[1]["id"]]
assert client.get("/api/v1/tags").json()[0]["device_count"] == 1
scene = client.post(
"/api/v1/scenes",
json={
"name": "Focus",
"favorite": True,
"items": [
{
"target_type": "group",
"target_id": group.json()["id"],
"state": {"power": True, "colors": [{"red": 42, "green": 80, "blue": 180}]},
}
],
},
)
assert scene.status_code == 201
preview = client.get(f"/api/v1/scenes/{scene.json()['id']}/preview").json()
assert preview["device_count"] == 2
applied = client.post(
f"/api/v1/scenes/{scene.json()['id']}/apply", json={"rollback_on_failure": True}
)
assert applied.status_code == 200
assert applied.json()["status"] == "succeeded"
duplicate = client.post(f"/api/v1/scenes/{scene.json()['id']}/duplicate", json={})
assert duplicate.status_code == 201
assert duplicate.json()["name"] == "Focus (kopie)"
assert len(duplicate.json()["items"]) == 1
exported = client.get(f"/api/v1/scenes/{scene.json()['id']}/export")
assert exported.status_code == 200
assert exported.json()["format"] == "lumaops-scene-v1"
imported = client.post("/api/v1/scenes/import", json=exported.json())
assert imported.status_code == 201
assert imported.json()["name"] == "Focus"
assert client.get("/api/v1/scenes").json()["total"] == 3
def test_physical_dram_family_groups_modules_and_supports_group_commands(
client: TestClient,
) -> None:
devices = client.get("/api/v1/devices").json()["items"]
device_ids = [device["id"] for device in devices]
with client.app.state.context.database.connection() as conn:
conn.execute(
"UPDATE devices SET device_type='dram', name='Corsair Vengeance RGB Pro SL DDR4', "
"vendor='Corsair', model='Corsair DRAM RGB Device', state_json=(SELECT state_json "
"FROM devices ORDER BY controller_index LIMIT 1) WHERE id IN (?, ?)",
tuple(device_ids),
)
groups = client.get("/api/v1/device-groups")
assert groups.status_code == 200
assert len(groups.json()) == 1
group = groups.json()[0]
assert group["device_type"] == "dram"
assert group["module_count"] == 2
assert group["mixed"] is False
assert [device["controller_index"] for device in group["devices"]] == [0, 1]
color = {"red": 34, "green": 102, "blue": 204}
applied = client.post(
f"/api/v1/device-groups/{group['id']}/state",
json={"state": {"power": True, "colors": [color]}},
)
assert applied.status_code == 200
assert applied.json()["status"] == "succeeded"
assert [result["status"] for result in applied.json()["results"]] == [
"succeeded",
"succeeded",
]
for device_id in device_ids:
detail = client.get(f"/api/v1/devices/{device_id}").json()
assert detail["desired_state"]["colors"][0] == color
def test_setup_health_backup_and_diagnostics(client: TestClient) -> None:
setup = client.post("/api/v1/setup/inspect")
assert setup.status_code == 200
assert "storage" in setup.json()
complete = client.post(
"/api/v1/setup/complete",
json={"appdata_confirmed": True, "backup_location_confirmed": True},
)
assert complete.json()["completed"] is True
assert client.get("/health/live").status_code == 200
assert client.get("/health/ready").status_code == 200
health = client.get("/api/v1/health")
assert health.status_code == 200
backup = client.post("/api/v1/backups")
assert backup.status_code == 201
assert backup.json()["name"].endswith(".db")
diagnostic = client.get("/api/v1/diagnostics/export")
assert diagnostic.status_code == 200
assert diagnostic.content.startswith(b"PK")
def test_setup_complete_accepts_legacy_backup_field(client: TestClient) -> None:
complete = client.post(
"/api/v1/setup/complete",
json={"appdata_confirmed": True, "backup_confirmed": True},
)
assert complete.status_code == 200
assert complete.json()["completed"] is True
def test_resource_lifecycle_group_capabilities_and_automation_history(
client: TestClient,
) -> None:
client.post("/api/v1/discovery")
devices = client.get("/api/v1/devices").json()["items"]
room = client.post("/api/v1/rooms", json={"name": "Studio", "description": "Boven"}).json()
updated_room = client.put(
f"/api/v1/rooms/{room['id']}",
json={"name": "Werkstudio", "description": "Bovenverdieping"},
)
assert updated_room.status_code == 200
assert updated_room.json()["description"] == "Bovenverdieping"
client.patch(f"/api/v1/devices/{devices[0]['id']}", json={"room_id": room["id"]})
assert client.delete(f"/api/v1/rooms/{room['id']}").status_code == 204
assert client.get(f"/api/v1/devices/{devices[0]['id']}").json()["room_id"] is None
group = client.post(
"/api/v1/groups",
json={"name": "Alles", "device_ids": [device["id"] for device in devices]},
).json()
group_result = client.post(
f"/api/v1/groups/{group['id']}/state",
json={"state": {"speed": 3}},
)
assert group_result.status_code == 200
assert {item["status"] for item in group_result.json()["results"]} == {
"succeeded",
"skipped",
}
scene = client.post("/api/v1/scenes", json={"name": "Leeg", "items": []}).json()
automation = client.post(
"/api/v1/automations",
json={
"name": "Avond",
"description": "Dagelijkse rustige scène",
"trigger": {"type": "time", "at": "20:00", "weekdays": [0, 2, 4]},
"actions": [{"type": "scene", "scene_id": scene["id"]}],
"timezone": "Europe/Brussels",
"cooldown_seconds": 0,
"conflict_key": "woonkamer",
},
)
assert automation.status_code == 201
assert automation.json()["description"] == "Dagelijkse rustige scène"
run = client.post(f"/api/v1/automations/{automation.json()['id']}/run")
assert run.status_code == 200
assert run.json()["status"] == "succeeded"
history = client.get(f"/api/v1/automations/{automation.json()['id']}/runs").json()
assert history["total"] == 1
assert history["items"][0]["status"] == "succeeded"
audit = client.get("/api/v1/audit?limit=500").json()["items"]
assert any(item["action"] == "api.create_room" for item in audit)
assert any(item["action"] == "api.create_automation" for item in audit)
def test_automation_requests_reject_invalid_schedules_and_actions(client: TestClient) -> None:
base = {
"name": "Ongeldig",
"description": "Validatiecontrole",
"trigger": {"type": "time", "at": "20:00", "weekdays": [0]},
"actions": [{"type": "scene", "scene_id": "scene-id"}],
"timezone": "Europe/Brussels",
}
invalid_time = client.post(
"/api/v1/automations",
json={**base, "trigger": {"type": "time", "at": "25:90", "weekdays": [0]}},
)
assert invalid_time.status_code == 422
assert invalid_time.json()["error"]["code"] == "validation_error"
missing_scene = client.post(
"/api/v1/automations",
json={**base, "actions": [{"type": "scene", "scene_id": None}]},
)
assert missing_scene.status_code == 422
invalid_timezone = client.post(
"/api/v1/automations",
json={**base, "timezone": "Mars/Olympus_Mons"},
)
assert invalid_timezone.status_code == 422
@@ -1,25 +1,25 @@
from __future__ import annotations
import pytest
from lumaops_backend.connectors.base import DeviceState, RGBColor
from lumaops_backend.connectors.mock import MockOpenRGBAdapter
@pytest.mark.asyncio
async def test_mock_connector_contract() -> None:
connector = MockOpenRGBAdapter()
await connector.start()
health = await connector.test_connection()
assert health.connected is True
devices = await connector.discover()
assert devices
device = devices[0]
before = await connector.get_state(device.external_id)
assert before.colors
after = await connector.set_state(
device.external_id,
DeviceState(power=True, colors=[RGBColor(red=10, green=20, blue=30)]),
)
assert after.colors == [RGBColor(red=10, green=20, blue=30)]
await connector.stop()
from __future__ import annotations
import pytest
from lumaops_backend.connectors.base import DeviceState, RGBColor
from lumaops_backend.connectors.mock import MockOpenRGBAdapter
@pytest.mark.asyncio
async def test_mock_connector_contract() -> None:
connector = MockOpenRGBAdapter()
await connector.start()
health = await connector.test_connection()
assert health.connected is True
devices = await connector.discover()
assert devices
device = devices[0]
before = await connector.get_state(device.external_id)
assert before.colors
after = await connector.set_state(
device.external_id,
DeviceState(power=True, colors=[RGBColor(red=10, green=20, blue=30)]),
)
assert after.colors == [RGBColor(red=10, green=20, blue=30)]
await connector.stop()
@@ -1,95 +1,95 @@
from __future__ import annotations
import logging
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from lumaops_backend.config import Settings
from lumaops_backend.database import Database
from lumaops_backend.logging_config import JsonFormatter
from lumaops_backend.main import create_app
from lumaops_backend.secrets import SecretStore
def test_production_lan_bind_requires_authentication() -> None:
with pytest.raises(ValidationError, match="niet-lokale productiebind"):
Settings(auth_enabled=False)
def test_authentication_rejects_placeholder_token() -> None:
with pytest.raises(ValidationError, match="minstens 32 tekens"):
Settings(LUMAOPS_ADMIN_TOKEN="replace-with-a-long-random-token") # noqa: S106
def test_migration_is_repeatable(settings: Settings) -> None:
database = Database(settings)
database.initialize()
database.initialize()
with database.connection() as conn:
versions = conn.execute("SELECT version FROM schema_migrations").fetchall()
foreign_keys = conn.execute("PRAGMA foreign_keys").fetchone()[0]
journal_mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
assert [row["version"] for row in versions] == [
"0001_initial",
"0002_automation_descriptions",
"0003_device_desired_state",
"0004_device_classification",
]
assert foreign_keys == 1
assert journal_mode == "wal"
def test_secrets_are_encrypted_and_key_is_persistent(settings: Settings) -> None:
first = SecretStore(settings)
ciphertext = first.encrypt("very-secret-token")
assert b"very-secret-token" not in ciphertext
second = SecretStore(settings)
assert second.decrypt(ciphertext) == "very-secret-token"
assert (settings.config_dir / "secret.key").exists()
def test_json_logger_redacts_secret_fields() -> None:
formatter = JsonFormatter()
record = logging.LogRecord(
"test",
logging.INFO,
__file__,
1,
"payload %s",
({"api_key": "secret", "device": "ok"},),
None,
)
rendered = formatter.format(record)
assert "[REDACTED]" in rendered
assert "secret" not in rendered
def test_auth_cookie_requires_csrf(tmp_path: Path) -> None:
settings = Settings(
LUMAOPS_ENV="test",
connector_mode="mock",
auth_enabled=True,
LUMAOPS_ADMIN_TOKEN="correct-horse-battery-staple-test-token", # noqa: S106 - test-only token
secure_cookies=False,
config_dir=tmp_path / "config",
openrgb_config_dir=tmp_path / "openrgb",
data_dir=tmp_path / "data",
logs_dir=tmp_path / "logs",
static_dir=tmp_path / "static",
database_url=f"sqlite:///{(tmp_path / 'data' / 'auth.db').as_posix()}",
log_level="ERROR",
)
with TestClient(create_app(settings)) as client:
assert client.get("/api/v1/system").status_code == 401
login = client.post(
"/api/v1/auth/login", json={"token": "correct-horse-battery-staple-test-token"}
)
assert login.status_code == 200
assert client.get("/api/v1/system").status_code == 200
without_csrf = client.post("/api/v1/discovery")
assert without_csrf.status_code == 403
csrf = login.json()["csrf_token"]
assert client.post("/api/v1/discovery", headers={"X-CSRF-Token": csrf}).status_code == 200
from __future__ import annotations
import logging
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from pydantic import ValidationError
from lumaops_backend.config import Settings
from lumaops_backend.database import Database
from lumaops_backend.logging_config import JsonFormatter
from lumaops_backend.main import create_app
from lumaops_backend.secrets import SecretStore
def test_production_lan_bind_requires_authentication() -> None:
with pytest.raises(ValidationError, match="niet-lokale productiebind"):
Settings(auth_enabled=False)
def test_authentication_rejects_placeholder_token() -> None:
with pytest.raises(ValidationError, match="minstens 32 tekens"):
Settings(LUMAOPS_ADMIN_TOKEN="replace-with-a-long-random-token") # noqa: S106
def test_migration_is_repeatable(settings: Settings) -> None:
database = Database(settings)
database.initialize()
database.initialize()
with database.connection() as conn:
versions = conn.execute("SELECT version FROM schema_migrations").fetchall()
foreign_keys = conn.execute("PRAGMA foreign_keys").fetchone()[0]
journal_mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
assert [row["version"] for row in versions] == [
"0001_initial",
"0002_automation_descriptions",
"0003_device_desired_state",
"0004_device_classification",
]
assert foreign_keys == 1
assert journal_mode == "wal"
def test_secrets_are_encrypted_and_key_is_persistent(settings: Settings) -> None:
first = SecretStore(settings)
ciphertext = first.encrypt("very-secret-token")
assert b"very-secret-token" not in ciphertext
second = SecretStore(settings)
assert second.decrypt(ciphertext) == "very-secret-token"
assert (settings.config_dir / "secret.key").exists()
def test_json_logger_redacts_secret_fields() -> None:
formatter = JsonFormatter()
record = logging.LogRecord(
"test",
logging.INFO,
__file__,
1,
"payload %s",
({"api_key": "secret", "device": "ok"},),
None,
)
rendered = formatter.format(record)
assert "[REDACTED]" in rendered
assert "secret" not in rendered
def test_auth_cookie_requires_csrf(tmp_path: Path) -> None:
settings = Settings(
LUMAOPS_ENV="test",
connector_mode="mock",
auth_enabled=True,
LUMAOPS_ADMIN_TOKEN="correct-horse-battery-staple-test-token", # noqa: S106 - test-only token
secure_cookies=False,
config_dir=tmp_path / "config",
openrgb_config_dir=tmp_path / "openrgb",
data_dir=tmp_path / "data",
logs_dir=tmp_path / "logs",
static_dir=tmp_path / "static",
database_url=f"sqlite:///{(tmp_path / 'data' / 'auth.db').as_posix()}",
log_level="ERROR",
)
with TestClient(create_app(settings)) as client:
assert client.get("/api/v1/system").status_code == 401
login = client.post(
"/api/v1/auth/login", json={"token": "correct-horse-battery-staple-test-token"}
)
assert login.status_code == 200
assert client.get("/api/v1/system").status_code == 200
without_csrf = client.post("/api/v1/discovery")
assert without_csrf.status_code == 403
csrf = login.json()["csrf_token"]
assert client.post("/api/v1/discovery", headers={"X-CSRF-Token": csrf}).status_code == 200
+240 -240
View File
@@ -1,240 +1,240 @@
from __future__ import annotations
import asyncio
import struct
from pathlib import Path
import pytest
from lumaops_backend.config import Settings
from lumaops_backend.connectors.base import DeviceState, RGBColor
from lumaops_backend.connectors.openrgb.adapter import OpenRGBAdapter, expand_colors
from lumaops_backend.connectors.openrgb.protocol import (
HEADER,
ModeFlag,
PacketId,
pack_color,
pack_header,
pack_string,
parse_header,
)
def controller_packet(
*,
active_mode: int = 0,
controller_colors: list[RGBColor] | None = None,
static_color: RGBColor | None = None,
) -> bytes:
color = static_color or RGBColor(red=16, green=32, blue=48)
led_colors = controller_colors or [RGBColor(red=16, green=32, blue=48)] * 2
direct_mode = (
pack_string("Direct")
+ struct.pack("<iI", 0, int(ModeFlag.HAS_PER_LED_COLOR))
+ struct.pack("<IIIIIIIIII", 0, 0, 0, 0, 0, 0, 0, 0, 0, 1)
+ struct.pack("<H", 0)
)
static_mode = (
pack_string("Static")
+ struct.pack("<iI", 7, int(ModeFlag.HAS_BRIGHTNESS | ModeFlag.HAS_MODE_SPECIFIC_COLOR))
+ struct.pack("<IIIIIIIIII", 0, 0, 0, 255, 1, 1, 0, 128, 0, 1)
+ struct.pack("<H", 1)
+ pack_color(color)
)
custom_mode = (
pack_string("Custom")
+ struct.pack("<iI", 1, int(ModeFlag.HAS_PER_LED_COLOR | ModeFlag.AUTOMATIC_SAVE))
+ struct.pack("<IIIIIIIIII", 0, 0, 0, 0, 0, 0, 0, 0, 0, 1)
+ struct.pack("<H", 0)
)
zone = (
pack_string("Main")
+ struct.pack("<iIIIH", 1, 0, 120, 2, 0)
+ struct.pack("<H", 0)
+ struct.pack("<I", 0)
)
leds = pack_string("LED 1") + struct.pack("<I", 1) + pack_string("LED 2") + struct.pack("<I", 2)
body = (
struct.pack("<i", 0)
+ pack_string("SDK Fixture")
+ pack_string("LumaOps")
+ pack_string("Integration test")
+ pack_string("1.2.3")
+ pack_string("SERIAL-1")
+ pack_string("usb:1-2")
+ struct.pack("<H", 3)
+ struct.pack("<i", active_mode)
+ direct_mode
+ custom_mode
+ static_mode
+ struct.pack("<H", 1)
+ zone
+ struct.pack("<H", 2)
+ leds
+ struct.pack("<H", 2)
+ b"".join(pack_color(item) for item in led_colors)
+ struct.pack("<H", 0)
+ struct.pack("<I", 1)
)
return struct.pack("<I", len(body) + 4) + body
def test_uniform_color_state_survives_a_changed_led_count() -> None:
color = RGBColor(red=86, green=96, blue=255)
assert expand_colors([color] * 6, 23) == [color] * 23
@pytest.mark.asyncio
async def test_real_adapter_handshake_inventory_and_write(tmp_path: Path) -> None:
seen: list[tuple[int, bytes]] = []
update_received = asyncio.Event()
effect_received = asyncio.Event()
resize_received = asyncio.Event()
active_mode = 0
fixture_colors = [RGBColor(red=16, green=32, blue=48)] * 2
fixture_static_color = RGBColor(red=16, green=32, blue=48)
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
nonlocal active_mode, fixture_colors, fixture_static_color
try:
while True:
header = parse_header(await reader.readexactly(HEADER.size))
payload = await reader.readexactly(header.payload_size)
seen.append((header.packet_id, payload))
if header.packet_id == PacketId.UPDATE_LEDS:
count = struct.unpack_from("<H", payload, 4)[0]
fixture_colors = []
for index in range(count):
red, green, blue = struct.unpack_from("<BBB", payload, 6 + index * 4)
fixture_colors.append(RGBColor(red=red, green=green, blue=blue))
update_received.set()
elif header.packet_id == PacketId.UPDATE_ZONE_LEDS:
zone_index, count = struct.unpack_from("<IH", payload, 4)
assert zone_index == 0
fixture_colors = []
for index in range(count):
red, green, blue = struct.unpack_from("<BBB", payload, 10 + index * 4)
fixture_colors.append(RGBColor(red=red, green=green, blue=blue))
update_received.set()
elif header.packet_id == PacketId.UPDATE_MODE:
active_mode = struct.unpack_from("<i", payload, 4)[0]
if active_mode == 2:
red, green, blue = struct.unpack_from("<BBB", payload, len(payload) - 4)
fixture_static_color = RGBColor(red=red, green=green, blue=blue)
effect_received.set()
elif header.packet_id == PacketId.SET_CUSTOM_MODE:
active_mode = 0
elif header.packet_id == PacketId.RESIZE_ZONE:
resize_received.set()
response: bytes | None = None
if header.packet_id == PacketId.REQUEST_PROTOCOL_VERSION:
response = struct.pack("<I", 5)
elif header.packet_id == PacketId.REQUEST_CONTROLLER_COUNT:
response = struct.pack("<I", 1)
elif header.packet_id == PacketId.REQUEST_CONTROLLER_DATA:
response = controller_packet(
active_mode=active_mode,
controller_colors=fixture_colors,
static_color=fixture_static_color,
)
if response is not None:
writer.write(
pack_header(header.device_index, header.packet_id, len(response)) + response
)
await writer.drain()
except asyncio.IncompleteReadError:
pass
finally:
writer.close()
await writer.wait_closed()
server = await asyncio.start_server(handle, "127.0.0.1", 0)
port = server.sockets[0].getsockname()[1]
settings = Settings(
LUMAOPS_ENV="test",
auth_enabled=False,
openrgb_host="127.0.0.1",
openrgb_port=port,
openrgb_connect_timeout=1,
openrgb_command_timeout=1,
config_dir=tmp_path / "config",
openrgb_config_dir=tmp_path / "openrgb",
data_dir=tmp_path / "data",
logs_dir=tmp_path / "logs",
database_url=f"sqlite:///{tmp_path / 'test.db'}",
)
adapter = OpenRGBAdapter(settings)
try:
devices = await adapter.inventory()
assert len(devices) == 1
assert devices[0].name == "SDK Fixture"
state = await adapter.set_state(
devices[0].external_id,
DeviceState(
colors=[RGBColor(red=2, green=4, blue=8)],
brightness=100,
mode_index=1,
),
)
assert state.colors == [RGBColor(red=2, green=4, blue=8)] * 2
assert state.mode == "Direct"
assert state.mode_index == 0
assert state.brightness is None
await adapter.resize_zone(devices[0].external_id, 0, 24)
await asyncio.wait_for(resize_received.wait(), timeout=1)
assert [packet_id for packet_id, _payload in seen[:4]] == [
PacketId.REQUEST_PROTOCOL_VERSION,
PacketId.SET_CLIENT_NAME,
PacketId.REQUEST_CONTROLLER_COUNT,
PacketId.REQUEST_CONTROLLER_DATA,
]
assert any(packet_id == PacketId.SET_CUSTOM_MODE for packet_id, _payload in seen)
assert any(packet_id == PacketId.UPDATE_LEDS for packet_id, _payload in seen)
assert (PacketId.RESIZE_ZONE, struct.pack("<ii", 0, 24)) in seen
zone_start = len(seen)
zone_color = RGBColor(red=9, green=18, blue=27)
zone_state = await adapter.set_state(
devices[0].external_id,
DeviceState(colors=[zone_color], zone_index=0),
)
zone_packets = [packet_id for packet_id, _payload in seen[zone_start:]]
assert zone_state.colors == [zone_color] * 2
assert PacketId.UPDATE_ZONE_LEDS in zone_packets
assert PacketId.SET_CUSTOM_MODE not in zone_packets
effect_start = len(seen)
effect_received.clear()
effect_state = await adapter.set_state(
devices[0].external_id,
DeviceState(
colors=[RGBColor(red=90, green=45, blue=180)],
brightness=80,
mode_index=2,
),
)
await asyncio.wait_for(effect_received.wait(), timeout=1)
effect_packets = [packet_id for packet_id, _payload in seen[effect_start:]]
assert effect_state.mode == "Static"
assert effect_state.mode_index == 2
assert effect_state.colors == [RGBColor(red=90, green=45, blue=180)]
assert PacketId.UPDATE_MODE in effect_packets
assert PacketId.SET_CUSTOM_MODE not in effect_packets
assert PacketId.UPDATE_LEDS not in effect_packets
direct_start = len(seen)
direct_color = RGBColor(red=24, green=48, blue=96)
direct_state = await adapter.set_state(
devices[0].external_id,
DeviceState(colors=[direct_color]),
)
direct_packets = [packet_id for packet_id, _payload in seen[direct_start:]]
assert direct_state.mode == "Direct"
assert direct_state.colors == [direct_color] * 2
assert PacketId.SET_CUSTOM_MODE in direct_packets
assert PacketId.UPDATE_LEDS in direct_packets
finally:
await adapter.stop()
server.close()
await server.wait_closed()
from __future__ import annotations
import asyncio
import struct
from pathlib import Path
import pytest
from lumaops_backend.config import Settings
from lumaops_backend.connectors.base import DeviceState, RGBColor
from lumaops_backend.connectors.openrgb.adapter import OpenRGBAdapter, expand_colors
from lumaops_backend.connectors.openrgb.protocol import (
HEADER,
ModeFlag,
PacketId,
pack_color,
pack_header,
pack_string,
parse_header,
)
def controller_packet(
*,
active_mode: int = 0,
controller_colors: list[RGBColor] | None = None,
static_color: RGBColor | None = None,
) -> bytes:
color = static_color or RGBColor(red=16, green=32, blue=48)
led_colors = controller_colors or [RGBColor(red=16, green=32, blue=48)] * 2
direct_mode = (
pack_string("Direct")
+ struct.pack("<iI", 0, int(ModeFlag.HAS_PER_LED_COLOR))
+ struct.pack("<IIIIIIIIII", 0, 0, 0, 0, 0, 0, 0, 0, 0, 1)
+ struct.pack("<H", 0)
)
static_mode = (
pack_string("Static")
+ struct.pack("<iI", 7, int(ModeFlag.HAS_BRIGHTNESS | ModeFlag.HAS_MODE_SPECIFIC_COLOR))
+ struct.pack("<IIIIIIIIII", 0, 0, 0, 255, 1, 1, 0, 128, 0, 1)
+ struct.pack("<H", 1)
+ pack_color(color)
)
custom_mode = (
pack_string("Custom")
+ struct.pack("<iI", 1, int(ModeFlag.HAS_PER_LED_COLOR | ModeFlag.AUTOMATIC_SAVE))
+ struct.pack("<IIIIIIIIII", 0, 0, 0, 0, 0, 0, 0, 0, 0, 1)
+ struct.pack("<H", 0)
)
zone = (
pack_string("Main")
+ struct.pack("<iIIIH", 1, 0, 120, 2, 0)
+ struct.pack("<H", 0)
+ struct.pack("<I", 0)
)
leds = pack_string("LED 1") + struct.pack("<I", 1) + pack_string("LED 2") + struct.pack("<I", 2)
body = (
struct.pack("<i", 0)
+ pack_string("SDK Fixture")
+ pack_string("LumaOps")
+ pack_string("Integration test")
+ pack_string("1.2.3")
+ pack_string("SERIAL-1")
+ pack_string("usb:1-2")
+ struct.pack("<H", 3)
+ struct.pack("<i", active_mode)
+ direct_mode
+ custom_mode
+ static_mode
+ struct.pack("<H", 1)
+ zone
+ struct.pack("<H", 2)
+ leds
+ struct.pack("<H", 2)
+ b"".join(pack_color(item) for item in led_colors)
+ struct.pack("<H", 0)
+ struct.pack("<I", 1)
)
return struct.pack("<I", len(body) + 4) + body
def test_uniform_color_state_survives_a_changed_led_count() -> None:
color = RGBColor(red=86, green=96, blue=255)
assert expand_colors([color] * 6, 23) == [color] * 23
@pytest.mark.asyncio
async def test_real_adapter_handshake_inventory_and_write(tmp_path: Path) -> None:
seen: list[tuple[int, bytes]] = []
update_received = asyncio.Event()
effect_received = asyncio.Event()
resize_received = asyncio.Event()
active_mode = 0
fixture_colors = [RGBColor(red=16, green=32, blue=48)] * 2
fixture_static_color = RGBColor(red=16, green=32, blue=48)
async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
nonlocal active_mode, fixture_colors, fixture_static_color
try:
while True:
header = parse_header(await reader.readexactly(HEADER.size))
payload = await reader.readexactly(header.payload_size)
seen.append((header.packet_id, payload))
if header.packet_id == PacketId.UPDATE_LEDS:
count = struct.unpack_from("<H", payload, 4)[0]
fixture_colors = []
for index in range(count):
red, green, blue = struct.unpack_from("<BBB", payload, 6 + index * 4)
fixture_colors.append(RGBColor(red=red, green=green, blue=blue))
update_received.set()
elif header.packet_id == PacketId.UPDATE_ZONE_LEDS:
zone_index, count = struct.unpack_from("<IH", payload, 4)
assert zone_index == 0
fixture_colors = []
for index in range(count):
red, green, blue = struct.unpack_from("<BBB", payload, 10 + index * 4)
fixture_colors.append(RGBColor(red=red, green=green, blue=blue))
update_received.set()
elif header.packet_id == PacketId.UPDATE_MODE:
active_mode = struct.unpack_from("<i", payload, 4)[0]
if active_mode == 2:
red, green, blue = struct.unpack_from("<BBB", payload, len(payload) - 4)
fixture_static_color = RGBColor(red=red, green=green, blue=blue)
effect_received.set()
elif header.packet_id == PacketId.SET_CUSTOM_MODE:
active_mode = 0
elif header.packet_id == PacketId.RESIZE_ZONE:
resize_received.set()
response: bytes | None = None
if header.packet_id == PacketId.REQUEST_PROTOCOL_VERSION:
response = struct.pack("<I", 5)
elif header.packet_id == PacketId.REQUEST_CONTROLLER_COUNT:
response = struct.pack("<I", 1)
elif header.packet_id == PacketId.REQUEST_CONTROLLER_DATA:
response = controller_packet(
active_mode=active_mode,
controller_colors=fixture_colors,
static_color=fixture_static_color,
)
if response is not None:
writer.write(
pack_header(header.device_index, header.packet_id, len(response)) + response
)
await writer.drain()
except asyncio.IncompleteReadError:
pass
finally:
writer.close()
await writer.wait_closed()
server = await asyncio.start_server(handle, "127.0.0.1", 0)
port = server.sockets[0].getsockname()[1]
settings = Settings(
LUMAOPS_ENV="test",
auth_enabled=False,
openrgb_host="127.0.0.1",
openrgb_port=port,
openrgb_connect_timeout=1,
openrgb_command_timeout=1,
config_dir=tmp_path / "config",
openrgb_config_dir=tmp_path / "openrgb",
data_dir=tmp_path / "data",
logs_dir=tmp_path / "logs",
database_url=f"sqlite:///{tmp_path / 'test.db'}",
)
adapter = OpenRGBAdapter(settings)
try:
devices = await adapter.inventory()
assert len(devices) == 1
assert devices[0].name == "SDK Fixture"
state = await adapter.set_state(
devices[0].external_id,
DeviceState(
colors=[RGBColor(red=2, green=4, blue=8)],
brightness=100,
mode_index=1,
),
)
assert state.colors == [RGBColor(red=2, green=4, blue=8)] * 2
assert state.mode == "Direct"
assert state.mode_index == 0
assert state.brightness is None
await adapter.resize_zone(devices[0].external_id, 0, 24)
await asyncio.wait_for(resize_received.wait(), timeout=1)
assert [packet_id for packet_id, _payload in seen[:4]] == [
PacketId.REQUEST_PROTOCOL_VERSION,
PacketId.SET_CLIENT_NAME,
PacketId.REQUEST_CONTROLLER_COUNT,
PacketId.REQUEST_CONTROLLER_DATA,
]
assert any(packet_id == PacketId.SET_CUSTOM_MODE for packet_id, _payload in seen)
assert any(packet_id == PacketId.UPDATE_LEDS for packet_id, _payload in seen)
assert (PacketId.RESIZE_ZONE, struct.pack("<ii", 0, 24)) in seen
zone_start = len(seen)
zone_color = RGBColor(red=9, green=18, blue=27)
zone_state = await adapter.set_state(
devices[0].external_id,
DeviceState(colors=[zone_color], zone_index=0),
)
zone_packets = [packet_id for packet_id, _payload in seen[zone_start:]]
assert zone_state.colors == [zone_color] * 2
assert PacketId.UPDATE_ZONE_LEDS in zone_packets
assert PacketId.SET_CUSTOM_MODE not in zone_packets
effect_start = len(seen)
effect_received.clear()
effect_state = await adapter.set_state(
devices[0].external_id,
DeviceState(
colors=[RGBColor(red=90, green=45, blue=180)],
brightness=80,
mode_index=2,
),
)
await asyncio.wait_for(effect_received.wait(), timeout=1)
effect_packets = [packet_id for packet_id, _payload in seen[effect_start:]]
assert effect_state.mode == "Static"
assert effect_state.mode_index == 2
assert effect_state.colors == [RGBColor(red=90, green=45, blue=180)]
assert PacketId.UPDATE_MODE in effect_packets
assert PacketId.SET_CUSTOM_MODE not in effect_packets
assert PacketId.UPDATE_LEDS not in effect_packets
direct_start = len(seen)
direct_color = RGBColor(red=24, green=48, blue=96)
direct_state = await adapter.set_state(
devices[0].external_id,
DeviceState(colors=[direct_color]),
)
direct_packets = [packet_id for packet_id, _payload in seen[direct_start:]]
assert direct_state.mode == "Direct"
assert direct_state.colors == [direct_color] * 2
assert PacketId.SET_CUSTOM_MODE in direct_packets
assert PacketId.UPDATE_LEDS in direct_packets
finally:
await adapter.stop()
server.close()
await server.wait_closed()
+169 -169
View File
@@ -1,169 +1,169 @@
from __future__ import annotations
import struct
import pytest
from lumaops_backend.connectors.base import RGBColor
from lumaops_backend.connectors.openrgb.protocol import (
HEADER,
MAGIC,
Mode,
ModeFlag,
PacketId,
ProtocolError,
pack_color,
pack_header,
pack_mode,
pack_string,
pack_update_leds,
parse_controller,
parse_header,
)
def controller_packet() -> bytes:
color = RGBColor(red=16, green=32, blue=48)
mode = (
pack_string("Static")
+ struct.pack("<i", 7)
+ struct.pack("<I", int(ModeFlag.HAS_BRIGHTNESS | ModeFlag.HAS_PER_LED_COLOR))
+ struct.pack("<IIIIIIIIII", 0, 0, 0, 255, 1, 1, 0, 128, 0, 1)
+ struct.pack("<H", 1)
+ pack_color(color)
)
zone = (
pack_string("Main")
+ struct.pack("<iIIIH", 1, 2, 2, 2, 0)
+ struct.pack("<H", 1)
+ pack_string("Segment A")
+ struct.pack("<iII", 1, 0, 2)
+ struct.pack("<I", 0)
)
leds = pack_string("LED 1") + struct.pack("<I", 1) + pack_string("LED 2") + struct.pack("<I", 2)
body = (
struct.pack("<i", 0)
+ pack_string("Test Controller")
+ pack_string("LumaOps")
+ pack_string("Fixture")
+ pack_string("1.2.3")
+ pack_string("SERIAL-1")
+ pack_string("usb:1-2")
+ struct.pack("<H", 1)
+ struct.pack("<i", 0)
+ mode
+ struct.pack("<H", 1)
+ zone
+ struct.pack("<H", 2)
+ leds
+ struct.pack("<H", 2)
+ pack_color(color)
+ pack_color(color)
+ struct.pack("<H", 2)
+ pack_string("A")
+ pack_string("B")
+ struct.pack("<I", 1)
)
return struct.pack("<I", len(body) + 4) + body
def test_header_roundtrip_and_limit() -> None:
raw = pack_header(3, PacketId.UPDATE_LEDS, 42)
assert len(raw) == HEADER.size
parsed = parse_header(raw)
assert parsed.device_index == 3
assert parsed.packet_id == PacketId.UPDATE_LEDS
assert parsed.payload_size == 42
oversized = HEADER.pack(MAGIC, 0, 0, 4097)
with pytest.raises(ProtocolError):
parse_header(oversized, max_packet_size=4096)
def test_parse_protocol_v5_controller() -> None:
controller = parse_controller(controller_packet(), 4)
assert controller.name == "Test Controller"
assert controller.vendor == "LumaOps"
assert controller.firmware_version == "1.2.3"
assert len(controller.leds) == 2
assert controller.led_alt_names == ["A", "B"]
assert controller.zones[0].segments[0].name == "Segment A"
assert controller.capabilities.brightness is True
assert controller.capabilities.per_led is True
def test_parser_rejects_truncation_and_bad_declared_size() -> None:
packet = controller_packet()
with pytest.raises(ProtocolError):
parse_controller(packet[:-1], 0)
corrupted = struct.pack("<I", len(packet) + 100) + packet[4:]
with pytest.raises(ProtocolError):
parse_controller(corrupted, 0)
def test_led_updates_require_exact_count() -> None:
color = RGBColor(red=1, green=2, blue=3)
payload = pack_update_leds([color, color], 2)
assert struct.unpack("<I", payload[:4])[0] == len(payload)
with pytest.raises(ProtocolError):
pack_update_leds([color], 2)
def test_mode_serializer_validates_ranges() -> None:
mode = Mode(
index=0,
name="Pulse",
value=1,
flags=int(ModeFlag.HAS_SPEED | ModeFlag.HAS_BRIGHTNESS),
speed_min=1,
speed_max=10,
brightness_min=0,
brightness_max=255,
colors_min=0,
colors_max=0,
speed=5,
brightness=128,
direction=0,
color_mode=0,
)
payload = pack_mode(mode, 0, brightness_percent=50, speed=7)
assert struct.unpack("<I", payload[:4])[0] == len(payload)
with pytest.raises(ProtocolError):
pack_mode(mode, 0, speed=11)
def test_mode_serializer_carries_effect_colors_and_direction() -> None:
mode = Mode(
index=4,
name="Color Wave",
value=4,
flags=int(
ModeFlag.HAS_SPEED
| ModeFlag.HAS_DIRECTION_LR
| ModeFlag.HAS_BRIGHTNESS
| ModeFlag.HAS_MODE_SPECIFIC_COLOR
),
speed_min=0,
speed_max=2,
brightness_min=0,
brightness_max=255,
colors_min=2,
colors_max=2,
speed=1,
brightness=128,
direction=0,
color_mode=1,
)
colors = [RGBColor(red=12, green=34, blue=56), RGBColor(red=78, green=90, blue=123)]
payload = pack_mode(
mode,
mode.index,
brightness_percent=75,
speed=2,
direction=1,
colors=colors,
)
assert struct.unpack("<I", payload[:4])[0] == len(payload)
assert payload.endswith(pack_color(colors[0]) + pack_color(colors[1]))
with pytest.raises(ProtocolError):
pack_mode(mode, mode.index, colors=colors[:1])
from __future__ import annotations
import struct
import pytest
from lumaops_backend.connectors.base import RGBColor
from lumaops_backend.connectors.openrgb.protocol import (
HEADER,
MAGIC,
Mode,
ModeFlag,
PacketId,
ProtocolError,
pack_color,
pack_header,
pack_mode,
pack_string,
pack_update_leds,
parse_controller,
parse_header,
)
def controller_packet() -> bytes:
color = RGBColor(red=16, green=32, blue=48)
mode = (
pack_string("Static")
+ struct.pack("<i", 7)
+ struct.pack("<I", int(ModeFlag.HAS_BRIGHTNESS | ModeFlag.HAS_PER_LED_COLOR))
+ struct.pack("<IIIIIIIIII", 0, 0, 0, 255, 1, 1, 0, 128, 0, 1)
+ struct.pack("<H", 1)
+ pack_color(color)
)
zone = (
pack_string("Main")
+ struct.pack("<iIIIH", 1, 2, 2, 2, 0)
+ struct.pack("<H", 1)
+ pack_string("Segment A")
+ struct.pack("<iII", 1, 0, 2)
+ struct.pack("<I", 0)
)
leds = pack_string("LED 1") + struct.pack("<I", 1) + pack_string("LED 2") + struct.pack("<I", 2)
body = (
struct.pack("<i", 0)
+ pack_string("Test Controller")
+ pack_string("LumaOps")
+ pack_string("Fixture")
+ pack_string("1.2.3")
+ pack_string("SERIAL-1")
+ pack_string("usb:1-2")
+ struct.pack("<H", 1)
+ struct.pack("<i", 0)
+ mode
+ struct.pack("<H", 1)
+ zone
+ struct.pack("<H", 2)
+ leds
+ struct.pack("<H", 2)
+ pack_color(color)
+ pack_color(color)
+ struct.pack("<H", 2)
+ pack_string("A")
+ pack_string("B")
+ struct.pack("<I", 1)
)
return struct.pack("<I", len(body) + 4) + body
def test_header_roundtrip_and_limit() -> None:
raw = pack_header(3, PacketId.UPDATE_LEDS, 42)
assert len(raw) == HEADER.size
parsed = parse_header(raw)
assert parsed.device_index == 3
assert parsed.packet_id == PacketId.UPDATE_LEDS
assert parsed.payload_size == 42
oversized = HEADER.pack(MAGIC, 0, 0, 4097)
with pytest.raises(ProtocolError):
parse_header(oversized, max_packet_size=4096)
def test_parse_protocol_v5_controller() -> None:
controller = parse_controller(controller_packet(), 4)
assert controller.name == "Test Controller"
assert controller.vendor == "LumaOps"
assert controller.firmware_version == "1.2.3"
assert len(controller.leds) == 2
assert controller.led_alt_names == ["A", "B"]
assert controller.zones[0].segments[0].name == "Segment A"
assert controller.capabilities.brightness is True
assert controller.capabilities.per_led is True
def test_parser_rejects_truncation_and_bad_declared_size() -> None:
packet = controller_packet()
with pytest.raises(ProtocolError):
parse_controller(packet[:-1], 0)
corrupted = struct.pack("<I", len(packet) + 100) + packet[4:]
with pytest.raises(ProtocolError):
parse_controller(corrupted, 0)
def test_led_updates_require_exact_count() -> None:
color = RGBColor(red=1, green=2, blue=3)
payload = pack_update_leds([color, color], 2)
assert struct.unpack("<I", payload[:4])[0] == len(payload)
with pytest.raises(ProtocolError):
pack_update_leds([color], 2)
def test_mode_serializer_validates_ranges() -> None:
mode = Mode(
index=0,
name="Pulse",
value=1,
flags=int(ModeFlag.HAS_SPEED | ModeFlag.HAS_BRIGHTNESS),
speed_min=1,
speed_max=10,
brightness_min=0,
brightness_max=255,
colors_min=0,
colors_max=0,
speed=5,
brightness=128,
direction=0,
color_mode=0,
)
payload = pack_mode(mode, 0, brightness_percent=50, speed=7)
assert struct.unpack("<I", payload[:4])[0] == len(payload)
with pytest.raises(ProtocolError):
pack_mode(mode, 0, speed=11)
def test_mode_serializer_carries_effect_colors_and_direction() -> None:
mode = Mode(
index=4,
name="Color Wave",
value=4,
flags=int(
ModeFlag.HAS_SPEED
| ModeFlag.HAS_DIRECTION_LR
| ModeFlag.HAS_BRIGHTNESS
| ModeFlag.HAS_MODE_SPECIFIC_COLOR
),
speed_min=0,
speed_max=2,
brightness_min=0,
brightness_max=255,
colors_min=2,
colors_max=2,
speed=1,
brightness=128,
direction=0,
color_mode=1,
)
colors = [RGBColor(red=12, green=34, blue=56), RGBColor(red=78, green=90, blue=123)]
payload = pack_mode(
mode,
mode.index,
brightness_percent=75,
speed=2,
direction=1,
colors=colors,
)
assert struct.unpack("<I", payload[:4])[0] == len(payload)
assert payload.endswith(pack_color(colors[0]) + pack_color(colors[1]))
with pytest.raises(ProtocolError):
pack_mode(mode, mode.index, colors=colors[:1])
+31 -31
View File
@@ -1,31 +1,31 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", "coverage"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
parserOptions: {
project: ["./tsconfig.app.json", "./tsconfig.node.json"],
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: { attributes: false } }],
},
},
);
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist", "coverage"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
parserOptions: {
project: ["./tsconfig.app.json", "./tsconfig.node.json"],
tsconfigRootDir: import.meta.dirname,
},
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: { attributes: false } }],
},
},
);
+15 -15
View File
@@ -1,15 +1,15 @@
<!doctype html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0b1020" />
<meta name="description" content="LumaOps lokaal RGB- en smart-lightingbeheer" />
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
<title>LumaOps</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
<!doctype html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0b1020" />
<meta name="description" content="LumaOps lokaal RGB- en smart-lightingbeheer" />
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
<title>LumaOps</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4845 -4845
View File
File diff suppressed because it is too large. Load diff
+40 -40
View File
@@ -1,40 +1,40 @@
{
"name": "lumaops-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"lint": "eslint . --max-warnings 0",
"test": "vitest run",
"test:watch": "vitest",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@tanstack/react-query": "5.80.7",
"lucide-react": "0.511.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-router-dom": "7.18.3"
},
"devDependencies": {
"@eslint/js": "9.28.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/node": "22.15.29",
"@types/react": "19.1.6",
"@types/react-dom": "19.1.5",
"@vitejs/plugin-react": "4.5.1",
"eslint": "9.28.0",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"globals": "16.2.0",
"jsdom": "26.1.0",
"typescript": "5.8.3",
"typescript-eslint": "8.33.1",
"vite": "6.4.3",
"vitest": "3.2.7"
}
}
{
"name": "lumaops-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"lint": "eslint . --max-warnings 0",
"test": "vitest run",
"test:watch": "vitest",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"@tanstack/react-query": "5.80.7",
"lucide-react": "0.511.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-router-dom": "7.18.3"
},
"devDependencies": {
"@eslint/js": "9.28.0",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/node": "22.15.29",
"@types/react": "19.1.6",
"@types/react-dom": "19.1.5",
"@vitejs/plugin-react": "4.5.1",
"eslint": "9.28.0",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"globals": "16.2.0",
"jsdom": "26.1.0",
"typescript": "5.8.3",
"typescript-eslint": "8.33.1",
"vite": "6.4.3",
"vitest": "3.2.7"
}
}
+12 -12
View File
@@ -1,12 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="luma" x1="14" y1="10" x2="51" y2="53" gradientUnits="userSpaceOnUse">
<stop stop-color="#9EA5FF"/>
<stop offset="0.52" stop-color="#6570FF"/>
<stop offset="1" stop-color="#36D9B6"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="15" fill="#0B1020"/>
<path d="M19 15v29c0 3.3 2.7 6 6 6h25" fill="none" stroke="url(#luma)" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="47" cy="19" r="5" fill="#36D9B6"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<linearGradient id="luma" x1="14" y1="10" x2="51" y2="53" gradientUnits="userSpaceOnUse">
<stop stop-color="#9EA5FF"/>
<stop offset="0.52" stop-color="#6570FF"/>
<stop offset="1" stop-color="#36D9B6"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="15" fill="#0B1020"/>
<path d="M19 15v29c0 3.3 2.7 6 6 6h25" fill="none" stroke="url(#luma)" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="47" cy="19" r="5" fill="#36D9B6"/>
</svg>

Before

Width:  |  Height:  |  Size: 589 B

After

Width:  |  Height:  |  Size: 577 B

+113 -113
View File
@@ -1,113 +1,113 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { lazy, Suspense, useState, type FormEvent, type ReactNode } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { api } from "./api/client";
import type { SetupState } from "./api/types";
import { AppShell } from "./components/AppShell";
type AuthStatus = { auth_enabled: boolean; authenticated: boolean };
const AboutPage = lazy(() => import("./pages/AboutPage").then((module) => ({ default: module.AboutPage })));
const ActivityPage = lazy(() => import("./pages/ActivityPage").then((module) => ({ default: module.ActivityPage })));
const AutomationsPage = lazy(() => import("./pages/AutomationsPage").then((module) => ({ default: module.AutomationsPage })));
const BackupsPage = lazy(() => import("./pages/BackupsPage").then((module) => ({ default: module.BackupsPage })));
const ConnectorsPage = lazy(() => import("./pages/ConnectorsPage").then((module) => ({ default: module.ConnectorsPage })));
const DeviceDetailPage = lazy(() => import("./pages/DeviceDetailPage").then((module) => ({ default: module.DeviceDetailPage })));
const DeviceGroupPage = lazy(() => import("./pages/DeviceGroupPage").then((module) => ({ default: module.DeviceGroupPage })));
const DevicesPage = lazy(() => import("./pages/DevicesPage").then((module) => ({ default: module.DevicesPage })));
const DiagnosticsPage = lazy(() => import("./pages/DiagnosticsPage").then((module) => ({ default: module.DiagnosticsPage })));
const DiscoveryPage = lazy(() => import("./pages/DiscoveryPage").then((module) => ({ default: module.DiscoveryPage })));
const NetworkPage = lazy(() => import("./pages/NetworkPage").then((module) => ({ default: module.NetworkPage })));
const OverviewPage = lazy(() => import("./pages/OverviewPage").then((module) => ({ default: module.OverviewPage })));
const ScenesPage = lazy(() => import("./pages/ScenesPage").then((module) => ({ default: module.ScenesPage })));
const SettingsPage = lazy(() => import("./pages/SettingsPage").then((module) => ({ default: module.SettingsPage })));
const SetupPage = lazy(() => import("./pages/SetupPage").then((module) => ({ default: module.SetupPage })));
const SpacesPage = lazy(() => import("./pages/SpacesPage").then((module) => ({ default: module.SpacesPage })));
export function App() {
return (
<AuthGate>
<Suspense fallback={<div className="app-loading"><span className="brand__mark" /><p>Pagina laden…</p></div>}>
<Routes>
<Route path="/setup" element={<SetupPage />} />
<Route element={<SetupGate />}>
<Route index element={<OverviewPage />} />
<Route path="devices" element={<DevicesPage />} />
<Route path="devices/:deviceId" element={<DeviceDetailPage />} />
<Route path="device-groups/:groupId" element={<DeviceGroupPage />} />
<Route path="spaces" element={<SpacesPage />} />
<Route path="scenes" element={<ScenesPage />} />
<Route path="automations" element={<AutomationsPage />} />
<Route path="network" element={<NetworkPage />} />
<Route path="connectors" element={<ConnectorsPage />} />
<Route path="discovery" element={<DiscoveryPage />} />
<Route path="activity" element={<ActivityPage mode="activity" />} />
<Route path="audit" element={<ActivityPage mode="audit" />} />
<Route path="diagnostics" element={<DiagnosticsPage />} />
<Route path="backups" element={<BackupsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="about" element={<AboutPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</AuthGate>
);
}
export function AuthGate({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const [token, setToken] = useState("");
const status = useQuery({
queryKey: ["auth-status"],
queryFn: () => api<AuthStatus>("/api/v1/auth/status"),
staleTime: 30_000,
});
const login = useMutation({
mutationFn: () => api<AuthStatus>("/api/v1/auth/login", {
method: "POST",
body: JSON.stringify({ token }),
}),
onSuccess: async () => {
setToken("");
await queryClient.invalidateQueries({ queryKey: ["auth-status"] });
},
});
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
login.mutate();
}
if (status.isLoading) {
return <div className="app-loading"><span className="brand__mark" /><p>Beveiligde sessie controleren…</p></div>;
}
if (status.isError) {
return <main className="login-shell"><section className="login-panel" role="alert"><h1>LumaOps is niet bereikbaar</h1><p>Controleer de server en vernieuw deze pagina.</p></section></main>;
}
if (status.data?.auth_enabled && !status.data.authenticated) {
return (
<main className="login-shell">
<section className="login-panel">
<span className="brand__mark" aria-hidden />
<div><h1>Aanmelden bij LumaOps</h1><p>Voer de beheertoken van deze installatie in.</p></div>
<form onSubmit={submit}>
<label htmlFor="admin-token">Beheertoken</label>
<input id="admin-token" type="password" autoComplete="current-password" value={token} onChange={(event) => setToken(event.target.value)} minLength={32} required autoFocus />
{login.isError ? <p className="login-error" role="alert">Aanmelden mislukt. Controleer de token.</p> : null}
<button className="button button--primary" type="submit" disabled={login.isPending}>{login.isPending ? "Aanmelden…" : "Aanmelden"}</button>
</form>
</section>
</main>
);
}
return children;
}
function SetupGate() {
const location = useLocation();
const setup = useQuery({ queryKey: ["setup"], queryFn: () => api<SetupState>("/api/v1/setup"), staleTime: 30_000 });
if (setup.isLoading) return <div className="app-loading"><span className="brand__mark" /><p>LumaOps voorbereiden…</p></div>;
if (setup.data && !setup.data.completed) return <Navigate to="/setup" replace state={{ from: location.pathname }} />;
return <AppShell />;
}
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { lazy, Suspense, useState, type FormEvent, type ReactNode } from "react";
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { api } from "./api/client";
import type { SetupState } from "./api/types";
import { AppShell } from "./components/AppShell";
type AuthStatus = { auth_enabled: boolean; authenticated: boolean };
const AboutPage = lazy(() => import("./pages/AboutPage").then((module) => ({ default: module.AboutPage })));
const ActivityPage = lazy(() => import("./pages/ActivityPage").then((module) => ({ default: module.ActivityPage })));
const AutomationsPage = lazy(() => import("./pages/AutomationsPage").then((module) => ({ default: module.AutomationsPage })));
const BackupsPage = lazy(() => import("./pages/BackupsPage").then((module) => ({ default: module.BackupsPage })));
const ConnectorsPage = lazy(() => import("./pages/ConnectorsPage").then((module) => ({ default: module.ConnectorsPage })));
const DeviceDetailPage = lazy(() => import("./pages/DeviceDetailPage").then((module) => ({ default: module.DeviceDetailPage })));
const DeviceGroupPage = lazy(() => import("./pages/DeviceGroupPage").then((module) => ({ default: module.DeviceGroupPage })));
const DevicesPage = lazy(() => import("./pages/DevicesPage").then((module) => ({ default: module.DevicesPage })));
const DiagnosticsPage = lazy(() => import("./pages/DiagnosticsPage").then((module) => ({ default: module.DiagnosticsPage })));
const DiscoveryPage = lazy(() => import("./pages/DiscoveryPage").then((module) => ({ default: module.DiscoveryPage })));
const NetworkPage = lazy(() => import("./pages/NetworkPage").then((module) => ({ default: module.NetworkPage })));
const OverviewPage = lazy(() => import("./pages/OverviewPage").then((module) => ({ default: module.OverviewPage })));
const ScenesPage = lazy(() => import("./pages/ScenesPage").then((module) => ({ default: module.ScenesPage })));
const SettingsPage = lazy(() => import("./pages/SettingsPage").then((module) => ({ default: module.SettingsPage })));
const SetupPage = lazy(() => import("./pages/SetupPage").then((module) => ({ default: module.SetupPage })));
const SpacesPage = lazy(() => import("./pages/SpacesPage").then((module) => ({ default: module.SpacesPage })));
export function App() {
return (
<AuthGate>
<Suspense fallback={<div className="app-loading"><span className="brand__mark" /><p>Pagina laden…</p></div>}>
<Routes>
<Route path="/setup" element={<SetupPage />} />
<Route element={<SetupGate />}>
<Route index element={<OverviewPage />} />
<Route path="devices" element={<DevicesPage />} />
<Route path="devices/:deviceId" element={<DeviceDetailPage />} />
<Route path="device-groups/:groupId" element={<DeviceGroupPage />} />
<Route path="spaces" element={<SpacesPage />} />
<Route path="scenes" element={<ScenesPage />} />
<Route path="automations" element={<AutomationsPage />} />
<Route path="network" element={<NetworkPage />} />
<Route path="connectors" element={<ConnectorsPage />} />
<Route path="discovery" element={<DiscoveryPage />} />
<Route path="activity" element={<ActivityPage mode="activity" />} />
<Route path="audit" element={<ActivityPage mode="audit" />} />
<Route path="diagnostics" element={<DiagnosticsPage />} />
<Route path="backups" element={<BackupsPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="about" element={<AboutPage />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</AuthGate>
);
}
export function AuthGate({ children }: { children: ReactNode }) {
const queryClient = useQueryClient();
const [token, setToken] = useState("");
const status = useQuery({
queryKey: ["auth-status"],
queryFn: () => api<AuthStatus>("/api/v1/auth/status"),
staleTime: 30_000,
});
const login = useMutation({
mutationFn: () => api<AuthStatus>("/api/v1/auth/login", {
method: "POST",
body: JSON.stringify({ token }),
}),
onSuccess: async () => {
setToken("");
await queryClient.invalidateQueries({ queryKey: ["auth-status"] });
},
});
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
login.mutate();
}
if (status.isLoading) {
return <div className="app-loading"><span className="brand__mark" /><p>Beveiligde sessie controleren…</p></div>;
}
if (status.isError) {
return <main className="login-shell"><section className="login-panel" role="alert"><h1>LumaOps is niet bereikbaar</h1><p>Controleer de server en vernieuw deze pagina.</p></section></main>;
}
if (status.data?.auth_enabled && !status.data.authenticated) {
return (
<main className="login-shell">
<section className="login-panel">
<span className="brand__mark" aria-hidden />
<div><h1>Aanmelden bij LumaOps</h1><p>Voer de beheertoken van deze installatie in.</p></div>
<form onSubmit={submit}>
<label htmlFor="admin-token">Beheertoken</label>
<input id="admin-token" type="password" autoComplete="current-password" value={token} onChange={(event) => setToken(event.target.value)} minLength={32} required autoFocus />
{login.isError ? <p className="login-error" role="alert">Aanmelden mislukt. Controleer de token.</p> : null}
<button className="button button--primary" type="submit" disabled={login.isPending}>{login.isPending ? "Aanmelden…" : "Aanmelden"}</button>
</form>
</section>
</main>
);
}
return children;
}
function SetupGate() {
const location = useLocation();
const setup = useQuery({ queryKey: ["setup"], queryFn: () => api<SetupState>("/api/v1/setup"), staleTime: 30_000 });
if (setup.isLoading) return <div className="app-loading"><span className="brand__mark" /><p>LumaOps voorbereiden…</p></div>;
if (setup.data && !setup.data.completed) return <Navigate to="/setup" replace state={{ from: location.pathname }} />;
return <AppShell />;
}
+36 -36
View File
@@ -1,36 +1,36 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, test, vi } from "vitest";
import { AuthGate } from "./App";
import { jsonResponse, renderApp, requestJson, requestUrl } from "./test/render";
afterEach(() => vi.mocked(globalThis.fetch).mockRestore());
test("shows protected content when authentication is disabled", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({ auth_enabled: false, authenticated: true }),
);
renderApp(<AuthGate><div>protected content</div></AuthGate>);
expect(await screen.findByText("protected content")).toBeInTheDocument();
});
test("requires the admin token before rendering protected content", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => {
const url = requestUrl(input);
if (url.endsWith("/auth/login")) {
expect(requestJson(init)).toEqual({ token: "a".repeat(32) });
return Promise.resolve(jsonResponse({ auth_enabled: true, authenticated: true }));
}
return Promise.resolve(jsonResponse({
auth_enabled: true,
authenticated: fetchMock.mock.calls.some(([, request]) => request?.method === "POST"),
}));
});
const user = userEvent.setup();
renderApp(<AuthGate><div>protected content</div></AuthGate>);
await user.type(await screen.findByLabelText("Beheertoken"), "a".repeat(32));
await user.click(screen.getByRole("button", { name: "Aanmelden" }));
await waitFor(() => expect(screen.getByText("protected content")).toBeInTheDocument());
});
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, test, vi } from "vitest";
import { AuthGate } from "./App";
import { jsonResponse, renderApp, requestJson, requestUrl } from "./test/render";
afterEach(() => vi.mocked(globalThis.fetch).mockRestore());
test("shows protected content when authentication is disabled", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
jsonResponse({ auth_enabled: false, authenticated: true }),
);
renderApp(<AuthGate><div>protected content</div></AuthGate>);
expect(await screen.findByText("protected content")).toBeInTheDocument();
});
test("requires the admin token before rendering protected content", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => {
const url = requestUrl(input);
if (url.endsWith("/auth/login")) {
expect(requestJson(init)).toEqual({ token: "a".repeat(32) });
return Promise.resolve(jsonResponse({ auth_enabled: true, authenticated: true }));
}
return Promise.resolve(jsonResponse({
auth_enabled: true,
authenticated: fetchMock.mock.calls.some(([, request]) => request?.method === "POST"),
}));
});
const user = userEvent.setup();
renderApp(<AuthGate><div>protected content</div></AuthGate>);
await user.type(await screen.findByLabelText("Beheertoken"), "a".repeat(32));
await user.click(screen.getByRole("button", { name: "Aanmelden" }));
await waitFor(() => expect(screen.getByText("protected content")).toBeInTheDocument());
});
+40 -40
View File
@@ -1,40 +1,40 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mutationId } from "./client";
const originalCrypto = globalThis.crypto;
afterEach(() => {
Object.defineProperty(globalThis, "crypto", { configurable: true, value: originalCrypto });
});
describe("mutationId", () => {
it("uses randomUUID when the browser exposes it", () => {
Object.defineProperty(globalThis, "crypto", {
configurable: true,
value: { randomUUID: () => "11111111-2222-4333-8444-555555555555" },
});
expect(mutationId()).toBe("11111111-2222-4333-8444-555555555555");
});
it("creates a UUID v4 when randomUUID is unavailable on an HTTP LAN origin", () => {
const getRandomValues = vi.fn((bytes: Uint8Array) => {
bytes.fill(0);
return bytes;
});
Object.defineProperty(globalThis, "crypto", {
configurable: true,
value: { getRandomValues },
});
expect(mutationId()).toBe("00000000-0000-4000-8000-000000000000");
expect(getRandomValues).toHaveBeenCalledOnce();
});
it("keeps the UUID format when Web Crypto is entirely unavailable", () => {
Object.defineProperty(globalThis, "crypto", { configurable: true, value: undefined });
vi.spyOn(Math, "random").mockReturnValue(0);
expect(mutationId()).toBe("00000000-0000-4000-8000-000000000000");
});
});
import { afterEach, describe, expect, it, vi } from "vitest";
import { mutationId } from "./client";
const originalCrypto = globalThis.crypto;
afterEach(() => {
Object.defineProperty(globalThis, "crypto", { configurable: true, value: originalCrypto });
});
describe("mutationId", () => {
it("uses randomUUID when the browser exposes it", () => {
Object.defineProperty(globalThis, "crypto", {
configurable: true,
value: { randomUUID: () => "11111111-2222-4333-8444-555555555555" },
});
expect(mutationId()).toBe("11111111-2222-4333-8444-555555555555");
});
it("creates a UUID v4 when randomUUID is unavailable on an HTTP LAN origin", () => {
const getRandomValues = vi.fn((bytes: Uint8Array) => {
bytes.fill(0);
return bytes;
});
Object.defineProperty(globalThis, "crypto", {
configurable: true,
value: { getRandomValues },
});
expect(mutationId()).toBe("00000000-0000-4000-8000-000000000000");
expect(getRandomValues).toHaveBeenCalledOnce();
});
it("keeps the UUID format when Web Crypto is entirely unavailable", () => {
Object.defineProperty(globalThis, "crypto", { configurable: true, value: undefined });
vi.spyOn(Math, "random").mockReturnValue(0);
expect(mutationId()).toBe("00000000-0000-4000-8000-000000000000");
});
});
+79 -79
View File
@@ -1,79 +1,79 @@
import type { ErrorEnvelope } from "./types";
export class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly requestId?: string;
readonly recovery: string[];
constructor(status: number, payload: ErrorEnvelope) {
super(payload.error.message);
this.name = "ApiError";
this.status = status;
this.code = payload.error.code;
this.requestId = payload.error.request_id;
this.recovery = payload.error.recovery;
}
}
function csrfToken(): string | undefined {
return document.cookie
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith("lumaops_csrf="))
?.split("=")[1];
}
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
if (init.body && !(init.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}
const csrf = csrfToken();
if (csrf && !["GET", "HEAD", "OPTIONS"].includes(init.method ?? "GET")) {
headers.set("X-CSRF-Token", decodeURIComponent(csrf));
}
const response = await fetch(path, { ...init, headers, credentials: "same-origin" });
if (!response.ok) {
const fallback: ErrorEnvelope = {
error: {
code: "http_error",
message: `HTTP ${response.status}`,
request_id: response.headers.get("X-Request-ID") ?? "",
details: {},
recovery: [],
},
};
let payload = fallback;
try {
payload = (await response.json()) as ErrorEnvelope;
} catch {
// The stable fallback keeps non-JSON proxy errors usable.
}
throw new ApiError(response.status, payload);
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
export function mutationId(): string {
const cryptoApi = globalThis.crypto;
if (typeof cryptoApi?.randomUUID === "function") {
return cryptoApi.randomUUID();
}
const bytes = new Uint8Array(16);
if (typeof cryptoApi?.getRandomValues === "function") {
cryptoApi.getRandomValues(bytes);
} else {
// Idempotency keys are identifiers rather than secrets. This final fallback
// keeps device control usable in older or restricted LAN browsers.
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
}
import type { ErrorEnvelope } from "./types";
export class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly requestId?: string;
readonly recovery: string[];
constructor(status: number, payload: ErrorEnvelope) {
super(payload.error.message);
this.name = "ApiError";
this.status = status;
this.code = payload.error.code;
this.requestId = payload.error.request_id;
this.recovery = payload.error.recovery;
}
}
function csrfToken(): string | undefined {
return document.cookie
.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith("lumaops_csrf="))
?.split("=")[1];
}
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
if (init.body && !(init.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}
const csrf = csrfToken();
if (csrf && !["GET", "HEAD", "OPTIONS"].includes(init.method ?? "GET")) {
headers.set("X-CSRF-Token", decodeURIComponent(csrf));
}
const response = await fetch(path, { ...init, headers, credentials: "same-origin" });
if (!response.ok) {
const fallback: ErrorEnvelope = {
error: {
code: "http_error",
message: `HTTP ${response.status}`,
request_id: response.headers.get("X-Request-ID") ?? "",
details: {},
recovery: [],
},
};
let payload = fallback;
try {
payload = (await response.json()) as ErrorEnvelope;
} catch {
// The stable fallback keeps non-JSON proxy errors usable.
}
throw new ApiError(response.status, payload);
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
export function mutationId(): string {
const cryptoApi = globalThis.crypto;
if (typeof cryptoApi?.randomUUID === "function") {
return cryptoApi.randomUUID();
}
const bytes = new Uint8Array(16);
if (typeof cryptoApi?.getRandomValues === "function") {
cryptoApi.getRandomValues(bytes);
} else {
// Idempotency keys are identifiers rather than secrets. This final fallback
// keeps device control usable in older or restricted LAN browsers.
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0"));
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
}
+21 -21
View File
@@ -1,21 +1,21 @@
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
export function useRealtimeUpdates(): void {
const queryClient = useQueryClient();
useEffect(() => {
const events = new EventSource("/api/v1/events");
const refresh = () => {
void queryClient.invalidateQueries({ queryKey: ["system"] });
void queryClient.invalidateQueries({ queryKey: ["devices"] });
void queryClient.invalidateQueries({ queryKey: ["activity"] });
};
events.addEventListener("inventory.updated", refresh);
events.addEventListener("command.completed", refresh);
events.onerror = () => {
// EventSource reconnects automatically. REST remains fully functional.
};
return () => events.close();
}, [queryClient]);
}
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
export function useRealtimeUpdates(): void {
const queryClient = useQueryClient();
useEffect(() => {
const events = new EventSource("/api/v1/events");
const refresh = () => {
void queryClient.invalidateQueries({ queryKey: ["system"] });
void queryClient.invalidateQueries({ queryKey: ["devices"] });
void queryClient.invalidateQueries({ queryKey: ["activity"] });
};
events.addEventListener("inventory.updated", refresh);
events.addEventListener("command.completed", refresh);
events.onerror = () => {
// EventSource reconnects automatically. REST remains fully functional.
};
return () => events.close();
}, [queryClient]);
}
+261 -261
View File
@@ -1,261 +1,261 @@
export type HealthStatus = "healthy" | "degraded" | "unhealthy" | "unknown";
export interface ErrorEnvelope {
error: {
code: string;
message: string;
request_id: string;
details: Record<string, unknown>;
recovery: string[];
};
}
export interface Page<T> {
items: T[];
total: number;
limit: number;
offset: number;
}
export interface RGBColor {
red: number;
green: number;
blue: number;
}
export interface DeviceState {
power?: boolean | null;
brightness?: number | null;
colors?: RGBColor[] | null;
mode?: string | null;
mode_index?: number | null;
speed?: number | null;
direction?: number | null;
zone_index?: number | null;
led_index?: number | null;
}
export interface Capabilities {
power: boolean;
restore: boolean;
rgb: boolean;
brightness: boolean;
color_temperature: boolean;
effect: boolean;
speed: boolean;
direction: boolean;
multiple_colors: boolean;
per_zone: boolean;
per_segment: boolean;
per_led: boolean;
profiles: boolean;
readable_state: boolean;
max_leds: number;
min_brightness: number;
max_brightness: number;
min_speed: number | null;
max_speed: number | null;
}
export interface DeviceZone {
index: number;
name: string;
type: number;
led_count: number;
leds_min: number;
leds_max: number;
start_index?: number;
resizable_effects_only?: boolean;
segments?: Array<Record<string, unknown>>;
}
export interface DeviceMode {
index: number;
name: string;
flags: number;
speed_min: number | null;
speed_max: number | null;
brightness: boolean;
colors_min: number;
colors_max: number;
}
export interface Device {
id: string;
connector_id: string;
external_id: string;
fingerprint: string;
source: string;
device_type: string;
owner: string;
name: string;
alias: string | null;
vendor: string | null;
model: string | null;
serial: string | null;
location: string | null;
ip_address: string | null;
firmware_version: string | null;
controller_index: number | null;
capabilities: Capabilities;
state: DeviceState;
zones: DeviceZone[];
modes: DeviceMode[];
metadata: Record<string, unknown>;
led_count: number;
online: boolean;
hidden: boolean;
favorite: boolean;
exclude_global: boolean;
read_only: boolean;
blocked: boolean;
experimental: boolean;
room_id: string | null;
room_name?: string | null;
tags: string[];
error_status: string | null;
last_detected_at: string | null;
last_command_at: string | null;
}
export interface DeviceGroup {
id: string;
kind: "device-family";
device_type: string;
connector_id: string;
name: string;
vendor: string | null;
model: string | null;
online: boolean;
online_count: number;
module_count: number;
led_count: number;
capabilities: Capabilities;
state: DeviceState;
modes: DeviceMode[];
mixed: boolean;
devices: Device[];
}
export interface ComponentHealth {
status: HealthStatus;
message: string;
connected?: boolean;
details?: Record<string, unknown>;
}
export interface SystemStatus {
name: string;
version: string;
openrgb_version: string;
sdk_protocol: number;
environment: string;
mock_mode: boolean;
health: {
status: HealthStatus;
components: Record<string, ComponentHealth>;
checked_at: string;
};
devices: { total: number; online: number; offline: number };
active_scene: Scene | null;
recent_commands: Command[];
warnings: Activity[];
emergency_stop: boolean;
}
export interface Room {
id: string;
name: string;
description: string | null;
sort_order: number;
device_count: number;
}
export interface Group {
id: string;
name: string;
description: string | null;
dynamic_query: { tags?: string[]; match?: "all" | "any" } | null;
sort_order: number;
device_count: number;
devices?: Device[];
}
export interface SceneItem {
id: string;
target_type: "device" | "group";
target_id: string;
state: DeviceState;
required: boolean;
sort_order: number;
}
export interface Scene {
id: string;
name: string;
description: string | null;
favorite: boolean;
version: number;
item_count?: number;
items?: SceneItem[];
last_applied_at: string | null;
}
export interface Automation {
id: string;
name: string;
description: string | null;
enabled: boolean;
trigger: Record<string, unknown>;
actions: Array<Record<string, unknown>>;
timezone: string;
cooldown_seconds: number;
conflict_key: string | null;
last_run_at: string | null;
next_run_at: string | null;
last_error: string | null;
}
export interface AutomationRun {
id: string;
automation_id: string;
status: string;
trigger: Record<string, unknown>;
result: unknown;
error: string | null;
started_at: string;
finished_at: string | null;
}
export interface SceneApplyResult {
scene_id: string;
status: "succeeded" | "failed";
applied: Array<{ device_id: string; status: string }>;
failed: Array<{ device_id: string; error: string }>;
skipped: Array<{ device_id: string; reason: string }>;
rolled_back: Array<{ device_id: string; status: string }>;
}
export interface Command {
id: string;
target_id: string;
status: string;
action: string;
created_at: string;
finished_at: string | null;
}
export interface Activity {
id: string;
category: string;
severity: string;
title: string;
message: string;
created_at: string;
}
export interface SetupState {
completed: boolean;
current_step: string;
report: Record<string, unknown>;
}
export type HealthStatus = "healthy" | "degraded" | "unhealthy" | "unknown";
export interface ErrorEnvelope {
error: {
code: string;
message: string;
request_id: string;
details: Record<string, unknown>;
recovery: string[];
};
}
export interface Page<T> {
items: T[];
total: number;
limit: number;
offset: number;
}
export interface RGBColor {
red: number;
green: number;
blue: number;
}
export interface DeviceState {
power?: boolean | null;
brightness?: number | null;
colors?: RGBColor[] | null;
mode?: string | null;
mode_index?: number | null;
speed?: number | null;
direction?: number | null;
zone_index?: number | null;
led_index?: number | null;
}
export interface Capabilities {
power: boolean;
restore: boolean;
rgb: boolean;
brightness: boolean;
color_temperature: boolean;
effect: boolean;
speed: boolean;
direction: boolean;
multiple_colors: boolean;
per_zone: boolean;
per_segment: boolean;
per_led: boolean;
profiles: boolean;
readable_state: boolean;
max_leds: number;
min_brightness: number;
max_brightness: number;
min_speed: number | null;
max_speed: number | null;
}
export interface DeviceZone {
index: number;
name: string;
type: number;
led_count: number;
leds_min: number;
leds_max: number;
start_index?: number;
resizable_effects_only?: boolean;
segments?: Array<Record<string, unknown>>;
}
export interface DeviceMode {
index: number;
name: string;
flags: number;
speed_min: number | null;
speed_max: number | null;
brightness: boolean;
colors_min: number;
colors_max: number;
}
export interface Device {
id: string;
connector_id: string;
external_id: string;
fingerprint: string;
source: string;
device_type: string;
owner: string;
name: string;
alias: string | null;
vendor: string | null;
model: string | null;
serial: string | null;
location: string | null;
ip_address: string | null;
firmware_version: string | null;
controller_index: number | null;
capabilities: Capabilities;
state: DeviceState;
zones: DeviceZone[];
modes: DeviceMode[];
metadata: Record<string, unknown>;
led_count: number;
online: boolean;
hidden: boolean;
favorite: boolean;
exclude_global: boolean;
read_only: boolean;
blocked: boolean;
experimental: boolean;
room_id: string | null;
room_name?: string | null;
tags: string[];
error_status: string | null;
last_detected_at: string | null;
last_command_at: string | null;
}
export interface DeviceGroup {
id: string;
kind: "device-family";
device_type: string;
connector_id: string;
name: string;
vendor: string | null;
model: string | null;
online: boolean;
online_count: number;
module_count: number;
led_count: number;
capabilities: Capabilities;
state: DeviceState;
modes: DeviceMode[];
mixed: boolean;
devices: Device[];
}
export interface ComponentHealth {
status: HealthStatus;
message: string;
connected?: boolean;
details?: Record<string, unknown>;
}
export interface SystemStatus {
name: string;
version: string;
openrgb_version: string;
sdk_protocol: number;
environment: string;
mock_mode: boolean;
health: {
status: HealthStatus;
components: Record<string, ComponentHealth>;
checked_at: string;
};
devices: { total: number; online: number; offline: number };
active_scene: Scene | null;
recent_commands: Command[];
warnings: Activity[];
emergency_stop: boolean;
}
export interface Room {
id: string;
name: string;
description: string | null;
sort_order: number;
device_count: number;
}
export interface Group {
id: string;
name: string;
description: string | null;
dynamic_query: { tags?: string[]; match?: "all" | "any" } | null;
sort_order: number;
device_count: number;
devices?: Device[];
}
export interface SceneItem {
id: string;
target_type: "device" | "group";
target_id: string;
state: DeviceState;
required: boolean;
sort_order: number;
}
export interface Scene {
id: string;
name: string;
description: string | null;
favorite: boolean;
version: number;
item_count?: number;
items?: SceneItem[];
last_applied_at: string | null;
}
export interface Automation {
id: string;
name: string;
description: string | null;
enabled: boolean;
trigger: Record<string, unknown>;
actions: Array<Record<string, unknown>>;
timezone: string;
cooldown_seconds: number;
conflict_key: string | null;
last_run_at: string | null;
next_run_at: string | null;
last_error: string | null;
}
export interface AutomationRun {
id: string;
automation_id: string;
status: string;
trigger: Record<string, unknown>;
result: unknown;
error: string | null;
started_at: string;
finished_at: string | null;
}
export interface SceneApplyResult {
scene_id: string;
status: "succeeded" | "failed";
applied: Array<{ device_id: string; status: string }>;
failed: Array<{ device_id: string; error: string }>;
skipped: Array<{ device_id: string; reason: string }>;
rolled_back: Array<{ device_id: string; status: string }>;
}
export interface Command {
id: string;
target_id: string;
status: string;
action: string;
created_at: string;
finished_at: string | null;
}
export interface Activity {
id: string;
category: string;
severity: string;
title: string;
message: string;
created_at: string;
}
export interface SetupState {
completed: boolean;
current_step: string;
report: Record<string, unknown>;
}
+116 -116
View File
@@ -1,116 +1,116 @@
import { useQuery } from "@tanstack/react-query";
import {
Activity,
ArchiveRestore,
Blocks,
Cable,
ChevronLeft,
CircleGauge,
ClipboardList,
Compass,
Cpu,
FlaskConical,
Info,
Lightbulb,
Menu,
Moon,
Network,
Orbit,
Settings,
Sun,
WandSparkles,
X,
} from "lucide-react";
import { useState } from "react";
import { NavLink, Outlet } from "react-router-dom";
import { api } from "../api/client";
import { useRealtimeUpdates } from "../api/hooks";
import type { SystemStatus } from "../api/types";
import { useI18n } from "../lib/i18n";
import { useTheme } from "../lib/theme";
import { cn } from "../lib/utils";
import { Badge, IconButton, StatusBadge } from "./ui";
const navigation = [
{ to: "/", key: "overview", icon: CircleGauge },
{ to: "/devices", key: "devices", icon: Cpu },
{ to: "/spaces", key: "spaces", icon: Blocks },
{ to: "/scenes", key: "scenes", icon: WandSparkles },
{ to: "/automations", key: "automations", icon: Orbit },
{ to: "/network", key: "network", icon: Network },
{ to: "/connectors", key: "connectors", icon: Cable },
{ to: "/discovery", key: "discovery", icon: Compass },
] as const;
const systemNavigation = [
{ to: "/activity", key: "activity", icon: Activity },
{ to: "/audit", key: "audit", icon: ClipboardList },
{ to: "/diagnostics", key: "diagnostics", icon: FlaskConical },
{ to: "/backups", key: "backups", icon: ArchiveRestore },
{ to: "/settings", key: "settings", icon: Settings },
{ to: "/about", key: "about", icon: Info },
] as const;
export function AppShell() {
const [open, setOpen] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const { t } = useI18n();
const { resolved, setTheme } = useTheme();
const system = useQuery({
queryKey: ["system"],
queryFn: () => api<SystemStatus>("/api/v1/system"),
refetchInterval: 30_000,
});
useRealtimeUpdates();
const nav = (items: typeof navigation | typeof systemNavigation) => items.map(({ to, key, icon: Icon }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) => cn("nav-link", isActive && "nav-link--active")}
onClick={() => setOpen(false)}
title={collapsed ? t(key) : undefined}
>
<Icon size={18} aria-hidden />
<span>{t(key)}</span>
</NavLink>
));
return (
<div className={cn("app-shell", collapsed && "app-shell--collapsed")}>
<aside className={cn("sidebar", open && "sidebar--open")}>
<div className="brand">
<span className="brand__mark"><Lightbulb size={20} aria-hidden /></span>
<div className="brand__text"><strong>LumaOps</strong><span>OpenRGB control plane</span></div>
<IconButton className="sidebar__close" label={t("closeMenu")} onClick={() => setOpen(false)}><X size={18} /></IconButton>
</div>
<nav aria-label="Hoofdnavigatie" className="sidebar__nav">
<span className="nav-label">Beheer</span>
{nav(navigation)}
<span className="nav-label">Systeem</span>
{nav(systemNavigation)}
</nav>
<button className="sidebar__collapse" onClick={() => setCollapsed((value) => !value)}>
<ChevronLeft size={16} aria-hidden /><span>Navigatie inklappen</span>
</button>
</aside>
{open ? <button className="sidebar-backdrop" aria-label={t("closeMenu")} onClick={() => setOpen(false)} /> : null}
<div className="app-main">
<header className="topbar">
<IconButton className="mobile-menu" label={t("openMenu")} onClick={() => setOpen(true)}><Menu size={20} /></IconButton>
<div className="topbar__status">
{system.data ? <StatusBadge status={system.data.health.status} /> : <Badge>Laden…</Badge>}
<span className="topbar__device-count">{system.data?.devices.online ?? 0} apparaten online</span>
</div>
<IconButton label={resolved === "dark" ? "Licht thema" : "Donker thema"} onClick={() => setTheme(resolved === "dark" ? "light" : "dark")}>
{resolved === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</IconButton>
</header>
{system.data?.mock_mode ? <div className="mock-banner"><FlaskConical size={16} /> {t("mockWarning")}</div> : null}
<main className="page"><Outlet /></main>
</div>
</div>
);
}
import { useQuery } from "@tanstack/react-query";
import {
Activity,
ArchiveRestore,
Blocks,
Cable,
ChevronLeft,
CircleGauge,
ClipboardList,
Compass,
Cpu,
FlaskConical,
Info,
Lightbulb,
Menu,
Moon,
Network,
Orbit,
Settings,
Sun,
WandSparkles,
X,
} from "lucide-react";
import { useState } from "react";
import { NavLink, Outlet } from "react-router-dom";
import { api } from "../api/client";
import { useRealtimeUpdates } from "../api/hooks";
import type { SystemStatus } from "../api/types";
import { useI18n } from "../lib/i18n";
import { useTheme } from "../lib/theme";
import { cn } from "../lib/utils";
import { Badge, IconButton, StatusBadge } from "./ui";
const navigation = [
{ to: "/", key: "overview", icon: CircleGauge },
{ to: "/devices", key: "devices", icon: Cpu },
{ to: "/spaces", key: "spaces", icon: Blocks },
{ to: "/scenes", key: "scenes", icon: WandSparkles },
{ to: "/automations", key: "automations", icon: Orbit },
{ to: "/network", key: "network", icon: Network },
{ to: "/connectors", key: "connectors", icon: Cable },
{ to: "/discovery", key: "discovery", icon: Compass },
] as const;
const systemNavigation = [
{ to: "/activity", key: "activity", icon: Activity },
{ to: "/audit", key: "audit", icon: ClipboardList },
{ to: "/diagnostics", key: "diagnostics", icon: FlaskConical },
{ to: "/backups", key: "backups", icon: ArchiveRestore },
{ to: "/settings", key: "settings", icon: Settings },
{ to: "/about", key: "about", icon: Info },
] as const;
export function AppShell() {
const [open, setOpen] = useState(false);
const [collapsed, setCollapsed] = useState(false);
const { t } = useI18n();
const { resolved, setTheme } = useTheme();
const system = useQuery({
queryKey: ["system"],
queryFn: () => api<SystemStatus>("/api/v1/system"),
refetchInterval: 30_000,
});
useRealtimeUpdates();
const nav = (items: typeof navigation | typeof systemNavigation) => items.map(({ to, key, icon: Icon }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) => cn("nav-link", isActive && "nav-link--active")}
onClick={() => setOpen(false)}
title={collapsed ? t(key) : undefined}
>
<Icon size={18} aria-hidden />
<span>{t(key)}</span>
</NavLink>
));
return (
<div className={cn("app-shell", collapsed && "app-shell--collapsed")}>
<aside className={cn("sidebar", open && "sidebar--open")}>
<div className="brand">
<span className="brand__mark"><Lightbulb size={20} aria-hidden /></span>
<div className="brand__text"><strong>LumaOps</strong><span>OpenRGB control plane</span></div>
<IconButton className="sidebar__close" label={t("closeMenu")} onClick={() => setOpen(false)}><X size={18} /></IconButton>
</div>
<nav aria-label="Hoofdnavigatie" className="sidebar__nav">
<span className="nav-label">Beheer</span>
{nav(navigation)}
<span className="nav-label">Systeem</span>
{nav(systemNavigation)}
</nav>
<button className="sidebar__collapse" onClick={() => setCollapsed((value) => !value)}>
<ChevronLeft size={16} aria-hidden /><span>Navigatie inklappen</span>
</button>
</aside>
{open ? <button className="sidebar-backdrop" aria-label={t("closeMenu")} onClick={() => setOpen(false)} /> : null}
<div className="app-main">
<header className="topbar">
<IconButton className="mobile-menu" label={t("openMenu")} onClick={() => setOpen(true)}><Menu size={20} /></IconButton>
<div className="topbar__status">
{system.data ? <StatusBadge status={system.data.health.status} /> : <Badge>Laden…</Badge>}
<span className="topbar__device-count">{system.data?.devices.online ?? 0} apparaten online</span>
</div>
<IconButton label={resolved === "dark" ? "Licht thema" : "Donker thema"} onClick={() => setTheme(resolved === "dark" ? "light" : "dark")}>
{resolved === "dark" ? <Sun size={18} /> : <Moon size={18} />}
</IconButton>
</header>
{system.data?.mock_mode ? <div className="mock-banner"><FlaskConical size={16} /> {t("mockWarning")}</div> : null}
<main className="page"><Outlet /></main>
</div>
</div>
);
}
@@ -1,72 +1,72 @@
import { Save, X } from "lucide-react";
import { useState, type FormEvent } from "react";
import type { Automation, Page, Scene } from "../api/types";
import { useI18n } from "../lib/i18n";
import { formValue } from "../lib/utils";
import { Button, Card, CardHeader, Field, Input, Select } from "./ui";
export interface AutomationPayload {
name: string;
description: string | null;
enabled: boolean;
trigger: { type: "time"; at: string; weekdays: number[] };
actions: Array<{ type: "scene"; scene_id: string }>;
timezone: string;
cooldown_seconds: number;
conflict_key: string | null;
}
interface AutomationEditorProps {
automation?: Automation;
scenes?: Page<Scene>;
busy: boolean;
onSubmit: (payload: AutomationPayload) => void;
onClose: () => void;
}
const weekdays = [
[0, "Ma", "Mon"],
[1, "Di", "Tue"],
[2, "Wo", "Wed"],
[3, "Do", "Thu"],
[4, "Vr", "Fri"],
[5, "Za", "Sat"],
[6, "Zo", "Sun"],
] as const;
export function AutomationEditor({ automation, scenes, busy, onSubmit, onClose }: AutomationEditorProps) {
const { text } = useI18n();
const selectedWeekdays = Array.isArray(automation?.trigger.weekdays) ? automation.trigger.weekdays.map(Number) : [0, 1, 2, 3, 4, 5, 6];
const [selectedDays, setSelectedDays] = useState(selectedWeekdays);
const sceneId = typeof automation?.actions[0]?.scene_id === "string" ? automation.actions[0].scene_id : "";
const at = typeof automation?.trigger.at === "string" ? automation.trigger.at : "20:00";
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
onSubmit({
name: formValue(data, "name").trim(),
description: formValue(data, "description").trim() || null,
enabled: data.get("enabled") === "on",
trigger: { type: "time", at: formValue(data, "at"), weekdays: selectedDays },
actions: [{ type: "scene", scene_id: formValue(data, "scene") }],
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
cooldown_seconds: Number(data.get("cooldown")),
conflict_key: formValue(data, "conflict_key").trim() || null,
});
};
return (
<Card className="resource-editor">
<CardHeader title={automation ? text("Automation bewerken", "Edit automation") : text("Automation aanmaken", "Create automation")} description={text("Plan een scène op geselecteerde weekdagen en voorkom conflicterende uitvoeringen.", "Schedule a scene on selected weekdays and prevent conflicting runs.")} action={<Button type="button" variant="ghost" onClick={onClose}><X size={16} /> {text("Sluiten", "Close")}</Button>} />
<form onSubmit={submit} className="form-stack">
<div className="form-grid form-grid--two"><Field label={text("Naam", "Name")}><Input name="name" required autoFocus defaultValue={automation?.name ?? ""} placeholder={text("Avondverlichting", "Evening lights")} /></Field><Field label={text("Omschrijving", "Description")}><Input name="description" defaultValue={automation?.description ?? ""} /></Field></div>
<div className="form-grid form-grid--three"><Field label={text("Tijdstip", "Time")}><Input name="at" type="time" required defaultValue={at} /></Field><Field label={text("Scène", "Scene")}><Select name="scene" required defaultValue={sceneId}><option value="">{text("Selecteer een scène", "Select a scene")}</option>{scenes?.items.map((scene) => <option key={scene.id} value={scene.id}>{scene.name}</option>)}</Select></Field><Field label={text("Cooldown (seconden)", "Cooldown (seconds)")}><Input name="cooldown" type="number" min="0" max="604800" defaultValue={automation?.cooldown_seconds ?? 60} /></Field></div>
<fieldset className="weekday-grid"><legend>{text("Weekdagen", "Weekdays")}</legend>{weekdays.map(([value, dutch, english]) => <label key={value}><input type="checkbox" name="weekdays" value={value} checked={selectedDays.includes(value)} onChange={(event) => setSelectedDays((current) => event.target.checked ? [...current, value].sort() : current.filter((day) => day !== value))} /> <span>{text(dutch, english)}</span></label>)}</fieldset>
{!selectedDays.length ? <p className="field-error" role="alert">{text("Selecteer minstens één weekdag.", "Select at least one weekday.")}</p> : null}
<Field label={text("Conflictgroep (optioneel)", "Conflict group (optional)")} hint={text("Regels met dezelfde sleutel worden nooit gelijktijdig uitgevoerd.", "Rules with the same key never run concurrently.")}><Input name="conflict_key" defaultValue={automation?.conflict_key ?? ""} placeholder="woonkamer" /></Field>
<label className="checkbox-row"><input name="enabled" type="checkbox" defaultChecked={automation?.enabled ?? true} /> {text("Automation is actief", "Automation is enabled")}</label>
<div className="form-actions"><Button variant="ghost" type="button" onClick={onClose}>{text("Annuleren", "Cancel")}</Button><Button type="submit" busy={busy} disabled={!selectedDays.length}><Save size={16} /> {automation ? text("Wijzigingen opslaan", "Save changes") : text("Aanmaken", "Create")}</Button></div>
</form>
</Card>
);
}
import { Save, X } from "lucide-react";
import { useState, type FormEvent } from "react";
import type { Automation, Page, Scene } from "../api/types";
import { useI18n } from "../lib/i18n";
import { formValue } from "../lib/utils";
import { Button, Card, CardHeader, Field, Input, Select } from "./ui";
export interface AutomationPayload {
name: string;
description: string | null;
enabled: boolean;
trigger: { type: "time"; at: string; weekdays: number[] };
actions: Array<{ type: "scene"; scene_id: string }>;
timezone: string;
cooldown_seconds: number;
conflict_key: string | null;
}
interface AutomationEditorProps {
automation?: Automation;
scenes?: Page<Scene>;
busy: boolean;
onSubmit: (payload: AutomationPayload) => void;
onClose: () => void;
}
const weekdays = [
[0, "Ma", "Mon"],
[1, "Di", "Tue"],
[2, "Wo", "Wed"],
[3, "Do", "Thu"],
[4, "Vr", "Fri"],
[5, "Za", "Sat"],
[6, "Zo", "Sun"],
] as const;
export function AutomationEditor({ automation, scenes, busy, onSubmit, onClose }: AutomationEditorProps) {
const { text } = useI18n();
const selectedWeekdays = Array.isArray(automation?.trigger.weekdays) ? automation.trigger.weekdays.map(Number) : [0, 1, 2, 3, 4, 5, 6];
const [selectedDays, setSelectedDays] = useState(selectedWeekdays);
const sceneId = typeof automation?.actions[0]?.scene_id === "string" ? automation.actions[0].scene_id : "";
const at = typeof automation?.trigger.at === "string" ? automation.trigger.at : "20:00";
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
onSubmit({
name: formValue(data, "name").trim(),
description: formValue(data, "description").trim() || null,
enabled: data.get("enabled") === "on",
trigger: { type: "time", at: formValue(data, "at"), weekdays: selectedDays },
actions: [{ type: "scene", scene_id: formValue(data, "scene") }],
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
cooldown_seconds: Number(data.get("cooldown")),
conflict_key: formValue(data, "conflict_key").trim() || null,
});
};
return (
<Card className="resource-editor">
<CardHeader title={automation ? text("Automation bewerken", "Edit automation") : text("Automation aanmaken", "Create automation")} description={text("Plan een scène op geselecteerde weekdagen en voorkom conflicterende uitvoeringen.", "Schedule a scene on selected weekdays and prevent conflicting runs.")} action={<Button type="button" variant="ghost" onClick={onClose}><X size={16} /> {text("Sluiten", "Close")}</Button>} />
<form onSubmit={submit} className="form-stack">
<div className="form-grid form-grid--two"><Field label={text("Naam", "Name")}><Input name="name" required autoFocus defaultValue={automation?.name ?? ""} placeholder={text("Avondverlichting", "Evening lights")} /></Field><Field label={text("Omschrijving", "Description")}><Input name="description" defaultValue={automation?.description ?? ""} /></Field></div>
<div className="form-grid form-grid--three"><Field label={text("Tijdstip", "Time")}><Input name="at" type="time" required defaultValue={at} /></Field><Field label={text("Scène", "Scene")}><Select name="scene" required defaultValue={sceneId}><option value="">{text("Selecteer een scène", "Select a scene")}</option>{scenes?.items.map((scene) => <option key={scene.id} value={scene.id}>{scene.name}</option>)}</Select></Field><Field label={text("Cooldown (seconden)", "Cooldown (seconds)")}><Input name="cooldown" type="number" min="0" max="604800" defaultValue={automation?.cooldown_seconds ?? 60} /></Field></div>
<fieldset className="weekday-grid"><legend>{text("Weekdagen", "Weekdays")}</legend>{weekdays.map(([value, dutch, english]) => <label key={value}><input type="checkbox" name="weekdays" value={value} checked={selectedDays.includes(value)} onChange={(event) => setSelectedDays((current) => event.target.checked ? [...current, value].sort() : current.filter((day) => day !== value))} /> <span>{text(dutch, english)}</span></label>)}</fieldset>
{!selectedDays.length ? <p className="field-error" role="alert">{text("Selecteer minstens één weekdag.", "Select at least one weekday.")}</p> : null}
<Field label={text("Conflictgroep (optioneel)", "Conflict group (optional)")} hint={text("Regels met dezelfde sleutel worden nooit gelijktijdig uitgevoerd.", "Rules with the same key never run concurrently.")}><Input name="conflict_key" defaultValue={automation?.conflict_key ?? ""} placeholder="woonkamer" /></Field>
<label className="checkbox-row"><input name="enabled" type="checkbox" defaultChecked={automation?.enabled ?? true} /> {text("Automation is actief", "Automation is enabled")}</label>
<div className="form-actions"><Button variant="ghost" type="button" onClick={onClose}>{text("Annuleren", "Cancel")}</Button><Button type="submit" busy={busy} disabled={!selectedDays.length}><Save size={16} /> {automation ? text("Wijzigingen opslaan", "Save changes") : text("Aanmaken", "Create")}</Button></div>
</form>
</Card>
);
}
@@ -1,44 +1,44 @@
import { useEffect, useId, useRef, type ReactNode } from "react";
import { Button } from "./ui";
export function ConfirmDialog({
open,
title,
description,
confirmLabel,
danger = false,
busy = false,
confirmDisabled = false,
onConfirm,
onClose,
children,
}: {
open: boolean;
title: string;
description: string;
confirmLabel: string;
danger?: boolean;
busy?: boolean;
confirmDisabled?: boolean;
onConfirm: () => void;
onClose: () => void;
children?: ReactNode;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const titleId = useId();
useEffect(() => {
if (open && !dialog.current?.open) dialog.current?.showModal();
if (!open && dialog.current?.open) dialog.current.close();
}, [open]);
return (
<dialog ref={dialog} className="dialog" aria-labelledby={titleId} onCancel={onClose} onClose={onClose}>
<h2 id={titleId}>{title}</h2>
<p>{description}</p>
{children}
<div className="dialog__actions">
<Button variant="ghost" onClick={onClose}>Annuleren</Button>
<Button variant={danger ? "danger" : "primary"} onClick={onConfirm} busy={busy} disabled={confirmDisabled}>{confirmLabel}</Button>
</div>
</dialog>
);
}
import { useEffect, useId, useRef, type ReactNode } from "react";
import { Button } from "./ui";
export function ConfirmDialog({
open,
title,
description,
confirmLabel,
danger = false,
busy = false,
confirmDisabled = false,
onConfirm,
onClose,
children,
}: {
open: boolean;
title: string;
description: string;
confirmLabel: string;
danger?: boolean;
busy?: boolean;
confirmDisabled?: boolean;
onConfirm: () => void;
onClose: () => void;
children?: ReactNode;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const titleId = useId();
useEffect(() => {
if (open && !dialog.current?.open) dialog.current?.showModal();
if (!open && dialog.current?.open) dialog.current.close();
}, [open]);
return (
<dialog ref={dialog} className="dialog" aria-labelledby={titleId} onCancel={onClose} onClose={onClose}>
<h2 id={titleId}>{title}</h2>
<p>{description}</p>
{children}
<div className="dialog__actions">
<Button variant="ghost" onClick={onClose}>Annuleren</Button>
<Button variant={danger ? "danger" : "primary"} onClick={onConfirm} busy={busy} disabled={confirmDisabled}>{confirmLabel}</Button>
</div>
</dialog>
);
}
+27 -27
View File
@@ -1,27 +1,27 @@
import { Cpu, MemoryStick, Star, WifiOff } from "lucide-react";
import type { CSSProperties } from "react";
import { Link } from "react-router-dom";
import type { Device } from "../api/types";
import { colorToHex, formatDate } from "../lib/utils";
import { Badge } from "./ui";
interface DeviceCardProps {
device: Device;
list?: boolean;
moduleLabel?: string;
}
export function DeviceCard({ device, list = false, moduleLabel }: DeviceCardProps) {
const color = colorToHex(device.state.colors?.[0]);
const Icon = device.device_type === "dram" ? MemoryStick : Cpu;
return <Link to={`/devices/${device.id}`} className={list ? "device-row" : "device-card"}>
<div className="device-card__visual" style={{ "--device-color": color } as CSSProperties}>
<span className="device-glow" /><Icon size={list ? 22 : 30} /><span className={`presence ${device.online ? "presence--online" : ""}`} />
</div>
<div className="device-card__content">
<div className="device-card__title"><div><h2>{moduleLabel || device.alias || device.name}</h2><p>{[device.vendor, device.model].filter(Boolean).join(" · ") || device.source}</p></div>{device.favorite ? <Star size={16} className="favorite" fill="currentColor" /> : null}</div>
<div className="device-card__meta"><Badge tone={device.online ? "success" : "neutral"}>{device.online ? "Online" : <><WifiOff size={12} /> Offline</>}</Badge>{device.room_name ? <Badge>{device.room_name}</Badge> : null}{device.read_only ? <Badge tone="warning">Alleen lezen</Badge> : null}{device.blocked ? <Badge tone="danger">Geblokkeerd</Badge> : null}</div>
{!list ? <div className="device-card__footer"><span><i style={{ background: color }} />{device.state.mode ?? "Onbekende modus"}</span><span>{device.led_count} leds</span></div> : <div className="device-row__extra"><span>{device.state.brightness ?? "—"}%</span><span>{formatDate(device.last_detected_at)}</span></div>}
</div>
</Link>;
}
import { Cpu, MemoryStick, Star, WifiOff } from "lucide-react";
import type { CSSProperties } from "react";
import { Link } from "react-router-dom";
import type { Device } from "../api/types";
import { colorToHex, formatDate } from "../lib/utils";
import { Badge } from "./ui";
interface DeviceCardProps {
device: Device;
list?: boolean;
moduleLabel?: string;
}
export function DeviceCard({ device, list = false, moduleLabel }: DeviceCardProps) {
const color = colorToHex(device.state.colors?.[0]);
const Icon = device.device_type === "dram" ? MemoryStick : Cpu;
return <Link to={`/devices/${device.id}`} className={list ? "device-row" : "device-card"}>
<div className="device-card__visual" style={{ "--device-color": color } as CSSProperties}>
<span className="device-glow" /><Icon size={list ? 22 : 30} /><span className={`presence ${device.online ? "presence--online" : ""}`} />
</div>
<div className="device-card__content">
<div className="device-card__title"><div><h2>{moduleLabel || device.alias || device.name}</h2><p>{[device.vendor, device.model].filter(Boolean).join(" · ") || device.source}</p></div>{device.favorite ? <Star size={16} className="favorite" fill="currentColor" /> : null}</div>
<div className="device-card__meta"><Badge tone={device.online ? "success" : "neutral"}>{device.online ? "Online" : <><WifiOff size={12} /> Offline</>}</Badge>{device.room_name ? <Badge>{device.room_name}</Badge> : null}{device.read_only ? <Badge tone="warning">Alleen lezen</Badge> : null}{device.blocked ? <Badge tone="danger">Geblokkeerd</Badge> : null}</div>
{!list ? <div className="device-card__footer"><span><i style={{ background: color }} />{device.state.mode ?? "Onbekende modus"}</span><span>{device.led_count} leds</span></div> : <div className="device-row__extra"><span>{device.state.brightness ?? "—"}%</span><span>{formatDate(device.last_detected_at)}</span></div>}
</div>
</Link>;
}
@@ -1,25 +1,25 @@
import { MemoryStick } from "lucide-react";
import type { CSSProperties } from "react";
import { Link } from "react-router-dom";
import type { DeviceGroup } from "../api/types";
import { colorToHex } from "../lib/utils";
import { Badge } from "./ui";
interface DeviceGroupCardProps {
group: DeviceGroup;
list?: boolean;
}
export function DeviceGroupCard({ group, list = false }: DeviceGroupCardProps) {
const color = colorToHex(group.state.colors?.[0]);
return <Link to={`/device-groups/${group.id}`} className={`${list ? "device-row" : "device-card"} device-card--group`}>
<div className="device-card__visual" style={{ "--device-color": color } as CSSProperties}>
<span className="device-glow" /><MemoryStick size={list ? 22 : 30} /><span className={`presence ${group.online_count ? "presence--online" : ""}`} />
</div>
<div className="device-card__content">
<div className="device-card__title"><div><h2>{group.name}</h2><p>{group.vendor} · gegroepeerd RAM-apparaat</p></div><Badge tone="accent">Groep</Badge></div>
<div className="device-card__meta"><Badge tone={group.online ? "success" : "warning"}>{group.online_count}/{group.module_count} online</Badge>{group.mixed ? <Badge tone="warning">Gemengde toestand</Badge> : null}</div>
<div className={list ? "device-row__extra" : "device-card__footer"}><span>{group.state.mode ?? "Gemengd"}</span><span>{group.module_count} modules · {group.led_count} leds</span></div>
</div>
</Link>;
}
import { MemoryStick } from "lucide-react";
import type { CSSProperties } from "react";
import { Link } from "react-router-dom";
import type { DeviceGroup } from "../api/types";
import { colorToHex } from "../lib/utils";
import { Badge } from "./ui";
interface DeviceGroupCardProps {
group: DeviceGroup;
list?: boolean;
}
export function DeviceGroupCard({ group, list = false }: DeviceGroupCardProps) {
const color = colorToHex(group.state.colors?.[0]);
return <Link to={`/device-groups/${group.id}`} className={`${list ? "device-row" : "device-card"} device-card--group`}>
<div className="device-card__visual" style={{ "--device-color": color } as CSSProperties}>
<span className="device-glow" /><MemoryStick size={list ? 22 : 30} /><span className={`presence ${group.online_count ? "presence--online" : ""}`} />
</div>
<div className="device-card__content">
<div className="device-card__title"><div><h2>{group.name}</h2><p>{group.vendor} · gegroepeerd RAM-apparaat</p></div><Badge tone="accent">Groep</Badge></div>
<div className="device-card__meta"><Badge tone={group.online ? "success" : "warning"}>{group.online_count}/{group.module_count} online</Badge>{group.mixed ? <Badge tone="warning">Gemengde toestand</Badge> : null}</div>
<div className={list ? "device-row__extra" : "device-card__footer"}><span>{group.state.mode ?? "Gemengd"}</span><span>{group.module_count} modules · {group.led_count} leds</span></div>
</div>
</Link>;
}
@@ -1,165 +1,165 @@
import { Power } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { Capabilities, DeviceMode, DeviceState } from "../api/types";
import { hexToColor, colorToHex } from "../lib/utils";
import { Button, Card, CardHeader, Field, Input, Select } from "./ui";
const DIRECTION_FLAGS = 2 | 4 | 8;
interface RgbControlPanelProps {
targetKey: string;
capabilities: Capabilities;
state: DeviceState;
modes: DeviceMode[];
pending: boolean;
disabled: boolean;
onApply: (state: DeviceState) => void;
}
export function RgbControlPanel({
targetKey,
capabilities,
state,
modes,
pending,
disabled,
onApply,
}: RgbControlPanelProps) {
const defaultColorMode = findColorMode(modes);
const [modeIndex, setModeIndex] = useState<number | undefined>(
state.mode_index ?? defaultColorMode?.index,
);
const [colors, setColors] = useState<string[]>(() => stateColors(state, modes));
const [brightness, setBrightness] = useState(state.brightness ?? 100);
const [speed, setSpeed] = useState(state.speed ?? 0);
const [direction, setDirection] = useState(state.direction ?? 0);
const synchronizedTarget = useRef("");
const dirty = useRef(false);
useEffect(() => {
const changedTarget = synchronizedTarget.current !== targetKey;
if (!changedTarget && dirty.current) return;
synchronizedTarget.current = targetKey;
dirty.current = false;
const nextModeIndex = state.mode_index ?? findColorMode(modes)?.index;
setModeIndex(nextModeIndex);
setColors(stateColors({ ...state, mode_index: nextModeIndex }, modes));
setBrightness(state.brightness ?? 100);
setSpeed(state.speed ?? modes.find((mode) => mode.index === nextModeIndex)?.speed_min ?? 0);
setDirection(state.direction ?? 0);
}, [modes, state, targetKey]);
const selectedMode = modes.find((mode) => mode.index === modeIndex);
const colorSlots = modeColorSlots(selectedMode, capabilities.rgb);
const applyColorSlots = colorSlots;
const supportsDirection = Boolean((selectedMode?.flags ?? 0) & DIRECTION_FLAGS);
const supportsSpeed = selectedMode?.speed_min != null && selectedMode.speed_max != null;
const selectMode = (nextIndex: number) => {
dirty.current = true;
const nextMode = modes.find((mode) => mode.index === nextIndex);
setModeIndex(nextIndex);
setColors(resizeColors(colors, modeColorSlots(nextMode, capabilities.rgb)));
setSpeed(clamp(state.speed ?? nextMode?.speed_min ?? 0, nextMode?.speed_min, nextMode?.speed_max));
setBrightness(state.brightness ?? 100);
setDirection(state.direction ?? 0);
};
const updateColor = (index: number, value: string) => {
dirty.current = true;
const next = resizeColors(colors, Math.max(colorSlots, index + 1));
next[index] = value;
setColors(next);
if (selectedMode?.name.toLocaleLowerCase() === "direct" && defaultColorMode) {
setModeIndex(defaultColorMode.index);
}
};
const apply = () => {
const command: DeviceState = { power: true };
if (modeIndex != null) command.mode_index = modeIndex;
if (applyColorSlots) command.colors = colors.slice(0, applyColorSlots).map(hexToColor);
if (selectedMode?.brightness) command.brightness = brightness;
if (supportsSpeed) command.speed = speed;
if (supportsDirection) command.direction = direction;
dirty.current = false;
onApply(command);
};
return <Card className="control-panel">
<CardHeader title="Bediening" description="Elke modus gebruikt uitsluitend de parameters die de hardware ondersteunt." />
<div className="power-actions">
<Button onClick={() => onApply({ power: true })} disabled={!capabilities.power || disabled}>
<Power size={16} /> Aan
</Button>
<Button variant="secondary" onClick={() => onApply({ power: false })} disabled={!capabilities.power || disabled}>
Uit
</Button>
</div>
{capabilities.effect && modes.length ? <Field label="Effectmodus">
<Select aria-label="Effectmodus" value={modeIndex ?? ""} onChange={(event) => selectMode(Number(event.target.value))}>
{modes.map((mode) => <option key={mode.index} value={mode.index}>{mode.name}</option>)}
</Select>
</Field> : null}
{colorSlots ? <Field label={colorSlots > 1 ? "Effectkleuren" : "Statische kleur"}>
<div className="effect-colors">
{resizeColors(colors, colorSlots).map((color, index) => <div className="effect-color" key={`${targetKey}-color-${index}`}>
{colorSlots > 1 ? <span>Kleur {index + 1}</span> : null}
<div className="large-color">
<input aria-label={`Kleur ${index + 1} kiezen`} type="color" value={validColor(color)} onChange={(event) => updateColor(index, event.target.value)} />
<Input aria-label={`Kleur ${index + 1}`} value={color.toUpperCase()} onChange={(event) => updateColor(index, event.target.value)} />
</div>
</div>)}
</div>
</Field> : <p className="mode-hint">Deze hardwaremodus genereert zijn kleuren automatisch.</p>}
{selectedMode?.brightness ? <Field label={`Helderheid · ${brightness}%`}>
<input aria-label="Helderheid" className="range" type="range" min="0" max="100" value={brightness} onChange={(event) => { dirty.current = true; setBrightness(Number(event.target.value)); }} />
</Field> : null}
{supportsSpeed ? <Field label={`Snelheid · ${speed}`}>
<input aria-label="Snelheid" className="range" type="range" min={selectedMode.speed_min ?? 0} max={selectedMode.speed_max ?? 0} value={speed} onChange={(event) => { dirty.current = true; setSpeed(Number(event.target.value)); }} />
</Field> : null}
{supportsDirection ? <Field label="Richting">
<Select aria-label="Richting" value={direction} onChange={(event) => { dirty.current = true; setDirection(Number(event.target.value)); }}>
<option value={0}>Vooruit</option>
<option value={1}>Achteruit</option>
</Select>
</Field> : null}
<Button className="full-width" onClick={apply} busy={pending} disabled={disabled}>
Instellingen toepassen
</Button>
</Card>;
}
function findColorMode(modes: DeviceMode[]) {
return modes.find((mode) => mode.name.toLocaleLowerCase() === "custom")
?? modes.find((mode) => mode.name.toLocaleLowerCase() === "static")
?? modes.find((mode) => mode.name.toLocaleLowerCase() === "direct");
}
function modeColorSlots(mode: DeviceMode | undefined, rgb: boolean) {
if (!rgb) return 0;
if (!mode) return 1;
if (mode.colors_min > 0) return mode.colors_min;
return ["direct", "custom"].includes(mode.name.toLocaleLowerCase()) ? 1 : 0;
}
function stateColors(state: DeviceState, modes: DeviceMode[]) {
const mode = modes.find((candidate) => candidate.index === state.mode_index);
const count = modeColorSlots(mode, true);
const values = (state.colors ?? []).map(colorToHex);
return resizeColors(values.length ? values : ["#5660ff"], count || 1);
}
function resizeColors(colors: string[], count: number) {
if (!count) return [];
const seed = colors[0] ?? "#5660ff";
return Array.from({ length: count }, (_, index) => colors[index] ?? seed);
}
function validColor(value: string) {
return /^#[0-9a-f]{6}$/i.test(value) ? value : "#000000";
}
function clamp(value: number, minimum: number | null | undefined, maximum: number | null | undefined) {
return Math.max(minimum ?? value, Math.min(maximum ?? value, value));
}
import { Power } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { Capabilities, DeviceMode, DeviceState } from "../api/types";
import { hexToColor, colorToHex } from "../lib/utils";
import { Button, Card, CardHeader, Field, Input, Select } from "./ui";
const DIRECTION_FLAGS = 2 | 4 | 8;
interface RgbControlPanelProps {
targetKey: string;
capabilities: Capabilities;
state: DeviceState;
modes: DeviceMode[];
pending: boolean;
disabled: boolean;
onApply: (state: DeviceState) => void;
}
export function RgbControlPanel({
targetKey,
capabilities,
state,
modes,
pending,
disabled,
onApply,
}: RgbControlPanelProps) {
const defaultColorMode = findColorMode(modes);
const [modeIndex, setModeIndex] = useState<number | undefined>(
state.mode_index ?? defaultColorMode?.index,
);
const [colors, setColors] = useState<string[]>(() => stateColors(state, modes));
const [brightness, setBrightness] = useState(state.brightness ?? 100);
const [speed, setSpeed] = useState(state.speed ?? 0);
const [direction, setDirection] = useState(state.direction ?? 0);
const synchronizedTarget = useRef("");
const dirty = useRef(false);
useEffect(() => {
const changedTarget = synchronizedTarget.current !== targetKey;
if (!changedTarget && dirty.current) return;
synchronizedTarget.current = targetKey;
dirty.current = false;
const nextModeIndex = state.mode_index ?? findColorMode(modes)?.index;
setModeIndex(nextModeIndex);
setColors(stateColors({ ...state, mode_index: nextModeIndex }, modes));
setBrightness(state.brightness ?? 100);
setSpeed(state.speed ?? modes.find((mode) => mode.index === nextModeIndex)?.speed_min ?? 0);
setDirection(state.direction ?? 0);
}, [modes, state, targetKey]);
const selectedMode = modes.find((mode) => mode.index === modeIndex);
const colorSlots = modeColorSlots(selectedMode, capabilities.rgb);
const applyColorSlots = colorSlots;
const supportsDirection = Boolean((selectedMode?.flags ?? 0) & DIRECTION_FLAGS);
const supportsSpeed = selectedMode?.speed_min != null && selectedMode.speed_max != null;
const selectMode = (nextIndex: number) => {
dirty.current = true;
const nextMode = modes.find((mode) => mode.index === nextIndex);
setModeIndex(nextIndex);
setColors(resizeColors(colors, modeColorSlots(nextMode, capabilities.rgb)));
setSpeed(clamp(state.speed ?? nextMode?.speed_min ?? 0, nextMode?.speed_min, nextMode?.speed_max));
setBrightness(state.brightness ?? 100);
setDirection(state.direction ?? 0);
};
const updateColor = (index: number, value: string) => {
dirty.current = true;
const next = resizeColors(colors, Math.max(colorSlots, index + 1));
next[index] = value;
setColors(next);
if (selectedMode?.name.toLocaleLowerCase() === "direct" && defaultColorMode) {
setModeIndex(defaultColorMode.index);
}
};
const apply = () => {
const command: DeviceState = { power: true };
if (modeIndex != null) command.mode_index = modeIndex;
if (applyColorSlots) command.colors = colors.slice(0, applyColorSlots).map(hexToColor);
if (selectedMode?.brightness) command.brightness = brightness;
if (supportsSpeed) command.speed = speed;
if (supportsDirection) command.direction = direction;
dirty.current = false;
onApply(command);
};
return <Card className="control-panel">
<CardHeader title="Bediening" description="Elke modus gebruikt uitsluitend de parameters die de hardware ondersteunt." />
<div className="power-actions">
<Button onClick={() => onApply({ power: true })} disabled={!capabilities.power || disabled}>
<Power size={16} /> Aan
</Button>
<Button variant="secondary" onClick={() => onApply({ power: false })} disabled={!capabilities.power || disabled}>
Uit
</Button>
</div>
{capabilities.effect && modes.length ? <Field label="Effectmodus">
<Select aria-label="Effectmodus" value={modeIndex ?? ""} onChange={(event) => selectMode(Number(event.target.value))}>
{modes.map((mode) => <option key={mode.index} value={mode.index}>{mode.name}</option>)}
</Select>
</Field> : null}
{colorSlots ? <Field label={colorSlots > 1 ? "Effectkleuren" : "Statische kleur"}>
<div className="effect-colors">
{resizeColors(colors, colorSlots).map((color, index) => <div className="effect-color" key={`${targetKey}-color-${index}`}>
{colorSlots > 1 ? <span>Kleur {index + 1}</span> : null}
<div className="large-color">
<input aria-label={`Kleur ${index + 1} kiezen`} type="color" value={validColor(color)} onChange={(event) => updateColor(index, event.target.value)} />
<Input aria-label={`Kleur ${index + 1}`} value={color.toUpperCase()} onChange={(event) => updateColor(index, event.target.value)} />
</div>
</div>)}
</div>
</Field> : <p className="mode-hint">Deze hardwaremodus genereert zijn kleuren automatisch.</p>}
{selectedMode?.brightness ? <Field label={`Helderheid · ${brightness}%`}>
<input aria-label="Helderheid" className="range" type="range" min="0" max="100" value={brightness} onChange={(event) => { dirty.current = true; setBrightness(Number(event.target.value)); }} />
</Field> : null}
{supportsSpeed ? <Field label={`Snelheid · ${speed}`}>
<input aria-label="Snelheid" className="range" type="range" min={selectedMode.speed_min ?? 0} max={selectedMode.speed_max ?? 0} value={speed} onChange={(event) => { dirty.current = true; setSpeed(Number(event.target.value)); }} />
</Field> : null}
{supportsDirection ? <Field label="Richting">
<Select aria-label="Richting" value={direction} onChange={(event) => { dirty.current = true; setDirection(Number(event.target.value)); }}>
<option value={0}>Vooruit</option>
<option value={1}>Achteruit</option>
</Select>
</Field> : null}
<Button className="full-width" onClick={apply} busy={pending} disabled={disabled}>
Instellingen toepassen
</Button>
</Card>;
}
function findColorMode(modes: DeviceMode[]) {
return modes.find((mode) => mode.name.toLocaleLowerCase() === "custom")
?? modes.find((mode) => mode.name.toLocaleLowerCase() === "static")
?? modes.find((mode) => mode.name.toLocaleLowerCase() === "direct");
}
function modeColorSlots(mode: DeviceMode | undefined, rgb: boolean) {
if (!rgb) return 0;
if (!mode) return 1;
if (mode.colors_min > 0) return mode.colors_min;
return ["direct", "custom"].includes(mode.name.toLocaleLowerCase()) ? 1 : 0;
}
function stateColors(state: DeviceState, modes: DeviceMode[]) {
const mode = modes.find((candidate) => candidate.index === state.mode_index);
const count = modeColorSlots(mode, true);
const values = (state.colors ?? []).map(colorToHex);
return resizeColors(values.length ? values : ["#5660ff"], count || 1);
}
function resizeColors(colors: string[], count: number) {
if (!count) return [];
const seed = colors[0] ?? "#5660ff";
return Array.from({ length: count }, (_, index) => colors[index] ?? seed);
}
function validColor(value: string) {
return /^#[0-9a-f]{6}$/i.test(value) ? value : "#000000";
}
function clamp(value: number, minimum: number | null | undefined, maximum: number | null | undefined) {
return Math.max(minimum ?? value, Math.min(maximum ?? value, value));
}
@@ -1,116 +1,116 @@
import { Fan } from "lucide-react";
import { useEffect, useState } from "react";
import type { DeviceState, DeviceZone, RGBColor } from "../api/types";
import { colorToHex, hexToColor } from "../lib/utils";
import { Badge, Button, Card, Field, Input, Select } from "./ui";
type ZonePattern = "static" | "rainbow" | "alternating" | "off";
interface ZoneDraft {
pattern: ZonePattern;
primary: string;
secondary: string;
}
interface RgbZoneControlsProps {
targetKey: string;
zones: DeviceZone[];
state: DeviceState;
pendingZone?: number;
disabled: boolean;
onApply: (zoneIndex: number, state: DeviceState) => void;
}
export function RgbZoneControls({
targetKey,
zones,
state,
pendingZone,
disabled,
onApply,
}: RgbZoneControlsProps) {
const [drafts, setDrafts] = useState<Record<number, ZoneDraft>>({});
useEffect(() => {
setDrafts(Object.fromEntries(zones.map((zone) => [zone.index, {
pattern: "static",
primary: zoneColor(state, zone),
secondary: "#00c2a8",
}])));
}, [state, targetKey, zones]);
const update = (zoneIndex: number, patch: Partial<ZoneDraft>) => {
setDrafts((current) => ({
...current,
[zoneIndex]: {
...(current[zoneIndex] ?? { pattern: "static", primary: "#5660ff", secondary: "#00c2a8" }),
...patch,
},
}));
};
return <div className="argb-zone-list">
{zones.map((zone) => {
const draft = drafts[zone.index] ?? { pattern: "static", primary: "#5660ff", secondary: "#00c2a8" };
const automaticLedCount = zone.leds_min === zone.leds_max ? zone.led_count : zone.leds_max;
const colors = patternColors(draft, automaticLedCount);
return <Card className="argb-zone-card" data-testid={`argb-zone-${zone.index}`} key={zone.index}>
<div className="argb-zone-card__heading">
<span><Fan size={19} /></span>
<div><h3>{zone.name}</h3><p>Individuele Aura-zone</p></div>
<Badge tone="success">Alle {automaticLedCount} leds</Badge>
</div>
<Field label={`Modus voor ${zone.name}`}>
<Select aria-label={`Modus voor ${zone.name}`} value={draft.pattern} onChange={(event) => update(zone.index, { pattern: event.target.value as ZonePattern })}>
<option value="static">Vaste kleur</option>
<option value="rainbow">Regenboogpatroon</option>
<option value="alternating">Afwisselende kleuren</option>
<option value="off">Uit</option>
</Select>
</Field>
{draft.pattern !== "off" && draft.pattern !== "rainbow" ? <Field label="Hoofdkleur">
<div className="large-color">
<input aria-label={`Hoofdkleur voor ${zone.name} kiezen`} type="color" value={validColor(draft.primary)} onChange={(event) => update(zone.index, { primary: event.target.value })} />
<Input aria-label={`Hoofdkleur voor ${zone.name}`} value={draft.primary.toUpperCase()} onChange={(event) => update(zone.index, { primary: event.target.value })} />
</div>
</Field> : null}
{draft.pattern === "alternating" ? <Field label="Tweede kleur">
<div className="large-color">
<input aria-label={`Tweede kleur voor ${zone.name} kiezen`} type="color" value={validColor(draft.secondary)} onChange={(event) => update(zone.index, { secondary: event.target.value })} />
<Input aria-label={`Tweede kleur voor ${zone.name}`} value={draft.secondary.toUpperCase()} onChange={(event) => update(zone.index, { secondary: event.target.value })} />
</div>
</Field> : null}
<p className="mode-hint">De volledige header wordt automatisch gebruikt; een LED-aantal ingeven is niet nodig.</p>
<Button className="full-width" busy={pendingZone === zone.index} disabled={disabled || pendingZone != null} onClick={() => onApply(zone.index, { power: true, colors })}>
Alleen {zone.name} toepassen
</Button>
</Card>;
})}
</div>;
}
function zoneColor(state: DeviceState, zone: DeviceZone) {
return colorToHex(state.colors?.[zone.start_index ?? 0] ?? state.colors?.[0]);
}
function patternColors(draft: ZoneDraft, count: number): RGBColor[] {
if (draft.pattern === "off") return [{ red: 0, green: 0, blue: 0 }];
if (draft.pattern === "static") return [hexToColor(validColor(draft.primary))];
if (draft.pattern === "alternating") {
const colors = [hexToColor(validColor(draft.primary)), hexToColor(validColor(draft.secondary))];
return Array.from({ length: count }, (_, index) => colors[index % colors.length]!);
}
return Array.from({ length: count }, (_, index) => hslToRgb(index / Math.max(1, count)));
}
function hslToRgb(hue: number): RGBColor {
const channel = (offset: number) => {
const value = (offset + hue * 6) % 6;
return Math.round(255 * (1 - Math.max(0, Math.min(value, 4 - value, 1))));
};
return { red: channel(5), green: channel(3), blue: channel(1) };
}
function validColor(value: string) {
return /^#[0-9a-f]{6}$/i.test(value) ? value : "#000000";
}
import { Fan } from "lucide-react";
import { useEffect, useState } from "react";
import type { DeviceState, DeviceZone, RGBColor } from "../api/types";
import { colorToHex, hexToColor } from "../lib/utils";
import { Badge, Button, Card, Field, Input, Select } from "./ui";
type ZonePattern = "static" | "rainbow" | "alternating" | "off";
interface ZoneDraft {
pattern: ZonePattern;
primary: string;
secondary: string;
}
interface RgbZoneControlsProps {
targetKey: string;
zones: DeviceZone[];
state: DeviceState;
pendingZone?: number;
disabled: boolean;
onApply: (zoneIndex: number, state: DeviceState) => void;
}
export function RgbZoneControls({
targetKey,
zones,
state,
pendingZone,
disabled,
onApply,
}: RgbZoneControlsProps) {
const [drafts, setDrafts] = useState<Record<number, ZoneDraft>>({});
useEffect(() => {
setDrafts(Object.fromEntries(zones.map((zone) => [zone.index, {
pattern: "static",
primary: zoneColor(state, zone),
secondary: "#00c2a8",
}])));
}, [state, targetKey, zones]);
const update = (zoneIndex: number, patch: Partial<ZoneDraft>) => {
setDrafts((current) => ({
...current,
[zoneIndex]: {
...(current[zoneIndex] ?? { pattern: "static", primary: "#5660ff", secondary: "#00c2a8" }),
...patch,
},
}));
};
return <div className="argb-zone-list">
{zones.map((zone) => {
const draft = drafts[zone.index] ?? { pattern: "static", primary: "#5660ff", secondary: "#00c2a8" };
const automaticLedCount = zone.leds_min === zone.leds_max ? zone.led_count : zone.leds_max;
const colors = patternColors(draft, automaticLedCount);
return <Card className="argb-zone-card" data-testid={`argb-zone-${zone.index}`} key={zone.index}>
<div className="argb-zone-card__heading">
<span><Fan size={19} /></span>
<div><h3>{zone.name}</h3><p>Individuele Aura-zone</p></div>
<Badge tone="success">Alle {automaticLedCount} leds</Badge>
</div>
<Field label={`Modus voor ${zone.name}`}>
<Select aria-label={`Modus voor ${zone.name}`} value={draft.pattern} onChange={(event) => update(zone.index, { pattern: event.target.value as ZonePattern })}>
<option value="static">Vaste kleur</option>
<option value="rainbow">Regenboogpatroon</option>
<option value="alternating">Afwisselende kleuren</option>
<option value="off">Uit</option>
</Select>
</Field>
{draft.pattern !== "off" && draft.pattern !== "rainbow" ? <Field label="Hoofdkleur">
<div className="large-color">
<input aria-label={`Hoofdkleur voor ${zone.name} kiezen`} type="color" value={validColor(draft.primary)} onChange={(event) => update(zone.index, { primary: event.target.value })} />
<Input aria-label={`Hoofdkleur voor ${zone.name}`} value={draft.primary.toUpperCase()} onChange={(event) => update(zone.index, { primary: event.target.value })} />
</div>
</Field> : null}
{draft.pattern === "alternating" ? <Field label="Tweede kleur">
<div className="large-color">
<input aria-label={`Tweede kleur voor ${zone.name} kiezen`} type="color" value={validColor(draft.secondary)} onChange={(event) => update(zone.index, { secondary: event.target.value })} />
<Input aria-label={`Tweede kleur voor ${zone.name}`} value={draft.secondary.toUpperCase()} onChange={(event) => update(zone.index, { secondary: event.target.value })} />
</div>
</Field> : null}
<p className="mode-hint">De volledige header wordt automatisch gebruikt; een LED-aantal ingeven is niet nodig.</p>
<Button className="full-width" busy={pendingZone === zone.index} disabled={disabled || pendingZone != null} onClick={() => onApply(zone.index, { power: true, colors })}>
Alleen {zone.name} toepassen
</Button>
</Card>;
})}
</div>;
}
function zoneColor(state: DeviceState, zone: DeviceZone) {
return colorToHex(state.colors?.[zone.start_index ?? 0] ?? state.colors?.[0]);
}
function patternColors(draft: ZoneDraft, count: number): RGBColor[] {
if (draft.pattern === "off") return [{ red: 0, green: 0, blue: 0 }];
if (draft.pattern === "static") return [hexToColor(validColor(draft.primary))];
if (draft.pattern === "alternating") {
const colors = [hexToColor(validColor(draft.primary)), hexToColor(validColor(draft.secondary))];
return Array.from({ length: count }, (_, index) => colors[index % colors.length]!);
}
return Array.from({ length: count }, (_, index) => hslToRgb(index / Math.max(1, count)));
}
function hslToRgb(hue: number): RGBColor {
const channel = (offset: number) => {
const value = (offset + hue * 6) % 6;
return Math.round(255 * (1 - Math.max(0, Math.min(value, 4 - value, 1))));
};
return { red: channel(5), green: channel(3), blue: channel(1) };
}
function validColor(value: string) {
return /^#[0-9a-f]{6}$/i.test(value) ? value : "#000000";
}
+139 -139
View File
@@ -1,139 +1,139 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Save, Trash2, X } from "lucide-react";
import { useEffect, useState, type FormEvent } from "react";
import { api } from "../api/client";
import type { Device, DeviceState, Group, Page, Scene, SceneItem } from "../api/types";
import { useI18n } from "../lib/i18n";
import { formValue, hexToColor } from "../lib/utils";
import { useToast } from "./Toast";
import { Button, Card, CardHeader, ErrorPanel, Field, Input, LoadingGrid, Select } from "./ui";
interface SceneEditorProps {
sceneId: string;
onClose: () => void;
}
type EditableItem = Omit<SceneItem, "id"> & { id?: string };
export function SceneEditor({ sceneId, onClose }: SceneEditorProps) {
const { text } = useI18n();
const { notify } = useToast();
const queryClient = useQueryClient();
const scene = useQuery({ queryKey: ["scene", sceneId], queryFn: () => api<Scene>(`/api/v1/scenes/${sceneId}`) });
const devices = useQuery({ queryKey: ["devices"], queryFn: () => api<Page<Device>>("/api/v1/devices?limit=500") });
const groups = useQuery({ queryKey: ["groups"], queryFn: () => api<Group[]>("/api/v1/groups") });
const [items, setItems] = useState<EditableItem[]>([]);
useEffect(() => {
if (scene.data?.items) setItems(scene.data.items);
}, [scene.data]);
const save = useMutation({
mutationFn: (payload: Record<string, unknown>) => api<Scene>(`/api/v1/scenes/${sceneId}`, { method: "PUT", body: JSON.stringify(payload) }),
onSuccess: () => {
notify(text("Scène opgeslagen.", "Scene saved."));
void queryClient.invalidateQueries({ queryKey: ["scenes"] });
void queryClient.invalidateQueries({ queryKey: ["scene", sceneId] });
onClose();
},
onError: (error) => notify(error instanceof Error ? error.message : text("Opslaan mislukt.", "Save failed."), "danger"),
});
if (scene.isLoading || devices.isLoading || groups.isLoading) return <LoadingGrid count={2} />;
if (scene.error || devices.error || groups.error || !scene.data) {
return <ErrorPanel error={scene.error ?? devices.error ?? groups.error} retry={() => void Promise.all([scene.refetch(), devices.refetch(), groups.refetch()])} />;
}
const targetName = (item: EditableItem) => {
if (item.target_type === "device") {
const device = devices.data?.items.find((candidate) => candidate.id === item.target_id);
return device?.alias || device?.name || item.target_id;
}
return groups.data?.find((candidate) => candidate.id === item.target_id)?.name || item.target_id;
};
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
save.mutate({
name: data.get("name"),
description: data.get("description") || null,
favorite: data.get("favorite") === "on",
items: items.map((item, index) => ({ ...item, sort_order: index })),
});
};
const addItem = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const target = formValue(data, "target");
if (!target.includes(":")) return;
const [targetType, targetId] = target.split(":", 2) as ["device" | "group", string];
const state: DeviceState = {};
const power = data.get("power");
if (power === "unchanged" && data.get("include_color") !== "on" && data.get("include_brightness") !== "on") {
notify(text("Selecteer minstens één eigenschap voor dit doel.", "Select at least one property for this target."), "danger");
return;
}
if (power === "on") state.power = true;
if (power === "off") state.power = false;
if (data.get("include_color") === "on") state.colors = [hexToColor(formValue(data, "color"))];
if (data.get("include_brightness") === "on") state.brightness = Number(data.get("brightness"));
setItems((current) => [
...current.filter((item) => !(item.target_type === targetType && item.target_id === targetId)),
{ target_type: targetType, target_id: targetId, state, required: true, sort_order: current.length },
]);
event.currentTarget.reset();
};
return (
<Card className="resource-editor">
<CardHeader
title={text("Scène bewerken", "Edit scene")}
description={text("Stel doelen en alleen de gewenste eigenschappen in. Niet aangevinkte eigenschappen blijven ongemoeid.", "Configure targets and only the desired properties. Unchecked properties remain unchanged.")}
action={<Button type="button" variant="ghost" onClick={onClose}><X size={16} /> {text("Sluiten", "Close")}</Button>}
/>
<form id="scene-metadata" className="form-stack" onSubmit={submit}>
<div className="form-grid form-grid--two">
<Field label={text("Naam", "Name")}><Input name="name" required defaultValue={scene.data.name} /></Field>
<Field label={text("Omschrijving", "Description")}><Input name="description" defaultValue={scene.data.description ?? ""} /></Field>
</div>
<label className="checkbox-row"><input name="favorite" type="checkbox" defaultChecked={scene.data.favorite} /> {text("Toon als favoriete scène", "Show as favorite scene")}</label>
</form>
<div className="resource-editor__section">
<h3>{text("Doeltoestanden", "Target states")}</h3>
{!items.length ? <p className="muted">{text("Deze scène bevat nog geen doelen.", "This scene has no targets yet.")}</p> : (
<div className="scene-item-list">
{items.map((item) => (
<div className="scene-item" key={`${item.target_type}:${item.target_id}`}>
<div><strong>{targetName(item)}</strong><span>{item.target_type === "group" ? text("Groep", "Group") : text("Apparaat", "Device")} · {describeState(item.state, text)}</span></div>
<Button type="button" variant="ghost" title={text("Verwijderen", "Remove")} aria-label={text(`Doel ${targetName(item)} verwijderen`, `Remove target ${targetName(item)}`)} onClick={() => setItems((current) => current.filter((candidate) => candidate !== item))}><Trash2 size={16} /></Button>
</div>
))}
</div>
)}
</div>
<form className="scene-target-form" onSubmit={addItem}>
<Field label={text("Doel", "Target")}>
<Select name="target" required defaultValue="">
<option value="" disabled>{text("Selecteer apparaat of groep", "Select device or group")}</option>
<optgroup label={text("Apparaten", "Devices")}>{devices.data?.items.map((device) => <option key={device.id} value={`device:${device.id}`}>{device.alias || device.name}</option>)}</optgroup>
<optgroup label={text("Groepen", "Groups")}>{groups.data?.map((group) => <option key={group.id} value={`group:${group.id}`}>{group.name}</option>)}</optgroup>
</Select>
</Field>
<Field label={text("Voeding", "Power")}><Select name="power" defaultValue="unchanged"><option value="unchanged">{text("Niet wijzigen", "Unchanged")}</option><option value="on">{text("Aan", "On")}</option><option value="off">{text("Uit", "Off")}</option></Select></Field>
<Field label={text("Kleur", "Color")}><div className="option-control"><input name="include_color" type="checkbox" aria-label={text("Kleur opnemen", "Include color")} /><input name="color" type="color" defaultValue="#5660ff" /></div></Field>
<Field label={text("Helderheid", "Brightness")}><div className="option-control"><input name="include_brightness" type="checkbox" aria-label={text("Helderheid opnemen", "Include brightness")} /><Input name="brightness" type="number" min="0" max="100" defaultValue="100" /></div></Field>
<Button type="submit" variant="secondary"><Plus size={16} /> {text("Doel toevoegen", "Add target")}</Button>
</form>
<div className="resource-editor__actions"><Button type="button" variant="ghost" onClick={onClose}>{text("Annuleren", "Cancel")}</Button><Button type="submit" form="scene-metadata" busy={save.isPending}><Save size={16} /> {text("Scène opslaan", "Save scene")}</Button></div>
</Card>
);
}
function describeState(state: DeviceState, text: (dutch: string, english: string) => string): string {
const values: string[] = [];
if (state.power !== undefined && state.power !== null) values.push(state.power ? text("aan", "on") : text("uit", "off"));
const color = state.colors?.[0];
if (color) values.push(`rgb(${color.red}, ${color.green}, ${color.blue})`);
if (state.brightness !== undefined && state.brightness !== null) values.push(`${state.brightness}%`);
return values.join(" · ") || text("geen eigenschappen", "no properties");
}
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Save, Trash2, X } from "lucide-react";
import { useEffect, useState, type FormEvent } from "react";
import { api } from "../api/client";
import type { Device, DeviceState, Group, Page, Scene, SceneItem } from "../api/types";
import { useI18n } from "../lib/i18n";
import { formValue, hexToColor } from "../lib/utils";
import { useToast } from "./Toast";
import { Button, Card, CardHeader, ErrorPanel, Field, Input, LoadingGrid, Select } from "./ui";
interface SceneEditorProps {
sceneId: string;
onClose: () => void;
}
type EditableItem = Omit<SceneItem, "id"> & { id?: string };
export function SceneEditor({ sceneId, onClose }: SceneEditorProps) {
const { text } = useI18n();
const { notify } = useToast();
const queryClient = useQueryClient();
const scene = useQuery({ queryKey: ["scene", sceneId], queryFn: () => api<Scene>(`/api/v1/scenes/${sceneId}`) });
const devices = useQuery({ queryKey: ["devices"], queryFn: () => api<Page<Device>>("/api/v1/devices?limit=500") });
const groups = useQuery({ queryKey: ["groups"], queryFn: () => api<Group[]>("/api/v1/groups") });
const [items, setItems] = useState<EditableItem[]>([]);
useEffect(() => {
if (scene.data?.items) setItems(scene.data.items);
}, [scene.data]);
const save = useMutation({
mutationFn: (payload: Record<string, unknown>) => api<Scene>(`/api/v1/scenes/${sceneId}`, { method: "PUT", body: JSON.stringify(payload) }),
onSuccess: () => {
notify(text("Scène opgeslagen.", "Scene saved."));
void queryClient.invalidateQueries({ queryKey: ["scenes"] });
void queryClient.invalidateQueries({ queryKey: ["scene", sceneId] });
onClose();
},
onError: (error) => notify(error instanceof Error ? error.message : text("Opslaan mislukt.", "Save failed."), "danger"),
});
if (scene.isLoading || devices.isLoading || groups.isLoading) return <LoadingGrid count={2} />;
if (scene.error || devices.error || groups.error || !scene.data) {
return <ErrorPanel error={scene.error ?? devices.error ?? groups.error} retry={() => void Promise.all([scene.refetch(), devices.refetch(), groups.refetch()])} />;
}
const targetName = (item: EditableItem) => {
if (item.target_type === "device") {
const device = devices.data?.items.find((candidate) => candidate.id === item.target_id);
return device?.alias || device?.name || item.target_id;
}
return groups.data?.find((candidate) => candidate.id === item.target_id)?.name || item.target_id;
};
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
save.mutate({
name: data.get("name"),
description: data.get("description") || null,
favorite: data.get("favorite") === "on",
items: items.map((item, index) => ({ ...item, sort_order: index })),
});
};
const addItem = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const data = new FormData(event.currentTarget);
const target = formValue(data, "target");
if (!target.includes(":")) return;
const [targetType, targetId] = target.split(":", 2) as ["device" | "group", string];
const state: DeviceState = {};
const power = data.get("power");
if (power === "unchanged" && data.get("include_color") !== "on" && data.get("include_brightness") !== "on") {
notify(text("Selecteer minstens één eigenschap voor dit doel.", "Select at least one property for this target."), "danger");
return;
}
if (power === "on") state.power = true;
if (power === "off") state.power = false;
if (data.get("include_color") === "on") state.colors = [hexToColor(formValue(data, "color"))];
if (data.get("include_brightness") === "on") state.brightness = Number(data.get("brightness"));
setItems((current) => [
...current.filter((item) => !(item.target_type === targetType && item.target_id === targetId)),
{ target_type: targetType, target_id: targetId, state, required: true, sort_order: current.length },
]);
event.currentTarget.reset();
};
return (
<Card className="resource-editor">
<CardHeader
title={text("Scène bewerken", "Edit scene")}
description={text("Stel doelen en alleen de gewenste eigenschappen in. Niet aangevinkte eigenschappen blijven ongemoeid.", "Configure targets and only the desired properties. Unchecked properties remain unchanged.")}
action={<Button type="button" variant="ghost" onClick={onClose}><X size={16} /> {text("Sluiten", "Close")}</Button>}
/>
<form id="scene-metadata" className="form-stack" onSubmit={submit}>
<div className="form-grid form-grid--two">
<Field label={text("Naam", "Name")}><Input name="name" required defaultValue={scene.data.name} /></Field>
<Field label={text("Omschrijving", "Description")}><Input name="description" defaultValue={scene.data.description ?? ""} /></Field>
</div>
<label className="checkbox-row"><input name="favorite" type="checkbox" defaultChecked={scene.data.favorite} /> {text("Toon als favoriete scène", "Show as favorite scene")}</label>
</form>
<div className="resource-editor__section">
<h3>{text("Doeltoestanden", "Target states")}</h3>
{!items.length ? <p className="muted">{text("Deze scène bevat nog geen doelen.", "This scene has no targets yet.")}</p> : (
<div className="scene-item-list">
{items.map((item) => (
<div className="scene-item" key={`${item.target_type}:${item.target_id}`}>
<div><strong>{targetName(item)}</strong><span>{item.target_type === "group" ? text("Groep", "Group") : text("Apparaat", "Device")} · {describeState(item.state, text)}</span></div>
<Button type="button" variant="ghost" title={text("Verwijderen", "Remove")} aria-label={text(`Doel ${targetName(item)} verwijderen`, `Remove target ${targetName(item)}`)} onClick={() => setItems((current) => current.filter((candidate) => candidate !== item))}><Trash2 size={16} /></Button>
</div>
))}
</div>
)}
</div>
<form className="scene-target-form" onSubmit={addItem}>
<Field label={text("Doel", "Target")}>
<Select name="target" required defaultValue="">
<option value="" disabled>{text("Selecteer apparaat of groep", "Select device or group")}</option>
<optgroup label={text("Apparaten", "Devices")}>{devices.data?.items.map((device) => <option key={device.id} value={`device:${device.id}`}>{device.alias || device.name}</option>)}</optgroup>
<optgroup label={text("Groepen", "Groups")}>{groups.data?.map((group) => <option key={group.id} value={`group:${group.id}`}>{group.name}</option>)}</optgroup>
</Select>
</Field>
<Field label={text("Voeding", "Power")}><Select name="power" defaultValue="unchanged"><option value="unchanged">{text("Niet wijzigen", "Unchanged")}</option><option value="on">{text("Aan", "On")}</option><option value="off">{text("Uit", "Off")}</option></Select></Field>
<Field label={text("Kleur", "Color")}><div className="option-control"><input name="include_color" type="checkbox" aria-label={text("Kleur opnemen", "Include color")} /><input name="color" type="color" defaultValue="#5660ff" /></div></Field>
<Field label={text("Helderheid", "Brightness")}><div className="option-control"><input name="include_brightness" type="checkbox" aria-label={text("Helderheid opnemen", "Include brightness")} /><Input name="brightness" type="number" min="0" max="100" defaultValue="100" /></div></Field>
<Button type="submit" variant="secondary"><Plus size={16} /> {text("Doel toevoegen", "Add target")}</Button>
</form>
<div className="resource-editor__actions"><Button type="button" variant="ghost" onClick={onClose}>{text("Annuleren", "Cancel")}</Button><Button type="submit" form="scene-metadata" busy={save.isPending}><Save size={16} /> {text("Scène opslaan", "Save scene")}</Button></div>
</Card>
);
}
function describeState(state: DeviceState, text: (dutch: string, english: string) => string): string {
const values: string[] = [];
if (state.power !== undefined && state.power !== null) values.push(state.power ? text("aan", "on") : text("uit", "off"));
const color = state.colors?.[0];
if (color) values.push(`rgb(${color.red}, ${color.green}, ${color.blue})`);
if (state.brightness !== undefined && state.brightness !== null) values.push(`${state.brightness}%`);
return values.join(" · ") || text("geen eigenschappen", "no properties");
}
+36 -36
View File
@@ -1,36 +1,36 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { CheckCircle2, X, XCircle } from "lucide-react";
import { IconButton } from "./ui";
interface ToastItem { id: string; message: string; tone: "success" | "danger" }
interface ToastValue { notify: (message: string, tone?: ToastItem["tone"]) => void }
const ToastContext = createContext<ToastValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]);
const dismiss = useCallback((id: string) => setItems((current) => current.filter((item) => item.id !== id)), []);
const value = useMemo<ToastValue>(() => ({ notify: (message, tone = "success") => {
const id = crypto.randomUUID();
setItems((current) => [...current.slice(-3), { id, message, tone }]);
window.setTimeout(() => dismiss(id), 4500);
} }), [dismiss]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="toasts" aria-live="polite">
{items.map((item) => <div key={item.id} className={`toast toast--${item.tone}`}>
{item.tone === "success" ? <CheckCircle2 size={18} /> : <XCircle size={18} />}
<span>{item.message}</span>
<IconButton label="Sluiten" onClick={() => dismiss(item.id)}><X size={15} /></IconButton>
</div>)}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastValue {
const value = useContext(ToastContext);
if (!value) throw new Error("useToast moet binnen ToastProvider gebruikt worden");
return value;
}
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
import { CheckCircle2, X, XCircle } from "lucide-react";
import { IconButton } from "./ui";
interface ToastItem { id: string; message: string; tone: "success" | "danger" }
interface ToastValue { notify: (message: string, tone?: ToastItem["tone"]) => void }
const ToastContext = createContext<ToastValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]);
const dismiss = useCallback((id: string) => setItems((current) => current.filter((item) => item.id !== id)), []);
const value = useMemo<ToastValue>(() => ({ notify: (message, tone = "success") => {
const id = crypto.randomUUID();
setItems((current) => [...current.slice(-3), { id, message, tone }]);
window.setTimeout(() => dismiss(id), 4500);
} }), [dismiss]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="toasts" aria-live="polite">
{items.map((item) => <div key={item.id} className={`toast toast--${item.tone}`}>
{item.tone === "success" ? <CheckCircle2 size={18} /> : <XCircle size={18} />}
<span>{item.message}</span>
<IconButton label="Sluiten" onClick={() => dismiss(item.id)}><X size={15} /></IconButton>
</div>)}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastValue {
const value = useContext(ToastContext);
if (!value) throw new Error("useToast moet binnen ToastProvider gebruikt worden");
return value;
}
+21 -21
View File
@@ -1,21 +1,21 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { StatusBadge } from "./ui";
import { renderApp } from "../test/render";
import { useI18n } from "../lib/i18n";
function LanguageFixture() {
const { setLanguage } = useI18n();
return <><StatusBadge status="degraded" /><button onClick={() => setLanguage("en")}>English</button></>;
}
describe("local component layer", () => {
it("renders status accessibly and switches language", async () => {
renderApp(<LanguageFixture />);
expect(screen.getByText("Beperkt")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "English" }));
expect(screen.getByText("Degraded")).toBeInTheDocument();
});
});
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { StatusBadge } from "./ui";
import { renderApp } from "../test/render";
import { useI18n } from "../lib/i18n";
function LanguageFixture() {
const { setLanguage } = useI18n();
return <><StatusBadge status="degraded" /><button onClick={() => setLanguage("en")}>English</button></>;
}
describe("local component layer", () => {
it("renders status accessibly and switches language", async () => {
renderApp(<LanguageFixture />);
expect(screen.getByText("Beperkt")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "English" }));
expect(screen.getByText("Degraded")).toBeInTheDocument();
});
});
+209 -209
View File
@@ -1,209 +1,209 @@
import {
AlertTriangle,
CheckCircle2,
CircleHelp,
Info,
LoaderCircle,
RefreshCw,
XCircle,
type LucideIcon,
} from "lucide-react";
import type {
ButtonHTMLAttributes,
HTMLAttributes,
InputHTMLAttributes,
ReactNode,
SelectHTMLAttributes,
} from "react";
import type { HealthStatus } from "../api/types";
import { useI18n } from "../lib/i18n";
import { cn } from "../lib/utils";
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
export function Button({
variant = "primary",
className,
children,
busy,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant; busy?: boolean }) {
return (
<button className={cn("button", `button--${variant}`, className)} disabled={busy || props.disabled} {...props}>
{busy ? <LoaderCircle className="spin" size={16} aria-hidden /> : null}
{children}
</button>
);
}
export function IconButton({ label, children, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { label: string }) {
return (
<button className="icon-button" aria-label={label} title={label} {...props}>
{children}
</button>
);
}
export function Card({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("card", className)} {...props}>
{children}
</div>
);
}
export function CardHeader({
title,
description,
action,
}: {
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="card__header">
<div>
<h2>{title}</h2>
{description ? <p>{description}</p> : null}
</div>
{action}
</div>
);
}
export function Badge({
tone = "neutral",
children,
}: {
tone?: "neutral" | "success" | "warning" | "danger" | "accent";
children: ReactNode;
}) {
return <span className={cn("badge", `badge--${tone}`)}>{children}</span>;
}
export function StatusBadge({ status }: { status: HealthStatus }) {
const { t } = useI18n();
const mapping: Record<HealthStatus, { icon: LucideIcon; tone: "success" | "warning" | "danger" | "neutral" }> = {
healthy: { icon: CheckCircle2, tone: "success" },
degraded: { icon: AlertTriangle, tone: "warning" },
unhealthy: { icon: XCircle, tone: "danger" },
unknown: { icon: CircleHelp, tone: "neutral" },
};
const item = mapping[status];
const Icon = item.icon;
return (
<Badge tone={item.tone}>
<Icon size={13} aria-hidden /> {t(status)}
</Badge>
);
}
export function Input(props: InputHTMLAttributes<HTMLInputElement>) {
return <input className={cn("input", props.className)} {...props} />;
}
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {
return <select className={cn("input", props.className)} {...props} />;
}
export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="field">
<span className="field__label">{label}</span>
{children}
{hint ? <span className="field__hint">{hint}</span> : null}
</label>
);
}
export function PageHeader({
eyebrow,
title,
description,
actions,
}: {
eyebrow?: string;
title: string;
description?: string;
actions?: ReactNode;
}) {
return (
<header className="page-header">
<div>
{eyebrow ? <span className="eyebrow">{eyebrow}</span> : null}
<h1>{title}</h1>
{description ? <p>{description}</p> : null}
</div>
{actions ? <div className="page-header__actions">{actions}</div> : null}
</header>
);
}
export function Skeleton({ className }: { className?: string }) {
return <div className={cn("skeleton", className)} aria-hidden />;
}
export function LoadingGrid({ count = 4 }: { count?: number }) {
return (
<div className="grid grid--cards" aria-label="Laden">
{Array.from({ length: count }, (_, index) => (
<Card key={index} className="skeleton-card">
<Skeleton className="skeleton--short" />
<Skeleton />
<Skeleton className="skeleton--medium" />
</Card>
))}
</div>
);
}
export function EmptyState({
title,
description,
action,
icon: Icon = Info,
}: {
title: string;
description: string;
action?: ReactNode;
icon?: LucideIcon;
}) {
return (
<div className="empty-state">
<span className="empty-state__icon"><Icon size={24} aria-hidden /></span>
<h2>{title}</h2>
<p>{description}</p>
{action}
</div>
);
}
export function ErrorPanel({ error, retry }: { error: unknown; retry?: () => void }) {
const { t } = useI18n();
const message = error instanceof Error ? error.message : "Onbekende fout";
return (
<div className="alert alert--danger" role="alert">
<XCircle size={20} aria-hidden />
<div>
<strong>Deze gegevens konden niet worden geladen.</strong>
<p>{message}</p>
</div>
{retry ? (
<Button variant="secondary" onClick={retry}>
<RefreshCw size={16} aria-hidden /> {t("retry")}
</Button>
) : null}
</div>
);
}
export function Stat({ label, value, detail, icon: Icon }: { label: string; value: ReactNode; detail?: string; icon: LucideIcon }) {
return (
<Card className="stat">
<span className="stat__icon"><Icon size={20} aria-hidden /></span>
<div><span className="stat__label">{label}</span><strong>{value}</strong>{detail ? <small>{detail}</small> : null}</div>
</Card>
);
}
import {
AlertTriangle,
CheckCircle2,
CircleHelp,
Info,
LoaderCircle,
RefreshCw,
XCircle,
type LucideIcon,
} from "lucide-react";
import type {
ButtonHTMLAttributes,
HTMLAttributes,
InputHTMLAttributes,
ReactNode,
SelectHTMLAttributes,
} from "react";
import type { HealthStatus } from "../api/types";
import { useI18n } from "../lib/i18n";
import { cn } from "../lib/utils";
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
export function Button({
variant = "primary",
className,
children,
busy,
...props
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant; busy?: boolean }) {
return (
<button className={cn("button", `button--${variant}`, className)} disabled={busy || props.disabled} {...props}>
{busy ? <LoaderCircle className="spin" size={16} aria-hidden /> : null}
{children}
</button>
);
}
export function IconButton({ label, children, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { label: string }) {
return (
<button className="icon-button" aria-label={label} title={label} {...props}>
{children}
</button>
);
}
export function Card({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div className={cn("card", className)} {...props}>
{children}
</div>
);
}
export function CardHeader({
title,
description,
action,
}: {
title: string;
description?: string;
action?: ReactNode;
}) {
return (
<div className="card__header">
<div>
<h2>{title}</h2>
{description ? <p>{description}</p> : null}
</div>
{action}
</div>
);
}
export function Badge({
tone = "neutral",
children,
}: {
tone?: "neutral" | "success" | "warning" | "danger" | "accent";
children: ReactNode;
}) {
return <span className={cn("badge", `badge--${tone}`)}>{children}</span>;
}
export function StatusBadge({ status }: { status: HealthStatus }) {
const { t } = useI18n();
const mapping: Record<HealthStatus, { icon: LucideIcon; tone: "success" | "warning" | "danger" | "neutral" }> = {
healthy: { icon: CheckCircle2, tone: "success" },
degraded: { icon: AlertTriangle, tone: "warning" },
unhealthy: { icon: XCircle, tone: "danger" },
unknown: { icon: CircleHelp, tone: "neutral" },
};
const item = mapping[status];
const Icon = item.icon;
return (
<Badge tone={item.tone}>
<Icon size={13} aria-hidden /> {t(status)}
</Badge>
);
}
export function Input(props: InputHTMLAttributes<HTMLInputElement>) {
return <input className={cn("input", props.className)} {...props} />;
}
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {
return <select className={cn("input", props.className)} {...props} />;
}
export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<label className="field">
<span className="field__label">{label}</span>
{children}
{hint ? <span className="field__hint">{hint}</span> : null}
</label>
);
}
export function PageHeader({
eyebrow,
title,
description,
actions,
}: {
eyebrow?: string;
title: string;
description?: string;
actions?: ReactNode;
}) {
return (
<header className="page-header">
<div>
{eyebrow ? <span className="eyebrow">{eyebrow}</span> : null}
<h1>{title}</h1>
{description ? <p>{description}</p> : null}
</div>
{actions ? <div className="page-header__actions">{actions}</div> : null}
</header>
);
}
export function Skeleton({ className }: { className?: string }) {
return <div className={cn("skeleton", className)} aria-hidden />;
}
export function LoadingGrid({ count = 4 }: { count?: number }) {
return (
<div className="grid grid--cards" aria-label="Laden">
{Array.from({ length: count }, (_, index) => (
<Card key={index} className="skeleton-card">
<Skeleton className="skeleton--short" />
<Skeleton />
<Skeleton className="skeleton--medium" />
</Card>
))}
</div>
);
}
export function EmptyState({
title,
description,
action,
icon: Icon = Info,
}: {
title: string;
description: string;
action?: ReactNode;
icon?: LucideIcon;
}) {
return (
<div className="empty-state">
<span className="empty-state__icon"><Icon size={24} aria-hidden /></span>
<h2>{title}</h2>
<p>{description}</p>
{action}
</div>
);
}
export function ErrorPanel({ error, retry }: { error: unknown; retry?: () => void }) {
const { t } = useI18n();
const message = error instanceof Error ? error.message : "Onbekende fout";
return (
<div className="alert alert--danger" role="alert">
<XCircle size={20} aria-hidden />
<div>
<strong>Deze gegevens konden niet worden geladen.</strong>
<p>{message}</p>
</div>
{retry ? (
<Button variant="secondary" onClick={retry}>
<RefreshCw size={16} aria-hidden /> {t("retry")}
</Button>
) : null}
</div>
);
}
export function Stat({ label, value, detail, icon: Icon }: { label: string; value: ReactNode; detail?: string; icon: LucideIcon }) {
return (
<Card className="stat">
<span className="stat__icon"><Icon size={20} aria-hidden /></span>
<div><span className="stat__label">{label}</span><strong>{value}</strong>{detail ? <small>{detail}</small> : null}</div>
</Card>
);
}
+116 -116
View File
@@ -1,116 +1,116 @@
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
type Language = "nl" | "en";
const messages = {
nl: {
overview: "Overzicht",
devices: "Apparaten",
spaces: "Kamers & groepen",
scenes: "Scènes",
automations: "Automations",
network: "Netwerkapparaten",
connectors: "Connectoren",
discovery: "Discovery",
activity: "Activiteit",
audit: "Auditlog",
diagnostics: "Diagnostiek",
backups: "Back-up & herstel",
settings: "Instellingen",
about: "Over LumaOps",
online: "Online",
offline: "Offline",
healthy: "Gezond",
degraded: "Beperkt",
unhealthy: "Ongezond",
unknown: "Onbekend",
loading: "Laden…",
retry: "Opnieuw proberen",
apply: "Toepassen",
cancel: "Annuleren",
save: "Opslaan",
create: "Aanmaken",
search: "Zoeken",
noResults: "Geen resultaten gevonden",
allOff: "Alles uit",
emergency: "Noodstop",
rescan: "Opnieuw scannen",
mockWarning: "Testmodus actief — opdrachten bereiken geen echte hardware.",
openMenu: "Navigatie openen",
closeMenu: "Navigatie sluiten",
},
en: {
overview: "Overview",
devices: "Devices",
spaces: "Rooms & groups",
scenes: "Scenes",
automations: "Automations",
network: "Network devices",
connectors: "Connectors",
discovery: "Discovery",
activity: "Activity",
audit: "Audit log",
diagnostics: "Diagnostics",
backups: "Backup & restore",
settings: "Settings",
about: "About LumaOps",
online: "Online",
offline: "Offline",
healthy: "Healthy",
degraded: "Degraded",
unhealthy: "Unhealthy",
unknown: "Unknown",
loading: "Loading…",
retry: "Try again",
apply: "Apply",
cancel: "Cancel",
save: "Save",
create: "Create",
search: "Search",
noResults: "No results found",
allOff: "All off",
emergency: "Emergency stop",
rescan: "Rescan",
mockWarning: "Test mode is active — commands do not reach real hardware.",
openMenu: "Open navigation",
closeMenu: "Close navigation",
},
} as const;
type MessageKey = keyof (typeof messages)["nl"];
interface I18nValue {
language: Language;
setLanguage: (language: Language) => void;
t: (key: MessageKey) => string;
text: (dutch: string, english: string) => string;
}
const I18nContext = createContext<I18nValue | null>(null);
export function I18nProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<Language>(() => {
const saved = localStorage.getItem("lumaops.language");
return saved === "en" ? "en" : "nl";
});
const value = useMemo<I18nValue>(
() => ({
language,
setLanguage: (next) => {
localStorage.setItem("lumaops.language", next);
document.documentElement.lang = next;
setLanguageState(next);
},
t: (key) => messages[language][key],
text: (dutch, english) => (language === "nl" ? dutch : english),
}),
[language],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nValue {
const value = useContext(I18nContext);
if (!value) throw new Error("useI18n moet binnen I18nProvider gebruikt worden");
return value;
}
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
type Language = "nl" | "en";
const messages = {
nl: {
overview: "Overzicht",
devices: "Apparaten",
spaces: "Kamers & groepen",
scenes: "Scènes",
automations: "Automations",
network: "Netwerkapparaten",
connectors: "Connectoren",
discovery: "Discovery",
activity: "Activiteit",
audit: "Auditlog",
diagnostics: "Diagnostiek",
backups: "Back-up & herstel",
settings: "Instellingen",
about: "Over LumaOps",
online: "Online",
offline: "Offline",
healthy: "Gezond",
degraded: "Beperkt",
unhealthy: "Ongezond",
unknown: "Onbekend",
loading: "Laden…",
retry: "Opnieuw proberen",
apply: "Toepassen",
cancel: "Annuleren",
save: "Opslaan",
create: "Aanmaken",
search: "Zoeken",
noResults: "Geen resultaten gevonden",
allOff: "Alles uit",
emergency: "Noodstop",
rescan: "Opnieuw scannen",
mockWarning: "Testmodus actief — opdrachten bereiken geen echte hardware.",
openMenu: "Navigatie openen",
closeMenu: "Navigatie sluiten",
},
en: {
overview: "Overview",
devices: "Devices",
spaces: "Rooms & groups",
scenes: "Scenes",
automations: "Automations",
network: "Network devices",
connectors: "Connectors",
discovery: "Discovery",
activity: "Activity",
audit: "Audit log",
diagnostics: "Diagnostics",
backups: "Backup & restore",
settings: "Settings",
about: "About LumaOps",
online: "Online",
offline: "Offline",
healthy: "Healthy",
degraded: "Degraded",
unhealthy: "Unhealthy",
unknown: "Unknown",
loading: "Loading…",
retry: "Try again",
apply: "Apply",
cancel: "Cancel",
save: "Save",
create: "Create",
search: "Search",
noResults: "No results found",
allOff: "All off",
emergency: "Emergency stop",
rescan: "Rescan",
mockWarning: "Test mode is active — commands do not reach real hardware.",
openMenu: "Open navigation",
closeMenu: "Close navigation",
},
} as const;
type MessageKey = keyof (typeof messages)["nl"];
interface I18nValue {
language: Language;
setLanguage: (language: Language) => void;
t: (key: MessageKey) => string;
text: (dutch: string, english: string) => string;
}
const I18nContext = createContext<I18nValue | null>(null);
export function I18nProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<Language>(() => {
const saved = localStorage.getItem("lumaops.language");
return saved === "en" ? "en" : "nl";
});
const value = useMemo<I18nValue>(
() => ({
language,
setLanguage: (next) => {
localStorage.setItem("lumaops.language", next);
document.documentElement.lang = next;
setLanguageState(next);
},
t: (key) => messages[language][key],
text: (dutch, english) => (language === "nl" ? dutch : english),
}),
[language],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n(): I18nValue {
const value = useContext(I18nContext);
if (!value) throw new Error("useI18n moet binnen I18nProvider gebruikt worden");
return value;
}
+57 -57
View File
@@ -1,57 +1,57 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
export type Theme = "light" | "dark" | "system";
interface ThemeValue {
theme: Theme;
resolved: "light" | "dark";
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeValue | null>(null);
function resolve(theme: Theme): "light" | "dark" {
if (theme !== "system") return theme;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
const saved = localStorage.getItem("lumaops.theme");
return saved === "light" || saved === "dark" ? saved : "system";
});
const [resolved, setResolved] = useState(() => resolve(theme));
useEffect(() => {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const update = () => setResolved(resolve(theme));
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, [theme]);
useEffect(() => {
document.documentElement.dataset.theme = resolved;
document.documentElement.style.colorScheme = resolved;
}, [resolved]);
const value = useMemo(
() => ({
theme,
resolved,
setTheme: (next: Theme) => {
localStorage.setItem("lumaops.theme", next);
setThemeState(next);
},
}),
[resolved, theme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeValue {
const value = useContext(ThemeContext);
if (!value) throw new Error("useTheme moet binnen ThemeProvider gebruikt worden");
return value;
}
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
export type Theme = "light" | "dark" | "system";
interface ThemeValue {
theme: Theme;
resolved: "light" | "dark";
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeValue | null>(null);
function resolve(theme: Theme): "light" | "dark" {
if (theme !== "system") return theme;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
const saved = localStorage.getItem("lumaops.theme");
return saved === "light" || saved === "dark" ? saved : "system";
});
const [resolved, setResolved] = useState(() => resolve(theme));
useEffect(() => {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const update = () => setResolved(resolve(theme));
update();
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, [theme]);
useEffect(() => {
document.documentElement.dataset.theme = resolved;
document.documentElement.style.colorScheme = resolved;
}, [resolved]);
const value = useMemo(
() => ({
theme,
resolved,
setTheme: (next: Theme) => {
localStorage.setItem("lumaops.theme", next);
setThemeState(next);
},
}),
[resolved, theme],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeValue {
const value = useContext(ThemeContext);
if (!value) throw new Error("useTheme moet binnen ThemeProvider gebruikt worden");
return value;
}
+31 -31
View File
@@ -1,31 +1,31 @@
export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
export function formatDate(value?: string | null): string {
if (!value) return "—";
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(
new Date(value),
);
}
export function colorToHex(color?: { red: number; green: number; blue: number }): string {
if (!color) return "#5660ff";
return `#${[color.red, color.green, color.blue]
.map((part) => part.toString(16).padStart(2, "0"))
.join("")}`;
}
export function hexToColor(hex: string): { red: number; green: number; blue: number } {
const value = hex.replace("#", "");
return {
red: Number.parseInt(value.slice(0, 2), 16),
green: Number.parseInt(value.slice(2, 4), 16),
blue: Number.parseInt(value.slice(4, 6), 16),
};
}
export function formValue(data: FormData, key: string): string {
const value = data.get(key);
return typeof value === "string" ? value : "";
}
export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(" ");
}
export function formatDate(value?: string | null): string {
if (!value) return "—";
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(
new Date(value),
);
}
export function colorToHex(color?: { red: number; green: number; blue: number }): string {
if (!color) return "#5660ff";
return `#${[color.red, color.green, color.blue]
.map((part) => part.toString(16).padStart(2, "0"))
.join("")}`;
}
export function hexToColor(hex: string): { red: number; green: number; blue: number } {
const value = hex.replace("#", "");
return {
red: Number.parseInt(value.slice(0, 2), 16),
green: Number.parseInt(value.slice(2, 4), 16),
blue: Number.parseInt(value.slice(4, 6), 16),
};
}
export function formValue(data: FormData, key: string): string {
const value = data.get(key);
return typeof value === "string" ? value : "";
}
+33 -33
View File
@@ -1,33 +1,33 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import { ToastProvider } from "./components/Toast";
import { I18nProvider } from "./lib/i18n";
import { ThemeProvider } from "./lib/theme";
import "./styles.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 10_000, retry: 1, refetchOnWindowFocus: false },
mutations: { retry: 0 },
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<I18nProvider>
<ToastProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</ToastProvider>
</I18nProvider>
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,
);
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { App } from "./App";
import { ToastProvider } from "./components/Toast";
import { I18nProvider } from "./lib/i18n";
import { ThemeProvider } from "./lib/theme";
import "./styles.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 10_000, retry: 1, refetchOnWindowFocus: false },
mutations: { retry: 0 },
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<I18nProvider>
<ToastProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</ToastProvider>
</I18nProvider>
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,
);
+13 -13
View File
@@ -1,13 +1,13 @@
import { BookOpen, Box, Code2, GitBranch, Github, Lightbulb, ShieldCheck } from "lucide-react";
import { Card, CardHeader, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
export function AboutPage() {
const { text } = useI18n();
return <>
<PageHeader eyebrow={text("Lokaal · open source · hardware-first", "Local · open source · hardware-first")} title={text("Over LumaOps", "About LumaOps")} description={text("Een persoonlijk beheerplatform dat OpenRGB als echte hardware-engine behoudt.", "A personal control platform that keeps OpenRGB as its real hardware engine.")} />
<div className="about-hero"><span><Lightbulb size={34} /></span><div><h2>LumaOps 0.1.0</h2><p>Gebouwd rond OpenRGB 1.0rc3, SDK protocol 5 en Plugin API 4.</p></div></div>
<div className="grid grid--cards"><Card><CardHeader title="Architectuur" /><ul className="icon-list"><li><Box size={16} /> Eén productiecontainer</li><li><Code2 size={16} /> FastAPI + React + TypeScript</li><li><GitBranch size={16} /> OpenRGB-bron als onderhoudbare upstream</li></ul></Card><Card><CardHeader title="Licentie" /><ul className="icon-list"><li><ShieldCheck size={16} /> GPL-2.0-or-later gecombineerd werk</li><li><Github size={16} /> Bron en buildinstructies mee distribueren</li><li><BookOpen size={16} /> Bestaande copyright- en SPDX-headers behouden</li></ul></Card></div>
<Card className="credits"><CardHeader title="Dank aan OpenRGB" description="LumaOps vervangt OpenRGB niet. De volwassen controller- en detectorlaag blijft verantwoordelijk voor hardwarecommunicatie." /><p>De LumaOps-servicelaag spreekt uitsluitend via de lokale SDK-v5-grens, met aanvullende identiteit-, capability- en veiligheidscontroles.</p></Card>
</>;
}
import { BookOpen, Box, Code2, GitBranch, Github, Lightbulb, ShieldCheck } from "lucide-react";
import { Card, CardHeader, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
export function AboutPage() {
const { text } = useI18n();
return <>
<PageHeader eyebrow={text("Lokaal · open source · hardware-first", "Local · open source · hardware-first")} title={text("Over LumaOps", "About LumaOps")} description={text("Een persoonlijk beheerplatform dat OpenRGB als echte hardware-engine behoudt.", "A personal control platform that keeps OpenRGB as its real hardware engine.")} />
<div className="about-hero"><span><Lightbulb size={34} /></span><div><h2>LumaOps 0.1.0</h2><p>Gebouwd rond OpenRGB 1.0rc3, SDK protocol 5 en Plugin API 4.</p></div></div>
<div className="grid grid--cards"><Card><CardHeader title="Architectuur" /><ul className="icon-list"><li><Box size={16} /> Eén productiecontainer</li><li><Code2 size={16} /> FastAPI + React + TypeScript</li><li><GitBranch size={16} /> OpenRGB-bron als onderhoudbare upstream</li></ul></Card><Card><CardHeader title="Licentie" /><ul className="icon-list"><li><ShieldCheck size={16} /> GPL-2.0-or-later gecombineerd werk</li><li><Github size={16} /> Bron en buildinstructies mee distribueren</li><li><BookOpen size={16} /> Bestaande copyright- en SPDX-headers behouden</li></ul></Card></div>
<Card className="credits"><CardHeader title="Dank aan OpenRGB" description="LumaOps vervangt OpenRGB niet. De volwassen controller- en detectorlaag blijft verantwoordelijk voor hardwarecommunicatie." /><p>De LumaOps-servicelaag spreekt uitsluitend via de lokale SDK-v5-grens, met aanvullende identiteit-, capability- en veiligheidscontroles.</p></Card>
</>;
}
+26 -26
View File
@@ -1,26 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import { Activity, ClipboardCheck, Search } from "lucide-react";
import { useState } from "react";
import { api } from "../api/client";
import type { Activity as ActivityRecord, Page } from "../api/types";
import { Badge, Card, EmptyState, Input, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formatDate } from "../lib/utils";
interface AuditRecord { id: string; actor: string; action: string; resource_type: string; resource_id: string | null; outcome: string; created_at: string; request_id: string }
export function ActivityPage({ mode }: { mode: "activity" | "audit" }) {
const { text } = useI18n();
const [search, setSearch] = useState("");
const query = useQuery({ queryKey: [mode], queryFn: () => api<Page<ActivityRecord | AuditRecord>>(`/api/v1/${mode}?limit=300`) });
const filtered = query.data?.items.filter((record) => JSON.stringify(record).toLowerCase().includes(search.toLowerCase())) ?? [];
const isAudit = mode === "audit";
return <>
<PageHeader eyebrow={isAudit ? text("Onveranderbare handelingenhistoriek", "Immutable action history") : text("Systeemgebeurtenissen", "System events")} title={isAudit ? text("Auditlog", "Audit log") : text("Activiteit", "Activity")} description={isAudit ? text("Wie wijzigde wat, op welk doel en met welk resultaat.", "Who changed what, on which target, and with which result.") : text("Recente opdrachten, discovery, automations en systeemmeldingen.", "Recent commands, discovery, automations and system messages.")} />
<div className="toolbar"><div className="search"><Search size={17} /><Input aria-label="Log doorzoeken" placeholder="Doorzoek gebeurtenissen…" value={search} onChange={(event) => setSearch(event.target.value)} /></div></div>
<Card>{filtered.length ? <div className="event-list">{filtered.map((record) => isAudit ? <AuditRow key={record.id} record={record as AuditRecord} /> : <ActivityRow key={record.id} record={record as ActivityRecord} />)}</div> : <EmptyState icon={isAudit ? ClipboardCheck : Activity} title="Geen gebeurtenissen" description="Nieuwe acties verschijnen hier automatisch." />}</Card>
</>;
}
function ActivityRow({ record }: { record: ActivityRecord }) { return <div className="event-row"><span className={`event-row__icon event-row__icon--${record.severity}`}><Activity size={16} /></span><div><strong>{record.title}</strong><p>{record.message}</p></div><div><Badge>{record.category}</Badge><span>{formatDate(record.created_at)}</span></div></div>; }
function AuditRow({ record }: { record: AuditRecord }) { return <div className="event-row"><span className={`event-row__icon event-row__icon--${record.outcome}`}><ClipboardCheck size={16} /></span><div><strong>{record.action}</strong><p>{record.actor} · {record.resource_type} {record.resource_id?.slice(0, 8) ?? ""}</p></div><div><Badge tone={record.outcome === "succeeded" ? "success" : "danger"}>{record.outcome}</Badge><span>{formatDate(record.created_at)}</span><small title={record.request_id}>req {record.request_id.slice(0, 8)}</small></div></div>; }
import { useQuery } from "@tanstack/react-query";
import { Activity, ClipboardCheck, Search } from "lucide-react";
import { useState } from "react";
import { api } from "../api/client";
import type { Activity as ActivityRecord, Page } from "../api/types";
import { Badge, Card, EmptyState, Input, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formatDate } from "../lib/utils";
interface AuditRecord { id: string; actor: string; action: string; resource_type: string; resource_id: string | null; outcome: string; created_at: string; request_id: string }
export function ActivityPage({ mode }: { mode: "activity" | "audit" }) {
const { text } = useI18n();
const [search, setSearch] = useState("");
const query = useQuery({ queryKey: [mode], queryFn: () => api<Page<ActivityRecord | AuditRecord>>(`/api/v1/${mode}?limit=300`) });
const filtered = query.data?.items.filter((record) => JSON.stringify(record).toLowerCase().includes(search.toLowerCase())) ?? [];
const isAudit = mode === "audit";
return <>
<PageHeader eyebrow={isAudit ? text("Onveranderbare handelingenhistoriek", "Immutable action history") : text("Systeemgebeurtenissen", "System events")} title={isAudit ? text("Auditlog", "Audit log") : text("Activiteit", "Activity")} description={isAudit ? text("Wie wijzigde wat, op welk doel en met welk resultaat.", "Who changed what, on which target, and with which result.") : text("Recente opdrachten, discovery, automations en systeemmeldingen.", "Recent commands, discovery, automations and system messages.")} />
<div className="toolbar"><div className="search"><Search size={17} /><Input aria-label="Log doorzoeken" placeholder="Doorzoek gebeurtenissen…" value={search} onChange={(event) => setSearch(event.target.value)} /></div></div>
<Card>{filtered.length ? <div className="event-list">{filtered.map((record) => isAudit ? <AuditRow key={record.id} record={record as AuditRecord} /> : <ActivityRow key={record.id} record={record as ActivityRecord} />)}</div> : <EmptyState icon={isAudit ? ClipboardCheck : Activity} title="Geen gebeurtenissen" description="Nieuwe acties verschijnen hier automatisch." />}</Card>
</>;
}
function ActivityRow({ record }: { record: ActivityRecord }) { return <div className="event-row"><span className={`event-row__icon event-row__icon--${record.severity}`}><Activity size={16} /></span><div><strong>{record.title}</strong><p>{record.message}</p></div><div><Badge>{record.category}</Badge><span>{formatDate(record.created_at)}</span></div></div>; }
function AuditRow({ record }: { record: AuditRecord }) { return <div className="event-row"><span className={`event-row__icon event-row__icon--${record.outcome}`}><ClipboardCheck size={16} /></span><div><strong>{record.action}</strong><p>{record.actor} · {record.resource_type} {record.resource_id?.slice(0, 8) ?? ""}</p></div><div><Badge tone={record.outcome === "succeeded" ? "success" : "danger"}>{record.outcome}</Badge><span>{formatDate(record.created_at)}</span><small title={record.request_id}>req {record.request_id.slice(0, 8)}</small></div></div>; }
@@ -1,48 +1,48 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { jsonResponse, renderApp, requestJson, requestUrl } from "../test/render";
import { AutomationsPage } from "./AutomationsPage";
const automation = {
id: "automation-1",
name: "Avond",
description: "Rustige verlichting",
enabled: true,
trigger: { type: "time", at: "20:00", weekdays: [0, 1, 2, 3, 4] },
actions: [{ type: "scene", scene_id: "scene-1" }],
timezone: "Europe/Brussels",
cooldown_seconds: 60,
conflict_key: "woonkamer",
last_run_at: null,
next_run_at: "2026-07-15T18:00:00Z",
last_error: null,
};
describe("AutomationsPage", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const url = requestUrl(input);
if (url.includes("/runs")) return Promise.resolve(jsonResponse({ items: [{ id: "run-1", automation_id: automation.id, status: "succeeded", trigger: { type: "manual" }, result: {}, error: null, started_at: "2026-07-15T12:00:00Z", finished_at: "2026-07-15T12:00:01Z" }], total: 1, limit: 25, offset: 0 }));
if (url.endsWith(`/automations/${automation.id}`) && init?.method === "PUT") return Promise.resolve(jsonResponse({ ...automation, enabled: false }));
if (url.includes("/automations")) return Promise.resolve(jsonResponse({ items: [automation], total: 1, limit: 200, offset: 0 }));
if (url.includes("/scenes")) return Promise.resolve(jsonResponse({ items: [{ id: "scene-1", name: "Avondrust" }], total: 1, limit: 200, offset: 0 }));
return Promise.resolve(jsonResponse({}));
}));
});
it("toggles an automation and exposes its run history", async () => {
const user = userEvent.setup();
renderApp(<AutomationsPage />, "/automations");
expect(await screen.findByText("Avond")).toBeInTheDocument();
await user.click(screen.getByTitle("Uitschakelen"));
await waitFor(() => {
const update = vi.mocked(fetch).mock.calls.find(([input, init]) => requestUrl(input).endsWith(`/automations/${automation.id}`) && init?.method === "PUT");
expect(update).toBeDefined();
expect(requestJson(update?.[1])).toMatchObject({ enabled: false, conflict_key: "woonkamer" });
});
await user.click(screen.getByTitle("Historiek"));
expect(await screen.findByText("succeeded")).toBeInTheDocument();
expect(screen.getByText("manual")).toBeInTheDocument();
});
});
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { jsonResponse, renderApp, requestJson, requestUrl } from "../test/render";
import { AutomationsPage } from "./AutomationsPage";
const automation = {
id: "automation-1",
name: "Avond",
description: "Rustige verlichting",
enabled: true,
trigger: { type: "time", at: "20:00", weekdays: [0, 1, 2, 3, 4] },
actions: [{ type: "scene", scene_id: "scene-1" }],
timezone: "Europe/Brussels",
cooldown_seconds: 60,
conflict_key: "woonkamer",
last_run_at: null,
next_run_at: "2026-07-15T18:00:00Z",
last_error: null,
};
describe("AutomationsPage", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const url = requestUrl(input);
if (url.includes("/runs")) return Promise.resolve(jsonResponse({ items: [{ id: "run-1", automation_id: automation.id, status: "succeeded", trigger: { type: "manual" }, result: {}, error: null, started_at: "2026-07-15T12:00:00Z", finished_at: "2026-07-15T12:00:01Z" }], total: 1, limit: 25, offset: 0 }));
if (url.endsWith(`/automations/${automation.id}`) && init?.method === "PUT") return Promise.resolve(jsonResponse({ ...automation, enabled: false }));
if (url.includes("/automations")) return Promise.resolve(jsonResponse({ items: [automation], total: 1, limit: 200, offset: 0 }));
if (url.includes("/scenes")) return Promise.resolve(jsonResponse({ items: [{ id: "scene-1", name: "Avondrust" }], total: 1, limit: 200, offset: 0 }));
return Promise.resolve(jsonResponse({}));
}));
});
it("toggles an automation and exposes its run history", async () => {
const user = userEvent.setup();
renderApp(<AutomationsPage />, "/automations");
expect(await screen.findByText("Avond")).toBeInTheDocument();
await user.click(screen.getByTitle("Uitschakelen"));
await waitFor(() => {
const update = vi.mocked(fetch).mock.calls.find(([input, init]) => requestUrl(input).endsWith(`/automations/${automation.id}`) && init?.method === "PUT");
expect(update).toBeDefined();
expect(requestJson(update?.[1])).toMatchObject({ enabled: false, conflict_key: "woonkamer" });
});
await user.click(screen.getByTitle("Historiek"));
expect(await screen.findByText("succeeded")).toBeInTheDocument();
expect(screen.getByText("manual")).toBeInTheDocument();
});
});
+77 -77
View File
@@ -1,77 +1,77 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CalendarClock, History, Pause, Pencil, Play, Plus, Timer, Trash2, X, Zap } from "lucide-react";
import { useState } from "react";
import { api } from "../api/client";
import type { Automation, AutomationRun, Page, Scene } from "../api/types";
import { AutomationEditor, type AutomationPayload } from "../components/AutomationEditor";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, CardHeader, EmptyState, ErrorPanel, LoadingGrid, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formatDate } from "../lib/utils";
export function AutomationsPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const [editor, setEditor] = useState<Automation | "new" | null>(null);
const [deleteAutomation, setDeleteAutomation] = useState<Automation | null>(null);
const [historyAutomation, setHistoryAutomation] = useState<Automation | null>(null);
const automations = useQuery({ queryKey: ["automations"], queryFn: () => api<Page<Automation>>("/api/v1/automations?limit=200") });
const scenes = useQuery({ queryKey: ["scenes"], queryFn: () => api<Page<Scene>>("/api/v1/scenes?limit=200") });
const history = useQuery({ queryKey: ["automation-runs", historyAutomation?.id], queryFn: () => api<Page<AutomationRun>>(`/api/v1/automations/${historyAutomation?.id}/runs?limit=25`), enabled: Boolean(historyAutomation) });
const refresh = () => void queryClient.invalidateQueries({ queryKey: ["automations"] });
const save = useMutation({
mutationFn: ({ id, payload }: { id?: string; payload: AutomationPayload }) => api<Automation>(`/api/v1/automations${id ? `/${id}` : ""}`, { method: id ? "PUT" : "POST", body: JSON.stringify(payload) }),
onSuccess: (_, variables) => { notify(variables.id ? text("Automation bijgewerkt.", "Automation updated.") : text("Automation aangemaakt.", "Automation created.")); setEditor(null); refresh(); },
onError: (error) => notify(error instanceof Error ? error.message : text("Opslaan mislukt.", "Save failed."), "danger"),
});
const run = useMutation({
mutationFn: (id: string) => api<{ status: string }>(`/api/v1/automations/${id}/run`, { method: "POST" }),
onSuccess: (result) => { notify(result.status === "succeeded" ? text("Automation uitgevoerd.", "Automation executed.") : text(`Uitvoering: ${result.status}`, `Run: ${result.status}`), result.status === "failed" ? "danger" : "success"); refresh(); void queryClient.invalidateQueries({ queryKey: ["automation-runs"] }); },
onError: (error) => notify(error instanceof Error ? error.message : text("Uitvoering mislukt.", "Run failed."), "danger"),
});
const toggle = useMutation({
mutationFn: (automation: Automation) => api<Automation>(`/api/v1/automations/${automation.id}`, { method: "PUT", body: JSON.stringify(toPayload(automation, !automation.enabled)) }),
onSuccess: (automation) => { notify(automation.enabled ? text("Automation ingeschakeld.", "Automation enabled.") : text("Automation uitgeschakeld.", "Automation disabled.")); refresh(); },
onError: (error) => notify(error instanceof Error ? error.message : text("Status wijzigen mislukt.", "Status update failed."), "danger"),
});
const remove = useMutation({
mutationFn: (id: string) => api(`/api/v1/automations/${id}`, { method: "DELETE" }),
onSuccess: () => { notify(text("Automation verwijderd.", "Automation deleted.")); setDeleteAutomation(null); refresh(); },
onError: (error) => notify(error instanceof Error ? error.message : text("Verwijderen mislukt.", "Delete failed."), "danger"),
});
const error = automations.error ?? scenes.error;
return <>
<PageHeader eyebrow={text("Regelengine", "Rules engine")} title="Automations" description={text("Plan scènes per weekdag met cooldown, conflictpreventie en volledige uitvoeringshistoriek.", "Schedule scenes by weekday with cooldown, conflict prevention, and complete run history.")} actions={<Button onClick={() => setEditor("new")}><Plus size={16} /> {text("Nieuwe automation", "New automation")}</Button>} />
{editor ? <AutomationEditor key={editor === "new" ? "new" : editor.id} automation={editor === "new" ? undefined : editor} scenes={scenes.data} busy={save.isPending} onClose={() => setEditor(null)} onSubmit={(payload) => save.mutate({ id: editor === "new" ? undefined : editor.id, payload })} /> : null}
{historyAutomation ? <Card className="resource-editor"><CardHeader title={text(`Uitvoeringshistoriek · ${historyAutomation.name}`, `Run history · ${historyAutomation.name}`)} description={text("De 25 meest recente handmatige en geplande uitvoeringen.", "The 25 most recent manual and scheduled runs.")} action={<Button variant="ghost" onClick={() => setHistoryAutomation(null)}><X size={16} /> {text("Sluiten", "Close")}</Button>} />{history.isLoading ? <LoadingGrid count={2} /> : history.error ? <ErrorPanel error={history.error} retry={() => void history.refetch()} /> : !history.data?.items.length ? <EmptyState icon={History} title={text("Nog geen uitvoeringen", "No runs yet")} description={text("Test de automation om de eerste uitvoering vast te leggen.", "Test the automation to record its first run.")} /> : <div className="run-list">{history.data.items.map((item) => <div className="run-row" key={item.id}><Badge tone={item.status === "succeeded" ? "success" : item.status === "failed" ? "danger" : "neutral"}>{item.status}</Badge><span>{formatDate(item.started_at)}</span><span>{typeof item.trigger.type === "string" ? item.trigger.type : "manual"}</span><span className="run-row__error">{item.error || "—"}</span></div>)}</div>}</Card> : null}
{error ? <ErrorPanel error={error} retry={() => void Promise.all([automations.refetch(), scenes.refetch()])} /> : automations.isLoading ? <LoadingGrid /> : !automations.data?.items.length ? <EmptyState icon={CalendarClock} title={text("Nog geen automations", "No automations yet")} description={text("Plan een scène op een tijdstip en geselecteerde weekdagen.", "Schedule a scene at a time on selected weekdays.")} action={<Button onClick={() => setEditor("new")}>{text("Eerste automation maken", "Create first automation")}</Button>} /> : <div className="automation-list">{automations.data.items.map((automation) => <Card key={automation.id} className="automation-row"><span className="automation-row__icon"><Zap size={20} /></span><div className="automation-row__main"><div><h2>{automation.name}</h2><p><Timer size={14} /> {typeof automation.trigger.at === "string" ? automation.trigger.at : text("handmatig", "manual")} · {weekdaySummary(automation.trigger.weekdays, text)}</p></div><div className="automation-row__meta"><Badge tone={automation.enabled ? "success" : "neutral"}>{automation.enabled ? text("Actief", "Enabled") : text("Uitgeschakeld", "Disabled")}</Badge><span>{text("Volgende", "Next")}: {formatDate(automation.next_run_at)}</span><span>{text("Laatste", "Last")}: {formatDate(automation.last_run_at)}</span>{automation.last_error ? <Badge tone="danger">{automation.last_error}</Badge> : null}</div></div><div className="automation-row__actions"><Button variant="secondary" onClick={() => run.mutate(automation.id)} busy={run.isPending}><Play size={15} /> {text("Test", "Test")}</Button><Button variant="ghost" title={automation.enabled ? text("Uitschakelen", "Disable") : text("Inschakelen", "Enable")} onClick={() => toggle.mutate(automation)} busy={toggle.isPending}>{automation.enabled ? <Pause size={15} /> : <Play size={15} />}</Button><Button variant="ghost" title={text("Historiek", "History")} onClick={() => setHistoryAutomation(automation)}><History size={15} /></Button><Button variant="ghost" title={text("Bewerken", "Edit")} onClick={() => setEditor(automation)}><Pencil size={15} /></Button><Button variant="ghost" title={text("Verwijderen", "Delete")} onClick={() => setDeleteAutomation(automation)}><Trash2 size={15} /></Button></div></Card>)}</div>}
<ConfirmDialog open={Boolean(deleteAutomation)} title={text("Automation verwijderen?", "Delete automation?")} description={text(`“${deleteAutomation?.name ?? ""}” wordt uitgeschakeld en verwijderd. Bestaande uitvoeringshistoriek blijft bewaard.`, `“${deleteAutomation?.name ?? ""}” will be disabled and deleted. Existing run history is preserved.`)} confirmLabel={text("Verwijderen", "Delete")} danger busy={remove.isPending} onClose={() => setDeleteAutomation(null)} onConfirm={() => deleteAutomation && remove.mutate(deleteAutomation.id)} />
</>;
}
function toPayload(automation: Automation, enabled: boolean): AutomationPayload {
const sceneId = automation.actions[0]?.scene_id;
return {
name: automation.name,
description: automation.description,
enabled,
trigger: {
type: "time",
at: typeof automation.trigger.at === "string" ? automation.trigger.at : "20:00",
weekdays: Array.isArray(automation.trigger.weekdays) ? automation.trigger.weekdays.map(Number) : [0, 1, 2, 3, 4, 5, 6],
},
actions: [{ type: "scene", scene_id: typeof sceneId === "string" ? sceneId : "" }],
timezone: automation.timezone,
cooldown_seconds: automation.cooldown_seconds,
conflict_key: automation.conflict_key,
};
}
function weekdaySummary(value: unknown, text: (dutch: string, english: string) => string): string {
if (!Array.isArray(value) || value.length === 7) return text("elke dag", "every day");
const labels = text("ma,di,wo,do,vr,za,zo", "Mon,Tue,Wed,Thu,Fri,Sat,Sun").split(",");
return value.map(Number).map((day) => labels[day]).filter(Boolean).join(", ");
}
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CalendarClock, History, Pause, Pencil, Play, Plus, Timer, Trash2, X, Zap } from "lucide-react";
import { useState } from "react";
import { api } from "../api/client";
import type { Automation, AutomationRun, Page, Scene } from "../api/types";
import { AutomationEditor, type AutomationPayload } from "../components/AutomationEditor";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, CardHeader, EmptyState, ErrorPanel, LoadingGrid, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formatDate } from "../lib/utils";
export function AutomationsPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const [editor, setEditor] = useState<Automation | "new" | null>(null);
const [deleteAutomation, setDeleteAutomation] = useState<Automation | null>(null);
const [historyAutomation, setHistoryAutomation] = useState<Automation | null>(null);
const automations = useQuery({ queryKey: ["automations"], queryFn: () => api<Page<Automation>>("/api/v1/automations?limit=200") });
const scenes = useQuery({ queryKey: ["scenes"], queryFn: () => api<Page<Scene>>("/api/v1/scenes?limit=200") });
const history = useQuery({ queryKey: ["automation-runs", historyAutomation?.id], queryFn: () => api<Page<AutomationRun>>(`/api/v1/automations/${historyAutomation?.id}/runs?limit=25`), enabled: Boolean(historyAutomation) });
const refresh = () => void queryClient.invalidateQueries({ queryKey: ["automations"] });
const save = useMutation({
mutationFn: ({ id, payload }: { id?: string; payload: AutomationPayload }) => api<Automation>(`/api/v1/automations${id ? `/${id}` : ""}`, { method: id ? "PUT" : "POST", body: JSON.stringify(payload) }),
onSuccess: (_, variables) => { notify(variables.id ? text("Automation bijgewerkt.", "Automation updated.") : text("Automation aangemaakt.", "Automation created.")); setEditor(null); refresh(); },
onError: (error) => notify(error instanceof Error ? error.message : text("Opslaan mislukt.", "Save failed."), "danger"),
});
const run = useMutation({
mutationFn: (id: string) => api<{ status: string }>(`/api/v1/automations/${id}/run`, { method: "POST" }),
onSuccess: (result) => { notify(result.status === "succeeded" ? text("Automation uitgevoerd.", "Automation executed.") : text(`Uitvoering: ${result.status}`, `Run: ${result.status}`), result.status === "failed" ? "danger" : "success"); refresh(); void queryClient.invalidateQueries({ queryKey: ["automation-runs"] }); },
onError: (error) => notify(error instanceof Error ? error.message : text("Uitvoering mislukt.", "Run failed."), "danger"),
});
const toggle = useMutation({
mutationFn: (automation: Automation) => api<Automation>(`/api/v1/automations/${automation.id}`, { method: "PUT", body: JSON.stringify(toPayload(automation, !automation.enabled)) }),
onSuccess: (automation) => { notify(automation.enabled ? text("Automation ingeschakeld.", "Automation enabled.") : text("Automation uitgeschakeld.", "Automation disabled.")); refresh(); },
onError: (error) => notify(error instanceof Error ? error.message : text("Status wijzigen mislukt.", "Status update failed."), "danger"),
});
const remove = useMutation({
mutationFn: (id: string) => api(`/api/v1/automations/${id}`, { method: "DELETE" }),
onSuccess: () => { notify(text("Automation verwijderd.", "Automation deleted.")); setDeleteAutomation(null); refresh(); },
onError: (error) => notify(error instanceof Error ? error.message : text("Verwijderen mislukt.", "Delete failed."), "danger"),
});
const error = automations.error ?? scenes.error;
return <>
<PageHeader eyebrow={text("Regelengine", "Rules engine")} title="Automations" description={text("Plan scènes per weekdag met cooldown, conflictpreventie en volledige uitvoeringshistoriek.", "Schedule scenes by weekday with cooldown, conflict prevention, and complete run history.")} actions={<Button onClick={() => setEditor("new")}><Plus size={16} /> {text("Nieuwe automation", "New automation")}</Button>} />
{editor ? <AutomationEditor key={editor === "new" ? "new" : editor.id} automation={editor === "new" ? undefined : editor} scenes={scenes.data} busy={save.isPending} onClose={() => setEditor(null)} onSubmit={(payload) => save.mutate({ id: editor === "new" ? undefined : editor.id, payload })} /> : null}
{historyAutomation ? <Card className="resource-editor"><CardHeader title={text(`Uitvoeringshistoriek · ${historyAutomation.name}`, `Run history · ${historyAutomation.name}`)} description={text("De 25 meest recente handmatige en geplande uitvoeringen.", "The 25 most recent manual and scheduled runs.")} action={<Button variant="ghost" onClick={() => setHistoryAutomation(null)}><X size={16} /> {text("Sluiten", "Close")}</Button>} />{history.isLoading ? <LoadingGrid count={2} /> : history.error ? <ErrorPanel error={history.error} retry={() => void history.refetch()} /> : !history.data?.items.length ? <EmptyState icon={History} title={text("Nog geen uitvoeringen", "No runs yet")} description={text("Test de automation om de eerste uitvoering vast te leggen.", "Test the automation to record its first run.")} /> : <div className="run-list">{history.data.items.map((item) => <div className="run-row" key={item.id}><Badge tone={item.status === "succeeded" ? "success" : item.status === "failed" ? "danger" : "neutral"}>{item.status}</Badge><span>{formatDate(item.started_at)}</span><span>{typeof item.trigger.type === "string" ? item.trigger.type : "manual"}</span><span className="run-row__error">{item.error || "—"}</span></div>)}</div>}</Card> : null}
{error ? <ErrorPanel error={error} retry={() => void Promise.all([automations.refetch(), scenes.refetch()])} /> : automations.isLoading ? <LoadingGrid /> : !automations.data?.items.length ? <EmptyState icon={CalendarClock} title={text("Nog geen automations", "No automations yet")} description={text("Plan een scène op een tijdstip en geselecteerde weekdagen.", "Schedule a scene at a time on selected weekdays.")} action={<Button onClick={() => setEditor("new")}>{text("Eerste automation maken", "Create first automation")}</Button>} /> : <div className="automation-list">{automations.data.items.map((automation) => <Card key={automation.id} className="automation-row"><span className="automation-row__icon"><Zap size={20} /></span><div className="automation-row__main"><div><h2>{automation.name}</h2><p><Timer size={14} /> {typeof automation.trigger.at === "string" ? automation.trigger.at : text("handmatig", "manual")} · {weekdaySummary(automation.trigger.weekdays, text)}</p></div><div className="automation-row__meta"><Badge tone={automation.enabled ? "success" : "neutral"}>{automation.enabled ? text("Actief", "Enabled") : text("Uitgeschakeld", "Disabled")}</Badge><span>{text("Volgende", "Next")}: {formatDate(automation.next_run_at)}</span><span>{text("Laatste", "Last")}: {formatDate(automation.last_run_at)}</span>{automation.last_error ? <Badge tone="danger">{automation.last_error}</Badge> : null}</div></div><div className="automation-row__actions"><Button variant="secondary" onClick={() => run.mutate(automation.id)} busy={run.isPending}><Play size={15} /> {text("Test", "Test")}</Button><Button variant="ghost" title={automation.enabled ? text("Uitschakelen", "Disable") : text("Inschakelen", "Enable")} onClick={() => toggle.mutate(automation)} busy={toggle.isPending}>{automation.enabled ? <Pause size={15} /> : <Play size={15} />}</Button><Button variant="ghost" title={text("Historiek", "History")} onClick={() => setHistoryAutomation(automation)}><History size={15} /></Button><Button variant="ghost" title={text("Bewerken", "Edit")} onClick={() => setEditor(automation)}><Pencil size={15} /></Button><Button variant="ghost" title={text("Verwijderen", "Delete")} onClick={() => setDeleteAutomation(automation)}><Trash2 size={15} /></Button></div></Card>)}</div>}
<ConfirmDialog open={Boolean(deleteAutomation)} title={text("Automation verwijderen?", "Delete automation?")} description={text(`“${deleteAutomation?.name ?? ""}” wordt uitgeschakeld en verwijderd. Bestaande uitvoeringshistoriek blijft bewaard.`, `“${deleteAutomation?.name ?? ""}” will be disabled and deleted. Existing run history is preserved.`)} confirmLabel={text("Verwijderen", "Delete")} danger busy={remove.isPending} onClose={() => setDeleteAutomation(null)} onConfirm={() => deleteAutomation && remove.mutate(deleteAutomation.id)} />
</>;
}
function toPayload(automation: Automation, enabled: boolean): AutomationPayload {
const sceneId = automation.actions[0]?.scene_id;
return {
name: automation.name,
description: automation.description,
enabled,
trigger: {
type: "time",
at: typeof automation.trigger.at === "string" ? automation.trigger.at : "20:00",
weekdays: Array.isArray(automation.trigger.weekdays) ? automation.trigger.weekdays.map(Number) : [0, 1, 2, 3, 4, 5, 6],
},
actions: [{ type: "scene", scene_id: typeof sceneId === "string" ? sceneId : "" }],
timezone: automation.timezone,
cooldown_seconds: automation.cooldown_seconds,
conflict_key: automation.conflict_key,
};
}
function weekdaySummary(value: unknown, text: (dutch: string, english: string) => string): string {
if (!Array.isArray(value) || value.length === 7) return text("elke dag", "every day");
const labels = text("ma,di,wo,do,vr,za,zo", "Mon,Tue,Wed,Thu,Fri,Sat,Sun").split(",");
return value.map(Number).map((day) => labels[day]).filter(Boolean).join(", ");
}
+26 -26
View File
@@ -1,26 +1,26 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Archive, ArchiveRestore, DatabaseBackup, Plus } from "lucide-react";
import { useState } from "react";
import { api } from "../api/client";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, CardHeader, EmptyState, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
interface Backup { name: string; size: number; modified_at: number }
export function BackupsPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const [restore, setRestore] = useState<string | null>(null);
const query = useQuery({ queryKey: ["backups"], queryFn: () => api<Backup[]>("/api/v1/backups") });
const create = useMutation({ mutationFn: () => api<Backup>("/api/v1/backups", { method: "POST" }), onSuccess: () => { notify("Databaseback-up aangemaakt."); void queryClient.invalidateQueries({ queryKey: ["backups"] }); } });
const restoreMutation = useMutation({ mutationFn: (name: string) => api<{ safety_backup: string }>("/api/v1/backups/restore", { method: "POST", body: JSON.stringify({ name }) }), onSuccess: (result) => { notify(`Hersteld. Veiligheidskopie: ${result.safety_backup}`); setRestore(null); void queryClient.invalidateQueries(); }, onError: (error) => notify(error instanceof Error ? error.message : "Herstel mislukt.", "danger") });
return <>
<PageHeader eyebrow={text("Appdata-bescherming", "Appdata protection")} title={text("Back-up & herstel", "Backup & restore")} description={text("SQLite-back-ups zijn atomair; bewaar ook /config/openrgb en secret.key buiten de server.", "SQLite backups are atomic; also store /config/openrgb and secret.key outside the server.")} actions={<Button onClick={() => create.mutate()} busy={create.isPending}><Plus size={16} /> {text("Nieuwe back-up", "New backup")}</Button>} />
<div className="alert alert--info"><DatabaseBackup size={20} /><div><strong>Een volledige back-up bestaat uit drie delen</strong><p>Database, /config/openrgb en /config/lumaops/secret.key. Zonder de sleutel zijn connectorsecrets niet herstelbaar.</p></div></div>
<Card><CardHeader title="Beheerde databaseback-ups" description="Voor destructieve migraties en restores wordt automatisch een extra veiligheidskopie gemaakt." />{query.data?.length ? <div className="backup-list">{query.data.map((backup) => <div className="backup-row" key={backup.name}><span><Archive size={19} /></span><div><strong>{backup.name}</strong><p>{new Intl.NumberFormat(undefined, { style: "unit", unit: "megabyte", maximumFractionDigits: 2 }).format(backup.size / 1_048_576)} · {new Date(backup.modified_at * 1000).toLocaleString()}</p></div><Badge>SQLite</Badge><Button variant="secondary" onClick={() => setRestore(backup.name)}><ArchiveRestore size={15} /> Herstellen</Button></div>)}</div> : <EmptyState icon={Archive} title="Nog geen back-ups" description="Maak een eerste herstelpunt voordat je hardware en scènes configureert." />}</Card>
<ConfirmDialog open={restore !== null} title="Database herstellen?" description={`Alle huidige LumaOps-data wordt vervangen door ${restore ?? "deze back-up"}. Eerst wordt automatisch een veiligheidskopie gemaakt.`} confirmLabel="Herstellen" danger onClose={() => setRestore(null)} onConfirm={() => restore && restoreMutation.mutate(restore)} />
</>;
}
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Archive, ArchiveRestore, DatabaseBackup, Plus } from "lucide-react";
import { useState } from "react";
import { api } from "../api/client";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, CardHeader, EmptyState, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
interface Backup { name: string; size: number; modified_at: number }
export function BackupsPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const [restore, setRestore] = useState<string | null>(null);
const query = useQuery({ queryKey: ["backups"], queryFn: () => api<Backup[]>("/api/v1/backups") });
const create = useMutation({ mutationFn: () => api<Backup>("/api/v1/backups", { method: "POST" }), onSuccess: () => { notify("Databaseback-up aangemaakt."); void queryClient.invalidateQueries({ queryKey: ["backups"] }); } });
const restoreMutation = useMutation({ mutationFn: (name: string) => api<{ safety_backup: string }>("/api/v1/backups/restore", { method: "POST", body: JSON.stringify({ name }) }), onSuccess: (result) => { notify(`Hersteld. Veiligheidskopie: ${result.safety_backup}`); setRestore(null); void queryClient.invalidateQueries(); }, onError: (error) => notify(error instanceof Error ? error.message : "Herstel mislukt.", "danger") });
return <>
<PageHeader eyebrow={text("Appdata-bescherming", "Appdata protection")} title={text("Back-up & herstel", "Backup & restore")} description={text("SQLite-back-ups zijn atomair; bewaar ook /config/openrgb en secret.key buiten de server.", "SQLite backups are atomic; also store /config/openrgb and secret.key outside the server.")} actions={<Button onClick={() => create.mutate()} busy={create.isPending}><Plus size={16} /> {text("Nieuwe back-up", "New backup")}</Button>} />
<div className="alert alert--info"><DatabaseBackup size={20} /><div><strong>Een volledige back-up bestaat uit drie delen</strong><p>Database, /config/openrgb en /config/lumaops/secret.key. Zonder de sleutel zijn connectorsecrets niet herstelbaar.</p></div></div>
<Card><CardHeader title="Beheerde databaseback-ups" description="Voor destructieve migraties en restores wordt automatisch een extra veiligheidskopie gemaakt." />{query.data?.length ? <div className="backup-list">{query.data.map((backup) => <div className="backup-row" key={backup.name}><span><Archive size={19} /></span><div><strong>{backup.name}</strong><p>{new Intl.NumberFormat(undefined, { style: "unit", unit: "megabyte", maximumFractionDigits: 2 }).format(backup.size / 1_048_576)} · {new Date(backup.modified_at * 1000).toLocaleString()}</p></div><Badge>SQLite</Badge><Button variant="secondary" onClick={() => setRestore(backup.name)}><ArchiveRestore size={15} /> Herstellen</Button></div>)}</div> : <EmptyState icon={Archive} title="Nog geen back-ups" description="Maak een eerste herstelpunt voordat je hardware en scènes configureert." />}</Card>
<ConfirmDialog open={restore !== null} title="Database herstellen?" description={`Alle huidige LumaOps-data wordt vervangen door ${restore ?? "deze back-up"}. Eerst wordt automatisch een veiligheidskopie gemaakt.`} confirmLabel="Herstellen" danger onClose={() => setRestore(null)} onConfirm={() => restore && restoreMutation.mutate(restore)} />
</>;
}
+21 -21
View File
@@ -1,21 +1,21 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { Cable, Check, PlugZap, RefreshCw, ShieldAlert } from "lucide-react";
import { api } from "../api/client";
import type { ComponentHealth } from "../api/types";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, CardHeader, LoadingGrid, PageHeader, StatusBadge } from "../components/ui";
import { useI18n } from "../lib/i18n";
interface Connector { id: string; kind: string; configuration_schema: { warning?: string }; health: ComponentHealth }
export function ConnectorsPage() {
const { text } = useI18n();
const { notify } = useToast();
const query = useQuery({ queryKey: ["connectors"], queryFn: () => api<Connector[]>("/api/v1/connectors") });
const test = useMutation({ mutationFn: (id: string) => api<ComponentHealth>(`/api/v1/connectors/${id}/test`, { method: "POST" }), onSuccess: (health) => notify(health.connected ? "Verbindingstest geslaagd." : health.message, health.connected ? "success" : "danger") });
return <>
<PageHeader eyebrow={text("Adapterlaag", "Adapter layer")} title={text("Connectoren", "Connectors")} description={text("Eén capabilitymodel voor OpenRGB en toekomstige WLED-, Home Assistant- en MQTT-integraties.", "One capability model for OpenRGB and future WLED, Home Assistant and MQTT integrations.")} />
{query.isLoading ? <LoadingGrid count={2} /> : <div className="grid grid--connectors">{query.data?.map((connector) => <Card key={connector.id} className="connector-card"><CardHeader title={connector.id === "openrgb-local" ? "OpenRGB Core" : connector.kind} description={connector.kind === "openrgb" ? "Primaire hardware-engine · SDK protocol 5" : "Expliciete testadapter"} action={<StatusBadge status={connector.health.status} />} /><div className="connector-hero"><span><PlugZap size={27} /></span><div><strong>{connector.health.connected ? "Verbonden" : "Niet verbonden"}</strong><p>{connector.health.message}</p></div></div><div className="connector-meta"><div><span>Type</span><strong>{connector.kind}</strong></div><div><span>Authenticatie</span><strong>{connector.kind === "openrgb" ? "Loopback" : "Niet vereist"}</strong></div></div>{connector.configuration_schema.warning ? <div className="mini-warning"><ShieldAlert size={15} />{connector.configuration_schema.warning}</div> : null}<Button variant="secondary" onClick={() => test.mutate(connector.id)} busy={test.isPending}><RefreshCw size={15} /> Verbinding testen</Button></Card>)}</div>}
<section className="section-block"><div className="section-heading"><div><h2>Uitbreidingsfase</h2><p>Connectorcontract is voorbereid; native integraties blijven standaard uit.</p></div></div><div className="grid grid--cards">{[["WLED", "JSON API, segmenten, effecten en presets"], ["Home Assistant", "REST, WebSocket en geselecteerde light-entiteiten"], ["MQTT", "Generieke toekomstige event- en statebridge"]].map(([name, description]) => <Card key={name} className="planned-connector"><span><Cable size={19} /></span><div><h3>{name}</h3><p>{description}</p></div><Badge><Check size={12} /> Architectuur klaar</Badge></Card>)}</div></section>
</>;
}
import { useMutation, useQuery } from "@tanstack/react-query";
import { Cable, Check, PlugZap, RefreshCw, ShieldAlert } from "lucide-react";
import { api } from "../api/client";
import type { ComponentHealth } from "../api/types";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, CardHeader, LoadingGrid, PageHeader, StatusBadge } from "../components/ui";
import { useI18n } from "../lib/i18n";
interface Connector { id: string; kind: string; configuration_schema: { warning?: string }; health: ComponentHealth }
export function ConnectorsPage() {
const { text } = useI18n();
const { notify } = useToast();
const query = useQuery({ queryKey: ["connectors"], queryFn: () => api<Connector[]>("/api/v1/connectors") });
const test = useMutation({ mutationFn: (id: string) => api<ComponentHealth>(`/api/v1/connectors/${id}/test`, { method: "POST" }), onSuccess: (health) => notify(health.connected ? "Verbindingstest geslaagd." : health.message, health.connected ? "success" : "danger") });
return <>
<PageHeader eyebrow={text("Adapterlaag", "Adapter layer")} title={text("Connectoren", "Connectors")} description={text("Eén capabilitymodel voor OpenRGB en toekomstige WLED-, Home Assistant- en MQTT-integraties.", "One capability model for OpenRGB and future WLED, Home Assistant and MQTT integrations.")} />
{query.isLoading ? <LoadingGrid count={2} /> : <div className="grid grid--connectors">{query.data?.map((connector) => <Card key={connector.id} className="connector-card"><CardHeader title={connector.id === "openrgb-local" ? "OpenRGB Core" : connector.kind} description={connector.kind === "openrgb" ? "Primaire hardware-engine · SDK protocol 5" : "Expliciete testadapter"} action={<StatusBadge status={connector.health.status} />} /><div className="connector-hero"><span><PlugZap size={27} /></span><div><strong>{connector.health.connected ? "Verbonden" : "Niet verbonden"}</strong><p>{connector.health.message}</p></div></div><div className="connector-meta"><div><span>Type</span><strong>{connector.kind}</strong></div><div><span>Authenticatie</span><strong>{connector.kind === "openrgb" ? "Loopback" : "Niet vereist"}</strong></div></div>{connector.configuration_schema.warning ? <div className="mini-warning"><ShieldAlert size={15} />{connector.configuration_schema.warning}</div> : null}<Button variant="secondary" onClick={() => test.mutate(connector.id)} busy={test.isPending}><RefreshCw size={15} /> Verbinding testen</Button></Card>)}</div>}
<section className="section-block"><div className="section-heading"><div><h2>Uitbreidingsfase</h2><p>Connectorcontract is voorbereid; native integraties blijven standaard uit.</p></div></div><div className="grid grid--cards">{[["WLED", "JSON API, segmenten, effecten en presets"], ["Home Assistant", "REST, WebSocket en geselecteerde light-entiteiten"], ["MQTT", "Generieke toekomstige event- en statebridge"]].map(([name, description]) => <Card key={name} className="planned-connector"><span><Cable size={19} /></span><div><h3>{name}</h3><p>{description}</p></div><Badge><Check size={12} /> Architectuur klaar</Badge></Card>)}</div></section>
</>;
}
@@ -1,232 +1,232 @@
import { fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { focusManager } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Route, Routes } from "react-router-dom";
import { DeviceDetailPage } from "./DeviceDetailPage";
import { jsonResponse, renderApp, requestJson, requestUrl } from "../test/render";
const device = {
id: "device-1", connector_id: "openrgb", external_id: "asus", fingerprint: "asus", source: "openrgb", device_type: "motherboard", owner: "openrgb",
name: "ASUS Aura Mainboard", alias: null, vendor: "ASUS", model: "Aura", serial: null, location: "HID: /dev/hidraw0",
ip_address: null, firmware_version: null, controller_index: 0,
capabilities: { power: true, restore: true, rgb: true, brightness: false, color_temperature: false, effect: true, speed: false,
direction: false, multiple_colors: true, per_zone: true, per_segment: false, per_led: true, profiles: false, readable_state: true,
max_leds: 126, min_brightness: 0, max_brightness: 100, min_speed: null, max_speed: null },
state: { power: true, colors: [{ red: 32, green: 64, blue: 128 }], mode: "Direct", mode_index: 0 },
zones: [
{ index: 0, name: "Aura Mainboard", type: 1, led_count: 6, leds_min: 6, leds_max: 6 },
{ index: 1, name: "Aura Addressable 1", type: 1, led_count: 0, leds_min: 0, leds_max: 120 },
],
modes: [
{ index: 0, name: "Direct", flags: 32, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
{ index: 1, name: "Off", flags: 256, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
{ index: 2, name: "Static", flags: 320, speed_min: null, speed_max: null, brightness: false, colors_min: 1, colors_max: 1 },
], metadata: {}, led_count: 6, online: true, hidden: false, favorite: false, exclude_global: false,
read_only: false, blocked: false, experimental: false, room_id: null, tags: [], error_status: null,
last_detected_at: "2026-07-15T02:00:00Z", last_command_at: null,
};
const ramDevice = {
...device,
id: "ram-1",
device_type: "dram",
external_id: "corsair-ram",
fingerprint: "corsair-ram",
name: "Corsair Vengeance RGB Pro SL DDR4",
vendor: "Corsair",
model: "Corsair DRAM RGB Device",
location: "I2C: /dev/i2c-0, address 0x58",
capabilities: { ...device.capabilities, brightness: true, speed: true, direction: true, max_leds: 10 },
state: { power: false, brightness: null, colors: Array.from({ length: 10 }, () => ({ red: 0, green: 0, blue: 0 })), mode: "Direct", mode_index: 0 },
zones: [{ index: 0, name: "Corsair DRAM", type: 1, led_count: 10, leds_min: 10, leds_max: 10 }],
modes: [
{ index: 0, name: "Direct", flags: 32, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
{ index: 1, name: "Custom", flags: 544, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
{ index: 2, name: "Color Pulse", flags: 721, speed_min: 0, speed_max: 2, brightness: true, colors_min: 2, colors_max: 2 },
{ index: 5, name: "Color Wave", flags: 727, speed_min: 0, speed_max: 2, brightness: true, colors_min: 2, colors_max: 2 },
{ index: 9, name: "Rainbow", flags: 513, speed_min: 0, speed_max: 2, brightness: false, colors_min: 0, colors_max: 0 },
],
led_count: 10,
};
describe("DeviceDetailPage", () => {
beforeEach(() => {
vi.mocked(window.matchMedia).mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
if (requestUrl(input).endsWith("/zones/1/state") && init?.method === "POST") {
return Promise.resolve(jsonResponse({ id: "zone-command", status: "succeeded", zone_index: 1, led_count: 120 }));
}
return Promise.resolve(jsonResponse(device));
}));
});
it("uses every addressable LED without asking for a count", async () => {
const user = userEvent.setup();
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/device-1");
const input = await screen.findByRole("textbox", { name: "Hoofdkleur voor Aura Addressable 1" });
await user.clear(input);
await user.type(input, "#336699");
expect(screen.queryByText(/Aantal leds voor/)).not.toBeInTheDocument();
expect(screen.getByText("Alle 120 leds")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Alleen Aura Addressable 1 toepassen" }));
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
"/api/v1/devices/device-1/zones/1/state",
expect.objectContaining({ method: "POST" }),
));
const zoneCall = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/zones/1/state"));
expect(requestJson(zoneCall?.[1])).toEqual({
state: { power: true, colors: [{ red: 51, green: 102, blue: 153 }] },
});
});
it("applies an individual rainbow pattern to an addressable fan header", async () => {
const user = userEvent.setup();
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/device-1");
await user.selectOptions(await screen.findByRole("combobox", { name: "Modus voor Aura Addressable 1" }), "rainbow");
await user.click(screen.getByRole("button", { name: "Alleen Aura Addressable 1 toepassen" }));
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
"/api/v1/devices/device-1/zones/1/state",
expect.objectContaining({ method: "POST" }),
));
const zoneCall = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/zones/1/state"));
const body = requestJson(zoneCall?.[1]) as { state: { colors: unknown[] } };
expect(body.state.colors).toHaveLength(120);
expect(new Set(body.state.colors.map((color) => JSON.stringify(color))).size).toBeGreaterThan(6);
});
it("keeps Direct as a hardware mode when no color is changed", async () => {
const user = userEvent.setup();
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/device-1");
await screen.findByRole("combobox", { name: "Effectmodus" });
await user.click(screen.getByRole("button", { name: "Instellingen toepassen" }));
const stateCall = await waitFor(() => {
const call = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/state"));
expect(call).toBeDefined();
return call;
});
expect(requestJson(stateCall?.[1])).toEqual({
state: { power: true, mode_index: 0, colors: [{ red: 32, green: 64, blue: 128 }] },
});
});
it("keeps a RAM color stable and omits unsupported brightness", async () => {
let currentRam: unknown = ramDevice;
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
if (requestUrl(input).endsWith("/state") && init?.method === "POST") {
currentRam = {
...ramDevice,
last_command_at: "2026-07-16T00:00:01Z",
state: {
power: true,
brightness: null,
colors: Array.from({ length: 10 }, () => ({ red: 136, green: 68, blue: 204 })),
mode: "Custom",
mode_index: 1,
},
};
return Promise.resolve(jsonResponse({ id: "command-1", status: "succeeded" }));
}
return Promise.resolve(jsonResponse(currentRam));
}));
const user = userEvent.setup();
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/ram-1");
const colorInput = await screen.findByRole("textbox", { name: "Kleur 1" });
expect(screen.queryByText(/Helderheid/)).not.toBeInTheDocument();
await user.clear(colorInput);
await user.type(colorInput, "#8844cc");
expect(screen.getByRole("combobox", { name: "Effectmodus" })).toHaveValue("1");
const readsBeforeRefetch = vi.mocked(fetch).mock.calls.filter(
([input, init]) => requestUrl(input).endsWith("/ram-1") && !init?.method,
).length;
currentRam = { ...ramDevice, last_command_at: "2026-07-16T00:00:00Z" };
focusManager.setFocused(false);
focusManager.setFocused(true);
await waitFor(() => expect(vi.mocked(fetch).mock.calls.filter(
([input, init]) => requestUrl(input).endsWith("/ram-1") && !init?.method,
).length).toBeGreaterThan(readsBeforeRefetch));
expect(colorInput).toHaveValue("#8844CC");
await user.click(screen.getByRole("button", { name: "Instellingen toepassen" }));
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
"/api/v1/devices/ram-1/state",
expect.objectContaining({ method: "POST" }),
));
const stateCall = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/state"));
expect(requestJson(stateCall?.[1])).toEqual({
state: { power: true, colors: [{ red: 136, green: 68, blue: 204 }], mode_index: 1 },
});
await waitFor(() => expect(colorInput).toHaveValue("#8844CC"));
focusManager.setFocused(undefined);
});
it("sends every parameter required by a two-color RAM effect", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(ramDevice)));
const user = userEvent.setup();
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/ram-1");
await user.selectOptions(await screen.findByRole("combobox", { name: "Effectmodus" }), "5");
const firstColor = screen.getByRole("textbox", { name: "Kleur 1" });
const secondColor = screen.getByRole("textbox", { name: "Kleur 2" });
await user.clear(firstColor);
await user.type(firstColor, "#112233");
await user.clear(secondColor);
await user.type(secondColor, "#aabbcc");
fireEvent.change(screen.getByLabelText("Helderheid"), { target: { value: "75" } });
fireEvent.change(screen.getByLabelText("Snelheid"), { target: { value: "2" } });
await user.selectOptions(screen.getByRole("combobox", { name: "Richting" }), "1");
await user.click(screen.getByRole("button", { name: "Instellingen toepassen" }));
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
"/api/v1/devices/ram-1/state",
expect.objectContaining({ method: "POST" }),
));
const stateCall = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/state"));
expect(requestJson(stateCall?.[1])).toEqual({
state: {
power: true,
mode_index: 5,
colors: [{ red: 17, green: 34, blue: 51 }, { red: 170, green: 187, blue: 204 }],
brightness: 75,
speed: 2,
direction: 1,
},
});
});
it("does not overwrite a self-colored hardware effect with a static color", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(ramDevice)));
const user = userEvent.setup();
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/ram-1");
await user.selectOptions(await screen.findByRole("combobox", { name: "Effectmodus" }), "9");
expect(screen.queryByRole("textbox", { name: "Kleur 1" })).not.toBeInTheDocument();
expect(screen.getByText("Deze hardwaremodus genereert zijn kleuren automatisch.")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Snelheid"), { target: { value: "2" } });
await user.click(screen.getByRole("button", { name: "Instellingen toepassen" }));
const stateCall = await waitFor(() => {
const call = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/state"));
expect(call).toBeDefined();
return call;
});
expect(requestJson(stateCall?.[1])).toEqual({ state: { power: true, mode_index: 9, speed: 2 } });
});
});
import { fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { focusManager } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Route, Routes } from "react-router-dom";
import { DeviceDetailPage } from "./DeviceDetailPage";
import { jsonResponse, renderApp, requestJson, requestUrl } from "../test/render";
const device = {
id: "device-1", connector_id: "openrgb", external_id: "asus", fingerprint: "asus", source: "openrgb", device_type: "motherboard", owner: "openrgb",
name: "ASUS Aura Mainboard", alias: null, vendor: "ASUS", model: "Aura", serial: null, location: "HID: /dev/hidraw0",
ip_address: null, firmware_version: null, controller_index: 0,
capabilities: { power: true, restore: true, rgb: true, brightness: false, color_temperature: false, effect: true, speed: false,
direction: false, multiple_colors: true, per_zone: true, per_segment: false, per_led: true, profiles: false, readable_state: true,
max_leds: 126, min_brightness: 0, max_brightness: 100, min_speed: null, max_speed: null },
state: { power: true, colors: [{ red: 32, green: 64, blue: 128 }], mode: "Direct", mode_index: 0 },
zones: [
{ index: 0, name: "Aura Mainboard", type: 1, led_count: 6, leds_min: 6, leds_max: 6 },
{ index: 1, name: "Aura Addressable 1", type: 1, led_count: 0, leds_min: 0, leds_max: 120 },
],
modes: [
{ index: 0, name: "Direct", flags: 32, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
{ index: 1, name: "Off", flags: 256, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
{ index: 2, name: "Static", flags: 320, speed_min: null, speed_max: null, brightness: false, colors_min: 1, colors_max: 1 },
], metadata: {}, led_count: 6, online: true, hidden: false, favorite: false, exclude_global: false,
read_only: false, blocked: false, experimental: false, room_id: null, tags: [], error_status: null,
last_detected_at: "2026-07-15T02:00:00Z", last_command_at: null,
};
const ramDevice = {
...device,
id: "ram-1",
device_type: "dram",
external_id: "corsair-ram",
fingerprint: "corsair-ram",
name: "Corsair Vengeance RGB Pro SL DDR4",
vendor: "Corsair",
model: "Corsair DRAM RGB Device",
location: "I2C: /dev/i2c-0, address 0x58",
capabilities: { ...device.capabilities, brightness: true, speed: true, direction: true, max_leds: 10 },
state: { power: false, brightness: null, colors: Array.from({ length: 10 }, () => ({ red: 0, green: 0, blue: 0 })), mode: "Direct", mode_index: 0 },
zones: [{ index: 0, name: "Corsair DRAM", type: 1, led_count: 10, leds_min: 10, leds_max: 10 }],
modes: [
{ index: 0, name: "Direct", flags: 32, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
{ index: 1, name: "Custom", flags: 544, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
{ index: 2, name: "Color Pulse", flags: 721, speed_min: 0, speed_max: 2, brightness: true, colors_min: 2, colors_max: 2 },
{ index: 5, name: "Color Wave", flags: 727, speed_min: 0, speed_max: 2, brightness: true, colors_min: 2, colors_max: 2 },
{ index: 9, name: "Rainbow", flags: 513, speed_min: 0, speed_max: 2, brightness: false, colors_min: 0, colors_max: 0 },
],
led_count: 10,
};
describe("DeviceDetailPage", () => {
beforeEach(() => {
vi.mocked(window.matchMedia).mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
if (requestUrl(input).endsWith("/zones/1/state") && init?.method === "POST") {
return Promise.resolve(jsonResponse({ id: "zone-command", status: "succeeded", zone_index: 1, led_count: 120 }));
}
return Promise.resolve(jsonResponse(device));
}));
});
it("uses every addressable LED without asking for a count", async () => {
const user = userEvent.setup();
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/device-1");
const input = await screen.findByRole("textbox", { name: "Hoofdkleur voor Aura Addressable 1" });
await user.clear(input);
await user.type(input, "#336699");
expect(screen.queryByText(/Aantal leds voor/)).not.toBeInTheDocument();
expect(screen.getByText("Alle 120 leds")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Alleen Aura Addressable 1 toepassen" }));
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
"/api/v1/devices/device-1/zones/1/state",
expect.objectContaining({ method: "POST" }),
));
const zoneCall = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/zones/1/state"));
expect(requestJson(zoneCall?.[1])).toEqual({
state: { power: true, colors: [{ red: 51, green: 102, blue: 153 }] },
});
});
it("applies an individual rainbow pattern to an addressable fan header", async () => {
const user = userEvent.setup();
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/device-1");
await user.selectOptions(await screen.findByRole("combobox", { name: "Modus voor Aura Addressable 1" }), "rainbow");
await user.click(screen.getByRole("button", { name: "Alleen Aura Addressable 1 toepassen" }));
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
"/api/v1/devices/device-1/zones/1/state",
expect.objectContaining({ method: "POST" }),
));
const zoneCall = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/zones/1/state"));
const body = requestJson(zoneCall?.[1]) as { state: { colors: unknown[] } };
expect(body.state.colors).toHaveLength(120);
expect(new Set(body.state.colors.map((color) => JSON.stringify(color))).size).toBeGreaterThan(6);
});
it("keeps Direct as a hardware mode when no color is changed", async () => {
const user = userEvent.setup();
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/device-1");
await screen.findByRole("combobox", { name: "Effectmodus" });
await user.click(screen.getByRole("button", { name: "Instellingen toepassen" }));
const stateCall = await waitFor(() => {
const call = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/state"));
expect(call).toBeDefined();
return call;
});
expect(requestJson(stateCall?.[1])).toEqual({
state: { power: true, mode_index: 0, colors: [{ red: 32, green: 64, blue: 128 }] },
});
});
it("keeps a RAM color stable and omits unsupported brightness", async () => {
let currentRam: unknown = ramDevice;
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
if (requestUrl(input).endsWith("/state") && init?.method === "POST") {
currentRam = {
...ramDevice,
last_command_at: "2026-07-16T00:00:01Z",
state: {
power: true,
brightness: null,
colors: Array.from({ length: 10 }, () => ({ red: 136, green: 68, blue: 204 })),
mode: "Custom",
mode_index: 1,
},
};
return Promise.resolve(jsonResponse({ id: "command-1", status: "succeeded" }));
}
return Promise.resolve(jsonResponse(currentRam));
}));
const user = userEvent.setup();
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/ram-1");
const colorInput = await screen.findByRole("textbox", { name: "Kleur 1" });
expect(screen.queryByText(/Helderheid/)).not.toBeInTheDocument();
await user.clear(colorInput);
await user.type(colorInput, "#8844cc");
expect(screen.getByRole("combobox", { name: "Effectmodus" })).toHaveValue("1");
const readsBeforeRefetch = vi.mocked(fetch).mock.calls.filter(
([input, init]) => requestUrl(input).endsWith("/ram-1") && !init?.method,
).length;
currentRam = { ...ramDevice, last_command_at: "2026-07-16T00:00:00Z" };
focusManager.setFocused(false);
focusManager.setFocused(true);
await waitFor(() => expect(vi.mocked(fetch).mock.calls.filter(
([input, init]) => requestUrl(input).endsWith("/ram-1") && !init?.method,
).length).toBeGreaterThan(readsBeforeRefetch));
expect(colorInput).toHaveValue("#8844CC");
await user.click(screen.getByRole("button", { name: "Instellingen toepassen" }));
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
"/api/v1/devices/ram-1/state",
expect.objectContaining({ method: "POST" }),
));
const stateCall = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/state"));
expect(requestJson(stateCall?.[1])).toEqual({
state: { power: true, colors: [{ red: 136, green: 68, blue: 204 }], mode_index: 1 },
});
await waitFor(() => expect(colorInput).toHaveValue("#8844CC"));
focusManager.setFocused(undefined);
});
it("sends every parameter required by a two-color RAM effect", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(ramDevice)));
const user = userEvent.setup();
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/ram-1");
await user.selectOptions(await screen.findByRole("combobox", { name: "Effectmodus" }), "5");
const firstColor = screen.getByRole("textbox", { name: "Kleur 1" });
const secondColor = screen.getByRole("textbox", { name: "Kleur 2" });
await user.clear(firstColor);
await user.type(firstColor, "#112233");
await user.clear(secondColor);
await user.type(secondColor, "#aabbcc");
fireEvent.change(screen.getByLabelText("Helderheid"), { target: { value: "75" } });
fireEvent.change(screen.getByLabelText("Snelheid"), { target: { value: "2" } });
await user.selectOptions(screen.getByRole("combobox", { name: "Richting" }), "1");
await user.click(screen.getByRole("button", { name: "Instellingen toepassen" }));
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
"/api/v1/devices/ram-1/state",
expect.objectContaining({ method: "POST" }),
));
const stateCall = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/state"));
expect(requestJson(stateCall?.[1])).toEqual({
state: {
power: true,
mode_index: 5,
colors: [{ red: 17, green: 34, blue: 51 }, { red: 170, green: 187, blue: 204 }],
brightness: 75,
speed: 2,
direction: 1,
},
});
});
it("does not overwrite a self-colored hardware effect with a static color", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(ramDevice)));
const user = userEvent.setup();
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/ram-1");
await user.selectOptions(await screen.findByRole("combobox", { name: "Effectmodus" }), "9");
expect(screen.queryByRole("textbox", { name: "Kleur 1" })).not.toBeInTheDocument();
expect(screen.getByText("Deze hardwaremodus genereert zijn kleuren automatisch.")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Snelheid"), { target: { value: "2" } });
await user.click(screen.getByRole("button", { name: "Instellingen toepassen" }));
const stateCall = await waitFor(() => {
const call = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/state"));
expect(call).toBeDefined();
return call;
});
expect(requestJson(stateCall?.[1])).toEqual({ state: { power: true, mode_index: 9, speed: 2 } });
});
});
+122 -122
View File
@@ -1,122 +1,122 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Eye, Lightbulb, MapPin, ScanLine, Shield, Star, Tag, Wifi } from "lucide-react";
import { Link, useParams } from "react-router-dom";
import { api, mutationId } from "../api/client";
import type { Device, DeviceState } from "../api/types";
import { RgbControlPanel } from "../components/RgbControlPanel";
import { RgbZoneControls } from "../components/RgbZoneControls";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, CardHeader, ErrorPanel, LoadingGrid, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formatDate } from "../lib/utils";
export function DeviceDetailPage() {
const { text } = useI18n();
const { deviceId = "" } = useParams();
const queryClient = useQueryClient();
const { notify } = useToast();
const query = useQuery({
queryKey: ["device", deviceId],
queryFn: () => api<Device>(`/api/v1/devices/${deviceId}`),
});
const command = useMutation({
mutationFn: (state: DeviceState) => api(`/api/v1/devices/${deviceId}/state`, {
method: "POST",
headers: { "Idempotency-Key": mutationId() },
body: JSON.stringify({ state }),
}),
onSuccess: () => {
notify("Apparaat bijgewerkt.");
void queryClient.invalidateQueries({ queryKey: ["device", deviceId] });
void queryClient.invalidateQueries({ queryKey: ["device-groups"] });
void queryClient.invalidateQueries({ queryKey: ["system"] });
},
onError: (error) => notify(error instanceof Error ? error.message : "Opdracht mislukt.", "danger"),
});
const patch = useMutation({
mutationFn: (value: Record<string, unknown>) => api<Device>(`/api/v1/devices/${deviceId}`, {
method: "PATCH",
body: JSON.stringify(value),
}),
onSuccess: (data) => {
queryClient.setQueryData(["device", deviceId], data);
notify("Apparaatinstelling opgeslagen.");
},
});
const identify = useMutation({
mutationFn: () => api(`/api/v1/devices/${deviceId}/identify`, { method: "POST" }),
onSuccess: () => notify("Identificatie afgerond."),
});
const zoneCommand = useMutation({
mutationFn: ({ zoneIndex: targetZone, state }: { zoneIndex: number; state: DeviceState }) =>
api(`/api/v1/devices/${deviceId}/zones/${targetZone}/state`, {
method: "POST",
headers: { "Idempotency-Key": mutationId() },
body: JSON.stringify({ state }),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["device", deviceId] });
void queryClient.invalidateQueries({ queryKey: ["device-groups"] });
void queryClient.invalidateQueries({ queryKey: ["system"] });
notify("Aura-zone bijgewerkt.");
},
onError: (error) => notify(error instanceof Error ? error.message : "Aura-zone kon niet worden bijgewerkt.", "danger"),
});
if (query.isLoading) return <LoadingGrid count={3} />;
if (query.error || !query.data) return <ErrorPanel error={query.error} retry={() => void query.refetch()} />;
const device = query.data;
return <>
<Link className="back-link" to="/devices"><ArrowLeft size={16} /> Terug naar apparaten</Link>
<PageHeader
eyebrow={device.source}
title={device.alias || device.name}
description={[device.vendor, device.model, device.location].filter(Boolean).join(" · ")}
actions={<>
<Button variant="secondary" onClick={() => identify.mutate()} busy={identify.isPending}><ScanLine size={16} /> {text("Identificeren", "Identify")}</Button>
<Button variant="ghost" onClick={() => patch.mutate({ favorite: !device.favorite })}><Star size={16} fill={device.favorite ? "currentColor" : "none"} /> {text("Favoriet", "Favorite")}</Button>
</>}
/>
<div className="detail-status">
<Badge tone={device.online ? "success" : "danger"}><Wifi size={13} /> {device.online ? "Online" : "Offline"}</Badge>
{device.read_only ? <Badge tone="warning">Alleen lezen</Badge> : null}
{device.experimental ? <Badge tone="warning">Experimenteel</Badge> : null}
<span>Laatst gezien {formatDate(device.last_detected_at)}</span>
</div>
<div className="detail-grid">
<RgbControlPanel
targetKey={device.id}
capabilities={device.capabilities}
state={device.state}
modes={device.modes}
pending={command.isPending}
disabled={!device.online || device.read_only || device.blocked}
onApply={(state) => command.mutate(state)}
/>
<div className="detail-side">
<Card><CardHeader title="Eigenschappen" /><dl className="properties"><div><dt><Shield size={15} /> Beheerder</dt><dd>{device.owner}</dd></div><div><dt><MapPin size={15} /> Locatie</dt><dd>{device.location || "Niet bekend"}</dd></div><div><dt><Lightbulb size={15} /> Leds</dt><dd>{device.led_count}</dd></div><div><dt><Tag size={15} /> Serienummer</dt><dd>{device.serial || "Niet gemeld"}</dd></div></dl></Card>
<Card><CardHeader title="Beheerbeleid" description="Voorkom onbedoelde globale of wijzigende acties." /><div className="switch-list"><Toggle label="Favoriet" checked={device.favorite} onChange={(value) => patch.mutate({ favorite: value })} /><Toggle label="Verbergen" checked={device.hidden} onChange={(value) => patch.mutate({ hidden: value })} /><Toggle label="Alleen lezen" checked={device.read_only} onChange={(value) => patch.mutate({ read_only: value })} /><Toggle label="Blokkeren" checked={device.blocked} onChange={(value) => patch.mutate({ blocked: value })} danger /></div></Card>
</div>
</div>
{device.capabilities.per_zone && device.zones.length ? <section className="section-block">
<div className="section-heading"><div><h2>Moederbord en Aura-addressables</h2><p>Bedien elke zone afzonderlijk. LumaOps gebruikt automatisch de volledige header; hardware-effecten in de algemene bediening blijven controllerbreed.</p></div></div>
<RgbZoneControls
targetKey={device.id}
zones={device.zones}
state={device.state}
pendingZone={zoneCommand.isPending ? zoneCommand.variables?.zoneIndex : undefined}
disabled={!device.online || device.read_only || device.blocked}
onApply={(zoneIndex, state) => zoneCommand.mutate({ zoneIndex, state })}
/>
</section> : null}
<section className="section-block"><div className="section-heading"><div><h2>Zones en capabilities</h2><p>OpenRGB SDK-v5 inventory zonder write-side effects.</p></div></div><div className="grid grid--cards">{device.zones.map((zone) => <Card key={zone.index}><div className="zone-card"><span><Eye size={18} /></span><div><h3>{zone.name}</h3><p>{zone.led_count} leds · type {zone.type}</p></div></div></Card>)}{!device.zones.length ? <Card><p className="muted">Dit apparaat rapporteert geen zones.</p></Card> : null}</div></section>
</>;
}
function Toggle({ label, checked, onChange, danger = false }: { label: string; checked: boolean; onChange: (value: boolean) => void; danger?: boolean }) {
return <label className={`switch-row ${danger ? "switch-row--danger" : ""}`}><span>{label}</span><input type="checkbox" role="switch" checked={checked} onChange={(event) => onChange(event.target.checked)} /><i /></label>;
}
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Eye, Lightbulb, MapPin, ScanLine, Shield, Star, Tag, Wifi } from "lucide-react";
import { Link, useParams } from "react-router-dom";
import { api, mutationId } from "../api/client";
import type { Device, DeviceState } from "../api/types";
import { RgbControlPanel } from "../components/RgbControlPanel";
import { RgbZoneControls } from "../components/RgbZoneControls";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, CardHeader, ErrorPanel, LoadingGrid, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formatDate } from "../lib/utils";
export function DeviceDetailPage() {
const { text } = useI18n();
const { deviceId = "" } = useParams();
const queryClient = useQueryClient();
const { notify } = useToast();
const query = useQuery({
queryKey: ["device", deviceId],
queryFn: () => api<Device>(`/api/v1/devices/${deviceId}`),
});
const command = useMutation({
mutationFn: (state: DeviceState) => api(`/api/v1/devices/${deviceId}/state`, {
method: "POST",
headers: { "Idempotency-Key": mutationId() },
body: JSON.stringify({ state }),
}),
onSuccess: () => {
notify("Apparaat bijgewerkt.");
void queryClient.invalidateQueries({ queryKey: ["device", deviceId] });
void queryClient.invalidateQueries({ queryKey: ["device-groups"] });
void queryClient.invalidateQueries({ queryKey: ["system"] });
},
onError: (error) => notify(error instanceof Error ? error.message : "Opdracht mislukt.", "danger"),
});
const patch = useMutation({
mutationFn: (value: Record<string, unknown>) => api<Device>(`/api/v1/devices/${deviceId}`, {
method: "PATCH",
body: JSON.stringify(value),
}),
onSuccess: (data) => {
queryClient.setQueryData(["device", deviceId], data);
notify("Apparaatinstelling opgeslagen.");
},
});
const identify = useMutation({
mutationFn: () => api(`/api/v1/devices/${deviceId}/identify`, { method: "POST" }),
onSuccess: () => notify("Identificatie afgerond."),
});
const zoneCommand = useMutation({
mutationFn: ({ zoneIndex: targetZone, state }: { zoneIndex: number; state: DeviceState }) =>
api(`/api/v1/devices/${deviceId}/zones/${targetZone}/state`, {
method: "POST",
headers: { "Idempotency-Key": mutationId() },
body: JSON.stringify({ state }),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["device", deviceId] });
void queryClient.invalidateQueries({ queryKey: ["device-groups"] });
void queryClient.invalidateQueries({ queryKey: ["system"] });
notify("Aura-zone bijgewerkt.");
},
onError: (error) => notify(error instanceof Error ? error.message : "Aura-zone kon niet worden bijgewerkt.", "danger"),
});
if (query.isLoading) return <LoadingGrid count={3} />;
if (query.error || !query.data) return <ErrorPanel error={query.error} retry={() => void query.refetch()} />;
const device = query.data;
return <>
<Link className="back-link" to="/devices"><ArrowLeft size={16} /> Terug naar apparaten</Link>
<PageHeader
eyebrow={device.source}
title={device.alias || device.name}
description={[device.vendor, device.model, device.location].filter(Boolean).join(" · ")}
actions={<>
<Button variant="secondary" onClick={() => identify.mutate()} busy={identify.isPending}><ScanLine size={16} /> {text("Identificeren", "Identify")}</Button>
<Button variant="ghost" onClick={() => patch.mutate({ favorite: !device.favorite })}><Star size={16} fill={device.favorite ? "currentColor" : "none"} /> {text("Favoriet", "Favorite")}</Button>
</>}
/>
<div className="detail-status">
<Badge tone={device.online ? "success" : "danger"}><Wifi size={13} /> {device.online ? "Online" : "Offline"}</Badge>
{device.read_only ? <Badge tone="warning">Alleen lezen</Badge> : null}
{device.experimental ? <Badge tone="warning">Experimenteel</Badge> : null}
<span>Laatst gezien {formatDate(device.last_detected_at)}</span>
</div>
<div className="detail-grid">
<RgbControlPanel
targetKey={device.id}
capabilities={device.capabilities}
state={device.state}
modes={device.modes}
pending={command.isPending}
disabled={!device.online || device.read_only || device.blocked}
onApply={(state) => command.mutate(state)}
/>
<div className="detail-side">
<Card><CardHeader title="Eigenschappen" /><dl className="properties"><div><dt><Shield size={15} /> Beheerder</dt><dd>{device.owner}</dd></div><div><dt><MapPin size={15} /> Locatie</dt><dd>{device.location || "Niet bekend"}</dd></div><div><dt><Lightbulb size={15} /> Leds</dt><dd>{device.led_count}</dd></div><div><dt><Tag size={15} /> Serienummer</dt><dd>{device.serial || "Niet gemeld"}</dd></div></dl></Card>
<Card><CardHeader title="Beheerbeleid" description="Voorkom onbedoelde globale of wijzigende acties." /><div className="switch-list"><Toggle label="Favoriet" checked={device.favorite} onChange={(value) => patch.mutate({ favorite: value })} /><Toggle label="Verbergen" checked={device.hidden} onChange={(value) => patch.mutate({ hidden: value })} /><Toggle label="Alleen lezen" checked={device.read_only} onChange={(value) => patch.mutate({ read_only: value })} /><Toggle label="Blokkeren" checked={device.blocked} onChange={(value) => patch.mutate({ blocked: value })} danger /></div></Card>
</div>
</div>
{device.capabilities.per_zone && device.zones.length ? <section className="section-block">
<div className="section-heading"><div><h2>Moederbord en Aura-addressables</h2><p>Bedien elke zone afzonderlijk. LumaOps gebruikt automatisch de volledige header; hardware-effecten in de algemene bediening blijven controllerbreed.</p></div></div>
<RgbZoneControls
targetKey={device.id}
zones={device.zones}
state={device.state}
pendingZone={zoneCommand.isPending ? zoneCommand.variables?.zoneIndex : undefined}
disabled={!device.online || device.read_only || device.blocked}
onApply={(zoneIndex, state) => zoneCommand.mutate({ zoneIndex, state })}
/>
</section> : null}
<section className="section-block"><div className="section-heading"><div><h2>Zones en capabilities</h2><p>OpenRGB SDK-v5 inventory zonder write-side effects.</p></div></div><div className="grid grid--cards">{device.zones.map((zone) => <Card key={zone.index}><div className="zone-card"><span><Eye size={18} /></span><div><h3>{zone.name}</h3><p>{zone.led_count} leds · type {zone.type}</p></div></div></Card>)}{!device.zones.length ? <Card><p className="muted">Dit apparaat rapporteert geen zones.</p></Card> : null}</div></section>
</>;
}
function Toggle({ label, checked, onChange, danger = false }: { label: string; checked: boolean; onChange: (value: boolean) => void; danger?: boolean }) {
return <label className={`switch-row ${danger ? "switch-row--danger" : ""}`}><span>{label}</span><input type="checkbox" role="switch" checked={checked} onChange={(event) => onChange(event.target.checked)} /><i /></label>;
}
@@ -1,63 +1,63 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Route, Routes } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { DeviceGroupPage } from "./DeviceGroupPage";
import { jsonResponse, renderApp, requestJson, requestUrl } from "../test/render";
const capabilities = {
power: true, restore: true, rgb: true, brightness: true, color_temperature: false, effect: true, speed: true,
direction: true, multiple_colors: true, per_zone: true, per_segment: false, per_led: true, profiles: true,
readable_state: true, max_leds: 40, min_brightness: 0, max_brightness: 100, min_speed: 0, max_speed: 2,
};
const modes = [
{ index: 0, name: "Direct", flags: 32, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
{ index: 1, name: "Custom", flags: 544, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
{ index: 9, name: "Rainbow", flags: 513, speed_min: 0, speed_max: 2, brightness: false, colors_min: 0, colors_max: 0 },
];
const modules = [0, 1, 2, 3].map((index) => ({
id: `ram-${index}`, connector_id: "openrgb-local", external_id: `ram-${index}`, fingerprint: `ram-${index}`,
source: "openrgb", device_type: "dram", owner: "openrgb", name: "Corsair Vengeance RGB Pro SL DDR4", alias: null,
vendor: "Corsair", model: "Corsair DRAM RGB Device", serial: null, location: `I2C: SMBus, address 0x5${8 + index}`,
ip_address: null, firmware_version: null, controller_index: index, capabilities: { ...capabilities, max_leds: 10 },
state: { power: true, colors: Array.from({ length: 10 }, () => ({ red: 86, green: 96, blue: 255 })), mode: "Custom", mode_index: 1 },
zones: [], modes, metadata: {}, led_count: 10, online: true, hidden: false, favorite: false, exclude_global: false,
read_only: false, blocked: false, experimental: false, room_id: null, tags: [], error_status: null,
last_detected_at: "2026-07-16T00:00:00Z", last_command_at: null,
}));
const group = {
id: "dram-family", kind: "device-family", device_type: "dram", connector_id: "openrgb-local",
name: "Corsair Vengeance RGB Pro SL DDR4", vendor: "Corsair", model: "Corsair DRAM RGB Device",
online: true, online_count: 4, module_count: 4, led_count: 40, capabilities,
state: { power: true, colors: [{ red: 86, green: 96, blue: 255 }], mode: "Custom", mode_index: 1 },
modes, mixed: false, devices: modules,
};
describe("DeviceGroupPage", () => {
it("controls the RAM family and links every physical module", async () => {
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
if (requestUrl(input).endsWith("/state") && init?.method === "POST") {
return Promise.resolve(jsonResponse({ status: "succeeded", results: modules.map(() => ({ status: "succeeded" })) }));
}
return Promise.resolve(jsonResponse(group));
}));
const user = userEvent.setup();
renderApp(<Routes><Route path="/device-groups/:groupId" element={<DeviceGroupPage />} /></Routes>, "/device-groups/dram-family");
expect(await screen.findByText("4 fysieke modules", { exact: false })).toBeInTheDocument();
expect(screen.getAllByText(/RAM-module [1-4]/)).toHaveLength(4);
const color = screen.getByRole("textbox", { name: "Kleur 1" });
await user.clear(color);
await user.type(color, "#2244aa");
await user.click(screen.getByRole("button", { name: "Instellingen toepassen" }));
const stateCall = await waitFor(() => {
const call = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/state"));
expect(call).toBeDefined();
return call;
});
expect(requestJson(stateCall?.[1])).toEqual({
state: { power: true, mode_index: 1, colors: [{ red: 34, green: 68, blue: 170 }] },
});
});
});
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Route, Routes } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { DeviceGroupPage } from "./DeviceGroupPage";
import { jsonResponse, renderApp, requestJson, requestUrl } from "../test/render";
const capabilities = {
power: true, restore: true, rgb: true, brightness: true, color_temperature: false, effect: true, speed: true,
direction: true, multiple_colors: true, per_zone: true, per_segment: false, per_led: true, profiles: true,
readable_state: true, max_leds: 40, min_brightness: 0, max_brightness: 100, min_speed: 0, max_speed: 2,
};
const modes = [
{ index: 0, name: "Direct", flags: 32, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
{ index: 1, name: "Custom", flags: 544, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
{ index: 9, name: "Rainbow", flags: 513, speed_min: 0, speed_max: 2, brightness: false, colors_min: 0, colors_max: 0 },
];
const modules = [0, 1, 2, 3].map((index) => ({
id: `ram-${index}`, connector_id: "openrgb-local", external_id: `ram-${index}`, fingerprint: `ram-${index}`,
source: "openrgb", device_type: "dram", owner: "openrgb", name: "Corsair Vengeance RGB Pro SL DDR4", alias: null,
vendor: "Corsair", model: "Corsair DRAM RGB Device", serial: null, location: `I2C: SMBus, address 0x5${8 + index}`,
ip_address: null, firmware_version: null, controller_index: index, capabilities: { ...capabilities, max_leds: 10 },
state: { power: true, colors: Array.from({ length: 10 }, () => ({ red: 86, green: 96, blue: 255 })), mode: "Custom", mode_index: 1 },
zones: [], modes, metadata: {}, led_count: 10, online: true, hidden: false, favorite: false, exclude_global: false,
read_only: false, blocked: false, experimental: false, room_id: null, tags: [], error_status: null,
last_detected_at: "2026-07-16T00:00:00Z", last_command_at: null,
}));
const group = {
id: "dram-family", kind: "device-family", device_type: "dram", connector_id: "openrgb-local",
name: "Corsair Vengeance RGB Pro SL DDR4", vendor: "Corsair", model: "Corsair DRAM RGB Device",
online: true, online_count: 4, module_count: 4, led_count: 40, capabilities,
state: { power: true, colors: [{ red: 86, green: 96, blue: 255 }], mode: "Custom", mode_index: 1 },
modes, mixed: false, devices: modules,
};
describe("DeviceGroupPage", () => {
it("controls the RAM family and links every physical module", async () => {
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
if (requestUrl(input).endsWith("/state") && init?.method === "POST") {
return Promise.resolve(jsonResponse({ status: "succeeded", results: modules.map(() => ({ status: "succeeded" })) }));
}
return Promise.resolve(jsonResponse(group));
}));
const user = userEvent.setup();
renderApp(<Routes><Route path="/device-groups/:groupId" element={<DeviceGroupPage />} /></Routes>, "/device-groups/dram-family");
expect(await screen.findByText("4 fysieke modules", { exact: false })).toBeInTheDocument();
expect(screen.getAllByText(/RAM-module [1-4]/)).toHaveLength(4);
const color = screen.getByRole("textbox", { name: "Kleur 1" });
await user.clear(color);
await user.type(color, "#2244aa");
await user.click(screen.getByRole("button", { name: "Instellingen toepassen" }));
const stateCall = await waitFor(() => {
const call = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/state"));
expect(call).toBeDefined();
return call;
});
expect(requestJson(stateCall?.[1])).toEqual({
state: { power: true, mode_index: 1, colors: [{ red: 34, green: 68, blue: 170 }] },
});
});
});
+80 -80
View File
@@ -1,80 +1,80 @@
import { ArrowLeft, Layers3, MemoryStick, Wifi } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useParams } from "react-router-dom";
import { api } from "../api/client";
import type { DeviceGroup, DeviceState } from "../api/types";
import { DeviceCard } from "../components/DeviceCard";
import { RgbControlPanel } from "../components/RgbControlPanel";
import { useToast } from "../components/Toast";
import { Badge, ErrorPanel, LoadingGrid, PageHeader } from "../components/ui";
export function DeviceGroupPage() {
const { groupId = "" } = useParams();
const queryClient = useQueryClient();
const { notify } = useToast();
const query = useQuery({
queryKey: ["device-group", groupId],
queryFn: () => api<DeviceGroup>(`/api/v1/device-groups/${groupId}`),
});
const command = useMutation({
mutationFn: (state: DeviceState) => api<{ status: string; results: Array<{ status: string }> }>(`/api/v1/device-groups/${groupId}/state`, {
method: "POST",
body: JSON.stringify({ state }),
}),
onSuccess: (result) => {
const failed = result.results.filter((item) => item.status === "failed").length;
notify(failed ? `${failed} RAM-modules konden niet worden bijgewerkt.` : "RAM-groep bijgewerkt.", failed ? "danger" : "success");
void queryClient.invalidateQueries({ queryKey: ["device-group", groupId] });
void queryClient.invalidateQueries({ queryKey: ["device-groups"] });
void queryClient.invalidateQueries({ queryKey: ["devices"] });
void queryClient.invalidateQueries({ queryKey: ["system"] });
},
onError: (error) => notify(error instanceof Error ? error.message : "RAM-groepsopdracht mislukt.", "danger"),
});
if (query.isLoading) return <LoadingGrid count={3} />;
if (query.error || !query.data) return <ErrorPanel error={query.error} retry={() => void query.refetch()} />;
const group = query.data;
const disabled = group.online_count === 0 || group.devices.every((device) => device.read_only || device.blocked);
return <>
<Link className="back-link" to="/devices"><ArrowLeft size={16} /> Terug naar apparaten</Link>
<PageHeader
eyebrow="Gegroepeerd apparaat"
title={group.name}
description={`${group.vendor ?? "RAM"} · ${group.module_count} fysieke modules · ${group.led_count} leds`}
/>
<div className="detail-status">
<Badge tone={group.online ? "success" : "warning"}><Wifi size={13} /> {group.online_count}/{group.module_count} online</Badge>
<Badge tone="accent"><Layers3 size={13} /> Als groep bedienbaar</Badge>
{group.mixed ? <Badge tone="warning">Gemengde toestand</Badge> : null}
</div>
<div className="detail-grid device-group-overview">
<RgbControlPanel
targetKey={group.id}
capabilities={group.capabilities}
state={group.state}
modes={group.modes}
pending={command.isPending}
disabled={disabled}
onApply={(state) => command.mutate(state)}
/>
<div className="group-summary">
<MemoryStick size={34} />
<strong>{group.module_count} modules</strong>
<span>Groepsopdrachten worden afzonderlijk bevestigd en blijvend opgeslagen per fysieke module.</span>
</div>
</div>
<section className="section-block">
<div className="section-heading"><div><h2>Individuele RAM-modules</h2><p>Open een module om kleur, effect en overige mogelijkheden uitsluitend daarop toe te passen.</p></div></div>
<div className="grid grid--devices">
{group.devices.map((device, index) => <DeviceCard device={device} moduleLabel={`RAM-module ${index + 1} · ${moduleAddress(device.location)}`} key={device.id} />)}
</div>
</section>
</>;
}
function moduleAddress(location: string | null) {
const address = location?.match(/address (0x[0-9a-f]+)/i)?.[1];
return address?.toUpperCase() ?? "onbekend adres";
}
import { ArrowLeft, Layers3, MemoryStick, Wifi } from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, useParams } from "react-router-dom";
import { api } from "../api/client";
import type { DeviceGroup, DeviceState } from "../api/types";
import { DeviceCard } from "../components/DeviceCard";
import { RgbControlPanel } from "../components/RgbControlPanel";
import { useToast } from "../components/Toast";
import { Badge, ErrorPanel, LoadingGrid, PageHeader } from "../components/ui";
export function DeviceGroupPage() {
const { groupId = "" } = useParams();
const queryClient = useQueryClient();
const { notify } = useToast();
const query = useQuery({
queryKey: ["device-group", groupId],
queryFn: () => api<DeviceGroup>(`/api/v1/device-groups/${groupId}`),
});
const command = useMutation({
mutationFn: (state: DeviceState) => api<{ status: string; results: Array<{ status: string }> }>(`/api/v1/device-groups/${groupId}/state`, {
method: "POST",
body: JSON.stringify({ state }),
}),
onSuccess: (result) => {
const failed = result.results.filter((item) => item.status === "failed").length;
notify(failed ? `${failed} RAM-modules konden niet worden bijgewerkt.` : "RAM-groep bijgewerkt.", failed ? "danger" : "success");
void queryClient.invalidateQueries({ queryKey: ["device-group", groupId] });
void queryClient.invalidateQueries({ queryKey: ["device-groups"] });
void queryClient.invalidateQueries({ queryKey: ["devices"] });
void queryClient.invalidateQueries({ queryKey: ["system"] });
},
onError: (error) => notify(error instanceof Error ? error.message : "RAM-groepsopdracht mislukt.", "danger"),
});
if (query.isLoading) return <LoadingGrid count={3} />;
if (query.error || !query.data) return <ErrorPanel error={query.error} retry={() => void query.refetch()} />;
const group = query.data;
const disabled = group.online_count === 0 || group.devices.every((device) => device.read_only || device.blocked);
return <>
<Link className="back-link" to="/devices"><ArrowLeft size={16} /> Terug naar apparaten</Link>
<PageHeader
eyebrow="Gegroepeerd apparaat"
title={group.name}
description={`${group.vendor ?? "RAM"} · ${group.module_count} fysieke modules · ${group.led_count} leds`}
/>
<div className="detail-status">
<Badge tone={group.online ? "success" : "warning"}><Wifi size={13} /> {group.online_count}/{group.module_count} online</Badge>
<Badge tone="accent"><Layers3 size={13} /> Als groep bedienbaar</Badge>
{group.mixed ? <Badge tone="warning">Gemengde toestand</Badge> : null}
</div>
<div className="detail-grid device-group-overview">
<RgbControlPanel
targetKey={group.id}
capabilities={group.capabilities}
state={group.state}
modes={group.modes}
pending={command.isPending}
disabled={disabled}
onApply={(state) => command.mutate(state)}
/>
<div className="group-summary">
<MemoryStick size={34} />
<strong>{group.module_count} modules</strong>
<span>Groepsopdrachten worden afzonderlijk bevestigd en blijvend opgeslagen per fysieke module.</span>
</div>
</div>
<section className="section-block">
<div className="section-heading"><div><h2>Individuele RAM-modules</h2><p>Open een module om kleur, effect en overige mogelijkheden uitsluitend daarop toe te passen.</p></div></div>
<div className="grid grid--devices">
{group.devices.map((device, index) => <DeviceCard device={device} moduleLabel={`RAM-module ${index + 1} · ${moduleAddress(device.location)}`} key={device.id} />)}
</div>
</section>
</>;
}
function moduleAddress(location: string | null) {
const address = location?.match(/address (0x[0-9a-f]+)/i)?.[1];
return address?.toUpperCase() ?? "onbekend adres";
}
+63 -63
View File
@@ -1,63 +1,63 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DevicesPage } from "./DevicesPage";
import { jsonResponse, renderApp, requestUrl } from "../test/render";
const devices = {
items: [
{
id: "device-1", connector_id: "openrgb-mock", external_id: "one", fingerprint: "one", source: "mock", owner: "openrgb",
name: "Aurora Mainboard", alias: null, vendor: "LumaOps Lab", model: "Virtual RGB", serial: "MOCK-1", location: "mock://one",
ip_address: null, firmware_version: "1.0", controller_index: 0, capabilities: { power: true, restore: true, rgb: true, brightness: true,
color_temperature: false, effect: true, speed: true, direction: false, multiple_colors: true, per_zone: true, per_segment: false,
per_led: true, profiles: true, readable_state: true, max_leds: 12, min_brightness: 0, max_brightness: 100, min_speed: 1, max_speed: 10 },
state: { power: true, brightness: 70, colors: [{ red: 86, green: 96, blue: 255 }], mode: "Static" }, zones: [], modes: [], led_count: 12,
online: true, hidden: false, favorite: true, exclude_global: false, read_only: false, blocked: false, experimental: false, room_id: null,
error_status: null, last_detected_at: "2026-07-14T12:00:00Z", last_command_at: null,
},
], total: 1, limit: 500, offset: 0,
};
describe("DevicesPage", () => {
beforeEach(() => {
vi.stubGlobal("matchMedia", vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})));
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL) => Promise.resolve(
jsonResponse(requestUrl(input).endsWith("/device-groups") ? [] : devices),
)));
});
it("shows and filters normalized inventory", async () => {
renderApp(<DevicesPage />, "/devices");
expect(await screen.findByText("Aurora Mainboard")).toBeInTheDocument();
await userEvent.type(screen.getByLabelText("Apparaten zoeken"), "niet-bestaand");
expect(screen.getByText("Geen apparaten in deze selectie")).toBeInTheDocument();
});
it("shows identical RAM modules as one grouped device", async () => {
const module = { ...devices.items[0], device_type: "dram", name: "Corsair Vengeance RGB Pro SL DDR4", vendor: "Corsair", model: "Corsair DRAM RGB Device", metadata: {}, favorite: false, led_count: 10 };
const modules = [0, 1, 2, 3].map((index) => ({ ...module, id: `ram-${index}`, external_id: `ram-${index}`, fingerprint: `ram-${index}`, controller_index: index }));
const group = {
id: "dram-family", kind: "device-family", device_type: "dram", connector_id: "openrgb-local",
name: module.name, vendor: module.vendor, model: module.model, online: true, online_count: 4, module_count: 4,
led_count: 40, capabilities: module.capabilities, state: module.state, modes: [], mixed: false, devices: modules,
};
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL) => Promise.resolve(
jsonResponse(requestUrl(input).endsWith("/device-groups") ? [group] : { items: modules, total: 4, limit: 500, offset: 0 }),
)));
renderApp(<DevicesPage />, "/devices");
expect(await screen.findByText("Corsair Vengeance RGB Pro SL DDR4")).toBeInTheDocument();
expect(screen.getByText("4 modules · 40 leds")).toBeInTheDocument();
expect(screen.getByText("gegroepeerd RAM-apparaat", { exact: false })).toBeInTheDocument();
expect(screen.queryByText("RAM-module 1", { exact: false })).not.toBeInTheDocument();
});
});
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DevicesPage } from "./DevicesPage";
import { jsonResponse, renderApp, requestUrl } from "../test/render";
const devices = {
items: [
{
id: "device-1", connector_id: "openrgb-mock", external_id: "one", fingerprint: "one", source: "mock", owner: "openrgb",
name: "Aurora Mainboard", alias: null, vendor: "LumaOps Lab", model: "Virtual RGB", serial: "MOCK-1", location: "mock://one",
ip_address: null, firmware_version: "1.0", controller_index: 0, capabilities: { power: true, restore: true, rgb: true, brightness: true,
color_temperature: false, effect: true, speed: true, direction: false, multiple_colors: true, per_zone: true, per_segment: false,
per_led: true, profiles: true, readable_state: true, max_leds: 12, min_brightness: 0, max_brightness: 100, min_speed: 1, max_speed: 10 },
state: { power: true, brightness: 70, colors: [{ red: 86, green: 96, blue: 255 }], mode: "Static" }, zones: [], modes: [], led_count: 12,
online: true, hidden: false, favorite: true, exclude_global: false, read_only: false, blocked: false, experimental: false, room_id: null,
error_status: null, last_detected_at: "2026-07-14T12:00:00Z", last_command_at: null,
},
], total: 1, limit: 500, offset: 0,
};
describe("DevicesPage", () => {
beforeEach(() => {
vi.stubGlobal("matchMedia", vi.fn().mockImplementation(() => ({
matches: false,
media: "",
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})));
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL) => Promise.resolve(
jsonResponse(requestUrl(input).endsWith("/device-groups") ? [] : devices),
)));
});
it("shows and filters normalized inventory", async () => {
renderApp(<DevicesPage />, "/devices");
expect(await screen.findByText("Aurora Mainboard")).toBeInTheDocument();
await userEvent.type(screen.getByLabelText("Apparaten zoeken"), "niet-bestaand");
expect(screen.getByText("Geen apparaten in deze selectie")).toBeInTheDocument();
});
it("shows identical RAM modules as one grouped device", async () => {
const module = { ...devices.items[0], device_type: "dram", name: "Corsair Vengeance RGB Pro SL DDR4", vendor: "Corsair", model: "Corsair DRAM RGB Device", metadata: {}, favorite: false, led_count: 10 };
const modules = [0, 1, 2, 3].map((index) => ({ ...module, id: `ram-${index}`, external_id: `ram-${index}`, fingerprint: `ram-${index}`, controller_index: index }));
const group = {
id: "dram-family", kind: "device-family", device_type: "dram", connector_id: "openrgb-local",
name: module.name, vendor: module.vendor, model: module.model, online: true, online_count: 4, module_count: 4,
led_count: 40, capabilities: module.capabilities, state: module.state, modes: [], mixed: false, devices: modules,
};
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL) => Promise.resolve(
jsonResponse(requestUrl(input).endsWith("/device-groups") ? [group] : { items: modules, total: 4, limit: 500, offset: 0 }),
)));
renderApp(<DevicesPage />, "/devices");
expect(await screen.findByText("Corsair Vengeance RGB Pro SL DDR4")).toBeInTheDocument();
expect(screen.getByText("4 modules · 40 leds")).toBeInTheDocument();
expect(screen.getByText("gegroepeerd RAM-apparaat", { exact: false })).toBeInTheDocument();
expect(screen.queryByText("RAM-module 1", { exact: false })).not.toBeInTheDocument();
});
});
+44 -44
View File
@@ -1,44 +1,44 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Cpu, Grid2X2, List, RefreshCw, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { api } from "../api/client";
import type { Device, DeviceGroup, Page } from "../api/types";
import { DeviceCard } from "../components/DeviceCard";
import { DeviceGroupCard } from "../components/DeviceGroupCard";
import { useToast } from "../components/Toast";
import { Button, EmptyState, ErrorPanel, Input, LoadingGrid, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
type InventoryEntry = { kind: "device"; device: Device } | { kind: "group"; group: DeviceGroup };
export function DevicesPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const [search, setSearch] = useState("");
const [status, setStatus] = useState<"all" | "online" | "offline">("all");
const [view, setView] = useState<"grid" | "list">("grid");
const query = useQuery({ queryKey: ["devices"], queryFn: () => api<Page<Device>>("/api/v1/devices?limit=500") });
const groups = useQuery({ queryKey: ["device-groups"], queryFn: () => api<DeviceGroup[]>("/api/v1/device-groups") });
const rescan = useMutation({ mutationFn: () => api("/api/v1/devices/rescan", { method: "POST" }), onSuccess: () => { notify("Apparaatscan afgerond."); void queryClient.invalidateQueries({ queryKey: ["devices"] }); void queryClient.invalidateQueries({ queryKey: ["device-groups"] }); }, onError: (error) => notify(error instanceof Error ? error.message : "Scan mislukt.", "danger") });
const filtered = useMemo(() => {
const groupedIds = new Set((groups.data ?? []).flatMap((group) => group.devices.map((device) => device.id)));
const entries: InventoryEntry[] = [
...(groups.data ?? []).map((group): InventoryEntry => ({ kind: "group", group })),
...(query.data?.items ?? []).filter((device) => !groupedIds.has(device.id)).map((device): InventoryEntry => ({ kind: "device", device })),
];
const searchTerm = search.toLocaleLowerCase();
return entries.filter((entry) => {
const item = entry.kind === "device" ? entry.device : entry.group;
const searchable = `${entry.kind === "device" ? entry.device.alias ?? "" : "RAM geheugen modules"} ${item.name} ${item.vendor ?? ""} ${item.model ?? ""}`.toLocaleLowerCase();
return searchable.includes(searchTerm) && (status === "all" || item.online === (status === "online"));
});
}, [groups.data, query.data, search, status]);
const error = query.error ?? groups.error;
return <>
<PageHeader eyebrow={text("Uniforme inventaris", "Unified inventory")} title={text("Apparaten", "Devices")} description={text("Lokale OpenRGB-controllers, netwerklichten en toekomstige agents met één stabiele identiteit.", "Local OpenRGB controllers, network lights and future agents with one stable identity.")} actions={<Button onClick={() => rescan.mutate()} busy={rescan.isPending}><RefreshCw size={16} /> {text("Opnieuw scannen", "Rescan")}</Button>} />
<div className="toolbar"><div className="search"><Search size={17} /><Input aria-label="Apparaten zoeken" placeholder="Zoek op naam, merk of model…" value={search} onChange={(event) => setSearch(event.target.value)} /></div><div className="segmented" aria-label="Statusfilter">{(["all", "online", "offline"] as const).map((item) => <button className={status === item ? "active" : ""} key={item} onClick={() => setStatus(item)}>{item === "all" ? "Alle" : item === "online" ? "Online" : "Offline"}</button>)}</div><div className="view-toggle"><button className={view === "grid" ? "active" : ""} aria-label="Rasterweergave" onClick={() => setView("grid")}><Grid2X2 size={17} /></button><button className={view === "list" ? "active" : ""} aria-label="Lijstweergave" onClick={() => setView("list")}><List size={17} /></button></div></div>
{query.isLoading || groups.isLoading ? <LoadingGrid count={6} /> : error ? <ErrorPanel error={error} retry={() => void Promise.all([query.refetch(), groups.refetch()])} /> : !filtered.length ? <EmptyState icon={Cpu} title="Geen apparaten in deze selectie" description="Voer een nieuwe scan uit of pas de filters aan." action={<Button onClick={() => rescan.mutate()}>Scannen</Button>} /> : <div className={view === "grid" ? "grid grid--devices" : "device-list"}>{filtered.map((entry) => entry.kind === "group" ? <DeviceGroupCard group={entry.group} list={view === "list"} key={entry.group.id} /> : <DeviceCard device={entry.device} list={view === "list"} key={entry.device.id} />)}</div>}
</>;
}
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Cpu, Grid2X2, List, RefreshCw, Search } from "lucide-react";
import { useMemo, useState } from "react";
import { api } from "../api/client";
import type { Device, DeviceGroup, Page } from "../api/types";
import { DeviceCard } from "../components/DeviceCard";
import { DeviceGroupCard } from "../components/DeviceGroupCard";
import { useToast } from "../components/Toast";
import { Button, EmptyState, ErrorPanel, Input, LoadingGrid, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
type InventoryEntry = { kind: "device"; device: Device } | { kind: "group"; group: DeviceGroup };
export function DevicesPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const [search, setSearch] = useState("");
const [status, setStatus] = useState<"all" | "online" | "offline">("all");
const [view, setView] = useState<"grid" | "list">("grid");
const query = useQuery({ queryKey: ["devices"], queryFn: () => api<Page<Device>>("/api/v1/devices?limit=500") });
const groups = useQuery({ queryKey: ["device-groups"], queryFn: () => api<DeviceGroup[]>("/api/v1/device-groups") });
const rescan = useMutation({ mutationFn: () => api("/api/v1/devices/rescan", { method: "POST" }), onSuccess: () => { notify("Apparaatscan afgerond."); void queryClient.invalidateQueries({ queryKey: ["devices"] }); void queryClient.invalidateQueries({ queryKey: ["device-groups"] }); }, onError: (error) => notify(error instanceof Error ? error.message : "Scan mislukt.", "danger") });
const filtered = useMemo(() => {
const groupedIds = new Set((groups.data ?? []).flatMap((group) => group.devices.map((device) => device.id)));
const entries: InventoryEntry[] = [
...(groups.data ?? []).map((group): InventoryEntry => ({ kind: "group", group })),
...(query.data?.items ?? []).filter((device) => !groupedIds.has(device.id)).map((device): InventoryEntry => ({ kind: "device", device })),
];
const searchTerm = search.toLocaleLowerCase();
return entries.filter((entry) => {
const item = entry.kind === "device" ? entry.device : entry.group;
const searchable = `${entry.kind === "device" ? entry.device.alias ?? "" : "RAM geheugen modules"} ${item.name} ${item.vendor ?? ""} ${item.model ?? ""}`.toLocaleLowerCase();
return searchable.includes(searchTerm) && (status === "all" || item.online === (status === "online"));
});
}, [groups.data, query.data, search, status]);
const error = query.error ?? groups.error;
return <>
<PageHeader eyebrow={text("Uniforme inventaris", "Unified inventory")} title={text("Apparaten", "Devices")} description={text("Lokale OpenRGB-controllers, netwerklichten en toekomstige agents met één stabiele identiteit.", "Local OpenRGB controllers, network lights and future agents with one stable identity.")} actions={<Button onClick={() => rescan.mutate()} busy={rescan.isPending}><RefreshCw size={16} /> {text("Opnieuw scannen", "Rescan")}</Button>} />
<div className="toolbar"><div className="search"><Search size={17} /><Input aria-label="Apparaten zoeken" placeholder="Zoek op naam, merk of model…" value={search} onChange={(event) => setSearch(event.target.value)} /></div><div className="segmented" aria-label="Statusfilter">{(["all", "online", "offline"] as const).map((item) => <button className={status === item ? "active" : ""} key={item} onClick={() => setStatus(item)}>{item === "all" ? "Alle" : item === "online" ? "Online" : "Offline"}</button>)}</div><div className="view-toggle"><button className={view === "grid" ? "active" : ""} aria-label="Rasterweergave" onClick={() => setView("grid")}><Grid2X2 size={17} /></button><button className={view === "list" ? "active" : ""} aria-label="Lijstweergave" onClick={() => setView("list")}><List size={17} /></button></div></div>
{query.isLoading || groups.isLoading ? <LoadingGrid count={6} /> : error ? <ErrorPanel error={error} retry={() => void Promise.all([query.refetch(), groups.refetch()])} /> : !filtered.length ? <EmptyState icon={Cpu} title="Geen apparaten in deze selectie" description="Voer een nieuwe scan uit of pas de filters aan." action={<Button onClick={() => rescan.mutate()}>Scannen</Button>} /> : <div className={view === "grid" ? "grid grid--devices" : "device-list"}>{filtered.map((entry) => entry.kind === "group" ? <DeviceGroupCard group={entry.group} list={view === "list"} key={entry.group.id} /> : <DeviceCard device={entry.device} list={view === "list"} key={entry.device.id} />)}</div>}
</>;
}
+25 -25
View File
@@ -1,25 +1,25 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, Download, FlaskConical, RotateCcw, ShieldAlert, TerminalSquare } from "lucide-react";
import { api } from "../api/client";
import type { ComponentHealth, HealthStatus } from "../api/types";
import { useToast } from "../components/Toast";
import { Badge, Card, CardHeader, PageHeader, StatusBadge } from "../components/ui";
import { useI18n } from "../lib/i18n";
interface Health { status: HealthStatus; components: Record<string, ComponentHealth>; checked_at: string }
export function DiagnosticsPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const health = useQuery({ queryKey: ["health"], queryFn: () => api<Health>("/api/v1/health"), refetchInterval: 15_000 });
const clearStop = useMutation({ mutationFn: () => api("/api/v1/commands/emergency-stop", { method: "DELETE" }), onSuccess: () => { notify("Noodstop vrijgegeven."); void queryClient.invalidateQueries(); } });
return <>
<PageHeader eyebrow={text("Herstel zonder giswerk", "Recovery without guesswork")} title={text("Diagnostiek", "Diagnostics")} description={text("Proces-, SDK-, database- en opslagstatus met geredigeerde export voor probleemonderzoek.", "Process, SDK, database and storage status with a redacted troubleshooting export.")} actions={<a className="button button--secondary" href="/api/v1/diagnostics/export"><Download size={16} /> {text("Diagnostiekspakket", "Diagnostics bundle")}</a>} />
<div className="grid grid--health">{Object.entries(health.data?.components ?? {}).map(([name, component]) => <Card key={name} className="health-card"><div className="health-card__top"><span><TerminalSquare size={19} /></span><StatusBadge status={component.status} /></div><h2>{componentName(name)}</h2><p>{component.message}</p>{component.connected !== undefined ? <Badge tone={component.connected ? "success" : "warning"}>{component.connected ? "Verbonden" : "Niet verbonden"}</Badge> : null}</Card>)}</div>
<div className="dashboard-grid"><Card><CardHeader title="Veilige herstelacties" description="Geen van deze acties wijzigt hostdrivers of firmware." /><div className="action-list"><button onClick={() => void health.refetch()}><span><RotateCcw size={18} /></span><div><strong>Health opnieuw controleren</strong><p>Ververs processen, SDK en opslagstatus.</p></div></button><button onClick={() => clearStop.mutate()}><span><ShieldAlert size={18} /></span><div><strong>Noodstop vrijgeven</strong><p>Sta nieuwe gevalideerde hardwareopdrachten toe.</p></div></button><a href="/api/v1/diagnostics/export"><span><FlaskConical size={18} /></span><div><strong>Geredigeerde bundle exporteren</strong><p>Zonder tokens, secrets of volledige environment dump.</p></div></a></div></Card>
<Card><CardHeader title="Veiligheidsgrenzen" /><ul className="check-list"><li><CheckCircle2 size={16} /> SDK uitsluitend op 127.0.0.1:6742</li><li><CheckCircle2 size={16} /> Per-device locks en rate limiting</li><li><CheckCircle2 size={16} /> Identiteitscontrole vóór iedere write</li><li><CheckCircle2 size={16} /> Secrets uit logs en bundles verwijderd</li><li><CheckCircle2 size={16} /> Inventory zonder schrijfopdrachten</li></ul></Card></div>
</>;
}
function componentName(name: string): string { return ({ database: "SQLite-database", storage: "Persistente opslag", openrgb_process: "OpenRGB-proces", "openrgb-local": "OpenRGB SDK", "openrgb-mock": "Mockadapter" } as Record<string, string>)[name] ?? name; }
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, Download, FlaskConical, RotateCcw, ShieldAlert, TerminalSquare } from "lucide-react";
import { api } from "../api/client";
import type { ComponentHealth, HealthStatus } from "../api/types";
import { useToast } from "../components/Toast";
import { Badge, Card, CardHeader, PageHeader, StatusBadge } from "../components/ui";
import { useI18n } from "../lib/i18n";
interface Health { status: HealthStatus; components: Record<string, ComponentHealth>; checked_at: string }
export function DiagnosticsPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const health = useQuery({ queryKey: ["health"], queryFn: () => api<Health>("/api/v1/health"), refetchInterval: 15_000 });
const clearStop = useMutation({ mutationFn: () => api("/api/v1/commands/emergency-stop", { method: "DELETE" }), onSuccess: () => { notify("Noodstop vrijgegeven."); void queryClient.invalidateQueries(); } });
return <>
<PageHeader eyebrow={text("Herstel zonder giswerk", "Recovery without guesswork")} title={text("Diagnostiek", "Diagnostics")} description={text("Proces-, SDK-, database- en opslagstatus met geredigeerde export voor probleemonderzoek.", "Process, SDK, database and storage status with a redacted troubleshooting export.")} actions={<a className="button button--secondary" href="/api/v1/diagnostics/export"><Download size={16} /> {text("Diagnostiekspakket", "Diagnostics bundle")}</a>} />
<div className="grid grid--health">{Object.entries(health.data?.components ?? {}).map(([name, component]) => <Card key={name} className="health-card"><div className="health-card__top"><span><TerminalSquare size={19} /></span><StatusBadge status={component.status} /></div><h2>{componentName(name)}</h2><p>{component.message}</p>{component.connected !== undefined ? <Badge tone={component.connected ? "success" : "warning"}>{component.connected ? "Verbonden" : "Niet verbonden"}</Badge> : null}</Card>)}</div>
<div className="dashboard-grid"><Card><CardHeader title="Veilige herstelacties" description="Geen van deze acties wijzigt hostdrivers of firmware." /><div className="action-list"><button onClick={() => void health.refetch()}><span><RotateCcw size={18} /></span><div><strong>Health opnieuw controleren</strong><p>Ververs processen, SDK en opslagstatus.</p></div></button><button onClick={() => clearStop.mutate()}><span><ShieldAlert size={18} /></span><div><strong>Noodstop vrijgeven</strong><p>Sta nieuwe gevalideerde hardwareopdrachten toe.</p></div></button><a href="/api/v1/diagnostics/export"><span><FlaskConical size={18} /></span><div><strong>Geredigeerde bundle exporteren</strong><p>Zonder tokens, secrets of volledige environment dump.</p></div></a></div></Card>
<Card><CardHeader title="Veiligheidsgrenzen" /><ul className="check-list"><li><CheckCircle2 size={16} /> SDK uitsluitend op 127.0.0.1:6742</li><li><CheckCircle2 size={16} /> Per-device locks en rate limiting</li><li><CheckCircle2 size={16} /> Identiteitscontrole vóór iedere write</li><li><CheckCircle2 size={16} /> Secrets uit logs en bundles verwijderd</li><li><CheckCircle2 size={16} /> Inventory zonder schrijfopdrachten</li></ul></Card></div>
</>;
}
function componentName(name: string): string { return ({ database: "SQLite-database", storage: "Persistente opslag", openrgb_process: "OpenRGB-proces", "openrgb-local": "OpenRGB SDK", "openrgb-mock": "Mockadapter" } as Record<string, string>)[name] ?? name; }
+23 -23
View File
@@ -1,23 +1,23 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Compass, Radar, RefreshCw, Usb } from "lucide-react";
import { api } from "../api/client";
import type { Page } from "../api/types";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, CardHeader, EmptyState, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formatDate } from "../lib/utils";
interface DiscoveryRun { id: string; status: string; found_count: number; inaccessible_count: number; started_at: string; finished_at: string | null }
export function DiscoveryPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const history = useQuery({ queryKey: ["discovery"], queryFn: () => api<Page<DiscoveryRun>>("/api/v1/discovery?limit=50") });
const discover = useMutation({ mutationFn: () => api<{ found_count: number }>("/api/v1/discovery", { method: "POST" }), onSuccess: (result) => { notify(`${result.found_count} apparaten gesynchroniseerd.`); void queryClient.invalidateQueries({ queryKey: ["discovery"] }); void queryClient.invalidateQueries({ queryKey: ["devices"] }); }, onError: (error) => notify(error instanceof Error ? error.message : "Discovery mislukt.", "danger") });
return <>
<PageHeader eyebrow={text("Alleen-lezen-inventaris", "Read-only inventory")} title="Discovery" description={text("Zoek apparaten zonder impliciete schrijfopdrachten of agressieve SMBus-probes.", "Find devices without implicit writes or aggressive SMBus probes.")} actions={<Button onClick={() => discover.mutate()} busy={discover.isPending}><Radar size={16} /> {text("Discovery starten", "Start discovery")}</Button>} />
<div className="grid grid--stats"><Card className="discovery-mode"><span><Usb size={21} /></span><div><strong>USB & hidraw</strong><p>Via expliciete device mapping</p></div></Card><Card className="discovery-mode"><span><Compass size={21} /></span><div><strong>Netwerk</strong><p>Broadcast/multicast indien ingeschakeld</p></div></Card><Card className="discovery-mode"><span><RefreshCw size={21} /></span><div><strong>SDK rescan</strong><p>OpenRGB protocol 5</p></div></Card></div>
<Card><CardHeader title="Scanhistoriek" description="Resultaten en niet-toegankelijke doelen blijven controleerbaar." />{history.data?.items.length ? <div className="table-wrap"><table><thead><tr><th>Gestart</th><th>Status</th><th>Gevonden</th><th>Niet toegankelijk</th><th>Duur</th></tr></thead><tbody>{history.data.items.map((run) => <tr key={run.id}><td>{formatDate(run.started_at)}</td><td><Badge tone={run.status === "completed" ? "success" : run.status === "failed" ? "danger" : "warning"}>{run.status}</Badge></td><td>{run.found_count}</td><td>{run.inaccessible_count}</td><td>{run.finished_at ? `${Math.max(0, (Date.parse(run.finished_at) - Date.parse(run.started_at)) / 1000).toFixed(1)} s` : "—"}</td></tr>)}</tbody></table></div> : <EmptyState icon={Radar} title="Nog geen scans" description="Start discovery om de lokale OpenRGB-inventory te synchroniseren." />}</Card>
</>;
}
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Compass, Radar, RefreshCw, Usb } from "lucide-react";
import { api } from "../api/client";
import type { Page } from "../api/types";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, CardHeader, EmptyState, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formatDate } from "../lib/utils";
interface DiscoveryRun { id: string; status: string; found_count: number; inaccessible_count: number; started_at: string; finished_at: string | null }
export function DiscoveryPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const history = useQuery({ queryKey: ["discovery"], queryFn: () => api<Page<DiscoveryRun>>("/api/v1/discovery?limit=50") });
const discover = useMutation({ mutationFn: () => api<{ found_count: number }>("/api/v1/discovery", { method: "POST" }), onSuccess: (result) => { notify(`${result.found_count} apparaten gesynchroniseerd.`); void queryClient.invalidateQueries({ queryKey: ["discovery"] }); void queryClient.invalidateQueries({ queryKey: ["devices"] }); }, onError: (error) => notify(error instanceof Error ? error.message : "Discovery mislukt.", "danger") });
return <>
<PageHeader eyebrow={text("Alleen-lezen-inventaris", "Read-only inventory")} title="Discovery" description={text("Zoek apparaten zonder impliciete schrijfopdrachten of agressieve SMBus-probes.", "Find devices without implicit writes or aggressive SMBus probes.")} actions={<Button onClick={() => discover.mutate()} busy={discover.isPending}><Radar size={16} /> {text("Discovery starten", "Start discovery")}</Button>} />
<div className="grid grid--stats"><Card className="discovery-mode"><span><Usb size={21} /></span><div><strong>USB & hidraw</strong><p>Via expliciete device mapping</p></div></Card><Card className="discovery-mode"><span><Compass size={21} /></span><div><strong>Netwerk</strong><p>Broadcast/multicast indien ingeschakeld</p></div></Card><Card className="discovery-mode"><span><RefreshCw size={21} /></span><div><strong>SDK rescan</strong><p>OpenRGB protocol 5</p></div></Card></div>
<Card><CardHeader title="Scanhistoriek" description="Resultaten en niet-toegankelijke doelen blijven controleerbaar." />{history.data?.items.length ? <div className="table-wrap"><table><thead><tr><th>Gestart</th><th>Status</th><th>Gevonden</th><th>Niet toegankelijk</th><th>Duur</th></tr></thead><tbody>{history.data.items.map((run) => <tr key={run.id}><td>{formatDate(run.started_at)}</td><td><Badge tone={run.status === "completed" ? "success" : run.status === "failed" ? "danger" : "warning"}>{run.status}</Badge></td><td>{run.found_count}</td><td>{run.inaccessible_count}</td><td>{run.finished_at ? `${Math.max(0, (Date.parse(run.finished_at) - Date.parse(run.started_at)) / 1000).toFixed(1)} s` : "—"}</td></tr>)}</tbody></table></div> : <EmptyState icon={Radar} title="Nog geen scans" description="Start discovery om de lokale OpenRGB-inventory te synchroniseren." />}</Card>
</>;
}
+27 -27
View File
@@ -1,27 +1,27 @@
import { useQuery } from "@tanstack/react-query";
import { Globe2, Network, RadioTower, ShieldCheck } from "lucide-react";
import { api } from "../api/client";
import type { Device, Page } from "../api/types";
import { Badge, Card, CardHeader, EmptyState, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
const protocols = [
["DDP", "UDP 4048", "Handmatig"], ["E1.31 / sACN", "UDP 5568", "Unicast / multicast"],
["Philips Hue", "HTTP + entertainment", "Bridge discovery"], ["Philips WiZ", "UDP 38899", "LAN discovery"],
["Nanoleaf", "HTTP + UDP", "Discovery / token"], ["LIFX", "UDP 56700", "LAN discovery"],
["Govee", "UDP 4001–4003", "Multicast"], ["Kasa", "TCP 9999", "LAN"],
["Yeelight", "TCP 55443", "LAN discovery"], ["Espurna", "HTTP/TCP", "Handmatig + API-key"],
["Elgato", "HTTP 9123", "mDNS / LAN"],
];
export function NetworkPage() {
const { text } = useI18n();
const devices = useQuery({ queryKey: ["devices"], queryFn: () => api<Page<Device>>("/api/v1/devices?limit=500") });
const networkDevices = devices.data?.items.filter((device) => device.ip_address || device.source !== "openrgb") ?? [];
return <>
<PageHeader eyebrow={text("LAN-verlichting", "LAN lighting")} title={text("Netwerkapparaten", "Network devices")} description={text("OpenRGB-netwerkcontrollers en toekomstige native connectorapparaten met expliciet eigenaarschap.", "OpenRGB network controllers and future native connector devices with explicit ownership.")} />
<div className="alert alert--info"><ShieldCheck size={20} /><div><strong>Dubbele aansturing wordt voorkomen</strong><p>Kies per fysiek apparaat één eigenaar: OpenRGB, native connector, Home Assistant of onbeheerd.</p></div></div>
<section className="section-block"><div className="section-heading"><div><h2>Gevonden netwerkapparaten</h2><p>Apparaten met een IP-adres of externe connectorbron.</p></div></div>{networkDevices.length ? <div className="grid grid--cards">{networkDevices.map((device) => <Card key={device.id} className="network-device"><span><Globe2 size={20} /></span><div><h3>{device.alias || device.name}</h3><p>{device.ip_address || device.source}</p></div><Badge tone={device.online ? "success" : "neutral"}>{device.owner}</Badge></Card>)}</div> : <EmptyState icon={Network} title="Nog geen netwerkapparaten" description="Activeer discovery of voeg een ondersteund doel handmatig toe via OpenRGB." />}</section>
<Card><CardHeader title="Ondersteund door OpenRGB 1.0rc3" description="Discovery kan host networking nodig hebben; de SDK blijft altijd loopback-only." /><div className="table-wrap"><table><thead><tr><th>Familie</th><th>Transport</th><th>Toevoegen</th></tr></thead><tbody>{protocols.map(([name, transport, discovery]) => <tr key={name}><td><RadioTower size={15} /> {name}</td><td>{transport}</td><td>{discovery}</td></tr>)}</tbody></table></div></Card>
</>;
}
import { useQuery } from "@tanstack/react-query";
import { Globe2, Network, RadioTower, ShieldCheck } from "lucide-react";
import { api } from "../api/client";
import type { Device, Page } from "../api/types";
import { Badge, Card, CardHeader, EmptyState, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
const protocols = [
["DDP", "UDP 4048", "Handmatig"], ["E1.31 / sACN", "UDP 5568", "Unicast / multicast"],
["Philips Hue", "HTTP + entertainment", "Bridge discovery"], ["Philips WiZ", "UDP 38899", "LAN discovery"],
["Nanoleaf", "HTTP + UDP", "Discovery / token"], ["LIFX", "UDP 56700", "LAN discovery"],
["Govee", "UDP 4001–4003", "Multicast"], ["Kasa", "TCP 9999", "LAN"],
["Yeelight", "TCP 55443", "LAN discovery"], ["Espurna", "HTTP/TCP", "Handmatig + API-key"],
["Elgato", "HTTP 9123", "mDNS / LAN"],
];
export function NetworkPage() {
const { text } = useI18n();
const devices = useQuery({ queryKey: ["devices"], queryFn: () => api<Page<Device>>("/api/v1/devices?limit=500") });
const networkDevices = devices.data?.items.filter((device) => device.ip_address || device.source !== "openrgb") ?? [];
return <>
<PageHeader eyebrow={text("LAN-verlichting", "LAN lighting")} title={text("Netwerkapparaten", "Network devices")} description={text("OpenRGB-netwerkcontrollers en toekomstige native connectorapparaten met expliciet eigenaarschap.", "OpenRGB network controllers and future native connector devices with explicit ownership.")} />
<div className="alert alert--info"><ShieldCheck size={20} /><div><strong>Dubbele aansturing wordt voorkomen</strong><p>Kies per fysiek apparaat één eigenaar: OpenRGB, native connector, Home Assistant of onbeheerd.</p></div></div>
<section className="section-block"><div className="section-heading"><div><h2>Gevonden netwerkapparaten</h2><p>Apparaten met een IP-adres of externe connectorbron.</p></div></div>{networkDevices.length ? <div className="grid grid--cards">{networkDevices.map((device) => <Card key={device.id} className="network-device"><span><Globe2 size={20} /></span><div><h3>{device.alias || device.name}</h3><p>{device.ip_address || device.source}</p></div><Badge tone={device.online ? "success" : "neutral"}>{device.owner}</Badge></Card>)}</div> : <EmptyState icon={Network} title="Nog geen netwerkapparaten" description="Activeer discovery of voeg een ondersteund doel handmatig toe via OpenRGB." />}</section>
<Card><CardHeader title="Ondersteund door OpenRGB 1.0rc3" description="Discovery kan host networking nodig hebben; de SDK blijft altijd loopback-only." /><div className="table-wrap"><table><thead><tr><th>Familie</th><th>Transport</th><th>Toevoegen</th></tr></thead><tbody>{protocols.map(([name, transport, discovery]) => <tr key={name}><td><RadioTower size={15} /> {name}</td><td>{transport}</td><td>{discovery}</td></tr>)}</tbody></table></div></Card>
</>;
}
@@ -1,27 +1,27 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { OverviewPage } from "./OverviewPage";
import { jsonResponse, renderApp } from "../test/render";
describe("OverviewPage command flow", () => {
it("sends a quick color through the versioned API", async () => {
const stateBodies: string[] = [];
const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : typeof input === "string" ? input : input.toString();
if (url.includes("/api/v1/system")) return Promise.resolve(jsonResponse({ name: "LumaOps", version: "0.1.0", openrgb_version: "1.0rc3", sdk_protocol: 5,
environment: "test", mock_mode: true, health: { status: "healthy", checked_at: "2026-07-14T12:00:00Z", components: { "openrgb-mock": { status: "healthy", message: "ok", connected: true } } },
devices: { total: 1, online: 1, offline: 0 }, active_scene: null, recent_commands: [], warnings: [], emergency_stop: false }));
if (url.includes("/api/v1/devices") && (init?.method ?? "GET") === "GET") return Promise.resolve(jsonResponse({ items: [{ id: "device-1", capabilities: { rgb: true }, read_only: false, blocked: false }], total: 1, limit: 500, offset: 0 }));
if (url.includes("/api/v1/scenes")) return Promise.resolve(jsonResponse({ items: [], total: 0, limit: 6, offset: 0 }));
if (url.includes("/state")) { if (typeof init?.body === "string") stateBodies.push(init.body); return Promise.resolve(jsonResponse({ id: "command-1", status: "succeeded" })); }
return Promise.resolve(jsonResponse({}));
});
vi.stubGlobal("fetch", fetchMock);
renderApp(<OverviewPage />);
await screen.findByText("Snelle bediening");
await userEvent.click(screen.getByRole("button", { name: /Kleur toepassen/i }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("/api/v1/devices/device-1/state"), expect.objectContaining({ method: "POST" })));
expect(stateBodies[0]).toContain('"colors"');
});
});
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { OverviewPage } from "./OverviewPage";
import { jsonResponse, renderApp } from "../test/render";
describe("OverviewPage command flow", () => {
it("sends a quick color through the versioned API", async () => {
const stateBodies: string[] = [];
const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : typeof input === "string" ? input : input.toString();
if (url.includes("/api/v1/system")) return Promise.resolve(jsonResponse({ name: "LumaOps", version: "0.1.0", openrgb_version: "1.0rc3", sdk_protocol: 5,
environment: "test", mock_mode: true, health: { status: "healthy", checked_at: "2026-07-14T12:00:00Z", components: { "openrgb-mock": { status: "healthy", message: "ok", connected: true } } },
devices: { total: 1, online: 1, offline: 0 }, active_scene: null, recent_commands: [], warnings: [], emergency_stop: false }));
if (url.includes("/api/v1/devices") && (init?.method ?? "GET") === "GET") return Promise.resolve(jsonResponse({ items: [{ id: "device-1", capabilities: { rgb: true }, read_only: false, blocked: false }], total: 1, limit: 500, offset: 0 }));
if (url.includes("/api/v1/scenes")) return Promise.resolve(jsonResponse({ items: [], total: 0, limit: 6, offset: 0 }));
if (url.includes("/state")) { if (typeof init?.body === "string") stateBodies.push(init.body); return Promise.resolve(jsonResponse({ id: "command-1", status: "succeeded" })); }
return Promise.resolve(jsonResponse({}));
});
vi.stubGlobal("fetch", fetchMock);
renderApp(<OverviewPage />);
await screen.findByText("Snelle bediening");
await userEvent.click(screen.getByRole("button", { name: /Kleur toepassen/i }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("/api/v1/devices/device-1/state"), expect.objectContaining({ method: "POST" })));
expect(stateBodies[0]).toContain('"colors"');
});
});
+97 -97
View File
@@ -1,97 +1,97 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Activity, Cable, CirclePower, Cpu, Lightbulb, ShieldAlert, Sparkles, Wifi } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { api, mutationId } from "../api/client";
import type { Device, Page, Scene, SystemStatus } from "../api/types";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, CardHeader, ErrorPanel, LoadingGrid, PageHeader, Stat, StatusBadge } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formatDate, hexToColor } from "../lib/utils";
export function OverviewPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const [color, setColor] = useState("#5660ff");
const [confirmEmergency, setConfirmEmergency] = useState(false);
const system = useQuery({ queryKey: ["system"], queryFn: () => api<SystemStatus>("/api/v1/system") });
const devices = useQuery({ queryKey: ["devices", "online"], queryFn: () => api<Page<Device>>("/api/v1/devices?online=true&limit=500") });
const scenes = useQuery({ queryKey: ["scenes"], queryFn: () => api<Page<Scene>>("/api/v1/scenes?limit=6") });
const quickColor = useMutation({
mutationFn: async () => {
const targets = devices.data?.items.filter((device) => device.capabilities.rgb && !device.read_only && !device.blocked) ?? [];
return Promise.allSettled(targets.map((device) => api(`/api/v1/devices/${device.id}/state`, {
method: "POST",
headers: { "Idempotency-Key": mutationId() },
body: JSON.stringify({ state: { power: true, colors: [hexToColor(color)] } }),
})));
},
onSuccess: (results) => {
const failed = results.filter((result) => result.status === "rejected").length;
notify(failed ? `${results.length - failed} apparaten bijgewerkt; ${failed} mislukt.` : "Kleur toegepast op alle geschikte apparaten.", failed ? "danger" : "success");
void queryClient.invalidateQueries({ queryKey: ["system"] });
},
});
const allOff = useMutation({
mutationFn: (emergency: boolean) => api(`/api/v1/commands/${emergency ? "emergency-stop" : "all-off"}`, { method: "POST" }),
onSuccess: (_, emergency) => { notify(emergency ? "Noodstop geactiveerd." : "Alles-uitopdracht voltooid."); void queryClient.invalidateQueries(); },
});
const applyScene = useMutation({
mutationFn: (id: string) => api(`/api/v1/scenes/${id}/apply`, { method: "POST", body: JSON.stringify({ rollback_on_failure: true }) }),
onSuccess: () => { notify("Scène toegepast."); void queryClient.invalidateQueries(); },
onError: (error) => notify(error instanceof Error ? error.message : "Scène mislukt.", "danger"),
});
if (system.isLoading) return <><PageHeader title={text("Overzicht", "Overview")} /><LoadingGrid /></>;
if (system.error || !system.data) return <ErrorPanel error={system.error} retry={() => void system.refetch()} />;
const status = system.data;
const openrgb = status.health.components["openrgb-local"] ?? status.health.components["openrgb-mock"];
return (
<>
<PageHeader eyebrow={text("Lokaal controlecentrum", "Local control center")} title={text("Goedenavond, Jens", "Good evening, Jens")} description={text("Je verlichting en OpenRGB-hardware in één rustig overzicht.", "Your lighting and OpenRGB hardware in one calm overview.")} actions={<><Button variant="secondary" onClick={() => allOff.mutate(false)} busy={allOff.isPending}><CirclePower size={16} /> {text("Alles uit", "All off")}</Button><Button variant="danger" onClick={() => setConfirmEmergency(true)}><ShieldAlert size={16} /> {text("Noodstop", "Emergency stop")}</Button></>} />
{status.emergency_stop ? <div className="alert alert--danger"><ShieldAlert size={20} /><div><strong>Globale noodstop actief</strong><p>Nieuwe hardwareopdrachten worden geblokkeerd totdat je de noodstop in Diagnostiek vrijgeeft.</p></div></div> : null}
<section className="grid grid--stats" aria-label="Systeemstatistieken">
<Stat label="Systeemstatus" value={<StatusBadge status={status.health.status} />} detail={`Gecontroleerd ${formatDate(status.health.checked_at)}`} icon={Activity} />
<Stat label="Apparaten online" value={`${status.devices.online} / ${status.devices.total}`} detail={`${status.devices.offline} offline`} icon={Cpu} />
<Stat label="OpenRGB Core" value={openrgb ? <StatusBadge status={openrgb.status} /> : "—"} detail={`1.0rc3 · SDK ${status.sdk_protocol}`} icon={Lightbulb} />
<Stat label="Connectoren" value={Object.keys(status.health.components).filter((key) => key.startsWith("openrgb")).length} detail="Actieve beheerkanalen" icon={Cable} />
</section>
<section className="dashboard-grid">
<Card className="quick-control">
<CardHeader title="Snelle bediening" description="Veilige statische kleur op alle geschikte apparaten." />
<div className="color-control">
<input aria-label="Snelle kleur" type="color" value={color} onChange={(event) => setColor(event.target.value)} />
<div><strong>{color.toUpperCase()}</strong><span>{devices.data?.items.length ?? 0} online doelen</span></div>
<Button onClick={() => quickColor.mutate()} busy={quickColor.isPending}><Sparkles size={16} /> Kleur toepassen</Button>
</div>
<div className="color-swatches" aria-label="Kleursuggesties">
{["#5660ff", "#00c2a8", "#ff7a59", "#f5c451", "#f7f7ff"].map((item) => <button key={item} aria-label={`Kies ${item}`} className={color === item ? "selected" : ""} style={{ background: item }} onClick={() => setColor(item)} />)}
</div>
</Card>
<Card>
<CardHeader title="Actieve scène" description="De laatst succesvol toegepaste LumaOps-scène." />
{status.active_scene ? <div className="active-scene"><span className="scene-orb" /><div><strong>{status.active_scene.name}</strong><span>{formatDate(status.active_scene.last_applied_at)}</span></div><Badge tone="accent">Actief</Badge></div> : <p className="muted">Nog geen scène toegepast.</p>}
</Card>
</section>
<section className="section-block">
<div className="section-heading"><div><h2>Laatst gebruikte scènes</h2><p>Start een sfeer zonder de pagina te verlaten.</p></div><Link className="text-link" to="/scenes">Alle scènes</Link></div>
<div className="grid grid--cards">
{scenes.data?.items.map((scene, index) => <Card key={scene.id} className="scene-card"><div className={`scene-gradient scene-gradient--${index % 4}`}><Sparkles size={20} /></div><div className="scene-card__body"><div><h3>{scene.name}</h3><p>{scene.item_count ?? 0} onderdelen · v{scene.version}</p></div><Button variant="secondary" onClick={() => applyScene.mutate(scene.id)} busy={applyScene.isPending}>Start</Button></div></Card>)}
{!scenes.isLoading && !scenes.data?.items.length ? <Card><p className="muted">Leg je huidige toestand vast als eerste scène.</p></Card> : null}
</div>
</section>
<section className="dashboard-grid">
<Card><CardHeader title="Recente opdrachten" description="Laatste wijzigingen via LumaOps." action={<Link to="/activity" className="text-link">Alles bekijken</Link>} />
<div className="timeline">{status.recent_commands.slice(0, 5).map((command) => <div className="timeline__item" key={command.id}><span className={`timeline__dot timeline__dot--${command.status}`} /><div><strong>{command.action === "set_state" ? "Apparaattoestand gewijzigd" : command.action}</strong><span>{formatDate(command.created_at)}</span></div><Badge tone={command.status === "succeeded" ? "success" : command.status === "failed" ? "danger" : "neutral"}>{command.status}</Badge></div>)}{!status.recent_commands.length ? <p className="muted">Nog geen opdrachten uitgevoerd.</p> : null}</div>
</Card>
<Card><CardHeader title="Waarschuwingen" description="Aandachtspunten met concrete herstelacties." action={<Wifi size={18} />} />
{status.warnings.length ? status.warnings.map((warning) => <div className="warning-row" key={warning.id}><span /><div><strong>{warning.title}</strong><p>{warning.message}</p></div></div>) : <div className="all-clear"><span><Lightbulb size={20} /></span><div><strong>Alles rustig</strong><p>Er zijn geen open waarschuwingen.</p></div></div>}
</Card>
</section>
<ConfirmDialog open={confirmEmergency} title="Globale noodstop activeren?" description="LumaOps probeert alle geschikte apparaten één keer uit te schakelen en blokkeert daarna nieuwe hardwareopdrachten." confirmLabel="Noodstop activeren" danger onClose={() => setConfirmEmergency(false)} onConfirm={() => { setConfirmEmergency(false); allOff.mutate(true); }} />
</>
);
}
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Activity, Cable, CirclePower, Cpu, Lightbulb, ShieldAlert, Sparkles, Wifi } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { api, mutationId } from "../api/client";
import type { Device, Page, Scene, SystemStatus } from "../api/types";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, CardHeader, ErrorPanel, LoadingGrid, PageHeader, Stat, StatusBadge } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formatDate, hexToColor } from "../lib/utils";
export function OverviewPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const [color, setColor] = useState("#5660ff");
const [confirmEmergency, setConfirmEmergency] = useState(false);
const system = useQuery({ queryKey: ["system"], queryFn: () => api<SystemStatus>("/api/v1/system") });
const devices = useQuery({ queryKey: ["devices", "online"], queryFn: () => api<Page<Device>>("/api/v1/devices?online=true&limit=500") });
const scenes = useQuery({ queryKey: ["scenes"], queryFn: () => api<Page<Scene>>("/api/v1/scenes?limit=6") });
const quickColor = useMutation({
mutationFn: async () => {
const targets = devices.data?.items.filter((device) => device.capabilities.rgb && !device.read_only && !device.blocked) ?? [];
return Promise.allSettled(targets.map((device) => api(`/api/v1/devices/${device.id}/state`, {
method: "POST",
headers: { "Idempotency-Key": mutationId() },
body: JSON.stringify({ state: { power: true, colors: [hexToColor(color)] } }),
})));
},
onSuccess: (results) => {
const failed = results.filter((result) => result.status === "rejected").length;
notify(failed ? `${results.length - failed} apparaten bijgewerkt; ${failed} mislukt.` : "Kleur toegepast op alle geschikte apparaten.", failed ? "danger" : "success");
void queryClient.invalidateQueries({ queryKey: ["system"] });
},
});
const allOff = useMutation({
mutationFn: (emergency: boolean) => api(`/api/v1/commands/${emergency ? "emergency-stop" : "all-off"}`, { method: "POST" }),
onSuccess: (_, emergency) => { notify(emergency ? "Noodstop geactiveerd." : "Alles-uitopdracht voltooid."); void queryClient.invalidateQueries(); },
});
const applyScene = useMutation({
mutationFn: (id: string) => api(`/api/v1/scenes/${id}/apply`, { method: "POST", body: JSON.stringify({ rollback_on_failure: true }) }),
onSuccess: () => { notify("Scène toegepast."); void queryClient.invalidateQueries(); },
onError: (error) => notify(error instanceof Error ? error.message : "Scène mislukt.", "danger"),
});
if (system.isLoading) return <><PageHeader title={text("Overzicht", "Overview")} /><LoadingGrid /></>;
if (system.error || !system.data) return <ErrorPanel error={system.error} retry={() => void system.refetch()} />;
const status = system.data;
const openrgb = status.health.components["openrgb-local"] ?? status.health.components["openrgb-mock"];
return (
<>
<PageHeader eyebrow={text("Lokaal controlecentrum", "Local control center")} title={text("Goedenavond, Jens", "Good evening, Jens")} description={text("Je verlichting en OpenRGB-hardware in één rustig overzicht.", "Your lighting and OpenRGB hardware in one calm overview.")} actions={<><Button variant="secondary" onClick={() => allOff.mutate(false)} busy={allOff.isPending}><CirclePower size={16} /> {text("Alles uit", "All off")}</Button><Button variant="danger" onClick={() => setConfirmEmergency(true)}><ShieldAlert size={16} /> {text("Noodstop", "Emergency stop")}</Button></>} />
{status.emergency_stop ? <div className="alert alert--danger"><ShieldAlert size={20} /><div><strong>Globale noodstop actief</strong><p>Nieuwe hardwareopdrachten worden geblokkeerd totdat je de noodstop in Diagnostiek vrijgeeft.</p></div></div> : null}
<section className="grid grid--stats" aria-label="Systeemstatistieken">
<Stat label="Systeemstatus" value={<StatusBadge status={status.health.status} />} detail={`Gecontroleerd ${formatDate(status.health.checked_at)}`} icon={Activity} />
<Stat label="Apparaten online" value={`${status.devices.online} / ${status.devices.total}`} detail={`${status.devices.offline} offline`} icon={Cpu} />
<Stat label="OpenRGB Core" value={openrgb ? <StatusBadge status={openrgb.status} /> : "—"} detail={`1.0rc3 · SDK ${status.sdk_protocol}`} icon={Lightbulb} />
<Stat label="Connectoren" value={Object.keys(status.health.components).filter((key) => key.startsWith("openrgb")).length} detail="Actieve beheerkanalen" icon={Cable} />
</section>
<section className="dashboard-grid">
<Card className="quick-control">
<CardHeader title="Snelle bediening" description="Veilige statische kleur op alle geschikte apparaten." />
<div className="color-control">
<input aria-label="Snelle kleur" type="color" value={color} onChange={(event) => setColor(event.target.value)} />
<div><strong>{color.toUpperCase()}</strong><span>{devices.data?.items.length ?? 0} online doelen</span></div>
<Button onClick={() => quickColor.mutate()} busy={quickColor.isPending}><Sparkles size={16} /> Kleur toepassen</Button>
</div>
<div className="color-swatches" aria-label="Kleursuggesties">
{["#5660ff", "#00c2a8", "#ff7a59", "#f5c451", "#f7f7ff"].map((item) => <button key={item} aria-label={`Kies ${item}`} className={color === item ? "selected" : ""} style={{ background: item }} onClick={() => setColor(item)} />)}
</div>
</Card>
<Card>
<CardHeader title="Actieve scène" description="De laatst succesvol toegepaste LumaOps-scène." />
{status.active_scene ? <div className="active-scene"><span className="scene-orb" /><div><strong>{status.active_scene.name}</strong><span>{formatDate(status.active_scene.last_applied_at)}</span></div><Badge tone="accent">Actief</Badge></div> : <p className="muted">Nog geen scène toegepast.</p>}
</Card>
</section>
<section className="section-block">
<div className="section-heading"><div><h2>Laatst gebruikte scènes</h2><p>Start een sfeer zonder de pagina te verlaten.</p></div><Link className="text-link" to="/scenes">Alle scènes</Link></div>
<div className="grid grid--cards">
{scenes.data?.items.map((scene, index) => <Card key={scene.id} className="scene-card"><div className={`scene-gradient scene-gradient--${index % 4}`}><Sparkles size={20} /></div><div className="scene-card__body"><div><h3>{scene.name}</h3><p>{scene.item_count ?? 0} onderdelen · v{scene.version}</p></div><Button variant="secondary" onClick={() => applyScene.mutate(scene.id)} busy={applyScene.isPending}>Start</Button></div></Card>)}
{!scenes.isLoading && !scenes.data?.items.length ? <Card><p className="muted">Leg je huidige toestand vast als eerste scène.</p></Card> : null}
</div>
</section>
<section className="dashboard-grid">
<Card><CardHeader title="Recente opdrachten" description="Laatste wijzigingen via LumaOps." action={<Link to="/activity" className="text-link">Alles bekijken</Link>} />
<div className="timeline">{status.recent_commands.slice(0, 5).map((command) => <div className="timeline__item" key={command.id}><span className={`timeline__dot timeline__dot--${command.status}`} /><div><strong>{command.action === "set_state" ? "Apparaattoestand gewijzigd" : command.action}</strong><span>{formatDate(command.created_at)}</span></div><Badge tone={command.status === "succeeded" ? "success" : command.status === "failed" ? "danger" : "neutral"}>{command.status}</Badge></div>)}{!status.recent_commands.length ? <p className="muted">Nog geen opdrachten uitgevoerd.</p> : null}</div>
</Card>
<Card><CardHeader title="Waarschuwingen" description="Aandachtspunten met concrete herstelacties." action={<Wifi size={18} />} />
{status.warnings.length ? status.warnings.map((warning) => <div className="warning-row" key={warning.id}><span /><div><strong>{warning.title}</strong><p>{warning.message}</p></div></div>) : <div className="all-clear"><span><Lightbulb size={20} /></span><div><strong>Alles rustig</strong><p>Er zijn geen open waarschuwingen.</p></div></div>}
</Card>
</section>
<ConfirmDialog open={confirmEmergency} title="Globale noodstop activeren?" description="LumaOps probeert alle geschikte apparaten één keer uit te schakelen en blokkeert daarna nieuwe hardwareopdrachten." confirmLabel="Noodstop activeren" danger onClose={() => setConfirmEmergency(false)} onConfirm={() => { setConfirmEmergency(false); allOff.mutate(true); }} />
</>
);
}
+67 -67
View File
@@ -1,67 +1,67 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Camera, Copy, Download, Eye, Pencil, Play, Plus, Sparkles, Trash2, Upload } from "lucide-react";
import { useRef, useState, type ChangeEvent, type FormEvent } from "react";
import { api } from "../api/client";
import type { Page, Scene, SceneApplyResult } from "../api/types";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { SceneEditor } from "../components/SceneEditor";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, EmptyState, ErrorPanel, Field, Input, LoadingGrid, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formatDate } from "../lib/utils";
interface ScenePreview {
scene: Scene;
deviceCount: number;
}
export function ScenesPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const [capture, setCapture] = useState(false);
const [createEmpty, setCreateEmpty] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteScene, setDeleteScene] = useState<Scene | null>(null);
const [preview, setPreview] = useState<ScenePreview | null>(null);
const importInput = useRef<HTMLInputElement>(null);
const query = useQuery({ queryKey: ["scenes"], queryFn: () => api<Page<Scene>>("/api/v1/scenes?limit=200") });
const refresh = () => void queryClient.invalidateQueries({ queryKey: ["scenes"] });
const captureMutation = useMutation({ mutationFn: (name: string) => api<Scene>("/api/v1/scenes/capture", { method: "POST", body: JSON.stringify({ name }) }), onSuccess: () => { notify(text("Huidige toestand als scène vastgelegd.", "Current state captured as a scene.")); setCapture(false); refresh(); }, onError: (error) => notify(error instanceof Error ? error.message : text("Vastleggen mislukt.", "Capture failed."), "danger") });
const createMutation = useMutation({ mutationFn: (name: string) => api<Scene>("/api/v1/scenes", { method: "POST", body: JSON.stringify({ name, items: [] }) }), onSuccess: (scene) => { notify(text("Lege scène gemaakt.", "Empty scene created.")); setCreateEmpty(false); setEditingId(scene.id); refresh(); }, onError: (error) => notify(error instanceof Error ? error.message : text("Aanmaken mislukt.", "Create failed."), "danger") });
const duplicate = useMutation({ mutationFn: (id: string) => api<Scene>(`/api/v1/scenes/${id}/duplicate`, { method: "POST", body: JSON.stringify({}) }), onSuccess: () => { notify(text("Scène gedupliceerd.", "Scene duplicated.")); refresh(); }, onError: (error) => notify(error instanceof Error ? error.message : text("Dupliceren mislukt.", "Duplicate failed."), "danger") });
const importScene = useMutation({ mutationFn: (payload: unknown) => api<Scene>("/api/v1/scenes/import", { method: "POST", body: JSON.stringify(payload) }), onSuccess: () => { notify(text("Scène geïmporteerd.", "Scene imported.")); refresh(); }, onError: (error) => notify(error instanceof Error ? error.message : text("Importeren mislukt.", "Import failed."), "danger") });
const apply = useMutation({
mutationFn: (id: string) => api<SceneApplyResult>(`/api/v1/scenes/${id}/apply`, { method: "POST", body: JSON.stringify({ rollback_on_failure: true }) }),
onSuccess: (result) => {
const summary = text(`${result.applied.length} toegepast, ${result.skipped.length} overgeslagen, ${result.failed.length} mislukt.`, `${result.applied.length} applied, ${result.skipped.length} skipped, ${result.failed.length} failed.`);
notify(summary, result.status === "succeeded" ? "success" : "danger");
setPreview(null);
void queryClient.invalidateQueries();
},
onError: (error) => notify(error instanceof Error ? error.message : text("Scène mislukt.", "Scene failed."), "danger"),
});
const inspect = useMutation({
mutationFn: async (scene: Scene) => ({ scene, result: await api<{ device_count: number }>(`/api/v1/scenes/${scene.id}/preview`) }),
onSuccess: ({ scene, result }) => setPreview({ scene, deviceCount: result.device_count }),
onError: (error) => notify(error instanceof Error ? error.message : text("Preview mislukt.", "Preview failed."), "danger"),
});
const remove = useMutation({
mutationFn: (id: string) => api(`/api/v1/scenes/${id}`, { method: "DELETE" }),
onSuccess: () => { notify(text("Scène verwijderd.", "Scene deleted.")); setDeleteScene(null); refresh(); },
onError: (error) => notify(error instanceof Error ? error.message : text("Verwijderen mislukt.", "Delete failed."), "danger"),
});
const submit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); const name = new FormData(event.currentTarget).get("name"); if (typeof name === "string") captureMutation.mutate(name); };
const submitEmpty = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); const name = new FormData(event.currentTarget).get("name"); if (typeof name === "string") createMutation.mutate(name); };
const onImport = async (event: ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; try { importScene.mutate(JSON.parse(await file.text()) as unknown); } catch { notify(text("Dit bestand bevat geen geldige JSON.", "This file does not contain valid JSON."), "danger"); } };
return <>
<PageHeader eyebrow={text("LumaOps-scènelaag", "LumaOps scene layer")} title={text("Scènes", "Scenes")} description={text("Leg toestanden over apparaten en groepen vast met preview, rapportage en best-effort rollback.", "Capture state across devices and groups with preview, reporting, and best-effort rollback.")} actions={<><input ref={importInput} className="visually-hidden" type="file" accept="application/json,.json" onChange={(event) => void onImport(event)} aria-label={text("Scènebestand importeren", "Import scene file")} /><Button variant="ghost" onClick={() => importInput.current?.click()} busy={importScene.isPending}><Upload size={16} /> {text("Importeren", "Import")}</Button><Button variant="secondary" onClick={() => setCreateEmpty(true)}><Plus size={16} /> {text("Lege scène", "Empty scene")}</Button><Button onClick={() => setCapture(true)}><Camera size={16} /> {text("Huidige toestand vastleggen", "Capture current state")}</Button></>} />
{editingId ? <SceneEditor sceneId={editingId} onClose={() => setEditingId(null)} /> : null}
{capture ? <Card className="inline-form"><form onSubmit={submit}><Field label={text("Naam voor de scène", "Scene name")}><Input required name="name" autoFocus placeholder={text("Bijvoorbeeld Avondrust", "For example Evening calm")} /></Field><div className="form-actions"><Button type="button" variant="ghost" onClick={() => setCapture(false)}>{text("Annuleren", "Cancel")}</Button><Button type="submit" busy={captureMutation.isPending}>{text("Vastleggen", "Capture")}</Button></div></form></Card> : null}
{createEmpty ? <Card className="inline-form"><form onSubmit={submitEmpty}><Field label={text("Naam voor de lege scène", "Empty scene name")}><Input required name="name" autoFocus placeholder={text("Bijvoorbeeld Filmavond", "For example Movie night")} /></Field><div className="form-actions"><Button type="button" variant="ghost" onClick={() => setCreateEmpty(false)}>{text("Annuleren", "Cancel")}</Button><Button type="submit" busy={createMutation.isPending}>{text("Maken", "Create")}</Button></div></form></Card> : null}
{query.isLoading ? <LoadingGrid /> : query.error ? <ErrorPanel error={query.error} retry={() => void query.refetch()} /> : !query.data?.items.length ? <EmptyState icon={Sparkles} title={text("Nog geen scènes", "No scenes yet")} description={text("Leg de huidige toestand van je online apparaten vast als eerste scène.", "Capture the current state of your online devices as your first scene.")} action={<Button onClick={() => setCapture(true)}>{text("Toestand vastleggen", "Capture state")}</Button>} /> : <div className="grid grid--scenes">{query.data.items.map((scene, index) => <Card key={scene.id} className="scene-tile"><div className={`scene-tile__visual scene-gradient--${index % 4}`}><Sparkles size={28} /><Badge tone="accent">v{scene.version}</Badge></div><div className="scene-tile__content"><div><h2>{scene.name}</h2><p>{scene.description || text(`${scene.item_count ?? 0} doeltoestanden`, `${scene.item_count ?? 0} target states`)}</p></div><dl><div><dt>{text("Laatst toegepast", "Last applied")}</dt><dd>{formatDate(scene.last_applied_at)}</dd></div><div><dt>{text("Rollback", "Rollback")}</dt><dd>{text("Ingeschakeld", "Enabled")}</dd></div></dl><div className="scene-tile__actions"><Button onClick={() => apply.mutate(scene.id)} busy={apply.isPending}><Play size={15} /> {text("Start", "Run")}</Button><Button variant="ghost" title={text("Preview", "Preview")} aria-label={text(`Preview van ${scene.name}`, `Preview ${scene.name}`)} busy={inspect.isPending} onClick={() => inspect.mutate(scene)}><Eye size={16} /></Button><Button variant="ghost" title={text("Bewerken", "Edit")} aria-label={text(`${scene.name} bewerken`, `Edit ${scene.name}`)} onClick={() => setEditingId(scene.id)}><Pencil size={16} /></Button><Button variant="ghost" title={text("Dupliceren", "Duplicate")} aria-label={text(`${scene.name} dupliceren`, `Duplicate ${scene.name}`)} onClick={() => duplicate.mutate(scene.id)} busy={duplicate.isPending}><Copy size={16} /></Button><a className="button button--ghost" href={`/api/v1/scenes/${scene.id}/export`} title={text("Exporteren", "Export")} aria-label={text("Scène exporteren", "Export scene")}><Download size={16} /></a><Button variant="ghost" title={text("Verwijderen", "Delete")} aria-label={text(`${scene.name} verwijderen`, `Delete ${scene.name}`)} onClick={() => setDeleteScene(scene)}><Trash2 size={16} /></Button></div></div></Card>)}</div>}
<ConfirmDialog open={Boolean(deleteScene)} title={text("Scène verwijderen?", "Delete scene?")} description={text(`“${deleteScene?.name ?? ""}” en alle doeltoestanden worden verwijderd.`, `“${deleteScene?.name ?? ""}” and all target states will be deleted.`)} confirmLabel={text("Verwijderen", "Delete")} danger busy={remove.isPending} onClose={() => setDeleteScene(null)} onConfirm={() => deleteScene && remove.mutate(deleteScene.id)} />
<ConfirmDialog open={Boolean(preview)} title={text("Scènepreview", "Scene preview")} description={text(`“${preview?.scene.name ?? ""}” stuurt opdrachten naar ${preview?.deviceCount ?? 0} unieke apparaten. Bij een vereiste fout worden reeds toegepaste opdrachten teruggedraaid.`, `“${preview?.scene.name ?? ""}” sends commands to ${preview?.deviceCount ?? 0} unique devices. On a required failure, applied commands are rolled back.`)} confirmLabel={text("Nu toepassen", "Apply now")} busy={apply.isPending} onClose={() => setPreview(null)} onConfirm={() => preview && apply.mutate(preview.scene.id)} />
</>;
}
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Camera, Copy, Download, Eye, Pencil, Play, Plus, Sparkles, Trash2, Upload } from "lucide-react";
import { useRef, useState, type ChangeEvent, type FormEvent } from "react";
import { api } from "../api/client";
import type { Page, Scene, SceneApplyResult } from "../api/types";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { SceneEditor } from "../components/SceneEditor";
import { useToast } from "../components/Toast";
import { Badge, Button, Card, EmptyState, ErrorPanel, Field, Input, LoadingGrid, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formatDate } from "../lib/utils";
interface ScenePreview {
scene: Scene;
deviceCount: number;
}
export function ScenesPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const [capture, setCapture] = useState(false);
const [createEmpty, setCreateEmpty] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [deleteScene, setDeleteScene] = useState<Scene | null>(null);
const [preview, setPreview] = useState<ScenePreview | null>(null);
const importInput = useRef<HTMLInputElement>(null);
const query = useQuery({ queryKey: ["scenes"], queryFn: () => api<Page<Scene>>("/api/v1/scenes?limit=200") });
const refresh = () => void queryClient.invalidateQueries({ queryKey: ["scenes"] });
const captureMutation = useMutation({ mutationFn: (name: string) => api<Scene>("/api/v1/scenes/capture", { method: "POST", body: JSON.stringify({ name }) }), onSuccess: () => { notify(text("Huidige toestand als scène vastgelegd.", "Current state captured as a scene.")); setCapture(false); refresh(); }, onError: (error) => notify(error instanceof Error ? error.message : text("Vastleggen mislukt.", "Capture failed."), "danger") });
const createMutation = useMutation({ mutationFn: (name: string) => api<Scene>("/api/v1/scenes", { method: "POST", body: JSON.stringify({ name, items: [] }) }), onSuccess: (scene) => { notify(text("Lege scène gemaakt.", "Empty scene created.")); setCreateEmpty(false); setEditingId(scene.id); refresh(); }, onError: (error) => notify(error instanceof Error ? error.message : text("Aanmaken mislukt.", "Create failed."), "danger") });
const duplicate = useMutation({ mutationFn: (id: string) => api<Scene>(`/api/v1/scenes/${id}/duplicate`, { method: "POST", body: JSON.stringify({}) }), onSuccess: () => { notify(text("Scène gedupliceerd.", "Scene duplicated.")); refresh(); }, onError: (error) => notify(error instanceof Error ? error.message : text("Dupliceren mislukt.", "Duplicate failed."), "danger") });
const importScene = useMutation({ mutationFn: (payload: unknown) => api<Scene>("/api/v1/scenes/import", { method: "POST", body: JSON.stringify(payload) }), onSuccess: () => { notify(text("Scène geïmporteerd.", "Scene imported.")); refresh(); }, onError: (error) => notify(error instanceof Error ? error.message : text("Importeren mislukt.", "Import failed."), "danger") });
const apply = useMutation({
mutationFn: (id: string) => api<SceneApplyResult>(`/api/v1/scenes/${id}/apply`, { method: "POST", body: JSON.stringify({ rollback_on_failure: true }) }),
onSuccess: (result) => {
const summary = text(`${result.applied.length} toegepast, ${result.skipped.length} overgeslagen, ${result.failed.length} mislukt.`, `${result.applied.length} applied, ${result.skipped.length} skipped, ${result.failed.length} failed.`);
notify(summary, result.status === "succeeded" ? "success" : "danger");
setPreview(null);
void queryClient.invalidateQueries();
},
onError: (error) => notify(error instanceof Error ? error.message : text("Scène mislukt.", "Scene failed."), "danger"),
});
const inspect = useMutation({
mutationFn: async (scene: Scene) => ({ scene, result: await api<{ device_count: number }>(`/api/v1/scenes/${scene.id}/preview`) }),
onSuccess: ({ scene, result }) => setPreview({ scene, deviceCount: result.device_count }),
onError: (error) => notify(error instanceof Error ? error.message : text("Preview mislukt.", "Preview failed."), "danger"),
});
const remove = useMutation({
mutationFn: (id: string) => api(`/api/v1/scenes/${id}`, { method: "DELETE" }),
onSuccess: () => { notify(text("Scène verwijderd.", "Scene deleted.")); setDeleteScene(null); refresh(); },
onError: (error) => notify(error instanceof Error ? error.message : text("Verwijderen mislukt.", "Delete failed."), "danger"),
});
const submit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); const name = new FormData(event.currentTarget).get("name"); if (typeof name === "string") captureMutation.mutate(name); };
const submitEmpty = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); const name = new FormData(event.currentTarget).get("name"); if (typeof name === "string") createMutation.mutate(name); };
const onImport = async (event: ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; try { importScene.mutate(JSON.parse(await file.text()) as unknown); } catch { notify(text("Dit bestand bevat geen geldige JSON.", "This file does not contain valid JSON."), "danger"); } };
return <>
<PageHeader eyebrow={text("LumaOps-scènelaag", "LumaOps scene layer")} title={text("Scènes", "Scenes")} description={text("Leg toestanden over apparaten en groepen vast met preview, rapportage en best-effort rollback.", "Capture state across devices and groups with preview, reporting, and best-effort rollback.")} actions={<><input ref={importInput} className="visually-hidden" type="file" accept="application/json,.json" onChange={(event) => void onImport(event)} aria-label={text("Scènebestand importeren", "Import scene file")} /><Button variant="ghost" onClick={() => importInput.current?.click()} busy={importScene.isPending}><Upload size={16} /> {text("Importeren", "Import")}</Button><Button variant="secondary" onClick={() => setCreateEmpty(true)}><Plus size={16} /> {text("Lege scène", "Empty scene")}</Button><Button onClick={() => setCapture(true)}><Camera size={16} /> {text("Huidige toestand vastleggen", "Capture current state")}</Button></>} />
{editingId ? <SceneEditor sceneId={editingId} onClose={() => setEditingId(null)} /> : null}
{capture ? <Card className="inline-form"><form onSubmit={submit}><Field label={text("Naam voor de scène", "Scene name")}><Input required name="name" autoFocus placeholder={text("Bijvoorbeeld Avondrust", "For example Evening calm")} /></Field><div className="form-actions"><Button type="button" variant="ghost" onClick={() => setCapture(false)}>{text("Annuleren", "Cancel")}</Button><Button type="submit" busy={captureMutation.isPending}>{text("Vastleggen", "Capture")}</Button></div></form></Card> : null}
{createEmpty ? <Card className="inline-form"><form onSubmit={submitEmpty}><Field label={text("Naam voor de lege scène", "Empty scene name")}><Input required name="name" autoFocus placeholder={text("Bijvoorbeeld Filmavond", "For example Movie night")} /></Field><div className="form-actions"><Button type="button" variant="ghost" onClick={() => setCreateEmpty(false)}>{text("Annuleren", "Cancel")}</Button><Button type="submit" busy={createMutation.isPending}>{text("Maken", "Create")}</Button></div></form></Card> : null}
{query.isLoading ? <LoadingGrid /> : query.error ? <ErrorPanel error={query.error} retry={() => void query.refetch()} /> : !query.data?.items.length ? <EmptyState icon={Sparkles} title={text("Nog geen scènes", "No scenes yet")} description={text("Leg de huidige toestand van je online apparaten vast als eerste scène.", "Capture the current state of your online devices as your first scene.")} action={<Button onClick={() => setCapture(true)}>{text("Toestand vastleggen", "Capture state")}</Button>} /> : <div className="grid grid--scenes">{query.data.items.map((scene, index) => <Card key={scene.id} className="scene-tile"><div className={`scene-tile__visual scene-gradient--${index % 4}`}><Sparkles size={28} /><Badge tone="accent">v{scene.version}</Badge></div><div className="scene-tile__content"><div><h2>{scene.name}</h2><p>{scene.description || text(`${scene.item_count ?? 0} doeltoestanden`, `${scene.item_count ?? 0} target states`)}</p></div><dl><div><dt>{text("Laatst toegepast", "Last applied")}</dt><dd>{formatDate(scene.last_applied_at)}</dd></div><div><dt>{text("Rollback", "Rollback")}</dt><dd>{text("Ingeschakeld", "Enabled")}</dd></div></dl><div className="scene-tile__actions"><Button onClick={() => apply.mutate(scene.id)} busy={apply.isPending}><Play size={15} /> {text("Start", "Run")}</Button><Button variant="ghost" title={text("Preview", "Preview")} aria-label={text(`Preview van ${scene.name}`, `Preview ${scene.name}`)} busy={inspect.isPending} onClick={() => inspect.mutate(scene)}><Eye size={16} /></Button><Button variant="ghost" title={text("Bewerken", "Edit")} aria-label={text(`${scene.name} bewerken`, `Edit ${scene.name}`)} onClick={() => setEditingId(scene.id)}><Pencil size={16} /></Button><Button variant="ghost" title={text("Dupliceren", "Duplicate")} aria-label={text(`${scene.name} dupliceren`, `Duplicate ${scene.name}`)} onClick={() => duplicate.mutate(scene.id)} busy={duplicate.isPending}><Copy size={16} /></Button><a className="button button--ghost" href={`/api/v1/scenes/${scene.id}/export`} title={text("Exporteren", "Export")} aria-label={text("Scène exporteren", "Export scene")}><Download size={16} /></a><Button variant="ghost" title={text("Verwijderen", "Delete")} aria-label={text(`${scene.name} verwijderen`, `Delete ${scene.name}`)} onClick={() => setDeleteScene(scene)}><Trash2 size={16} /></Button></div></div></Card>)}</div>}
<ConfirmDialog open={Boolean(deleteScene)} title={text("Scène verwijderen?", "Delete scene?")} description={text(`“${deleteScene?.name ?? ""}” en alle doeltoestanden worden verwijderd.`, `“${deleteScene?.name ?? ""}” and all target states will be deleted.`)} confirmLabel={text("Verwijderen", "Delete")} danger busy={remove.isPending} onClose={() => setDeleteScene(null)} onConfirm={() => deleteScene && remove.mutate(deleteScene.id)} />
<ConfirmDialog open={Boolean(preview)} title={text("Scènepreview", "Scene preview")} description={text(`“${preview?.scene.name ?? ""}” stuurt opdrachten naar ${preview?.deviceCount ?? 0} unieke apparaten. Bij een vereiste fout worden reeds toegepaste opdrachten teruggedraaid.`, `“${preview?.scene.name ?? ""}” sends commands to ${preview?.deviceCount ?? 0} unique devices. On a required failure, applied commands are rolled back.`)} confirmLabel={text("Nu toepassen", "Apply now")} busy={apply.isPending} onClose={() => setPreview(null)} onConfirm={() => preview && apply.mutate(preview.scene.id)} />
</>;
}
+19 -19
View File
@@ -1,19 +1,19 @@
import { Languages, LockKeyhole, Moon, Network, Save, Sun, SunMoon } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { api } from "../api/client";
import { useToast } from "../components/Toast";
import { Button, Card, CardHeader, Field, PageHeader, Select } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { useTheme, type Theme } from "../lib/theme";
export function SettingsPage() {
const { language, setLanguage, text } = useI18n();
const { theme, setTheme } = useTheme();
const { notify } = useToast();
const save = useMutation({ mutationFn: ({ key, value }: { key: string; value: unknown }) => api(`/api/v1/settings/${key}`, { method: "PUT", body: JSON.stringify({ value }) }), onSuccess: () => notify("Instelling opgeslagen.") });
return <>
<PageHeader eyebrow={text("Persoonlijke voorkeuren", "Personal preferences")} title={text("Instellingen", "Settings")} description={text("Interfacevoorkeuren worden lokaal toegepast; deploymentkritische waarden blijven in de containeromgeving.", "Interface preferences apply locally; deployment-critical values remain in the container environment.")} actions={<Button onClick={() => { save.mutate({ key: "language", value: language }); save.mutate({ key: "theme", value: theme }); }} busy={save.isPending}><Save size={16} /> {text("Opslaan", "Save")}</Button>} />
<div className="settings-grid"><Card><CardHeader title="Weergave" description="Taal en thema worden ook in deze browser onthouden." /><div className="form-stack"><Field label="Taal"><div className="setting-select"><Languages size={18} /><Select value={language} onChange={(event) => setLanguage(event.target.value === "en" ? "en" : "nl")}><option value="nl">Nederlands</option><option value="en">English</option></Select></div></Field><Field label="Thema"><div className="theme-options">{(["light", "dark", "system"] as Theme[]).map((item) => <button className={theme === item ? "active" : ""} onClick={() => setTheme(item)} key={item}>{item === "light" ? <Sun size={18} /> : item === "dark" ? <Moon size={18} /> : <SunMoon size={18} />}<span>{item === "light" ? "Licht" : item === "dark" ? "Donker" : "Systeem"}</span></button>)}</div></Field></div></Card>
<Card><CardHeader title="Netwerk & beveiliging" description="Alleen informatief; wijzig deze invarianten via Unraid-containerinstellingen." /><dl className="properties"><div><dt><Network size={15} /> Webinterface</dt><dd>0.0.0.0 · configureerbare APP_PORT</dd></div><div><dt><LockKeyhole size={15} /> OpenRGB SDK</dt><dd>127.0.0.1:6742 · niet gepubliceerd</dd></div><div><dt><LockKeyhole size={15} /> Externe toegang</dt><dd>Standaard uit</dd></div></dl><p className="mini-warning">Forwarded headers worden alleen van expliciet vertrouwde proxy-adressen geaccepteerd.</p></Card></div>
</>;
}
import { Languages, LockKeyhole, Moon, Network, Save, Sun, SunMoon } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { api } from "../api/client";
import { useToast } from "../components/Toast";
import { Button, Card, CardHeader, Field, PageHeader, Select } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { useTheme, type Theme } from "../lib/theme";
export function SettingsPage() {
const { language, setLanguage, text } = useI18n();
const { theme, setTheme } = useTheme();
const { notify } = useToast();
const save = useMutation({ mutationFn: ({ key, value }: { key: string; value: unknown }) => api(`/api/v1/settings/${key}`, { method: "PUT", body: JSON.stringify({ value }) }), onSuccess: () => notify("Instelling opgeslagen.") });
return <>
<PageHeader eyebrow={text("Persoonlijke voorkeuren", "Personal preferences")} title={text("Instellingen", "Settings")} description={text("Interfacevoorkeuren worden lokaal toegepast; deploymentkritische waarden blijven in de containeromgeving.", "Interface preferences apply locally; deployment-critical values remain in the container environment.")} actions={<Button onClick={() => { save.mutate({ key: "language", value: language }); save.mutate({ key: "theme", value: theme }); }} busy={save.isPending}><Save size={16} /> {text("Opslaan", "Save")}</Button>} />
<div className="settings-grid"><Card><CardHeader title="Weergave" description="Taal en thema worden ook in deze browser onthouden." /><div className="form-stack"><Field label="Taal"><div className="setting-select"><Languages size={18} /><Select value={language} onChange={(event) => setLanguage(event.target.value === "en" ? "en" : "nl")}><option value="nl">Nederlands</option><option value="en">English</option></Select></div></Field><Field label="Thema"><div className="theme-options">{(["light", "dark", "system"] as Theme[]).map((item) => <button className={theme === item ? "active" : ""} onClick={() => setTheme(item)} key={item}>{item === "light" ? <Sun size={18} /> : item === "dark" ? <Moon size={18} /> : <SunMoon size={18} />}<span>{item === "light" ? "Licht" : item === "dark" ? "Donker" : "Systeem"}</span></button>)}</div></Field></div></Card>
<Card><CardHeader title="Netwerk & beveiliging" description="Alleen informatief; wijzig deze invarianten via Unraid-containerinstellingen." /><dl className="properties"><div><dt><Network size={15} /> Webinterface</dt><dd>0.0.0.0 · configureerbare APP_PORT</dd></div><div><dt><LockKeyhole size={15} /> OpenRGB SDK</dt><dd>127.0.0.1:6742 · niet gepubliceerd</dd></div><div><dt><LockKeyhole size={15} /> Externe toegang</dt><dd>Standaard uit</dd></div></dl><p className="mini-warning">Forwarded headers worden alleen van expliciet vertrouwde proxy-adressen geaccepteerd.</p></Card></div>
</>;
}
+23 -23
View File
@@ -1,23 +1,23 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { jsonResponse, renderApp } from "../test/render";
import { SetupPage } from "./SetupPage";
describe("SetupPage", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({
completed: false,
current_step: "welcome",
report: {},
})));
});
it("handles a fresh empty persisted report before the first inspection", async () => {
const user = userEvent.setup();
renderApp(<SetupPage />, "/setup");
await user.click(await screen.findByRole("button", { name: "Configuratie starten" }));
expect(await screen.findByRole("heading", { name: "Opslagrechten" })).toBeInTheDocument();
expect(screen.getByText("Voer de systeemcontrole uit om mountrechten te testen.")).toBeInTheDocument();
});
});
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { jsonResponse, renderApp } from "../test/render";
import { SetupPage } from "./SetupPage";
describe("SetupPage", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({
completed: false,
current_step: "welcome",
report: {},
})));
});
it("handles a fresh empty persisted report before the first inspection", async () => {
const user = userEvent.setup();
renderApp(<SetupPage />, "/setup");
await user.click(await screen.findByRole("button", { name: "Configuratie starten" }));
expect(await screen.findByRole("heading", { name: "Opslagrechten" })).toBeInTheDocument();
expect(screen.getByText("Voer de systeemcontrole uit om mountrechten te testen.")).toBeInTheDocument();
});
});
+83 -83
View File
@@ -1,83 +1,83 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ChevronRight, CircleCheck, Cpu, Database, HardDrive, Lightbulb, Network, ScanSearch, Server, ShieldCheck, Usb, Wrench } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import type { ComponentHealth, SetupState } from "../api/types";
import { useToast } from "../components/Toast";
import { Badge, Button, EmptyState, ErrorPanel, StatusBadge } from "../components/ui";
interface PathStatus { path: string; exists: boolean; readable: boolean; writable: boolean }
interface NodeStatus { path: string; readable: boolean; writable: boolean }
interface Recovery { area: string; message: string }
interface SetupReport {
storage: PathStatus[];
openrgb: ComponentHealth | null;
usb_devices: NodeStatus[];
i2c_devices: NodeStatus[];
serial_devices: NodeStatus[];
recovery: Recovery[];
checked_at: string;
openrgb_inventory?: Record<string, unknown>;
}
const steps = [
["Welkom", Lightbulb], ["Opslagrechten", HardDrive], ["OpenRGB-proces", Server], ["SDK-verbinding", Network],
["USB-inventory", Usb], ["I²C-inventory", Cpu], ["OpenRGB-detectie", ScanSearch], ["Toegankelijkheid", ShieldCheck],
["Netwerkdiscovery", Network], ["Appdata & back-up", Database], ["Systeemrapport", CircleCheck],
] as const;
export function SetupPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { notify } = useToast();
const [active, setActive] = useState(0);
const [networkDiscovery, setNetworkDiscovery] = useState(false);
const [appdata, setAppdata] = useState(false);
const [backup, setBackup] = useState(false);
const state = useQuery({ queryKey: ["setup"], queryFn: () => api<SetupState>("/api/v1/setup") });
const inspect = useMutation({ mutationFn: () => api<SetupReport>(`/api/v1/setup/inspect?include_network=${networkDiscovery}`, { method: "POST" }), onSuccess: () => { notify("Systeemcontrole afgerond."); setActive(10); }, onError: (error) => notify(error instanceof Error ? error.message : "Controle mislukt.", "danger") });
const complete = useMutation({ mutationFn: () => api<SetupState>("/api/v1/setup/complete", { method: "POST", body: JSON.stringify({ appdata_confirmed: appdata, backup_location_confirmed: backup }) }), onSuccess: (result) => { queryClient.setQueryData(["setup"], result); notify("LumaOps is klaar voor gebruik."); void navigate("/", { replace: true }); }, onError: (error) => notify(error instanceof Error ? error.message : "Setup afronden mislukt.", "danger") });
const report = normalizeSetupReport(inspect.data ?? state.data?.report);
if (state.error) return <ErrorPanel error={state.error} retry={() => void state.refetch()} />;
return <div className="setup-shell">
<aside className="setup-sidebar"><div className="brand"><span className="brand__mark"><Lightbulb size={20} /></span><div className="brand__text"><strong>LumaOps</strong><span>Eerste configuratie</span></div></div><ol>{steps.map(([label, Icon], index) => <li key={label} className={active === index ? "active" : index < active ? "complete" : ""}><button onClick={() => setActive(index)}><span>{index < active ? <Check size={15} /> : <Icon size={16} />}</span><div><small>Stap {index + 1}</small><strong>{label}</strong></div></button></li>)}</ol></aside>
<main className="setup-main">
<div className="setup-progress"><span>Stap {active + 1} van {steps.length}</span><div><i style={{ width: `${((active + 1) / steps.length) * 100}%` }} /></div></div>
{active === 0 ? <Welcome onNext={() => setActive(1)} /> : null}
{active >= 1 && active <= 8 ? <CheckStep index={active} report={report} networkDiscovery={networkDiscovery} setNetworkDiscovery={setNetworkDiscovery} busy={inspect.isPending} onInspect={() => inspect.mutate()} onPrevious={() => setActive((value) => Math.max(0, value - 1))} onNext={() => setActive((value) => Math.min(10, value + 1))} /> : null}
{active === 9 ? <StorageConfirm appdata={appdata} backup={backup} onAppdata={setAppdata} onBackup={setBackup} onPrevious={() => setActive(8)} onNext={() => setActive(10)} /> : null}
{active === 10 ? <Report report={report} appdata={appdata} backup={backup} busy={complete.isPending} onInspect={() => inspect.mutate()} onComplete={() => complete.mutate()} /> : null}
</main>
</div>;
}
function normalizeSetupReport(value: unknown): SetupReport | undefined {
if (!value || typeof value !== "object") return undefined;
const candidate = value as Partial<SetupReport>;
return {
storage: Array.isArray(candidate.storage) ? candidate.storage : [],
openrgb: candidate.openrgb ?? null,
usb_devices: Array.isArray(candidate.usb_devices) ? candidate.usb_devices : [],
i2c_devices: Array.isArray(candidate.i2c_devices) ? candidate.i2c_devices : [],
serial_devices: Array.isArray(candidate.serial_devices) ? candidate.serial_devices : [],
recovery: Array.isArray(candidate.recovery) ? candidate.recovery : [],
checked_at: typeof candidate.checked_at === "string" ? candidate.checked_at : "",
openrgb_inventory: candidate.openrgb_inventory,
};
}
function Welcome({ onNext }: { onNext: () => void }) { return <div className="setup-panel setup-welcome"><span className="setup-hero-icon"><Lightbulb size={34} /></span><Badge tone="accent">OpenRGB 1.0rc3 · SDK 5</Badge><h1>Welkom bij LumaOps</h1><p>We controleren eerst veilig je container, opslag en OpenRGB-verbinding. De wizard voert geen wijzigende hardwareopdrachten uit.</p><div className="setup-promises"><div><ShieldCheck size={19} /><span><strong>Loopback-only SDK</strong>Poort 6742 blijft intern.</span></div><div><Wrench size={19} /><span><strong>Concrete herstelstappen</strong>Geen vage foutmeldingen.</span></div><div><Database size={19} /><span><strong>Persistente appdata</strong>Back-up vóór risicovolle wijzigingen.</span></div></div><Button onClick={onNext}>Configuratie starten <ChevronRight size={16} /></Button></div>; }
function CheckStep({ index, report, networkDiscovery, setNetworkDiscovery, busy, onInspect, onPrevious, onNext }: { index: number; report?: SetupReport; networkDiscovery: boolean; setNetworkDiscovery: (value: boolean) => void; busy: boolean; onInspect: () => void; onPrevious: () => void; onNext: () => void }) {
const [title, Icon] = steps[index]!;
const contents = index === 1 ? <StatusList items={report?.storage.map((item) => ({ name: item.path, ok: item.writable, detail: item.writable ? "Schrijfbaar" : "Niet schrijfbaar" })) ?? []} empty="Voer de systeemcontrole uit om mountrechten te testen." /> : index === 2 || index === 3 ? report?.openrgb ? <div className="setup-health"><StatusBadge status={report.openrgb.status} /><strong>{report.openrgb.message}</strong><p>De SDK moet exact protocol 5 rapporteren en op loopback bereikbaar zijn.</p></div> : <EmptyState icon={Server} title="Nog niet gecontroleerd" description="Start de veilige systeemcontrole." /> : index === 4 ? <NodeList title="USB-nodes" nodes={report?.usb_devices ?? []} /> : index === 5 ? <NodeList title="I²C-nodes" nodes={report?.i2c_devices ?? []} /> : index === 6 ? <StatusList items={report?.openrgb_inventory ? Object.entries(report.openrgb_inventory).map(([name, value]) => ({ name, ok: (value as { status?: string }).status === "completed", detail: JSON.stringify(value) })) : []} empty="Na de SDK-test wordt de OpenRGB-inventory read-only gesynchroniseerd." /> : index === 7 ? <div>{report?.recovery.length ? report.recovery.map((item) => <div className="recovery" key={`${item.area}-${item.message}`}><Wrench size={17} /><div><strong>{item.area}</strong><p>{item.message}</p></div></div>) : <EmptyState icon={ShieldCheck} title="Geen herstelacties nodig" description="Alle uitgevoerde controles zijn geslaagd." />}</div> : <label className="choice-card"><input type="checkbox" checked={networkDiscovery} onChange={(event) => setNetworkDiscovery(event.target.checked)} /><span><Network size={20} /></span><div><strong>Optionele netwerkdiscovery</strong><p>Broadcast, multicast en mDNS kunnen host networking vereisen. Bridge blijft de veilige standaard.</p></div></label>;
return <div className="setup-panel"><span className="eyebrow">Stap {index + 1}</span><div className="setup-title"><span><Icon size={24} /></span><div><h1>{title}</h1><p>Deze controle is read-only en verandert geen apparaattoestand.</p></div></div><div className="setup-content">{contents}</div><div className="setup-actions"><Button variant="ghost" onClick={onPrevious}>Vorige</Button><Button variant="secondary" onClick={onInspect} busy={busy}><ScanSearch size={16} /> Systeemcontrole</Button><Button onClick={onNext}>Volgende <ChevronRight size={16} /></Button></div></div>;
}
function StorageConfirm({ appdata, backup, onAppdata, onBackup, onPrevious, onNext }: { appdata: boolean; backup: boolean; onAppdata: (value: boolean) => void; onBackup: (value: boolean) => void; onPrevious: () => void; onNext: () => void }) { return <div className="setup-panel"><span className="eyebrow">Stap 10</span><div className="setup-title"><span><Database size={24} /></span><div><h1>Appdata & back-up</h1><p>Bevestig dat de persistentie buiten de container is gekoppeld.</p></div></div><div className="setup-content"><label className="choice-card"><input type="checkbox" checked={appdata} onChange={(event) => onAppdata(event.target.checked)} /><span><HardDrive size={20} /></span><div><strong>Appdata is persistent</strong><p>/config/openrgb, /config/lumaops, /data en /logs zijn aan Unraid gekoppeld.</p></div></label><label className="choice-card"><input type="checkbox" checked={backup} onChange={(event) => onBackup(event.target.checked)} /><span><Database size={20} /></span><div><strong>Back-uplocatie is bevestigd</strong><p>Database, OpenRGB-config en secret.key worden samen extern geback-upt.</p></div></label></div><div className="setup-actions"><Button variant="ghost" onClick={onPrevious}>Vorige</Button><Button onClick={onNext} disabled={!appdata || !backup}>Rapport bekijken <ChevronRight size={16} /></Button></div></div>; }
function Report({ report, appdata, backup, busy, onInspect, onComplete }: { report?: SetupReport; appdata: boolean; backup: boolean; busy: boolean; onInspect: () => void; onComplete: () => void }) { const ready = Boolean(report?.openrgb?.connected && report.storage.every((item) => item.writable) && appdata && backup); return <div className="setup-panel"><span className="setup-hero-icon"><CircleCheck size={34} /></span><Badge tone={ready ? "success" : "warning"}>{ready ? "Klaar voor gebruik" : "Aandacht vereist"}</Badge><h1>Systeemstatusrapport</h1><p>{ready ? "De container, opslag en SDK-verbinding voldoen aan de veilige MVP-voorwaarden." : "LumaOps kan starten, maar los onderstaande herstelpunten op voor volledige hardwarebediening."}</p><div className="report-summary"><div><strong>{report?.storage.filter((item) => item.writable).length ?? 0}/{report?.storage.length ?? 4}</strong><span>opslagpaden</span></div><div><strong>{report?.usb_devices.length ?? 0}</strong><span>USB-nodes</span></div><div><strong>{report?.i2c_devices.length ?? 0}</strong><span>I²C-nodes</span></div><div><strong>{report?.openrgb?.connected ? "SDK 5" : "Offline"}</strong><span>OpenRGB</span></div></div>{report?.recovery.map((item) => <div className="recovery" key={`${item.area}-${item.message}`}><Wrench size={17} /><div><strong>{item.area}</strong><p>{item.message}</p></div></div>)}<div className="setup-actions"><Button variant="secondary" onClick={onInspect}>Opnieuw controleren</Button><Button onClick={onComplete} busy={busy} disabled={!appdata || !backup}>LumaOps openen <ChevronRight size={16} /></Button></div></div>; }
function StatusList({ items, empty }: { items: Array<{ name: string; ok: boolean; detail: string }>; empty: string }) { if (!items.length) return <p className="muted">{empty}</p>; return <div className="status-list">{items.map((item) => <div key={item.name}><span className={item.ok ? "ok" : "bad"}>{item.ok ? <Check size={14} /> : "!"}</span><div><strong>{item.name}</strong><p>{item.detail}</p></div></div>)}</div>; }
function NodeList({ title, nodes }: { title: string; nodes: NodeStatus[] }) { return nodes.length ? <div><h3>{title}</h3><StatusList items={nodes.map((node) => ({ name: node.path, ok: node.readable && node.writable, detail: node.writable ? "Lees- en schrijfbaar" : node.readable ? "Alleen leesbaar" : "Niet toegankelijk" }))} empty="" /></div> : <EmptyState icon={Usb} title={`Geen ${title.toLowerCase()}`} description="Dit is normaal als de betreffende hardware niet is doorgemapt." />; }
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ChevronRight, CircleCheck, Cpu, Database, HardDrive, Lightbulb, Network, ScanSearch, Server, ShieldCheck, Usb, Wrench } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import type { ComponentHealth, SetupState } from "../api/types";
import { useToast } from "../components/Toast";
import { Badge, Button, EmptyState, ErrorPanel, StatusBadge } from "../components/ui";
interface PathStatus { path: string; exists: boolean; readable: boolean; writable: boolean }
interface NodeStatus { path: string; readable: boolean; writable: boolean }
interface Recovery { area: string; message: string }
interface SetupReport {
storage: PathStatus[];
openrgb: ComponentHealth | null;
usb_devices: NodeStatus[];
i2c_devices: NodeStatus[];
serial_devices: NodeStatus[];
recovery: Recovery[];
checked_at: string;
openrgb_inventory?: Record<string, unknown>;
}
const steps = [
["Welkom", Lightbulb], ["Opslagrechten", HardDrive], ["OpenRGB-proces", Server], ["SDK-verbinding", Network],
["USB-inventory", Usb], ["I²C-inventory", Cpu], ["OpenRGB-detectie", ScanSearch], ["Toegankelijkheid", ShieldCheck],
["Netwerkdiscovery", Network], ["Appdata & back-up", Database], ["Systeemrapport", CircleCheck],
] as const;
export function SetupPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { notify } = useToast();
const [active, setActive] = useState(0);
const [networkDiscovery, setNetworkDiscovery] = useState(false);
const [appdata, setAppdata] = useState(false);
const [backup, setBackup] = useState(false);
const state = useQuery({ queryKey: ["setup"], queryFn: () => api<SetupState>("/api/v1/setup") });
const inspect = useMutation({ mutationFn: () => api<SetupReport>(`/api/v1/setup/inspect?include_network=${networkDiscovery}`, { method: "POST" }), onSuccess: () => { notify("Systeemcontrole afgerond."); setActive(10); }, onError: (error) => notify(error instanceof Error ? error.message : "Controle mislukt.", "danger") });
const complete = useMutation({ mutationFn: () => api<SetupState>("/api/v1/setup/complete", { method: "POST", body: JSON.stringify({ appdata_confirmed: appdata, backup_location_confirmed: backup }) }), onSuccess: (result) => { queryClient.setQueryData(["setup"], result); notify("LumaOps is klaar voor gebruik."); void navigate("/", { replace: true }); }, onError: (error) => notify(error instanceof Error ? error.message : "Setup afronden mislukt.", "danger") });
const report = normalizeSetupReport(inspect.data ?? state.data?.report);
if (state.error) return <ErrorPanel error={state.error} retry={() => void state.refetch()} />;
return <div className="setup-shell">
<aside className="setup-sidebar"><div className="brand"><span className="brand__mark"><Lightbulb size={20} /></span><div className="brand__text"><strong>LumaOps</strong><span>Eerste configuratie</span></div></div><ol>{steps.map(([label, Icon], index) => <li key={label} className={active === index ? "active" : index < active ? "complete" : ""}><button onClick={() => setActive(index)}><span>{index < active ? <Check size={15} /> : <Icon size={16} />}</span><div><small>Stap {index + 1}</small><strong>{label}</strong></div></button></li>)}</ol></aside>
<main className="setup-main">
<div className="setup-progress"><span>Stap {active + 1} van {steps.length}</span><div><i style={{ width: `${((active + 1) / steps.length) * 100}%` }} /></div></div>
{active === 0 ? <Welcome onNext={() => setActive(1)} /> : null}
{active >= 1 && active <= 8 ? <CheckStep index={active} report={report} networkDiscovery={networkDiscovery} setNetworkDiscovery={setNetworkDiscovery} busy={inspect.isPending} onInspect={() => inspect.mutate()} onPrevious={() => setActive((value) => Math.max(0, value - 1))} onNext={() => setActive((value) => Math.min(10, value + 1))} /> : null}
{active === 9 ? <StorageConfirm appdata={appdata} backup={backup} onAppdata={setAppdata} onBackup={setBackup} onPrevious={() => setActive(8)} onNext={() => setActive(10)} /> : null}
{active === 10 ? <Report report={report} appdata={appdata} backup={backup} busy={complete.isPending} onInspect={() => inspect.mutate()} onComplete={() => complete.mutate()} /> : null}
</main>
</div>;
}
function normalizeSetupReport(value: unknown): SetupReport | undefined {
if (!value || typeof value !== "object") return undefined;
const candidate = value as Partial<SetupReport>;
return {
storage: Array.isArray(candidate.storage) ? candidate.storage : [],
openrgb: candidate.openrgb ?? null,
usb_devices: Array.isArray(candidate.usb_devices) ? candidate.usb_devices : [],
i2c_devices: Array.isArray(candidate.i2c_devices) ? candidate.i2c_devices : [],
serial_devices: Array.isArray(candidate.serial_devices) ? candidate.serial_devices : [],
recovery: Array.isArray(candidate.recovery) ? candidate.recovery : [],
checked_at: typeof candidate.checked_at === "string" ? candidate.checked_at : "",
openrgb_inventory: candidate.openrgb_inventory,
};
}
function Welcome({ onNext }: { onNext: () => void }) { return <div className="setup-panel setup-welcome"><span className="setup-hero-icon"><Lightbulb size={34} /></span><Badge tone="accent">OpenRGB 1.0rc3 · SDK 5</Badge><h1>Welkom bij LumaOps</h1><p>We controleren eerst veilig je container, opslag en OpenRGB-verbinding. De wizard voert geen wijzigende hardwareopdrachten uit.</p><div className="setup-promises"><div><ShieldCheck size={19} /><span><strong>Loopback-only SDK</strong>Poort 6742 blijft intern.</span></div><div><Wrench size={19} /><span><strong>Concrete herstelstappen</strong>Geen vage foutmeldingen.</span></div><div><Database size={19} /><span><strong>Persistente appdata</strong>Back-up vóór risicovolle wijzigingen.</span></div></div><Button onClick={onNext}>Configuratie starten <ChevronRight size={16} /></Button></div>; }
function CheckStep({ index, report, networkDiscovery, setNetworkDiscovery, busy, onInspect, onPrevious, onNext }: { index: number; report?: SetupReport; networkDiscovery: boolean; setNetworkDiscovery: (value: boolean) => void; busy: boolean; onInspect: () => void; onPrevious: () => void; onNext: () => void }) {
const [title, Icon] = steps[index]!;
const contents = index === 1 ? <StatusList items={report?.storage.map((item) => ({ name: item.path, ok: item.writable, detail: item.writable ? "Schrijfbaar" : "Niet schrijfbaar" })) ?? []} empty="Voer de systeemcontrole uit om mountrechten te testen." /> : index === 2 || index === 3 ? report?.openrgb ? <div className="setup-health"><StatusBadge status={report.openrgb.status} /><strong>{report.openrgb.message}</strong><p>De SDK moet exact protocol 5 rapporteren en op loopback bereikbaar zijn.</p></div> : <EmptyState icon={Server} title="Nog niet gecontroleerd" description="Start de veilige systeemcontrole." /> : index === 4 ? <NodeList title="USB-nodes" nodes={report?.usb_devices ?? []} /> : index === 5 ? <NodeList title="I²C-nodes" nodes={report?.i2c_devices ?? []} /> : index === 6 ? <StatusList items={report?.openrgb_inventory ? Object.entries(report.openrgb_inventory).map(([name, value]) => ({ name, ok: (value as { status?: string }).status === "completed", detail: JSON.stringify(value) })) : []} empty="Na de SDK-test wordt de OpenRGB-inventory read-only gesynchroniseerd." /> : index === 7 ? <div>{report?.recovery.length ? report.recovery.map((item) => <div className="recovery" key={`${item.area}-${item.message}`}><Wrench size={17} /><div><strong>{item.area}</strong><p>{item.message}</p></div></div>) : <EmptyState icon={ShieldCheck} title="Geen herstelacties nodig" description="Alle uitgevoerde controles zijn geslaagd." />}</div> : <label className="choice-card"><input type="checkbox" checked={networkDiscovery} onChange={(event) => setNetworkDiscovery(event.target.checked)} /><span><Network size={20} /></span><div><strong>Optionele netwerkdiscovery</strong><p>Broadcast, multicast en mDNS kunnen host networking vereisen. Bridge blijft de veilige standaard.</p></div></label>;
return <div className="setup-panel"><span className="eyebrow">Stap {index + 1}</span><div className="setup-title"><span><Icon size={24} /></span><div><h1>{title}</h1><p>Deze controle is read-only en verandert geen apparaattoestand.</p></div></div><div className="setup-content">{contents}</div><div className="setup-actions"><Button variant="ghost" onClick={onPrevious}>Vorige</Button><Button variant="secondary" onClick={onInspect} busy={busy}><ScanSearch size={16} /> Systeemcontrole</Button><Button onClick={onNext}>Volgende <ChevronRight size={16} /></Button></div></div>;
}
function StorageConfirm({ appdata, backup, onAppdata, onBackup, onPrevious, onNext }: { appdata: boolean; backup: boolean; onAppdata: (value: boolean) => void; onBackup: (value: boolean) => void; onPrevious: () => void; onNext: () => void }) { return <div className="setup-panel"><span className="eyebrow">Stap 10</span><div className="setup-title"><span><Database size={24} /></span><div><h1>Appdata & back-up</h1><p>Bevestig dat de persistentie buiten de container is gekoppeld.</p></div></div><div className="setup-content"><label className="choice-card"><input type="checkbox" checked={appdata} onChange={(event) => onAppdata(event.target.checked)} /><span><HardDrive size={20} /></span><div><strong>Appdata is persistent</strong><p>/config/openrgb, /config/lumaops, /data en /logs zijn aan Unraid gekoppeld.</p></div></label><label className="choice-card"><input type="checkbox" checked={backup} onChange={(event) => onBackup(event.target.checked)} /><span><Database size={20} /></span><div><strong>Back-uplocatie is bevestigd</strong><p>Database, OpenRGB-config en secret.key worden samen extern geback-upt.</p></div></label></div><div className="setup-actions"><Button variant="ghost" onClick={onPrevious}>Vorige</Button><Button onClick={onNext} disabled={!appdata || !backup}>Rapport bekijken <ChevronRight size={16} /></Button></div></div>; }
function Report({ report, appdata, backup, busy, onInspect, onComplete }: { report?: SetupReport; appdata: boolean; backup: boolean; busy: boolean; onInspect: () => void; onComplete: () => void }) { const ready = Boolean(report?.openrgb?.connected && report.storage.every((item) => item.writable) && appdata && backup); return <div className="setup-panel"><span className="setup-hero-icon"><CircleCheck size={34} /></span><Badge tone={ready ? "success" : "warning"}>{ready ? "Klaar voor gebruik" : "Aandacht vereist"}</Badge><h1>Systeemstatusrapport</h1><p>{ready ? "De container, opslag en SDK-verbinding voldoen aan de veilige MVP-voorwaarden." : "LumaOps kan starten, maar los onderstaande herstelpunten op voor volledige hardwarebediening."}</p><div className="report-summary"><div><strong>{report?.storage.filter((item) => item.writable).length ?? 0}/{report?.storage.length ?? 4}</strong><span>opslagpaden</span></div><div><strong>{report?.usb_devices.length ?? 0}</strong><span>USB-nodes</span></div><div><strong>{report?.i2c_devices.length ?? 0}</strong><span>I²C-nodes</span></div><div><strong>{report?.openrgb?.connected ? "SDK 5" : "Offline"}</strong><span>OpenRGB</span></div></div>{report?.recovery.map((item) => <div className="recovery" key={`${item.area}-${item.message}`}><Wrench size={17} /><div><strong>{item.area}</strong><p>{item.message}</p></div></div>)}<div className="setup-actions"><Button variant="secondary" onClick={onInspect}>Opnieuw controleren</Button><Button onClick={onComplete} busy={busy} disabled={!appdata || !backup}>LumaOps openen <ChevronRight size={16} /></Button></div></div>; }
function StatusList({ items, empty }: { items: Array<{ name: string; ok: boolean; detail: string }>; empty: string }) { if (!items.length) return <p className="muted">{empty}</p>; return <div className="status-list">{items.map((item) => <div key={item.name}><span className={item.ok ? "ok" : "bad"}>{item.ok ? <Check size={14} /> : "!"}</span><div><strong>{item.name}</strong><p>{item.detail}</p></div></div>)}</div>; }
function NodeList({ title, nodes }: { title: string; nodes: NodeStatus[] }) { return nodes.length ? <div><h3>{title}</h3><StatusList items={nodes.map((node) => ({ name: node.path, ok: node.readable && node.writable, detail: node.writable ? "Lees- en schrijfbaar" : node.readable ? "Alleen leesbaar" : "Niet toegankelijk" }))} empty="" /></div> : <EmptyState icon={Usb} title={`Geen ${title.toLowerCase()}`} description="Dit is normaal als de betreffende hardware niet is doorgemapt." />; }
+46 -46
View File
@@ -1,46 +1,46 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { jsonResponse, renderApp, requestJson, requestUrl } from "../test/render";
import { SpacesPage } from "./SpacesPage";
const device = {
id: "device-1",
alias: null,
name: "Bureaulamp",
};
describe("SpacesPage", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const url = requestUrl(input);
if (url.includes("/rooms")) return Promise.resolve(jsonResponse([]));
if (url.includes("/devices")) return Promise.resolve(jsonResponse({ items: [device], total: 1, limit: 500, offset: 0 }));
if (url.includes("/groups") && (init?.method ?? "GET") === "POST") return Promise.resolve(jsonResponse({ id: "group-1", name: "Bureau", device_count: 1 }, 201));
if (url.includes("/groups")) return Promise.resolve(jsonResponse([]));
return Promise.resolve(jsonResponse({}));
}));
});
it("creates a static and dynamic group from the management form", async () => {
const user = userEvent.setup();
renderApp(<SpacesPage />, "/spaces");
await user.click(await screen.findByRole("button", { name: "Nieuwe groep" }));
await screen.findByText("Bureaulamp");
await user.type(screen.getByLabelText("Naam"), "Bureau");
await user.type(screen.getByLabelText("Dynamische tags (optioneel)"), "bureau, rgb");
await user.click(screen.getByRole("checkbox", { name: "Bureaulamp" }));
await user.click(screen.getByRole("button", { name: "Aanmaken" }));
await waitFor(() => {
const calls = vi.mocked(fetch).mock.calls;
const create = calls.find(([input, init]) => requestUrl(input).endsWith("/api/v1/groups") && init?.method === "POST");
expect(create).toBeDefined();
expect(requestJson(create?.[1])).toMatchObject({
name: "Bureau",
device_ids: ["device-1"],
dynamic_query: { tags: ["bureau", "rgb"], match: "all" },
});
});
});
});
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { jsonResponse, renderApp, requestJson, requestUrl } from "../test/render";
import { SpacesPage } from "./SpacesPage";
const device = {
id: "device-1",
alias: null,
name: "Bureaulamp",
};
describe("SpacesPage", () => {
beforeEach(() => {
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
const url = requestUrl(input);
if (url.includes("/rooms")) return Promise.resolve(jsonResponse([]));
if (url.includes("/devices")) return Promise.resolve(jsonResponse({ items: [device], total: 1, limit: 500, offset: 0 }));
if (url.includes("/groups") && (init?.method ?? "GET") === "POST") return Promise.resolve(jsonResponse({ id: "group-1", name: "Bureau", device_count: 1 }, 201));
if (url.includes("/groups")) return Promise.resolve(jsonResponse([]));
return Promise.resolve(jsonResponse({}));
}));
});
it("creates a static and dynamic group from the management form", async () => {
const user = userEvent.setup();
renderApp(<SpacesPage />, "/spaces");
await user.click(await screen.findByRole("button", { name: "Nieuwe groep" }));
await screen.findByText("Bureaulamp");
await user.type(screen.getByLabelText("Naam"), "Bureau");
await user.type(screen.getByLabelText("Dynamische tags (optioneel)"), "bureau, rgb");
await user.click(screen.getByRole("checkbox", { name: "Bureaulamp" }));
await user.click(screen.getByRole("button", { name: "Aanmaken" }));
await waitFor(() => {
const calls = vi.mocked(fetch).mock.calls;
const create = calls.find(([input, init]) => requestUrl(input).endsWith("/api/v1/groups") && init?.method === "POST");
expect(create).toBeDefined();
expect(requestJson(create?.[1])).toMatchObject({
name: "Bureau",
device_ids: ["device-1"],
dynamic_query: { tags: ["bureau", "rgb"], match: "all" },
});
});
});
});
+131 -131
View File
@@ -1,131 +1,131 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Boxes, DoorOpen, Palette, Pencil, Plus, Power, Trash2, Users } from "lucide-react";
import { useState, type FormEvent } from "react";
import { api } from "../api/client";
import type { Device, Group, Page, Room } from "../api/types";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { useToast } from "../components/Toast";
import { Button, Card, CardHeader, EmptyState, ErrorPanel, Field, Input, LoadingGrid, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formValue, hexToColor } from "../lib/utils";
type Editor = { kind: "room"; item?: Room } | { kind: "group"; item?: Group };
type DeleteTarget = { kind: "room" | "group"; id: string; name: string };
export function SpacesPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const [editor, setEditor] = useState<Editor | null>(null);
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null);
const [controlGroup, setControlGroup] = useState<Group | null>(null);
const [groupColor, setGroupColor] = useState("#5660ff");
const [groupBrightness, setGroupBrightness] = useState(100);
const rooms = useQuery({ queryKey: ["rooms"], queryFn: () => api<Room[]>("/api/v1/rooms") });
const groups = useQuery({ queryKey: ["groups"], queryFn: () => api<Group[]>("/api/v1/groups") });
const devices = useQuery({ queryKey: ["devices"], queryFn: () => api<Page<Device>>("/api/v1/devices?limit=500") });
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ["rooms"] });
void queryClient.invalidateQueries({ queryKey: ["groups"] });
void queryClient.invalidateQueries({ queryKey: ["devices"] });
};
const save = useMutation({
mutationFn: ({ kind, id, payload }: { kind: "room" | "group"; id?: string; payload: Record<string, unknown> }) => api(
`/api/v1/${kind === "room" ? "rooms" : "groups"}${id ? `/${id}` : ""}`,
{ method: id ? "PUT" : "POST", body: JSON.stringify(payload) },
),
onSuccess: (_, variables) => {
notify(variables.id ? text("Indeling bijgewerkt.", "Organization updated.") : text("Indeling aangemaakt.", "Organization created."));
setEditor(null);
invalidate();
},
onError: (error) => notify(error instanceof Error ? error.message : text("Opslaan mislukt.", "Save failed."), "danger"),
});
const remove = useMutation({
mutationFn: (target: DeleteTarget) => api(`/api/v1/${target.kind === "room" ? "rooms" : "groups"}/${target.id}`, { method: "DELETE" }),
onSuccess: () => {
notify(text("Indeling verwijderd.", "Organization removed."));
setDeleteTarget(null);
invalidate();
},
onError: (error) => notify(error instanceof Error ? error.message : text("Verwijderen mislukt.", "Delete failed."), "danger"),
});
const loadGroup = useMutation({
mutationFn: (id: string) => api<Group>(`/api/v1/groups/${id}`),
onSuccess: (item) => setEditor({ kind: "group", item }),
onError: (error) => notify(error instanceof Error ? error.message : text("Groep laden mislukt.", "Failed to load group."), "danger"),
});
const groupState = useMutation({
mutationFn: ({ id, state }: { id: string; state: Record<string, unknown> }) => api<{ results: Array<{ status: string }> }>(`/api/v1/groups/${id}/state`, { method: "POST", body: JSON.stringify({ state }) }),
onSuccess: (result) => {
const failed = result.results.filter((item) => item.status === "failed").length;
notify(failed ? text(`${failed} apparaten konden niet worden bijgewerkt.`, `${failed} devices could not be updated.`) : text("Groep bijgewerkt.", "Group updated."), failed ? "danger" : "success");
setControlGroup(null);
void queryClient.invalidateQueries({ queryKey: ["devices"] });
void queryClient.invalidateQueries({ queryKey: ["system"] });
},
onError: (error) => notify(error instanceof Error ? error.message : text("Groepsopdracht mislukt.", "Group command failed."), "danger"),
});
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!editor) return;
const data = new FormData(event.currentTarget);
const name = formValue(data, "name").trim();
const description = formValue(data, "description").trim() || null;
if (!name) return;
if (editor.kind === "room") {
save.mutate({ kind: "room", id: editor.item?.id, payload: { name, description, sort_order: editor.item?.sort_order ?? 0 } });
return;
}
const tags = formValue(data, "tags").split(",").map((tag) => tag.trim()).filter(Boolean);
save.mutate({
kind: "group",
id: editor.item?.id,
payload: {
name,
description,
sort_order: editor.item?.sort_order ?? 0,
device_ids: data.getAll("devices").map(String),
dynamic_query: tags.length ? { tags, match: data.get("tag_match") === "any" ? "any" : "all" } : null,
},
});
};
const error = rooms.error ?? groups.error ?? devices.error;
return <>
<PageHeader
eyebrow={text("Logische indeling", "Logical organization")}
title={text("Kamers & groepen", "Rooms & groups")}
description={text("Een apparaat kan in één kamer en in meerdere statische of dynamische groepen staan.", "A device can belong to one room and multiple static or dynamic groups.")}
actions={<><Button variant="secondary" onClick={() => setEditor({ kind: "room" })}><DoorOpen size={16} /> {text("Nieuwe kamer", "New room")}</Button><Button onClick={() => setEditor({ kind: "group" })}><Plus size={16} /> {text("Nieuwe groep", "New group")}</Button></>}
/>
{editor ? (
<Card className="inline-form">
<CardHeader title={editor.item ? text("Indeling bewerken", "Edit organization") : editor.kind === "room" ? text("Kamer aanmaken", "Create room") : text("Groep aanmaken", "Create group")} description={editor.kind === "room" ? text("Gebruik een herkenbare fysieke locatie.", "Use a recognizable physical location.") : text("Selecteer apparaten en/of match dynamisch op tags.", "Select devices and/or match dynamically by tags.")} />
<form onSubmit={submit}>
<div className="form-grid form-grid--two"><Field label={text("Naam", "Name")}><Input name="name" required autoFocus defaultValue={editor.item?.name ?? ""} placeholder={editor.kind === "room" ? text("Bijvoorbeeld Werkplek", "For example Workspace") : text("Bijvoorbeeld Bureauverlichting", "For example Desk lights")} /></Field><Field label={text("Omschrijving", "Description")}><Input name="description" defaultValue={editor.item?.description ?? ""} /></Field></div>
{editor.kind === "group" ? <>
<div className="form-grid form-grid--two"><Field label={text("Dynamische tags (optioneel)", "Dynamic tags (optional)")}><Input name="tags" defaultValue={editor.item?.dynamic_query?.tags?.join(", ") ?? ""} placeholder="bureau, rgb" /></Field><Field label={text("Tagmatching", "Tag matching")}><select className="input" name="tag_match" defaultValue={editor.item?.dynamic_query?.match ?? "all"}><option value="all">{text("Alle tags", "All tags")}</option><option value="any">{text("Minstens één tag", "At least one tag")}</option></select></Field></div>
<fieldset className="check-grid"><legend>{text("Statische apparaten", "Static devices")}</legend>{devices.data?.items.map((device) => <label key={device.id}><input type="checkbox" name="devices" value={device.id} defaultChecked={editor.item?.devices?.some((item) => item.id === device.id)} /> <span>{device.alias || device.name}</span></label>)}</fieldset>
</> : null}
<div className="form-actions"><Button variant="ghost" type="button" onClick={() => setEditor(null)}>{text("Annuleren", "Cancel")}</Button><Button type="submit" busy={save.isPending}>{editor.item ? text("Opslaan", "Save") : text("Aanmaken", "Create")}</Button></div>
</form>
</Card>
) : null}
{error ? <ErrorPanel error={error} retry={() => void Promise.all([rooms.refetch(), groups.refetch(), devices.refetch()])} /> : null}
<section className="section-block">
<div className="section-heading"><div><h2>{text("Kamers", "Rooms")}</h2><p>{text("Fysieke locaties voor snelle selectie en overzicht.", "Physical locations for quick selection and overview.")}</p></div></div>
{rooms.isLoading ? <LoadingGrid /> : rooms.data?.length ? <div className="grid grid--cards">{rooms.data.map((room, index) => <Card key={room.id} className="space-card"><span className={`space-card__icon space-card__icon--${index % 4}`}><DoorOpen size={21} /></span><div className="space-card__content"><h3>{room.name}</h3><p>{room.description || text(`${room.device_count} apparaten`, `${room.device_count} devices`)}</p></div><div className="card-actions"><Button variant="ghost" title={text("Bewerken", "Edit")} onClick={() => setEditor({ kind: "room", item: room })}><Pencil size={15} /></Button><Button variant="ghost" title={text("Verwijderen", "Delete")} onClick={() => setDeleteTarget({ kind: "room", id: room.id, name: room.name })}><Trash2 size={15} /></Button></div></Card>)}</div> : <EmptyState icon={DoorOpen} title={text("Nog geen kamers", "No rooms yet")} description={text("Maak je eerste fysieke locatie aan.", "Create your first physical location.")} />}
</section>
<section className="section-block">
<div className="section-heading"><div><h2>{text("Logische groepen", "Logical groups")}</h2><p>{text("Combineer apparaten over kamers en connectoren heen.", "Combine devices across rooms and connectors.")}</p></div></div>
{groups.isLoading ? <LoadingGrid /> : groups.data?.length ? <div className="grid grid--cards">{groups.data.map((group, index) => <Card key={group.id} className="space-card"><span className={`space-card__icon space-card__icon--${(index + 1) % 4}`}><Users size={21} /></span><div className="space-card__content"><h3>{group.name}</h3><p>{text(`${group.device_count} apparaten`, `${group.device_count} devices`)}{group.dynamic_query?.tags?.length ? ` · ${group.dynamic_query.tags.join(", ")}` : ""}</p></div><div className="card-actions"><Button variant="ghost" title={text("Groep bedienen", "Control group")} onClick={() => setControlGroup(group)}><Palette size={15} /></Button><Button variant="ghost" title={text("Alles uit", "All off")} onClick={() => groupState.mutate({ id: group.id, state: { power: false } })}><Power size={15} /></Button><Button variant="ghost" title={text("Bewerken", "Edit")} busy={loadGroup.isPending} onClick={() => loadGroup.mutate(group.id)}><Pencil size={15} /></Button><Button variant="ghost" title={text("Verwijderen", "Delete")} onClick={() => setDeleteTarget({ kind: "group", id: group.id, name: group.name })}><Trash2 size={15} /></Button></div></Card>)}</div> : <EmptyState icon={Boxes} title={text("Nog geen groepen", "No groups yet")} description={text("Maak een groep om meerdere apparaten tegelijk te bedienen.", "Create a group to control multiple devices together.")} />}
</section>
<ConfirmDialog open={Boolean(deleteTarget)} title={text("Indeling verwijderen?", "Delete organization?")} description={text(`“${deleteTarget?.name ?? ""}” wordt verwijderd. Apparaten zelf blijven behouden.`, `“${deleteTarget?.name ?? ""}” will be deleted. Devices themselves are preserved.`)} confirmLabel={text("Verwijderen", "Delete")} danger busy={remove.isPending} onClose={() => setDeleteTarget(null)} onConfirm={() => deleteTarget && remove.mutate(deleteTarget)} />
<ConfirmDialog open={Boolean(controlGroup)} title={text("Groep bedienen", "Control group")} description={text(`Pas kleur en helderheid toe op alle geschikte apparaten in “${controlGroup?.name ?? ""}”.`, `Apply color and brightness to all capable devices in “${controlGroup?.name ?? ""}”.`)} confirmLabel={text("Toepassen", "Apply")} busy={groupState.isPending} confirmDisabled={!/^#[0-9a-f]{6}$/i.test(groupColor)} onClose={() => setControlGroup(null)} onConfirm={() => controlGroup && groupState.mutate({ id: controlGroup.id, state: { power: true, colors: [hexToColor(groupColor)], brightness: groupBrightness } })}>
<div className="dialog-form"><Field label={text("Kleur", "Color")}><div className="large-color"><input type="color" value={groupColor} onChange={(event) => setGroupColor(event.target.value)} /><Input value={groupColor.toUpperCase()} onChange={(event) => setGroupColor(event.target.value)} /></div></Field><Field label={text(`Helderheid · ${groupBrightness}%`, `Brightness · ${groupBrightness}%`)}><input className="range" type="range" min="0" max="100" value={groupBrightness} onChange={(event) => setGroupBrightness(Number(event.target.value))} /></Field></div>
</ConfirmDialog>
</>;
}
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Boxes, DoorOpen, Palette, Pencil, Plus, Power, Trash2, Users } from "lucide-react";
import { useState, type FormEvent } from "react";
import { api } from "../api/client";
import type { Device, Group, Page, Room } from "../api/types";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { useToast } from "../components/Toast";
import { Button, Card, CardHeader, EmptyState, ErrorPanel, Field, Input, LoadingGrid, PageHeader } from "../components/ui";
import { useI18n } from "../lib/i18n";
import { formValue, hexToColor } from "../lib/utils";
type Editor = { kind: "room"; item?: Room } | { kind: "group"; item?: Group };
type DeleteTarget = { kind: "room" | "group"; id: string; name: string };
export function SpacesPage() {
const { text } = useI18n();
const queryClient = useQueryClient();
const { notify } = useToast();
const [editor, setEditor] = useState<Editor | null>(null);
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null);
const [controlGroup, setControlGroup] = useState<Group | null>(null);
const [groupColor, setGroupColor] = useState("#5660ff");
const [groupBrightness, setGroupBrightness] = useState(100);
const rooms = useQuery({ queryKey: ["rooms"], queryFn: () => api<Room[]>("/api/v1/rooms") });
const groups = useQuery({ queryKey: ["groups"], queryFn: () => api<Group[]>("/api/v1/groups") });
const devices = useQuery({ queryKey: ["devices"], queryFn: () => api<Page<Device>>("/api/v1/devices?limit=500") });
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: ["rooms"] });
void queryClient.invalidateQueries({ queryKey: ["groups"] });
void queryClient.invalidateQueries({ queryKey: ["devices"] });
};
const save = useMutation({
mutationFn: ({ kind, id, payload }: { kind: "room" | "group"; id?: string; payload: Record<string, unknown> }) => api(
`/api/v1/${kind === "room" ? "rooms" : "groups"}${id ? `/${id}` : ""}`,
{ method: id ? "PUT" : "POST", body: JSON.stringify(payload) },
),
onSuccess: (_, variables) => {
notify(variables.id ? text("Indeling bijgewerkt.", "Organization updated.") : text("Indeling aangemaakt.", "Organization created."));
setEditor(null);
invalidate();
},
onError: (error) => notify(error instanceof Error ? error.message : text("Opslaan mislukt.", "Save failed."), "danger"),
});
const remove = useMutation({
mutationFn: (target: DeleteTarget) => api(`/api/v1/${target.kind === "room" ? "rooms" : "groups"}/${target.id}`, { method: "DELETE" }),
onSuccess: () => {
notify(text("Indeling verwijderd.", "Organization removed."));
setDeleteTarget(null);
invalidate();
},
onError: (error) => notify(error instanceof Error ? error.message : text("Verwijderen mislukt.", "Delete failed."), "danger"),
});
const loadGroup = useMutation({
mutationFn: (id: string) => api<Group>(`/api/v1/groups/${id}`),
onSuccess: (item) => setEditor({ kind: "group", item }),
onError: (error) => notify(error instanceof Error ? error.message : text("Groep laden mislukt.", "Failed to load group."), "danger"),
});
const groupState = useMutation({
mutationFn: ({ id, state }: { id: string; state: Record<string, unknown> }) => api<{ results: Array<{ status: string }> }>(`/api/v1/groups/${id}/state`, { method: "POST", body: JSON.stringify({ state }) }),
onSuccess: (result) => {
const failed = result.results.filter((item) => item.status === "failed").length;
notify(failed ? text(`${failed} apparaten konden niet worden bijgewerkt.`, `${failed} devices could not be updated.`) : text("Groep bijgewerkt.", "Group updated."), failed ? "danger" : "success");
setControlGroup(null);
void queryClient.invalidateQueries({ queryKey: ["devices"] });
void queryClient.invalidateQueries({ queryKey: ["system"] });
},
onError: (error) => notify(error instanceof Error ? error.message : text("Groepsopdracht mislukt.", "Group command failed."), "danger"),
});
const submit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!editor) return;
const data = new FormData(event.currentTarget);
const name = formValue(data, "name").trim();
const description = formValue(data, "description").trim() || null;
if (!name) return;
if (editor.kind === "room") {
save.mutate({ kind: "room", id: editor.item?.id, payload: { name, description, sort_order: editor.item?.sort_order ?? 0 } });
return;
}
const tags = formValue(data, "tags").split(",").map((tag) => tag.trim()).filter(Boolean);
save.mutate({
kind: "group",
id: editor.item?.id,
payload: {
name,
description,
sort_order: editor.item?.sort_order ?? 0,
device_ids: data.getAll("devices").map(String),
dynamic_query: tags.length ? { tags, match: data.get("tag_match") === "any" ? "any" : "all" } : null,
},
});
};
const error = rooms.error ?? groups.error ?? devices.error;
return <>
<PageHeader
eyebrow={text("Logische indeling", "Logical organization")}
title={text("Kamers & groepen", "Rooms & groups")}
description={text("Een apparaat kan in één kamer en in meerdere statische of dynamische groepen staan.", "A device can belong to one room and multiple static or dynamic groups.")}
actions={<><Button variant="secondary" onClick={() => setEditor({ kind: "room" })}><DoorOpen size={16} /> {text("Nieuwe kamer", "New room")}</Button><Button onClick={() => setEditor({ kind: "group" })}><Plus size={16} /> {text("Nieuwe groep", "New group")}</Button></>}
/>
{editor ? (
<Card className="inline-form">
<CardHeader title={editor.item ? text("Indeling bewerken", "Edit organization") : editor.kind === "room" ? text("Kamer aanmaken", "Create room") : text("Groep aanmaken", "Create group")} description={editor.kind === "room" ? text("Gebruik een herkenbare fysieke locatie.", "Use a recognizable physical location.") : text("Selecteer apparaten en/of match dynamisch op tags.", "Select devices and/or match dynamically by tags.")} />
<form onSubmit={submit}>
<div className="form-grid form-grid--two"><Field label={text("Naam", "Name")}><Input name="name" required autoFocus defaultValue={editor.item?.name ?? ""} placeholder={editor.kind === "room" ? text("Bijvoorbeeld Werkplek", "For example Workspace") : text("Bijvoorbeeld Bureauverlichting", "For example Desk lights")} /></Field><Field label={text("Omschrijving", "Description")}><Input name="description" defaultValue={editor.item?.description ?? ""} /></Field></div>
{editor.kind === "group" ? <>
<div className="form-grid form-grid--two"><Field label={text("Dynamische tags (optioneel)", "Dynamic tags (optional)")}><Input name="tags" defaultValue={editor.item?.dynamic_query?.tags?.join(", ") ?? ""} placeholder="bureau, rgb" /></Field><Field label={text("Tagmatching", "Tag matching")}><select className="input" name="tag_match" defaultValue={editor.item?.dynamic_query?.match ?? "all"}><option value="all">{text("Alle tags", "All tags")}</option><option value="any">{text("Minstens één tag", "At least one tag")}</option></select></Field></div>
<fieldset className="check-grid"><legend>{text("Statische apparaten", "Static devices")}</legend>{devices.data?.items.map((device) => <label key={device.id}><input type="checkbox" name="devices" value={device.id} defaultChecked={editor.item?.devices?.some((item) => item.id === device.id)} /> <span>{device.alias || device.name}</span></label>)}</fieldset>
</> : null}
<div className="form-actions"><Button variant="ghost" type="button" onClick={() => setEditor(null)}>{text("Annuleren", "Cancel")}</Button><Button type="submit" busy={save.isPending}>{editor.item ? text("Opslaan", "Save") : text("Aanmaken", "Create")}</Button></div>
</form>
</Card>
) : null}
{error ? <ErrorPanel error={error} retry={() => void Promise.all([rooms.refetch(), groups.refetch(), devices.refetch()])} /> : null}
<section className="section-block">
<div className="section-heading"><div><h2>{text("Kamers", "Rooms")}</h2><p>{text("Fysieke locaties voor snelle selectie en overzicht.", "Physical locations for quick selection and overview.")}</p></div></div>
{rooms.isLoading ? <LoadingGrid /> : rooms.data?.length ? <div className="grid grid--cards">{rooms.data.map((room, index) => <Card key={room.id} className="space-card"><span className={`space-card__icon space-card__icon--${index % 4}`}><DoorOpen size={21} /></span><div className="space-card__content"><h3>{room.name}</h3><p>{room.description || text(`${room.device_count} apparaten`, `${room.device_count} devices`)}</p></div><div className="card-actions"><Button variant="ghost" title={text("Bewerken", "Edit")} onClick={() => setEditor({ kind: "room", item: room })}><Pencil size={15} /></Button><Button variant="ghost" title={text("Verwijderen", "Delete")} onClick={() => setDeleteTarget({ kind: "room", id: room.id, name: room.name })}><Trash2 size={15} /></Button></div></Card>)}</div> : <EmptyState icon={DoorOpen} title={text("Nog geen kamers", "No rooms yet")} description={text("Maak je eerste fysieke locatie aan.", "Create your first physical location.")} />}
</section>
<section className="section-block">
<div className="section-heading"><div><h2>{text("Logische groepen", "Logical groups")}</h2><p>{text("Combineer apparaten over kamers en connectoren heen.", "Combine devices across rooms and connectors.")}</p></div></div>
{groups.isLoading ? <LoadingGrid /> : groups.data?.length ? <div className="grid grid--cards">{groups.data.map((group, index) => <Card key={group.id} className="space-card"><span className={`space-card__icon space-card__icon--${(index + 1) % 4}`}><Users size={21} /></span><div className="space-card__content"><h3>{group.name}</h3><p>{text(`${group.device_count} apparaten`, `${group.device_count} devices`)}{group.dynamic_query?.tags?.length ? ` · ${group.dynamic_query.tags.join(", ")}` : ""}</p></div><div className="card-actions"><Button variant="ghost" title={text("Groep bedienen", "Control group")} onClick={() => setControlGroup(group)}><Palette size={15} /></Button><Button variant="ghost" title={text("Alles uit", "All off")} onClick={() => groupState.mutate({ id: group.id, state: { power: false } })}><Power size={15} /></Button><Button variant="ghost" title={text("Bewerken", "Edit")} busy={loadGroup.isPending} onClick={() => loadGroup.mutate(group.id)}><Pencil size={15} /></Button><Button variant="ghost" title={text("Verwijderen", "Delete")} onClick={() => setDeleteTarget({ kind: "group", id: group.id, name: group.name })}><Trash2 size={15} /></Button></div></Card>)}</div> : <EmptyState icon={Boxes} title={text("Nog geen groepen", "No groups yet")} description={text("Maak een groep om meerdere apparaten tegelijk te bedienen.", "Create a group to control multiple devices together.")} />}
</section>
<ConfirmDialog open={Boolean(deleteTarget)} title={text("Indeling verwijderen?", "Delete organization?")} description={text(`“${deleteTarget?.name ?? ""}” wordt verwijderd. Apparaten zelf blijven behouden.`, `“${deleteTarget?.name ?? ""}” will be deleted. Devices themselves are preserved.`)} confirmLabel={text("Verwijderen", "Delete")} danger busy={remove.isPending} onClose={() => setDeleteTarget(null)} onConfirm={() => deleteTarget && remove.mutate(deleteTarget)} />
<ConfirmDialog open={Boolean(controlGroup)} title={text("Groep bedienen", "Control group")} description={text(`Pas kleur en helderheid toe op alle geschikte apparaten in “${controlGroup?.name ?? ""}”.`, `Apply color and brightness to all capable devices in “${controlGroup?.name ?? ""}”.`)} confirmLabel={text("Toepassen", "Apply")} busy={groupState.isPending} confirmDisabled={!/^#[0-9a-f]{6}$/i.test(groupColor)} onClose={() => setControlGroup(null)} onConfirm={() => controlGroup && groupState.mutate({ id: controlGroup.id, state: { power: true, colors: [hexToColor(groupColor)], brightness: groupBrightness } })}>
<div className="dialog-form"><Field label={text("Kleur", "Color")}><div className="large-color"><input type="color" value={groupColor} onChange={(event) => setGroupColor(event.target.value)} /><Input value={groupColor.toUpperCase()} onChange={(event) => setGroupColor(event.target.value)} /></div></Field><Field label={text(`Helderheid · ${groupBrightness}%`, `Brightness · ${groupBrightness}%`)}><input className="range" type="range" min="0" max="100" value={groupBrightness} onChange={(event) => setGroupBrightness(Number(event.target.value))} /></Field></div>
</ConfirmDialog>
</>;
}
File diff suppressed because it is too large. Load diff
+38 -38
View File
@@ -1,38 +1,38 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, type RenderResult } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import type { ReactElement } from "react";
import { ToastProvider } from "../components/Toast";
import { I18nProvider } from "../lib/i18n";
import { ThemeProvider } from "../lib/theme";
export function renderApp(element: ReactElement, route = "/"): RenderResult {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<I18nProvider>
<ToastProvider>
<MemoryRouter initialEntries={[route]}>{element}</MemoryRouter>
</ToastProvider>
</I18nProvider>
</ThemeProvider>
</QueryClientProvider>,
);
}
export function jsonResponse(value: unknown, status = 200): Response {
return new Response(JSON.stringify(value), {
status,
headers: { "Content-Type": "application/json", "X-Request-ID": "test-request" },
});
}
export function requestUrl(input: RequestInfo | URL): string {
if (typeof input === "string") return input;
return input instanceof URL ? input.href : input.url;
}
export function requestJson(init?: RequestInit): unknown {
return typeof init?.body === "string" ? JSON.parse(init.body) as unknown : null;
}
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, type RenderResult } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import type { ReactElement } from "react";
import { ToastProvider } from "../components/Toast";
import { I18nProvider } from "../lib/i18n";
import { ThemeProvider } from "../lib/theme";
export function renderApp(element: ReactElement, route = "/"): RenderResult {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<I18nProvider>
<ToastProvider>
<MemoryRouter initialEntries={[route]}>{element}</MemoryRouter>
</ToastProvider>
</I18nProvider>
</ThemeProvider>
</QueryClientProvider>,
);
}
export function jsonResponse(value: unknown, status = 200): Response {
return new Response(JSON.stringify(value), {
status,
headers: { "Content-Type": "application/json", "X-Request-ID": "test-request" },
});
}
export function requestUrl(input: RequestInfo | URL): string {
if (typeof input === "string") return input;
return input instanceof URL ? input.href : input.url;
}
export function requestJson(init?: RequestInit): unknown {
return typeof init?.body === "string" ? JSON.parse(init.body) as unknown : null;
}
+36 -36
View File
@@ -1,36 +1,36 @@
import "@testing-library/jest-dom/vitest";
import { afterEach, beforeEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn(),
});
beforeEach(() => {
vi.mocked(window.matchMedia).mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
});
class EventSourceStub {
addEventListener = vi.fn();
close = vi.fn();
onerror: (() => void) | null = null;
constructor(public readonly url: string) {}
}
vi.stubGlobal("EventSource", EventSourceStub);
vi.stubGlobal("crypto", { randomUUID: () => "00000000-0000-4000-8000-000000000001" });
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
import "@testing-library/jest-dom/vitest";
import { afterEach, beforeEach, vi } from "vitest";
import { cleanup } from "@testing-library/react";
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn(),
});
beforeEach(() => {
vi.mocked(window.matchMedia).mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
});
class EventSourceStub {
addEventListener = vi.fn();
close = vi.fn();
onerror: (() => void) | null = null;
constructor(public readonly url: string) {}
}
vi.stubGlobal("EventSource", EventSourceStub);
vi.stubGlobal("crypto", { randomUUID: () => "00000000-0000-4000-8000-000000000001" });
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
+2 -2
View File
@@ -1,2 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite/client" />
+26 -26
View File
@@ -1,26 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"]
}
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"]
}
+8 -8
View File
@@ -1,8 +1,8 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+17 -17
View File
@@ -1,17 +1,17 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts", "eslint.config.js"]
}
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts", "eslint.config.js"]
}
+18 -18
View File
@@ -1,18 +1,18 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": "http://127.0.0.1:8080",
"/health": "http://127.0.0.1:8080",
},
},
test: {
environment: "jsdom",
setupFiles: "./src/test/setup.ts",
css: true,
},
});
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/api": "http://127.0.0.1:8080",
"/health": "http://127.0.0.1:8080",
},
},
test: {
environment: "jsdom",
setupFiles: "./src/test/setup.ts",
css: true,
},
});