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

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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"])

View File

@@ -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",

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() {

View File

@@ -9,12 +9,11 @@
<link rel="stylesheet" href="/static/ui.css">
</head>
<body>
<div id="app" v-cloak class="max-w-6xl mx-auto p-4 md:p-8">
<div id="app" v-cloak class="max-w-7xl mx-auto p-4 md:p-8">
<!-- Авторизация -->
<div v-if="!apiKey" class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-md p-4">
<div class="glass p-10 rounded-3xl w-full max-w-sm shadow-2xl fade-up text-center">
<div class="w-16 h-16 mx-auto mb-6 bg-gradient-to-br from-orange-500 to-red-600 rounded-2xl flex items-center justify-center shadow-lg shadow-orange-900/30">
<div class="brand-mark w-16 h-16 mx-auto mb-6 rounded-2xl flex items-center justify-center shadow-lg shadow-orange-900/30">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<h2 class="text-2xl font-black uppercase tracking-tight mb-1">Ignis</h2>
@@ -31,398 +30,568 @@
</div>
</div>
<!-- Основной интерфейс -->
<template v-else>
<header class="flex flex-col md:flex-row justify-between items-center mb-10 gap-5 fade-up">
<div class="flex items-center gap-3">
<div class="bg-gradient-to-br from-orange-500 to-red-600 p-2.5 rounded-xl shadow-lg shadow-orange-900/20">
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<div>
<h1 class="text-2xl font-black tracking-tight uppercase">Ignis<span class="text-orange-500">Core</span></h1>
<div class="flex items-center gap-2">
<span v-if="authName" class="text-[9px] mono text-slate-600">{{ authName }}</span>
<span v-if="isMaster" class="text-[9px] text-orange-500 font-bold uppercase">master</span>
<span v-if="!isAdmin" class="text-[9px] text-yellow-600 font-bold uppercase">гость</span>
<button @click="logout" class="text-[9px] text-slate-600 hover:text-red-400 uppercase font-bold tracking-widest transition-colors">выйти</button>
<header class="mb-8 md:mb-10 fade-up">
<section class="hero-panel rounded-[2rem] p-6 md:p-8 mb-5">
<div class="flex flex-col xl:flex-row xl:items-center xl:justify-between gap-6">
<div class="flex items-start gap-4">
<div class="brand-mark shrink-0 w-14 h-14 rounded-2xl flex items-center justify-center">
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
</div>
<div class="space-y-3 min-w-0">
<div>
<div class="text-[11px] font-black uppercase tracking-[0.22em] text-orange-300">Ignis Core</div>
<h1 class="text-3xl md:text-4xl font-black tracking-tight">{{ serverDisplayName() }}</h1>
</div>
<div class="flex flex-wrap items-center gap-2 text-[11px] text-slate-400">
<span v-if="serverDisplaySubtitle() !== serverDisplayName()">{{ serverDisplaySubtitle() }}</span>
<span v-if="serverInfo?.build?.version && serverDisplaySubtitle() !== serverDisplayName()">·</span>
<span v-if="serverInfo?.build?.version">версия {{ serverInfo.build.version }}</span>
<span v-if="serverDiscovery?.last_scan_at && (serverInfo?.build?.version || serverDisplaySubtitle() !== serverDisplayName())">·</span>
<span v-if="serverDiscovery?.last_scan_at">discovery {{ formatRelativeTimestamp(serverDiscovery.last_scan_at) }}</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="status-pill text-slate-200 border-slate-600/60 bg-black/20">{{ roleLabel }}</span>
<span v-if="authName" class="status-pill mono text-slate-300 border-slate-700/70 bg-black/20">{{ authName }}</span>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3 xl:min-w-[320px]">
<div class="metric-card">
<div class="metric-label">Группы</div>
<div class="metric-value">{{ groupCount }}</div>
</div>
<div class="metric-card">
<div class="metric-label">Онлайн-лампы</div>
<div class="metric-value">{{ onlineDeviceCount }}</div>
</div>
<div class="metric-card" v-if="isAdmin">
<div class="metric-label">Задачи</div>
<div class="metric-value">{{ scheduleCount }}</div>
</div>
<div class="metric-card col-span-2">
<div class="metric-label">Запущен</div>
<div class="metric-value text-xl">{{ formatRelativeUptime(serverInfo?.uptime_seconds) }}</div>
</div>
</div>
</div>
</section>
<div class="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<nav class="glass rounded-2xl p-1.5 flex flex-wrap gap-1.5">
<button
v-for="tabItem in visibleTabs"
:key="tabItem.id"
@click="openTab(tabItem.id)"
:class="tab === tabItem.id ? 'active-tab text-white' : 'text-slate-400 hover:text-slate-200'"
class="px-4 md:px-5 py-2.5 rounded-xl font-bold text-xs md:text-sm tracking-wide transition-all"
>
{{ tabItem.label }}
</button>
</nav>
<div class="flex flex-wrap gap-2">
<button @click="refreshWorkspace" class="glass px-4 py-2.5 rounded-xl text-xs font-bold tracking-wide hover:text-orange-300 transition-colors">ОБНОВИТЬ</button>
<button v-if="isAdmin" @click="rescan" :disabled="isRescanning" class="glass px-4 py-2.5 rounded-xl text-xs font-bold tracking-wide text-blue-300 hover:text-blue-200 transition-colors disabled:opacity-50">
{{ isRescanning ? 'СКАНИРУЮ...' : 'ПЕРЕСКАНИРОВАТЬ' }}
</button>
<button @click="logout" class="glass px-4 py-2.5 rounded-xl text-xs font-bold tracking-wide text-slate-400 hover:text-red-300 transition-colors">ВЫЙТИ</button>
</div>
</div>
<nav class="flex glass p-1 rounded-xl">
<button @click="tab = 'control'" :class="tab === 'control' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">ПУЛЬТ</button>
<button @click="tab = 'server'" :class="tab === 'server' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">СЕРВЕР</button>
<button v-if="isAdmin" @click="tab = 'schedules'" :class="tab === 'schedules' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">ГРАФИК</button>
<button v-if="isAdmin" @click="tab = 'admin'" :class="tab === 'admin' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">АДМИНКА</button>
<button v-if="isAdmin" @click="tab = 'stats'; fetchStats()" :class="tab === 'stats' ? 'active-tab text-white' : 'text-slate-500 hover:text-slate-300'" class="px-6 py-2 rounded-lg font-bold text-sm transition-all">СТАТА</button>
</nav>
</header>
<div v-if="isLoading" class="flex justify-center py-20">
<div class="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full spinner"></div>
</div>
<div v-if="tab === 'server' && !isLoading" class="space-y-8 fade-up">
<section v-if="serverInfo" class="glass p-6 rounded-2xl">
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
<div v-if="tab === 'control' && !isLoading" class="space-y-8 fade-up">
<section class="glass rounded-3xl p-6">
<div class="flex flex-col lg:flex-row lg:items-end lg:justify-between gap-5 mb-5">
<div>
<div class="text-[10px] uppercase tracking-[0.2em] text-slate-600 mb-2">О сервере</div>
<h2 class="text-2xl font-black tracking-tight">{{ serverDisplayName() }}</h2>
<div class="text-sm text-slate-500 mt-1">{{ serverDisplaySubtitle() }}</div>
<div class="section-kicker">Пульт</div>
<h2 class="text-2xl font-black tracking-tight">Комнаты, сцены и свет</h2>
</div>
<div class="flex flex-wrap gap-2 text-[10px]">
<span class="border px-2.5 py-1 rounded-lg font-bold uppercase text-green-300 border-green-500/30 bg-green-500/10">
онлайн
</span>
<span :class="isAdmin ? 'text-blue-300 border-blue-500/30 bg-blue-500/10' : 'text-slate-300 border-slate-700/60 bg-black/20'" class="border px-2.5 py-1 rounded-lg font-bold uppercase">
{{ isAdmin ? 'админ-доступ' : 'гостевой доступ' }}
</span>
<span v-if="serverInfo.diagnostics_visible" class="border px-2.5 py-1 rounded-lg font-bold uppercase text-orange-300 border-orange-500/30 bg-orange-500/10">
расширенная диагностика
</span>
<div v-if="featuredScenes.length > 0" class="flex flex-wrap gap-2">
<span class="text-[11px] uppercase tracking-[0.22em] text-slate-500 mr-1 self-center">Быстрые сцены</span>
<span v-for="scene in featuredScenes" :key="scene" class="status-pill text-slate-200 border-slate-700/70 bg-black/20">{{ scene }}</span>
</div>
</div>
<div v-if="sortedGroups.length === 0" class="empty-state">{{ isAdmin ? 'Создайте группу на вкладке устройств, чтобы управлять домом через веб-интерфейс.' : 'Пока нет доступных групп света.' }}</div>
<div v-else class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<article v-for="entry in sortedGroups" :key="entry[0]" class="glass rounded-3xl p-6 transition-all" :class="sliders[entry[0]]?.state ? 'group-active' : ''">
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4 mb-6">
<div>
<div class="flex items-center gap-2">
<h3 class="text-2xl font-black">{{ entry[1].name }}</h3>
<span :class="sliders[entry[0]]?.state ? 'bg-green-400 pulse-on' : 'bg-slate-600'" class="w-2.5 h-2.5 rounded-full"></span>
</div>
<div class="text-[11px] text-slate-500 mt-2">
{{ getGroupOnlineCount(entry[1]) }}/{{ getGroupDeviceCount(entry[1]) }} ламп онлайн · {{ entry[0] }}
</div>
</div>
<div class="flex flex-wrap gap-2">
<button @click="toggleGroup(entry[0], true)" :class="sliders[entry[0]]?.state ? 'bg-orange-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-2.5 rounded-xl text-xs font-bold tracking-wide transition-all">ВКЛЮЧИТЬ</button>
<button @click="toggleGroup(entry[0], false)" :class="!sliders[entry[0]]?.state ? 'bg-slate-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-2.5 rounded-xl text-xs font-bold tracking-wide transition-all">ВЫКЛЮЧИТЬ</button>
<button v-if="isAdmin" @click="setTimer4h(entry[0])" class="glass px-4 py-2.5 rounded-xl text-xs font-bold tracking-wide text-blue-300 hover:text-blue-100 transition-colors">ТАЙМЕР 4Ч</button>
</div>
</div>
<div class="space-y-5">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="surface-card">
<div class="metric-label">Яркость</div>
<div class="text-lg font-bold mb-3">{{ sliders[entry[0]]?.brightness || 100 }}%</div>
<input type="range" min="10" max="100" step="10" class="w-full" :disabled="!sliders[entry[0]]?.state" :value="sliders[entry[0]]?.brightness || 100" @change="setBrightness(entry[0], +$event.target.value)">
</div>
<div class="surface-card">
<div class="metric-label">Температура</div>
<div class="text-lg font-bold mb-3">{{ sliders[entry[0]]?.temp || 4000 }}K</div>
<input type="range" min="2700" max="6500" step="100" class="w-full temp-gradient" :disabled="!sliders[entry[0]]?.state" :value="sliders[entry[0]]?.temp || 4000" @change="setTemp(entry[0], +$event.target.value)">
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-[140px,1fr] gap-4">
<div class="surface-card">
<div class="metric-label mb-3">Цвет</div>
<input type="color" class="w-full h-12 bg-transparent border border-slate-700/50 rounded-xl cursor-pointer" :disabled="!sliders[entry[0]]?.state" @input="setColor(entry[0], $event.target.value)">
</div>
<div class="surface-card">
<div class="metric-label mb-3">Сцены</div>
<div class="flex flex-wrap gap-2 mb-3">
<button v-for="scene in featuredScenes" :key="scene" @click="setScene(entry[0], scene)" :disabled="!sliders[entry[0]]?.state" class="glass px-3 py-2 rounded-xl text-xs font-bold text-slate-300 hover:text-white transition-colors disabled:opacity-30">
{{ scene }}
</button>
</div>
<select @change="setScene(entry[0], $event.target.value); $event.target.value=''" :disabled="!sliders[entry[0]]?.state" class="w-full bg-black/30 border border-slate-700/50 p-3 rounded-xl text-sm outline-none focus:border-orange-500 disabled:opacity-30">
<option value="" disabled selected>Все сцены...</option>
<option v-for="scene in sceneOptions" :key="scene" :value="scene">{{ scene }}</option>
</select>
</div>
</div>
</div>
</article>
</div>
</section>
</div>
<div v-if="tab === 'devices' && !isLoading && isAdmin" class="space-y-8 fade-up">
<section class="glass rounded-3xl p-6">
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4 mb-6">
<div>
<div class="section-kicker">Устройства и группы</div>
<h2 class="text-2xl font-black tracking-tight">Устройства</h2>
</div>
<button @click="rescan" :disabled="isRescanning" class="accent-button disabled:opacity-50">{{ isRescanning ? 'СКАНИРУЮ...' : 'СКАНИРОВАТЬ СЕТЬ' }}</button>
</div>
<div class="md:hidden space-y-3">
<article v-for="device in sortedDevices" :key="device.id" class="surface-card">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<div class="font-bold truncate">{{ device.name }}</div>
<div class="text-xs text-slate-400 mt-1">Локация: {{ deviceLocationLabel(device) }}</div>
</div>
<button @click="blink(device.id)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-slate-300 hover:text-orange-300 transition-colors shrink-0">BLINK</button>
</div>
<div class="grid grid-cols-1 gap-2 mt-4 text-[11px] text-slate-500 mono">
<div>IP: <span class="text-slate-300">{{ device.ip }}</span></div>
<div>MAC: <span class="text-slate-300 break-all">{{ device.id }}</span></div>
</div>
</article>
</div>
<div class="hidden md:block overflow-x-auto">
<table class="w-full min-w-[720px] table-fixed ui-table">
<thead>
<tr>
<th>Устройство</th>
<th>Локация</th>
<th>IP</th>
<th>MAC</th>
<th class="text-right">Действие</th>
</tr>
</thead>
<tbody>
<tr v-for="device in sortedDevices" :key="device.id">
<td>
<div class="font-bold">{{ device.name }}</div>
</td>
<td class="text-slate-400">{{ deviceLocationLabel(device) }}</td>
<td class="mono text-slate-300">{{ device.ip }}</td>
<td class="mono text-slate-500">{{ device.id }}</td>
<td class="text-right">
<button @click="blink(device.id)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-slate-300 hover:text-orange-300 transition-colors">BLINK</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="sortedDevices.length === 0" class="empty-state mt-4">Устройства не найдены. Запустите discovery и проверьте подсеть.</div>
</section>
<section class="grid grid-cols-1 xl:grid-cols-[1.2fr,0.8fr] gap-6">
<div class="glass rounded-3xl p-6">
<div class="section-kicker">Новая группа</div>
<h2 class="text-xl font-black tracking-tight mb-5">Собрать комнату из найденных ламп</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-5">
<input v-model="newGroup.id" placeholder="ID (bedroom)" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm mono">
<input v-model="newGroup.name" placeholder="Название (Спальня)" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm">
<button @click="createGroup" :disabled="!newGroup.id || !newGroup.name || !newGroup.macs.length" class="accent-button disabled:opacity-30 disabled:cursor-not-allowed">СОЗДАТЬ ГРУППУ</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div v-for="device in sortedDevices" :key="device.id" class="surface-row flex-col items-stretch sm:flex-row sm:items-center">
<label class="flex items-center gap-3 cursor-pointer flex-1 min-w-0">
<input type="checkbox" :value="device.id" v-model="newGroup.macs" class="w-4 h-4 accent-orange-500 rounded">
<div class="min-w-0">
<div class="font-bold truncate">{{ device.name }}</div>
<div class="text-[11px] mono text-slate-500 truncate">{{ device.ip }} · {{ device.id }}</div>
</div>
</label>
<div class="flex items-center justify-between gap-2 shrink-0 sm:max-w-[50%]">
<span class="text-xs text-slate-500 truncate">{{ deviceLocationLabel(device) }}</span>
<button @click="blink(device.id)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-slate-300 hover:text-orange-300 transition-colors">BLINK</button>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 mt-6">
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
<div class="text-[10px] uppercase text-slate-600 mb-2">Статус</div>
<div class="text-sm font-bold">Подключено</div>
<div class="text-[11px] text-slate-500 mt-1">API отвечает и готов к управлению.</div>
<div class="glass rounded-3xl p-6">
<div class="section-kicker">Существующие группы</div>
<h2 class="text-xl font-black tracking-tight mb-5">Комнаты и их состав</h2>
<div class="space-y-4">
<article v-for="entry in sortedGroups" :key="entry[0]" class="surface-card">
<div class="flex items-start justify-between gap-4 mb-3">
<div>
<h3 class="font-black text-lg">{{ entry[1].name }}</h3>
<div class="text-[11px] mono text-slate-500 mt-1">{{ entry[0] }}</div>
</div>
<button @click="deleteGroup(entry[0])" class="glass px-3 py-2 rounded-xl text-xs font-bold text-red-300 hover:text-red-200 transition-colors">УДАЛИТЬ</button>
</div>
<div class="flex flex-wrap gap-2">
<span v-for="deviceName in getGroupDeviceNames(entry[1])" :key="deviceName" class="status-pill text-slate-300 border-slate-700/70 bg-black/20">{{ deviceName }}</span>
</div>
</article>
<div v-if="sortedGroups.length === 0" class="empty-state">Групп пока нет.</div>
</div>
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
<div class="text-[10px] uppercase text-slate-600 mb-2">Запущен</div>
<div class="text-sm font-bold">{{ formatRelativeUptime(serverInfo.uptime_seconds) }}</div>
<div class="text-[11px] text-slate-500 mt-1">
{{ serverInfo.started_at ? formatServerTimestamp(serverInfo.started_at) : `аптайм ${formatUptime(serverInfo.uptime_seconds)}` }}
</div>
</section>
</div>
<div v-if="tab === 'automation' && !isLoading && isAdmin" class="space-y-8 fade-up">
<section class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div class="glass rounded-3xl p-6">
<div class="section-kicker">One-shot</div>
<h2 class="text-xl font-black tracking-tight mb-5">Одноразовая задача</h2>
<div class="space-y-4">
<select v-model="onceForm.target_id" class="w-full bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
<option value="" disabled>Группа...</option>
<option v-for="entry in sortedGroups" :key="entry[0]" :value="entry[0]">{{ entry[1].name }}</option>
</select>
<div class="grid grid-cols-2 gap-3">
<button @click="onceForm.state = true" :class="onceForm.state ? 'bg-orange-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-3 rounded-xl text-sm font-bold transition-all">Включить</button>
<button @click="onceForm.state = false" :class="!onceForm.state ? 'bg-slate-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-3 rounded-xl text-sm font-bold transition-all">Выключить</button>
</div>
</div>
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
<div class="text-[10px] uppercase text-slate-600 mb-2">Доступ</div>
<div class="text-sm font-bold">{{ isMaster ? 'Мастер' : (isAdmin ? 'Администратор' : 'Гостевой') }}</div>
<div class="text-[11px] text-slate-500 mt-1">
{{ isAdmin ? 'Служебная диагностика доступна на этой вкладке.' : 'Служебные параметры и адреса скрыты.' }}
<div class="grid grid-cols-2 gap-3">
<button @click="onceForm.mode = 'hours'" :class="onceForm.mode === 'hours' ? 'bg-orange-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-3 rounded-xl text-sm font-bold transition-all">Через N часов</button>
<button @click="onceForm.mode = 'datetime'" :class="onceForm.mode === 'datetime' ? 'bg-orange-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-3 rounded-xl text-sm font-bold transition-all">Точное время</button>
</div>
<div v-if="onceForm.mode === 'hours'">
<select v-model="onceForm.hours_from_now" class="w-full bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
<option :value="1">Через 1 час</option>
<option :value="2">Через 2 часа</option>
<option :value="4">Через 4 часа</option>
<option :value="8">Через 8 часов</option>
<option :value="12">Через 12 часов</option>
</select>
</div>
<div v-else>
<input v-model="onceForm.run_at" type="datetime-local" class="w-full bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
</div>
<button @click="createOnceTask" :disabled="!onceForm.target_id" class="accent-button disabled:opacity-30 disabled:cursor-not-allowed">СОЗДАТЬ ONE-SHOT</button>
</div>
<div v-if="serverInfo.diagnostics_visible" class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
<div class="text-[10px] uppercase text-slate-600 mb-2">Версия</div>
<div class="text-sm font-bold">{{ serverInfo.build?.version ? `v${serverInfo.build.version}` : 'не определена' }}</div>
<div class="text-[11px] text-slate-500 mt-1">{{ serverInfo.timezone || 'таймзона не указана' }}</div>
</div>
<div class="glass rounded-3xl p-6">
<div class="section-kicker">Cron</div>
<h2 class="text-xl font-black tracking-tight mb-5">Повторяющееся расписание</h2>
<div class="space-y-4">
<select v-model="cronForm.target_id" class="w-full bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
<option value="" disabled>Группа...</option>
<option v-for="entry in sortedGroups" :key="entry[0]" :value="entry[0]">{{ entry[1].name }}</option>
</select>
<div class="grid grid-cols-2 gap-3">
<button @click="cronForm.state = true" :class="cronForm.state ? 'bg-orange-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-3 rounded-xl text-sm font-bold transition-all">Включить</button>
<button @click="cronForm.state = false" :class="!cronForm.state ? 'bg-slate-600 text-white' : 'glass text-slate-300 hover:text-white'" class="px-4 py-3 rounded-xl text-sm font-bold transition-all">Выключить</button>
</div>
<div class="grid grid-cols-[1fr,24px,1fr] items-center gap-2">
<select v-model="cronHour" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm text-center">
<option v-for="h in 24" :key="h" :value="String(h-1).padStart(2,'0')">{{ String(h-1).padStart(2,'0') }}</option>
</select>
<span class="text-center text-slate-500 font-bold">:</span>
<select v-model="cronMinute" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm text-center">
<option v-for="m in 60" :key="m" :value="String(m-1).padStart(2,'0')">{{ String(m-1).padStart(2,'0') }}</option>
</select>
</div>
<select v-model="cronForm.day_of_week" class="w-full bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
<option value="*">Каждый день</option>
<option value="mon-fri">Будни</option>
<option value="sat,sun">Выходные</option>
<option value="mon">Понедельник</option>
<option value="tue">Вторник</option>
<option value="wed">Среда</option>
<option value="thu">Четверг</option>
<option value="fri">Пятница</option>
<option value="sat">Суббота</option>
<option value="sun">Воскресенье</option>
</select>
<button @click="createCronTask" :disabled="!cronForm.target_id" class="accent-button disabled:opacity-30 disabled:cursor-not-allowed">СОХРАНИТЬ CRON</button>
</div>
</div>
</section>
<section v-if="serverInfo?.diagnostics_visible" class="glass p-6 rounded-2xl">
<h2 class="text-lg font-black uppercase mb-5">Диагностика</h2>
<section class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div class="glass rounded-3xl p-6">
<div class="section-kicker">Cron</div>
<h2 class="text-xl font-black tracking-tight mb-5">Повторяющиеся задачи</h2>
<div class="space-y-3">
<article v-for="task in cronTasks" :key="task.id" class="surface-card">
<div class="flex items-start justify-between gap-4">
<div>
<div class="flex items-center gap-2 mb-2">
<span class="status-pill text-orange-200 border-orange-500/30 bg-orange-500/10">{{ formatTaskTime(task) }}</span>
<span class="status-pill text-slate-300 border-slate-700/70 bg-black/20">{{ formatActionLabel(task.action_params) }}</span>
</div>
<div class="font-bold">{{ getGroupName(task.target_id) || task.target_id }}</div>
<div class="text-[11px] text-slate-500 mt-1">{{ formatTaskSubtitle(task) }}</div>
</div>
<button @click="deleteTask(task.id)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-red-300 hover:text-red-200 transition-colors">УДАЛИТЬ</button>
</div>
</article>
<div v-if="cronTasks.length === 0" class="empty-state">Cron-задач пока нет.</div>
</div>
</div>
<div class="glass rounded-3xl p-6">
<div class="section-kicker">One-shot</div>
<h2 class="text-xl font-black tracking-tight mb-5">Одноразовые задачи</h2>
<div class="space-y-3">
<article v-for="task in onceTasks" :key="task.id" class="surface-card">
<div class="flex items-start justify-between gap-4">
<div>
<div class="flex items-center gap-2 mb-2">
<span class="status-pill text-blue-200 border-blue-500/30 bg-blue-500/10">{{ formatTaskTime(task) }}</span>
<span class="status-pill text-slate-300 border-slate-700/70 bg-black/20">{{ formatActionLabel(task.action_params) }}</span>
</div>
<div class="font-bold">{{ getGroupName(task.target_id) || task.target_id }}</div>
<div class="text-[11px] text-slate-500 mt-1">{{ formatTaskSubtitle(task) }}</div>
</div>
<button @click="deleteTask(task.id)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-red-300 hover:text-red-200 transition-colors">УДАЛИТЬ</button>
</div>
</article>
<div v-if="onceTasks.length === 0" class="empty-state">Одноразовых задач пока нет.</div>
</div>
</div>
</section>
</div>
<div v-if="tab === 'access' && !isLoading && isAdmin" class="space-y-8 fade-up">
<section class="grid grid-cols-1 xl:grid-cols-[1.1fr,0.9fr] gap-6">
<div class="glass rounded-3xl p-6">
<div class="flex items-center justify-between gap-3 mb-5">
<div>
<div class="section-kicker">Аудит</div>
<h2 class="text-2xl font-black tracking-tight">Активность за период</h2>
</div>
<div class="flex items-center gap-2">
<button v-for="days in [1, 7, 30]" :key="days" @click="statsDays = days; fetchStats()"
:class="statsDays === days ? 'bg-orange-600 text-white' : 'glass text-slate-300 hover:text-white'"
class="px-3 py-2 rounded-xl text-xs font-bold transition-all">
{{ days === 1 ? 'Сегодня' : `${days}д` }}
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<article v-for="summary in statsData" :key="summary.target_id" class="surface-card">
<div class="flex items-start justify-between gap-3 mb-3">
<div>
<h3 class="font-black text-lg">{{ getGroupName(summary.target_id) || summary.target_id }}</h3>
<div class="text-[11px] text-slate-500 mt-1">{{ summary.total_commands }} команд · {{ summary.estimated_hours }} ч активности</div>
</div>
<span class="status-pill text-orange-200 border-orange-500/30 bg-orange-500/10">{{ summary.toggles_on }}/{{ summary.toggles_off }}</span>
</div>
<div class="flex flex-wrap gap-2">
<span v-for="(count, actor) in summary.by_user" :key="actor" class="status-pill mono text-slate-300 border-slate-700/70 bg-black/20">{{ actor }}: {{ count }}</span>
</div>
</article>
<div v-if="statsData.length === 0" class="empty-state md:col-span-2">Нет статистики за выбранный период.</div>
</div>
</div>
<section v-if="isMaster" class="glass rounded-3xl p-6">
<div class="section-kicker">Доступ</div>
<h2 class="text-2xl font-black tracking-tight mb-5">Гостевые и админ-ключи</h2>
<div class="grid grid-cols-1 gap-3 mb-5">
<input v-model="newKeyName" placeholder="Имя ключа (гости, кухня, wall-panel)" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm">
<label class="surface-row cursor-pointer">
<div class="flex items-center gap-3">
<input type="checkbox" v-model="newKeyAdmin" class="w-4 h-4 accent-orange-500 rounded">
<span class="text-sm text-slate-300">Выдать админ-права этому ключу</span>
</div>
</label>
<button @click="createApiKey" :disabled="!newKeyName.trim()" class="accent-button disabled:opacity-30 disabled:cursor-not-allowed">СОЗДАТЬ КЛЮЧ</button>
</div>
<div v-if="lastCreatedKey" class="bg-green-900/20 border border-green-500/30 p-4 rounded-2xl mb-5">
<div class="flex items-center justify-between gap-3 mb-2">
<span class="text-[11px] font-bold uppercase tracking-[0.18em] text-green-300">Скопируйте новый ключ сейчас</span>
<button @click="copyKey(lastCreatedKey)" class="text-xs font-bold text-green-300 hover:text-green-100 transition-colors">КОПИРОВАТЬ</button>
</div>
<div class="mono text-sm text-green-200 break-all select-all">{{ lastCreatedKey }}</div>
</div>
<div class="space-y-3">
<article v-for="item in apiKeys" :key="item.key" class="surface-card">
<div class="flex items-start justify-between gap-4">
<div>
<div class="flex flex-wrap items-center gap-2 mb-2">
<span class="font-black">{{ item.name }}</span>
<span class="status-pill" :class="item.is_admin ? 'text-orange-200 border-orange-500/30 bg-orange-500/10' : 'text-slate-300 border-slate-700/70 bg-black/20'">{{ item.is_admin ? 'админ' : 'гость' }}</span>
<span class="status-pill" :class="item.active ? 'text-green-200 border-green-500/30 bg-green-500/10' : 'text-red-200 border-red-500/30 bg-red-500/10'">{{ item.active ? 'активен' : 'отозван' }}</span>
</div>
<div class="mono text-[11px] text-slate-500">{{ item.display_key || item.key }}</div>
</div>
<button v-if="item.active" @click="revokeApiKey(item.key, item.name)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-red-300 hover:text-red-200 transition-colors">ОТОЗВАТЬ</button>
<button v-else @click="activateApiKey(item.key, item.name)" class="glass px-3 py-2 rounded-xl text-xs font-bold text-green-300 hover:text-green-200 transition-colors">АКТИВИРОВАТЬ</button>
</div>
</article>
<div v-if="apiKeys.length === 0" class="empty-state">Ключей пока нет.</div>
</div>
</section>
</section>
<section class="glass rounded-3xl p-6">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-5">
<div>
<div class="section-kicker">Лог событий</div>
<h2 class="text-2xl font-black tracking-tight">Действия пользователей и автоматики</h2>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 lg:min-w-[420px]">
<select v-model="logFilterActor" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
<option value="all">Все пользователи</option>
<option v-for="actor in availableLogActors" :key="actor" :value="actor">{{ actor }}</option>
</select>
<select v-model="logFilterAction" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
<option value="all">Все действия</option>
<option v-for="action in availableLogActions" :key="action" :value="action">{{ action }}</option>
</select>
</div>
</div>
<div class="space-y-3 max-h-[36rem] overflow-y-auto pr-1">
<article v-for="event in filteredEventLog" :key="event.id" class="surface-row">
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<span class="status-pill mono text-slate-300 border-slate-700/70 bg-black/20">{{ event.key_name }}</span>
<span class="font-bold">{{ event.action }}</span>
<span class="text-sm text-slate-400">{{ getGroupName(event.target_id) || event.target_id }}</span>
</div>
<div class="text-[11px] text-slate-500">{{ formatTime(event.timestamp) }} · {{ event.target_type }}</div>
</div>
<div class="text-[11px] text-slate-500 mono">{{ event.id }}</div>
</article>
<div v-if="filteredEventLog.length === 0" class="empty-state">Под выбранные фильтры событий нет.</div>
</div>
</section>
</div>
<div v-if="tab === 'server' && !isLoading" class="space-y-8 fade-up">
<section v-if="serverInfo" class="glass rounded-3xl p-6 md:p-7">
<div class="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
<div>
<div class="section-kicker">О сервере</div>
<h2 class="text-2xl font-black tracking-tight">{{ serverDisplayName() }}</h2>
<div class="text-sm text-slate-400 mt-2">{{ serverDisplaySubtitle() }}</div>
</div>
<div class="flex flex-wrap gap-2 text-[10px]">
<span class="status-pill text-green-200 border-green-500/30 bg-green-500/10">онлайн</span>
<span :class="isAdmin ? 'text-blue-200 border-blue-500/30 bg-blue-500/10' : 'text-slate-300 border-slate-700/70 bg-black/20'" class="status-pill">
{{ isAdmin ? 'админ-доступ' : 'гостевой доступ' }}
</span>
<span v-if="serverInfo.diagnostics_visible" class="status-pill text-orange-200 border-orange-500/30 bg-orange-500/10">расширенная диагностика</span>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 mt-6">
<div class="surface-card">
<div class="metric-label">Запущен</div>
<div class="text-lg font-bold">{{ formatRelativeUptime(serverInfo.uptime_seconds) }}</div>
<div class="text-[11px] text-slate-500 mt-2">{{ serverInfo.started_at ? formatServerTimestamp(serverInfo.started_at) : `аптайм ${formatUptime(serverInfo.uptime_seconds)}` }}</div>
</div>
<div class="surface-card">
<div class="metric-label">Discovery</div>
<div class="text-lg font-bold">{{ serverDiscovery?.last_scan_at ? formatRelativeTimestamp(serverDiscovery.last_scan_at) : 'ещё не выполнялся' }}</div>
<div class="text-[11px] text-slate-500 mt-2">{{ serverDiscovery?.last_scan_mode ? `режим: ${formatDiscoveryMode(serverDiscovery.last_scan_mode)}` : 'статус появится после первого скана' }}</div>
</div>
<div class="surface-card">
<div class="metric-label">Доступ</div>
<div class="text-lg font-bold">{{ roleLabel }}</div>
<div class="text-[11px] text-slate-500 mt-2">{{ isAdmin ? 'Служебные вкладки открыты в этом сеансе.' : 'Чувствительные параметры скрыты.' }}</div>
</div>
<div v-if="serverInfo.diagnostics_visible" class="surface-card">
<div class="metric-label">Версия</div>
<div class="text-lg font-bold">{{ serverInfo.build?.version ? `v${serverInfo.build.version}` : 'не определена' }}</div>
<div class="text-[11px] text-slate-500 mt-2">{{ serverInfo.timezone || 'таймзона не указана' }}</div>
</div>
</div>
</section>
<section v-if="serverInfo?.diagnostics_visible" class="glass rounded-3xl p-6 md:p-7">
<div class="section-kicker">Диагностика</div>
<h2 class="text-2xl font-black tracking-tight mb-5">Инстанс и развёртывание</h2>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
<div class="text-[10px] uppercase text-slate-600 mb-2">Сборка</div>
<div class="surface-card">
<div class="metric-label">Сборка</div>
<div class="text-sm font-bold">{{ formatServerBuild(serverInfo.build) }}</div>
</div>
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
<div class="text-[10px] uppercase text-slate-600 mb-2">Таймзона</div>
<div class="surface-card">
<div class="metric-label">Таймзона</div>
<div class="text-sm font-bold">{{ serverInfo.timezone || 'не указана' }}</div>
</div>
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
<div class="text-[10px] uppercase text-slate-600 mb-2">Публичный URL</div>
<div class="text-xs mono break-all text-slate-300">
{{ serverInfo.urls?.effective_public_base_url || 'не определён' }}
</div>
<div class="surface-card">
<div class="metric-label">Публичный URL</div>
<div class="text-xs mono break-all text-slate-300">{{ serverInfo.urls?.effective_public_base_url || 'не определён' }}</div>
</div>
<div class="bg-black/20 border border-slate-800/50 rounded-xl p-4">
<div class="text-[10px] uppercase text-slate-600 mb-2">Наблюдаемый URL</div>
<div class="text-xs mono break-all text-slate-300">
{{ serverInfo.urls?.observed_base_url || 'не определён' }}
</div>
<div class="surface-card">
<div class="metric-label">Наблюдаемый URL</div>
<div class="text-xs mono break-all text-slate-300">{{ serverInfo.urls?.observed_base_url || 'не определён' }}</div>
</div>
</div>
<div class="flex flex-wrap gap-2 text-[10px] mt-5">
<span :class="serverInfo.configuration?.configured ? 'text-green-300 border-green-500/30 bg-green-500/10' : 'text-red-300 border-red-500/30 bg-red-500/10'" class="border px-2.5 py-1 rounded-lg font-bold uppercase">
<span :class="serverInfo.configuration?.configured ? 'text-green-200 border-green-500/30 bg-green-500/10' : 'text-red-200 border-red-500/30 bg-red-500/10'" class="status-pill">
{{ serverInfo.configuration?.configured ? 'настроен' : 'требует настройки' }}
</span>
<span :class="serverInfo.configuration?.public_base_url_configured ? 'text-blue-300 border-blue-500/30 bg-blue-500/10' : 'text-slate-400 border-slate-700/60 bg-black/20'" class="border px-2.5 py-1 rounded-lg font-bold uppercase">
<span :class="serverInfo.configuration?.public_base_url_configured ? 'text-blue-200 border-blue-500/30 bg-blue-500/10' : 'text-slate-300 border-slate-700/70 bg-black/20'" class="status-pill">
{{ serverInfo.configuration?.public_base_url_configured ? 'public url задан' : 'public url не задан' }}
</span>
<span :class="serverInfo.configuration?.scan_network_configured ? 'text-cyan-300 border-cyan-500/30 bg-cyan-500/10' : 'text-slate-400 border-slate-700/60 bg-black/20'" class="border px-2.5 py-1 rounded-lg font-bold uppercase">
<span :class="serverInfo.configuration?.scan_network_configured ? 'text-cyan-200 border-cyan-500/30 bg-cyan-500/10' : 'text-slate-300 border-slate-700/70 bg-black/20'" class="status-pill">
{{ serverInfo.configuration?.scan_network_configured ? 'scan network задан' : 'scan network auto' }}
</span>
<span :class="serverInfo.configuration?.build_metadata_complete ? 'text-orange-300 border-orange-500/30 bg-orange-500/10' : 'text-slate-400 border-slate-700/60 bg-black/20'" class="border px-2.5 py-1 rounded-lg font-bold uppercase">
<span :class="serverInfo.configuration?.build_metadata_complete ? 'text-orange-200 border-orange-500/30 bg-orange-500/10' : 'text-slate-300 border-slate-700/70 bg-black/20'" class="status-pill">
{{ serverInfo.configuration?.build_metadata_complete ? 'build metadata полная' : 'build metadata частичная' }}
</span>
</div>
</section>
</div>
<!-- ПУЛЬТ -->
<div v-if="tab === 'control' && !isLoading" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div v-if="Object.keys(groups).length === 0" class="col-span-full text-center py-20 glass rounded-2xl">
<p v-if="isAdmin" class="text-slate-500">Создайте группу в админке</p>
<p v-else class="text-slate-500">Нет доступных групп</p>
</div>
<div v-for="(group, id) in groups" :key="id" class="glass p-6 rounded-2xl transition-all fade-up" :class="sliders[id]?.state ? 'group-active' : ''">
<div class="flex justify-between items-start mb-6">
<div>
<h2 class="text-xl font-black text-white flex items-center gap-2">
{{ group.name }}
<span :class="sliders[id]?.state ? 'bg-green-400 pulse-on' : 'bg-slate-600'" class="w-2 h-2 rounded-full inline-block"></span>
</h2>
<span class="text-[10px] mono text-slate-600">{{ id }} · {{ group.device_ids?.length || 0 }} ламп</span>
</div>
<div class="flex gap-1.5">
<button v-if="isAdmin" @click="deleteGroup(id)" class="p-2 rounded-lg bg-slate-800/50 hover:bg-red-900/40 text-slate-600 hover:text-red-400 transition-all" title="Удалить">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button v-if="isAdmin" @click="setTimer4h(id)" class="p-2 px-3 rounded-lg bg-blue-600/20 hover:bg-blue-600/40 text-blue-400 text-xs font-bold transition-all" title="4 часа"></button>
<button @click="toggleGroup(id, true)" :class="sliders[id]?.state ? 'bg-orange-600 text-white' : 'bg-slate-800/50 text-slate-400 hover:bg-orange-600/30 hover:text-orange-300'" class="p-2 px-4 rounded-lg font-bold text-sm transition-all">ВКЛ</button>
<button @click="toggleGroup(id, false)" :class="!sliders[id]?.state ? 'bg-slate-600 text-white' : 'bg-slate-800/50 text-slate-400 hover:bg-slate-600/50'" class="p-2 px-4 rounded-lg font-bold text-sm transition-all">ВЫКЛ</button>
</div>
</div>
<div class="space-y-5">
<div>
<div class="flex justify-between text-[10px] font-bold uppercase text-slate-500 mb-2"><span>Яркость</span><span :class="sliders[id]?.state ? 'text-orange-400' : 'text-slate-600'" class="mono">{{ sliders[id]?.brightness || 100 }}%</span></div>
<input type="range" min="10" max="100" step="10" class="w-full" :disabled="!sliders[id]?.state" :value="sliders[id]?.brightness || 100" @change="setBrightness(id, +$event.target.value)">
</div>
<div>
<div class="flex justify-between text-[10px] font-bold uppercase text-slate-500 mb-2"><span>Температура</span><span :class="sliders[id]?.state ? 'text-blue-300' : 'text-slate-600'" class="mono">{{ sliders[id]?.temp || 4000 }}K</span></div>
<input type="range" min="2700" max="6500" step="100" class="w-full temp-gradient" :disabled="!sliders[id]?.state" :value="sliders[id]?.temp || 4000" @change="setTemp(id, +$event.target.value)">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 mb-2 block">Цвет</label>
<input type="color" class="w-full h-10 bg-transparent border border-slate-700/50 rounded-lg cursor-pointer" :disabled="!sliders[id]?.state" @input="setColor(id, $event.target.value)">
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 mb-2 block">Сцена</label>
<select @change="setScene(id, $event.target.value); $event.target.value=''" :disabled="!sliders[id]?.state" class="w-full bg-black/30 border border-slate-700/50 p-2.5 rounded-lg text-xs outline-none focus:border-orange-500 disabled:opacity-25">
<option value="" disabled selected>Пресет...</option>
<option v-for="(sceneId, sceneName) in allScenes" :key="sceneName" :value="sceneName">{{ sceneName }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- РАСПИСАНИЯ (только админ) -->
<div v-if="tab === 'schedules' && !isLoading && isAdmin" class="space-y-8 fade-up">
<div class="glass p-6 rounded-2xl">
<h2 class="text-lg font-black mb-5 uppercase">Новая задача</h2>
<div class="grid grid-cols-1 md:grid-cols-4 gap-3">
<select v-model="newTask.target_id" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none focus:border-orange-500 text-sm">
<option value="" disabled>Группа...</option>
<option v-for="(g, id) in groups" :key="id" :value="id">{{ g.name }}</option>
</select>
<div class="flex items-center gap-1 bg-black/30 border border-slate-700/50 p-1 rounded-xl">
<select v-model="taskHour" class="bg-transparent outline-none flex-1 text-center font-bold text-sm">
<option v-for="h in 24" :key="h" :value="String(h-1).padStart(2,'0')">{{ String(h-1).padStart(2,'0') }}</option>
</select>
<span class="font-bold text-slate-500">:</span>
<select v-model="taskMin" class="bg-transparent outline-none flex-1 text-center font-bold text-sm">
<option v-for="m in 60" :key="m" :value="String(m-1).padStart(2,'0')">{{ String(m-1).padStart(2,'0') }}</option>
</select>
</div>
<select v-model="newTask.state" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl outline-none text-sm">
<option :value="true">ВКЛЮЧИТЬ</option>
<option :value="false">ВЫКЛЮЧИТЬ</option>
</select>
<button @click="addSchedule" :disabled="!newTask.target_id" class="bg-orange-600 hover:bg-orange-500 disabled:opacity-30 disabled:cursor-not-allowed rounded-xl font-bold transition-all text-sm active:scale-[0.97]">ДОБАВИТЬ</button>
</div>
</div>
<div class="glass p-6 rounded-2xl">
<h2 class="text-lg font-black mb-5 uppercase">Активные задачи</h2>
<div class="space-y-3">
<div v-for="task in tasks" :key="task.id" class="bg-black/20 border border-slate-800/50 p-4 rounded-xl flex justify-between items-center group hover:border-slate-700 transition-all">
<div>
<div class="flex items-center gap-3 mb-1">
<span class="mono text-orange-400 text-lg font-bold">
<template v-if="task.hour != null">{{ String(task.hour).padStart(2,'0') }}:{{ String(task.minute).padStart(2,'0') }}</template>
<template v-else-if="task.next_run">{{ new Date(task.next_run).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}) }}</template>
</span>
<span :class="task.state ? 'text-green-400' : 'text-red-400'" class="text-[10px] font-bold uppercase">{{ task.state ? 'ВКЛ' : 'ВЫКЛ' }}</span>
</div>
<div class="flex items-center gap-2 text-[10px] text-slate-600">
<span class="mono">{{ task.target_id }}</span>
<span v-if="getGroupName(task.target_id)" class="text-slate-500">· {{ getGroupName(task.target_id) }}</span>
<span v-if="task.next_run">· {{ new Date(task.next_run).toLocaleDateString() }}</span>
</div>
</div>
<button @click="deleteTask(task.id)" class="text-slate-700 hover:text-red-400 p-2 transition-colors opacity-0 group-hover:opacity-100">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
<div v-if="tasks.length === 0" class="text-center py-12 text-slate-600 text-sm">Задач пока нет</div>
</div>
</div>
</div>
<!-- АДМИНКА (только админ) -->
<div v-if="tab === 'admin' && !isLoading && isAdmin" class="space-y-8 fade-up">
<section class="glass p-6 rounded-2xl">
<div class="flex justify-between items-center mb-6">
<h2 class="text-lg font-black uppercase">Устройства в сети</h2>
<button @click="rescan" :disabled="isRescanning" class="bg-blue-600/20 hover:bg-blue-600/30 text-blue-400 px-4 py-2 rounded-lg text-xs font-bold transition-all disabled:opacity-50 flex items-center gap-2">
<span :class="isRescanning ? 'spinner' : ''" class="inline-block">🔄</span> СКАНИРОВАТЬ
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-8">
<input v-model="newGroup.id" placeholder="ID (bedroom)" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm mono">
<input v-model="newGroup.name" placeholder="Название (Спальня)" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm">
<button @click="createGroup" :disabled="!newGroup.id || !newGroup.macs.length" class="bg-orange-600 hover:bg-orange-500 disabled:opacity-30 disabled:cursor-not-allowed rounded-xl font-bold transition-all text-sm active:scale-[0.97]">СОЗДАТЬ ГРУППУ</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<div v-for="dev in devices" :key="dev.id" :class="newGroup.macs.includes(dev.id) ? 'border-orange-500/40 bg-orange-500/5' : 'border-slate-800/50'" class="border p-3 rounded-xl flex items-center justify-between transition-all hover:border-slate-600">
<label class="flex items-center gap-3 cursor-pointer flex-1">
<input type="checkbox" :value="dev.id" v-model="newGroup.macs" class="w-4 h-4 accent-orange-500 rounded">
<div>
<p class="font-bold text-sm mono">{{ dev.id }}</p>
<p class="text-[10px] mono text-slate-600">{{ dev.ip }}</p>
</div>
</label>
<button @click="blink(dev.id)" class="p-2 rounded-lg bg-slate-800/30 hover:bg-orange-600/30 hover:text-orange-400 transition-all text-slate-600" title="Мигнуть">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</button>
</div>
</div>
<div v-if="devices.length === 0" class="text-center py-10 text-slate-600 text-sm">Устройства не найдены. Нажмите "Сканировать".</div>
</section>
<!-- Гостевые API-ключи -->
<section v-if="isMaster" class="glass p-6 rounded-2xl">
<h2 class="text-lg font-black mb-6 uppercase">Гостевые ключи</h2>
<!-- Создание -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-6">
<input v-model="newKeyName" placeholder="Имя (Вася, гости...)" class="bg-black/30 border border-slate-700/50 p-3 rounded-xl focus:border-orange-500 outline-none text-sm">
<label class="flex items-center gap-2 bg-black/30 border border-slate-700/50 p-3 rounded-xl text-sm text-slate-400 cursor-pointer">
<input type="checkbox" v-model="newKeyAdmin" class="w-4 h-4 accent-orange-500 rounded">
Админ-права
</label>
<button @click="createApiKey" :disabled="!newKeyName.trim()" class="bg-orange-600 hover:bg-orange-500 disabled:opacity-30 disabled:cursor-not-allowed rounded-xl font-bold transition-all text-sm active:scale-[0.97]">СОЗДАТЬ КЛЮЧ</button>
</div>
<!-- Только что созданный ключ -->
<div v-if="lastCreatedKey" class="bg-green-900/20 border border-green-500/30 p-4 rounded-xl mb-6">
<div class="flex justify-between items-center mb-2">
<span class="text-green-400 text-xs font-bold uppercase">Новый ключ создан -- скопируйте!</span>
<button @click="copyKey(lastCreatedKey)" class="text-green-400 hover:text-green-300 text-xs font-bold transition-colors">КОПИРОВАТЬ</button>
</div>
<div class="mono text-sm text-green-300 break-all select-all bg-black/30 p-3 rounded-lg">{{ lastCreatedKey }}</div>
</div>
<!-- Список ключей -->
<div class="space-y-2">
<div v-for="k in apiKeys" :key="k.key" class="bg-black/20 border border-slate-800/50 p-4 rounded-xl flex justify-between items-center group hover:border-slate-700 transition-all">
<div>
<div class="flex items-center gap-2">
<span class="font-bold text-sm">{{ k.name }}</span>
<span v-if="k.is_admin" class="text-[9px] text-orange-400 font-bold uppercase bg-orange-400/10 px-2 py-0.5 rounded">админ</span>
<span v-else class="text-[9px] text-slate-500 font-bold uppercase bg-slate-500/10 px-2 py-0.5 rounded">гость</span>
<span :class="k.active ? 'text-green-500' : 'text-red-500'" class="text-[9px] font-bold uppercase">{{ k.active ? 'активен' : 'отозван' }}</span>
</div>
<div class="mono text-[10px] text-slate-600 mt-1">{{ k.display_key || (k.key.slice(0, 12) + '...' + k.key.slice(-6)) }}</div>
</div>
<div class="flex gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button v-if="k.active" @click="revokeApiKey(k.key, k.name)" class="p-2 rounded-lg bg-red-900/20 hover:bg-red-900/40 text-red-400 text-xs font-bold transition-all" title="Отозвать">ОТОЗВАТЬ</button>
<button v-else @click="activateApiKey(k.key, k.name)" class="p-2 rounded-lg bg-green-900/20 hover:bg-green-900/40 text-green-400 text-xs font-bold transition-all" title="Активировать">АКТИВИР.</button>
</div>
</div>
<div v-if="apiKeys.length === 0" class="text-center py-8 text-slate-600 text-sm">Гостевых ключей нет</div>
</div>
</section>
</div>
<!-- СТАТИСТИКА (только админ) -->
<div v-if="tab === 'stats' && !isLoading && isAdmin" class="space-y-8 fade-up">
<!-- Период -->
<div class="flex items-center gap-3">
<span class="text-sm text-slate-500">Период:</span>
<button v-for="d in [1, 7, 30]" :key="d" @click="statsDays = d; fetchStats()"
:class="statsDays === d ? 'bg-orange-600 text-white' : 'bg-slate-800/50 text-slate-400 hover:text-white'"
class="px-4 py-1.5 rounded-lg text-sm font-bold transition-all">
{{ d === 1 ? 'Сегодня' : d + 'д' }}
</button>
</div>
<!-- Карточки по группам -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div v-for="s in statsData" :key="s.target_id" class="glass p-6 rounded-2xl">
<div class="flex justify-between items-start mb-4">
<div>
<h3 class="text-lg font-black">{{ getGroupName(s.target_id) || s.target_id }}</h3>
<span class="text-[10px] mono text-slate-600">{{ s.target_id }}</span>
</div>
<span class="mono text-2xl font-black text-orange-400">{{ s.estimated_hours }}ч</span>
</div>
<div class="grid grid-cols-3 gap-3 mb-4">
<div class="bg-black/20 p-3 rounded-xl text-center">
<div class="text-lg font-bold text-green-400">{{ s.toggles_on }}</div>
<div class="text-[10px] text-slate-600 uppercase">вкл</div>
</div>
<div class="bg-black/20 p-3 rounded-xl text-center">
<div class="text-lg font-bold text-red-400">{{ s.toggles_off }}</div>
<div class="text-[10px] text-slate-600 uppercase">выкл</div>
</div>
<div class="bg-black/20 p-3 rounded-xl text-center">
<div class="text-lg font-bold text-slate-300">{{ s.total_commands }}</div>
<div class="text-[10px] text-slate-600 uppercase">всего</div>
</div>
</div>
<div class="flex flex-wrap gap-2 mb-3">
<span v-if="s.scenes" class="text-[10px] bg-purple-500/10 text-purple-400 px-2 py-1 rounded font-bold">🎨 {{ s.scenes }} сцен</span>
<span v-if="s.colors" class="text-[10px] bg-pink-500/10 text-pink-400 px-2 py-1 rounded font-bold">🌈 {{ s.colors }} цветов</span>
<span v-if="s.brightness" class="text-[10px] bg-yellow-500/10 text-yellow-400 px-2 py-1 rounded font-bold">🔆 {{ s.brightness }} яркость</span>
<span v-if="s.temperature" class="text-[10px] bg-blue-500/10 text-blue-400 px-2 py-1 rounded font-bold">🌡 {{ s.temperature }} темп.</span>
</div>
<!-- Кто управлял -->
<div v-if="Object.keys(s.by_user).length > 0" class="border-t border-slate-800/50 pt-3">
<div class="text-[10px] text-slate-600 uppercase mb-2">Кто управлял</div>
<div class="flex flex-wrap gap-2">
<span v-for="(count, user) in s.by_user" :key="user" class="text-[10px] mono bg-slate-800/50 text-slate-400 px-2 py-1 rounded">
{{ user }}: {{ count }}
</span>
</div>
</div>
</div>
</div>
<div v-if="statsData.length === 0" class="text-center py-16 glass rounded-2xl text-slate-600 text-sm">
Нет данных за выбранный период
</div>
<!-- Лог последних событий -->
<div class="glass p-6 rounded-2xl">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-black uppercase">Лог событий</h2>
<button @click="fetchEventLog" class="text-xs text-slate-500 hover:text-orange-400 font-bold transition-colors">ОБНОВИТЬ</button>
</div>
<div class="space-y-1 max-h-96 overflow-y-auto">
<div v-for="ev in eventLog" :key="ev.id" class="flex items-center gap-3 py-2 border-b border-slate-800/30 text-sm">
<span class="mono text-[10px] text-slate-600 w-36 shrink-0">{{ formatTime(ev.timestamp) }}</span>
<span class="mono text-[10px] text-slate-500 w-16 shrink-0">{{ ev.key_name }}</span>
<span :class="{
'text-green-400': ev.action === 'toggle_on',
'text-red-400': ev.action === 'toggle_off',
'text-purple-400': ev.action === 'scene',
'text-pink-400': ev.action === 'color',
'text-yellow-400': ev.action === 'brightness',
'text-blue-400': ev.action === 'temperature',
'text-slate-400': !['toggle_on','toggle_off','scene','color','brightness','temperature'].includes(ev.action),
}" class="text-xs font-bold w-24 shrink-0">{{ ev.action }}</span>
<span class="text-xs text-slate-500">{{ getGroupName(ev.target_id) || ev.target_id }}</span>
</div>
<div v-if="eventLog.length === 0" class="text-center py-8 text-slate-600 text-sm">Событий пока нет</div>
</div>
</div>
</div>
</template>
<!-- Toast -->
<div class="fixed bottom-6 right-6 z-50 space-y-2">
<div v-for="(toast, i) in toasts" :key="toast.id" :class="toast.type === 'error' ? 'border-red-500/30 text-red-300' : toast.type === 'success' ? 'border-green-500/30 text-green-300' : 'border-slate-700 text-slate-300'" class="glass border px-5 py-3 rounded-xl text-sm font-medium toast-enter shadow-xl max-w-xs">{{ toast.text }}</div>
<div v-for="toast in toasts" :key="toast.id" :class="toast.type === 'error' ? 'border-red-500/30 text-red-200' : toast.type === 'success' ? 'border-green-500/30 text-green-200' : 'border-slate-700 text-slate-200'" class="glass border px-5 py-3 rounded-xl text-sm font-medium toast-enter shadow-xl max-w-xs">{{ toast.text }}</div>
</div>
</div>

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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)