const SESSION_KEY_NAME = "ignis_session_key"; function summaryInt(summary, key) { const value = summary?.[key]; if (typeof value === "number") { return value; } const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : 0; } function formatRescanSummary(summary) { const found = summaryInt(summary, "found"); const added = summaryInt(summary, "added"); const updated = summaryInt(summary, "updated"); const removed = summaryInt(summary, "removed_offline"); if (added === 0 && updated === 0 && removed === 0 && found === 0) { return "Сканирование завершено: устройства не найдены"; } if (added === 0 && removed === 0) { return `Сканирование завершено: найдено ${found}, обновлено ${updated}`; } return `Сканирование завершено: найдено ${found}, новых ${added}, обновлено ${updated}, убрано ${removed}`; } const { createApp } = Vue; createApp({ data() { const sessionApiKey = sessionStorage.getItem(SESSION_KEY_NAME) || ""; return { apiKey: sessionApiKey, tempKey: "", rememberInSession: sessionApiKey.length > 0, tab: "control", isAdmin: false, isMaster: false, authName: "", serverInfo: null, 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, refreshTimerId: null, }; }, methods: { saveKey() { const key = this.tempKey.trim(); if (!key) { return; } this.apiKey = key; this.tempKey = ""; if (this.rememberInSession) { sessionStorage.setItem(SESSION_KEY_NAME, key); } else { sessionStorage.removeItem(SESSION_KEY_NAME); } this.initApp(); }, resetAuthState() { this.apiKey = ""; this.tempKey = ""; this.isAdmin = false; this.isMaster = false; this.authName = ""; this.serverInfo = null; this.groups = {}; this.devices = []; this.sliders = {}; this.tasks = []; this.apiKeys = []; this.statsData = []; this.eventLog = []; this.lastCreatedKey = ""; if (this.refreshTimerId !== null) { clearInterval(this.refreshTimerId); this.refreshTimerId = null; } sessionStorage.removeItem(SESSION_KEY_NAME); }, logout() { this.resetAuthState(); location.reload(); }, toast(text, type = "info", duration = 3000) { const id = ++this.toastCounter; this.toasts.push({ id, text, type }); setTimeout(() => { this.toasts = this.toasts.filter((toast) => toast.id !== id); }, duration); }, getGroupName(targetId) { const group = this.groups[targetId]; return group ? group.name : null; }, serverDisplayName() { if (this.serverInfo?.instance_name) { return this.serverInfo.instance_name; } return this.serverInfo?.app_name || "Ignis Core"; }, serverDisplaySubtitle() { if (this.serverInfo?.instance_name) { return this.serverInfo.app_name || "Ignis Core"; } if (this.serverInfo?.diagnostics_visible) { return "Подключение активно и готово к управлению."; } return "Подключение активно. Операционная диагностика скрыта для гостевого доступа."; }, shortSha(value) { if (!value) { return null; } return value.length <= 7 ? value : value.slice(0, 7); }, formatServerTimestamp(iso) { if (!iso) { return "unknown"; } const parsed = new Date(iso); if (Number.isNaN(parsed.getTime())) { return iso; } const pad = (value) => String(value).padStart(2, "0"); return `${parsed.getUTCFullYear()}-${pad(parsed.getUTCMonth() + 1)}-${pad(parsed.getUTCDate())} ${pad(parsed.getUTCHours())}:${pad(parsed.getUTCMinutes())} UTC`; }, formatServerBuild(build) { if (!build) { return "build info unavailable"; } const parts = [`v${build.version}`]; const shortSha = this.shortSha(build.git_sha); if (shortSha) { parts.push(shortSha); } if (build.build_date) { parts.push(this.formatServerTimestamp(build.build_date)); } return parts.join(" · "); }, formatDuration(totalSeconds, maxParts = 2) { const seconds = Math.max(Number(totalSeconds || 0), 0); if (seconds < 60) { return "меньше минуты"; } const units = [ { size: 86400, forms: ["день", "дня", "дней"] }, { size: 3600, forms: ["час", "часа", "часов"] }, { size: 60, forms: ["минута", "минуты", "минут"] }, ]; let remaining = seconds; const parts = []; units.forEach((unit) => { if (parts.length >= maxParts) { return; } const value = Math.floor(remaining / unit.size); if (value <= 0) { return; } parts.push(`${value} ${this.pluralRu(value, ...unit.forms)}`); remaining -= value * unit.size; }); return parts.join(" "); }, formatUptime(totalSeconds) { return this.formatDuration(totalSeconds, 2); }, formatRelativeUptime(totalSeconds) { const seconds = Math.max(Number(totalSeconds || 0), 0); if (seconds < 60) { return "только что"; } return `${this.formatDuration(seconds, 2)} назад`; }, pluralRu(count, one, few, many) { const mod10 = count % 10; const mod100 = count % 100; if (mod10 === 1 && mod100 !== 11) { return one; } if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) { return few; } return many; }, async request(path, { method = "GET", query = null, body = null } = {}) { let url = path; if (query) { url += `?${new URLSearchParams(query).toString()}`; } try { const response = await fetch(url, { method, cache: "no-store", credentials: "same-origin", referrerPolicy: "no-referrer", headers: { "X-API-Key": this.apiKey, "Content-Type": "application/json", }, body: body ? JSON.stringify(body) : null, }); if (response.status === 403) { const err = await response.json().catch(() => ({})); if ( err.detail === "Недостаточно прав" || err.detail === "Требуется мастер-ключ" ) { this.toast("Нет прав", "error"); return null; } this.toast("Неверный API-ключ", "error"); this.logout(); return null; } if (!response.ok) { const err = await response.json().catch(() => ({})); this.toast(err.detail || `Ошибка ${response.status}`, "error"); return null; } return await response.json(); } catch (_) { 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 = Boolean(auth.is_master); this.authName = auth.name; await this.fetchData(); this.isLoading = false; if (this.refreshTimerId === null) { this.refreshTimerId = setInterval(() => this.fetchData(), 15000); } }, async fetchData() { if (!this.apiKey || this.isFetching) { return; } this.isFetching = true; try { const [serverInfoData, groupsData, devicesData, scenesData] = await Promise.all([ this.request("/system/info"), this.request("/devices/groups"), this.request("/devices"), this.request("/devices/scenes"), ]); if (serverInfoData) { this.serverInfo = serverInfoData; } if (groupsData) { this.groups = groupsData; Object.keys(this.groups).forEach((id) => { if (!this.sliders[id]) { this.sliders[id] = { brightness: 100, temp: 4000, state: false }; } }); await this.syncGroupStatuses(); } if (devicesData) { this.devices = Array.isArray(devicesData) ? devicesData : Object.values(devicesData); } if (scenesData) { this.allScenes = scenesData; } if (this.isAdmin) { await this.fetchTasks(); } if (this.isMaster) { await 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, value) { if (this.sliders[id]) { this.sliders[id].brightness = value; } this.control(id, { brightness: value }); }, setTemp(id, value) { if (this.sliders[id]) { this.sliders[id].temp = value; } this.control(id, { temp: value }); }, setScene(id, scene) { this.control(id, { scene }); }, setColor(id, hex) { this.control(id, { r: Number.parseInt(hex.slice(1, 3), 16), g: Number.parseInt(hex.slice(3, 5), 16), b: Number.parseInt(hex.slice(5, 7), 16), }); }, async createGroup() { const response = await this.request("/devices/groups", { method: "POST", body: { id: this.newGroup.id, name: this.newGroup.name, macs: this.newGroup.macs, }, }); if (!response) { return; } 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}"?`)) { return; } const response = await this.request(`/devices/groups/${id}`, { method: "DELETE", }); if (!response) { return; } this.toast("Удалена", "success"); await this.fetchData(); }, async rescan() { this.isRescanning = true; try { const response = await this.request("/devices/rescan", { method: "POST" }); if (!response) { return; } await this.fetchData(); this.toast(formatRescanSummary(response), "success"); } finally { this.isRescanning = false; } }, 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, index) => { const data = results[index]; if (!data?.results?.length) { return; } const validResult = data.results.find((result) => result.status && !result.error); if (validResult) { this.sliders[id] = { brightness: validResult.status.dimming || 100, temp: validResult.status.temp || 4000, state: validResult.status.state || false, }; } }); } finally { this.isLoadingStatus = false; } }, async fetchTasks() { const data = await this.request("/schedules/tasks"); if (data) { this.tasks = data.tasks || []; } }, async addSchedule() { if (!this.newTask.target_id) { this.toast("Выберите группу", "error"); return; } const response = 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 (response) { this.toast(`${this.taskHour}:${this.taskMin} добавлено`, "success"); await this.fetchTasks(); } }, async deleteTask(id) { const response = await this.request(`/schedules/${id}`, { method: "DELETE", }); if (response) { this.toast("Отменено", "success"); await this.fetchTasks(); } }, async setTimer4h(id) { this.toggleGroup(id, true); const response = await this.request("/schedules/once", { method: "POST", body: { target_id: id, hours_from_now: 4, is_group: true, state: false, }, }); if (response) { this.toast("Таймер 4ч", "success"); await this.fetchTasks(); } }, 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 response = await this.request("/api-keys", { method: "POST", query: { name, is_admin: this.newKeyAdmin }, }); if (!response) { return; } this.lastCreatedKey = response.key; this.newKeyName = ""; this.newKeyAdmin = false; this.toast(`Ключ "${name}" создан`, "success"); await this.fetchApiKeys(); }, async revokeApiKey(key, name) { if (!confirm(`Отозвать ключ "${name}"?`)) { return; } const response = await this.request("/api-keys/revoke", { method: "POST", body: { key }, }); if (response) { this.toast(`Ключ "${name}" отозван`, "success"); await this.fetchApiKeys(); } }, async activateApiKey(key, name) { const response = await this.request("/api-keys/activate", { method: "POST", body: { key }, }); if (response) { this.toast(`Ключ "${name}" активирован`, "success"); await this.fetchApiKeys(); } }, async copyKey(key) { try { await navigator.clipboard.writeText(key); this.toast("Скопировано", "success"); } catch (_) { this.toast("Не удалось скопировать ключ", "error"); } }, 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 date = new Date(iso); const pad = (value) => String(value).padStart(2, "0"); return `${pad(date.getDate())}.${pad(date.getMonth() + 1)} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; }, }, async mounted() { if (this.apiKey) { await this.initApp(); } }, beforeUnmount() { if (this.refreshTimerId !== null) { clearInterval(this.refreshTimerId); } }, }).mount("#app");