950 lines
27 KiB
JavaScript
950 lines
27 KiB
JavaScript
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");
|