Files
ignis-core/static/index.html
2026-03-28 23:06:40 +07:00

536 lines
42 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IGNIS | Smart Control</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔥</text></svg>">
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<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); }
.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; }
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: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; }
.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-enter { animation: fadeUp 0.3s ease-out; }
@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>
</head>
<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-ключ</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>
</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">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<div>
<h1 class="text-2xl font-black tracking-tight uppercase">Ignis<span class="text-orange-500">Core</span></h1>
<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 @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>
<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>
</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 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 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>
</h2>
<span class="text-[10px] mono text-slate-600">{{ id }} · {{ group.device_ids?.length || 0 }} ламп</span>
</div>
<div class="flex gap-1.5">
<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 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>
<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>
<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)">
</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">
<option value="" disabled selected>Пресет...</option>
<option v-for="(sceneId, sceneName) in allScenes" :key="sceneName" :value="sceneName">{{ sceneName }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- РАСПИСАНИЯ (только админ) -->
<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">
<select v-model="newTask.target_id" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
<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>
</select>
<span class="font-bold text-slate-500">:</span>
<select v-model="taskMin" class="bg-transparent outline-none flex-1 text-center font-bold text-sm">
<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>
</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>
<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>
</div>
<div class="flex items-center gap-2 text-[10px] text-slate-600">
<span class="mono">{{ task.target_id }}</span>
<span v-if="getGroupName(task.target_id)" class="text-slate-500">· {{ getGroupName(task.target_id) }}</span>
<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">
<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>
<div v-if="tasks.length === 0" class="text-center py-12 text-slate-600 text-sm">Задач пока нет</div>
</div>
</div>
</div>
<!-- АДМИНКА (только админ) -->
<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">
<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>
</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">
<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>
<p class="font-bold text-sm mono">{{ dev.id }}</p>
<p class="text-[10px] mono text-slate-600">{{ dev.ip }}</p>
</div>
</label>
<button @click="blink(dev.id)" class="p-2 rounded-lg bg-slate-800/30 hover:bg-orange-600/30 hover:text-orange-400 transition-all text-slate-600" title="Мигнуть">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</button>
</div>
</div>
<div v-if="devices.length === 0" class="text-center py-10 text-slate-600 text-sm">Устройства не найдены. Нажмите "Сканировать".</div>
</section>
<!-- Гостевые API-ключи -->
<section class="glass p-6 rounded-2xl">
<h2 class="text-lg font-black mb-6 uppercase">Гостевые ключи</h2>
<!-- Создание -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-6">
<input v-model="newKeyName" placeholder="Имя (Вася, гости...)" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm">
<label class="flex items-center gap-2 bg-black/30 border border-slate-700/50 p-3 rounded-xl text-sm text-slate-400 cursor-pointer">
<input type="checkbox" v-model="newKeyAdmin" class="w-4 h-4 accent-orange-500 rounded">
Админ-права
</label>
<button @click="createApiKey" :disabled="!newKeyName.trim()" 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 v-if="lastCreatedKey" class="bg-green-900/20 border border-green-500/30 p-4 rounded-xl mb-6">
<div class="flex justify-between items-center mb-2">
<span class="text-green-400 text-xs font-bold uppercase">Новый ключ создан -- скопируйте!</span>
<button @click="copyKey(lastCreatedKey)" class="text-green-400 hover:text-green-300 text-xs font-bold transition-colors">КОПИРОВАТЬ</button>
</div>
<div class="mono text-sm text-green-300 break-all select-all bg-black/30 p-3 rounded-lg">{{ lastCreatedKey }}</div>
</div>
<!-- Список ключей -->
<div class="space-y-2">
<div v-for="k in apiKeys" :key="k.key" 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-2">
<span class="font-bold text-sm">{{ k.name }}</span>
<span v-if="k.is_admin" class="text-[9px] text-orange-400 font-bold uppercase bg-orange-400/10 px-2 py-0.5 rounded">админ</span>
<span v-else class="text-[9px] text-slate-500 font-bold uppercase bg-slate-500/10 px-2 py-0.5 rounded">гость</span>
<span :class="k.active ? 'text-green-500' : 'text-red-500'" class="text-[9px] font-bold uppercase">{{ k.active ? 'активен' : 'отозван' }}</span>
</div>
<div class="mono text-[10px] text-slate-600 mt-1">{{ k.key.slice(0, 12) }}...{{ k.key.slice(-6) }}</div>
</div>
<div class="flex gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button v-if="k.active" @click="revokeApiKey(k.key, k.name)" class="p-2 rounded-lg bg-red-900/20 hover:bg-red-900/40 text-red-400 text-xs font-bold transition-all" title="Отозвать">ОТОЗВАТЬ</button>
<button v-else @click="activateApiKey(k.key, k.name)" class="p-2 rounded-lg bg-green-900/20 hover:bg-green-900/40 text-green-400 text-xs font-bold transition-all" title="Активировать">АКТИВИР.</button>
</div>
</div>
<div v-if="apiKeys.length === 0" class="text-center py-8 text-slate-600 text-sm">Гостевых ключей нет</div>
</div>
</section>
</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>
<!-- 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>
</div>
<script>
const { createApp } = Vue
createApp({
data() {
return {
apiKey: localStorage.getItem('ignis_key') || '',
tempKey: '',
tab: 'control',
isAdmin: false,
authName: '',
groups: {}, devices: [], sliders: {},
newGroup: { id: '', name: '', macs: [] },
isLoading: false, isLoadingStatus: false, isFetching: false, isRescanning: false,
taskHour: '22', taskMin: '00',
newTask: { target_id: '', state: true },
tasks: [], allScenes: {},
toasts: [], toastCounter: 0,
apiKeys: [], newKeyName: '', newKeyAdmin: false, lastCreatedKey: '',
statsData: [], eventLog: [], statsDays: 7,
}
},
methods: {
saveKey() {
if (this.tempKey) { this.apiKey = this.tempKey; localStorage.setItem('ignis_key', this.tempKey); this.initApp(); }
},
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);
},
getGroupName(tid) { const g = this.groups[tid]; return g ? g.name : null; },
async request(path, method = 'GET', params = null, body = null) {
let url = path;
if (params) url += `?${new URLSearchParams(params).toString()}`;
try {
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 (!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'),
]);
if (gData) {
this.groups = gData;
Object.keys(this.groups).forEach(id => { if (!this.sliders[id]) this.sliders[id] = { brightness: 100, temp: 4000, state: false }; });
await this.syncGroupStatuses();
}
if (dData) this.devices = Array.isArray(dData) ? dData : Object.values(dData);
if (sData) this.allScenes = sData;
if (this.isAdmin) this.fetchTasks();
if (this.isAdmin) this.fetchApiKeys();
} finally { this.isFetching = false; }
},
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) { 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'; }
},
async deleteGroup(id) {
const name = this.groups[id]?.name || id;
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);
},
async blink(id) { await this.request(`/control/device/${id}/blink`, 'POST'); },
async syncGroupStatuses() {
if (this.isLoadingStatus) return; this.isLoadingStatus = true;
try {
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; }
},
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(); },
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(); }
},
// ─── API-ключи ───────────────────────────────
async fetchApiKeys() {
const data = await this.request('/api-keys');
if (data) this.apiKeys = data;
},
async createApiKey() {
const name = this.newKeyName.trim();
if (!name) return;
const res = await this.request('/api-keys', 'POST', { name, is_admin: this.newKeyAdmin });
if (res) {
this.lastCreatedKey = res.key;
this.newKeyName = '';
this.newKeyAdmin = false;
this.toast(`Ключ "${name}" создан`, 'success');
this.fetchApiKeys();
}
},
async revokeApiKey(key, name) {
if (confirm(`Отозвать ключ "${name}"?`)) {
await this.request(`/api-keys/${key}`, 'DELETE');
this.toast(`Ключ "${name}" отозван`, 'success');
this.fetchApiKeys();
}
},
async activateApiKey(key, name) {
await this.request(`/api-keys/${key}/activate`, 'POST');
this.toast(`Ключ "${name}" активирован`, 'success');
this.fetchApiKeys();
},
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')
</script>
</body>
</html>