fix(keenetic): Web/AnyDesk links and import sync

Add web_url/anydesk to router records, host fallback for Web button
on offline cards, cache-bust app.js, and scripts to sync import list.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Андрей Бобырев
2026-05-21 23:06:35 +03:00
parent cc01667879
commit de99b60910
6 changed files with 1465 additions and 14 deletions

43
scripts/check_keenetic.py Normal file
View File

@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Check Keenetic API connectivity for all routers."""
import asyncio
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from server.services.keenetic_client import KeeneticClient
DATA = Path("/opt/vps-monitoring/data/keenetic.json")
async def check(dev):
c = KeeneticClient(
host=dev.get("host", ""),
login=dev.get("login", "admin"),
password=dev.get("password", ""),
web_url=dev.get("web_url", ""),
)
try:
m = await c.collect_metrics()
if m.get("online"):
return "online"
err = (m.get("error") or "unknown")[:80]
return f"offline: {err}"
except Exception as e:
return f"err: {str(e)[:80]}"
finally:
await c.close()
async def main():
with open(DATA) as f:
devices = json.load(f)
for d in devices:
r = await check(d)
print(f"{d['name']:22} {r}")
await asyncio.sleep(0.3)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Sync keenetic.json with canonical import list (names, web_url, anydesk)."""
import json
import sys
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
IMPORT = [
("Лилиана Лофт", "https://loftliliana.netcraze.pro", "1020687391"),
("Артем vtb126", "https://vtb126.netcraze.club", ""),
("Артем Квартира Подмосковный", "http://95.165.93.46:777", "1527495291"),
("Артем Квартира 2", "https://pomidor.netcraze.pro:5443", ""),
("Артем Малаховка", "https://malahovka1.netcraze.pro:5083", ""),
("Арсен Квартира", "https://arsen53.netcraze.link", "1633258458"),
("Вишневый Сад", "https://visheviisad.netcraze.link", "135128054"),
("Антоновка", "https://antonovka.netcraze.pro:5083", "1356582394"),
("Цехомский Николай Дом", "https://peredelkino15.netcraze.link", "1726497286"),
("Цехомский Домик Истра", "https://utrodomik.netcraze.club:5443", "451325164"),
("Маршала Жукова Квартира", "https://marshalaszukova.netcraze.pro", "1677737879"),
("Ломакин Квартира", "https://lomakinkvartira.netcraze.pro", ""),
("Ломакин Дача", "https://lomakindacha.netcraze.pro:8443/", "815142240"),
("КАСТАНАЕВСКАЯ", "https://kastanaevskaya.netcraze.link", "1627649255"),
("Чиверево Меламед", "https://chiverevo.netcraze.pro", "846367657"),
("Рав Гедалья Меламед", "https://ravged.netcraze.pro", ""),
("Кургин Дом", "https://kurgin.netcraze.link", "709243112"),
("Таланова Дом", "https://talanovadom.netcraze.pro", "951049627"),
("Загорье Дом", "https://zagorie.netcraze.link", "249788953"),
]
def normalize_web_url(url: str) -> str:
url = (url or "").strip().rstrip("/")
if not url:
return ""
if not url.startswith(("http://", "https://")):
url = "https://" + url
return url
def host_from_url(url: str) -> str:
parsed = urlparse(normalize_web_url(url))
return parsed.netloc or url.replace("https://", "").replace("http://", "").rstrip("/")
IMPORT_HOSTS = {host_from_url(url) for _, url, _ in IMPORT}
def make_device(name, keenetic_url, login, password, anydesk="", added=None):
keenetic_url = normalize_web_url(keenetic_url)
host = host_from_url(keenetic_url)
return {
"name": name,
"host": host,
"web_url": keenetic_url,
"anydesk": (anydesk or "").strip(),
"login": login,
"password": password,
"added": added or datetime.now().isoformat(),
}
def sync_file(path: Path, default_password: str):
with open(path) as f:
old = json.load(f)
by_host = {d.get("host", ""): d for d in old}
path.with_suffix(".json.bak").write_text(
json.dumps(old, indent=2, ensure_ascii=False), encoding="utf-8"
)
result = []
for name, url, ad in IMPORT:
host = host_from_url(url)
prev = by_host.get(host, {})
login = prev.get("login", "admin")
password = prev.get("password") or default_password
added = prev.get("added")
result.append(make_device(name, url, login, password, ad, added))
for d in old:
host = d.get("host", "")
if host in IMPORT_HOSTS:
continue
if not d.get("web_url") and host:
h = host
d["web_url"] = normalize_web_url(
h if h.startswith("http") else ("http://" if h[0].isdigit() else "https://") + h
)
result.append(d)
path.write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"Wrote {len(result)} devices ({len(IMPORT)} import + {len(result) - len(IMPORT)} extra)")
if __name__ == "__main__":
p = Path(sys.argv[1] if len(sys.argv) > 1 else "/opt/vps-monitoring/data/keenetic.json")
pwd = sys.argv[2] if len(sys.argv) > 2 else "Ipadipad1"
sync_file(p, pwd)

318
server/api/keenetic.py Normal file
View File

