Files
ignis-core/static/app.js
2026-05-21 21:47:33 +07:00

950 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const SESSION_KEY_NAME = "ignis_session_key";
const DEFAULT_STATS_DAYS = 7;
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: [] },
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;
},
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: [] };
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");