feat: Stats. Closes #6

This commit is contained in:
Artem Kokos
2026-03-28 23:06:40 +07:00
parent c793b73fa2
commit 732313a61c
5 changed files with 303 additions and 12 deletions

View File

@@ -1,10 +1,13 @@
import asyncio import asyncio
import json
import logging import logging
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from app.core.state import state_manager from app.core.state import state_manager
from app.core.database import async_session
from app.drivers.wiz import WizDriver 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__) logger = logging.getLogger(__name__)
@@ -12,9 +15,44 @@ router = APIRouter(dependencies=[Depends(verify_token)])
wiz = WizDriver() 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}") @router.post("/device/{device_id}")
async def control_device( async def control_device(
device_id: str, device_id: str,
auth: AuthContext = Depends(verify_token),
state: Optional[bool] = None, state: Optional[bool] = None,
brightness: Optional[int] = None, brightness: Optional[int] = None,
scene: Optional[str] = None, scene: Optional[str] = None,
@@ -44,12 +82,17 @@ async def control_device(
raise HTTPException(status_code=400, detail="Никаких команд не передано") raise HTTPException(status_code=400, detail="Никаких команд не передано")
result = await wiz.set_pilot(device.ip, params) 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} return {"device_id": device_id, "applied": params, "result": result}
@router.post("/group/{group_id}") @router.post("/group/{group_id}")
async def control_group( async def control_group(
group_id: str, group_id: str,
auth: AuthContext = Depends(verify_token),
state: Optional[bool] = None, state: Optional[bool] = None,
brightness: Optional[int] = None, brightness: Optional[int] = None,
scene: Optional[str] = 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 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] tasks = [wiz.set_pilot(ip, params) for ip in ips]
await asyncio.gather(*tasks, return_exceptions=True) 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} 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="Лампа оффлайн") raise HTTPException(status_code=404, detail="Лампа оффлайн")
try: try:
# 1. Получаем текущее состояние
current = await wiz.get_pilot(device.ip) current = await wiz.get_pilot(device.ip)
# Если не удалось получить статус, считаем что она выключена (False)
original_state = current.get("result", {}).get("state", False) original_state = current.get("result", {}).get("state", False)
# 2. Инвертируем состояние
await wiz.set_pilot(device.ip, {"state": not original_state}) await wiz.set_pilot(device.ip, {"state": not original_state})
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
# 3. Возвращаем как было
await wiz.set_pilot(device.ip, {"state": original_state}) await wiz.set_pilot(device.ip, {"state": original_state})
return {"status": "blink_done", "original": original_state} return {"status": "blink_done", "original": original_state}
except Exception as e: except Exception as e:
logger.error(f"Blink error: {e}") logger.error(f"Blink error: {e}")
@@ -127,11 +166,9 @@ async def get_group_status(group_id: str):
if not ips: if not ips:
raise HTTPException(status_code=404, detail="Группа не найдена или оффлайн") raise HTTPException(status_code=404, detail="Группа не найдена или оффлайн")
# Опрашиваем все лампы группы параллельно
tasks = [wiz.get_pilot(ip) for ip in ips] tasks = [wiz.get_pilot(ip) for ip in ips]
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
# Формируем красивый ответ
status_report = [] status_report = []
for ip, res in zip(ips, results): for ip, res in zip(ips, results):
if isinstance(res, Exception): if isinstance(res, Exception):

119
app/api/routes/stats.py Normal file
View File

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

26
app/models/event_log.py Normal file
View File

@@ -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 строка параметров

View File

@@ -10,7 +10,7 @@ from app.core.scheduler import start_scheduler
from app.core.state import state_manager, discovery_service from app.core.state import state_manager, discovery_service
from sqlalchemy import select from sqlalchemy import select
from app.models.device import GroupModel 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 from app.api.deps import verify_token
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper() 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(control.router, prefix="/control", tags=["Control"])
app.include_router(schedules.router, prefix="/schedules", tags=["Schedules"]) app.include_router(schedules.router, prefix="/schedules", tags=["Schedules"])
app.include_router(api_keys.router, prefix="/api-keys", tags=["API Keys"]) 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 # Мы убираем html=True из корня, чтобы 404-е ошибки API не превращались в загрузку index.html

View File

@@ -69,6 +69,7 @@
<button @click="tab = 'control'" :class="tab === 'control' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">ПУЛЬТ</button> <button @click="tab = 'control'" :class="tab === 'control' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">ПУЛЬТ</button>
<button v-if="isAdmin" @click="tab = 'schedules'" :class="tab === 'schedules' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">ГРАФИК</button> <button v-if="isAdmin" @click="tab = 'schedules'" :class="tab === 'schedules' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">ГРАФИК</button>
<button v-if="isAdmin" @click="tab = 'admin'" :class="tab === 'admin' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">АДМИНКА</button> <button v-if="isAdmin" @click="tab = 'admin'" :class="tab === 'admin' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">АДМИНКА</button>
<button v-if="isAdmin" @click="tab = 'stats'; fetchStats()" :class="tab === 'stats' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">СТАТА</button>
</nav> </nav>
</header> </header>
@@ -254,6 +255,95 @@
</div> </div>
</section> </section>
</div> </div>
<!-- СТАТИСТИКА (только админ) -->
<div v-if="tab === 'stats' && !isLoading && isAdmin" class="space-y-8 fade-up">
<!-- Период -->
<div class="flex items-center gap-3">
<span class="text-sm text-slate-500">Период:</span>
<button v-for="d in [1, 7, 30]" :key="d" @click="statsDays = d; fetchStats()"
:class="statsDays === d ? 'bg-orange-600 text-white' : 'bg-slate-800/50 text-slate-400 hover:text-white'"
class="px-4 py-1.5 rounded-lg text-sm font-bold transition-all">
{{ d === 1 ? 'Сегодня' : d + 'д' }}
</button>
</div>
<!-- Карточки по группам -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div v-for="s in statsData" :key="s.target_id" class="glass p-6 rounded-2xl">
<div class="flex justify-between items-start mb-4">
<div>
<h3 class="text-lg font-black">{{ getGroupName(s.target_id) || s.target_id }}</h3>
<span class="text-[10px] mono text-slate-600">{{ s.target_id }}</span>
</div>
<span class="mono text-2xl font-black text-orange-400">{{ s.estimated_hours }}ч</span>
</div>
<div class="grid grid-cols-3 gap-3 mb-4">
<div class="bg-black/20 p-3 rounded-xl text-center">
<div class="text-lg font-bold text-green-400">{{ s.toggles_on }}</div>
<div class="text-[10px] text-slate-600 uppercase">вкл</div>
</div>
<div class="bg-black/20 p-3 rounded-xl text-center">
<div class="text-lg font-bold text-red-400">{{ s.toggles_off }}</div>
<div class="text-[10px] text-slate-600 uppercase">выкл</div>
</div>
<div class="bg-black/20 p-3 rounded-xl text-center">
<div class="text-lg font-bold text-slate-300">{{ s.total_commands }}</div>
<div class="text-[10px] text-slate-600 uppercase">всего</div>
</div>
</div>
<div class="flex flex-wrap gap-2 mb-3">
<span v-if="s.scenes" class="text-[10px] bg-purple-500/10 text-purple-400 px-2 py-1 rounded font-bold">🎨 {{ s.scenes }} сцен</span>
<span v-if="s.colors" class="text-[10px] bg-pink-500/10 text-pink-400 px-2 py-1 rounded font-bold">🌈 {{ s.colors }} цветов</span>
<span v-if="s.brightness" class="text-[10px] bg-yellow-500/10 text-yellow-400 px-2 py-1 rounded font-bold">🔆 {{ s.brightness }} яркость</span>
<span v-if="s.temperature" class="text-[10px] bg-blue-500/10 text-blue-400 px-2 py-1 rounded font-bold">🌡 {{ s.temperature }} темп.</span>
</div>
<!-- Кто управлял -->
<div v-if="Object.keys(s.by_user).length > 0" class="border-t border-slate-800/50 pt-3">
<div class="text-[10px] text-slate-600 uppercase mb-2">Кто управлял</div>
<div class="flex flex-wrap gap-2">
<span v-for="(count, user) in s.by_user" :key="user" class="text-[10px] mono bg-slate-800/50 text-slate-400 px-2 py-1 rounded">
{{ user }}: {{ count }}
</span>
</div>
</div>
</div>
</div>
<div v-if="statsData.length === 0" class="text-center py-16 glass rounded-2xl text-slate-600 text-sm">
Нет данных за выбранный период
</div>
<!-- Лог последних событий -->
<div class="glass p-6 rounded-2xl">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-black uppercase">Лог событий</h2>
<button @click="fetchEventLog" class="text-xs text-slate-500 hover:text-orange-400 font-bold transition-colors">ОБНОВИТЬ</button>
</div>
<div class="space-y-1 max-h-96 overflow-y-auto">
<div v-for="ev in eventLog" :key="ev.id" class="flex items-center gap-3 py-2 border-b border-slate-800/30 text-sm">
<span class="mono text-[10px] text-slate-600 w-36 shrink-0">{{ formatTime(ev.timestamp) }}</span>
<span class="mono text-[10px] text-slate-500 w-16 shrink-0">{{ ev.key_name }}</span>
<span :class="{
'text-green-400': ev.action === 'toggle_on',
'text-red-400': ev.action === 'toggle_off',
'text-purple-400': ev.action === 'scene',
'text-pink-400': ev.action === 'color',
'text-yellow-400': ev.action === 'brightness',
'text-blue-400': ev.action === 'temperature',
'text-slate-400': !['toggle_on','toggle_off','scene','color','brightness','temperature'].includes(ev.action),
}" class="text-xs font-bold w-24 shrink-0">{{ ev.action }}</span>
<span class="text-xs text-slate-500">{{ getGroupName(ev.target_id) || ev.target_id }}</span>
</div>
<div v-if="eventLog.length === 0" class="text-center py-8 text-slate-600 text-sm">Событий пока нет</div>
</div>
</div>
</div>
</template> </template>
<!-- Toast --> <!-- Toast -->
@@ -280,6 +370,7 @@
tasks: [], allScenes: {}, tasks: [], allScenes: {},
toasts: [], toastCounter: 0, toasts: [], toastCounter: 0,
apiKeys: [], newKeyName: '', newKeyAdmin: false, lastCreatedKey: '', apiKeys: [], newKeyName: '', newKeyAdmin: false, lastCreatedKey: '',
statsData: [], eventLog: [], statsDays: 7,
} }
}, },
methods: { methods: {
@@ -419,6 +510,23 @@
copyKey(key) { copyKey(key) {
navigator.clipboard.writeText(key).then(() => this.toast('Скопировано', 'success')); 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(); } async mounted() { if (this.apiKey) await this.initApp(); }
}).mount('#app') }).mount('#app')