@@ -0,0 +1,318 @@
"""Keenetic router monitoring API endpoints."""
import asyncio
import json
import logging
from datetime import datetime
from typing import Dict, List
from urllib.parse import urlparse
from fastapi import APIRouter, Request, Depends
from server.auth import require_auth
from server.config import DATA_DIR, load_settings
from server.services.keenetic_client import (
KeeneticClient,
normalize_web_url,
build_api_base_url,
)
router = APIRouter(prefix="/api/keenetic", tags=["keenetic"])
logger = logging.getLogger(__name__)
keenetic_metrics: Dict[str, dict] = {}
KEENETIC_FILE = DATA_DIR / "keenetic.json"
def _load_keenetic():
if KEENETIC_FILE.exists():
with open(KEENETIC_FILE) as f:
return json.load(f)
return []
def _save_keenetic(data):
with open(KEENETIC_FILE, "w") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def _host_from_url(url: str) -> str:
"""host:port for API from Keenetic web URL."""
parsed = urlparse(normalize_web_url(url))
return parsed.netloc or url.replace("https://", "").replace("http://", "").rstrip("/")
def _make_device(name: str, keenetic_url: str, login: str, password: str,
anydesk: str = "") -> dict:
keenetic_url = (keenetic_url or "").strip()
host = _host_from_url(keenetic_url) if keenetic_url else ""
return {
"name": name,
"host": host,
"web_url": normalize_web_url(keenetic_url) if keenetic_url else "",
"anydesk": (anydesk or "").strip(),
"login": login,
"password": password,
"added": datetime.now().isoformat(),
}
def _client_for_device(dev: dict) -> KeeneticClient:
return KeeneticClient(
host=dev.get("host", ""),
login=dev.get("login", "admin"),
password=dev.get("password", ""),
web_url=dev.get("web_url", ""),
)
async def _refresh_device(dev: dict) -> dict:
name = dev["name"]
client = _client_for_device(dev)
try:
cached = keenetic_metrics.get(name)
metrics = await client.collect_metrics(cached_info=cached)
metrics["last_updated"] = datetime.now().isoformat()
keenetic_metrics[name] = metrics
return metrics
finally:
await client.close()
@router.get("/list")
async def keenetic_list(request: Request, user: str = Depends(require_auth)):
devices = _load_keenetic()
for dev in devices:
name = dev["name"]
if name in keenetic_metrics:
dev["metrics"] = keenetic_metrics[name]
return devices
@router.post("/add")
async def keenetic_add(request: Request, user: str = Depends(require_auth)):
body = await request.json()
name = body.get("name", "").strip()
host = body.get("host", "").strip()
web_url = body.get("web_url", "").strip()
if not name or (not host and not web_url):
return {"status": "error", "detail": "name and host (or web_url) required"}
if not host and web_url:
host = _host_from_url(web_url)
devices = _load_keenetic()
device = {
"name": name,
"host": host,
"web_url": normalize_web_url(web_url or host),
"anydesk": body.get("anydesk", "").strip(),
"login": body.get("login", "admin"),
"password": body.get("password", ""),
"added": datetime.now().isoformat(),
}
devices.append(device)
_save_keenetic(devices)
return {"status": "ok"}
@router.post("/import")
async def keenetic_import(request: Request, user: str = Depends(require_auth)):
"""Import routers from spreadsheet columns: Address, Keenetic, AnyDesk."""
body = await request.json()
login = body.get("login", "admin").strip()
password = body.get("password", "")
rows = body.get("rows")
tsv = body.get("tsv", "").strip()
parsed: List[dict] = []
if tsv:
lines = [l for l in tsv.splitlines() if l.strip()]
if not lines:
return {"status": "error", "detail": "Empty import"}
header = [c.strip().lower() for c in lines[0].split("\t")]
if len(header) < 2:
header = [c.strip().lower() for c in lines[0].split(",")]
start = 1 if any(h in ("address", "keenetic", "anydesk", "имя") for h in header) else 0
if start == 0:
header = ["address", "keenetic", "anydesk"]
col = {h: i for i, h in enumerate(header)}
def col_val(parts, *keys):
for k in keys:
if k in col and col[k] < len(parts):
return parts[col[k]].strip()
return ""
for line in lines[start:]:
parts = line.split("\t") if "\t" in line else line.split(",")
addr = col_val(parts, "address", "имя", "name")
keen = col_val(parts, "keenetic", "url", "веб")
ad = col_val(parts, "anydesk", "any desk")
if keen or addr:
parsed.append({"address": addr, "keenetic": keen, "anydesk": ad})
elif rows:
parsed = rows
else:
return {"status": "error", "detail": "Provide rows or tsv"}
devices = _load_keenetic()
existing_hosts = {d.get("host") for d in devices}
existing_names = {d["name"] for d in devices}
added, skipped = [], []
for row in parsed:
keen_url = (row.get("keenetic") or row.get("url") or "").strip()
if not keen_url:
continue
host = _host_from_url(keen_url)
addr = (row.get("address") or row.get("name") or "").strip()
name = addr.capitalize() if addr else host.split(".")[0].capitalize()
base_name, counter = name, 2
while name in existing_names:
name = f"{base_name}_{counter}"
counter += 1
if host in existing_hosts:
skipped.append(host)
continue
device = _make_device(name, keen_url, login, password, row.get("anydesk", ""))
devices.append(device)
existing_hosts.add(host)
existing_names.add(name)
added.append(name)
_save_keenetic(devices)
return {"status": "ok", "added": added, "skipped": skipped}
@router.post("/add-bulk")
async def keenetic_add_bulk(request: Request, user: str = Depends(require_auth)):
body = await request.json()
domains_raw = body.get("domains", "")
login = body.get("login", "admin").strip()
password = body.get("password", "")
lines = [l.strip() for l in domains_raw.strip().splitlines() if l.strip()]
if not lines:
return {"status": "error", "detail": "No domains provided"}
devices = _load_keenetic()
existing = {d["name"] for d in devices}
existing_hosts = {d.get("host") for d in devices}
added, skipped = [], []
for raw in lines:
keen_url = raw if "://" in raw else f"https://{raw}"
host = _host_from_url(keen_url)
name = host.split(".")[0].capitalize() if "." in host else host
base_name, counter = name, 2
while name in existing:
name = f"{base_name}_{counter}"
counter += 1
if host in existing_hosts:
skipped.append(host)
continue
device = _make_device(name, keen_url, login, password)
devices.append(device)
existing.add(name)
existing_hosts.add(host)
added.append(name)
_save_keenetic(devices)
return {"status": "ok", "added": added, "skipped": skipped}
@router.delete("/{name}")
async def keenetic_delete(name: str, request: Request, user: str = Depends(require_auth)):
devices = _load_keenetic()
devices = [d for d in devices if d["name"] != name]
_save_keenetic(devices)
keenetic_metrics.pop(name, None)
return {"status": "ok"}
@router.post("/refresh/{name}")
async def keenetic_refresh(name: str, request: Request, user: str = Depends(require_auth)):
devices = _load_keenetic()
dev = next((d for d in devices if d["name"] == name), None)
if not dev:
return {"status": "error", "detail": "router not found"}
try:
metrics = await _refresh_device(dev)
if not metrics["online"] and metrics.get("error"):
return {"status": "error", "detail": metrics["error"], "metrics": metrics}
return {"status": "ok", "metrics": metrics}
except Exception as e:
return {"status": "error", "detail": str(e)}
@router.post("/refresh-all")
async def keenetic_refresh_all(request: Request, user: str = Depends(require_auth)):
devices = _load_keenetic()
results = []
for dev in devices:
try:
metrics = await _refresh_device(dev)
results.append({"name": dev["name"], "online": metrics["online"],
"error": metrics.get("error", "")})
except Exception as e:
results.append({"name": dev["name"], "online": False, "error": str(e)})
return {"status": "ok", "results": results}
@router.get("/detail/{name}")
async def keenetic_detail(name: str, request: Request, user: str = Depends(require_auth)):
devices = _load_keenetic()
dev = next((d for d in devices if d["name"] == name), None)
if not dev:
return {"status": "error", "detail": "router not found"}
client = _client_for_device(dev)
try:
detail = await client.collect_detail()
if detail:
return {"status": "ok", **detail}
return {"status": "error", "detail": client.last_error or "No data"}
finally:
await client.close()
@router.post("/reboot/{name}")
async def keenetic_reboot(name: str, request: Request, user: str = Depends(require_auth)):
devices = _load_keenetic()
dev = next((d for d in devices if d["name"] == name), None)
if not dev:
return {"status": "error", "detail": "router not found"}
client = _client_for_device(dev)
try:
if not await client.authenticate():
return {"status": "error", "detail": client.last_error or "Authentication failed"}
await client.rci_post({"system": {"reboot": {}}})
return {"status": "ok", "detail": f"Reboot command sent to {name}"}
finally:
await client.close()
async def keenetic_monitor_loop():
"""Background polling for all Keenetic routers."""
await asyncio.sleep(15)
while True:
try:
devices = _load_keenetic()
if devices:
logger.info(f"Keenetic monitor: refreshing {len(devices)} routers")
for dev in devices:
try:
await _refresh_device(dev)
except Exception as e:
logger.error(f"Keenetic refresh {dev['name']}: {e}")
await asyncio.sleep(2)
except Exception as e:
logger.error(f"Keenetic monitor loop error: {e}")
settings = load_settings()
interval = int(settings.get("keenetic_interval", 60))
await asyncio.sleep(max(interval, 30))

