602 lines
49 KiB
HTML
602 lines
49 KiB
HTML
<!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.name" @input="handleNewGroupNameInput" placeholder="Название (Спальня)" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm">
|
||
<input v-model="newGroup.id" @input="handleNewGroupIdInput" 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">
|
||
<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>
|