This commit is contained in:
2026-02-21 16:55:10 +01:00
commit 1b8bf79d46
49 changed files with 4347 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
HA_BASE_URL=http://10.0.0.2:8123
HA_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJhNjI1NzkyYjZhZmE0MTliOTEwMmFiMGQ4YjExNDZjNiIsImlhdCI6MTc3MTY4MTE2NSwiZXhwIjoyMDg3MDQxMTY1fQ.uus2-4HCDFajj2oFPiiW9b3KjhxF-DdhFN0XuZ_n5E8

44
.gitignore vendored Normal file
View File

@@ -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

82
CLAUDE.md Normal file
View File

@@ -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`

102
README.md Normal file
View File

@@ -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 <url> && 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.

14
backend/Dockerfile Normal file
View File

@@ -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"]

0
backend/app/__init__.py Normal file
View File

13
backend/app/config.py Normal file
View File

@@ -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()

35
backend/app/database.py Normal file
View File

@@ -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

135
backend/app/ha_client.py Normal file
View File

@@ -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()

31
backend/app/main.py Normal file
View File

@@ -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")

47
backend/app/models.py Normal file
View File

@@ -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 = ""

View File

View File

@@ -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}

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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(),
}

View File

@@ -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()}

39
backend/app/scan_state.py Normal file
View File

@@ -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()

View File

View File

@@ -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()

View File

@@ -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

8
backend/requirements.txt Normal file
View File

@@ -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

View File

39
backend/tests/conftest.py Normal file
View File

@@ -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()

View File

@@ -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"

View File

@@ -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

View File

@@ -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"

151
consigne.md Normal file
View File

@@ -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 nest pas possible via API HA, fournir une alternative fiable et clairement indiquée à lutilisateur).
## 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 denvironnement.
- Journalisation : logs backend lisibles + page “Journal” simple (dernières actions : scan, désactivation, erreurs).
- Fournir un **README** (installation, variables denv, utilisation).
- Ne jamais bloquer lUI : scan asynchrone + état davancement.
## 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 lAPI 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 laffichage :
- `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 daction sur cette entité
## “Désactiver une entité” — règles dimplémentation
Home Assistant noffre 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 dentité :
- Si lentité 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 lapp” (flag local DB) avec libellé clair.
- Option “Désactiver en empêchant les services” : non (trop intrusif) sauf si explicitement demandé.
2) LUI doit **indiquer clairement** ce que fait laction (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 lapp) :
- “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 denvironnement pour `HA_BASE_URL`, `HA_TOKEN`
## Endpoints backend (à produire)
- `GET /api/health` : état de lapp + é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 derreurs 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

24
docker-compose.yml Normal file
View File

@@ -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:

View File

@@ -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

16
frontend/Dockerfile Normal file
View File

@@ -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

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HA Entity Scanner</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

16
frontend/nginx.conf Normal file
View File

@@ -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;
}
}

1535
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View File

@@ -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"
}
}

