Add Web Dashboard

This commit is contained in:
Артём Кокос
2026-02-12 23:17:13 +07:00
parent 3c52fcf4ec
commit 87e03fdb26
2 changed files with 153 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ from contextlib import asynccontextmanager
from typing import Optional, List from typing import Optional, List
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from sqlalchemy import select from sqlalchemy import select
from app.core.discovery import DiscoveryService from app.core.discovery import DiscoveryService
@@ -173,6 +174,9 @@ async def control_group(
return {"status": "ok", "applied": params, "sent_to": ips} return {"status": "ok", "applied": params, "sent_to": ips}
# Монтируем папку static для фронтенда
app.mount("/", StaticFiles(directory="static", html=True), name="static")
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn

149
static/index.html Normal file
View File

@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ignis Control Center</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { background: #0f172a; color: #f8fafc; font-family: 'Inter', sans-serif; }
.card { background: #1e293b; border: 1px solid #334155; transition: transform 0.2s; }
.card:hover { transform: translateY(-2px); }
input[type="range"] { -webkit-appearance: none; height: 8px; border-radius: 4px; }
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; background: white; border-radius: 50%; cursor: pointer; border: 2px solid #f97316; }
.temp-gradient { background: linear-gradient(to right, #ffb366, #ffffff, #99ccff); }
</style>
</head>
<body>
<div id="app" class="p-4 md:p-8 max-w-5xl mx-auto">
<header class="mb-10 flex flex-col md:flex-row justify-between items-center gap-4">
<div class="flex items-center gap-3">
<span class="text-4xl">🔥</span>
<h1 class="text-3xl font-black text-orange-500 tracking-tighter uppercase">Ignis Core</h1>
</div>
<div class="flex items-center gap-6 bg-slate-800/50 px-6 py-3 rounded-2xl border border-slate-700">
<div class="text-center">
<div class="text-xs text-slate-400 uppercase font-bold">Устройств</div>
<div class="text-xl font-mono text-orange-400">{{ devicesCount }}</div>
</div>
<div class="h-8 w-[1px] bg-slate-700"></div>
<button @click="allOff" class="text-xs bg-red-500/10 hover:bg-red-500/20 text-red-400 px-4 py-2 rounded-lg border border-red-500/20 transition-all">
ВЫКЛЮЧИТЬ ВСЁ
</button>
</div>
</header>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<div v-for="(group, id) in groups" :key="id" class="card p-6 rounded-3xl shadow-2xl relative overflow-hidden">
<div class="flex justify-between items-center mb-8">
<div>
<h2 class="text-2xl font-bold tracking-tight">{{ group.name }}</h2>
<span class="text-[10px] text-slate-500 font-mono">{{ id }}</span>
</div>
<div class="flex gap-2">
<button @click="toggleGroup(id, true)" class="bg-orange-600 hover:bg-orange-500 text-white px-5 py-2 rounded-xl text-sm font-bold transition-colors shadow-lg shadow-orange-900/20">ВКЛ</button>
<button @click="toggleGroup(id, false)" class="bg-slate-700 hover:bg-slate-600 text-white px-5 py-2 rounded-xl text-sm font-bold transition-colors">ВЫКЛ</button>
</div>
</div>
<div class="space-y-8">
<div>
<div class="flex justify-between mb-2">
<label class="text-xs font-black text-slate-400 uppercase tracking-widest">Яркость</label>
<span class="text-xs font-mono text-orange-500">{{ group.brightness || 100 }}%</span>
</div>
<input type="range" min="10" max="100" class="w-full bg-slate-800 accent-orange-500"
v-model="group.brightness"
@change="setBrightness(id, $event.target.value)">
</div>
<div>
<div class="flex justify-between mb-2">
<label class="text-xs font-black text-slate-400 uppercase tracking-widest">Температура</label>
<span class="text-xs font-mono text-blue-400">{{ group.temp || 3000 }}K</span>
</div>
<input type="range" min="2700" max="6500" step="100" class="w-full temp-gradient accent-white"
v-model="group.temp"
@change="setTemp(id, $event.target.value)">
</div>
<div class="grid grid-cols-2 gap-4 items-end">
<div class="w-full">
<label class="text-xs font-black text-slate-400 uppercase tracking-widest mb-2 block">Цвет RGB</label>
<input type="color" class="w-full h-12 bg-transparent border-2 border-slate-700 rounded-xl cursor-pointer"
@input="setColor(id, $event.target.value)">
</div>
<div class="flex flex-wrap gap-2">
<button v-for="s in ['ocean', 'fireplace', 'party', 'steampunk']"
@click="setScene(id, s)"
class="flex-1 text-[9px] font-bold uppercase py-2 px-1 rounded-lg border border-slate-700 hover:bg-slate-800 transition-all">
{{ s }}
</button>
</div>
</div>
</div>
</div>
</div>
<div v-if="Object.keys(groups).length === 0" class="text-center py-20 bg-slate-800/20 rounded-3xl border-2 border-dashed border-slate-700">
<p class="text-slate-500 uppercase tracking-widest font-bold">Группы не найдены в базе</p>
</div>
</div>
<script>
const { createApp } = Vue
createApp({
data() {
return {
groups: {},
devicesCount: 0
}
},
methods: {
async fetchData() {
try {
const gResp = await fetch('/groups');
const gData = await gResp.json();
// Сохраняем значения ползунков, чтобы они не прыгали при обновлении
for (let id in gData) {
if (this.groups[id]) {
gData[id].brightness = this.groups[id].brightness;
gData[id].temp = this.groups[id].temp;
}
}
this.groups = gData;
const dResp = await fetch('/devices');
const dData = await dResp.json();
this.devicesCount = Object.keys(dData).length;
} catch (e) { console.error("Ошибка обновления данных", e); }
},
async control(id, params) {
const query = new URLSearchParams(params).toString();
await fetch(`/control/group/${id}?${query}`, { method: 'POST' });
},
toggleGroup(id, state) { this.control(id, { state }); },
setBrightness(id, val) { this.control(id, { brightness: val }); },
setTemp(id, val) { this.control(id, { temp: val }); },
setScene(id, scene) { this.control(id, { scene }); },
setColor(id, hex) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
this.control(id, { r, g, b });
},
async allOff() {
for (let id in this.groups) {
await this.toggleGroup(id, false);
}
}
},
mounted() {
this.fetchData();
setInterval(this.fetchData, 10000); // Раз в 10 сек для статуса ламп
}
}).mount('#app')
</script>
</body>
</html>