Files
ignis-core/static/index.html
2026-05-21 20:46:04 +07:00

433 lines
37 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="/static/vendor/tailwindcdn.js"></script>
<link rel="stylesheet" href="/static/ui.css">
</head>
<body>
<div id="app" v-cloak 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"
autocomplete="off" spellcheck="false"
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">
<label class="flex items-center justify-center gap-2 text-xs text-slate-500 mb-4 cursor-pointer">
<input v-model="rememberInSession" type="checkbox" class="w-4 h-4 accent-orange-500 rounded">
Запомнить только в этой вкладке
</label>
<p class="text-[11px] text-slate-600 mb-4">По умолчанию ключ хранится только в памяти текущей страницы.</p>
<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="isMaster" class="text-[9px] text-orange-500 font-bold uppercase">master</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 @click="tab = 'server'" :class="tab === 'server' ? '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 === 'server' && !isLoading" class="space-y-8 fade-up">
<section v-if="serverInfo" class="glass p-6 rounded-2xl">
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
<div>
<div class="text-[10px] uppercase tracking-[0.2em] text-slate-600 mb-2">О сервере</div>
<h2 class="text-2xl font-black tracking-tight">{{ serverDisplayName() }}</h2>
<div class="text-sm text-slate-500 mt-1">{{ serverDisplaySubtitle() }}</div>
</div>
<div class="flex flex-wrap gap-2 text-[10px]">
<span class="border px-2.5 py-1 rounded-lg font-bold uppercase text-green-300 border-green-500/30 bg-green-500/10">
онлайн
</span>
<span :class="isAdmin ? 'text-blue-300 border-blue-500/30 bg-blue-500/10' : 'text-slate-300 border-slate-700/60 bg-black/20'" class="border px-2.5 py-1 rounded-lg font-bold uppercase">
{{ isAdmin ? 'админ-доступ' : 'гостевой доступ' }}
</span>
<span v-if="serverInfo.diagnostics_visible" class="border px-2.5 py-1 rounded-lg font-bold uppercase text-orange-300 border-orange-500/30 bg-orange-500/10">
расширенная диагностика
</span>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 mt-6">
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
<div class="text-[10px] uppercase text-slate-600 mb-2">Статус</div>
<div class="text-sm font-bold">Подключено</div>
<div class="text-[11px] text-slate-500 mt-1">API отвечает и готов к управлению.</div>
</div>
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
<div class="text-[10px] uppercase text-slate-600 mb-2">Запущен</div>
<div class="text-sm font-bold">{{ formatRelativeUptime(serverInfo.uptime_seconds) }}</div>
<div class="text-[11px] text-slate-500 mt-1">
{{ serverInfo.started_at ? formatServerTimestamp(serverInfo.started_at) : `аптайм ${formatUptime(serverInfo.uptime_seconds)}` }}
</div>
</div>
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
<div class="text-[10px] uppercase text-slate-600 mb-2">Доступ</div>
<div class="text-sm font-bold">{{ isMaster ? 'Мастер' : (isAdmin ? 'Администратор' : 'Гостевой') }}</div>
<div class="text-[11px] text-slate-500 mt-1">
{{ isAdmin ? 'Служебная диагностика доступна на этой вкладке.' : 'Служебные параметры и адреса скрыты.' }}
</div>
</div>
<div v-if="serverInfo.diagnostics_visible" class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
<div class="text-[10px] uppercase text-slate-600 mb-2">Версия</div>
<div class="text-sm font-bold">{{ serverInfo.build?.version ? `v${serverInfo.build.version}` : 'не определена' }}</div>
<div class="text-[11px] text-slate-500 mt-1">{{ serverInfo.timezone || 'таймзона не указана' }}</div>
</div>
</div>
</section>
<section v-if="serverInfo?.diagnostics_visible" class="glass p-6 rounded-2xl">
<h2 class="text-lg font-black uppercase mb-5">Диагностика</h2>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
<div class="text-[10px] uppercase text-slate-600 mb-2">Сборка</div>
<div class="text-sm font-bold">{{ formatServerBuild(serverInfo.build) }}</div>
</div>
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
<div class="text-[10px] uppercase text-slate-600 mb-2">Таймзона</div>
<div class="text-sm font-bold">{{ serverInfo.timezone || 'не указана' }}</div>
</div>
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
<div class="text-[10px] uppercase text-slate-600 mb-2">Публичный URL</div>
<div class="text-xs mono break-all text-slate-300">
{{ serverInfo.urls?.effective_public_base_url || 'не определён' }}
</div>
</div>
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
<div class="text-[10px] uppercase text-slate-600 mb-2">Наблюдаемый URL</div>
<div class="text-xs mono break-all text-slate-300">
{{ serverInfo.urls?.observed_base_url || 'не определён' }}
</div>
</div>
</div>
<div class="flex flex-wrap gap-2 text-[10px] mt-5">
<span :class="serverInfo.configuration?.configured ? 'text-green-300 border-green-500/30 bg-green-500/10' : 'text-red-300 border-red-500/30 bg-red-500/10'" class="border px-2.5 py-1 rounded-lg font-bold uppercase">
{{ serverInfo.configuration?.configured ? 'настроен' : 'требует настройки' }}
</span>
<span :class="serverInfo.configuration?.public_base_url_configured ? 'text-blue-300 border-blue-500/30 bg-blue-500/10' : 'text-slate-400 border-slate-700/60 bg-black/20'" class="border px-2.5 py-1 rounded-lg font-bold uppercase">
{{ serverInfo.configuration?.public_base_url_configured ? 'public url задан' : 'public url не задан' }}
</span>
<span :class="serverInfo.configuration?.scan_network_configured ? 'text-cyan-300 border-cyan-500/30 bg-cyan-500/10' : 'text-slate-400 border-slate-700/60 bg-black/20'" class="border px-2.5 py-1 rounded-lg font-bold uppercase">
{{ serverInfo.configuration?.scan_network_configured ? 'scan network задан' : 'scan network auto' }}
</span>
<span :class="serverInfo.configuration?.build_metadata_complete ? 'text-orange-300 border-orange-500/30 bg-orange-500/10' : 'text-slate-400 border-slate-700/60 bg-black/20'" class="border px-2.5 py-1 rounded-lg font-bold uppercase">
{{ serverInfo.configuration?.build_metadata_complete ? 'build metadata полная' : 'build metadata частичная' }}
</span>
</div>
</section>
</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" :class="sliders[id]?.state ? 'group-active' : ''">
<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 v-if="isMaster" 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.display_key || (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 src="/static/vendor/vue.global.prod.js"></script>
<script src="/static/app.js"></script>
</body>
</html>