128
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,128 @@
<template>
<v-app>
<!-- Barre de navigation -->
<v-app-bar flat density="compact" color="surface">
<v-app-bar-title>
<v-icon start>mdi-home-assistant</v-icon>
HA Entity Scanner
</v-app-bar-title>
<template #append>
<!-- Statut HA -->
<v-chip
:color="health?.ha_connected ? 'success' : 'error'"
size="small"
variant="tonal"
class="mr-3"
>
<v-icon start size="small">
{{ health?.ha_connected ? 'mdi-check-circle' : 'mdi-alert-circle' }}
</v-icon>
{{ health?.ha_connected ? 'HA connecté' : health?.ha_message || 'HA déconnecté' }}
</v-chip>
<!-- Onglets -->
<v-btn-toggle v-model="currentTab" mandatory density="compact" variant="outlined" class="mr-3">
<v-btn value="entities" size="small">
<v-icon start>mdi-format-list-bulleted</v-icon>
Entités
</v-btn>
<v-btn value="audit" size="small">
<v-icon start>mdi-clipboard-text-clock</v-icon>
Journal
</v-btn>
</v-btn-toggle>
</template>
</v-app-bar>
<v-main>
<v-container fluid>
<!-- Page Entités -->
<template v-if="currentTab === 'entities'">
<div class="d-flex align-center justify-space-between mb-4">
<ScanButton :health="health" @scanned="fetchHealth" />
</div>
<FilterBar v-model:filters="filters" @filter="onFilter" />
<EntityTable
:entities="entities"
:total="total"
:loading="entitiesLoading"
:page="page"
:per-page="perPage"
@select="onSelectEntity"
@update:options="onTableOptions"
@refresh="fetchEntities"
/>
</template>
<!-- Page Journal -->
<template v-if="currentTab === 'audit'">
<AuditLog />
</template>
</v-container>
</v-main>
<!-- Panneau détails -->
<EntityDetail
:entity="selectedEntity"
@close="selectedEntity = null"
@refresh="onEntityActionDone"
/>
</v-app>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useHealth } from '@/composables/useHealth'
import { useEntities } from '@/composables/useEntities'
import type { Entity } from '@/api'
import { api } from '@/api'
import ScanButton from '@/components/ScanButton.vue'
import FilterBar from '@/components/FilterBar.vue'
import EntityTable from '@/components/EntityTable.vue'
import EntityDetail from '@/components/EntityDetail.vue'
import AuditLog from '@/components/AuditLog.vue'
const currentTab = ref('entities')
const { health, fetchHealth } = useHealth()
const {
entities, total, loading: entitiesLoading,
page, perPage, sortBy, sortDir,
filters, fetchEntities,
} = useEntities()
const selectedEntity = ref<Entity | null>(null)
function onFilter() {
page.value = 1
fetchEntities()
}
function onTableOptions(opts: any) {
page.value = opts.page
perPage.value = opts.itemsPerPage
if (opts.sortBy?.length) {
sortBy.value = opts.sortBy[0].key
sortDir.value = opts.sortBy[0].order
}
fetchEntities()
}
function onSelectEntity(entity: Entity) {
selectedEntity.value = entity
}
async function onEntityActionDone() {
// Recharger les détails de l'entité sélectionnée + la liste
if (selectedEntity.value) {
try {
selectedEntity.value = await api.entity(selectedEntity.value.entity_id)
} catch { /* ignore */ }
}
fetchEntities()
}
onMounted(fetchEntities)
</script>

111
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,111 @@
const BASE = '/api'
async function request<T>(path: string, options?: RequestInit): Promise<T> {
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<HealthResponse>('/health'),
scan: () => request<{ message: string }>('/scan', { method: 'POST' }),
entities: (params: Record<string, string | number | boolean>) => {
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<EntitiesResponse>(`/entities?${qs}`)
},
entity: (id: string) => request<Entity>(`/entities/${encodeURIComponent(id)}`),
filterValues: () => request<FilterValues>('/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<string, string | number> = {}) => {
const qs = new URLSearchParams()
for (const [k, v] of Object.entries(params)) {
if (v !== '' && v !== undefined) qs.set(k, String(v))
}
return request<AuditResponse>(`/audit?${qs}`)
},
}

View File

