feat: Guests API keys. Closes #3

This commit is contained in:
Artem Kokos
2026-03-28 21:20:55 +07:00
parent d024ba78ab
commit 3d8939a6aa
7 changed files with 297 additions and 400 deletions

View File

@@ -1,27 +1,74 @@
import os
import logging
from fastapi import Depends, HTTPException, Security
from dataclasses import dataclass
from typing import Optional
from fastapi import Depends, HTTPException
from fastapi.security import APIKeyHeader
from starlette.status import HTTP_403_FORBIDDEN
from dotenv import load_dotenv
from sqlalchemy import select
from app.core.database import async_session
from app.models.api_key import ApiKeyModel
load_dotenv()
logger = logging.getLogger(__name__)
API_KEY = os.getenv("IGNIS_API_KEY")
if not API_KEY:
MASTER_KEY = os.getenv("IGNIS_API_KEY")
if not MASTER_KEY:
logger.warning("IGNIS_API_KEY не задан -- авторизация отключена!")
API_KEY_NAME = "X-API-Key"
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
async def verify_token(header_value: str = Depends(api_key_header)):
if not API_KEY:
return None
if header_value == API_KEY:
return header_value
raise HTTPException(
status_code=HTTP_403_FORBIDDEN, detail="Could not validate credentials"
)
@dataclass
class AuthContext:
"""Результат авторизации -- передаётся в роуты через Depends."""
is_master: bool # мастер-ключ из .env
is_admin: bool # право на CRUD групп, расписания, ресканирование
key_name: str # имя ключа (для логов)
async def verify_token(header_value: str = Depends(api_key_header)) -> AuthContext:
"""
Проверка API-ключа:
1. Если IGNIS_API_KEY не задан -- авторизация отключена, полный доступ
2. Мастер-ключ из .env -- полный доступ
3. Ключ из БД (api_keys) -- проверяем active и is_admin
4. Иначе -- 403
"""
# Авторизация отключена
if not MASTER_KEY:
return AuthContext(is_master=True, is_admin=True, key_name="no-auth")
if not header_value:
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="API-ключ не передан")
# Мастер-ключ
if header_value == MASTER_KEY:
return AuthContext(is_master=True, is_admin=True, key_name="master")
# Ищем в БД
async with async_session() as session:
result = await session.execute(
select(ApiKeyModel).where(ApiKeyModel.key == header_value)
)
api_key = result.scalar_one_or_none()
if api_key and api_key.active:
return AuthContext(
is_master=False,
is_admin=api_key.is_admin,
key_name=api_key.name,
)
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Неверный или деактивированный ключ")
def require_admin(auth: AuthContext = Depends(verify_token)) -> AuthContext:
"""Dependency для роутов, требующих админских прав."""
if not auth.is_admin:
raise HTTPException(status_code=HTTP_403_FORBIDDEN, detail="Недостаточно прав")
return auth

View File

@@ -0,0 +1,85 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from app.core.database import async_session
from app.models.api_key import ApiKeyModel
from app.api.deps import require_admin, AuthContext
# Все операции с ключами -- только для админов (мастер-ключ)
router = APIRouter(dependencies=[Depends(require_admin)])
@router.get("")
async def list_keys():
"""Список всех гостевых ключей."""
async with async_session() as session:
result = await session.execute(select(ApiKeyModel))
keys = result.scalars().all()
return [
{
"key": k.key,
"name": k.name,
"is_admin": k.is_admin,
"active": k.active,
"created_at": k.created_at,
}
for k in keys
]
@router.post("")
async def create_key(name: str, is_admin: bool = False):
"""Создать гостевой ключ. Возвращает сгенерированный токен."""
new_key = ApiKeyModel(
key=ApiKeyModel.generate_key(),
name=name,
is_admin=is_admin,
)
async with async_session() as session:
session.add(new_key)
await session.commit()
return {
"key": new_key.key,
"name": new_key.name,
"is_admin": new_key.is_admin,
"message": "Сохраните ключ -- он больше не будет показан полностью",
}
@router.delete("/{key}")
async def revoke_key(key: str):
"""Деактивировать (отозвать) гостевой ключ."""
async with async_session() as session:
result = await session.execute(
select(ApiKeyModel).where(ApiKeyModel.key == key)
)
api_key = result.scalar_one_or_none()
if not api_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
api_key.active = False
session.add(api_key)
await session.commit()
return {"status": "revoked", "name": api_key.name}
@router.post("/{key}/activate")
async def activate_key(key: str):
"""Повторно активировать ключ."""
async with async_session() as session:
result = await session.execute(
select(ApiKeyModel).where(ApiKeyModel.key == key)
)
api_key = result.scalar_one_or_none()
if not api_key:
raise HTTPException(status_code=404, detail="Ключ не найден")
api_key.active = True
session.add(api_key)
await session.commit()
return {"status": "activated", "name": api_key.name}

View File

