Harden UI security and add deployment templates

This commit is contained in:
Artem Kokos
2026-05-16 11:22:02 +07:00
parent 1ac66ec4ac
commit 0fd64307b7
12 changed files with 962 additions and 210 deletions

483
static/app.js Normal file
View File

@@ -0,0 +1,483 @@
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");