Files
ignis-core/static/app.js
2026-05-16 11:22:02 +07:00

484 lines
13 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";
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: "",
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.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;
},
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 [groupsData, devicesData, scenesData] = await Promise.all([
this.request("/devices/groups"),
this.request("/devices"),
this.request("/devices/scenes"),
]);
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");