From 732313a61c20f2ab43036807feb71c2781f263b9 Mon Sep 17 00:00:00 2001 From: Artem Kokos Date: Sat, 28 Mar 2026 23:06:40 +0700 Subject: [PATCH] feat: Stats. Closes #6 --- app/api/routes/control.py | 59 +++++++++++++++---- app/api/routes/stats.py | 119 ++++++++++++++++++++++++++++++++++++++ app/models/event_log.py | 26 +++++++++ main.py | 3 +- static/index.html | 108 ++++++++++++++++++++++++++++++++++ 5 files changed, 303 insertions(+), 12 deletions(-) create mode 100644 app/api/routes/stats.py create mode 100644 app/models/event_log.py diff --git a/app/api/routes/control.py b/app/api/routes/control.py index dfc2adc..c9daee8 100644 --- a/app/api/routes/control.py +++ b/app/api/routes/control.py @@ -1,10 +1,13 @@ import asyncio +import json import logging from typing import Optional from fastapi import APIRouter, Depends, HTTPException from app.core.state import state_manager +from app.core.database import async_session from app.drivers.wiz import WizDriver -from app.api.deps import verify_token +from app.api.deps import verify_token, AuthContext +from app.models.event_log import EventLog logger = logging.getLogger(__name__) @@ -12,9 +15,44 @@ router = APIRouter(dependencies=[Depends(verify_token)]) wiz = WizDriver() +async def _log_event( + auth: AuthContext, action: str, target_type: str, target_id: str, params: dict +): + """Записать событие в лог.""" + try: + async with async_session() as session: + event = EventLog( + key_name=auth.key_name, + action=action, + target_type=target_type, + target_id=target_id, + params=json.dumps(params, ensure_ascii=False) if params else None, + ) + session.add(event) + await session.commit() + except Exception as e: + logger.error(f"Ошибка записи в лог: {e}") + + +def _classify_action(params: dict) -> str: + """Определить тип действия по параметрам.""" + if "state" in params and len(params) == 1: + return "toggle_on" if params["state"] else "toggle_off" + if "sceneId" in params or "scene" in params: + return "scene" + if "r" in params or "g" in params or "b" in params: + return "color" + if "temp" in params: + return "temperature" + if "dimming" in params: + return "brightness" + return "control" + + @router.post("/device/{device_id}") async def control_device( device_id: str, + auth: AuthContext = Depends(verify_token), state: Optional[bool] = None, brightness: Optional[int] = None, scene: Optional[str] = None, @@ -44,12 +82,17 @@ async def control_device( raise HTTPException(status_code=400, detail="Никаких команд не передано") result = await wiz.set_pilot(device.ip, params) + + # Логируем + await _log_event(auth, _classify_action(params), "device", device_id, params) + return {"device_id": device_id, "applied": params, "result": result} @router.post("/group/{group_id}") async def control_group( group_id: str, + auth: AuthContext = Depends(verify_token), state: Optional[bool] = None, brightness: Optional[int] = None, scene: Optional[str] = None, @@ -76,8 +119,11 @@ async def control_group( params["r"], params["g"], params["b"] = r or 0, g or 0, b or 0 tasks = [wiz.set_pilot(ip, params) for ip in ips] - await asyncio.gather(*tasks, return_exceptions=True) + + # Логируем + await _log_event(auth, _classify_action(params), "group", group_id, params) + return {"status": "ok", "applied": params, "sent_to": ips} @@ -88,18 +134,11 @@ async def blink_device(device_id: str): raise HTTPException(status_code=404, detail="Лампа оффлайн") try: - # 1. Получаем текущее состояние current = await wiz.get_pilot(device.ip) - # Если не удалось получить статус, считаем что она выключена (False) original_state = current.get("result", {}).get("state", False) - - # 2. Инвертируем состояние await wiz.set_pilot(device.ip, {"state": not original_state}) await asyncio.sleep(0.5) - - # 3. Возвращаем как было await wiz.set_pilot(device.ip, {"state": original_state}) - return {"status": "blink_done", "original": original_state} except Exception as e: logger.error(f"Blink error: {e}") @@ -127,11 +166,9 @@ async def get_group_status(group_id: str): if not ips: raise HTTPException(status_code=404, detail="Группа не найдена или оффлайн") - # Опрашиваем все лампы группы параллельно tasks = [wiz.get_pilot(ip) for ip in ips] results = await asyncio.gather(*tasks, return_exceptions=True) - # Формируем красивый ответ status_report = [] for ip, res in zip(ips, results): if isinstance(res, Exception): diff --git a/app/api/routes/stats.py b/app/api/routes/stats.py new file mode 100644 index 0000000..97625e1 --- /dev/null +++ b/app/api/routes/stats.py @@ -0,0 +1,119 @@ +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select, func, and_, case +from app.core.database import async_session +from app.models.event_log import EventLog +from app.api.deps import require_admin + +router = APIRouter(dependencies=[Depends(require_admin)]) + + +@router.get("/summary") +async def get_summary(days: int = Query(default=7, ge=1, le=365)): + """ + Сводная статистика за последние N дней. + Возвращает по каждой группе: + - total_commands -- общее количество команд + - toggles_on / toggles_off -- включений / выключений + - scenes / colors / brightness / temperature -- количество смен режимов + - estimated_hours -- оценка часов работы (по парам on/off) + """ + since = (datetime.now() - timedelta(days=days)).isoformat() + + async with async_session() as session: + # Все события за период + result = await session.execute( + select(EventLog) + .where(EventLog.timestamp >= since) + .order_by(EventLog.timestamp) + ) + events = result.scalars().all() + + # Агрегация по target_id + stats = {} + # Для подсчёта часов работы: запоминаем время последнего включения + last_on = {} + + for ev in events: + tid = ev.target_id + if tid not in stats: + stats[tid] = { + "target_id": tid, + "target_type": ev.target_type, + "total_commands": 0, + "toggles_on": 0, + "toggles_off": 0, + "scenes": 0, + "colors": 0, + "brightness": 0, + "temperature": 0, + "estimated_hours": 0.0, + "by_user": {}, + } + + s = stats[tid] + s["total_commands"] += 1 + + # Счётчик по пользователям + u = ev.key_name + s["by_user"][u] = s["by_user"].get(u, 0) + 1 + + # Классификация + if ev.action == "toggle_on": + s["toggles_on"] += 1 + last_on[tid] = ev.timestamp + elif ev.action == "toggle_off": + s["toggles_off"] += 1 + # Считаем время работы + if tid in last_on: + try: + t_on = datetime.fromisoformat(last_on[tid]) + t_off = datetime.fromisoformat(ev.timestamp) + delta = (t_off - t_on).total_seconds() / 3600.0 + # Защита от мусора: макс 24 часа за один сеанс + if 0 < delta < 24: + s["estimated_hours"] += delta + except (ValueError, TypeError): + pass + del last_on[tid] + elif ev.action == "scene": + s["scenes"] += 1 + elif ev.action == "color": + s["colors"] += 1 + elif ev.action == "brightness": + s["brightness"] += 1 + elif ev.action == "temperature": + s["temperature"] += 1 + + # Округляем часы + for s in stats.values(): + s["estimated_hours"] = round(s["estimated_hours"], 1) + + return { + "period_days": days, + "since": since, + "groups": list(stats.values()), + } + + +@router.get("/log") +async def get_log(limit: int = Query(default=50, ge=1, le=500)): + """Последние N событий (для просмотра лога).""" + async with async_session() as session: + result = await session.execute( + select(EventLog).order_by(EventLog.timestamp.desc()).limit(limit) + ) + events = result.scalars().all() + + return [ + { + "id": ev.id, + "timestamp": ev.timestamp, + "key_name": ev.key_name, + "action": ev.action, + "target_type": ev.target_type, + "target_id": ev.target_id, + "params": ev.params, + } + for ev in events + ] diff --git a/app/models/event_log.py b/app/models/event_log.py new file mode 100644 index 0000000..26c6507 --- /dev/null +++ b/app/models/event_log.py @@ -0,0 +1,26 @@ +from datetime import datetime +from sqlalchemy import String, Integer, DateTime, JSON +from sqlalchemy.orm import Mapped, mapped_column +from app.core.database import Base + + +class EventLog(Base): + """Лог событий управления лампами.""" + + __tablename__ = "event_log" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + timestamp: Mapped[str] = mapped_column( + String, default=lambda: datetime.now().isoformat() + ) + key_name: Mapped[str] = mapped_column( + String, default="unknown" + ) # кто: "master", "vasya", ... + action: Mapped[str] = mapped_column( + String + ) # "control", "toggle_on", "toggle_off", "scene", ... + target_type: Mapped[str] = mapped_column( + String, default="group" + ) # "group" или "device" + target_id: Mapped[str] = mapped_column(String) # id группы или устройства + params: Mapped[str] = mapped_column(String, nullable=True) # JSON строка параметров diff --git a/main.py b/main.py index b64b539..018818e 100644 --- a/main.py +++ b/main.py @@ -10,7 +10,7 @@ from app.core.scheduler import start_scheduler from app.core.state import state_manager, discovery_service from sqlalchemy import select from app.models.device import GroupModel -from app.api.routes import devices, control, schedules, api_keys +from app.api.routes import devices, control, schedules, api_keys, stats from app.api.deps import verify_token LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() @@ -50,6 +50,7 @@ app.include_router(devices.router, prefix="/devices", tags=["Devices & Groups"]) app.include_router(control.router, prefix="/control", tags=["Control"]) app.include_router(schedules.router, prefix="/schedules", tags=["Schedules"]) app.include_router(api_keys.router, prefix="/api-keys", tags=["API Keys"]) +app.include_router(stats.router, prefix="/stats", tags=["Stats"]) # Статика # Мы убираем html=True из корня, чтобы 404-е ошибки API не превращались в загрузку index.html diff --git a/static/index.html b/static/index.html index f5acaaf..53b9983 100644 --- a/static/index.html +++ b/static/index.html @@ -69,6 +69,7 @@ + @@ -254,6 +255,95 @@ + + +
+ + +
+ Период: + +
+ + +
+
+
+
+