View File

@@ -0,0 +1,346 @@
"""Keenetic router RCI API client for monitoring via KeenDNS."""
import hashlib
import logging
from typing import Optional
from urllib.parse import urlparse
import aiohttp
logger = logging.getLogger(__name__)
KEENDNS_MARKERS = (".pro", ".club", ".link", "netcraze", "keenetic")
def normalize_web_url(url: str) -> str:
"""Ensure web UI URL has a scheme (https by default)."""
url = (url or "").strip()
if not url:
return ""
if not url.startswith("http://") and not url.startswith("https://"):
url = "https://" + url
return url.rstrip("/")
def build_api_base_url(host: str, web_url: str = "") -> str:
"""Build RCI API base URL from host and/or Keenetic web URL column."""
raw = (host or "").strip()
if not raw and web_url:
parsed = urlparse(normalize_web_url(web_url))
raw = parsed.netloc or (parsed.path.split("/")[0] if parsed.path else "")
if not raw:
return ""
if raw.startswith("http://") or raw.startswith("https://"):
return raw.rstrip("/")
domain = raw.split(":")[0].lower()
is_keendns = any(m in domain for m in KEENDNS_MARKERS)
scheme = "https" if is_keendns else "http"
return f"{scheme}://{raw}".rstrip("/")
class KeeneticClient:
"""Async client for Keenetic router RCI API.
Auth flow:
1. GET /auth -> 401 with X-NDM-Challenge + X-NDM-Realm headers
2. Compute: md5(login:realm:password) -> sha256(challenge + md5_hex)
3. POST /auth {"login": ..., "password": sha256_hex}
4. Session cookie persists for subsequent requests
"""
def __init__(self, host: str, login: str = "admin", password: str = "",
web_url: str = ""):
base = build_api_base_url(host, web_url)
if not base:
raise ValueError("host or web_url required")
self.base_url = base
self.login = login
self.password = password
self._session: Optional[aiohttp.ClientSession] = None
self._authenticated = False
self.last_error = ""
async def _get_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
timeout = aiohttp.ClientTimeout(total=25, connect=10)
jar = aiohttp.CookieJar(unsafe=True)
self._session = aiohttp.ClientSession(timeout=timeout, cookie_jar=jar)
return self._session
async def authenticate(self) -> bool:
"""Perform challenge-response authentication."""
self.last_error = ""
session = await self._get_session()
auth_url = f"{self.base_url}/auth"
try:
async with session.get(auth_url, ssl=False) as resp:
if resp.status == 200:
self._authenticated = True
return True
if resp.status != 401:
if resp.status in (400, 403):
self.last_error = "Wrong protocol or port (try http/https)"
else:
self.last_error = f"Auth HTTP {resp.status}"
logger.error(f"Keenetic auth unexpected status: {resp.status} @ {self.base_url}")
return False
challenge = resp.headers.get("X-NDM-Challenge", "")
realm = resp.headers.get("X-NDM-Realm", "")
if not challenge or not realm:
self.last_error = "No auth challenge from router"
logger.error("Keenetic auth: missing challenge/realm headers")
return False
md5_input = f"{self.login}:{realm}:{self.password}"
md5_hex = hashlib.md5(md5_input.encode("utf-8")).hexdigest()
sha_input = f"{challenge}{md5_hex}"
sha_hex = hashlib.sha256(sha_input.encode("utf-8")).hexdigest()
async with session.post(
auth_url,
json={"login": self.login, "password": sha_hex},
ssl=False,
) as resp:
if resp.status == 200:
self._authenticated = True
return True
self.last_error = "Wrong login or password"
logger.error(f"Keenetic auth failed: {resp.status} @ {self.base_url}")
return False
except aiohttp.ClientConnectorError as e:
self.last_error = "Cannot connect to router"
logger.error(f"Keenetic auth error: {type(e).__name__}: {e}")
return False
except TimeoutError:
self.last_error = "Connection timeout"
logger.error(f"Keenetic auth timeout @ {self.base_url}")
return False
except Exception as e:
self.last_error = type(e).__name__
logger.error(f"Keenetic auth error: {type(e).__name__}: {e}")
return False
async def rci_show(self, command: str, params: Optional[dict] = None) -> Optional[dict]:
"""GET /rci/show/<command> with optional query params."""
if not self._authenticated:
if not await self.authenticate():
return None
session = await self._get_session()
path = command.replace(" ", "/")
url = f"{self.base_url}/rci/show/{path}"
try:
async with session.get(url, params=params, ssl=False) as resp:
if resp.status == 200:
return await resp.json(content_type=None)
elif resp.status == 401:
self._authenticated = False
if await self.authenticate():
async with session.get(url, params=params, ssl=False) as resp2:
if resp2.status == 200:
return await resp2.json(content_type=None)
logger.error(f"Keenetic RCI {command}: HTTP {resp.status}")
return None
except Exception as e:
logger.error(f"Keenetic RCI error ({command}): {type(e).__name__}: {e}")
return None
async def rci_post(self, body: dict) -> Optional[dict]:
"""POST /rci/ with JSON body for batch commands."""
if not self._authenticated:
if not await self.authenticate():
return None
session = await self._get_session()
url = f"{self.base_url}/rci/"
try:
async with session.post(url, json=body, ssl=False) as resp:
if resp.status == 200:
return await resp.json(content_type=None)
logger.error(f"Keenetic RCI POST: HTTP {resp.status}")
return None
except Exception as e:
logger.error(f"Keenetic RCI POST error: {type(e).__name__}: {e}")
return None
async def collect_metrics(self, cached_info: Optional[dict] = None) -> dict:
"""Lightweight refresh: system + internet + VPN only."""
result = {
"online": False,
"hostname": "",
"model": "",
"firmware": "",
"cpuload": 0,
"memtotal": 0,
"memfree": 0,
"mem_percent": 0,
"uptime": 0,
"uptime_str": "",
"internet": False,
"gateway_accessible": False,
"dns_accessible": False,
"vpn": [],
"clients_count": 0,
"wifi_clients": 0,
"wired_clients": 0,
"error": "",
}
if cached_info:
result["model"] = cached_info.get("model", "")
result["firmware"] = cached_info.get("firmware", "")
if not await self.authenticate():
result["error"] = self.last_error or "Authentication failed"
return result
result["online"] = True
batch_cmd = {
"show": {
"system": {},
"internet": {"status": {}},
"interface": {},
}
}
need_info = not result.get("model")
if need_info:
batch_cmd["show"]["version"] = {}
batch_cmd["show"]["defaults"] = {}
batch = await self.rci_post(batch_cmd)
if not batch or not isinstance(batch, dict):
result["error"] = "No data from router"
result["online"] = False
return result
show = batch.get("show", batch)
sys_data = show.get("system", {})
if sys_data:
result["hostname"] = sys_data.get("hostname", "")
result["cpuload"] = int(sys_data.get("cpuload", 0) or 0)
result["memtotal"] = int(sys_data.get("memtotal", 0) or 0)
result["memfree"] = int(sys_data.get("memfree", 0) or 0)
memtotal = result["memtotal"]
memfree = result["memfree"]
if memtotal > 0:
result["mem_percent"] = round((1 - memfree / memtotal) * 100, 1)
uptime_sec = int(sys_data.get("uptime", 0) or 0)
result["uptime"] = uptime_sec
if uptime_sec:
days = uptime_sec // 86400
hours = (uptime_sec % 86400) // 3600
mins = (uptime_sec % 3600) // 60
result["uptime_str"] = f"{days}d {hours}h {mins}m"
if need_info:
ver_data = show.get("version", {})
if ver_data:
result["firmware"] = ver_data.get("title", ver_data.get("release", ""))
defaults = show.get("defaults", {})
if defaults:
product = defaults.get("product", "")
hw_id = defaults.get("ndmhwid", "")
result["model"] = f"{product} ({hw_id})" if product and hw_id else product or hw_id
inet_block = show.get("internet", {})
inet = inet_block.get("status", inet_block) if isinstance(inet_block, dict) else {}
if inet:
result["internet"] = bool(inet.get("internet"))
result["gateway_accessible"] = bool(inet.get("gateway-accessible"))
result["dns_accessible"] = bool(inet.get("dns-accessible"))
VPN_TYPES = {"Wireguard", "WireGuard", "OpenVPN", "PPTP", "L2TP", "SSTP", "EoIP", "IPsec"}
ifaces = show.get("interface", {})
if ifaces and isinstance(ifaces, dict):
for iface_name, iface_data in ifaces.items():
if not isinstance(iface_data, dict):
continue
itype = iface_data.get("type", "")
if itype in VPN_TYPES:
result["vpn"].append({
"name": iface_name,
"type": itype,
"state": iface_data.get("state", ""),
"description": iface_data.get("description", ""),
"address": iface_data.get("address", ""),
})
return result
async def collect_detail(self) -> Optional[dict]:
"""Heavy detail fetch: interfaces + connected clients."""
if not self._authenticated:
if not await self.authenticate():
return None
batch = await self.rci_post({
"show": {
"interface": {},
"ip": {"hotspot": {}},
}
})
if not batch or not isinstance(batch, dict):
return None
show = batch.get("show", batch)
detail = {"interfaces": [], "clients": []}
VPN_TYPES = {"Wireguard", "WireGuard", "OpenVPN", "PPTP", "L2TP", "SSTP", "EoIP", "IPsec"}
SHOW_TYPES = {"GigabitEthernet", "XGigabitEthernet", "WifiMaster", "AccessPoint",
"Bridge", "PPPoE"} | VPN_TYPES
ifaces = show.get("interface", {})
if ifaces and isinstance(ifaces, dict):
for iface_name, iface_data in ifaces.items():
if not isinstance(iface_data, dict):
continue
itype = iface_data.get("type", "")
if itype in SHOW_TYPES:
detail["interfaces"].append({
"id": iface_data.get("id", iface_name),
"type": itype,
"description": iface_data.get("description", ""),
"state": iface_data.get("state", ""),
"address": iface_data.get("address", ""),
"uptime": iface_data.get("uptime", 0),
})
ip_block = show.get("ip", {})
hotspot = ip_block.get("hotspot", ip_block) if isinstance(ip_block, dict) else {}
if hotspot:
hosts = hotspot.get("host", [])
if isinstance(hosts, list):
for h in hosts:
detail["clients"].append({
"name": h.get("name", h.get("hostname", "")),
"hostname": h.get("hostname", ""),
"ip": h.get("ip", ""),
"mac": h.get("mac", ""),
"active": h.get("active", False),
"speed": h.get("speed", 0),
"ssid": h.get("ssid", ""),
})
return detail
async def close(self):
if self._session and not self._session.closed:
await self._session.close()

