mirror of
https://github.com/andrey271192/vps_monitoring.git
synced 2026-09-20 11:55:34 +00:00
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:
43
scripts/check_keenetic.py
Normal file
43
scripts/check_keenetic.py
Normal 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())
|
||||
99
scripts/sync_keenetic_import.py
Normal file
99
scripts/sync_keenetic_import.py
Normal 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
318
server/api/keenetic.py
Normal 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))
|
||||
346
server/services/keenetic_client.py
Normal file
346
server/services/keenetic_client.py
Normal 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()
|
||||
@@ -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, '"');
|
||||
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,'&').replace(/</g,'<').replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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')">×</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')">×</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')">×</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')">×</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')">×</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"); & "$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>
|
||||
|
||||
Reference in New Issue
Block a user