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 servers = [];
|
||||||
let currentTerminal = null;
|
let currentTerminal = null;
|
||||||
let currentWs = 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
|
// i18n render
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
@@ -57,6 +105,7 @@ function renderServers() {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="server-card ${isOnline ? 'online' : 'offline'}" onclick="showServerDetail('${id}')">
|
<div class="server-card ${isOnline ? 'online' : 'offline'}" onclick="showServerDetail('${id}')">
|
||||||
|
${bellHtml('servers', srv.name)}
|
||||||
<div class="server-card-header">
|
<div class="server-card-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="name">${srv.name}</div>
|
<div class="name">${srv.name}</div>
|
||||||
@@ -391,7 +440,7 @@ document.addEventListener('click', (e) => {
|
|||||||
|
|
||||||
// ===================== TABS =====================
|
// ===================== TABS =====================
|
||||||
let activeTab = 'servers';
|
let activeTab = 'servers';
|
||||||
const allTabs = ['servers', 'pc', 'synology', 'ha'];
|
const allTabs = ['servers', 'pc', 'synology', 'ha', 'keenetic'];
|
||||||
|
|
||||||
function switchTab(tab) {
|
function switchTab(tab) {
|
||||||
activeTab = tab;
|
activeTab = tab;
|
||||||
@@ -405,6 +454,7 @@ function switchTab(tab) {
|
|||||||
if (tab === 'pc') loadPCs();
|
if (tab === 'pc') loadPCs();
|
||||||
else if (tab === 'synology') loadSynology();
|
else if (tab === 'synology') loadSynology();
|
||||||
else if (tab === 'ha') loadHA();
|
else if (tab === 'ha') loadHA();
|
||||||
|
else if (tab === 'keenetic') loadKeenetic();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================== PC MONITORING =====================
|
// ===================== PC MONITORING =====================
|
||||||
@@ -448,6 +498,7 @@ function renderPCs() {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="server-card ${isOnline ? 'online' : 'offline'}">
|
<div class="server-card ${isOnline ? 'online' : 'offline'}">
|
||||||
|
${bellHtml('pc', pc.agent_name)}
|
||||||
<div class="server-card-header">
|
<div class="server-card-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="name">💻 ${pc.agent_name}</div>
|
<div class="name">💻 ${pc.agent_name}</div>
|
||||||
@@ -567,10 +618,11 @@ function renderSynology() {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="server-card ${isOnline ? 'online' : 'offline'}" onclick="showSynologyDetail('${dev.name}')">
|
<div class="server-card ${isOnline ? 'online' : 'offline'}" onclick="showSynologyDetail('${dev.name}')">
|
||||||
|
${bellHtml('synology', dev.name)}
|
||||||
<div class="server-card-header">
|
<div class="server-card-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="name">📦 ${dev.name}</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>
|
</div>
|
||||||
<span class="status-badge ${isOnline ? 'online' : 'offline'}">
|
<span class="status-badge ${isOnline ? 'online' : 'offline'}">
|
||||||
<span class="status-dot ${isOnline ? 'online' : 'offline'}"></span>
|
<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>` : ''}
|
${vmsCount ? `<div style="font-size:11px;color:var(--accent)">🖥 ${vmsCount} VM</div>` : ''}
|
||||||
${dockerCount ? `<div style="font-size:11px;color:var(--accent)">🐳 ${dockerCount} контейнеров</div>` : ''}
|
${dockerCount ? `<div style="font-size:11px;color:var(--accent)">🐳 ${dockerCount} контейнеров</div>` : ''}
|
||||||
${volsHtml}
|
${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">
|
<div class="server-card-actions">
|
||||||
<button onclick="event.stopPropagation(); refreshSynology('${dev.name}')">🔄 Обновить</button>
|
<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>
|
<button class="danger" onclick="event.stopPropagation(); deleteSynology('${dev.name}')">🗑</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -701,15 +754,24 @@ document.getElementById('addSynologyForm')?.addEventListener('submit', async (e)
|
|||||||
|
|
||||||
async function refreshSynology(name) {
|
async function refreshSynology(name) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/synology/refresh/${encodeURIComponent(name)}`, {method: 'POST', credentials: 'include'});
|
const resp = await fetch(`/api/synology/refresh/${encodeURIComponent(name)}`, {method: 'POST', credentials: 'include'});
|
||||||
loadSynology();
|
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); }
|
} catch (e) { alert('Error: ' + e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAllSynology() {
|
async function refreshAllSynology() {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/synology/refresh-all', {method: 'POST', credentials: 'include'});
|
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); }
|
} catch (e) { alert('Error: ' + e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,6 +781,28 @@ async function deleteSynology(name) {
|
|||||||
loadSynology();
|
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 =====================
|
// ===================== HOME ASSISTANT =====================
|
||||||
let haInstances = [];
|
let haInstances = [];
|
||||||
|
|
||||||
@@ -753,6 +837,7 @@ function renderHA() {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="server-card ${isOnline ? 'online' : 'offline'}" onclick="showHADetail('${inst.name}')">
|
<div class="server-card ${isOnline ? 'online' : 'offline'}" onclick="showHADetail('${inst.name}')">
|
||||||
|
${bellHtml('ha', inst.name)}
|
||||||
<div class="server-card-header">
|
<div class="server-card-header">
|
||||||
<div>
|
<div>
|
||||||
<div class="name">🏠 ${inst.name}</div>
|
<div class="name">🏠 ${inst.name}</div>
|
||||||
@@ -938,14 +1023,18 @@ document.getElementById('addHAForm')?.addEventListener('submit', async (e) => {
|
|||||||
async function refreshHA(name) {
|
async function refreshHA(name) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/ha/refresh/${encodeURIComponent(name)}`, {method: 'POST', credentials: 'include'});
|
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); }
|
} catch (e) { alert('Error: ' + e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAllHA() {
|
async function refreshAllHA() {
|
||||||
try {
|
try {
|
||||||
await fetch('/api/ha/refresh-all', {method: 'POST', credentials: 'include'});
|
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); }
|
} catch (e) { alert('Error: ' + e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1050,11 +1139,421 @@ async function testNotification(channel) {
|
|||||||
} catch (e) { alert('Error: ' + e.message); }
|
} 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 =====================
|
// ===================== INIT =====================
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
applyTheme(currentTheme);
|
applyTheme(currentTheme);
|
||||||
|
loadMutedDevices().then(() => {
|
||||||
renderPage();
|
renderPage();
|
||||||
loadServers();
|
loadServers();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
@@ -1062,4 +1561,5 @@ setInterval(() => {
|
|||||||
else if (activeTab === 'pc') loadPCs();
|
else if (activeTab === 'pc') loadPCs();
|
||||||
else if (activeTab === 'synology') loadSynology();
|
else if (activeTab === 'synology') loadSynology();
|
||||||
else if (activeTab === 'ha') loadHA();
|
else if (activeTab === 'ha') loadHA();
|
||||||
|
else if (activeTab === 'keenetic') loadKeenetic();
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|||||||
@@ -43,10 +43,11 @@
|
|||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<button class="tab active" id="tab-servers" onclick="switchTab('servers')">🖥 <span data-i18n="tabServers">Серверы</span></button>
|
<button class="tab active" id="tab-servers" onclick="switchTab('servers')">🖥 VPS</button>
|
||||||
<button class="tab" id="tab-pc" onclick="switchTab('pc')">💻 <span data-i18n="tabPC">ПК</span></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-synology" onclick="switchTab('synology')">📦 Synology</button>
|
||||||
<button class="tab" id="tab-ha" onclick="switchTab('ha')">🏠 Home Assistant</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>
|
</div>
|
||||||
|
|
||||||
<!-- Servers Tab -->
|
<!-- Servers Tab -->
|
||||||
@@ -72,6 +73,7 @@
|
|||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<button class="btn-primary" onclick="showAddSynology()">+ Добавить NAS</button>
|
<button class="btn-primary" onclick="showAddSynology()">+ Добавить NAS</button>
|
||||||
<button class="btn-secondary" onclick="refreshAllSynology()">🔄 Обновить все</button>
|
<button class="btn-secondary" onclick="refreshAllSynology()">🔄 Обновить все</button>
|
||||||
|
<button class="btn-secondary" onclick="showSynTunnel()">🔗 Туннель</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="servers-grid" id="synology-grid"></div>
|
<div class="servers-grid" id="synology-grid"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,6 +86,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="servers-grid" id="ha-grid"></div>
|
<div class="servers-grid" id="ha-grid"></div>
|
||||||
</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>
|
</main>
|
||||||
|
|
||||||
<!-- Add Server Modal -->
|
<!-- Add Server Modal -->
|
||||||
@@ -185,6 +197,105 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- PC Download Modal -->
|
||||||
<div class="modal" id="pcDownloadModal" style="display:none">
|
<div class="modal" id="pcDownloadModal" style="display:none">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -415,6 +526,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- PC Setup Modal -->
|
||||||
<div class="modal" id="pcSetupModal" style="display:none">
|
<div class="modal" id="pcSetupModal" style="display:none">
|
||||||
<div class="modal-content modal-wide">
|
<div class="modal-content modal-wide">
|
||||||
@@ -425,7 +570,7 @@
|
|||||||
<div class="instructions-section">
|
<div class="instructions-section">
|
||||||
<div class="instr-card">
|
<div class="instr-card">
|
||||||
<h4>📥 Быстрая установка (PowerShell от Администратора)</h4>
|
<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>
|
||||||
<div class="instr-card">
|
<div class="instr-card">
|
||||||
<h4>⚙️ Параметры</h4>
|
<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@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="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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user