commit 1b8bf79d465f335d8fa9c8690f0a47912bd8c832 Author: gilles Date: Sat Feb 21 16:55:10 2026 +0100 first diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2dbd03b --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +HA_BASE_URL=http://10.0.0.2:8123 +HA_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhNjI1NzkyYjZhZmE0MTliOTEwMmFiMGQ4YjExNDZjNiIsImlhdCI6MTc3MTY4MTE2NSwiZXhwIjoyMDg3MDQxMTY1fQ.uus2-4HCDFajj2oFPiiW9b3KjhxF-DdhFN0XuZ_n5E8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df1c294 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +*.egg + +# Virtual environments +venv/ +.venv/ +env/ + +# SQLite +*.db +*.sqlite3 + +# Backend data +backend/data/ + +# Node / Frontend +node_modules/ +frontend/dist/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Environment +.env + +# OS +.DS_Store +Thumbs.db + +# pytest +.pytest_cache/ +htmlcov/ +.coverage + +# Vite +*.local diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..61daca5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,82 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**HA Entity Scanner & Manager** — Webapp self-hosted (Docker) pour scanner, lister, filtrer et gérer les entités Home Assistant. UI en français, orientée admin. + +Spécification complète : `consigne.md` + +## Tech Stack + +- **Backend** : Python FastAPI, SQLite (SQLModel/SQLAlchemy), client HA REST + WebSocket +- **Frontend** : Vue 3 + Vite +- **Déploiement** : Docker + docker-compose +- **Config** : variables d'environnement `HA_BASE_URL`, `HA_TOKEN` + +## Architecture + +Backend API (FastAPI) + Frontend SPA (Vue 3), séparés. Le backend sert de proxy vers Home Assistant — le token HA ne doit jamais être exposé côté frontend. + +### Endpoints backend + +| Méthode | Route | Description | +|---------|-------|-------------| +| GET | `/api/health` | État app + état HA | +| POST | `/api/scan` | Lance un scan asynchrone | +| GET | `/api/entities` | Liste paginée + filtres (query params) | +| GET | `/api/entities/{entity_id}` | Détails entité | +| POST | `/api/entities/actions` | Actions bulk (disable/enable/hide/favorite…) | +| GET | `/api/audit` | Journal des actions | + +### Base de données SQLite + +- `entities_cache` : cache des entités HA (entity_id PK, domain, friendly_name, state, attrs_json, timestamps) +- `entity_flags` : flags locaux (ignored, favorite, notes) +- `audit_log` : historique des actions (action, entity_ids, result, error) + +### Désactivation des entités + +Deux modes selon le type d'entité : +1. **Désactivation réelle** via entity_registry/device_registry (WebSocket API HA) quand possible +2. **Fallback** : masquage/ignore local (flag en DB) — l'UI doit indiquer clairement le mode utilisé + +## Build & Run Commands + +```bash +# Docker +docker-compose up --build +docker-compose down + +# Backend (dev) +cd backend +pip install -r requirements.txt +uvicorn app.main:app --reload --port 8000 + +# Frontend (dev) +cd frontend +npm install +npm run dev + +# Tests +cd backend +pytest +pytest tests/test_entities.py -k "test_parse" # test unique +``` + +## Development Order (from spec) + +1. Backend : health + scan + entities list +2. Frontend : page liste + filtres + détails +3. Flags locaux (ignore/favorite) +4. Désactiver/masquer via API HA officielle +5. Audit log + finitions UI + +## Key Constraints + +- UI langue française uniquement +- Scan asynchrone — ne jamais bloquer l'UI +- Token HA en variable d'environnement, jamais dans le HTML/JS +- CORS maîtrisé côté backend +- Toute action sur une entité doit être journalisée dans `audit_log` diff --git a/README.md b/README.md new file mode 100644 index 0000000..b0ba12e --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# HA Entity Scanner & Manager + +Webapp self-hosted pour scanner, lister, filtrer et gérer les entités Home Assistant. +UI en français, orientée admin. + +## Fonctionnalités + +- **Scanner** les entités HA (REST API + WebSocket registry) +- **Lister** avec tri, pagination, recherche texte +- **Filtrer** par domaine, état, disponibilité, device_class, intégration, zone +- **Gérer** : favori, ignorer, désactiver/réactiver +- **Désactivation** via HA entity_registry (WebSocket) avec fallback local +- **Journal** des actions (audit log) + +## Installation (Docker) + +```bash +# Cloner le projet +git clone && cd ha_explore + +# Configurer +cp .env.example .env +# Éditer .env avec votre HA_BASE_URL et HA_TOKEN + +# Lancer +docker-compose up --build +``` + +L'application est accessible sur `http://localhost:8080`. + +## Variables d'environnement + +| Variable | Description | Défaut | +|----------|-------------|--------| +| `HA_BASE_URL` | URL de Home Assistant | `http://10.0.0.2:8123` | +| `HA_TOKEN` | Token d'accès longue durée HA | (requis) | + +## Développement + +### Backend + +```bash +cd backend +python -m venv venv && source venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload --port 8000 +``` + +### Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +Le frontend (port 5173) proxy les appels `/api` vers le backend (port 8000). + +### Tests + +```bash +cd backend +pytest -v +``` + +## Architecture + +``` +frontend/ Vue 3 + Vuetify 3 (SPA) +backend/ FastAPI + SQLite (SQLModel) + app/ + routers/ health, scan, entities, actions, audit + services/ scanner, entity_actions + models.py, database.py, ha_client.py, config.py +``` + +## API Endpoints + +| Méthode | Route | Description | +|---------|-------|-------------| +| GET | `/api/health` | État app + connexion HA + statut scan | +| POST | `/api/scan` | Lance un scan asynchrone (202) | +| GET | `/api/entities` | Liste paginée + filtres | +| GET | `/api/entities/{id}` | Détails d'une entité | +| POST | `/api/entities/actions` | Actions bulk (disable/enable/favorite/ignore) | +| GET | `/api/audit` | Journal des actions | + +## Limitations + +### Désactivation des entités + +Deux modes existent selon le type d'entité : + +1. **Désactivation HA réelle** : via `config/entity_registry/update` (WebSocket). + Fonctionne pour les entités enregistrées dans le registry HA. + L'entité est marquée `disabled_by: "user"` côté HA. + +2. **Fallback local** : si la désactivation via registry échoue (entité non enregistrée, erreur WS), + l'entité est marquée `ignored_local: true` en base locale. + Elle reste active côté HA mais est masquée dans l'app. + +L'UI indique clairement le mode utilisé via des badges distincts. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..6fcc6a3 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /app/data + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..c8c9b59 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,13 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + ha_base_url: str = "http://10.0.0.2:8123" + ha_token: str = "" + database_url: str = "sqlite:///./data/ha_explorer.db" + cors_origins: list[str] = ["http://localhost:5173"] + + model_config = {"env_prefix": ""} + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..bb10ea8 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,35 @@ +from pathlib import Path +from collections.abc import Generator + +from sqlmodel import Session, SQLModel, create_engine + +from app.config import settings + +_db_path = settings.database_url.replace("sqlite:///", "") +Path(_db_path).parent.mkdir(parents=True, exist_ok=True) + +_default_engine = create_engine( + settings.database_url, + connect_args={"check_same_thread": False}, + echo=False, +) + +# Engine mutable pour permettre le remplacement en tests +_engine_holder: dict = {"engine": _default_engine} + + +def get_engine(): + return _engine_holder["engine"] + + +def set_engine(engine): + _engine_holder["engine"] = engine + + +def create_db_and_tables(): + SQLModel.metadata.create_all(get_engine()) + + +def get_session() -> Generator[Session, None, None]: + with Session(get_engine()) as session: + yield session diff --git a/backend/app/ha_client.py b/backend/app/ha_client.py new file mode 100644 index 0000000..25f51cf --- /dev/null +++ b/backend/app/ha_client.py @@ -0,0 +1,135 @@ +import json +import asyncio +from datetime import datetime +from typing import Any + +import aiohttp + +from app.config import settings + + +class HAClient: + def __init__(self): + self.base_url = settings.ha_base_url.rstrip("/") + self.token = settings.ha_token + self._headers = { + "Authorization": f"Bearer {self.token}", + "Content-Type": "application/json", + } + self._ws_id_counter = 0 + + def _next_ws_id(self) -> int: + self._ws_id_counter += 1 + return self._ws_id_counter + + async def check_connection(self) -> tuple[bool, str]: + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{self.base_url}/api/", + headers=self._headers, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + if resp.status == 200: + return True, "Connecté" + elif resp.status == 401: + return False, "Token invalide (401)" + else: + return False, f"Erreur HTTP {resp.status}" + except aiohttp.ClientError as e: + return False, f"Connexion impossible : {e}" + except asyncio.TimeoutError: + return False, "Timeout de connexion" + + async def fetch_all_states(self) -> list[dict[str, Any]]: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{self.base_url}/api/states", + headers=self._headers, + timeout=aiohttp.ClientTimeout(total=30), + ) as resp: + resp.raise_for_status() + return await resp.json() + + async def _ws_command(self, command: dict[str, Any]) -> dict[str, Any]: + async with aiohttp.ClientSession() as session: + async with session.ws_connect( + f"{self.base_url}/api/websocket", + timeout=aiohttp.ClientTimeout(total=30), + ) as ws: + # Attendre auth_required + msg = await ws.receive_json() + + # Authentification + await ws.send_json({"type": "auth", "access_token": self.token}) + msg = await ws.receive_json() + if msg.get("type") != "auth_ok": + raise ConnectionError(f"Authentification WS échouée : {msg}") + + # Envoyer la commande + cmd_id = self._next_ws_id() + command["id"] = cmd_id + await ws.send_json(command) + + # Attendre la réponse + msg = await ws.receive_json() + if not msg.get("success"): + raise RuntimeError( + f"Commande WS échouée : {msg.get('error', {}).get('message', 'Erreur inconnue')}" + ) + return msg.get("result", {}) + + async def fetch_entity_registry(self) -> list[dict[str, Any]]: + return await self._ws_command({"type": "config/entity_registry/list"}) + + async def update_entity_registry( + self, entity_id: str, **updates: Any + ) -> dict[str, Any]: + return await self._ws_command( + { + "type": "config/entity_registry/update", + "entity_id": entity_id, + **updates, + } + ) + + +def _parse_dt(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except (ValueError, AttributeError): + return None + + +def normalize_entity( + state: dict[str, Any], + registry_entry: dict[str, Any] | None = None, +) -> dict[str, Any]: + attrs = state.get("attributes", {}) + entity_id = state.get("entity_id", "") + domain = entity_id.split(".")[0] if "." in entity_id else "" + + reg = registry_entry or {} + + return { + "entity_id": entity_id, + "domain": domain, + "friendly_name": attrs.get("friendly_name", ""), + "state": state.get("state", ""), + "attrs_json": json.dumps(attrs, ensure_ascii=False), + "device_class": attrs.get("device_class"), + "unit_of_measurement": attrs.get("unit_of_measurement"), + "area_id": reg.get("area_id"), + "device_id": reg.get("device_id"), + "integration": reg.get("platform"), + "is_disabled": reg.get("disabled_by") is not None, + "is_hidden": reg.get("hidden_by") is not None, + "is_available": state.get("state") not in ("unavailable", "unknown"), + "last_changed": _parse_dt(state.get("last_changed")), + "last_updated": _parse_dt(state.get("last_updated")), + } + + +ha_client = HAClient() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..562f3e0 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,31 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import settings +from app.database import create_db_and_tables +from app.routers import health, scan, entities, actions, audit + + +@asynccontextmanager +async def lifespan(app: FastAPI): + create_db_and_tables() + yield + + +app = FastAPI(title="HA Entity Scanner", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(health.router, prefix="/api") +app.include_router(scan.router, prefix="/api") +app.include_router(entities.router, prefix="/api") +app.include_router(actions.router, prefix="/api") +app.include_router(audit.router, prefix="/api") diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..daf4e96 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,47 @@ +from datetime import datetime +from typing import Optional + +from sqlmodel import Field, SQLModel + + +class EntityCache(SQLModel, table=True): + __tablename__ = "entities_cache" + + entity_id: str = Field(primary_key=True) + domain: str = "" + friendly_name: str = "" + state: str = "" + attrs_json: str = "{}" + device_class: Optional[str] = None + unit_of_measurement: Optional[str] = None + area_id: Optional[str] = None + device_id: Optional[str] = None + integration: Optional[str] = None + is_disabled: bool = False + is_hidden: bool = False + is_available: bool = True + last_changed: Optional[datetime] = None + last_updated: Optional[datetime] = None + fetched_at: datetime = Field(default_factory=datetime.utcnow) + + +class EntityFlag(SQLModel, table=True): + __tablename__ = "entity_flags" + + entity_id: str = Field(primary_key=True) + ignored_local: bool = False + favorite: bool = False + notes: str = "" + original_state: Optional[str] = None + disabled_at: Optional[datetime] = None + + +class AuditLog(SQLModel, table=True): + __tablename__ = "audit_log" + + id: Optional[int] = Field(default=None, primary_key=True) + ts: datetime = Field(default_factory=datetime.utcnow) + action: str = "" + entity_ids_json: str = "[]" + result: str = "" + error: str = "" diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/actions.py b/backend/app/routers/actions.py new file mode 100644 index 0000000..d39a450 --- /dev/null +++ b/backend/app/routers/actions.py @@ -0,0 +1,33 @@ +from typing import Optional + +from fastapi import APIRouter +from pydantic import BaseModel + +from app.services.entity_actions import disable_entity, enable_entity, set_flag + +router = APIRouter() + + +class BulkActionRequest(BaseModel): + action: str # disable, enable, favorite, unfavorite, ignore, unignore + entity_ids: list[str] + + +@router.post("/entities/actions") +async def bulk_action(req: BulkActionRequest): + results = [] + + if req.action in ("favorite", "unfavorite", "ignore", "unignore"): + results = set_flag(req.entity_ids, req.action) + elif req.action == "disable": + for eid in req.entity_ids: + r = await disable_entity(eid) + results.append(r) + elif req.action == "enable": + for eid in req.entity_ids: + r = await enable_entity(eid) + results.append(r) + else: + return {"error": f"Action inconnue : {req.action}"} + + return {"action": req.action, "results": results} diff --git a/backend/app/routers/audit.py b/backend/app/routers/audit.py new file mode 100644 index 0000000..f013c25 --- /dev/null +++ b/backend/app/routers/audit.py @@ -0,0 +1,38 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlmodel import Session, col, func, select + +from app.database import get_session +from app.models import AuditLog + +router = APIRouter() + + +@router.get("/audit") +def list_audit( + page: int = Query(1, ge=1), + per_page: int = Query(50, ge=1, le=200), + action: Optional[str] = None, + session: Session = Depends(get_session), +): + query = select(AuditLog) + + if action: + query = query.where(AuditLog.action == action) + + count_query = select(func.count()).select_from(query.subquery()) + total = session.exec(count_query).one() + + query = query.order_by(col(AuditLog.ts).desc()) + offset = (page - 1) * per_page + query = query.offset(offset).limit(per_page) + + logs = session.exec(query).all() + + return { + "items": [log.model_dump() for log in logs], + "total": total, + "page": page, + "per_page": per_page, + } diff --git a/backend/app/routers/entities.py b/backend/app/routers/entities.py new file mode 100644 index 0000000..4d1e1aa --- /dev/null +++ b/backend/app/routers/entities.py @@ -0,0 +1,150 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlmodel import Session, col, func, or_, select + +from app.database import get_session +from app.models import EntityCache, EntityFlag + +router = APIRouter() + + +@router.get("/entities") +def list_entities( + page: int = Query(1, ge=1), + per_page: int = Query(50, ge=1, le=500), + domain: Optional[str] = None, + state: Optional[str] = None, + search: Optional[str] = None, + available: Optional[bool] = None, + sort_by: str = Query("entity_id"), + sort_dir: str = Query("asc", pattern="^(asc|desc)$"), + favorite: Optional[bool] = None, + ignored: Optional[bool] = None, + device_class: Optional[str] = None, + integration: Optional[str] = None, + area_id: Optional[str] = None, + session: Session = Depends(get_session), +): + query = select(EntityCache) + + # Filtres + if domain: + domains = [d.strip() for d in domain.split(",")] + query = query.where(col(EntityCache.domain).in_(domains)) + + if state: + states = [s.strip() for s in state.split(",")] + query = query.where(col(EntityCache.state).in_(states)) + + if search: + pattern = f"%{search}%" + query = query.where( + or_( + col(EntityCache.entity_id).ilike(pattern), + col(EntityCache.friendly_name).ilike(pattern), + ) + ) + + if available is not None: + query = query.where(EntityCache.is_available == available) + + if device_class: + query = query.where(EntityCache.device_class == device_class) + + if integration: + query = query.where(EntityCache.integration == integration) + + if area_id: + query = query.where(EntityCache.area_id == area_id) + + # Filtres flags (nécessite jointure) + if favorite is not None or ignored is not None: + query = query.outerjoin( + EntityFlag, EntityCache.entity_id == EntityFlag.entity_id + ) + if favorite is not None: + query = query.where(EntityFlag.favorite == favorite) + if ignored is not None: + query = query.where(EntityFlag.ignored_local == ignored) + + # Compteur total + count_query = select(func.count()).select_from(query.subquery()) + total = session.exec(count_query).one() + + # Tri + sort_column = getattr(EntityCache, sort_by, EntityCache.entity_id) + if sort_dir == "desc": + query = query.order_by(col(sort_column).desc()) + else: + query = query.order_by(col(sort_column).asc()) + + # Pagination + offset = (page - 1) * per_page + query = query.offset(offset).limit(per_page) + + entities = session.exec(query).all() + + # Récupérer les flags pour chaque entité + entity_ids = [e.entity_id for e in entities] + flags_query = select(EntityFlag).where(col(EntityFlag.entity_id).in_(entity_ids)) + flags = {f.entity_id: f for f in session.exec(flags_query).all()} + + results = [] + for e in entities: + d = e.model_dump() + flag = flags.get(e.entity_id) + d["favorite"] = flag.favorite if flag else False + d["ignored_local"] = flag.ignored_local if flag else False + d["notes"] = flag.notes if flag else "" + d["original_state"] = flag.original_state if flag else None + d["disabled_at"] = flag.disabled_at.isoformat() if flag and flag.disabled_at else None + results.append(d) + + return { + "items": results, + "total": total, + "page": page, + "per_page": per_page, + "pages": (total + per_page - 1) // per_page if per_page > 0 else 0, + } + + +@router.get("/entities/filters") +def get_filter_values(session: Session = Depends(get_session)): + """Retourne les valeurs disponibles pour les filtres.""" + domains = session.exec( + select(EntityCache.domain).distinct().order_by(EntityCache.domain) + ).all() + areas = session.exec( + select(EntityCache.area_id).where(EntityCache.area_id.is_not(None)).distinct().order_by(EntityCache.area_id) # type: ignore + ).all() + integrations = session.exec( + select(EntityCache.integration).where(EntityCache.integration.is_not(None)).distinct().order_by(EntityCache.integration) # type: ignore + ).all() + device_classes = session.exec( + select(EntityCache.device_class).where(EntityCache.device_class.is_not(None)).distinct().order_by(EntityCache.device_class) # type: ignore + ).all() + return { + "domains": domains, + "areas": areas, + "integrations": integrations, + "device_classes": device_classes, + } + + +@router.get("/entities/{entity_id}") +def get_entity(entity_id: str, session: Session = Depends(get_session)): + entity = session.get(EntityCache, entity_id) + if not entity: + raise HTTPException(status_code=404, detail="Entité non trouvée") + + d = entity.model_dump() + flag = session.get(EntityFlag, entity_id) + d["favorite"] = flag.favorite if flag else False + d["ignored_local"] = flag.ignored_local if flag else False + d["notes"] = flag.notes if flag else "" + d["original_state"] = flag.original_state if flag else None + d["disabled_at"] = flag.disabled_at.isoformat() if flag and flag.disabled_at else None + + return d diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py new file mode 100644 index 0000000..a9a4a1c --- /dev/null +++ b/backend/app/routers/health.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter, Depends +from sqlmodel import Session, func, select + +from app.database import get_session +from app.ha_client import ha_client +from app.models import EntityCache +from app.scan_state import scan_state + +router = APIRouter() + + +@router.get("/health") +async def health(session: Session = Depends(get_session)): + connected, message = await ha_client.check_connection() + count = session.exec(select(func.count()).select_from(EntityCache)).one() + + return { + "status": "ok", + "ha_connected": connected, + "ha_message": message, + "entity_count": count, + **scan_state.to_dict(), + } diff --git a/backend/app/routers/scan.py b/backend/app/routers/scan.py new file mode 100644 index 0000000..36eceb5 --- /dev/null +++ b/backend/app/routers/scan.py @@ -0,0 +1,20 @@ +from fastapi import APIRouter, BackgroundTasks + +from app.scan_state import scan_state +from app.services.scanner import run_scan + +router = APIRouter() + + +def _run_scan_sync(): + import asyncio + asyncio.run(run_scan()) + + +@router.post("/scan", status_code=202) +async def trigger_scan(background_tasks: BackgroundTasks): + if scan_state.status == "scanning": + return {"message": "Scan déjà en cours", **scan_state.to_dict()} + + background_tasks.add_task(run_scan) + return {"message": "Scan lancé", **scan_state.to_dict()} diff --git a/backend/app/scan_state.py b/backend/app/scan_state.py new file mode 100644 index 0000000..d033d8b --- /dev/null +++ b/backend/app/scan_state.py @@ -0,0 +1,39 @@ +from datetime import datetime +from typing import Optional + + +class ScanState: + def __init__(self): + self.status: str = "idle" # idle, scanning, done, error + self.last_scan: Optional[datetime] = None + self.progress: int = 0 + self.total: int = 0 + self.error: str = "" + + def start(self): + self.status = "scanning" + self.progress = 0 + self.total = 0 + self.error = "" + + def finish(self, count: int): + self.status = "done" + self.progress = count + self.total = count + self.last_scan = datetime.utcnow() + + def fail(self, error: str): + self.status = "error" + self.error = error + + def to_dict(self) -> dict: + return { + "scan_status": self.status, + "last_scan": self.last_scan.isoformat() if self.last_scan else None, + "progress": self.progress, + "total": self.total, + "error": self.error, + } + + +scan_state = ScanState() diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/entity_actions.py b/backend/app/services/entity_actions.py new file mode 100644 index 0000000..8e5f553 --- /dev/null +++ b/backend/app/services/entity_actions.py @@ -0,0 +1,134 @@ +import json +from datetime import datetime + +from sqlmodel import Session + +from app.database import get_engine +from app.ha_client import ha_client +from app.models import EntityCache, EntityFlag, AuditLog + + +def _get_current_state(session: Session, entity_id: str) -> str | None: + """Récupère l'état actuel d'une entité depuis le cache.""" + entity = session.get(EntityCache, entity_id) + return entity.state if entity else None + + +def _save_original_state(session: Session, entity_id: str): + """Sauvegarde l'état original avant désactivation.""" + flag = session.get(EntityFlag, entity_id) + if not flag: + flag = EntityFlag(entity_id=entity_id) + # Ne sauvegarder que si pas déjà désactivé (garder le vrai état original) + if not flag.original_state: + flag.original_state = _get_current_state(session, entity_id) + flag.disabled_at = datetime.utcnow() + session.add(flag) + return flag + + +def _clear_original_state(session: Session, entity_id: str): + """Efface l'état original lors de la réactivation.""" + flag = session.get(EntityFlag, entity_id) + if flag: + flag.original_state = None + flag.disabled_at = None + session.add(flag) + + +async def disable_entity(entity_id: str) -> dict: + mode = "local_flag" + error = "" + + # Sauvegarder l'état original + with Session(get_engine()) as session: + _save_original_state(session, entity_id) + session.commit() + + # Tenter désactivation via HA registry + try: + await ha_client.update_entity_registry(entity_id, disabled_by="user") + mode = "ha_registry" + except Exception as e: + error = str(e) + # Fallback : flag local + with Session(get_engine()) as session: + flag = session.get(EntityFlag, entity_id) + if not flag: + flag = EntityFlag(entity_id=entity_id) + flag.ignored_local = True + session.add(flag) + session.commit() + + _log_action("disable", [entity_id], mode, error) + return {"entity_id": entity_id, "mode": mode, "error": error} + + +async def enable_entity(entity_id: str) -> dict: + mode = "local_flag" + error = "" + + try: + await ha_client.update_entity_registry(entity_id, disabled_by=None) + mode = "ha_registry" + except Exception as e: + error = str(e) + with Session(get_engine()) as session: + flag = session.get(EntityFlag, entity_id) + if flag: + flag.ignored_local = False + session.add(flag) + session.commit() + + # Effacer l'état original + with Session(get_engine()) as session: + _clear_original_state(session, entity_id) + session.commit() + + _log_action("enable", [entity_id], mode, error) + return {"entity_id": entity_id, "mode": mode, "error": error} + + +def set_flag(entity_ids: list[str], action: str) -> list[dict]: + results = [] + with Session(get_engine()) as session: + for eid in entity_ids: + flag = session.get(EntityFlag, eid) + if not flag: + flag = EntityFlag(entity_id=eid) + + if action == "favorite": + flag.favorite = True + elif action == "unfavorite": + flag.favorite = False + elif action == "ignore": + # Sauvegarder l'état original avant ignore + if not flag.original_state: + flag.original_state = _get_current_state(session, eid) + flag.disabled_at = datetime.utcnow() + flag.ignored_local = True + elif action == "unignore": + flag.ignored_local = False + flag.original_state = None + flag.disabled_at = None + + session.add(flag) + results.append({"entity_id": eid, "action": action, "ok": True}) + + session.commit() + + _log_action(action, entity_ids, "ok", "") + return results + + +def _log_action(action: str, entity_ids: list[str], result: str, error: str): + with Session(get_engine()) as session: + log = AuditLog( + ts=datetime.utcnow(), + action=action, + entity_ids_json=json.dumps(entity_ids), + result=result, + error=error, + ) + session.add(log) + session.commit() diff --git a/backend/app/services/scanner.py b/backend/app/services/scanner.py new file mode 100644 index 0000000..9f0a2bd --- /dev/null +++ b/backend/app/services/scanner.py @@ -0,0 +1,53 @@ +import json +from datetime import datetime + +from sqlmodel import Session, select + +from app.database import get_engine +from app.ha_client import ha_client, normalize_entity +from app.models import EntityCache +from app.scan_state import scan_state + + +async def run_scan(): + scan_state.start() + try: + states = await ha_client.fetch_all_states() + scan_state.total = len(states) + + # Tenter de récupérer le registry (peut échouer si WS non dispo) + registry_map: dict[str, dict] = {} + try: + registry = await ha_client.fetch_entity_registry() + registry_map = {e["entity_id"]: e for e in registry} + except Exception: + pass # On continue sans registry + + with Session(get_engine()) as session: + for i, state in enumerate(states): + entity_id = state.get("entity_id", "") + reg_entry = registry_map.get(entity_id) + normalized = normalize_entity(state, reg_entry) + + existing = session.get(EntityCache, entity_id) + if existing: + for key, value in normalized.items(): + if key != "entity_id": + setattr(existing, key, value) + existing.fetched_at = datetime.utcnow() + else: + entity = EntityCache( + **normalized, + fetched_at=datetime.utcnow(), + ) + session.add(entity) + + scan_state.progress = i + 1 + + session.commit() + + scan_state.finish(len(states)) + + except Exception as e: + scan_state.fail(str(e)) + raise diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..6a4e8db --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.0 +sqlmodel==0.0.22 +aiohttp==3.10.0 +pydantic-settings==2.5.0 +pytest==8.3.0 +pytest-asyncio==0.24.0 +httpx==0.27.0 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..3de2c70 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,39 @@ +import pytest +from sqlalchemy.pool import StaticPool +from sqlmodel import Session, SQLModel, create_engine +from fastapi.testclient import TestClient + +from app.models import EntityCache, EntityFlag, AuditLog +from app.database import set_engine, get_session + + +@pytest.fixture(name="engine") +def engine_fixture(): + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + SQLModel.metadata.create_all(engine) + set_engine(engine) + yield engine + + +@pytest.fixture(name="session") +def session_fixture(engine): + with Session(engine) as session: + yield session + + +@pytest.fixture(name="client") +def client_fixture(engine): + from app.main import app + + def override_get_session(): + with Session(engine) as session: + yield session + + app.dependency_overrides[get_session] = override_get_session + client = TestClient(app) + yield client + app.dependency_overrides.clear() diff --git a/backend/tests/test_actions.py b/backend/tests/test_actions.py new file mode 100644 index 0000000..87c2b35 --- /dev/null +++ b/backend/tests/test_actions.py @@ -0,0 +1,80 @@ +from datetime import datetime + +from sqlmodel import Session + +from app.models import EntityCache, EntityFlag, AuditLog + + +def _seed_entity(session: Session): + session.add(EntityCache( + entity_id="light.test", + domain="light", + friendly_name="Test", + state="on", + fetched_at=datetime.utcnow(), + )) + session.commit() + + +def test_favorite_entity(client, session): + _seed_entity(session) + resp = client.post("/api/entities/actions", json={ + "action": "favorite", + "entity_ids": ["light.test"], + }) + assert resp.status_code == 200 + data = resp.json() + assert data["action"] == "favorite" + assert data["results"][0]["ok"] is True + + # Vérifier le flag + flag = session.get(EntityFlag, "light.test") + assert flag is not None + assert flag.favorite is True + + +def test_unfavorite_entity(client, session): + _seed_entity(session) + # D'abord favori + client.post("/api/entities/actions", json={ + "action": "favorite", + "entity_ids": ["light.test"], + }) + # Puis défavori + resp = client.post("/api/entities/actions", json={ + "action": "unfavorite", + "entity_ids": ["light.test"], + }) + assert resp.status_code == 200 + flag = session.get(EntityFlag, "light.test") + assert flag.favorite is False + + +def test_ignore_entity(client, session): + _seed_entity(session) + resp = client.post("/api/entities/actions", json={ + "action": "ignore", + "entity_ids": ["light.test"], + }) + assert resp.status_code == 200 + flag = session.get(EntityFlag, "light.test") + assert flag.ignored_local is True + + +def test_bulk_action(client, session): + session.add(EntityCache(entity_id="light.a", domain="light", state="on", fetched_at=datetime.utcnow())) + session.add(EntityCache(entity_id="light.b", domain="light", state="off", fetched_at=datetime.utcnow())) + session.commit() + + resp = client.post("/api/entities/actions", json={ + "action": "favorite", + "entity_ids": ["light.a", "light.b"], + }) + data = resp.json() + assert len(data["results"]) == 2 + + # Vérifier audit_log + from sqlmodel import select + logs = session.exec(select(AuditLog)).all() + assert len(logs) >= 1 + assert logs[-1].action == "favorite" diff --git a/backend/tests/test_entities.py b/backend/tests/test_entities.py new file mode 100644 index 0000000..5143a59 --- /dev/null +++ b/backend/tests/test_entities.py @@ -0,0 +1,119 @@ +from datetime import datetime + +from sqlmodel import Session + +from app.models import EntityCache, EntityFlag + + +def _seed_entities(session: Session): + entities = [ + EntityCache( + entity_id="light.salon", + domain="light", + friendly_name="Lumière Salon", + state="on", + is_available=True, + fetched_at=datetime.utcnow(), + ), + EntityCache( + entity_id="sensor.temperature", + domain="sensor", + friendly_name="Température", + state="22.5", + device_class="temperature", + unit_of_measurement="°C", + is_available=True, + fetched_at=datetime.utcnow(), + ), + EntityCache( + entity_id="switch.garage", + domain="switch", + friendly_name="Garage", + state="off", + is_available=True, + fetched_at=datetime.utcnow(), + ), + EntityCache( + entity_id="sensor.humidity", + domain="sensor", + friendly_name="Humidité", + state="unavailable", + is_available=False, + fetched_at=datetime.utcnow(), + ), + ] + for e in entities: + session.add(e) + session.commit() + + +def test_list_entities_empty(client): + resp = client.get("/api/entities") + assert resp.status_code == 200 + data = resp.json() + assert data["items"] == [] + assert data["total"] == 0 + + +def test_list_entities_with_data(client, session): + _seed_entities(session) + resp = client.get("/api/entities") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 4 + assert len(data["items"]) == 4 + + +def test_list_entities_filter_domain(client, session): + _seed_entities(session) + resp = client.get("/api/entities?domain=sensor") + assert resp.status_code == 200 + data = resp.json() + assert data["total"] == 2 + assert all(e["domain"] == "sensor" for e in data["items"]) + + +def test_list_entities_filter_multi_domain(client, session): + _seed_entities(session) + resp = client.get("/api/entities?domain=light,switch") + data = resp.json() + assert data["total"] == 2 + + +def test_list_entities_search(client, session): + _seed_entities(session) + resp = client.get("/api/entities?search=salon") + data = resp.json() + assert data["total"] == 1 + assert data["items"][0]["entity_id"] == "light.salon" + + +def test_list_entities_filter_available(client, session): + _seed_entities(session) + resp = client.get("/api/entities?available=false") + data = resp.json() + assert data["total"] == 1 + assert data["items"][0]["entity_id"] == "sensor.humidity" + + +def test_list_entities_pagination(client, session): + _seed_entities(session) + resp = client.get("/api/entities?page=1&per_page=2") + data = resp.json() + assert len(data["items"]) == 2 + assert data["total"] == 4 + assert data["pages"] == 2 + + +def test_get_entity_detail(client, session): + _seed_entities(session) + resp = client.get("/api/entities/light.salon") + assert resp.status_code == 200 + data = resp.json() + assert data["entity_id"] == "light.salon" + assert data["favorite"] is False + + +def test_get_entity_not_found(client): + resp = client.get("/api/entities/nonexistent.entity") + assert resp.status_code == 404 diff --git a/backend/tests/test_ha_client.py b/backend/tests/test_ha_client.py new file mode 100644 index 0000000..648b47c --- /dev/null +++ b/backend/tests/test_ha_client.py @@ -0,0 +1,79 @@ +from app.ha_client import normalize_entity + + +def test_normalize_entity_basic(): + state = { + "entity_id": "light.salon", + "state": "on", + "attributes": { + "friendly_name": "Lumière Salon", + "device_class": "light", + }, + "last_changed": "2026-01-01T00:00:00Z", + "last_updated": "2026-01-01T00:00:00Z", + } + result = normalize_entity(state) + + assert result["entity_id"] == "light.salon" + assert result["domain"] == "light" + assert result["friendly_name"] == "Lumière Salon" + assert result["state"] == "on" + assert result["device_class"] == "light" + assert result["is_available"] is True + assert result["is_disabled"] is False + + +def test_normalize_entity_unavailable(): + state = { + "entity_id": "sensor.temp", + "state": "unavailable", + "attributes": {}, + } + result = normalize_entity(state) + + assert result["is_available"] is False + assert result["domain"] == "sensor" + + +def test_normalize_entity_with_registry(): + state = { + "entity_id": "switch.garage", + "state": "off", + "attributes": {"friendly_name": "Garage"}, + } + registry = { + "entity_id": "switch.garage", + "area_id": "garage", + "device_id": "dev_123", + "platform": "esphome", + "disabled_by": None, + "hidden_by": "user", + } + result = normalize_entity(state, registry) + + assert result["area_id"] == "garage" + assert result["device_id"] == "dev_123" + assert result["integration"] == "esphome" + assert result["is_disabled"] is False + assert result["is_hidden"] is True + + +def test_normalize_entity_disabled_in_registry(): + state = { + "entity_id": "sensor.disabled", + "state": "unknown", + "attributes": {}, + } + registry = { + "entity_id": "sensor.disabled", + "disabled_by": "user", + "hidden_by": None, + "area_id": None, + "device_id": None, + "platform": "mqtt", + } + result = normalize_entity(state, registry) + + assert result["is_disabled"] is True + assert result["is_available"] is False + assert result["integration"] == "mqtt" diff --git a/consigne.md b/consigne.md new file mode 100644 index 0000000..6a4046f --- /dev/null +++ b/consigne.md @@ -0,0 +1,151 @@ +# CONSIGNE — Claude Code — Webapp “HA Entity Scanner & Manager” + +## Objectif +Développer une webapp self-hosted (Docker) qui se connecte à Home Assistant (URL : `http://10.0.0.2:8123` et token:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhNjI1NzkyYjZhZmE0MTliOTEwMmFiMGQ4YjExNDZjNiIsImlhdCI6MTc3MTY4MTE2NSwiZXhwIjoyMDg3MDQxMTY1fQ.uus2-4HCDFajj2oFPiiW9b3KjhxF-DdhFN0XuZ_n5E8) pour : +1) **Scanner / récupérer** la liste des entités, +2) **Afficher** une page web avec **liste + filtres**, +3) Permettre de **désactiver** une ou plusieurs entités (ou, si la désactivation native n’est pas possible via API HA, fournir une alternative fiable et clairement indiquée à l’utilisateur). + +## Contraintes et attentes générales +- Langue UI : **français**. +- UI : claire, orientée “admin”, table + panneaux, responsive. +- Architecture standard : **Backend API + Frontend**. +- Stockage : **SQLite** par défaut (persistant via volume Docker). +- Auth : au minimum **token HA** côté backend (jamais exposé côté frontend). Idéalement variable d’environnement. +- Journalisation : logs backend lisibles + page “Journal” simple (dernières actions : scan, désactivation, erreurs). +- Fournir un **README** (installation, variables d’env, utilisation). +- Ne jamais bloquer l’UI : scan asynchrone + état d’avancement. + +## Périmètre fonctionnel (MVP) +### A. Connexion Home Assistant +- Paramètres : + - `HA_BASE_URL = http://10.0.0.2:8123` + - `HA_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhNjI1NzkyYjZhZmE0MTliOTEwMmFiMGQ4YjExNDZjNiIsImlhdCI6MTc3MTY4MTE2NSwiZXhwIjoyMDg3MDQxMTY1fQ.uus2-4HCDFajj2oFPiiW9b3KjhxF-DdhFN0XuZ_n5E8` +- Tester la connexion via l’API REST Home Assistant. +- Si erreur : message UI explicite (HTTP code + cause probable). + +### B. Récupération des entités +- Récupérer la liste des entités (au minimum : `entity_id`, `state`, `attributes`, `last_changed`, `last_updated`). +- Normaliser les champs utiles pour l’affichage : + - `domain` (ex: light, switch, sensor) + - `friendly_name` + - `device_class`, `unit_of_measurement`, `icon` + - `area_id` / `device_id` / `integration` si accessibles (sinon marquer “non disponible”) + - booléen `is_hidden` / `is_disabled` / `is_available` si déductible +- Mettre en cache en base (table `entities_cache`) et timestamp du dernier scan. + +### C. UI de listing + actions +- Page principale : tableau (virtualisé si besoin) avec colonnes configurables. +- Action : sélection multiple (checkbox) + actions en lot : + - “Désactiver” + - “Réactiver” (si applicable) + - “Masquer (UI)” (fallback si désactivation native impossible) +- Afficher un panneau “Détails” à droite quand on clique une entité : + - attributs bruts JSON + - infos clés (domain, nom, device_class, etc.) + - boutons d’action sur cette entité + +## “Désactiver une entité” — règles d’implémentation +Home Assistant n’offre pas toujours une “désactivation” universelle via une simple API REST. Claude Code doit : + +1) **Rechercher la méthode la plus correcte** selon le type d’entité : + - Si l’entité est gérable via **registry** (entity_registry / device_registry) via WebSocket API, utiliser la méthode officielle. + - Sinon, proposer un **fallback** : + - “Masquer” (côté HA si possible via registry) ou “Ignorer dans l’app” (flag local DB) avec libellé clair. + - Option “Désactiver en empêchant les services” : non (trop intrusif) sauf si explicitement demandé. +2) L’UI doit **indiquer clairement** ce que fait l’action (désactivation HA réelle vs masquage/ignore local). +3) Toute action doit être **journalisée** (quoi, qui, quand, résultat, erreur). + +## Brainstorming — filtres “judicieux” à implémenter +Implémenter une barre de filtres combinables + chips actives : +1) **Recherche texte** (entity_id, friendly_name). +2) **Domain** (multi-select) : light/switch/sensor/… + compteur par domaine. +3) **État** : + - `state` exact (on/off/…) + - “Indisponible” (`unavailable`, `unknown`) +4) **Disponibilité** : + - disponibles vs indisponibles +5) **Dernier changement** : + - “modifié dans les 5 min / 1 h / 24 h” +6) **Attributs** : + - `device_class` (multi-select) + - `unit_of_measurement` (multi-select) +7) **Source / intégration** (si accessible) : + - ZHA / Zigbee2MQTT / ESPHome / MQTT / Tapo / Reolink / … +8) **Zone / Pièce** (area) si accessible. +9) **Entités problématiques** (smart filters) : + - “spam logbook” : change très souvent (détecter fréquence) + - “entités orphelines” : pas de device_id / integration inconnue + - “noms manquants” : pas de friendly_name +10) **Statut de gestion** (dans l’app) : + - “Désactivées” + - “Masquées” + - “Ignorées localement” +11) **Favoris** : + - Marquer favori (flag local) pour accès rapide. + +## UX attendue +- En-tête : + - bouton “Scanner maintenant” + - indicateur “Dernier scan : …” + - statut connexion HA (OK/KO) +- Zone filtres : + - champ recherche + filtres en dropdown + chips +- Table : + - tri colonnes (domain, nom, last_changed, state) + - pagination ou virtual scroll + - multi-sélection + actions groupées +- Panneau latéral détails : + - affichage propre des attributs JSON + - actions rapides (désactiver/masquer/ignorer/favori) + +## Tech recommandée (choix par défaut) +- Backend : **Python FastAPI** + - Client HA via REST + WebSocket (si nécessaire pour registry) + - SQLite via SQLModel ou SQLAlchemy +- Frontend : **Vue 3 + Vite** (ou React si préféré), UI simple +- Docker : + - `docker-compose.yml` avec volumes (db + config) + - variables d’environnement pour `HA_BASE_URL`, `HA_TOKEN` + +## Endpoints backend (à produire) +- `GET /api/health` : état de l’app + état HA +- `POST /api/scan` : lance un scan (asynchrone) +- `GET /api/entities` : liste paginée + filtres (query params) +- `GET /api/entities/{entity_id}` : détails +- `POST /api/entities/actions` : actions bulk (disable/enable/hide/unhide/ignore/unignore/favorite/unfavorite) +- `GET /api/audit` : dernières actions + +## Données (SQLite) +Tables minimales : +- `entities_cache` : entity_id (PK), domain, friendly_name, state, attrs_json, last_changed, last_updated, fetched_at +- `entity_flags` : entity_id (PK), ignored_local (bool), favorite (bool), notes (text) +- `audit_log` : id, ts, action, entity_ids_json, result, error + +## Exigences qualité +- Gestion d’erreurs explicite (HA down, token invalide, timeout). +- Timeout réseau configurable. +- Tests minimaux : + - test de parsing entités + - test filtre domain/state + - test “action bulk” (mock HA) +- Sécurité : + - le token HA ne doit jamais apparaître dans le HTML/JS. + - CORS maîtrisé. + +## Livrables attendus +- Arborescence projet complète. +- `docker-compose.yml` +- Backend FastAPI prêt à lancer. +- Frontend page unique fonctionnelle. +- `README.md` clair. +- Une section “Limitations” expliquant la différence entre : + - désactivation réelle HA via registry (si implémentée) + - masquage/ignore local si non possible selon entité. + +## Ordre de réalisation imposé +1) Backend : health + scan + entities list (sans disable) +2) Frontend : page liste + filtres + détails +3) Ajout flags locaux (ignore/favorite) +4) Implémenter “désactiver/masquer” via API HA la plus officielle possible +5) Audit log + finitions UI + README final \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3d5e071 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +services: + backend: + build: ./backend + environment: + - HA_BASE_URL=${HA_BASE_URL:-http://10.0.0.2:8123} + - HA_TOKEN=${HA_TOKEN} + - DATABASE_URL=sqlite:///./data/ha_explorer.db + - CORS_ORIGINS=["http://localhost", "http://localhost:8080"] + volumes: + - db_data:/app/data + ports: + - "8000:8000" + restart: unless-stopped + + frontend: + build: ./frontend + ports: + - "8080:80" + depends_on: + - backend + restart: unless-stopped + +volumes: + db_data: diff --git a/docs/plans/2026-02-21-ha-entity-scanner-design.md b/docs/plans/2026-02-21-ha-entity-scanner-design.md new file mode 100644 index 0000000..7da3c28 --- /dev/null +++ b/docs/plans/2026-02-21-ha-entity-scanner-design.md @@ -0,0 +1,80 @@ +# Design — HA Entity Scanner & Manager + +**Date** : 2026-02-21 +**Statut** : Approuvé + +## Architecture globale + +``` +Frontend (Vue 3 + Vuetify 3) ──► Backend (FastAPI) ──► Home Assistant + SPA :5173 API :8000 :8123 + SQLite DB REST + WS +``` + +- **Frontend** : SPA Vue 3 + Vite + Vuetify 3, appelle `/api/*` +- **Backend** : FastAPI, proxy sécurisé vers HA (token jamais exposé côté client) +- **DB** : SQLite via SQLModel, 3 tables (entities_cache, entity_flags, audit_log) +- **Client HA** : `aiohttp` pour REST (`/api/states`) et WebSocket (`/api/websocket`) +- **Déploiement** : Docker + docker-compose, variables d'env `HA_BASE_URL` / `HA_TOKEN` + +## Backend — structure des modules + +| Module | Responsabilité | +|--------|---------------| +| `app/main.py` | App FastAPI, CORS, lifespan | +| `app/config.py` | Settings via pydantic-settings (env vars) | +| `app/models.py` | SQLModel : EntityCache, EntityFlag, AuditLog | +| `app/database.py` | Init SQLite, get_session | +| `app/ha_client.py` | Client aiohttp : REST states + WS registry | +| `app/routers/health.py` | `GET /api/health` | +| `app/routers/scan.py` | `POST /api/scan` (async background task) | +| `app/routers/entities.py` | `GET /api/entities`, `GET /api/entities/{id}` | +| `app/routers/actions.py` | `POST /api/entities/actions` | +| `app/routers/audit.py` | `GET /api/audit` | +| `app/services/scanner.py` | Logique scan : fetch + normalize + upsert DB | +| `app/services/entity_actions.py` | Logique disable/enable/hide via WS ou fallback | + +## Frontend — composants principaux + +| Composant | Rôle | +|-----------|------| +| `App.vue` | Layout principal, header avec statut HA | +| `EntityTable.vue` | `v-data-table-server` avec tri, pagination, sélection | +| `FilterBar.vue` | Recherche texte + dropdowns domaine/état + chips actives | +| `EntityDetail.vue` | Panneau latéral détails + actions | +| `AuditLog.vue` | Page journal des actions | +| `ScanButton.vue` | Bouton scan + indicateur progression | + +## Base de données SQLite + +### entities_cache +- `entity_id` (PK), `domain`, `friendly_name`, `state` +- `attrs_json` (TEXT — attributs HA complets) +- `device_class`, `unit_of_measurement`, `area_id`, `device_id`, `integration` +- `is_disabled`, `is_hidden`, `is_available` (booléens déduits) +- `last_changed`, `last_updated`, `fetched_at` + +### entity_flags +- `entity_id` (PK), `ignored_local` (bool), `favorite` (bool), `notes` (text) + +### audit_log +- `id` (PK auto), `ts` (datetime), `action` (text), `entity_ids_json` (text), `result` (text), `error` (text) + +## Scan asynchrone + +`POST /api/scan` lance une `BackgroundTask` FastAPI. Un état en mémoire (`idle`/`scanning`/`done`/`error` + progression) est exposé via `GET /api/health`. Le frontend poll le health pour afficher la progression. + +## Désactivation des entités + +1. **Méthode principale** : WS API HA `config/entity_registry/update` avec `disabled_by: "user"` +2. **Fallback** : flag `ignored_local=true` en DB locale +3. L'UI affiche un badge distinct selon le mode utilisé (désactivé HA vs ignoré local) +4. Toute action journalisée dans `audit_log` + +## Choix techniques + +- **Python FastAPI** + **aiohttp** (client HA REST + WS) +- **SQLModel** (SQLAlchemy + Pydantic) +- **Vue 3** + **Vite** + **Vuetify 3** +- **Docker** + **docker-compose** +- UI en **français** uniquement diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..adbb0df --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,16 @@ +FROM node:20-alpine AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM nginx:alpine + +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0e0e869 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + HA Entity Scanner + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..586653b --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,16 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..0dfb882 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1535 @@ +{ + "name": "ha-entity-scanner", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ha-entity-scanner", + "version": "0.1.0", + "dependencies": { + "@mdi/font": "^7.4.0", + "vue": "^3.5.0", + "vuetify": "^3.7.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.0", + "typescript": "~5.6.0", + "vite": "^6.0.0", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@mdi/font": { + "version": "7.4.47", + "resolved": "https://registry.npmjs.org/@mdi/font/-/font-7.4.47.tgz", + "integrity": "sha512-43MtGpd585SNzHZPcYowu/84Vz2a2g31TvPMTm9uTiCSWzaheQySUcSyUH/46fPnuPQWof2yd0pGBtzee/IQWw==", + "license": "Apache-2.0" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz", + "integrity": "sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.58.0.tgz", + "integrity": "sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.58.0.tgz", + "integrity": "sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.58.0.tgz", + "integrity": "sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.58.0.tgz", + "integrity": "sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.58.0.tgz", + "integrity": "sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.58.0.tgz", + "integrity": "sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.58.0.tgz", + "integrity": "sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.58.0.tgz", + "integrity": "sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.58.0.tgz", + "integrity": "sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.58.0.tgz", + "integrity": "sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.58.0.tgz", + "integrity": "sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.58.0.tgz", + "integrity": "sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.58.0.tgz", + "integrity": "sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.58.0.tgz", + "integrity": "sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.58.0.tgz", + "integrity": "sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.58.0.tgz", + "integrity": "sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.58.0.tgz", + "integrity": "sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.58.0.tgz", + "integrity": "sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.58.0.tgz", + "integrity": "sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.58.0.tgz", + "integrity": "sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.58.0.tgz", + "integrity": "sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.58.0.tgz", + "integrity": "sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.58.0.tgz", + "integrity": "sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.58.0.tgz", + "integrity": "sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.28.tgz", + "integrity": "sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.28", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.28.tgz", + "integrity": "sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.28.tgz", + "integrity": "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.28", + "@vue/compiler-dom": "3.5.28", + "@vue/compiler-ssr": "3.5.28", + "@vue/shared": "3.5.28", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.28.tgz", + "integrity": "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.28.tgz", + "integrity": "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.28.tgz", + "integrity": "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.28.tgz", + "integrity": "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.28", + "@vue/runtime-core": "3.5.28", + "@vue/shared": "3.5.28", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.28.tgz", + "integrity": "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.28", + "@vue/shared": "3.5.28" + }, + "peerDependencies": { + "vue": "3.5.28" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.28.tgz", + "integrity": "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.58.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.58.0.tgz", + "integrity": "sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.58.0", + "@rollup/rollup-android-arm64": "4.58.0", + "@rollup/rollup-darwin-arm64": "4.58.0", + "@rollup/rollup-darwin-x64": "4.58.0", + "@rollup/rollup-freebsd-arm64": "4.58.0", + "@rollup/rollup-freebsd-x64": "4.58.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.58.0", + "@rollup/rollup-linux-arm-musleabihf": "4.58.0", + "@rollup/rollup-linux-arm64-gnu": "4.58.0", + "@rollup/rollup-linux-arm64-musl": "4.58.0", + "@rollup/rollup-linux-loong64-gnu": "4.58.0", + "@rollup/rollup-linux-loong64-musl": "4.58.0", + "@rollup/rollup-linux-ppc64-gnu": "4.58.0", + "@rollup/rollup-linux-ppc64-musl": "4.58.0", + "@rollup/rollup-linux-riscv64-gnu": "4.58.0", + "@rollup/rollup-linux-riscv64-musl": "4.58.0", + "@rollup/rollup-linux-s390x-gnu": "4.58.0", + "@rollup/rollup-linux-x64-gnu": "4.58.0", + "@rollup/rollup-linux-x64-musl": "4.58.0", + "@rollup/rollup-openbsd-x64": "4.58.0", + "@rollup/rollup-openharmony-arm64": "4.58.0", + "@rollup/rollup-win32-arm64-msvc": "4.58.0", + "@rollup/rollup-win32-ia32-msvc": "4.58.0", + "@rollup/rollup-win32-x64-gnu": "4.58.0", + "@rollup/rollup-win32-x64-msvc": "4.58.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.28", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.28.tgz", + "integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.28", + "@vue/compiler-sfc": "3.5.28", + "@vue/runtime-dom": "3.5.28", + "@vue/server-renderer": "3.5.28", + "@vue/shared": "3.5.28" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vuetify": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.12.0.tgz", + "integrity": "sha512-N1y3sxLAyrblBHJ6vFTQoTM9icwZd/jNUsmYVQTvHNQHN22XDqb0w2+ujaSoEn/JCHbtGb70tKRlB9SJ6HhVgg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/johnleider" + }, + "peerDependencies": { + "typescript": ">=4.7", + "vite-plugin-vuetify": ">=2.1.0", + "vue": "^3.5.0", + "webpack-plugin-vuetify": ">=3.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vite-plugin-vuetify": { + "optional": true + }, + "webpack-plugin-vuetify": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..7f868df --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "ha-entity-scanner", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.5.0", + "vuetify": "^3.7.0", + "@mdi/font": "^7.4.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.0", + "typescript": "~5.6.0", + "vite": "^6.0.0", + "vue-tsc": "^2.2.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..0719636 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,128 @@ + + + diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..59bfb68 --- /dev/null +++ b/frontend/src/api.ts @@ -0,0 +1,111 @@ +const BASE = '/api' + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }) + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${res.statusText}`) + } + return res.json() +} + +export interface HealthResponse { + status: string + ha_connected: boolean + ha_message: string + entity_count: number + scan_status: string + last_scan: string | null + progress: number + total: number + error: string +} + +export interface Entity { + entity_id: string + domain: string + friendly_name: string + state: string + attrs_json: string + device_class: string | null + unit_of_measurement: string | null + area_id: string | null + device_id: string | null + integration: string | null + is_disabled: boolean + is_hidden: boolean + is_available: boolean + last_changed: string | null + last_updated: string | null + fetched_at: string + favorite: boolean + ignored_local: boolean + notes: string + original_state: string | null + disabled_at: string | null +} + +export interface EntitiesResponse { + items: Entity[] + total: number + page: number + per_page: number + pages: number +} + +export interface AuditEntry { + id: number + ts: string + action: string + entity_ids_json: string + result: string + error: string +} + +export interface AuditResponse { + items: AuditEntry[] + total: number + page: number + per_page: number +} + +export interface FilterValues { + domains: string[] + areas: string[] + integrations: string[] + device_classes: string[] +} + +export const api = { + health: () => request('/health'), + + scan: () => request<{ message: string }>('/scan', { method: 'POST' }), + + entities: (params: Record) => { + const qs = new URLSearchParams() + for (const [k, v] of Object.entries(params)) { + if (v !== '' && v !== undefined && v !== null) qs.set(k, String(v)) + } + return request(`/entities?${qs}`) + }, + + entity: (id: string) => request(`/entities/${encodeURIComponent(id)}`), + + filterValues: () => request('/entities/filters'), + + action: (action: string, entityIds: string[]) => + request<{ action: string; results: any[] }>('/entities/actions', { + method: 'POST', + body: JSON.stringify({ action, entity_ids: entityIds }), + }), + + audit: (params: Record = {}) => { + const qs = new URLSearchParams() + for (const [k, v] of Object.entries(params)) { + if (v !== '' && v !== undefined) qs.set(k, String(v)) + } + return request(`/audit?${qs}`) + }, +} diff --git a/frontend/src/components/AuditLog.vue b/frontend/src/components/AuditLog.vue new file mode 100644 index 0000000..80cfdb3 --- /dev/null +++ b/frontend/src/components/AuditLog.vue @@ -0,0 +1,98 @@ + + + diff --git a/frontend/src/components/EntityDetail.vue b/frontend/src/components/EntityDetail.vue new file mode 100644 index 0000000..fcbb699 --- /dev/null +++ b/frontend/src/components/EntityDetail.vue @@ -0,0 +1,172 @@ + + + diff --git a/frontend/src/components/EntityTable.vue b/frontend/src/components/EntityTable.vue new file mode 100644 index 0000000..f1c46fe --- /dev/null +++ b/frontend/src/components/EntityTable.vue @@ -0,0 +1,176 @@ + + + diff --git a/frontend/src/components/FilterBar.vue b/frontend/src/components/FilterBar.vue new file mode 100644 index 0000000..9bd4975 --- /dev/null +++ b/frontend/src/components/FilterBar.vue @@ -0,0 +1,197 @@ + + + diff --git a/frontend/src/components/ScanButton.vue b/frontend/src/components/ScanButton.vue new file mode 100644 index 0000000..58b21e7 --- /dev/null +++ b/frontend/src/components/ScanButton.vue @@ -0,0 +1,60 @@ + + + diff --git a/frontend/src/composables/useEntities.ts b/frontend/src/composables/useEntities.ts new file mode 100644 index 0000000..0eb5aca --- /dev/null +++ b/frontend/src/composables/useEntities.ts @@ -0,0 +1,80 @@ +import { ref, reactive, watch } from 'vue' +import { api, type Entity, type EntitiesResponse } from '@/api' + +export interface Filters { + search: string + domain: string[] + state: string + available: string + device_class: string + integration: string + area_id: string + favorite: string + ignored: string +} + +export function useEntities() { + const entities = ref([]) + const total = ref(0) + const pages = ref(0) + const loading = ref(false) + const page = ref(1) + const perPage = ref(50) + const sortBy = ref('entity_id') + const sortDir = ref<'asc' | 'desc'>('asc') + + const filters = reactive({ + search: '', + domain: [], + state: '', + available: '', + device_class: '', + integration: '', + area_id: '', + favorite: '', + ignored: '', + }) + + async function fetchEntities() { + loading.value = true + try { + const params: Record = { + page: page.value, + per_page: perPage.value, + sort_by: sortBy.value, + sort_dir: sortDir.value, + } + if (filters.search) params.search = filters.search + if (filters.domain.length) params.domain = filters.domain.join(',') + if (filters.state) params.state = filters.state + if (filters.available) params.available = filters.available === 'true' + if (filters.device_class) params.device_class = filters.device_class + if (filters.integration) params.integration = filters.integration + if (filters.area_id) params.area_id = filters.area_id + if (filters.favorite) params.favorite = filters.favorite === 'true' + if (filters.ignored) params.ignored = filters.ignored === 'true' + + const data = await api.entities(params) + entities.value = data.items + total.value = data.total + pages.value = data.pages + } catch (e) { + console.error('Erreur chargement entités:', e) + } finally { + loading.value = false + } + } + + return { + entities, + total, + pages, + loading, + page, + perPage, + sortBy, + sortDir, + filters, + fetchEntities, + } +} diff --git a/frontend/src/composables/useHealth.ts b/frontend/src/composables/useHealth.ts new file mode 100644 index 0000000..87a6814 --- /dev/null +++ b/frontend/src/composables/useHealth.ts @@ -0,0 +1,30 @@ +import { ref, onMounted, onUnmounted } from 'vue' +import { api, type HealthResponse } from '@/api' + +export function useHealth() { + const health = ref(null) + const loading = ref(false) + let timer: ReturnType | null = null + + async function fetchHealth() { + loading.value = true + try { + health.value = await api.health() + } catch (e) { + health.value = null + } finally { + loading.value = false + } + } + + onMounted(() => { + fetchHealth() + timer = setInterval(fetchHealth, 5000) + }) + + onUnmounted(() => { + if (timer) clearInterval(timer) + }) + + return { health, loading, fetchHealth } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..8721944 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,73 @@ +import { createApp } from 'vue' +import { createVuetify } from 'vuetify' +import * as components from 'vuetify/components' +import * as labsComponents from 'vuetify/labs/components' +import * as directives from 'vuetify/directives' +import 'vuetify/styles' +import '@mdi/font/css/materialdesignicons.css' +import App from './App.vue' + +// Gruvbox Dark (Seventies) palette +const vuetify = createVuetify({ + components: { ...components, ...labsComponents }, + directives, + theme: { + defaultTheme: 'gruvboxDark', + themes: { + gruvboxDark: { + dark: true, + colors: { + background: '#1d2021', + surface: '#282828', + 'surface-variant': '#3c3836', + 'on-surface': '#ebdbb2', + 'on-background': '#ebdbb2', + primary: '#d79921', // Gruvbox yellow + 'primary-darken-1': '#b57614', + secondary: '#689d6a', // Gruvbox aqua + 'secondary-darken-1': '#427b58', + error: '#cc241d', // Gruvbox red + warning: '#d65d0e', // Gruvbox orange + info: '#458588', // Gruvbox blue + success: '#98971a', // Gruvbox green + 'on-primary': '#1d2021', + 'on-secondary': '#1d2021', + 'on-error': '#1d2021', + 'on-warning': '#1d2021', + 'on-info': '#ebdbb2', + 'on-success': '#1d2021', + }, + variables: { + 'border-color': '#504945', + 'border-opacity': 0.4, + 'high-emphasis-opacity': 0.95, + 'medium-emphasis-opacity': 0.7, + 'disabled-opacity': 0.4, + }, + }, + }, + }, + defaults: { + VDataTableServer: { + fixedHeader: true, + hover: true, + }, + VCard: { + color: 'surface', + }, + VAppBar: { + color: '#32302f', + }, + VNavigationDrawer: { + color: '#32302f', + }, + VToolbar: { + color: 'surface', + }, + VChip: { + variant: 'tonal', + }, + }, +}) + +createApp(App).use(vuetify).mount('#app') diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..323c78a --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..daa5c70 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "noEmit": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.vue", "src/vite-env.d.ts"] +} diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo new file mode 100644 index 0000000..291f1fe --- /dev/null +++ b/frontend/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/api.ts","./src/main.ts","./src/vite-env.d.ts","./src/composables/useEntities.ts","./src/composables/useHealth.ts","./src/App.vue","./src/components/AuditLog.vue","./src/components/EntityDetail.vue","./src/components/EntityTable.vue","./src/components/FilterBar.vue","./src/components/ScanButton.vue"],"version":"5.6.3"} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..266a05b --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + server: { + port: 5173, + proxy: { + '/api': 'http://localhost:8000' + } + } +})