{{ getGroupName(s.target_id) || s.target_id }}

+ {{ s.target_id }} +
+ {{ s.estimated_hours }}ч +
+ +
+
+
{{ s.toggles_on }}
+
вкл
+
+
+
{{ s.toggles_off }}
+
выкл
+
+
+
{{ s.total_commands }}
+
всего
+
+
+ +
+ 🎨 {{ s.scenes }} сцен + 🌈 {{ s.colors }} цветов + 🔆 {{ s.brightness }} яркость + 🌡 {{ s.temperature }} темп. +
+ + +
+
Кто управлял
+
+ + {{ user }}: {{ count }} + +
+
+
+
+ +
+ Нет данных за выбранный период +
+ + +
+
+

Лог событий

+ +
+
+
+ {{ formatTime(ev.timestamp) }} + {{ ev.key_name }} + {{ ev.action }} + {{ getGroupName(ev.target_id) || ev.target_id }} +
+
Событий пока нет
+
+
+
+ @@ -280,6 +370,7 @@ tasks: [], allScenes: {}, toasts: [], toastCounter: 0, apiKeys: [], newKeyName: '', newKeyAdmin: false, lastCreatedKey: '', + statsData: [], eventLog: [], statsDays: 7, } }, methods: { @@ -419,6 +510,23 @@ copyKey(key) { navigator.clipboard.writeText(key).then(() => this.toast('Скопировано', 'success')); }, + + // ─── Статистика ────────────────────────────── + async fetchStats() { + const data = await this.request(`/stats/summary`, 'GET', { days: this.statsDays }); + if (data) this.statsData = data.groups || []; + await this.fetchEventLog(); + }, + async fetchEventLog() { + const data = await this.request('/stats/log', 'GET', { limit: 100 }); + if (data) this.eventLog = data; + }, + formatTime(iso) { + if (!iso) return ''; + const d = new Date(iso); + const pad = n => String(n).padStart(2, '0'); + return `${pad(d.getDate())}.${pad(d.getMonth()+1)} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; + }, }, async mounted() { if (this.apiKey) await this.initApp(); } }).mount('#app')