@@ -3,7 +3,7 @@ from sqlalchemy import select
from app.core.state import state_manager, discovery_service
from app.core.database import async_session
from app.models.device import GroupModel, GroupCreateSchema
from app.api.deps import verify_token
from app.api.deps import verify_token, require_admin
from app.drivers.wiz import WizDriver
# Создаем роутер с защитой
@@ -26,7 +26,7 @@ async def get_scenes():
return wiz.SCENES
@router.post("/groups")
@router.post("/groups", dependencies=[Depends(require_admin)])
async def create_group(data: GroupCreateSchema):
async with async_session() as session:
existing = await session.get(GroupModel, data.id)
@@ -40,7 +40,7 @@ async def create_group(data: GroupCreateSchema):
return {"status": "created", "group": data.name}
@router.delete("/groups/{group_id}")
@router.delete("/groups/{group_id}", dependencies=[Depends(require_admin)])
async def delete_group(group_id: str):
async with async_session() as session:
result = await session.execute(
@@ -56,7 +56,7 @@ async def delete_group(group_id: str):
return {"status": "deleted", "id": group_id}
@router.post("/rescan")
@router.post("/rescan", dependencies=[Depends(require_admin)])
async def rescan_network():
found_devices = await discovery_service.scan_network()
for dev_data in found_devices:

View File

@@ -8,11 +8,11 @@ from apscheduler.triggers.date import DateTrigger
from app.core.scheduler import app_tz, scheduler
from app.core.state import state_manager
from app.drivers.wiz import WizDriver
from app.api.deps import verify_token
from app.api.deps import require_admin
logger = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(verify_token)])
router = APIRouter(dependencies=[Depends(require_admin)])
async def run_group_command(target_id: str, is_group: bool, params: dict):

21
app/models/api_key.py Normal file
View File

@@ -0,0 +1,21 @@
import secrets
from datetime import datetime
from sqlalchemy import String, Boolean, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class ApiKeyModel(Base):
"""Гостевой API-ключ с ограниченными правами."""
__tablename__ = "api_keys"
key: Mapped[str] = mapped_column(String, primary_key=True)
name: Mapped[str] = mapped_column(String) # "Вася", "гости"
is_admin: Mapped[bool] = mapped_column(Boolean, default=False) # доступ к CRUD групп, расписаниям
active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[str] = mapped_column(String, default=lambda: datetime.now().isoformat())
@staticmethod
def generate_key() -> str:
"""Генерация безопасного случайного токена."""
return secrets.token_urlsafe(32)

11
main.py
View File

@@ -2,7 +2,7 @@ import logging
import asyncio
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, Depends
from fastapi.staticfiles import StaticFiles
from app.core.database import init_db, async_session
@@ -10,7 +10,8 @@ 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
from app.api.routes import devices, control, schedules, api_keys
from app.api.deps import verify_token
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
@@ -48,6 +49,7 @@ app = FastAPI(title="Ignis Core API", lifespan=lifespan)
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"])
# Статика
# Мы убираем html=True из корня, чтобы 404-е ошибки API не превращались в загрузку index.html
@@ -61,6 +63,11 @@ async def read_index():
return FileResponse("static/index.html")
@app.get("/auth/me")
async def auth_me(auth = Depends(verify_token)):
return {"is_admin": auth.is_admin, "name": auth.key_name}
if __name__ == "__main__":
import uvicorn

View File

