Refine built-in web app experience

This commit is contained in:
Artem Kokos
2026-05-21 21:47:33 +07:00
parent 61b21c63ea
commit f55e00bce1
11 changed files with 1320 additions and 456 deletions

View File

@@ -1,4 +1,5 @@
const SESSION_KEY_NAME = "ignis_session_key";
const DEFAULT_STATS_DAYS = 7;
function summaryInt(summary, key) {
const value = summary?.[key];
@@ -26,6 +27,12 @@ function formatRescanSummary(summary) {
return `Сканирование завершено: найдено ${found}, новых ${added}, обновлено ${updated}, убрано ${removed}`;
}
function compareText(left, right) {
return String(left || "").localeCompare(String(right || ""), "ru", {
sensitivity: "base",
});
}
const { createApp } = Vue;
createApp({
@@ -48,9 +55,16 @@ createApp({
isLoadingStatus: false,
isFetching: false,
isRescanning: false,
taskHour: "22",
taskMin: "00",
newTask: { target_id: "", state: true },
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: [],
@@ -61,10 +75,138 @@ createApp({
lastCreatedKey: "",
statsData: [],
eventLog: [],
statsDays: 7,
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();
@@ -96,6 +238,8 @@ createApp({
this.statsData = [];
this.eventLog = [];
this.lastCreatedKey = "";
this.logFilterAction = "all";
this.logFilterActor = "all";
if (this.refreshTimerId !== null) {
clearInterval(this.refreshTimerId);
this.refreshTimerId = null;
@@ -113,10 +257,58 @@ createApp({
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;
@@ -124,13 +316,7 @@ createApp({
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 "Подключение активно. Операционная диагностика скрыта для гостевого доступа.";
return this.serverInfo?.app_name || "Ignis Core";
},
shortSha(value) {
if (!value) {
@@ -204,6 +390,28 @@ createApp({
}
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;
@@ -215,6 +423,67 @@ createApp({
}
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) {
@@ -271,19 +540,22 @@ createApp({
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(), 15000);
this.refreshTimerId = setInterval(() => this.fetchData(), 30000);
}
},
async fetchData() {
if (!this.apiKey || this.isFetching) {
return;
return false;
}
this.isFetching = true;
let hadErrors = false;
try {
const [serverInfoData, groupsData, devicesData, scenesData] = await Promise.all([
this.request("/system/info"),
@@ -292,18 +564,28 @@ createApp({
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 };
}
});
await this.syncGroupStatuses();
if (
this.tab === "control" ||
!hadSliders
) {
await this.syncGroupStatuses();
}
}
if (devicesData) {
@@ -325,33 +607,68 @@ createApp({
} 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) {
await this.request(`/control/group/${id}`, { method: "POST", body: params });
return await this.request(`/control/group/${id}`, {
method: "POST",
body: params,
});
},
toggleGroup(id, state) {
async toggleGroup(id, state) {
if (this.sliders[id]) {
this.sliders[id].state = state;
}
this.control(id, { state });
const response = await this.control(id, { state });
if (!response) {
await this.syncGroupStatuses();
}
},
setBrightness(id, value) {
async setBrightness(id, value) {
if (this.sliders[id]) {
this.sliders[id].brightness = value;
}
this.control(id, { brightness: value });
const response = await this.control(id, { brightness: value });
if (!response) {
await this.syncGroupStatuses();
}
},
setTemp(id, value) {
async setTemp(id, value) {
if (this.sliders[id]) {
this.sliders[id].temp = value;
}
this.control(id, { temp: value });
const response = await this.control(id, { temp: value });
if (!response) {
await this.syncGroupStatuses();
}
},
setScene(id, scene) {
this.control(id, { scene });
async setScene(id, scene) {
if (!scene) {
return;
}
await this.control(id, { scene });
},
setColor(id, hex) {
this.control(id, {
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),
@@ -373,7 +690,6 @@ createApp({
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;
@@ -388,7 +704,7 @@ createApp({
return;
}
this.toast("Удалена", "success");
this.toast("Группа удалена", "success");
await this.fetchData();
},
async rescan() {
@@ -406,7 +722,12 @@ createApp({
}
},
async blink(id) {
await this.request(`/control/device/${id}/blink`, { method: "POST" });
const response = await this.request(`/control/device/${id}/blink`, {
method: "POST",
});
if (response) {
this.toast(`Лампа ${id} мигнула`, "success", 1800);
}
},
async syncGroupStatuses() {
if (this.isLoadingStatus) {
@@ -444,8 +765,8 @@ createApp({
this.tasks = data.tasks || [];
}
},
async addSchedule() {
if (!this.newTask.target_id) {
async createCronTask() {
if (!this.cronForm.target_id) {
this.toast("Выберите группу", "error");
return;
}
@@ -453,15 +774,53 @@ createApp({
const response = await this.request("/schedules/cron", {
method: "POST",
body: {
target_id: this.newTask.target_id,
hour: this.taskHour,
minute: this.taskMin,
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.newTask.state,
state: this.cronForm.state,
},
});
if (response) {
this.toast(`${this.taskHour}:${this.taskMin} добавлено`, "success");
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();
}
},
@@ -470,12 +829,12 @@ createApp({
method: "DELETE",
});
if (response) {
this.toast("Отменено", "success");
this.toast("Задача удалена", "success");
await this.fetchTasks();
}
},
async setTimer4h(id) {
this.toggleGroup(id, true);
await this.toggleGroup(id, true);
const response = await this.request("/schedules/once", {
method: "POST",
body: {
@@ -486,7 +845,7 @@ createApp({
},
});
if (response) {
this.toast("Таймер 4ч", "success");
this.toast("Выключение через 4 часа запланировано", "success");
await this.fetchTasks();
}
},
@@ -555,11 +914,10 @@ createApp({
if (data) {
this.statsData = data.groups || [];
}
await this.fetchEventLog();
},
async fetchEventLog() {
async fetchEventLog(limit = 100) {
const data = await this.request("/stats/log", {
query: { limit: 100 },
query: { limit },
});
if (data) {
this.eventLog = data;
@@ -571,8 +929,11 @@ createApp({
}
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())}:${pad(date.getSeconds())}`;
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
},
},
async mounted() {