diff --git a/app/api/schemas.py b/app/api/schemas.py index 919e6a7..9e936f0 100644 --- a/app/api/schemas.py +++ b/app/api/schemas.py @@ -200,6 +200,17 @@ class ServerConfigurationStatusResponse(BaseModel): build_metadata_complete: bool +class ServerDiscoveryInfoResponse(BaseModel): + last_scan_at: str | None = None + last_scan_mode: str | None = None + online: int | None = None + found: int | None = None + added: int | None = None + updated: int | None = None + removed_offline: int | None = None + pending_removal: int | None = None + + class ServerInfoResponse(BaseModel): app_name: str instance_name: str | None = None @@ -210,3 +221,4 @@ class ServerInfoResponse(BaseModel): build: ServerBuildInfoResponse | None = None urls: ServerUrlInfoResponse | None = None configuration: ServerConfigurationStatusResponse | None = None + discovery: ServerDiscoveryInfoResponse | None = None diff --git a/app/core/discovery.py b/app/core/discovery.py index a8c7aff..ed8fd8e 100644 --- a/app/core/discovery.py +++ b/app/core/discovery.py @@ -60,9 +60,7 @@ class DiscoveryService: def _background_interval_seconds(self) -> int: return int( - os.getenv( - "DISCOVERY_INTERVAL_SECONDS", DEFAULT_DISCOVERY_INTERVAL_SECONDS - ) + os.getenv("DISCOVERY_INTERVAL_SECONDS", DEFAULT_DISCOVERY_INTERVAL_SECONDS) ) def _background_missing_threshold(self) -> int: @@ -161,7 +159,9 @@ class DiscoveryService: if not candidates: return [] - private_candidates = [candidate for candidate in candidates if candidate.address.is_private] + private_candidates = [ + candidate for candidate in candidates if candidate.address.is_private + ] usable_candidates = private_candidates or candidates preferred_candidates = [ candidate @@ -202,9 +202,7 @@ class DiscoveryService: f"{local_ip}/{self._auto_min_prefix_len()}", strict=False, ) - logger.info( - "Авто-discovery fallback: использую локальный сегмент %s", network - ) + logger.info("Авто-discovery fallback: использую локальный сегмент %s", network) return [str(network)] def _get_target_subnets(self) -> List[str]: @@ -298,6 +296,7 @@ class DiscoveryService: remove_missing=remove_missing, missing_threshold=missing_threshold, ) + state_manager.record_discovery(mode, result) logger.info( "Discovery (%s): found=%s added=%s updated=%s removed=%s pending_removal=%s online=%s", mode, @@ -337,7 +336,9 @@ class DiscoveryService: timeout=timeout, ) - async def start_background_discovery(self, state_manager, interval: int | None = None): + async def start_background_discovery( + self, state_manager, interval: int | None = None + ): interval_seconds = interval or self._background_interval_seconds() while True: await asyncio.sleep(interval_seconds) diff --git a/app/core/server_info.py b/app/core/server_info.py index 9101552..7d17587 100644 --- a/app/core/server_info.py +++ b/app/core/server_info.py @@ -7,6 +7,7 @@ from pathlib import Path import socket from app.api.deps import get_master_key +from app.core.state import state_manager APP_NAME = "Ignis Core" PROJECT_ROOT = Path(__file__).resolve().parents[2] @@ -58,11 +59,7 @@ def _read_version_file() -> str | None: def get_app_version() -> str: - return ( - _clean_env("IGNIS_BUILD_VERSION") - or _read_version_file() - or "1.0.0" - ) + return _clean_env("IGNIS_BUILD_VERSION") or _read_version_file() or "1.0.0" def _resolve_git_ref(git_dir: Path, ref_name: str) -> str | None: @@ -144,7 +141,9 @@ def get_configuration_status(build_info: ServerBuildInfo) -> ServerConfiguration master_key_configured = get_master_key() is not None public_base_url_configured = _clean_env("IGNIS_PUBLIC_BASE_URL") is not None scan_network_configured = _clean_env("SCAN_NETWORK") is not None - build_metadata_complete = bool(build_info.version and build_info.git_sha and build_info.build_date) + build_metadata_complete = bool( + build_info.version and build_info.git_sha and build_info.build_date + ) return ServerConfigurationStatus( configured=master_key_configured, master_key_configured=master_key_configured, @@ -166,14 +165,21 @@ def build_server_info( ) -> dict: payload = { "app_name": APP_NAME, + "instance_name": get_instance_name(), "uptime_seconds": get_uptime_seconds(), "diagnostics_visible": include_diagnostics, } if not include_diagnostics: + discovery_snapshot = state_manager.get_discovery_snapshot() + if discovery_snapshot: + payload["discovery"] = { + "last_scan_at": discovery_snapshot["last_scan_at"], + "last_scan_mode": discovery_snapshot["last_scan_mode"], + "online": discovery_snapshot["summary"].get("online"), + } return payload build_info = get_build_info() - payload["instance_name"] = get_instance_name() payload["timezone"] = os.getenv("APP_TIMEZONE", "Asia/Novosibirsk") payload["started_at"] = SERVER_STARTED_AT.isoformat() payload["build"] = { @@ -181,6 +187,13 @@ def build_server_info( "git_sha": build_info.git_sha, "build_date": build_info.build_date, } + discovery_snapshot = state_manager.get_discovery_snapshot() + if discovery_snapshot: + payload["discovery"] = { + "last_scan_at": discovery_snapshot["last_scan_at"], + "last_scan_mode": discovery_snapshot["last_scan_mode"], + **discovery_snapshot["summary"], + } payload["urls"] = asdict(get_server_urls(observed_base_url)) payload["configuration"] = asdict(get_configuration_status(build_info)) return payload diff --git a/app/core/state.py b/app/core/state.py index ff9dc7e..4f53a34 100644 --- a/app/core/state.py +++ b/app/core/state.py @@ -1,4 +1,5 @@ from dataclasses import asdict, dataclass +from datetime import datetime import logging from typing import Dict, List @@ -21,6 +22,18 @@ class DiscoveryApplyResult: return asdict(self) +@dataclass(frozen=True) +class DiscoverySnapshot: + last_scan_at: str + last_scan_mode: str + summary: DiscoveryApplyResult + + def to_dict(self) -> dict: + payload = asdict(self) + payload["summary"] = self.summary.to_dict() + return payload + + class StateManager: def __init__(self): # Храним устройства как Pydantic объекты @@ -29,6 +42,7 @@ class StateManager: self.groups: Dict[str, GroupModel] = {} # Сколько подряд циклов discovery устройство не видно self._missing_scan_counts: Dict[str, int] = {} + self.discovery_snapshot: DiscoverySnapshot | None = None def update_device(self, device_data: dict): """Обновляет или добавляет устройство в состояние.""" @@ -92,6 +106,18 @@ class StateManager: online=len(self.devices), ) + def record_discovery(self, mode: str, result: DiscoveryApplyResult): + self.discovery_snapshot = DiscoverySnapshot( + last_scan_at=datetime.now().isoformat(), + last_scan_mode=mode, + summary=result, + ) + + def get_discovery_snapshot(self) -> dict | None: + if not self.discovery_snapshot: + return None + return self.discovery_snapshot.to_dict() + def get_group_ips(self, group_id: str) -> List[str]: """Возвращает список IP всех ламп, входящих в группу.""" group = self.groups.get(group_id) diff --git a/main.py b/main.py index 59455c5..f43e56e 100644 --- a/main.py +++ b/main.py @@ -82,6 +82,7 @@ async def add_security_headers(request, call_next): response.headers.setdefault("Content-Security-Policy", UI_CONTENT_SECURITY_POLICY) return response + # Регистрация роутеров app.include_router(devices.router, prefix="/devices", tags=["Devices & Groups"]) app.include_router(control.router, prefix="/control", tags=["Control"]) diff --git a/openapi.json b/openapi.json index 54c66df..97a9c91 100644 --- a/openapi.json +++ b/openapi.json @@ -1843,6 +1843,100 @@ ], "title": "ServerConfigurationStatusResponse" }, + "ServerDiscoveryInfoResponse": { + "properties": { + "last_scan_at": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Scan At" + }, + "last_scan_mode": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Scan Mode" + }, + "online": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Online" + }, + "found": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Found" + }, + "added": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Added" + }, + "updated": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Updated" + }, + "removed_offline": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Removed Offline" + }, + "pending_removal": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Pending Removal" + } + }, + "type": "object", + "title": "ServerDiscoveryInfoResponse" + }, "ServerInfoResponse": { "properties": { "app_name": { @@ -1919,6 +2013,16 @@ "type": "null" } ] + }, + "discovery": { + "anyOf": [ + { + "$ref": "#/components/schemas/ServerDiscoveryInfoResponse" + }, + { + "type": "null" + } + ] } }, "type": "object", diff --git a/static/app.js b/static/app.js index 8ee69ad..b4eb10e 100644 --- a/static/app.js +++ b/static/app.js @@ -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() { diff --git a/static/index.html b/static/index.html index cd80ad0..dffe252 100644 --- a/static/index.html +++ b/static/index.html @@ -9,12 +9,11 @@
-| Устройство | +Локация | +IP | +MAC | +Действие | +
|---|---|---|---|---|
|
+ {{ device.name }}
+ |
+ {{ deviceLocationLabel(device) }} | +{{ device.ip }} | +{{ device.id }} | ++ + | +
Создайте группу в админке
-Нет доступных групп
-