From f55e00bce1c78e2a0971941ed7c1230dbfd844ad Mon Sep 17 00:00:00 2001 From: Artem Kokos Date: Thu, 21 May 2026 21:47:33 +0700 Subject: [PATCH] Refine built-in web app experience --- app/api/schemas.py | 12 + app/core/discovery.py | 17 +- app/core/server_info.py | 27 +- app/core/state.py | 26 + main.py | 1 + openapi.json | 104 +++ static/app.js | 445 +++++++++++-- static/index.html | 867 +++++++++++++++----------- static/ui.css | 203 ++++-- tests/test_p0_security_and_control.py | 53 +- tests/test_p1_ui_security.py | 21 +- 11 files changed, 1320 insertions(+), 456 deletions(-) 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 @@ -
+
-
-
+

Ignis

@@ -31,398 +30,568 @@
- -
-
{{ toast.text }}
+
{{ toast.text }}
diff --git a/static/ui.css b/static/ui.css index 5201cc9..ce05938 100644 --- a/static/ui.css +++ b/static/ui.css @@ -1,10 +1,12 @@ :root { - --bg-deep: #08090c; - --bg-card: rgba(20, 22, 30, 0.8); - --border-subtle: rgba(255, 255, 255, 0.06); - --border-hover: rgba(249, 115, 22, 0.4); + --bg-deep: #0b0d12; + --bg-card: #121722; + --bg-card-strong: #171d29; + --border-subtle: rgba(255, 255, 255, 0.07); + --border-hover: rgba(249, 115, 22, 0.22); --accent: #f97316; - --accent-glow: rgba(249, 115, 22, 0.15); + --accent-deep: #ea580c; + --accent-glow: rgba(249, 115, 22, 0.12); } [v-cloak] { @@ -23,51 +25,181 @@ body { } body::before { - content: ""; - position: fixed; - inset: 0; - z-index: -1; - background: - radial-gradient( - ellipse at 20% 0%, - rgba(249, 115, 22, 0.06) 0%, - transparent 60% - ), - radial-gradient( - ellipse at 80% 100%, - rgba(30, 27, 75, 0.15) 0%, - transparent 60% - ); + content: none; } .glass { background: var(--bg-card); - backdrop-filter: blur(16px); border: 1px solid var(--border-subtle); + box-shadow: none; } .glass:hover { + border-color: var(--border-subtle); +} + +.hero-panel { + background: var(--bg-card-strong); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: none; +} + +.brand-mark { + background: + linear-gradient(135deg, #fb923c, #dc2626 70%), + var(--accent); + box-shadow: none; +} + +.metric-card, +.surface-card { + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 1.2rem; + padding: 1rem; +} + +.metric-card { + min-height: 92px; +} + +.surface-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 1rem; + padding: 0.95rem 1rem; +} + +.metric-label, +.section-kicker { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.22em; + text-transform: uppercase; + color: #64748b; +} + +.metric-value { + margin-top: 0.45rem; + font-size: 1.7rem; + line-height: 1; + font-weight: 900; +} + +.status-pill { + display: inline-flex; + align-items: center; + gap: 0.25rem; + border: 1px solid; + border-radius: 999px; + padding: 0.38rem 0.72rem; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; +} + +.quick-action { + width: 100%; + text-align: left; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 1rem; + padding: 0.95rem 1rem; + font-size: 0.82rem; + font-weight: 700; + color: #cbd5e1; + transition: + border-color 0.2s ease, + color 0.2s ease; +} + +.quick-action:hover { border-color: var(--border-hover); + color: #fff; +} + +.accent-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.8rem 1.15rem; + border-radius: 1rem; + background: linear-gradient(135deg, var(--accent), var(--accent-deep)); + color: white; + font-size: 12px; + font-weight: 900; + letter-spacing: 0.16em; + text-transform: uppercase; + box-shadow: none; + transition: + filter 0.18s ease; +} + +.accent-button:hover { + filter: brightness(1.04); +} + +.accent-button:disabled { + filter: saturate(0.3); + box-shadow: none; } .group-active { - border-color: rgba(249, 115, 22, 0.2); + border-color: rgba(249, 115, 22, 0.24); + box-shadow: none; } .active-tab { background: var(--accent); - box-shadow: - 0 0 24px var(--accent-glow), - inset 0 1px 0 rgba(255, 255, 255, 0.15); + box-shadow: none; +} + +.empty-state { + border: 1px dashed rgba(148, 163, 184, 0.28); + border-radius: 1.2rem; + padding: 2.4rem 1.2rem; + text-align: center; + color: #64748b; + font-size: 0.92rem; +} + +.ui-table { + border-collapse: collapse; +} + +.ui-table th { + padding: 0.8rem 0.9rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.18em; + text-transform: uppercase; + color: #64748b; + text-align: left; +} + +.ui-table td { + padding: 0.95rem 0.9rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + vertical-align: middle; +} + +.ui-table tbody tr:hover { + background: rgba(255, 255, 255, 0.02); } input[type="range"] { -webkit-appearance: none; height: 6px; - border-radius: 10px; + border-radius: 999px; background: #1e293b; outline: none; - transition: opacity 0.2s; } input[type="range"]:disabled { @@ -79,16 +211,16 @@ input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; - background: #fff; + background: white; border-radius: 50%; cursor: pointer; border: 3px solid var(--accent); - box-shadow: 0 0 8px rgba(0, 0, 0, 0.4); - transition: transform 0.15s; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.42); + transition: transform 0.15s ease; } input[type="range"]::-webkit-slider-thumb:hover { - transform: scale(1.15); + transform: scale(1.12); } input[type="range"]:disabled::-webkit-slider-thumb { @@ -129,12 +261,9 @@ select { } } -.fade-up { - animation: fadeUp 0.4s ease-out both; -} - +.fade-up, .toast-enter { - animation: fadeUp 0.3s ease-out; + animation: none; } @keyframes pulse-dot { @@ -149,7 +278,7 @@ select { } .pulse-on { - animation: pulse-dot 2s ease-in-out infinite; + animation: none; } @keyframes spin { diff --git a/tests/test_p0_security_and_control.py b/tests/test_p0_security_and_control.py index 19ebaa6..60f4e95 100644 --- a/tests/test_p0_security_and_control.py +++ b/tests/test_p0_security_and_control.py @@ -20,7 +20,7 @@ os.environ["IGNIS_SYNC_DATABASE_URL"] = f"sqlite:///{TEST_DB_PATH}" import main # noqa: E402 from app.api.routes import control # noqa: E402 from app.core.database import async_session, engine, init_db, sync_engine # noqa: E402 -from app.core.state import state_manager # noqa: E402 +from app.core.state import DiscoveryApplyResult, state_manager # noqa: E402 from app.drivers.wiz import WizResponse # noqa: E402 from app.models.api_key import ApiKeyModel # noqa: E402 from app.models.device import DeviceSchema # noqa: E402 @@ -42,6 +42,7 @@ class SecurityAndControlApiTests(unittest.IsolatedAsyncioTestCase): await self._reset_database() state_manager.devices.clear() state_manager.groups.clear() + state_manager.discovery_snapshot = None self.client = AsyncClient( transport=ASGITransport(app=main.app), base_url="http://testserver", @@ -51,6 +52,7 @@ class SecurityAndControlApiTests(unittest.IsolatedAsyncioTestCase): await self.client.aclose() state_manager.devices.clear() state_manager.groups.clear() + state_manager.discovery_snapshot = None async def _reset_database(self): async with async_session() as session: @@ -107,6 +109,17 @@ class SecurityAndControlApiTests(unittest.IsolatedAsyncioTestCase): async def test_system_info_returns_installed_server_metadata_without_secrets( self, ): + state_manager.record_discovery( + "manual", + DiscoveryApplyResult( + found=4, + added=1, + updated=3, + removed_offline=0, + pending_removal=0, + online=4, + ), + ) with patch.dict( os.environ, { @@ -148,10 +161,24 @@ class SecurityAndControlApiTests(unittest.IsolatedAsyncioTestCase): self.assertTrue(payload["configuration"]["public_base_url_configured"]) self.assertTrue(payload["configuration"]["build_metadata_complete"]) self.assertIn("started_at", payload) + self.assertEqual(payload["discovery"]["last_scan_mode"], "manual") + self.assertEqual(payload["discovery"]["online"], 4) + self.assertEqual(payload["discovery"]["found"], 4) self.assertNotIn(MASTER_KEY, response.text) self.assertNotIn("api_key", payload) async def test_guest_key_can_read_system_info(self): + state_manager.record_discovery( + "startup", + DiscoveryApplyResult( + found=2, + added=2, + updated=0, + removed_offline=0, + pending_removal=0, + online=2, + ), + ) create_response = await self.client.post( "/api-keys", headers=self._master_headers(), @@ -159,22 +186,32 @@ class SecurityAndControlApiTests(unittest.IsolatedAsyncioTestCase): ) guest_key = create_response.json()["key"] - response = await self.client.get( - "/system/info", - headers={"X-API-Key": guest_key}, - ) + with patch.dict( + os.environ, + { + "IGNIS_INSTANCE_NAME": "Home Lab", + }, + clear=False, + ): + response = await self.client.get( + "/system/info", + headers={"X-API-Key": guest_key}, + ) self.assertEqual(response.status_code, 200) payload = response.json() self.assertEqual(payload["app_name"], "Ignis Core") + self.assertEqual(payload["instance_name"], "Home Lab") self.assertGreaterEqual(payload["uptime_seconds"], 0) self.assertFalse(payload["diagnostics_visible"]) - self.assertNotIn("instance_name", payload) self.assertNotIn("timezone", payload) self.assertNotIn("started_at", payload) self.assertNotIn("build", payload) self.assertNotIn("urls", payload) self.assertNotIn("configuration", payload) + self.assertEqual(payload["discovery"]["last_scan_mode"], "startup") + self.assertEqual(payload["discovery"]["online"], 2) + self.assertNotIn("found", payload["discovery"]) async def test_master_can_create_key_and_list_endpoint_returns_public_id(self): create_response = await self.client.post( @@ -485,7 +522,9 @@ class SecurityAndControlApiTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(response.status_code, 422) - async def test_stats_summary_counts_real_commands_without_requested_duplicates(self): + async def test_stats_summary_counts_real_commands_without_requested_duplicates( + self, + ): self._set_single_device_state() with patch.object( diff --git a/tests/test_p1_ui_security.py b/tests/test_p1_ui_security.py index 1da6c3c..f39677a 100644 --- a/tests/test_p1_ui_security.py +++ b/tests/test_p1_ui_security.py @@ -30,7 +30,10 @@ class UiSecurityTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(response.headers["x-content-type-options"], "nosniff") self.assertEqual(response.headers["x-frame-options"], "DENY") self.assertIn("default-src 'self'", response.headers["content-security-policy"]) - self.assertIn("script-src 'self' 'unsafe-eval'", response.headers["content-security-policy"]) + self.assertIn( + "script-src 'self' 'unsafe-eval'", + response.headers["content-security-policy"], + ) self.assertIn( "style-src 'self' 'unsafe-inline'", response.headers["content-security-policy"], @@ -40,10 +43,10 @@ class UiSecurityTests(unittest.IsolatedAsyncioTestCase): index_html = Path("static/index.html").read_text(encoding="utf-8") app_js = Path("static/app.js").read_text(encoding="utf-8") - self.assertIn('/static/vendor/tailwindcdn.js', index_html) - self.assertIn('/static/ui.css', index_html) - self.assertIn('/static/vendor/vue.global.prod.js', index_html) - self.assertIn('/static/app.js', index_html) + self.assertIn("/static/vendor/tailwindcdn.js", index_html) + self.assertIn("/static/ui.css", index_html) + self.assertIn("/static/vendor/vue.global.prod.js", index_html) + self.assertIn("/static/app.js", index_html) self.assertNotIn("https://unpkg.com", index_html) self.assertNotIn("https://cdn.tailwindcss.com", index_html) self.assertNotIn("https://fonts.googleapis.com", index_html) @@ -52,6 +55,12 @@ class UiSecurityTests(unittest.IsolatedAsyncioTestCase): self.assertIn("sessionStorage", app_js) self.assertIn("/system/info", app_js) self.assertIn("serverInfo", app_js) + self.assertIn("Комнаты, сцены и свет", index_html) + self.assertIn("Устройства и группы", index_html) + self.assertIn("Собрать комнату из найденных ламп", index_html) + self.assertIn("Повторяющееся расписание", index_html) + self.assertIn("Гостевые и админ-ключи", index_html) self.assertIn("О сервере", index_html) - self.assertIn("СЕРВЕР", index_html) self.assertIn("Запущен", index_html) + self.assertNotIn("ОБЗОР", index_html) + self.assertNotIn("tab === 'overview'", app_js)