mirror of
https://github.com/andrey271192/vps_monitoring.git
synced 2026-09-20 11:55:34 +00:00
Add PATCH endpoint and inline card editor to update web_url and host, with URL validation and automatic refresh after save. Co-authored-by: Cursor <cursoragent@cursor.com>
354 lines
12 KiB
Python
354 lines
12 KiB
Python
"""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,
|
|
parse_keenetic_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.patch("/{name}")
|
|
async def keenetic_update(name: str, request: Request, user: str = Depends(require_auth)):
|
|
"""Update KeenDNS/web URL for a router (web_url + host)."""
|
|
body = await request.json()
|
|
web_url_raw = (body.get("web_url") or body.get("url") or "").strip()
|
|
if not web_url_raw:
|
|
return {"status": "error", "detail": "web_url required"}
|
|
|
|
try:
|
|
web_url, host = parse_keenetic_web_url(web_url_raw)
|
|
except ValueError as e:
|
|
return {"status": "error", "detail": str(e)}
|
|
|
|
devices = _load_keenetic()
|
|
idx = next((i for i, d in enumerate(devices) if d["name"] == name), None)
|
|
if idx is None:
|
|
return {"status": "error", "detail": "router not found"}
|
|
|
|
devices[idx]["web_url"] = web_url
|
|
devices[idx]["host"] = host
|
|
_save_keenetic(devices)
|
|
keenetic_metrics.pop(name, None)
|
|
|
|
result = {"status": "ok", "web_url": web_url, "host": host}
|
|
if body.get("refresh", True):
|
|
try:
|
|
metrics = await _refresh_device(devices[idx])
|
|
result["metrics"] = metrics
|
|
except Exception as e:
|
|
result["refresh_error"] = str(e)
|
|
|
|
return result
|
|
|
|
|
|
@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(3)
|
|
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))
|