@@ -0,0 +1,98 @@
<template>
<v-card flat>
<v-card-title class="d-flex align-center">
<v-icon start>mdi-clipboard-text-clock</v-icon>
Journal des actions
</v-card-title>
<v-data-table-server
:headers="headers"
:items="items"
:items-length="total"
:loading="loading"
:page="page"
:items-per-page="perPage"
density="compact"
@update:options="onOptions"
>
<template #item.ts="{ item }">
<span class="text-caption">{{ formatDate(item.ts) }}</span>
</template>
<template #item.action="{ item }">
<v-chip size="small" :color="actionColor(item.action)">
{{ item.action }}
</v-chip>
</template>
<template #item.entity_ids_json="{ item }">
<span class="text-caption">{{ formatEntities(item.entity_ids_json) }}</span>
</template>
<template #item.result="{ item }">
<v-chip size="x-small" :color="item.error ? 'error' : 'success'" variant="tonal">
{{ item.error || item.result }}
</v-chip>
</template>
</v-data-table-server>
</v-card>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { api, type AuditEntry } from '@/api'
const items = ref<AuditEntry[]>([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const perPage = ref(50)
const headers = [
{ title: 'Date', key: 'ts', sortable: false },
{ title: 'Action', key: 'action', sortable: false },
{ title: 'Entités', key: 'entity_ids_json', sortable: false },
{ title: 'Résultat', key: 'result', sortable: false },
]
async function fetchAudit() {
loading.value = true
try {
const data = await api.audit({ page: page.value, per_page: perPage.value })
items.value = data.items
total.value = data.total
} catch (e) {
console.error('Erreur audit:', e)
} finally {
loading.value = false
}
}
function onOptions(opts: any) {
page.value = opts.page
perPage.value = opts.itemsPerPage
fetchAudit()
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('fr-FR')
}
function actionColor(action: string): string {
const map: Record<string, string> = {
disable: 'error', enable: 'success',
favorite: 'warning', unfavorite: 'grey',
ignore: 'grey', unignore: 'secondary',
scan: 'info',
}
return map[action] || 'default'
}
function formatEntities(json: string): string {
try {
const ids = JSON.parse(json) as string[]
if (ids.length <= 3) return ids.join(', ')
return `${ids.slice(0, 3).join(', ')} +${ids.length - 3}`
} catch {
return json
}
}
onMounted(fetchAudit)
</script>

View File

@@ -0,0 +1,172 @@
<template>
<v-navigation-drawer
:model-value="!!entity"
location="right"
width="450"
temporary
@update:model-value="!$event && emit('close')"
>
<template v-if="entity">
<v-toolbar flat density="compact" color="surface">
<v-toolbar-title class="text-body-1 font-weight-bold">
{{ entity.friendly_name || entity.entity_id }}
</v-toolbar-title>
<v-btn icon="mdi-close" variant="text" @click="emit('close')" />
</v-toolbar>
<v-card flat>
<v-card-text>
<!-- Infos clés -->
<v-list density="compact" class="pa-0">
<v-list-item>
<template #prepend><v-icon icon="mdi-identifier" size="small" /></template>
<v-list-item-title class="text-caption">Entity ID</v-list-item-title>
<v-list-item-subtitle class="text-body-2">{{ entity.entity_id }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend><v-icon icon="mdi-tag" size="small" /></template>
<v-list-item-title class="text-caption">Domaine</v-list-item-title>
<v-list-item-subtitle>{{ entity.domain }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend><v-icon icon="mdi-circle" size="small" :color="entity.is_available ? 'success' : 'error'" /></template>
<v-list-item-title class="text-caption">État</v-list-item-title>
<v-list-item-subtitle>
<v-chip size="small" :color="entity.state === 'on' ? 'success' : 'default'">
{{ entity.state }}
</v-chip>
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="entity.device_class">
<template #prepend><v-icon icon="mdi-devices" size="small" /></template>
<v-list-item-title class="text-caption">Device Class</v-list-item-title>
<v-list-item-subtitle>{{ entity.device_class }}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="entity.integration">
<template #prepend><v-icon icon="mdi-puzzle" size="small" /></template>
<v-list-item-title class="text-caption">Intégration</v-list-item-title>
<v-list-item-subtitle>{{ entity.integration }}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="entity.area_id">
<template #prepend><v-icon icon="mdi-home-map-marker" size="small" /></template>
<v-list-item-title class="text-caption">Zone</v-list-item-title>
<v-list-item-subtitle>{{ entity.area_id }}</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="entity.unit_of_measurement">
<template #prepend><v-icon icon="mdi-ruler" size="small" /></template>
<v-list-item-title class="text-caption">Unité</v-list-item-title>
<v-list-item-subtitle>{{ entity.unit_of_measurement }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<v-divider class="my-3" />
<!-- Badges statut -->
<div class="d-flex flex-wrap ga-2 mb-3">
<v-chip v-if="entity.is_disabled" color="error" size="small" prepend-icon="mdi-power-off">
Désactivé (HA)
</v-chip>
<v-chip v-if="entity.ignored_local" color="grey" size="small" prepend-icon="mdi-eye-off">
Ignoré (local)
</v-chip>
<v-chip v-if="entity.favorite" color="warning" size="small" prepend-icon="mdi-star">
Favori
</v-chip>
<v-chip v-if="!entity.is_available" color="error" size="small" variant="outlined" prepend-icon="mdi-alert">
Indisponible
</v-chip>
</div>
<!-- État original (si désactivé) -->
<v-alert
v-if="entity.original_state && (entity.is_disabled || entity.ignored_local)"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
icon="mdi-history"
>
<div class="text-body-2">
<strong>État avant désactivation :</strong> {{ entity.original_state }}
</div>
<div v-if="entity.disabled_at" class="text-caption text-medium-emphasis">
Désactivé le {{ new Date(entity.disabled_at).toLocaleString('fr-FR') }}
</div>
</v-alert>
<!-- Actions -->
<div class="d-flex flex-wrap ga-2 mb-4">
<v-btn
size="small"
:color="entity.favorite ? 'grey' : 'warning'"
variant="outlined"
:prepend-icon="entity.favorite ? 'mdi-star-off' : 'mdi-star'"
@click="doAction(entity.favorite ? 'unfavorite' : 'favorite')"
>
{{ entity.favorite ? 'Retirer favori' : 'Favori' }}
</v-btn>
<v-btn
size="small"
:color="entity.ignored_local ? 'secondary' : 'grey'"
variant="outlined"
:prepend-icon="entity.ignored_local ? 'mdi-eye' : 'mdi-eye-off'"
@click="doAction(entity.ignored_local ? 'unignore' : 'ignore')"
>
{{ entity.ignored_local ? 'Restaurer' : 'Ignorer' }}
</v-btn>
<v-btn
size="small"
:color="entity.is_disabled ? 'success' : 'error'"
variant="outlined"
:prepend-icon="entity.is_disabled ? 'mdi-power' : 'mdi-power-off'"
@click="doAction(entity.is_disabled ? 'enable' : 'disable')"
>
{{ entity.is_disabled ? 'Réactiver' : 'Désactiver' }}
</v-btn>
</div>
<!-- Attributs bruts -->
<v-expansion-panels variant="accordion">
<v-expansion-panel title="Attributs bruts (JSON)">
<v-expansion-panel-text>
<pre class="text-caption" style="white-space: pre-wrap; word-break: break-all;">{{ formatJson(entity.attrs_json) }}</pre>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-card-text>
</v-card>
</template>
</v-navigation-drawer>
</template>
<script setup lang="ts">
import type { Entity } from '@/api'
import { api } from '@/api'
const props = defineProps<{
entity: Entity | null
}>()
const emit = defineEmits<{
close: []
refresh: []
}>()
async function doAction(action: string) {
if (!props.entity) return
try {
await api.action(action, [props.entity.entity_id])
emit('refresh')
} catch (e) {
console.error('Erreur action:', e)
}
}
function formatJson(jsonStr: string): string {
try {
return JSON.stringify(JSON.parse(jsonStr), null, 2)
} catch {
return jsonStr
}
}
</script>

View File

@@ -0,0 +1,176 @@
<template>
<v-data-table-server
v-model="selected"
:headers="headers"
:items="entities"
:items-length="total"
:loading="loading"
:page="page"
:items-per-page="perPage"
show-select
item-value="entity_id"
density="compact"
class="elevation-1"
@update:options="onOptions"
@click:row="(_e: any, { item }: any) => emit('select', item)"
>
<template #item.entity_id="{ item }">
<span class="text-body-2 font-weight-medium">{{ item.entity_id }}</span>
</template>
<template #item.state="{ item }">
<div class="d-flex align-center ga-1">
<v-chip
:color="stateColor(item.state)"
size="small"
variant="tonal"
>
{{ item.state }}
</v-chip>
<v-chip
v-if="item.original_state && (item.is_disabled || item.ignored_local)"
size="x-small"
variant="outlined"
color="warning"
prepend-icon="mdi-history"
>
était : {{ item.original_state }}
</v-chip>
</div>
</template>
<template #item.is_available="{ item }">
<v-icon
:icon="item.is_available ? 'mdi-check-circle' : 'mdi-alert-circle'"
:color="item.is_available ? 'success' : 'error'"
size="small"
/>
</template>
<template #item.enabled="{ item }">
<div @click.stop>
<v-switch
:model-value="!item.is_disabled && !item.ignored_local"
density="compact"
hide-details
color="success"
@update:model-value="(val: boolean | null) => toggleEntity(item, !!val)"
/>
</div>
</template>
<template #item.favorite="{ item }">
<v-icon
v-if="item.favorite"
icon="mdi-star"
color="warning"
size="small"
/>
</template>
<template #item.area_id="{ item }">
<span class="text-caption">{{ item.area_id || '-' }}</span>
</template>
<template #item.last_changed="{ item }">
<span class="text-caption">{{ formatDate(item.last_changed) }}</span>
</template>
<template #top>
<v-toolbar flat density="compact" v-if="selected.length > 0">
<v-toolbar-title class="text-body-2">
{{ selected.length }} sélectionnée(s)
</v-toolbar-title>
<v-spacer />
<v-btn size="small" variant="outlined" color="warning" class="mr-2" @click="bulkAction('favorite')">
<v-icon start>mdi-star</v-icon> Favori
</v-btn>
<v-btn size="small" variant="outlined" color="grey" class="mr-2" @click="bulkAction('ignore')">
<v-icon start>mdi-eye-off</v-icon> Ignorer
</v-btn>
<v-btn size="small" variant="outlined" color="error" class="mr-2" @click="bulkAction('disable')">
<v-icon start>mdi-power-off</v-icon> Désactiver
</v-btn>
<v-btn size="small" variant="outlined" color="success" @click="bulkAction('enable')">
<v-icon start>mdi-power</v-icon> Activer
</v-btn>
</v-toolbar>
</template>
</v-data-table-server>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import type { Entity } from '@/api'
import { api } from '@/api'
defineProps<{
entities: Entity[]
total: number
loading: boolean
page: number
perPage: number
}>()
const emit = defineEmits<{
select: [entity: Entity]
'update:options': [options: { page: number; itemsPerPage: number; sortBy: { key: string; order: string }[] }]
refresh: []
}>()
const selected = ref<string[]>([])
const headers = [
{ title: 'Entity ID', key: 'entity_id', sortable: true },
{ title: 'Nom', key: 'friendly_name', sortable: true },
{ title: 'Domaine', key: 'domain', sortable: true },
{ title: 'Pièce', key: 'area_id', sortable: true },
{ title: 'État', key: 'state', sortable: true },
{ title: 'Dispo', key: 'is_available', sortable: true, width: '70px' },
{ title: 'Actif', key: 'enabled', sortable: false, width: '80px' },
{ title: '', key: 'favorite', sortable: false, width: '50px' },
{ title: 'Modifié', key: 'last_changed', sortable: true },
]
function stateColor(state: string): string {
switch (state) {
case 'on': return 'success'
case 'off': return 'default'
case 'unavailable': return 'error'
case 'unknown': return 'warning'
default: return 'info'
}
}
function formatDate(iso: string | null): string {
if (!iso) return '-'
return new Date(iso).toLocaleString('fr-FR', {
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit'
})
}
function onOptions(opts: any) {
emit('update:options', opts)
}
async function toggleEntity(entity: Entity, enabled: boolean) {
try {
const action = enabled ? 'enable' : 'disable'
await api.action(action, [entity.entity_id])
emit('refresh')
} catch (e) {
console.error('Erreur toggle:', e)
}
}
async function bulkAction(action: string) {
if (!selected.value.length) return
try {
await api.action(action, selected.value)
selected.value = []
emit('refresh')
} catch (e) {
console.error('Erreur action bulk:', e)
}
}
</script>

View File

@@ -0,0 +1,197 @@
<template>
<v-card flat class="mb-4">
<v-card-text>
<v-row dense align="center">
<v-col cols="12" md="3">
<v-text-field
v-model="filters.search"
label="Rechercher (entity_id, nom)"
prepend-inner-icon="mdi-magnify"
clearable
density="compact"
hide-details
@update:model-value="onFilter"
/>
</v-col>
<v-col cols="6" md="2">
<v-select
v-model="filters.domain"
:items="domainItems"
label="Domaine"
multiple
clearable
density="compact"
hide-details
@update:model-value="onFilter"
>
<template #selection="{ item, index }">
<v-chip v-if="index < 2" size="small" closable @click:close="removeDomain(index)">
{{ item.title }}
</v-chip>
<span v-if="index === 2" class="text-caption text-medium-emphasis">
+{{ filters.domain.length - 2 }}
</span>
</template>
</v-select>
</v-col>
<v-col cols="6" md="2">
<v-select
v-model="filters.area_id"
:items="areaItems"
label="Pièce"
clearable
density="compact"
hide-details
@update:model-value="onFilter"
/>
</v-col>
<v-col cols="6" md="1">
<v-select
v-model="filters.state"
:items="stateItems"
label="État"
clearable
density="compact"
hide-details
@update:model-value="onFilter"
/>
</v-col>
<v-col cols="6" md="2">
<v-select
v-model="filters.available"
:items="availableItems"
label="Disponibilité"
clearable
density="compact"
hide-details
@update:model-value="onFilter"
/>
</v-col>
<v-col cols="6" md="2">
<v-select
v-model="filters.favorite"
:items="flagItems"
label="Favoris"
clearable
density="compact"
hide-details
@update:model-value="onFilter"
/>
</v-col>
</v-row>
<!-- Chips actives -->
<div v-if="activeFilters.length" class="mt-2 d-flex flex-wrap ga-1">
<v-chip
v-for="chip in activeFilters"
:key="chip.key"
size="small"
closable
color="primary"
variant="outlined"
@click:close="clearFilter(chip.key)"
>
{{ chip.label }}
</v-chip>
<v-btn
v-if="activeFilters.length > 1"
variant="text"
size="small"
color="error"
@click="clearAll"
>
Tout effacer
</v-btn>
</div>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { Filters } from '@/composables/useEntities'
import { api } from '@/api'
const filters = defineModel<Filters>('filters', { required: true })
const emit = defineEmits<{
filter: []
}>()
const domainItems = ref<string[]>([])
const areaItems = ref<string[]>([])
onMounted(async () => {
try {
const fv = await api.filterValues()
domainItems.value = fv.domains
areaItems.value = fv.areas
} catch {
// Fallback statique si l'endpoint n'est pas encore dispo
domainItems.value = [
'automation', 'binary_sensor', 'button', 'camera', 'climate',
'cover', 'device_tracker', 'fan', 'input_boolean', 'light',
'lock', 'media_player', 'number', 'person', 'scene', 'script',
'select', 'sensor', 'sun', 'switch', 'timer', 'update', 'weather', 'zone',
]
}
})
const stateItems = [
{ title: 'on', value: 'on' },
{ title: 'off', value: 'off' },
{ title: 'unavailable', value: 'unavailable' },
{ title: 'unknown', value: 'unknown' },
]
const availableItems = [
{ title: 'Disponible', value: 'true' },
{ title: 'Indisponible', value: 'false' },
]
const flagItems = [
{ title: 'Oui', value: 'true' },
{ title: 'Non', value: 'false' },
]
const activeFilters = computed(() => {
const chips: { key: string; label: string }[] = []
if (filters.value.search) chips.push({ key: 'search', label: `Recherche : ${filters.value.search}` })
if (filters.value.domain.length) chips.push({ key: 'domain', label: `Domaine : ${filters.value.domain.join(', ')}` })
if (filters.value.area_id) chips.push({ key: 'area_id', label: `Pièce : ${filters.value.area_id}` })
if (filters.value.state) chips.push({ key: 'state', label: `État : ${filters.value.state}` })
if (filters.value.available) chips.push({ key: 'available', label: filters.value.available === 'true' ? 'Disponible' : 'Indisponible' })
if (filters.value.favorite) chips.push({ key: 'favorite', label: filters.value.favorite === 'true' ? 'Favoris' : 'Non favoris' })
if (filters.value.ignored) chips.push({ key: 'ignored', label: filters.value.ignored === 'true' ? 'Ignorés' : 'Non ignorés' })
return chips
})
function onFilter() {
emit('filter')
}
function removeDomain(index: number) {
filters.value.domain.splice(index, 1)
onFilter()
}
function clearFilter(key: string) {
if (key === 'domain') {
filters.value.domain = []
} else {
(filters.value as any)[key] = ''
}
onFilter()
}
function clearAll() {
filters.value.search = ''
filters.value.domain = []
filters.value.area_id = ''
filters.value.state = ''
filters.value.available = ''
filters.value.favorite = ''
filters.value.ignored = ''
onFilter()
}
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div class="d-flex align-center ga-3">
<v-btn
color="primary"
:loading="scanning"
:disabled="scanning"
prepend-icon="mdi-radar"
@click="triggerScan"
>
Scanner
</v-btn>
<div v-if="health" class="text-body-2 text-medium-emphasis">
<template v-if="health.scan_status === 'scanning'">
<v-progress-circular size="16" width="2" indeterminate class="mr-1" />
Scan en cours... {{ health.progress }}/{{ health.total }}
</template>
<template v-else-if="health.last_scan">
Dernier scan : {{ formatDate(health.last_scan) }}
&middot; {{ health.entity_count }} entités
</template>
<template v-else>
Aucun scan effectué
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { api, type HealthResponse } from '@/api'
const props = defineProps<{
health: HealthResponse | null
}>()
const emit = defineEmits<{
scanned: []
}>()
const scanning = computed(() => props.health?.scan_status === 'scanning')
async function triggerScan() {
try {
await api.scan()
emit('scanned')
} catch (e) {
console.error('Erreur scan:', e)
}
}
function formatDate(iso: string): string {
const d = new Date(iso)
const now = new Date()
const diff = Math.floor((now.getTime() - d.getTime()) / 1000)
if (diff < 60) return 'il y a moins d\'une minute'
if (diff < 3600) return `il y a ${Math.floor(diff / 60)} min`
if (diff < 86400) return `il y a ${Math.floor(diff / 3600)} h`
return d.toLocaleString('fr-FR')
}
</script>

View File

@@ -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<Entity[]>([])
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<Filters>({
search: '',
domain: [],
state: '',
available: '',
device_class: '',
integration: '',
area_id: '',
favorite: '',
ignored: '',
})
async function fetchEntities() {
loading.value = true
try {
const params: Record<string, string | number | boolean> = {
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,
}
}

View File

@@ -0,0 +1,30 @@
import { ref, onMounted, onUnmounted } from 'vue'
import { api, type HealthResponse } from '@/api'
export function useHealth() {
const health = ref<HealthResponse | null>(null)
const loading = ref(false)
let timer: ReturnType<typeof setInterval> | 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 }
}

73
frontend/src/main.ts Normal file
View File

@@ -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')

7
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

19
frontend/tsconfig.json Normal file
View File

@@ -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"]
}

View File

@@ -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"}

18
frontend/vite.config.ts Normal file
View File

@@ -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'
}
}
})