Files
ignis-core/static/index.html
2026-05-21 21:47:33 +07:00

602 lines
49 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-7xl 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="brand-mark w-16 h-16 mx-auto mb-6 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="mb-8 md:mb-10 fade-up">
<section class="hero-panel rounded-[2rem] p-6 md:p-8 mb-5">
<div class="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-6">
<div class="flex items-start gap-4">
<div class="brand-mark shrink-0 w-14 h-14 rounded-2xl flex items-center justify-center">
<svg class="w-7 h-7 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 class="space-y-3 min-w-0">
<div>
<div class="text-[11px] font-black uppercase tracking-[0.22em] text-orange-300">Ignis Core</div>
<h1 class="text-3xl md:text-4xl font-black tracking-tight">{{ serverDisplayName() }}</h1>
</div>
<div class="flex flex-wrap items-center gap-2 text-[11px] text-slate-400">
<span v-if="serverDisplaySubtitle() !== serverDisplayName()">{{ serverDisplaySubtitle() }}</span>
<span v-if="serverInfo?.build?.version && serverDisplaySubtitle() !== serverDisplayName()">·</span>
<span v-if="serverInfo?.build?.version">версия {{ serverInfo.build.version }}</span>
<span v-if="serverDiscovery?.last_scan_at && (serverInfo?.build?.version || serverDisplaySubtitle() !== serverDisplayName())">·</span>
<span v-if="serverDiscovery?.last_scan_at">discovery {{ formatRelativeTimestamp(serverDiscovery.last_scan_at) }}</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="status-pill text-slate-200 border-slate-600/60 bg-black/20">{{ roleLabel }}</span>
<span v-if="authName" class="status-pill mono text-slate-300 border-slate-700/70 bg-black/20">{{ authName }}</span>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3 xl:min-w-[320px]">
<div class="metric-card">
<div class="metric-label">Группы</div>
<div class="metric-value">{{ groupCount }}</div>
</div>
<div class="metric-card">
<div class="metric-label">Онлайн-лампы</div>
<div class="metric-value">{{ onlineDeviceCount }}</div>
</div>
<div class="metric-card" v-if="isAdmin">
<div class="metric-label">Задачи</div>
<div class="metric-value">{{ scheduleCount }}</div>
</div>
<div class="metric-card col-span-2">
<div class="metric-label">Запущен</div>
<div class="metric-value text-xl">{{ formatRelativeUptime(serverInfo?.uptime_seconds) }}</div>
</div>
</div>
</div>
</section>
<div class="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<nav class="glass rounded-2xl p-1.5 flex flex-wrap gap-1.5">
<button
v-for="tabItem in visibleTabs"
:key="tabItem.id"
@click="openTab(tabItem.id)"
:class="tab === tabItem.id ? 'active-tab text-white' : 'text-slate-400 hover:text-slate-200'"
class="px-4 md:px-5 py-2.5 rounded-xl font-bold text-xs md:text-sm tracking-wide transition-all"
>
{{ tabItem.label }}
</button>
</nav>
<div class="flex flex-wrap gap-2">
<button @click="refreshWorkspace" class="glass px-4 py-2.5 rounded-xl text-xs font-bold tracking-wide hover:text-orange-300 transition-colors">ОБНОВИТЬ</button>
<button v-if="isAdmin" @click="rescan" :disabled="isRescanning" class="glass px-4 py-2.5 rounded-xl text-xs font-bold tracking-wide text-blue-300 hover:text-blue-200 transition-colors disabled:opacity-50">
{{ isRescanning ? 'СКАНИРУЮ...' : 'ПЕРЕСКАНИРОВАТЬ' }}
</button>
<button @click="logout" class="glass px-4 py-2.5 rounded-xl text-xs font-bold tracking-wide text-slate-400 hover:text-red-300 transition-colors">ВЫЙТИ</button>
</div>
</div>
</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="space-y-8 fade-up">
<section class="glass rounded-3xl p-6">
<div class="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-5 mb-5">
<div>
<div class="section-kicker">Пульт</div>
<h2 class="text-2xl font-black tracking-tight">Комнаты, сцены и свет</h2>
</div>
<div v-if="featuredScenes.length > 0" class="flex flex-wrap gap-2">
<span class="text-[11px] uppercase tracking-[0.22em] text-slate-500 mr-1 self-center">Быстрые сцены</span>
<span v-for="scene in featuredScenes" :key="scene" class="status-pill text-slate-200 border-slate-700/70 bg-black/20">{{ scene }}</span>
</div>
</div>
<div v-if="sortedGroups.length === 0" class="empty-state">{{ isAdmin ? 'Создайте группу на вкладке устройств, чтобы управлять домом через веб-интерфейс.' : 'Пока нет доступных групп света.' }}</div>
<div v-else class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<article v-for="entry in sortedGroups" :key="entry[0]" class="glass rounded-3xl p-6 transition-all" :class="sliders[entry[0]]?.state ? 'group-active' : ''">
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4 mb-6">
<div>
<div class="flex items-center gap-2">
<h3 class="text-2xl font-black">{{ entry[1].name }}</h3>
<span :class="sliders[entry[0]]?.state ? 'bg-green-400 pulse-on' : 'bg-slate-600'" class="w-2.5 h-2.5 rounded-full"></span>
</div>
<div class="text-[11px] text-slate-500 mt-2">
{{ getGroupOnlineCount(entry[1]) }}/{{ getGroupDeviceCount(entry[1]) }} ламп онлайн · {{ entry[0] }}
</div>
</div>
<div class="flex flex-wrap gap-2">
<button @click="toggleGroup(entry[0], true)" :class="sliders[entry[0]]?.state ? 'bg-orange-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-2.5 rounded-xl text-xs font-bold tracking-wide transition-all">ВКЛЮЧИТЬ</button>
<button @click="toggleGroup(entry[0], false)" :class="!sliders[entry[0]]?.state ? 'bg-slate-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-2.5 rounded-xl text-xs font-bold tracking-wide transition-all">ВЫКЛЮЧИТЬ</button>
<button v-if="isAdmin" @click="setTimer4h(entry[0])" class="glass px-4 py-2.5 rounded-xl text-xs font-bold tracking-wide text-blue-300 hover:text-blue-100 transition-colors">ТАЙМЕР 4Ч</button>
</div>
</div>
<div class="space-y-5">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="surface-card">
<div class="metric-label">Яркость</div>
<div class="text-lg font-bold mb-3">{{ sliders[entry[0]]?.brightness || 100 }}%</div>
<input type="range" min="10" max="100" step="10" class="w-full" :disabled="!sliders[entry[0]]?.state" :value="sliders[entry[0]]?.brightness || 100" @change="setBrightness(entry[0], +$event.target.value)">
</div>
<div class="surface-card">
<div class="metric-label">Температура</div>
<div class="text-lg font-bold mb-3">{{ sliders[entry[0]]?.temp || 4000 }}K</div>
<input type="range" min="2700" max="6500" step="100" class="w-full temp-gradient" :disabled="!sliders[entry[0]]?.state" :value="sliders[entry[0]]?.temp || 4000" @change="setTemp(entry[0], +$event.target.value)">
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-[140px,1fr] gap-4">
<div class="surface-card">
<div class="metric-label mb-3">Цвет</div>
<input type="color" class="w-full h-12 bg-transparent border border-slate-700/50 rounded-xl cursor-pointer" :disabled="!sliders[entry[0]]?.state" @input="setColor(entry[0], $event.target.value)">
</div>
<div class="surface-card">
<div class="metric-label mb-3">Сцены</div>
<div class="flex flex-wrap gap-2 mb-3">
<button v-for="scene in featuredScenes" :key="scene" @click="setScene(entry[0], scene)" :disabled="!sliders[entry[0]]?.state" class="glass px-3 py-2 rounded-xl text-xs font-bold text-slate-300 hover:text-white transition-colors disabled:opacity-30">
{{ scene }}
</button>
</div>
<select @change="setScene(entry[0], $event.target.value); $event.target.value=''" :disabled="!sliders[entry[0]]?.state" class="w-full bg-black/30 border border-slate-700/50 p-3 rounded-xl text-sm outline-none focus:border-orange-500 disabled:opacity-30">
<option value="" disabled selected>Все сцены...</option>
<option v-for="scene in sceneOptions" :key="scene" :value="scene">{{ scene }}</option>
</select>
</div>
</div>
</div>
</article>
</div>
</section>
</div>
<div v-if="tab === 'devices' && !isLoading && isAdmin" class="space-y-8 fade-up">
<section class="glass rounded-3xl p-6">
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 mb-6">
<div>
<div class="section-kicker">Устройства и группы</div>
<h2 class="text-2xl font-black tracking-tight">Устройства</h2>
</div>
<button @click="rescan" :disabled="isRescanning" class="accent-button disabled:opacity-50">{{ isRescanning ? 'СКАНИРУЮ...' : 'СКАНИРОВАТЬ СЕТЬ' }}</button>
</div>
<div class="md:hidden space-y-3">
<article v-for="device in sortedDevices" :key="device.id" class="surface-card">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="font-bold truncate">{{ device.name }}</div>
<div class="text-xs text-slate-400 mt-1">Локация: {{ deviceLocationLabel(device) }}</div>
</div>
<button @click="blink(device.id)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-slate-300 hover:text-orange-300 transition-colors shrink-0">BLINK</button>
</div>
<div class="grid grid-cols-1 gap-2 mt-4 text-[11px] text-slate-500 mono">
<div>IP: <span class="text-slate-300">{{ device.ip }}</span></div>
<div>MAC: <span class="text-slate-300 break-all">{{ device.id }}</span></div>
</div>
</article>
</div>
<div class="hidden md:block overflow-x-auto">
<table class="w-full min-w-[720px] table-fixed ui-table">
<thead>
<tr>
<th>Устройство</th>
<th>Локация</th>
<th>IP</th>
<th>MAC</th>
<th class="text-right">Действие</th>
</tr>
</thead>
<tbody>
<tr v-for="device in sortedDevices" :key="device.id">
<td>
<div class="font-bold">{{ device.name }}</div>
</td>
<td class="text-slate-400">{{ deviceLocationLabel(device) }}</td>
<td class="mono text-slate-300">{{ device.ip }}</td>
<td class="mono text-slate-500">{{ device.id }}</td>
<td class="text-right">
<button @click="blink(device.id)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-slate-300 hover:text-orange-300 transition-colors">BLINK</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="sortedDevices.length === 0" class="empty-state mt-4">Устройства не найдены. Запустите discovery и проверьте подсеть.</div>
</section>
<section class="grid grid-cols-1 xl:grid-cols-[1.2fr,0.8fr] gap-6">
<div class="glass rounded-3xl p-6">
<div class="section-kicker">Новая группа</div>
<h2 class="text-xl font-black tracking-tight mb-5">Собрать комнату из найденных ламп</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-5">
<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.name || !newGroup.macs.length" class="accent-button disabled:opacity-30 disabled:cursor-not-allowed">СОЗДАТЬ ГРУППУ</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div v-for="device in sortedDevices" :key="device.id" class="surface-row flex-col items-stretch sm:flex-row sm:items-center">
<label class="flex items-center gap-3 cursor-pointer flex-1 min-w-0">
<input type="checkbox" :value="device.id" v-model="newGroup.macs" class="w-4 h-4 accent-orange-500 rounded">
<div class="min-w-0">
<div class="font-bold truncate">{{ device.name }}</div>
<div class="text-[11px] mono text-slate-500 truncate">{{ device.ip }} · {{ device.id }}</div>
</div>
</label>
<div class="flex items-center justify-between gap-2 shrink-0 sm:max-w-[50%]">
<span class="text-xs text-slate-500 truncate">{{ deviceLocationLabel(device) }}</span>
<button @click="blink(device.id)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-slate-300 hover:text-orange-300 transition-colors">BLINK</button>
</div>
</div>
</div>
</div>
<div class="glass rounded-3xl p-6">
<div class="section-kicker">Существующие группы</div>
<h2 class="text-xl font-black tracking-tight mb-5">Комнаты и их состав</h2>
<div class="space-y-4">
<article v-for="entry in sortedGroups" :key="entry[0]" class="surface-card">
<div class="flex items-start justify-between gap-4 mb-3">
<div>
<h3 class="font-black text-lg">{{ entry[1].name }}</h3>
<div class="text-[11px] mono text-slate-500 mt-1">{{ entry[0] }}</div>
</div>
<button @click="deleteGroup(entry[0])" class="glass px-3 py-2 rounded-xl text-xs font-bold text-red-300 hover:text-red-200 transition-colors">УДАЛИТЬ</button>
</div>
<div class="flex flex-wrap gap-2">
<span v-for="deviceName in getGroupDeviceNames(entry[1])" :key="deviceName" class="status-pill text-slate-300 border-slate-700/70 bg-black/20">{{ deviceName }}</span>
</div>
</article>
<div v-if="sortedGroups.length === 0" class="empty-state">Групп пока нет.</div>
</div>
</div>
</section>
</div>
<div v-if="tab === 'automation' && !isLoading && isAdmin" class="space-y-8 fade-up">
<section class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div class="glass rounded-3xl p-6">
<div class="section-kicker">One-shot</div>
<h2 class="text-xl font-black tracking-tight mb-5">Одноразовая задача</h2>
<div class="space-y-4">
<select v-model="onceForm.target_id" class="w-full 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="entry in sortedGroups" :key="entry[0]" :value="entry[0]">{{ entry[1].name }}</option>
</select>
<div class="grid grid-cols-2 gap-3">
<button @click="onceForm.state = true" :class="onceForm.state ? 'bg-orange-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-3 rounded-xl text-sm font-bold transition-all">Включить</button>
<button @click="onceForm.state = false" :class="!onceForm.state ? 'bg-slate-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-3 rounded-xl text-sm font-bold transition-all">Выключить</button>
</div>
<div class="grid grid-cols-2 gap-3">
<button @click="onceForm.mode = 'hours'" :class="onceForm.mode === 'hours' ? 'bg-orange-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-3 rounded-xl text-sm font-bold transition-all">Через N часов</button>
<button @click="onceForm.mode = 'datetime'" :class="onceForm.mode === 'datetime' ? 'bg-orange-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-3 rounded-xl text-sm font-bold transition-all">Точное время</button>
</div>
<div v-if="onceForm.mode === 'hours'">
<select v-model="onceForm.hours_from_now" class="w-full bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
<option :value="1">Через 1 час</option>
<option :value="2">Через 2 часа</option>
<option :value="4">Через 4 часа</option>
<option :value="8">Через 8 часов</option>
<option :value="12">Через 12 часов</option>
</select>
</div>
<div v-else>
<input v-model="onceForm.run_at" type="datetime-local" class="w-full bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
</div>
<button @click="createOnceTask" :disabled="!onceForm.target_id" class="accent-button disabled:opacity-30 disabled:cursor-not-allowed">СОЗДАТЬ ONE-SHOT</button>
</div>
</div>
<div class="glass rounded-3xl p-6">
<div class="section-kicker">Cron</div>
<h2 class="text-xl font-black tracking-tight mb-5">Повторяющееся расписание</h2>
<div class="space-y-4">
<select v-model="cronForm.target_id" class="w-full 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="entry in sortedGroups" :key="entry[0]" :value="entry[0]">{{ entry[1].name }}</option>
</select>
<div class="grid grid-cols-2 gap-3">
<button @click="cronForm.state = true" :class="cronForm.state ? 'bg-orange-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-3 rounded-xl text-sm font-bold transition-all">Включить</button>
<button @click="cronForm.state = false" :class="!cronForm.state ? 'bg-slate-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-3 rounded-xl text-sm font-bold transition-all">Выключить</button>
</div>
<div class="grid grid-cols-[1fr,24px,1fr] items-center gap-2">
<select v-model="cronHour" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm text-center">
<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="text-center text-slate-500 font-bold">:</span>
<select v-model="cronMinute" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm text-center">
<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="cronForm.day_of_week" class="w-full bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
<option value="*">Каждый день</option>
<option value="mon-fri">Будни</option>
<option value="sat,sun">Выходные</option>
<option value="mon">Понедельник</option>
<option value="tue">Вторник</option>
<option value="wed">Среда</option>
<option value="thu">Четверг</option>
<option value="fri">Пятница</option>
<option value="sat">Суббота</option>
<option value="sun">Воскресенье</option>
</select>
<button @click="createCronTask" :disabled="!cronForm.target_id" class="accent-button disabled:opacity-30 disabled:cursor-not-allowed">СОХРАНИТЬ CRON</button>
</div>
</div>
</section>
<section class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div class="glass rounded-3xl p-6">
<div class="section-kicker">Cron</div>
<h2 class="text-xl font-black tracking-tight mb-5">Повторяющиеся задачи</h2>
<div class="space-y-3">
<article v-for="task in cronTasks" :key="task.id" class="surface-card">
<div class="flex items-start justify-between gap-4">
<div>
<div class="flex items-center gap-2 mb-2">
<span class="status-pill text-orange-200 border-orange-500/30 bg-orange-500/10">{{ formatTaskTime(task) }}</span>
<span class="status-pill text-slate-300 border-slate-700/70 bg-black/20">{{ formatActionLabel(task.action_params) }}</span>
</div>
<div class="font-bold">{{ getGroupName(task.target_id) || task.target_id }}</div>
<div class="text-[11px] text-slate-500 mt-1">{{ formatTaskSubtitle(task) }}</div>
</div>
<button @click="deleteTask(task.id)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-red-300 hover:text-red-200 transition-colors">УДАЛИТЬ</button>
</div>
</article>
<div v-if="cronTasks.length === 0" class="empty-state">Cron-задач пока нет.</div>
</div>
</div>
<div class="glass rounded-3xl p-6">
<div class="section-kicker">One-shot</div>
<h2 class="text-xl font-black tracking-tight mb-5">Одноразовые задачи</h2>
<div class="space-y-3">
<article v-for="task in onceTasks" :key="task.id" class="surface-card">
<div class="flex items-start justify-between gap-4">
<div>
<div class="flex items-center gap-2 mb-2">
<span class="status-pill text-blue-200 border-blue-500/30 bg-blue-500/10">{{ formatTaskTime(task) }}</span>
<span class="status-pill text-slate-300 border-slate-700/70 bg-black/20">{{ formatActionLabel(task.action_params) }}</span>
</div>
<div class="font-bold">{{ getGroupName(task.target_id) || task.target_id }}</div>
<div class="text-[11px] text-slate-500 mt-1">{{ formatTaskSubtitle(task) }}</div>
</div>
<button @click="deleteTask(task.id)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-red-300 hover:text-red-200 transition-colors">УДАЛИТЬ</button>
</div>
</article>
<div v-if="onceTasks.length === 0" class="empty-state">Одноразовых задач пока нет.</div>
</div>
</div>
</section>
</div>
<div v-if="tab === 'access' && !isLoading && isAdmin" class="space-y-8 fade-up">
<section class="grid grid-cols-1 xl:grid-cols-[1.1fr,0.9fr] gap-6">
<div class="glass rounded-3xl p-6">
<div class="flex items-center justify-between gap-3 mb-5">
<div>
<div class="section-kicker">Аудит</div>
<h2 class="text-2xl font-black tracking-tight">Активность за период</h2>
</div>
<div class="flex items-center gap-2">
<button v-for="days in [1, 7, 30]" :key="days" @click="statsDays = days; fetchStats()"
:class="statsDays === days ? 'bg-orange-600 text-white' : 'glass text-slate-300 hover:text-white'"
class="px-3 py-2 rounded-xl text-xs font-bold transition-all">
{{ days === 1 ? 'Сегодня' : `${days}д` }}
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<article v-for="summary in statsData" :key="summary.target_id" class="surface-card">
<div class="flex items-start justify-between gap-3 mb-3">
<div>
<h3 class="font-black text-lg">{{ getGroupName(summary.target_id) || summary.target_id }}</h3>
<div class="text-[11px] text-slate-500 mt-1">{{ summary.total_commands }} команд · {{ summary.estimated_hours }} ч активности</div>
</div>
<span class="status-pill text-orange-200 border-orange-500/30 bg-orange-500/10">{{ summary.toggles_on }}/{{ summary.toggles_off }}</span>
</div>
<div class="flex flex-wrap gap-2">
<span v-for="(count, actor) in summary.by_user" :key="actor" class="status-pill mono text-slate-300 border-slate-700/70 bg-black/20">{{ actor }}: {{ count }}</span>
</div>
</article>
<div v-if="statsData.length === 0" class="empty-state md:col-span-2">Нет статистики за выбранный период.</div>
</div>
</div>
<section v-if="isMaster" class="glass rounded-3xl p-6">
<div class="section-kicker">Доступ</div>
<h2 class="text-2xl font-black tracking-tight mb-5">Гостевые и админ-ключи</h2>
<div class="grid grid-cols-1 gap-3 mb-5">
<input v-model="newKeyName" placeholder="Имя ключа (гости, кухня, wall-panel)" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm">
<label class="surface-row cursor-pointer">
<div class="flex items-center gap-3">
<input type="checkbox" v-model="newKeyAdmin" class="w-4 h-4 accent-orange-500 rounded">
<span class="text-sm text-slate-300">Выдать админ-права этому ключу</span>
</div>
</label>
<button @click="createApiKey" :disabled="!newKeyName.trim()" class="accent-button disabled:opacity-30 disabled:cursor-not-allowed">СОЗДАТЬ КЛЮЧ</button>
</div>
<div v-if="lastCreatedKey" class="bg-green-900/20 border border-green-500/30 p-4 rounded-2xl mb-5">
<div class="flex items-center justify-between gap-3 mb-2">
<span class="text-[11px] font-bold uppercase tracking-[0.18em] text-green-300">Скопируйте новый ключ сейчас</span>
<button @click="copyKey(lastCreatedKey)" class="text-xs font-bold text-green-300 hover:text-green-100 transition-colors">КОПИРОВАТЬ</button>
</div>
<div class="mono text-sm text-green-200 break-all select-all">{{ lastCreatedKey }}</div>
</div>
<div class="space-y-3">
<article v-for="item in apiKeys" :key="item.key" class="surface-card">
<div class="flex items-start justify-between gap-4">
<div>
<div class="flex flex-wrap items-center gap-2 mb-2">
<span class="font-black">{{ item.name }}</span>
<span class="status-pill" :class="item.is_admin ? 'text-orange-200 border-orange-500/30 bg-orange-500/10' : 'text-slate-300 border-slate-700/70 bg-black/20'">{{ item.is_admin ? 'админ' : 'гость' }}</span>
<span class="status-pill" :class="item.active ? 'text-green-200 border-green-500/30 bg-green-500/10' : 'text-red-200 border-red-500/30 bg-red-500/10'">{{ item.active ? 'активен' : 'отозван' }}</span>
</div>
<div class="mono text-[11px] text-slate-500">{{ item.display_key || item.key }}</div>
</div>
<button v-if="item.active" @click="revokeApiKey(item.key, item.name)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-red-300 hover:text-red-200 transition-colors">ОТОЗВАТЬ</button>
<button v-else @click="activateApiKey(item.key, item.name)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-green-300 hover:text-green-200 transition-colors">АКТИВИРОВАТЬ</button>
</div>
</article>
<div v-if="apiKeys.length === 0" class="empty-state">Ключей пока нет.</div>
</div>
</section>
</section>
<section class="glass rounded-3xl p-6">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-5">
<div>
<div class="section-kicker">Лог событий</div>
<h2 class="text-2xl font-black tracking-tight">Действия пользователей и автоматики</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 lg:min-w-[420px]">
<select v-model="logFilterActor" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
<option value="all">Все пользователи</option>
<option v-for="actor in availableLogActors" :key="actor" :value="actor">{{ actor }}</option>
</select>
<select v-model="logFilterAction" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
<option value="all">Все действия</option>
<option v-for="action in availableLogActions" :key="action" :value="action">{{ action }}</option>
</select>
</div>
</div>
<div class="space-y-3 max-h-[36rem] overflow-y-auto pr-1">
<article v-for="event in filteredEventLog" :key="event.id" class="surface-row">
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<span class="status-pill mono text-slate-300 border-slate-700/70 bg-black/20">{{ event.key_name }}</span>
<span class="font-bold">{{ event.action }}</span>
<span class="text-sm text-slate-400">{{ getGroupName(event.target_id) || event.target_id }}</span>
</div>
<div class="text-[11px] text-slate-500">{{ formatTime(event.timestamp) }} · {{ event.target_type }}</div>
</div>
<div class="text-[11px] text-slate-500 mono">{{ event.id }}</div>
</article>
<div v-if="filteredEventLog.length === 0" class="empty-state">Под выбранные фильтры событий нет.</div>
</div>
</section>
</div>
<div v-if="tab === 'server' && !isLoading" class="space-y-8 fade-up">
<section v-if="serverInfo" class="glass rounded-3xl p-6 md:p-7">
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
<div>
<div class="section-kicker">О сервере</div>
<h2 class="text-2xl font-black tracking-tight">{{ serverDisplayName() }}</h2>
<div class="text-sm text-slate-400 mt-2">{{ serverDisplaySubtitle() }}</div>
</div>
<div class="flex flex-wrap gap-2 text-[10px]">
<span class="status-pill text-green-200 border-green-500/30 bg-green-500/10">онлайн</span>
<span :class="isAdmin ? 'text-blue-200 border-blue-500/30 bg-blue-500/10' : 'text-slate-300 border-slate-700/70 bg-black/20'" class="status-pill">
{{ isAdmin ? 'админ-доступ' : 'гостевой доступ' }}
</span>
<span v-if="serverInfo.diagnostics_visible" class="status-pill text-orange-200 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="surface-card">
<div class="metric-label">Запущен</div>
<div class="text-lg font-bold">{{ formatRelativeUptime(serverInfo.uptime_seconds) }}</div>
<div class="text-[11px] text-slate-500 mt-2">{{ serverInfo.started_at ? formatServerTimestamp(serverInfo.started_at) : `аптайм ${formatUptime(serverInfo.uptime_seconds)}` }}</div>
</div>
<div class="surface-card">
<div class="metric-label">Discovery</div>
<div class="text-lg font-bold">{{ serverDiscovery?.last_scan_at ? formatRelativeTimestamp(serverDiscovery.last_scan_at) : 'ещё не выполнялся' }}</div>
<div class="text-[11px] text-slate-500 mt-2">{{ serverDiscovery?.last_scan_mode ? `режим: ${formatDiscoveryMode(serverDiscovery.last_scan_mode)}` : 'статус появится после первого скана' }}</div>
</div>
<div class="surface-card">
<div class="metric-label">Доступ</div>
<div class="text-lg font-bold">{{ roleLabel }}</div>
<div class="text-[11px] text-slate-500 mt-2">{{ isAdmin ? 'Служебные вкладки открыты в этом сеансе.' : 'Чувствительные параметры скрыты.' }}</div>
</div>
<div v-if="serverInfo.diagnostics_visible" class="surface-card">
<div class="metric-label">Версия</div>
<div class="text-lg font-bold">{{ serverInfo.build?.version ? `v${serverInfo.build.version}` : 'не определена' }}</div>
<div class="text-[11px] text-slate-500 mt-2">{{ serverInfo.timezone || 'таймзона не указана' }}</div>
</div>
</div>
</section>
<section v-if="serverInfo?.diagnostics_visible" class="glass rounded-3xl p-6 md:p-7">
<div class="section-kicker">Диагностика</div>
<h2 class="text-2xl font-black tracking-tight mb-5">Инстанс и развёртывание</h2>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<div class="surface-card">
<div class="metric-label">Сборка</div>
<div class="text-sm font-bold">{{ formatServerBuild(serverInfo.build) }}</div>
</div>
<div class="surface-card">
<div class="metric-label">Таймзона</div>
<div class="text-sm font-bold">{{ serverInfo.timezone || 'не указана' }}</div>
</div>
<div class="surface-card">
<div class="metric-label">Публичный URL</div>
<div class="text-xs mono break-all text-slate-300">{{ serverInfo.urls?.effective_public_base_url || 'не определён' }}</div>
</div>
<div class="surface-card">
<div class="metric-label">Наблюдаемый 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-200 border-green-500/30 bg-green-500/10' : 'text-red-200 border-red-500/30 bg-red-500/10'" class="status-pill">
{{ serverInfo.configuration?.configured ? 'настроен' : 'требует настройки' }}
</span>
<span :class="serverInfo.configuration?.public_base_url_configured ? 'text-blue-200 border-blue-500/30 bg-blue-500/10' : 'text-slate-300 border-slate-700/70 bg-black/20'" class="status-pill">
{{ serverInfo.configuration?.public_base_url_configured ? 'public url задан' : 'public url не задан' }}
</span>
<span :class="serverInfo.configuration?.scan_network_configured ? 'text-cyan-200 border-cyan-500/30 bg-cyan-500/10' : 'text-slate-300 border-slate-700/70 bg-black/20'" class="status-pill">
{{ serverInfo.configuration?.scan_network_configured ? 'scan network задан' : 'scan network auto' }}
</span>
<span :class="serverInfo.configuration?.build_metadata_complete ? 'text-orange-200 border-orange-500/30 bg-orange-500/10' : 'text-slate-300 border-slate-700/70 bg-black/20'" class="status-pill">
{{ serverInfo.configuration?.build_metadata_complete ? 'build metadata полная' : 'build metadata частичная' }}
</span>
</div>
</section>
</div>
</template>
<div class="fixed bottom-6 right-6 z-50 space-y-2">
<div v-for="toast in toasts" :key="toast.id" :class="toast.type === 'error' ? 'border-red-500/30 text-red-200' : toast.type === 'success' ? 'border-green-500/30 text-green-200' : 'border-slate-700 text-slate-200'" 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>