Refine built-in web app experience
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
1
main.py
1
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"])
|
||||
|
||||
104
openapi.json
104
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",
|
||||
|
||||
445
static/app.js
445
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() {
|
||||
|
||||
@@ -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 часа">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>
|
||||
|
||||
|
||||
203
static/ui.css
203
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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user