first
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal 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
44
.gitignore
vendored
Normal 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
82
CLAUDE.md
Normal 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
102
README.md
Normal 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
14
backend/Dockerfile
Normal 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
0
backend/app/__init__.py
Normal file
13
backend/app/config.py
Normal file
13
backend/app/config.py
Normal 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
35
backend/app/database.py
Normal 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
135
backend/app/ha_client.py
Normal 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
31
backend/app/main.py
Normal 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
47
backend/app/models.py
Normal 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 = ""
|
||||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
33
backend/app/routers/actions.py
Normal file
33
backend/app/routers/actions.py
Normal 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}
|
||||||
38
backend/app/routers/audit.py
Normal file
38
backend/app/routers/audit.py
Normal 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,
|
||||||
|
}
|
||||||
150
backend/app/routers/entities.py
Normal file
150
backend/app/routers/entities.py
Normal 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
|
||||||
23
backend/app/routers/health.py
Normal file
23
backend/app/routers/health.py
Normal 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(),
|
||||||
|
}
|
||||||
20
backend/app/routers/scan.py
Normal file
20
backend/app/routers/scan.py
Normal 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
39
backend/app/scan_state.py
Normal 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()
|
||||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
134
backend/app/services/entity_actions.py
Normal file
134
backend/app/services/entity_actions.py
Normal 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()
|
||||||
53
backend/app/services/scanner.py
Normal file
53
backend/app/services/scanner.py
Normal 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
8
backend/requirements.txt
Normal 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
|
||||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
39
backend/tests/conftest.py
Normal file
39
backend/tests/conftest.py
Normal 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()
|
||||||
80
backend/tests/test_actions.py
Normal file
80
backend/tests/test_actions.py
Normal 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"
|
||||||
119
backend/tests/test_entities.py
Normal file
119
backend/tests/test_entities.py
Normal 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
|
||||||
79
backend/tests/test_ha_client.py
Normal file
79
backend/tests/test_ha_client.py
Normal 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
151
consigne.md
Normal 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 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
|
||||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal 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:
|
||||||
80
docs/plans/2026-02-21-ha-entity-scanner-design.md
Normal file
80
docs/plans/2026-02-21-ha-entity-scanner-design.md
Normal 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
16
frontend/Dockerfile
Normal 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
13
frontend/index.html
Normal 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
16
frontend/nginx.conf
Normal 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
1535
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
frontend/package.json
Normal file
22
frontend/package.json
Normal 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
128
frontend/src/App.vue
Normal 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
111
frontend/src/api.ts
Normal 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}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
98
frontend/src/components/AuditLog.vue
Normal file
98
frontend/src/components/AuditLog.vue
Normal 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>
|
||||||
172
frontend/src/components/EntityDetail.vue
Normal file
172
frontend/src/components/EntityDetail.vue
Normal 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>
|
||||||
176
frontend/src/components/EntityTable.vue
Normal file
176
frontend/src/components/EntityTable.vue
Normal 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>
|
||||||
197
frontend/src/components/FilterBar.vue
Normal file
197
frontend/src/components/FilterBar.vue
Normal 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>
|
||||||
60
frontend/src/components/ScanButton.vue
Normal file
60
frontend/src/components/ScanButton.vue
Normal 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) }}
|
||||||
|
· {{ 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>
|
||||||
80
frontend/src/composables/useEntities.ts
Normal file
80
frontend/src/composables/useEntities.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
30
frontend/src/composables/useHealth.ts
Normal file
30
frontend/src/composables/useHealth.ts
Normal 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
73
frontend/src/main.ts
Normal 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
7
frontend/src/vite-env.d.ts
vendored
Normal 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
19
frontend/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal 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
18
frontend/vite.config.ts
Normal 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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user