const SESSION_KEY_NAME = "ignis_session_key"; const DEFAULT_STATS_DAYS = 7; const GROUP_ID_SANITIZE_PATTERN = /[^a-z0-9_-]+/g; const GROUP_ID_DASH_COLLAPSE_PATTERN = /[-_]{2,}/g; const GROUP_ID_EDGE_TRIM_PATTERN = /^[-_]+|[-_]+$/g; const GROUP_ID_TRANSLITERATION_MAP = { а: "a", б: "b", в: "v", г: "g", д: "d", е: "e", ё: "e", ж: "zh", з: "z", и: "i", й: "y", к: "k", л: "l", м: "m", н: "n", о: "o", п: "p", р: "r", с: "s", т: "t", у: "u", ф: "f", х: "h", ц: "ts", ч: "ch", ш: "sh", щ: "sch", ъ: "", ы: "y", ь: "", э: "e", ю: "yu", я: "ya", }; 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}`; } function compareText(left, right) { return String(left || "").localeCompare(String(right || ""), "ru", { sensitivity: "base", }); } 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: [] }, isSyncingNewGroupId: false, isNewGroupIdEditedManually: false, isLoading: false, isLoadingStatus: false, isFetching: false, isRescanning: false, cronForm: { target_id: "", state: true, day_of_week: "*" }, cronHour: "22", cronMinute: "00", onceForm: { target_id: "", state: false, mode: "hours", hours_from_now: 4, run_at: "", }, tasks: [], allScenes: {}, toasts: [], toastCounter: 0, apiKeys: [], newKeyName: "", newKeyAdmin: false, lastCreatedKey: "", statsData: [], eventLog: [], statsDays: DEFAULT_STATS_DAYS, logFilterAction: "all", logFilterActor: "all", refreshTimerId: null, }; }, computed: { roleLabel() { if (this.isMaster) { return "Мастер"; } if (this.isAdmin) { return "Администратор"; } return "Гость"; }, visibleTabs() { const tabs = [ { id: "control", label: "ПУЛЬТ" }, { id: "devices", label: "УСТРОЙСТВА", adminOnly: true }, { id: "automation", label: "АВТОМАТИЗАЦИЯ", adminOnly: true }, { id: "server", label: "СЕРВЕР" }, { id: "access", label: "ДОСТУП", adminOnly: true }, ]; return tabs.filter((tab) => !tab.adminOnly || this.isAdmin); }, sortedGroups() { return Object.entries(this.groups).sort((left, right) => { const [, leftGroup] = left; const [, rightGroup] = right; const byName = compareText(leftGroup?.name, rightGroup?.name); if (byName !== 0) { return byName; } return compareText(left[0], right[0]); }); }, deviceGroupNamesMap() { const map = {}; this.sortedGroups.forEach(([groupId, group]) => { const groupName = group?.name || groupId; (group?.device_ids || []).forEach((deviceId) => { if (!map[deviceId]) { map[deviceId] = []; } map[deviceId].push(groupName); }); }); return map; }, sortedDevices() { return [...this.devices].sort((left, right) => { const byRoom = compareText( this.deviceLocationLabel(left), this.deviceLocationLabel(right), ); if (byRoom !== 0) { return byRoom; } const byName = compareText(left.name, right.name); if (byName !== 0) { return byName; } return compareText(left.id, right.id); }); }, deviceMap() { const map = {}; this.devices.forEach((device) => { map[device.id] = device; }); return map; }, groupCount() { return this.sortedGroups.length; }, onlineDeviceCount() { return this.devices.length; }, scheduleCount() { return this.tasks.length; }, cronTasks() { return this.tasks.filter((task) => task.trigger_type === "cron"); }, onceTasks() { return this.tasks.filter((task) => task.trigger_type === "once"); }, sceneOptions() { return Object.keys(this.allScenes).sort((left, right) => compareText(left, right)); }, featuredScenes() { const preferred = [ "Warm White", "Daylight", "Cozy", "Night Light", "TV Time", "Ocean", ]; const selected = preferred.filter((scene) => this.sceneOptions.includes(scene)); if (selected.length >= 4) { return selected.slice(0, 4); } const fallback = this.sceneOptions.filter((scene) => !selected.includes(scene)); return [...selected, ...fallback].slice(0, 4); }, serverDiscovery() { return this.serverInfo?.discovery || null; }, availableLogActors() { return [...new Set(this.eventLog.map((event) => event.key_name).filter(Boolean))].sort( (left, right) => compareText(left, right), ); }, availableLogActions() { return [...new Set(this.eventLog.map((event) => event.action).filter(Boolean))].sort( (left, right) => compareText(left, right), ); }, filteredEventLog() { return this.eventLog.filter((event) => { if (this.logFilterActor !== "all" && event.key_name !== this.logFilterActor) { return false; } if (this.logFilterAction !== "all" && event.action !== this.logFilterAction) { return false; } return true; }); }, }, 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 = ""; this.logFilterAction = "all"; this.logFilterActor = "all"; 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); }, openTab(tabId) { this.tab = tabId; if (tabId === "access" && this.isAdmin) { this.fetchAuditData(); } }, ensureValidTab() { const allowedTabs = new Set(this.visibleTabs.map((tab) => tab.id)); if (!allowedTabs.has(this.tab)) { this.tab = "control"; } }, getGroupName(targetId) { const group = this.groups[targetId]; return group ? group.name : null; }, getGroupDeviceCount(group) { return Array.isArray(group?.device_ids) ? group.device_ids.length : 0; }, getGroupOnlineCount(group) { if (!Array.isArray(group?.device_ids)) { return 0; } return group.device_ids.filter((deviceId) => Boolean(this.deviceMap[deviceId])).length; }, getGroupDeviceNames(group) { if (!Array.isArray(group?.device_ids)) { return []; } return group.device_ids.map((deviceId) => this.deviceMap[deviceId]?.name || deviceId); }, deviceGroupNames(device) { return this.deviceGroupNamesMap[device?.id] || []; }, formatRoomName(room) { if (!room || room === "Default") { return "Без комнаты"; } return room; }, slugifyGroupId(input) { const lower = String(input || "").trim().toLowerCase(); let value = ""; for (const char of lower) { value += GROUP_ID_TRANSLITERATION_MAP[char] ?? char; } value = value .replace(/\s+/g, "-") .replace(GROUP_ID_SANITIZE_PATTERN, "-") .replace(GROUP_ID_DASH_COLLAPSE_PATTERN, "-") .replace(GROUP_ID_EDGE_TRIM_PATTERN, ""); if (value.length > 32) { value = value.slice(0, 32).replace(GROUP_ID_EDGE_TRIM_PATTERN, ""); } return value; }, syncNewGroupIdFromName() { if (this.isNewGroupIdEditedManually) { return; } const slug = this.slugifyGroupId(this.newGroup.name); this.isSyncingNewGroupId = true; this.newGroup.id = slug; this.isSyncingNewGroupId = false; }, handleNewGroupNameInput() { this.syncNewGroupIdFromName(); }, handleNewGroupIdInput() { if (this.isSyncingNewGroupId) { return; } const suggested = this.slugifyGroupId(this.newGroup.name); const current = String(this.newGroup.id || "").trim(); this.isNewGroupIdEditedManually = current.length > 0 && current !== suggested; }, deviceLocationLabel(device) { const room = this.formatRoomName(device?.room); if (room !== "Без комнаты") { return room; } const groupNames = this.deviceGroupNames(device); if (groupNames.length === 0) { return room; } return groupNames.join(", "); }, serverDisplayName() { if (this.serverInfo?.instance_name) { return this.serverInfo.instance_name; } return this.serverInfo?.app_name || "Ignis Core"; }, serverDisplaySubtitle() { return this.serverInfo?.app_name || "Ignis Core"; }, 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)} назад`; }, formatRelativeTimestamp(iso) { if (!iso) { return "ещё не выполнялось"; } const parsed = new Date(iso); if (Number.isNaN(parsed.getTime())) { return iso; } const deltaSeconds = Math.max(Math.floor((Date.now() - parsed.getTime()) / 1000), 0); if (deltaSeconds < 60) { return "только что"; } return `${this.formatDuration(deltaSeconds, 2)} назад`; }, formatDiscoveryMode(mode) { const labels = { startup: "при запуске", manual: "вручную", background: "в фоне", }; return labels[mode] || mode || "неизвестно"; }, 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; }, sceneNameById(sceneId) { const entry = Object.entries(this.allScenes).find(([, value]) => value === sceneId); return entry ? entry[0] : `scene ${sceneId}`; }, formatActionLabel(actionParams) { if (!actionParams) { return "Команда"; } if (typeof actionParams.state === "boolean") { return actionParams.state ? "Включить" : "Выключить"; } if (typeof actionParams.sceneId === "number") { return this.sceneNameById(actionParams.sceneId); } if (typeof actionParams.temp === "number") { return `${actionParams.temp}K`; } if ( typeof actionParams.r === "number" && typeof actionParams.g === "number" && typeof actionParams.b === "number" ) { return `RGB ${actionParams.r}/${actionParams.g}/${actionParams.b}`; } if (typeof actionParams.dimming === "number") { return `Яркость ${actionParams.dimming}%`; } return "Команда"; }, formatDayOfWeek(dayOfWeek) { const labels = { "*": "ежедневно", "mon-fri": "будни", "sat,sun": "выходные", mon: "понедельник", tue: "вторник", wed: "среда", thu: "четверг", fri: "пятница", sat: "суббота", sun: "воскресенье", }; return labels[dayOfWeek] || dayOfWeek; }, formatTaskTime(task) { if (task.trigger_type === "cron") { return `${String(task.hour).padStart(2, "0")}:${String(task.minute).padStart(2, "0")}`; } if (task.next_run) { return this.formatTime(task.next_run); } return "ожидает"; }, formatTaskSubtitle(task) { if (task.trigger_type === "cron") { return this.formatDayOfWeek(task.day_of_week || "*"); } return task.next_run ? `в ${this.formatRelativeTimestamp(task.next_run)}` : "одноразовый запуск"; }, 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; this.tab = "control"; await this.fetchData(); this.ensureValidTab(); this.isLoading = false; if (this.refreshTimerId === null) { this.refreshTimerId = setInterval(() => this.fetchData(), 30000); } }, async fetchData() { if (!this.apiKey || this.isFetching) { return false; } this.isFetching = true; let hadErrors = false; 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 || !groupsData || !devicesData || !scenesData) { hadErrors = true; } if (serverInfoData) { this.serverInfo = serverInfoData; } if (groupsData) { const hadSliders = Object.keys(this.sliders).length > 0; this.groups = groupsData; Object.keys(this.groups).forEach((id) => { if (!this.sliders[id]) { this.sliders[id] = { brightness: 100, temp: 4000, state: false }; } }); if ( this.tab === "control" || !hadSliders ) { 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; } return !hadErrors; }, async fetchAuditData() { if (!this.isAdmin) { return; } await Promise.all([ this.fetchStats(), this.fetchEventLog(100), this.isMaster ? this.fetchApiKeys() : Promise.resolve(), ]); }, async refreshWorkspace() { const refreshed = await this.fetchData(); if (this.isAdmin && this.tab === "access") { await this.fetchAuditData(); } if (refreshed) { this.toast("Данные обновлены", "success", 1800); } }, async control(id, params) { return await this.request(`/control/group/${id}`, { method: "POST", body: params, }); }, async toggleGroup(id, state) { if (this.sliders[id]) { this.sliders[id].state = state; } const response = await this.control(id, { state }); if (!response) { await this.syncGroupStatuses(); } }, async setBrightness(id, value) { if (this.sliders[id]) { this.sliders[id].brightness = value; } const response = await this.control(id, { brightness: value }); if (!response) { await this.syncGroupStatuses(); } }, async setTemp(id, value) { if (this.sliders[id]) { this.sliders[id].temp = value; } const response = await this.control(id, { temp: value }); if (!response) { await this.syncGroupStatuses(); } }, async setScene(id, scene) { if (!scene) { return; } await this.control(id, { scene }); }, async setColor(id, hex) { await 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: [] }; this.isSyncingNewGroupId = false; this.isNewGroupIdEditedManually = false; await this.fetchData(); }, 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) { const response = await this.request(`/control/device/${id}/blink`, { method: "POST", }); if (response) { this.toast(`Лампа ${id} мигнула`, "success", 1800); } }, 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 createCronTask() { if (!this.cronForm.target_id) { this.toast("Выберите группу", "error"); return; } const response = await this.request("/schedules/cron", { method: "POST", body: { target_id: this.cronForm.target_id, hour: this.cronHour, minute: this.cronMinute, day_of_week: this.cronForm.day_of_week, is_group: true, state: this.cronForm.state, }, }); if (response) { this.toast("Расписание сохранено", "success"); await this.fetchTasks(); } }, async createOnceTask() { if (!this.onceForm.target_id) { this.toast("Выберите группу", "error"); return; } const body = { target_id: this.onceForm.target_id, is_group: true, state: this.onceForm.state, }; if (this.onceForm.mode === "hours") { body.hours_from_now = Number(this.onceForm.hours_from_now); } else { if (!this.onceForm.run_at) { this.toast("Укажите точное время", "error"); return; } const parsed = new Date(this.onceForm.run_at); if (Number.isNaN(parsed.getTime())) { this.toast("Неверный формат времени", "error"); return; } body.run_at = parsed.toISOString(); } const response = await this.request("/schedules/once", { method: "POST", body, }); if (response) { this.toast("Одноразовая задача создана", "success"); this.onceForm.run_at = ""; 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) { await 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 || []; } }, async fetchEventLog(limit = 100) { const data = await this.request("/stats/log", { query: { limit }, }); if (data) { this.eventLog = data; } }, formatTime(iso) { if (!iso) { return ""; } const date = new Date(iso); if (Number.isNaN(date.getTime())) { return iso; } const pad = (value) => String(value).padStart(2, "0"); return `${pad(date.getDate())}.${pad(date.getMonth() + 1)} ${pad(date.getHours())}:${pad(date.getMinutes())}`; }, }, async mounted() { if (this.apiKey) { await this.initApp(); } }, beforeUnmount() { if (this.refreshTimerId !== null) { clearInterval(this.refreshTimerId); } }, }).mount("#app");