@@ -9,75 +9,25 @@
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700;800&family=Outfit:wght@300;400;600;800;900&display=swap" rel="stylesheet">
<style>
:root {
--bg-deep: #08090c;
--bg-card: rgba(20, 22, 30, 0.8);
--border-subtle: rgba(255,255,255,0.06);
--border-hover: rgba(249, 115, 22, 0.4);
--accent: #f97316;
--accent-glow: rgba(249, 115, 22, 0.15);
}
body {
background: var(--bg-deep);
color: #e2e8f0;
font-family: 'Outfit', sans-serif;
min-height: 100vh;
}
/* Тонкий паттерн на фоне */
body::before {
content: '';
position: fixed; inset: 0; z-index: -1;
background:
radial-gradient(ellipse at 20% 0%, rgba(249,115,22,0.06) 0%, transparent 60%),
radial-gradient(ellipse at 80% 100%, rgba(30,27,75,0.15) 0%, transparent 60%);
}
.glass {
background: var(--bg-card);
backdrop-filter: blur(16px);
border: 1px solid var(--border-subtle);
}
:root { --bg-deep: #08090c; --bg-card: rgba(20,22,30,0.8); --border-subtle: rgba(255,255,255,0.06); --border-hover: rgba(249,115,22,0.4); --accent: #f97316; --accent-glow: rgba(249,115,22,0.15); }
body { background: var(--bg-deep); color: #e2e8f0; font-family: 'Outfit', sans-serif; min-height: 100vh; }
body::before { content: ''; position: fixed; inset: 0; z-index: -1; background: radial-gradient(ellipse at 20% 0%, rgba(249,115,22,0.06) 0%, transparent 60%), radial-gradient(ellipse at 80% 100%, rgba(30,27,75,0.15) 0%, transparent 60%); }
.glass { background: var(--bg-card); backdrop-filter: blur(16px); border: 1px solid var(--border-subtle); }
.glass:hover { border-color: var(--border-hover); }
.active-tab {
background: var(--accent);
box-shadow: 0 0 24px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.15);
}
/* Слайдеры */
input[type="range"] {
-webkit-appearance: none; height: 6px;
border-radius: 10px; background: #1e293b; outline: none;
transition: opacity 0.2s;
}
.active-tab { background: var(--accent); box-shadow: 0 0 24px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.15); }
input[type="range"] { -webkit-appearance: none; height: 6px; border-radius: 10px; background: #1e293b; outline: none; transition: opacity 0.2s; }
input[type="range"]:disabled { opacity: 0.25; cursor: not-allowed; }
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 20px; height: 20px;
background: #fff; border-radius: 50%; cursor: pointer;
border: 3px solid var(--accent);
box-shadow: 0 0 8px rgba(0,0,0,0.4);
transition: transform 0.15s;
}
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; background: #fff; border-radius: 50%; cursor: pointer; border: 3px solid var(--accent); box-shadow: 0 0 8px rgba(0,0,0,0.4); transition: transform 0.15s; }
input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.15); }
input[type="range"]:disabled::-webkit-slider-thumb { border-color: #475569; cursor: not-allowed; transform: none; }
.temp-gradient { background: linear-gradient(to right, #ffcc66, #ffffff, #99ccff) !important; }
/* Селекты */
select {
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'/%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 1em;
}
/* Моно для технических данных */
select { appearance: none; background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'/%3e%3c/svg%3e"); background-repeat: no-repeat; background-position: right 0.75rem center; background-size: 1em; }
.mono { font-family: 'JetBrains Mono', monospace; }
/* Анимации появления */
@keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
.fade-up { animation: fadeUp 0.4s ease-out both; }
/* Toast-уведомления */
.toast-enter { animation: fadeUp 0.3s ease-out; }
.toast-leave { animation: fadeUp 0.3s ease-in reverse; }
/* Пульсация статуса */
@keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
.pulse-on { animation: pulse-dot 2s ease-in-out infinite; }
/* Спиннер */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner { animation: spin 0.8s linear infinite; }
</style>
@@ -85,27 +35,22 @@
<body>
<div id="app" class="max-w-6xl mx-auto p-4 md:p-8">
<!-- ═══════ Экран авторизации ═══════ -->
<!-- Авторизация -->
<div v-if="!apiKey" class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-md p-4">
<div class="glass p-10 rounded-3xl w-full max-w-sm shadow-2xl fade-up text-center">
<div class="w-16 h-16 mx-auto mb-6 bg-gradient-to-br from-orange-500 to-red-600 rounded-2xl flex items-center justify-center shadow-lg shadow-orange-900/30">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<h2 class="text-2xl font-black uppercase tracking-tight mb-1">Ignis</h2>
<p class="text-slate-500 text-xs mb-8">API-ключ из .env сервера</p>
<input v-model="tempKey" type="password" placeholder="X-API-Key"
@keyup.enter="saveKey"
<p class="text-slate-500 text-xs mb-8">API-ключ</p>
<input v-model="tempKey" type="password" placeholder="X-API-Key" @keyup.enter="saveKey"
class="w-full bg-black/40 border border-slate-700/50 p-4 rounded-xl mb-4 focus:border-orange-500 outline-none text-center mono tracking-widest text-sm">
<button @click="saveKey" class="w-full bg-orange-600 hover:bg-orange-500 py-3.5 rounded-xl font-bold transition-all shadow-lg shadow-orange-900/30 active:scale-[0.98]">
ВОЙТИ
</button>
<button @click="saveKey" class="w-full bg-orange-600 hover:bg-orange-500 py-3.5 rounded-xl font-bold transition-all shadow-lg shadow-orange-900/30 active:scale-[0.98]">ВОЙТИ</button>
</div>
</div>
<!-- ═══════ Основной интерфейс ═══════ -->
<!-- Основной интерфейс -->
<template v-else>
<!-- Хедер -->
<header class="flex flex-col md:flex-row justify-between items-center mb-10 gap-5 fade-up">
<div class="flex items-center gap-3">
<div class="bg-gradient-to-br from-orange-500 to-red-600 p-2.5 rounded-xl shadow-lg shadow-orange-900/20">
@@ -113,101 +58,66 @@
</div>
<div>
<h1 class="text-2xl font-black tracking-tight uppercase">Ignis<span class="text-orange-500">Core</span></h1>
<button @click="logout" class="text-[9px] text-slate-600 hover:text-red-400 uppercase font-bold tracking-widest transition-colors">выйти</button>
<div class="flex items-center gap-2">
<span v-if="authName" class="text-[9px] mono text-slate-600">{{ authName }}</span>
<span v-if="!isAdmin" class="text-[9px] text-yellow-600 font-bold uppercase">гость</span>
<button @click="logout" class="text-[9px] text-slate-600 hover:text-red-400 uppercase font-bold tracking-widest transition-colors">выйти</button>
</div>
</div>
</div>
<nav class="flex glass p-1 rounded-xl">
<button v-for="t in [{id:'control',label:'ПУЛЬТ'},{id:'schedules',label:'ГРАФИК'},{id:'admin',label:'АДМИНКА'}]"
:key="t.id" @click="tab = t.id"
:class="tab === t.id ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'"
class="px-6 py-2 rounded-lg font-bold text-sm transition-all">
{{ t.label }}
</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 = '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>
</nav>
</header>
<!-- Индикатор загрузки -->
<div v-if="isLoading" class="flex justify-center py-20">
<div class="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full spinner"></div>
</div>
<!-- ═══════ ПУЛЬТ ═══════ -->
<!-- ПУЛЬТ -->
<div v-if="tab === 'control' && !isLoading" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div v-if="Object.keys(groups).length === 0" class="col-span-full text-center py-20 glass rounded-2xl">
<p class="text-slate-500">Создайте группу в админке</p>
<p v-if="isAdmin" class="text-slate-500">Создайте группу в админке</p>
<p v-else class="text-slate-500">Нет доступных групп</p>
</div>
<div v-for="(group, id) in groups" :key="id"
class="glass p-6 rounded-2xl transition-all fade-up"
:style="sliders[id]?.state ? 'border-color: rgba(249,115,22,0.2)' : ''">
<!-- Шапка группы -->
<div v-for="(group, id) in groups" :key="id" class="glass p-6 rounded-2xl transition-all fade-up" :style="sliders[id]?.state ? 'border-color: rgba(249,115,22,0.2)' : ''">
<div class="flex justify-between items-start mb-6">
<div>
<h2 class="text-xl font-black text-white flex items-center gap-2">
{{ group.name }}
<span :class="sliders[id]?.state ? 'bg-green-400 pulse-on' : 'bg-slate-600'"
class="w-2 h-2 rounded-full inline-block"></span>
<span :class="sliders[id]?.state ? 'bg-green-400 pulse-on' : 'bg-slate-600'" class="w-2 h-2 rounded-full inline-block"></span>
</h2>
<span class="text-[10px] mono text-slate-600">
{{ id }} · {{ group.device_ids?.length || 0 }} ламп
</span>
<span class="text-[10px] mono text-slate-600">{{ id }} · {{ group.device_ids?.length || 0 }} ламп</span>
</div>
<div class="flex gap-1.5">
<button @click="deleteGroup(id)" class="p-2 rounded-lg bg-slate-800/50 hover:bg-red-900/40 text-slate-600 hover:text-red-400 transition-all" title="Удалить">
<button v-if="isAdmin" @click="deleteGroup(id)" class="p-2 rounded-lg bg-slate-800/50 hover:bg-red-900/40 text-slate-600 hover:text-red-400 transition-all" title="Удалить">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button @click="setTimer4h(id)" class="p-2 px-3 rounded-lg bg-blue-600/20 hover:bg-blue-600/40 text-blue-400 text-xs font-bold transition-all" title="Включить на 4 часа">
</button>
<button @click="toggleGroup(id, true)"
:class="sliders[id]?.state ? 'bg-orange-600 text-white' : 'bg-slate-800/50 text-slate-400 hover:bg-orange-600/30 hover:text-orange-300'"
class="p-2 px-4 rounded-lg font-bold text-sm transition-all">ВКЛ</button>
<button @click="toggleGroup(id, false)"
:class="!sliders[id]?.state ? 'bg-slate-600 text-white' : 'bg-slate-800/50 text-slate-400 hover:bg-slate-600/50'"
class="p-2 px-4 rounded-lg font-bold text-sm transition-all">ВЫКЛ</button>
<button v-if="isAdmin" @click="setTimer4h(id)" class="p-2 px-3 rounded-lg bg-blue-600/20 hover:bg-blue-600/40 text-blue-400 text-xs font-bold transition-all" title="4 часа"></button>
<button @click="toggleGroup(id, true)" :class="sliders[id]?.state ? 'bg-orange-600 text-white' : 'bg-slate-800/50 text-slate-400 hover:bg-orange-600/30 hover:text-orange-300'" class="p-2 px-4 rounded-lg font-bold text-sm transition-all">ВКЛ</button>
<button @click="toggleGroup(id, false)" :class="!sliders[id]?.state ? 'bg-slate-600 text-white' : 'bg-slate-800/50 text-slate-400 hover:bg-slate-600/50'" class="p-2 px-4 rounded-lg font-bold text-sm transition-all">ВЫКЛ</button>
</div>
</div>
<!-- Управление -->
<div class="space-y-5">
<!-- Яркость -->
<div>
<div class="flex justify-between text-[10px] font-bold uppercase text-slate-500 mb-2">
<span>Яркость</span>
<span :class="sliders[id]?.state ? 'text-orange-400' : 'text-slate-600'" class="mono">{{ sliders[id]?.brightness || 100 }}%</span>
</div>
<input type="range" min="10" max="100" step="10" class="w-full"
:disabled="!sliders[id]?.state"
:value="sliders[id]?.brightness || 100"
@change="setBrightness(id, +$event.target.value)">
<div class="flex justify-between text-[10px] font-bold uppercase text-slate-500 mb-2"><span>Яркость</span><span :class="sliders[id]?.state ? 'text-orange-400' : 'text-slate-600'" class="mono">{{ sliders[id]?.brightness || 100 }}%</span></div>
<input type="range" min="10" max="100" step="10" class="w-full" :disabled="!sliders[id]?.state" :value="sliders[id]?.brightness || 100" @change="setBrightness(id, +$event.target.value)">
</div>
<!-- Температура -->
<div>
<div class="flex justify-between text-[10px] font-bold uppercase text-slate-500 mb-2">
<span>Температура</span>
<span :class="sliders[id]?.state ? 'text-blue-300' : 'text-slate-600'" class="mono">{{ sliders[id]?.temp || 4000 }}K</span>
</div>
<input type="range" min="2700" max="6500" step="100" class="w-full temp-gradient"
:disabled="!sliders[id]?.state"
:value="sliders[id]?.temp || 4000"
@change="setTemp(id, +$event.target.value)">
<div class="flex justify-between text-[10px] font-bold uppercase text-slate-500 mb-2"><span>Температура</span><span :class="sliders[id]?.state ? 'text-blue-300' : 'text-slate-600'" class="mono">{{ sliders[id]?.temp || 4000 }}K</span></div>
<input type="range" min="2700" max="6500" step="100" class="w-full temp-gradient" :disabled="!sliders[id]?.state" :value="sliders[id]?.temp || 4000" @change="setTemp(id, +$event.target.value)">
</div>
<!-- Цвет и Сцена -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 mb-2 block">Цвет</label>
<input type="color" class="w-full h-10 bg-transparent border border-slate-700/50 rounded-lg cursor-pointer"
:disabled="!sliders[id]?.state"
@input="setColor(id, $event.target.value)">
<input type="color" class="w-full h-10 bg-transparent border border-slate-700/50 rounded-lg cursor-pointer" :disabled="!sliders[id]?.state" @input="setColor(id, $event.target.value)">
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 mb-2 block">Сцена</label>
<select @change="setScene(id, $event.target.value); $event.target.value=''"
:disabled="!sliders[id]?.state"
class="w-full bg-black/30 border border-slate-700/50 p-2.5 rounded-lg text-xs outline-none focus:border-orange-500 disabled:opacity-25">
<select @change="setScene(id, $event.target.value); $event.target.value=''" :disabled="!sliders[id]?.state" class="w-full bg-black/30 border border-slate-700/50 p-2.5 rounded-lg text-xs outline-none focus:border-orange-500 disabled:opacity-25">
<option value="" disabled selected>Пресет...</option>
<option v-for="(sceneId, sceneName) in allScenes" :key="sceneName" :value="sceneName">{{ sceneName }}</option>
</select>
@@ -217,10 +127,8 @@
</div>
</div>
<!-- ═══════ РАСПИСАНИЯ ═══════ -->
<div v-if="tab === 'schedules' && !isLoading" class="space-y-8 fade-up">
<!-- Создание -->
<!-- РАСПИСАНИЯ (только админ) -->
<div v-if="tab === 'schedules' && !isLoading && isAdmin" class="space-y-8 fade-up">
<div class="glass p-6 rounded-2xl">
<h2 class="text-lg font-black mb-5 uppercase">Новая задача</h2>
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
@@ -228,7 +136,6 @@
<option value="" disabled>Группа...</option>
<option v-for="(g, id) in groups" :key="id" :value="id">{{ g.name }}</option>
</select>
<div class="flex items-center gap-1 bg-black/30 border border-slate-700/50 p-1 rounded-xl">
<select v-model="taskHour" class="bg-transparent outline-none flex-1 text-center font-bold text-sm">
<option v-for="h in 24" :key="h" :value="String(h-1).padStart(2,'0')">{{ String(h-1).padStart(2,'0') }}</option>
@@ -238,35 +145,24 @@
<option v-for="m in 60" :key="m" :value="String(m-1).padStart(2,'0')">{{ String(m-1).padStart(2,'0') }}</option>
</select>
</div>
<select v-model="newTask.state" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none text-sm">
<option :value="true">ВКЛЮЧИТЬ</option>
<option :value="false">ВЫКЛЮЧИТЬ</option>
</select>
<button @click="addSchedule"
:disabled="!newTask.target_id"
class="bg-orange-600 hover:bg-orange-500 disabled:opacity-30 disabled:cursor-not-allowed rounded-xl font-bold transition-all text-sm active:scale-[0.97]">
ДОБАВИТЬ
</button>
<button @click="addSchedule" :disabled="!newTask.target_id" class="bg-orange-600 hover:bg-orange-500 disabled:opacity-30 disabled:cursor-not-allowed rounded-xl font-bold transition-all text-sm active:scale-[0.97]">ДОБАВИТЬ</button>
</div>
</div>
<!-- Список задач -->
<div class="glass p-6 rounded-2xl">
<h2 class="text-lg font-black mb-5 uppercase">Активные задачи</h2>
<div class="space-y-3">
<div v-for="task in tasks" :key="task.id"
class="bg-black/20 border border-slate-800/50 p-4 rounded-xl flex justify-between items-center group hover:border-slate-700 transition-all">
<div v-for="task in tasks" :key="task.id" class="bg-black/20 border border-slate-800/50 p-4 rounded-xl flex justify-between items-center group hover:border-slate-700 transition-all">
<div>
<div class="flex items-center gap-3 mb-1">
<span class="mono text-orange-400 text-lg font-bold">
<template v-if="task.hour != null">{{ String(task.hour).padStart(2,'0') }}:{{ String(task.minute).padStart(2,'0') }}</template>
<template v-else-if="task.next_run">{{ new Date(task.next_run).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}) }}</template>
</span>
<span :class="task.state ? 'text-green-400' : 'text-red-400'" class="text-[10px] font-bold uppercase">
{{ task.state ? 'ВКЛ' : 'ВЫКЛ' }}
</span>
<span :class="task.state ? 'text-green-400' : 'text-red-400'" class="text-[10px] font-bold uppercase">{{ task.state ? 'ВКЛ' : 'ВЫКЛ' }}</span>
</div>
<div class="flex items-center gap-2 text-[10px] text-slate-600">
<span class="mono">{{ task.target_id }}</span>
@@ -274,8 +170,7 @@
<span v-if="task.next_run">· {{ new Date(task.next_run).toLocaleDateString() }}</span>
</div>
</div>
<button @click="deleteTask(task.id)"
class="text-slate-700 hover:text-red-400 p-2 transition-colors opacity-0 group-hover:opacity-100">
<button @click="deleteTask(task.id)" class="text-slate-700 hover:text-red-400 p-2 transition-colors opacity-0 group-hover:opacity-100">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
@@ -284,34 +179,22 @@
</div>
</div>
<!-- ═══════ АДМИНКА ═══════ -->
<div v-if="tab === 'admin' && !isLoading" class="space-y-8 fade-up">
<!-- АДМИНКА (только админ) -->
<div v-if="tab === 'admin' && !isLoading && isAdmin" class="space-y-8 fade-up">
<section class="glass p-6 rounded-2xl">
<div class="flex justify-between items-center mb-6">
<h2 class="text-lg font-black uppercase">Устройства в сети</h2>
<button @click="rescan" :disabled="isRescanning"
class="bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 px-4 py-2 rounded-lg text-xs font-bold transition-all disabled:opacity-50 flex items-center gap-2">
<button @click="rescan" :disabled="isRescanning" class="bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 px-4 py-2 rounded-lg text-xs font-bold transition-all disabled:opacity-50 flex items-center gap-2">
<span :class="isRescanning ? 'spinner' : ''" class="inline-block">🔄</span> СКАНИРОВАТЬ
</button>
</div>
<!-- Создание группы -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-8">
<input v-model="newGroup.id" placeholder="ID (bedroom)"
class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm mono">
<input v-model="newGroup.name" placeholder="Название (Спальня)"
class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm">
<button @click="createGroup" :disabled="!newGroup.id || !newGroup.macs.length"
class="bg-orange-600 hover:bg-orange-500 disabled:opacity-30 disabled:cursor-not-allowed rounded-xl font-bold transition-all text-sm active:scale-[0.97]">
СОЗДАТЬ ГРУППУ
</button>
<input v-model="newGroup.id" placeholder="ID (bedroom)" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm mono">
<input v-model="newGroup.name" placeholder="Название (Спальня)" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm">
<button @click="createGroup" :disabled="!newGroup.id || !newGroup.macs.length" class="bg-orange-600 hover:bg-orange-500 disabled:opacity-30 disabled:cursor-not-allowed rounded-xl font-bold transition-all text-sm active:scale-[0.97]">СОЗДАТЬ ГРУППУ</button>
</div>
<!-- Список устройств -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<div v-for="dev in devices" :key="dev.id"
:class="newGroup.macs.includes(dev.id) ? 'border-orange-500/40 bg-orange-500/5' : 'border-slate-800/50'"
class="border p-3 rounded-xl flex items-center justify-between transition-all hover:border-slate-600">
<div v-for="dev in devices" :key="dev.id" :class="newGroup.macs.includes(dev.id) ? 'border-orange-500/40 bg-orange-500/5' : 'border-slate-800/50'" class="border p-3 rounded-xl flex items-center justify-between transition-all hover:border-slate-600">
<label class="flex items-center gap-3 cursor-pointer flex-1">
<input type="checkbox" :value="dev.id" v-model="newGroup.macs" class="w-4 h-4 accent-orange-500 rounded">
<div>
@@ -324,22 +207,14 @@
</button>
</div>
</div>
<div v-if="devices.length === 0" class="text-center py-10 text-slate-600 text-sm">
Устройства не найдены. Нажмите "Сканировать".
</div>
<div v-if="devices.length === 0" class="text-center py-10 text-slate-600 text-sm">Устройства не найдены. Нажмите "Сканировать".</div>
</section>
</div>
</template>
<!-- ═══════ Toast-уведомления ═══════ -->
<!-- Toast -->
<div class="fixed bottom-6 right-6 z-50 space-y-2">
<div v-for="(toast, i) in toasts" :key="toast.id"
:class="toast.type === 'error' ? 'border-red-500/30 text-red-300' : toast.type === 'success' ? 'border-green-500/30 text-green-300' : 'border-slate-700 text-slate-300'"
class="glass border px-5 py-3 rounded-xl text-sm font-medium toast-enter shadow-xl max-w-xs">
{{ toast.text }}
</div>
<div v-for="(toast, i) in toasts" :key="toast.id" :class="toast.type === 'error' ? 'border-red-500/30 text-red-300' : toast.type === 'success' ? 'border-green-500/30 text-green-300' : 'border-slate-700 text-slate-300'" class="glass border px-5 py-3 rounded-xl text-sm font-medium toast-enter shadow-xl max-w-xs">{{ toast.text }}</div>
</div>
</div>
@@ -351,260 +226,122 @@
apiKey: localStorage.getItem('ignis_key') || '',
tempKey: '',
tab: 'control',
groups: {},
devices: [],
sliders: {},
isAdmin: false,
authName: '',
groups: {}, devices: [], sliders: {},
newGroup: { id: '', name: '', macs: [] },
isLoading: false,
isLoadingStatus: false,
isFetching: false, // защита от параллельных fetchData
isRescanning: false,
taskHour: '22',
taskMin: '00',
isLoading: false, isLoadingStatus: false, isFetching: false, isRescanning: false,
taskHour: '22', taskMin: '00',
newTask: { target_id: '', state: true },
tasks: [],
allScenes: {}, // загружается с бэкенда
toasts: [],
toastCounter: 0,
tasks: [], allScenes: {},
toasts: [], toastCounter: 0,
}
},
methods: {
// ─── Утилиты ─────────────────────────────────
saveKey() {
if (this.tempKey) {
this.apiKey = this.tempKey;
localStorage.setItem('ignis_key', this.tempKey);
this.fetchData();
}
if (this.tempKey) { this.apiKey = this.tempKey; localStorage.setItem('ignis_key', this.tempKey); this.initApp(); }
},
logout() {
this.apiKey = '';
localStorage.removeItem('ignis_key');
location.reload();
},
// Toast-уведомление
logout() { this.apiKey = ''; this.isAdmin = false; this.authName = ''; localStorage.removeItem('ignis_key'); location.reload(); },
toast(text, type = 'info', duration = 3000) {
const id = ++this.toastCounter;
this.toasts.push({ id, text, type });
setTimeout(() => {
this.toasts = this.toasts.filter(t => t.id !== id);
}, duration);
},
// Имя группы по id (для расписаний)
getGroupName(targetId) {
const g = this.groups[targetId];
return g ? g.name : null;
const id = ++this.toastCounter; this.toasts.push({ id, text, type });
setTimeout(() => { this.toasts = this.toasts.filter(t => t.id !== id); }, duration);
},
getGroupName(tid) { const g = this.groups[tid]; return g ? g.name : null; },
// ─── HTTP ────────────────────────────────────
async request(path, method = 'GET', params = null, body = null) {
let url = path;
if (params) {
const q = new URLSearchParams(params).toString();
url += `?${q}`;
}
if (params) url += `?${new URLSearchParams(params).toString()}`;
try {
const response = await fetch(url, {
method,
headers: {
'X-API-Key': this.apiKey,
'Content-Type': 'application/json'
},
body: body ? JSON.stringify(body) : null
});
if (response.status === 403) {
this.toast('Неверный API-ключ', 'error');
this.logout();
return null;
const r = await fetch(url, { method, headers: { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : null });
if (r.status === 403) {
const err = await r.json().catch(() => ({}));
if (err.detail === 'Недостаточно прав') { this.toast('Нет прав', 'error'); return null; }
this.toast('Неверный API-ключ', 'error'); this.logout(); return null;
}
if (!response.ok) {
const err = await response.json().catch(() => ({}));
this.toast(err.detail || `Ошибка ${response.status}`, 'error');
return null;
}
return await response.json();
} catch (e) {
this.toast('Сервер недоступен', 'error');
return null;
}
if (!r.ok) { const err = await r.json().catch(() => ({})); this.toast(err.detail || `Ошибка ${r.status}`, 'error'); return null; }
return await r.json();
} catch (e) { this.toast('Сервер недоступен', 'error'); return null; }
},
async initApp() {
this.isLoading = true;
const auth = await this.request('/auth/me');
if (!auth) { this.isLoading = false; return; }
this.isAdmin = auth.is_admin;
this.authName = auth.name;
await this.fetchData();
this.isLoading = false;
setInterval(() => this.fetchData(), 15000);
},
// ─── Данные ──────────────────────────────────
async fetchData() {
if (!this.apiKey || this.isFetching) return;
this.isFetching = true;
try {
const [gData, dData, sData] = await Promise.all([
this.request('/devices/groups'),
this.request('/devices'),
this.request('/devices/scenes'),
this.request('/devices/groups'), this.request('/devices'), this.request('/devices/scenes'),
]);
if (gData) {
this.groups = gData;
// Инициализируем слайдеры для новых групп
Object.keys(this.groups).forEach(id => {
if (!this.sliders[id]) {
this.sliders[id] = { brightness: 100, temp: 4000, state: false };
}
});
Object.keys(this.groups).forEach(id => { if (!this.sliders[id]) this.sliders[id] = { brightness: 100, temp: 4000, state: false }; });
await this.syncGroupStatuses();
}
if (dData) {
// Бэкенд может вернуть dict или list
this.devices = Array.isArray(dData) ? dData : Object.values(dData);
}
// Сцены с бэкенда (dict: {name: sceneId})
if (dData) this.devices = Array.isArray(dData) ? dData : Object.values(dData);
if (sData) this.allScenes = sData;
this.fetchTasks();
} finally {
this.isFetching = false;
}
if (this.isAdmin) this.fetchTasks();
} finally { this.isFetching = false; }
},
// ─── Управление ──────────────────────────────
async control(id, params) {
await this.request(`/control/group/${id}`, 'POST', params);
// Не делаем полный syncGroupStatuses -- ждём следующий цикл
},
toggleGroup(id, state) {
if (this.sliders[id]) {
this.sliders[id].state = state;
}
this.control(id, { state });
},
setBrightness(id, val) {
if (this.sliders[id]) this.sliders[id].brightness = val;
this.control(id, { brightness: val });
},
setTemp(id, val) {
if (this.sliders[id]) this.sliders[id].temp = val;
this.control(id, { temp: val });
},
async control(id, params) { await this.request(`/control/group/${id}`, 'POST', params); },
toggleGroup(id, state) { if (this.sliders[id]) this.sliders[id].state = state; this.control(id, { state }); },
setBrightness(id, val) { if (this.sliders[id]) this.sliders[id].brightness = val; this.control(id, { brightness: val }); },
setTemp(id, val) { if (this.sliders[id]) this.sliders[id].temp = val; this.control(id, { temp: val }); },
setScene(id, scene) { this.control(id, { scene }); },
setColor(id, hex) {
const r = parseInt(hex.slice(1,3),16);
const g = parseInt(hex.slice(3,5),16);
const b = parseInt(hex.slice(5,7),16);
this.control(id, { r, g, b });
},
setColor(id, hex) { this.control(id, { r: parseInt(hex.slice(1,3),16), g: parseInt(hex.slice(3,5),16), b: parseInt(hex.slice(5,7),16) }); },
// ─── Группы ──────────────────────────────────
async createGroup() {
const res = await this.request('/devices/groups', 'POST', null, {
id: this.newGroup.id,
name: this.newGroup.name,
macs: this.newGroup.macs
});
if (res) {
this.toast(`Группа "${this.newGroup.name}" создана`, 'success');
this.newGroup = { id: '', name: '', macs: [] };
await this.fetchData();
this.tab = 'control';
}
const res = await this.request('/devices/groups', 'POST', null, { id: this.newGroup.id, name: this.newGroup.name, macs: this.newGroup.macs });
if (res) { this.toast(`Группа "${this.newGroup.name}" создана`, 'success'); this.newGroup = { id: '', name: '', macs: [] }; await this.fetchData(); this.tab = 'control'; }
},
async deleteGroup(id) {
const name = this.groups[id]?.name || id;
if (confirm(`Удалить группу "${name}"?`)) {
await this.request(`/devices/groups/${id}`, 'DELETE');
this.toast(`Группа "${name}" удалена`, 'success');
await this.fetchData();
}
if (confirm(`Удалить группу "${name}"?`)) { await this.request(`/devices/groups/${id}`, 'DELETE'); this.toast(`Удалена`, 'success'); await this.fetchData(); }
},
async rescan() {
this.isRescanning = true;
await this.request('/devices/rescan', 'POST');
this.toast('Сканирование запущено...', 'info');
setTimeout(async () => {
await this.fetchData();
this.isRescanning = false;
this.toast(`Найдено ${this.devices.length} устройств`, 'success');
}, 3000);
this.isRescanning = true; await this.request('/devices/rescan', 'POST'); this.toast('Сканирование...', 'info');
setTimeout(async () => { await this.fetchData(); this.isRescanning = false; this.toast(`Найдено ${this.devices.length} устройств`, 'success'); }, 3000);
},
async blink(deviceId) { await this.request(`/control/device/${deviceId}/blink`, 'POST'); },
async blink(id) { await this.request(`/control/device/${id}/blink`, 'POST'); },
// ─── Синхронизация состояния ─────────────────
async syncGroupStatuses() {
if (this.isLoadingStatus) return;
this.isLoadingStatus = true;
if (this.isLoadingStatus) return; this.isLoadingStatus = true;
try {
// Параллельный опрос всех групп
const groupIds = Object.keys(this.groups);
const results = await Promise.all(
groupIds.map(id => this.request(`/control/group/${id}/status`))
);
groupIds.forEach((id, i) => {
const data = results[i];
if (data && data.results && data.results.length > 0) {
const firstValid = data.results.find(r => r.status && !r.error);
if (firstValid) {
const s = firstValid.status;
this.sliders[id] = {
brightness: s.dimming || 100,
temp: s.temp || 4000,
state: s.state || false,
};
}
const ids = Object.keys(this.groups);
const results = await Promise.all(ids.map(id => this.request(`/control/group/${id}/status`)));
ids.forEach((id, i) => {
const d = results[i];
if (d?.results?.length > 0) {
const v = d.results.find(r => r.status && !r.error);
if (v) this.sliders[id] = { brightness: v.status.dimming || 100, temp: v.status.temp || 4000, state: v.status.state || false };
}
});
} finally {
this.isLoadingStatus = false;
}
} finally { this.isLoadingStatus = false; }
},
// ─── Расписания ──────────────────────────────
async fetchTasks() {
const data = await this.request('/schedules/tasks');
if (data) this.tasks = data.tasks || [];
},
async fetchTasks() { const d = await this.request('/schedules/tasks'); if (d) this.tasks = d.tasks || []; },
async addSchedule() {
if (!this.newTask.target_id) {
this.toast('Выберите группу', 'error');
return;
}
const res = await this.request('/schedules/cron', 'POST', {
target_id: this.newTask.target_id,
hour: this.taskHour,
minute: this.taskMin,
is_group: true,
state: this.newTask.state
});
if (res) {
this.toast(`Задача добавлена: ${this.taskHour}:${this.taskMin}`, 'success');
this.fetchTasks();
}
},
async deleteTask(id) {
await this.request(`/schedules/${id}`, 'DELETE');
this.toast('Задача отменена', 'success');
this.fetchTasks();
if (!this.newTask.target_id) { this.toast('Выберите группу', 'error'); return; }
const res = await this.request('/schedules/cron', 'POST', { target_id: this.newTask.target_id, hour: this.taskHour, minute: this.taskMin, is_group: true, state: this.newTask.state });
if (res) { this.toast(`${this.taskHour}:${this.taskMin} добавлено`, 'success'); this.fetchTasks(); }
},
async deleteTask(id) { await this.request(`/schedules/${id}`, 'DELETE'); this.toast('Отменено', 'success'); this.fetchTasks(); },
async setTimer4h(id) {
await this.toggleGroup(id, true);
const res = await this.request('/schedules/once', 'POST', {
target_id: id,
hours_from_now: 4,
is_group: true,
state: false
});
if (res) {
this.toast('Таймер 4ч установлен', 'success');
this.fetchTasks();
}
const res = await this.request('/schedules/once', 'POST', { target_id: id, hours_from_now: 4, is_group: true, state: false });
if (res) { this.toast('Таймер 4ч', 'success'); this.fetchTasks(); }
},
},
async mounted() {
if (this.apiKey) {
this.isLoading = true;
await this.fetchData();
this.isLoading = false;
// Периодический опрос с защитой от наложений
setInterval(() => this.fetchData(), 15000);
}
}
async mounted() { if (this.apiKey) await this.initApp(); }
}).mount('#app')
</script>
</body>