Harden UI security and add deployment templates
This commit is contained in:
@@ -5,35 +5,11 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>IGNIS | Smart Control</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔥</text></svg>">
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700;800&family=Outfit:wght@300;400;600;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root { --bg-deep: #08090c; --bg-card: rgba(20,22,30,0.8); --border-subtle: rgba(255,255,255,0.06); --border-hover: rgba(249,115,22,0.4); --accent: #f97316; --accent-glow: rgba(249,115,22,0.15); }
|
||||
body { background: var(--bg-deep); color: #e2e8f0; font-family: 'Outfit', sans-serif; min-height: 100vh; }
|
||||
body::before { content: ''; position: fixed; inset: 0; z-index: -1; background: radial-gradient(ellipse at 20% 0%, rgba(249,115,22,0.06) 0%, transparent 60%), radial-gradient(ellipse at 80% 100%, rgba(30,27,75,0.15) 0%, transparent 60%); }
|
||||
.glass { background: var(--bg-card); backdrop-filter: blur(16px); border: 1px solid var(--border-subtle); }
|
||||
.glass:hover { border-color: var(--border-hover); }
|
||||
.active-tab { background: var(--accent); box-shadow: 0 0 24px var(--accent-glow), inset 0 1px 0 rgba(255,255,255,0.15); }
|
||||
input[type="range"] { -webkit-appearance: none; height: 6px; border-radius: 10px; background: #1e293b; outline: none; transition: opacity 0.2s; }
|
||||
input[type="range"]:disabled { opacity: 0.25; cursor: not-allowed; }
|
||||
input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; background: #fff; border-radius: 50%; cursor: pointer; border: 3px solid var(--accent); box-shadow: 0 0 8px rgba(0,0,0,0.4); transition: transform 0.15s; }
|
||||
input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.15); }
|
||||
input[type="range"]:disabled::-webkit-slider-thumb { border-color: #475569; cursor: not-allowed; transform: none; }
|
||||
.temp-gradient { background: linear-gradient(to right, #ffcc66, #ffffff, #99ccff) !important; }
|
||||
select { appearance: none; background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'/%3e%3c/svg%3e"); background-repeat: no-repeat; background-position: right 0.75rem center; background-size: 1em; }
|
||||
.mono { font-family: 'JetBrains Mono', monospace; }
|
||||
@keyframes fadeUp { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: none; } }
|
||||
.fade-up { animation: fadeUp 0.4s ease-out both; }
|
||||
.toast-enter { animation: fadeUp 0.3s ease-out; }
|
||||
@keyframes pulse-dot { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
.pulse-on { animation: pulse-dot 2s ease-in-out infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner { animation: spin 0.8s linear infinite; }
|
||||
</style>
|
||||
<script src="/static/vendor/tailwindcdn.js"></script>
|
||||
<link rel="stylesheet" href="/static/ui.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" class="max-w-6xl mx-auto p-4 md:p-8">
|
||||
<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">
|
||||
@@ -44,7 +20,13 @@
|
||||
<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>
|
||||
@@ -85,7 +67,7 @@
|
||||
<p v-else class="text-slate-500">Нет доступных групп</p>
|
||||
</div>
|
||||
|
||||
<div v-for="(group, id) in groups" :key="id" class="glass p-6 rounded-2xl transition-all fade-up" :style="sliders[id]?.state ? 'border-color: rgba(249,115,22,0.2)' : ''">
|
||||
<div 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">
|
||||
@@ -353,186 +335,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const { createApp } = Vue
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
apiKey: localStorage.getItem('ignis_key') || '',
|
||||
tempKey: '',
|
||||
tab: 'control',
|
||||
isAdmin: false,
|
||||
isMaster: false,
|
||||
authName: '',
|
||||
groups: {}, devices: [], sliders: {},
|
||||
newGroup: { id: '', name: '', macs: [] },
|
||||
isLoading: false, isLoadingStatus: false, isFetching: false, isRescanning: false,
|
||||
taskHour: '22', taskMin: '00',
|
||||
newTask: { target_id: '', state: true },
|
||||
tasks: [], allScenes: {},
|
||||
toasts: [], toastCounter: 0,
|
||||
apiKeys: [], newKeyName: '', newKeyAdmin: false, lastCreatedKey: '',
|
||||
statsData: [], eventLog: [], statsDays: 7,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
saveKey() {
|
||||
if (this.tempKey) { this.apiKey = this.tempKey; localStorage.setItem('ignis_key', this.tempKey); this.initApp(); }
|
||||
},
|
||||
logout() { this.apiKey = ''; this.isAdmin = false; this.isMaster = false; this.authName = ''; localStorage.removeItem('ignis_key'); location.reload(); },
|
||||
toast(text, type = 'info', duration = 3000) {
|
||||
const id = ++this.toastCounter; this.toasts.push({ id, text, type });
|
||||
setTimeout(() => { this.toasts = this.toasts.filter(t => t.id !== id); }, duration);
|
||||
},
|
||||
getGroupName(tid) { const g = this.groups[tid]; return g ? g.name : null; },
|
||||
|
||||
async request(path, { method = 'GET', query = null, body = null } = {}) {
|
||||
let url = path;
|
||||
if (query) url += `?${new URLSearchParams(query).toString()}`;
|
||||
try {
|
||||
const r = await fetch(url, { method, headers: { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : null });
|
||||
if (r.status === 403) {
|
||||
const err = await r.json().catch(() => ({}));
|
||||
if (err.detail === 'Недостаточно прав' || err.detail === 'Требуется мастер-ключ') { this.toast('Нет прав', 'error'); return null; }
|
||||
this.toast('Неверный API-ключ', 'error'); this.logout(); return null;
|
||||
}
|
||||
if (!r.ok) { const err = await r.json().catch(() => ({})); this.toast(err.detail || `Ошибка ${r.status}`, 'error'); return null; }
|
||||
return await r.json();
|
||||
} catch (e) { this.toast('Сервер недоступен', 'error'); return null; }
|
||||
},
|
||||
|
||||
async initApp() {
|
||||
this.isLoading = true;
|
||||
const auth = await this.request('/auth/me');
|
||||
if (!auth) { this.isLoading = false; return; }
|
||||
this.isAdmin = auth.is_admin;
|
||||
this.isMaster = !!auth.is_master;
|
||||
this.authName = auth.name;
|
||||
await this.fetchData();
|
||||
this.isLoading = false;
|
||||
setInterval(() => this.fetchData(), 15000);
|
||||
},
|
||||
|
||||
async fetchData() {
|
||||
if (!this.apiKey || this.isFetching) return;
|
||||
this.isFetching = true;
|
||||
try {
|
||||
const [gData, dData, sData] = await Promise.all([
|
||||
this.request('/devices/groups'), this.request('/devices'), this.request('/devices/scenes'),
|
||||
]);
|
||||
if (gData) {
|
||||
this.groups = gData;
|
||||
Object.keys(this.groups).forEach(id => { if (!this.sliders[id]) this.sliders[id] = { brightness: 100, temp: 4000, state: false }; });
|
||||
await this.syncGroupStatuses();
|
||||
}
|
||||
if (dData) this.devices = Array.isArray(dData) ? dData : Object.values(dData);
|
||||
if (sData) this.allScenes = sData;
|
||||
if (this.isAdmin) this.fetchTasks();
|
||||
if (this.isMaster) this.fetchApiKeys();
|
||||
} finally { this.isFetching = false; }
|
||||
},
|
||||
|
||||
async control(id, params) { await this.request(`/control/group/${id}`, { method: 'POST', body: params }); },
|
||||
toggleGroup(id, state) { if (this.sliders[id]) this.sliders[id].state = state; this.control(id, { state }); },
|
||||
setBrightness(id, val) { if (this.sliders[id]) this.sliders[id].brightness = val; this.control(id, { brightness: val }); },
|
||||
setTemp(id, val) { if (this.sliders[id]) this.sliders[id].temp = val; this.control(id, { temp: val }); },
|
||||
setScene(id, scene) { this.control(id, { scene }); },
|
||||
setColor(id, hex) { this.control(id, { r: parseInt(hex.slice(1,3),16), g: parseInt(hex.slice(3,5),16), b: parseInt(hex.slice(5,7),16) }); },
|
||||
|
||||
async createGroup() {
|
||||
const res = await this.request('/devices/groups', { method: 'POST', body: { id: this.newGroup.id, name: this.newGroup.name, macs: this.newGroup.macs } });
|
||||
if (res) { this.toast(`Группа "${this.newGroup.name}" создана`, 'success'); this.newGroup = { id: '', name: '', macs: [] }; await this.fetchData(); this.tab = 'control'; }
|
||||
},
|
||||
async deleteGroup(id) {
|
||||
const name = this.groups[id]?.name || id;
|
||||
if (confirm(`Удалить группу "${name}"?`)) { await this.request(`/devices/groups/${id}`, { method: 'DELETE' }); this.toast(`Удалена`, 'success'); await this.fetchData(); }
|
||||
},
|
||||
async rescan() {
|
||||
this.isRescanning = true; await this.request('/devices/rescan', { method: 'POST' }); this.toast('Сканирование...', 'info');
|
||||
setTimeout(async () => { await this.fetchData(); this.isRescanning = false; this.toast(`Найдено ${this.devices.length} устройств`, 'success'); }, 3000);
|
||||
},
|
||||
async blink(id) { await this.request(`/control/device/${id}/blink`, { method: 'POST' }); },
|
||||
|
||||
async syncGroupStatuses() {
|
||||
if (this.isLoadingStatus) return; this.isLoadingStatus = true;
|
||||
try {
|
||||
const ids = Object.keys(this.groups);
|
||||
const results = await Promise.all(ids.map(id => this.request(`/control/group/${id}/status`)));
|
||||
ids.forEach((id, i) => {
|
||||
const d = results[i];
|
||||
if (d?.results?.length > 0) {
|
||||
const v = d.results.find(r => r.status && !r.error);
|
||||
if (v) this.sliders[id] = { brightness: v.status.dimming || 100, temp: v.status.temp || 4000, state: v.status.state || false };
|
||||
}
|
||||
});
|
||||
} finally { this.isLoadingStatus = false; }
|
||||
},
|
||||
|
||||
async fetchTasks() { const d = await this.request('/schedules/tasks'); if (d) this.tasks = d.tasks || []; },
|
||||
async addSchedule() {
|
||||
if (!this.newTask.target_id) { this.toast('Выберите группу', 'error'); return; }
|
||||
const res = await this.request('/schedules/cron', { method: 'POST', body: { target_id: this.newTask.target_id, hour: this.taskHour, minute: this.taskMin, is_group: true, state: this.newTask.state } });
|
||||
if (res) { this.toast(`${this.taskHour}:${this.taskMin} добавлено`, 'success'); this.fetchTasks(); }
|
||||
},
|
||||
async deleteTask(id) { await this.request(`/schedules/${id}`, { method: 'DELETE' }); this.toast('Отменено', 'success'); this.fetchTasks(); },
|
||||
async setTimer4h(id) {
|
||||
await this.toggleGroup(id, true);
|
||||
const res = await this.request('/schedules/once', { method: 'POST', body: { target_id: id, hours_from_now: 4, is_group: true, state: false } });
|
||||
if (res) { this.toast('Таймер 4ч', 'success'); this.fetchTasks(); }
|
||||
},
|
||||
|
||||
// ─── API-ключи ───────────────────────────────
|
||||
async fetchApiKeys() {
|
||||
const data = await this.request('/api-keys');
|
||||
if (data) this.apiKeys = data;
|
||||
},
|
||||
async createApiKey() {
|
||||
const name = this.newKeyName.trim();
|
||||
if (!name) return;
|
||||
const res = await this.request('/api-keys', { method: 'POST', query: { name, is_admin: this.newKeyAdmin } });
|
||||
if (res) {
|
||||
this.lastCreatedKey = res.key;
|
||||
this.newKeyName = '';
|
||||
this.newKeyAdmin = false;
|
||||
this.toast(`Ключ "${name}" создан`, 'success');
|
||||
this.fetchApiKeys();
|
||||
}
|
||||
},
|
||||
async revokeApiKey(key, name) {
|
||||
if (confirm(`Отозвать ключ "${name}"?`)) {
|
||||
await this.request('/api-keys/revoke', { method: 'POST', body: { key } });
|
||||
this.toast(`Ключ "${name}" отозван`, 'success');
|
||||
this.fetchApiKeys();
|
||||
}
|
||||
},
|
||||
async activateApiKey(key, name) {
|
||||
await this.request('/api-keys/activate', { method: 'POST', body: { key } });
|
||||
this.toast(`Ключ "${name}" активирован`, 'success');
|
||||
this.fetchApiKeys();
|
||||
},
|
||||
copyKey(key) {
|
||||
navigator.clipboard.writeText(key).then(() => this.toast('Скопировано', 'success'));
|
||||
},
|
||||
|
||||
// ─── Статистика ──────────────────────────────
|
||||
async fetchStats() {
|
||||
const data = await this.request(`/stats/summary`, { query: { days: this.statsDays } });
|
||||
if (data) this.statsData = data.groups || [];
|
||||
await this.fetchEventLog();
|
||||
},
|
||||
async fetchEventLog() {
|
||||
const data = await this.request('/stats/log', { query: { limit: 100 } });
|
||||
if (data) this.eventLog = data;
|
||||
},
|
||||
formatTime(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${pad(d.getDate())}.${pad(d.getMonth()+1)} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
},
|
||||
},
|
||||
async mounted() { if (this.apiKey) await this.initApp(); }
|
||||
}).mount('#app')
|
||||
</script>
|
||||
<script src="/static/vendor/vue.global.prod.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user