View File

@@ -1,6 +1,54 @@
let servers = [];
let currentTerminal = null;
let currentWs = null;
let mutedDevices = new Set();
async function loadMutedDevices() {
try {
const resp = await fetch('/api/notifications/muted-devices', {credentials: 'include'});
const data = await resp.json();
mutedDevices = new Set(data.muted || []);
} catch (e) { console.error('Muted devices load error:', e); }
}
async function toggleMuteDevice(btn) {
const category = btn.dataset.cat;
const name = btn.dataset.name;
try {
const resp = await fetch('/api/notifications/mute-device', {
method: 'POST', credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({category, name}),
});
const data = await resp.json();
const key = `${category}:${name}`;
if (data.muted) {
mutedDevices.add(key);
} else {
mutedDevices.delete(key);
}
btn.textContent = data.muted ? '🔕' : '🔔';
btn.className = 'mute-bell' + (data.muted ? ' muted' : '');
btn.title = data.muted ? 'Уведомления выключены' : 'Уведомления включены';
} catch (e) { console.error('Mute toggle error:', e); }
}
// Delegate bell clicks from document level
document.addEventListener('click', function(e) {
const bell = e.target.closest('.mute-bell');
if (bell) {
e.stopPropagation();
e.preventDefault();
toggleMuteDevice(bell);
}
});
function bellHtml(category, name) {
const key = `${category}:${name}`;
const isMuted = mutedDevices.has(key);
const esc = name.replace(/"/g, '&quot;');
return `<button class="mute-bell${isMuted ? ' muted' : ''}" data-cat="${category}" data-name="${esc}" title="${isMuted ? 'Уведомления выключены' : 'Уведомления включены'}">${isMuted ? '🔕' : '🔔'}</button>`;
}
// i18n render
function renderPage() {
@@ -57,6 +105,7 @@ function renderServers() {
return `
<div class="server-card ${isOnline ? 'online' : 'offline'}" onclick="showServerDetail('${id}')">
${bellHtml('servers', srv.name)}
<div class="server-card-header">
<div>
<div class="name">${srv.name}</div>
@@ -391,7 +440,7 @@ document.addEventListener('click', (e) => {
// ===================== TABS =====================
let activeTab = 'servers';
const allTabs = ['servers', 'pc', 'synology', 'ha'];
const allTabs = ['servers', 'pc', 'synology', 'ha', 'keenetic'];
function switchTab(tab) {
activeTab = tab;
@@ -405,6 +454,7 @@ function switchTab(tab) {
if (tab === 'pc') loadPCs();
else if (tab === 'synology') loadSynology();
else if (tab === 'ha') loadHA();
else if (tab === 'keenetic') loadKeenetic();
}
// ===================== PC MONITORING =====================
@@ -448,6 +498,7 @@ function renderPCs() {
return `
<div class="server-card ${isOnline ? 'online' : 'offline'}">
${bellHtml('pc', pc.agent_name)}
<div class="server-card-header">
<div>
<div class="name">💻 ${pc.agent_name}</div>
@@ -567,10 +618,11 @@ function renderSynology() {
return `
<div class="server-card ${isOnline ? 'online' : 'offline'}" onclick="showSynologyDetail('${dev.name}')">
${bellHtml('synology', dev.name)}
<div class="server-card-header">
<div>
<div class="name">📦 ${dev.name}</div>
<div class="host">${dev.host}:${dev.port || 5000} ${m.model ? '• ' + m.model : ''}</div>
<div class="host">${dev.tunnel?.enabled ? '🔗 tunnel' : dev.host + ':' + (dev.port || 5000)} ${m.model ? '• ' + m.model : ''}</div>
</div>
<span class="status-badge ${isOnline ? 'online' : 'offline'}">
<span class="status-dot ${isOnline ? 'online' : 'offline'}"></span>
@@ -598,9 +650,10 @@ function renderSynology() {
${vmsCount ? `<div style="font-size:11px;color:var(--accent)">🖥 ${vmsCount} VM</div>` : ''}
${dockerCount ? `<div style="font-size:11px;color:var(--accent)">🐳 ${dockerCount} контейнеров</div>` : ''}
${volsHtml}
` : '<div style="padding:20px 0;text-align:center;opacity:0.5">Нет данных — нажмите 🔄</div>'}
` : `<div style="padding:20px 0;text-align:center;opacity:0.5">${m.error ? '⚠️ ' + m.error : 'Нет данных — нажмите 🔄'}</div>`}
<div class="server-card-actions">
<button onclick="event.stopPropagation(); refreshSynology('${dev.name}')">🔄 Обновить</button>
${dev.tunnel?.enabled ? `<button onclick="event.stopPropagation(); showSynTunnel()">🔗 Туннель</button>` : ''}
<button class="danger" onclick="event.stopPropagation(); deleteSynology('${dev.name}')">🗑</button>
</div>
</div>
@@ -701,15 +754,24 @@ document.getElementById('addSynologyForm')?.addEventListener('submit', async (e)
async function refreshSynology(name) {
try {
await fetch(`/api/synology/refresh/${encodeURIComponent(name)}`, {method: 'POST', credentials: 'include'});
loadSynology();
const resp = await fetch(`/api/synology/refresh/${encodeURIComponent(name)}`, {method: 'POST', credentials: 'include'});
const data = await resp.json();
if (data.status === 'error' && data.detail) {
console.warn('Synology refresh:', data.detail);
}
// Reload list with fresh data
const listResp = await fetch('/api/synology/list', {credentials: 'include'});
synologyDevices = await listResp.json();
renderSynology();
} catch (e) { alert('Error: ' + e.message); }
}
async function refreshAllSynology() {
try {
await fetch('/api/synology/refresh-all', {method: 'POST', credentials: 'include'});
loadSynology();
const resp = await fetch('/api/synology/list', {credentials: 'include'});
synologyDevices = await resp.json();
renderSynology();
} catch (e) { alert('Error: ' + e.message); }
}
@@ -719,6 +781,28 @@ async function deleteSynology(name) {
loadSynology();
}
async function showSynTunnel() {
// Find first synology device with tunnel config
const dev = synologyDevices.find(d => d.tunnel?.enabled) || synologyDevices[0];
if (!dev) {
alert('Сначала добавьте Synology NAS');
return;
}
const name = dev.name;
try {
const resp = await fetch(`/api/synology/tunnel/setup-command?name=${encodeURIComponent(name)}`, {credentials: 'include'});
const data = await resp.json();
if (data.command) {
document.getElementById('syn-tunnel-cmd').textContent = data.command;
}
} catch (e) {
document.getElementById('syn-tunnel-cmd').textContent = 'Ошибка загрузки команды';
}
document.getElementById('synTunnelModal').style.display = 'flex';
}
// ===================== HOME ASSISTANT =====================
let haInstances = [];
@@ -753,6 +837,7 @@ function renderHA() {
return `
<div class="server-card ${isOnline ? 'online' : 'offline'}" onclick="showHADetail('${inst.name}')">
${bellHtml('ha', inst.name)}
<div class="server-card-header">
<div>
<div class="name">🏠 ${inst.name}</div>
@@ -938,14 +1023,18 @@ document.getElementById('addHAForm')?.addEventListener('submit', async (e) => {
async function refreshHA(name) {
try {
await fetch(`/api/ha/refresh/${encodeURIComponent(name)}`, {method: 'POST', credentials: 'include'});
loadHA();
const resp = await fetch('/api/ha/list', {credentials: 'include'});
haInstances = await resp.json();
renderHA();
} catch (e) { alert('Error: ' + e.message); }
}
async function refreshAllHA() {
try {
await fetch('/api/ha/refresh-all', {method: 'POST', credentials: 'include'});
loadHA();
const resp = await fetch('/api/ha/list', {credentials: 'include'});
haInstances = await resp.json();
renderHA();
} catch (e) { alert('Error: ' + e.message); }
}
@@ -1050,11 +1139,421 @@ async function testNotification(channel) {
} catch (e) { alert('Error: ' + e.message); }
}
// ===================== KEENETIC MONITORING =====================
let keeneticDevices = [];
function escHtml(s) {
return String(s || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function keeneticWebUrl(dev) {
let u = (dev.web_url || '').trim();
if (!u && dev.host) {
const h = dev.host.trim();
u = h.startsWith('http') ? h : (/^\d+\.\d+/.test(h) ? 'http://' : 'https://') + h;
}
if (u && !/^https?:\/\//i.test(u)) u = 'https://' + u;
return u.replace(/\/$/, '');
}
function openAnyDesk(id, ev) {
if (ev) ev.preventDefault();
const proto = 'anydesk://' + id;
window.location.href = proto;
setTimeout(() => {
if (document.hidden) return;
if (confirm('AnyDesk не открылся. Скачать с anydesk.com?')) {
window.open('https://anydesk.com/en/downloads', '_blank');
}
}, 1500);
}
async function loadKeenetic() {
try {
const resp = await fetch('/api/keenetic/list', {credentials: 'include'});
if (resp.status === 401) return;
keeneticDevices = await resp.json();
renderKeenetic();
} catch (e) { console.error('Keenetic load error:', e); }
}
function renderKeenetic() {
const grid = document.getElementById('keenetic-grid');
if (!keeneticDevices.length) {
grid.innerHTML = '<div style="text-align:center;padding:60px;opacity:0.5;grid-column:1/-1">Нет роутеров. Нажмите "+ Добавить роутер"</div>';
return;
}
grid.innerHTML = keeneticDevices.map(dev => {
const m = dev.metrics || {};
const isOnline = m.online;
const cpuload = m.cpuload || 0;
const memPct = m.mem_percent || 0;
const inet = m.internet;
const uptime = m.uptime_str || '';
const model = m.model || '';
const firmware = m.firmware || '';
const error = m.error || '';
const vpns = m.vpn || [];
const cpuClass = cpuload > 90 ? 'crit' : cpuload > 70 ? 'warn' : '';
const ramClass = memPct > 90 ? 'crit' : memPct > 70 ? 'warn' : '';
// VPN badges
let vpnHtml = '';
if (vpns.length) {
vpnHtml = `<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:10px">` +
vpns.map(v => {
const up = v.state === 'up';
return `<span class="badge ${up ? 'badge-vpn-up' : 'badge-vpn-down'}">${v.type} ${v.description || v.name} ${up ? '🟢' : '🔴'}</span>`;
}).join('') + `</div>`;
}
const hostParts = [dev.host];
if (model) hostParts.push(model);
if (firmware) hostParts.push('v' + firmware);
const webUrl = keeneticWebUrl(dev);
const anydeskId = (dev.anydesk || '').replace(/\D/g, '');
let linksHtml = '';
if (webUrl || anydeskId) {
linksHtml = '<div class="keenetic-links" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px">';
if (webUrl) {
linksHtml += `<a class="btn-secondary" style="padding:4px 10px;font-size:12px;text-decoration:none" href="${escHtml(webUrl)}" target="_blank" rel="noopener" onclick="event.stopPropagation()">🌐 Веб</a>`;
}
if (anydeskId) {
linksHtml += `<a class="btn-secondary" style="padding:4px 10px;font-size:12px;text-decoration:none;cursor:pointer" href="anydesk://${escHtml(anydeskId)}" onclick="event.stopPropagation();openAnyDesk('${escHtml(anydeskId)}',event)">🖥 AnyDesk ${escHtml(anydeskId)}</a>`;
}
linksHtml += '</div>';
}
return `
<div class="server-card ${isOnline ? 'online' : 'offline'}" data-keen="${dev.name}" onclick="showKeeneticDetail('${dev.name}')">
${bellHtml('keenetic', dev.name)}
<div class="server-card-header">
<div>
<div class="name">📡 ${dev.name}</div>
<div class="host">${hostParts.join(' • ')}</div>
</div>
<span class="status-badge ${isOnline ? 'online' : 'offline'}">
<span class="status-dot ${isOnline ? 'online' : 'offline'}"></span>
${isOnline ? 'Online' : 'Offline'}
</span>
</div>
${linksHtml}
${vpnHtml}
${isOnline ? `
<div class="metrics-grid" style="grid-template-columns: repeat(2, 1fr)">
<div class="metric-item">
<span class="label">CPU</span>
<span class="value ${cpuClass}">${cpuload}%</span>
<div class="progress-bar"><div class="fill ${cpuClass || 'ok'}" style="width:${cpuload}%"></div></div>
</div>
<div class="metric-item">
<span class="label">RAM</span>
<span class="value ${ramClass}">${memPct}%</span>
<div class="progress-bar"><div class="fill ${ramClass || 'ok'}" style="width:${memPct}%"></div></div>
</div>
</div>
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">
${inet ? '🌐 Internet' : '<span style="color:var(--danger)">⚠️ No Internet</span>'}
${uptime ? ' • ⏱ ' + uptime : ''}
</div>
` : `<div style="padding:20px 0;text-align:center;opacity:0.5">${error ? '⚠️ ' + error : 'Нет данных — нажмите 🔄'}</div>`}
<div class="server-card-actions">
<button onclick="event.stopPropagation();refreshKeenetic('${dev.name}')">🔄 Обновить</button>
<button onclick="event.stopPropagation();rebootKeenetic('${dev.name}')">🔁 Reboot</button>
<button class="danger" onclick="event.stopPropagation();deleteKeenetic('${dev.name}')">🗑</button>
</div>
</div>`;
}).join('');
}
async function showKeeneticDetail(name) {
const dev = keeneticDevices.find(d => d.name === name);
if (!dev || !dev.metrics) return;
const m = dev.metrics;
document.getElementById('keeneticDetailTitle').textContent = `📡 ${dev.name}`;
let html = `
<div class="detail-grid">
<div class="detail-section">
<h3>Система</h3>
<table class="detail-table">
<tr><td>Статус</td><td>${m.online ? '🟢 Online' : '🔴 Offline'}</td></tr>
<tr><td>Хост</td><td>${dev.host}</td></tr>
<tr><td>Hostname</td><td>${m.hostname || '-'}</td></tr>
<tr><td>Модель</td><td>${m.model || '-'}</td></tr>
<tr><td>Прошивка</td><td>${m.firmware || '-'}</td></tr>
<tr><td>CPU</td><td>${m.cpuload || 0}%</td></tr>
<tr><td>RAM</td><td>${m.mem_percent || 0}% (${formatBytes(m.memtotal - m.memfree)}/${formatBytes(m.memtotal)})</td></tr>
<tr><td>Uptime</td><td>${m.uptime_str || '-'}</td></tr>
</table>
</div>
<div class="detail-section">
<h3>Интернет</h3>
<table class="detail-table">
<tr><td>Internet</td><td>${m.internet ? '✅' : '❌'}</td></tr>
<tr><td>Gateway</td><td>${m.gateway_accessible ? '✅' : '❌'}</td></tr>
<tr><td>DNS</td><td>${m.dns_accessible ? '✅' : '❌'}</td></tr>
</table>
</div>
</div>
<div id="keeneticDetailExtra" style="margin-top:16px;opacity:0.5;text-align:center">⏳ Загрузка клиентов и интерфейсов...</div>`;
if (m.last_updated) {
html += `<div style="margin-top:12px;opacity:0.5;font-size:12px">Обновлено: ${new Date(m.last_updated).toLocaleTimeString()}</div>`;
}
document.getElementById('keeneticDetailBody').innerHTML = html;
document.getElementById('keeneticDetailModal').style.display = 'flex';
// Fetch clients + interfaces lazily
try {
const resp = await fetch(`/api/keenetic/detail/${name}`, {credentials: 'include'});
const data = await resp.json();
const extra = document.getElementById('keeneticDetailExtra');
if (!extra) return;
let extraHtml = '';
// Clients
const activeClients = (data.clients || []).filter(c => c.active);
if (activeClients.length) {
extraHtml += `<div class="detail-section">
<h3>Подключенные устройства (${activeClients.length})</h3>
<table class="detail-table">
<tr><th>Имя</th><th>IP</th><th>MAC</th><th>Тип</th><th>Speed</th></tr>
${activeClients.map(c => `
<tr>
<td>${c.name || c.hostname || '-'}</td>
<td>${c.ip}</td>
<td style="font-size:11px">${c.mac}</td>
<td>${c.ssid ? '📶 ' + c.ssid : '🔌 LAN'}</td>
<td>${c.speed ? c.speed + ' Mbps' : '-'}</td>
</tr>`).join('')}
</table>
</div>`;
} else {
extraHtml += '<div style="opacity:0.5">Нет активных клиентов</div>';
}
// Interfaces
if (data.interfaces && data.interfaces.length) {
extraHtml += `<div class="detail-section" style="margin-top:16px">
<h3>Интерфейсы</h3>
<table class="detail-table">
<tr><th>ID</th><th>Тип</th><th>Состояние</th><th>IP</th></tr>
${data.interfaces.map(i => `
<tr>
<td>${i.id}</td>
<td>${i.type}</td>
<td>${i.state === 'up' ? '🟢' : '🔴'} ${i.state}</td>
<td>${i.address || '-'}</td>
</tr>`).join('')}
</table>
</div>`;
}
extra.innerHTML = extraHtml;
extra.style.opacity = '1';
} catch (e) {
const extra = document.getElementById('keeneticDetailExtra');
if (extra) extra.innerHTML = '<div style="color:#ef4444">Ошибка загрузки деталей</div>';
}
}
function formatBytes(bytes) {
if (!bytes || bytes <= 0) return '0';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(0) + ' KB';
return (bytes / 1048576).toFixed(0) + ' MB';
}
function showAddKeenetic() {
document.getElementById('addKeeneticModal').style.display = 'flex';
document.getElementById('addKeeneticForm').onsubmit = async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const body = {
name: fd.get('name'),
host: fd.get('host'),
web_url: fd.get('web_url') || fd.get('host'),
anydesk: fd.get('anydesk') || '',
login: fd.get('login') || 'admin',
password: fd.get('password'),
};
try {
const resp = await fetch('/api/keenetic/add', {
method: 'POST', credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const data = await resp.json();
if (data.status === 'ok') {
closeModal('addKeeneticModal');
e.target.reset();
await refreshAllKeenetic();
} else {
alert(data.detail || 'Error');
}
} catch (err) { alert('Error: ' + err.message); }
};
}
function showImportKeenetic() {
document.getElementById('importKeeneticResult').style.display = 'none';
document.getElementById('importKeeneticModal').style.display = 'flex';
document.getElementById('importKeeneticForm').onsubmit = async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const btn = e.target.querySelector('button[type=submit]');
btn.disabled = true;
try {
const resp = await fetch('/api/keenetic/import', {
method: 'POST', credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
tsv: fd.get('tsv'),
login: fd.get('login') || 'admin',
password: fd.get('password'),
}),
});
const data = await resp.json();
const resultDiv = document.getElementById('importKeeneticResult');
if (data.status === 'ok') {
resultDiv.innerHTML = `✅ Добавлено: ${(data.added || []).join(', ') || '—'}`
+ (data.skipped?.length ? `<br>⏭ Пропущено: ${data.skipped.join(', ')}` : '');
resultDiv.style.display = 'block';
await refreshAllKeenetic();
} else {
resultDiv.innerHTML = `${data.detail || 'Error'}`;
resultDiv.style.display = 'block';
}
} catch (err) { alert('Error: ' + err.message); }
btn.disabled = false;
};
}
function showBulkAddKeenetic() {
document.getElementById('bulkAddResult').style.display = 'none';
document.getElementById('bulkAddKeeneticModal').style.display = 'flex';
document.getElementById('bulkAddKeeneticForm').onsubmit = async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const body = {
domains: fd.get('domains'),
login: fd.get('login') || 'admin',
password: fd.get('password'),
};
const btn = e.target.querySelector('button[type=submit]');
btn.disabled = true;
btn.textContent = '⏳ Добавление...';
try {
const resp = await fetch('/api/keenetic/add-bulk', {
method: 'POST', credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
const data = await resp.json();
const resultDiv = document.getElementById('bulkAddResult');
if (data.status === 'ok') {
let msg = `✅ Добавлено: ${data.added.length} роутеров`;
if (data.added.length) msg += `<br><b>${data.added.join(', ')}</b>`;
if (data.skipped.length) msg += `<br>⏭ Пропущено (уже есть): ${data.skipped.join(', ')}`;
resultDiv.innerHTML = msg;
resultDiv.style.display = 'block';
e.target.querySelector('textarea').value = '';
await loadKeenetic();
} else {
resultDiv.innerHTML = `${data.detail || 'Error'}`;
resultDiv.style.display = 'block';
}
} catch (err) {
alert('Error: ' + err.message);
}
btn.disabled = false;
btn.textContent = 'Добавить все';
};
}
let keeneticRefreshCooldown = 0;
async function refreshKeenetic(name) {
const now = Date.now();
if (now < keeneticRefreshCooldown) {
console.log('Keenetic refresh cooldown, skip');
return;
}
keeneticRefreshCooldown = now + 30000; // 30s cooldown
const card = document.querySelector(`[data-keen="${name}"]`);
if (card) card.classList.add('loading');
try {
const resp = await fetch(`/api/keenetic/refresh/${name}`, {
method: 'POST', credentials: 'include',
});
const resp2 = await fetch('/api/keenetic/list', {credentials: 'include'});
keeneticDevices = await resp2.json();
renderKeenetic();
} catch (e) { console.error('Keenetic refresh error:', e); }
}
async function refreshAllKeenetic() {
const now = Date.now();
if (now < keeneticRefreshCooldown) {
console.log('Keenetic refresh cooldown, skip');
return;
}
keeneticRefreshCooldown = now + 30000;
document.querySelectorAll('#keenetic-grid .server-card').forEach(c => c.classList.add('loading'));
const grid = document.getElementById('keenetic-grid');
if (!keeneticDevices.length) {
grid.innerHTML = '<div style="text-align:center;padding:60px;opacity:0.5;grid-column:1/-1">⏳ Загрузка...</div>';
}
try {
const resp = await fetch('/api/keenetic/refresh-all', {
method: 'POST', credentials: 'include',
});
const resp2 = await fetch('/api/keenetic/list', {credentials: 'include'});
keeneticDevices = await resp2.json();
renderKeenetic();
} catch (e) { console.error('Keenetic refresh-all error:', e); }
}
async function rebootKeenetic(name) {
if (!confirm(`Перезагрузить роутер "${name}"?`)) return;
try {
const resp = await fetch(`/api/keenetic/reboot/${name}`, {
method: 'POST', credentials: 'include',
});
const data = await resp.json();
if (data.status === 'ok') {
alert(`✅ Команда перезагрузки отправлена: ${name}`);
} else {
alert(`❌ Ошибка: ${data.detail || 'unknown'}`);
}
} catch (e) { alert('Error: ' + e.message); }
}
async function deleteKeenetic(name) {
if (!confirm(`Удалить роутер "${name}"?`)) return;
try {
await fetch(`/api/keenetic/${name}`, {
method: 'DELETE', credentials: 'include',
});
await loadKeenetic();
} catch (e) { alert('Error: ' + e.message); }
}
// ===================== INIT =====================
document.addEventListener('DOMContentLoaded', () => {
applyTheme(currentTheme);
renderPage();
loadServers();
loadMutedDevices().then(() => {
renderPage();
loadServers();
});
});
setInterval(() => {
@@ -1062,4 +1561,5 @@ setInterval(() => {
else if (activeTab === 'pc') loadPCs();
else if (activeTab === 'synology') loadSynology();
else if (activeTab === 'ha') loadHA();
else if (activeTab === 'keenetic') loadKeenetic();
}, 30000);

View File

@@ -43,10 +43,11 @@
<!-- Tabs -->
<div class="tabs">
<button class="tab active" id="tab-servers" onclick="switchTab('servers')">🖥 <span data-i18n="tabServers">Серверы</span></button>
<button class="tab" id="tab-pc" onclick="switchTab('pc')">💻 <span data-i18n="tabPC">ПК</span></button>
<button class="tab active" id="tab-servers" onclick="switchTab('servers')">🖥 VPS</button>
<button class="tab" id="tab-pc" onclick="switchTab('pc')">💻 PC</button>
<button class="tab" id="tab-synology" onclick="switchTab('synology')">📦 Synology</button>
<button class="tab" id="tab-ha" onclick="switchTab('ha')">🏠 Home Assistant</button>
<button class="tab" id="tab-keenetic" onclick="switchTab('keenetic')">📡 Keenetic</button>
</div>
<!-- Servers Tab -->
@@ -72,6 +73,7 @@
<div class="toolbar">
<button class="btn-primary" onclick="showAddSynology()">+ Добавить NAS</button>
<button class="btn-secondary" onclick="refreshAllSynology()">🔄 Обновить все</button>
<button class="btn-secondary" onclick="showSynTunnel()">🔗 Туннель</button>
</div>
<div class="servers-grid" id="synology-grid"></div>
</div>
@@ -84,6 +86,16 @@
</div>
<div class="servers-grid" id="ha-grid"></div>
</div>
<!-- Keenetic Tab -->
<div id="panel-keenetic" style="display:none">
<div class="toolbar">
<button class="btn-primary" onclick="showAddKeenetic()">+ Добавить роутер</button>
<button class="btn-secondary" onclick="showBulkAddKeenetic()">📋 Массовое добавление</button>
<button class="btn-secondary" onclick="showImportKeenetic()">📥 Импорт таблицы</button>
<button class="btn-secondary" onclick="refreshAllKeenetic()">🔄 Обновить все</button>
</div>
<div class="servers-grid" id="keenetic-grid"></div>
</div>
</main>
<!-- Add Server Modal -->
@@ -185,6 +197,105 @@
</div>
</div>
<!-- Add Keenetic Modal -->
<div class="modal" id="addKeeneticModal" style="display:none">
<div class="modal-content">
<div class="modal-header">
<h2>📡 Добавить Keenetic</h2>
<button class="btn-close" onclick="closeModal('addKeeneticModal')">&times;</button>
</div>
<form id="addKeeneticForm">
<div class="form-group">
<label>Название</label>
<input type="text" name="name" required placeholder="Home Router">
</div>
<div class="form-group">
<label>Хост / Keenetic URL</label>
<input type="text" name="host" required placeholder="loftliliana.netcraze.pro или https://...">
</div>
<div class="form-group">
<label>AnyDesk ID</label>
<input type="text" name="anydesk" placeholder="1020687391">
</div>
<div class="form-group">
<label>Логин</label>
<input type="text" name="login" value="admin">
</div>
<div class="form-group">
<label>Пароль</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn-primary">Добавить</button>
</form>
</div>
</div>
<!-- Bulk Add Keenetic Modal -->
<div class="modal" id="bulkAddKeeneticModal" style="display:none">
<div class="modal-content">
<div class="modal-header">
<h2>📋 Массовое добавление роутеров</h2>
<button class="btn-close" onclick="closeModal('bulkAddKeeneticModal')">&times;</button>
</div>
<form id="bulkAddKeeneticForm">
<div class="form-group">
<label>Домены (по одному на строку)</label>
<textarea name="domains" rows="10" required placeholder="vimpel.netcraze.link
home.netcraze.link
office.netcraze.link" style="width:100%;font-family:monospace;font-size:13px;resize:vertical"></textarea>
</div>
<div class="form-group">
<label>Логин (общий для всех)</label>
<input type="text" name="login" value="admin">
</div>
<div class="form-group">
<label>Пароль (общий для всех)</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn-primary" style="width:100%">Добавить все</button>
</form>
<div id="bulkAddResult" style="display:none;margin-top:12px;padding:12px;border-radius:8px;background:var(--bg-primary);font-size:13px"></div>
</div>
</div>
<!-- Import Keenetic Modal -->
<div class="modal" id="importKeeneticModal" style="display:none">
<div class="modal-content">
<div class="modal-header">
<h2>📥 Импорт из таблицы</h2>
<button class="btn-close" onclick="closeModal('importKeeneticModal')">&times;</button>
</div>
<form id="importKeeneticForm">
<p style="font-size:13px;opacity:0.8;margin-bottom:12px">Вставьте строки из Excel: колонки Address, Keenetic, AnyDesk (Tab-разделитель)</p>
<div class="form-group">
<textarea name="tsv" rows="12" required placeholder="Address Keenetic AnyDesk
loftliliana https://loftliliana.netcraze.pro 1020687391" style="width:100%;font-family:monospace;font-size:12px"></textarea>
</div>
<div class="form-group">
<label>Логин (общий)</label>
<input type="text" name="login" value="admin">
</div>
<div class="form-group">
<label>Пароль (общий)</label>
<input type="password" name="password" required>
</div>
<button type="submit" class="btn-primary" style="width:100%">Импортировать</button>
</form>
<div id="importKeeneticResult" style="display:none;margin-top:12px;padding:12px;border-radius:8px;background:var(--bg-primary);font-size:13px"></div>
</div>
</div>
<!-- Keenetic Detail Modal -->
<div class="modal" id="keeneticDetailModal" style="display:none">
<div class="modal-content modal-wide">
<div class="modal-header">
<h2 id="keeneticDetailTitle">📡 Router</h2>
<button class="btn-close" onclick="closeModal('keeneticDetailModal')">&times;</button>
</div>
<div id="keeneticDetailBody"></div>
</div>
</div>
<!-- PC Download Modal -->
<div class="modal" id="pcDownloadModal" style="display:none">
<div class="modal-content">
@@ -415,6 +526,40 @@
</div>
</div>
<!-- Synology Tunnel Setup Modal -->
<div class="modal" id="synTunnelModal" style="display:none">
<div class="modal-content modal-wide">
<div class="modal-header">
<h2>🔗 Настройка туннеля для Synology</h2>
<button class="btn-close" onclick="closeModal('synTunnelModal')">&times;</button>
</div>
<div class="instructions-section">
<div class="instr-card" style="border-left:3px solid var(--accent)">
<h4>⚠️ Зачем нужен туннель?</h4>
<p>VPS не может напрямую подключиться к вашему Synology NAS — порт 5000 не доступен из интернета.
Туннель через ПК на той же сети решает эту проблему.</p>
</div>
<div class="instr-card">
<h4>📋 Шаг 1 — Запустите на ПК в той же сети (PowerShell от Администратора)</h4>
<code id="syn-tunnel-cmd" style="cursor:pointer" onclick="copyToClipboard(this.textContent)" title="Нажмите чтобы скопировать">Загрузка...</code>
<p style="margin-top:8px;font-size:11px;color:var(--text-secondary)">Нажмите на команду чтобы скопировать</p>
</div>
<div class="instr-card">
<h4>📊 Шаг 2 — Обновите Synology на дашборде</h4>
<p>После запуска туннеля нажмите "Обновить" на карточке Synology — статус должен стать ONLINE.</p>
</div>
<div class="instr-card">
<h4>🔧 Управление туннелем</h4>
<ul class="cmd-list">
<li>Get-ScheduledTask 'VPS-Monitor-SynologyTunnel'</li>
<li>Stop-ScheduledTask 'VPS-Monitor-SynologyTunnel'</li>
<li>Unregister-ScheduledTask 'VPS-Monitor-SynologyTunnel'</li>
</ul>
</div>
</div>
</div>
</div>
<!-- PC Setup Modal -->
<div class="modal" id="pcSetupModal" style="display:none">
<div class="modal-content modal-wide">
@@ -425,7 +570,7 @@
<div class="instructions-section">
<div class="instr-card">
<h4>📥 Быстрая установка (PowerShell от Администратора)</h4>
<code id="pc-install-cmd">powershell -ExecutionPolicy Bypass -Command "Invoke-WebRequest -Uri 'http://77.239.126.123:7272/static/downloads/install_agent.ps1' -OutFile install_agent.ps1; .\install_agent.ps1 -ServerUrl 'http://77.239.126.123:7272' -AgentName 'MyPC'"</code>
<code id="pc-install-cmd">Set-ExecutionPolicy Bypass -Scope Process -Force; $ProgressPreference = 'SilentlyContinue'; (New-Object Net.WebClient).DownloadFile('http://77.239.126.123/static/downloads/install_agent.ps1', "$PWD\install_agent.ps1"); &amp; "$PWD\install_agent.ps1" -ServerUrl 'http://77.239.126.123' -AgentName 'MyPC'</code>
</div>
<div class="instr-card">
<h4>⚙️ Параметры</h4>
@@ -456,6 +601,6 @@
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
<script src="/static/js/app.js"></script>
<script src="/static/js/app.js?v=20260521b"></script>
</body>
</html>