diff --git a/server/api/keenetic.py b/server/api/keenetic.py index 1930694..98a976c 100644 --- a/server/api/keenetic.py +++ b/server/api/keenetic.py @@ -14,6 +14,7 @@ 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, ) @@ -223,6 +224,40 @@ async def keenetic_add_bulk(request: Request, user: str = Depends(require_auth)) 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() diff --git a/server/services/keenetic_client.py b/server/services/keenetic_client.py index 717f768..42586f9 100644 --- a/server/services/keenetic_client.py +++ b/server/services/keenetic_client.py @@ -27,6 +27,23 @@ def normalize_web_url(url: str) -> str: return url.rstrip("/") +def parse_keenetic_web_url(url: str) -> tuple[str, str]: + """Validate KeenDNS/web URL; return (web_url, host) with port in host.""" + raw = (url or "").strip() + if not raw: + raise ValueError("Укажите адрес KeenDNS") + if "://" in raw: + normalized = raw.rstrip("/") + else: + normalized = f"https://{raw}".rstrip("/") + parsed = urlparse(normalized) + if not parsed.scheme or parsed.scheme not in ("http", "https"): + raise ValueError("Адрес должен начинаться с http:// или https://") + if not parsed.netloc or not parsed.hostname: + raise ValueError("Некорректный формат адреса") + return normalized, parsed.netloc + + def is_public_ip_host(host: str) -> bool: """True when host is a bare IPv4 (optional :port), not KeenDNS.""" if not host: diff --git a/server/static/css/style.css b/server/static/css/style.css index 4aa8e3e..d47f2f2 100644 --- a/server/static/css/style.css +++ b/server/static/css/style.css @@ -433,10 +433,21 @@ main { .progress-bar .fill.warn { background: var(--warning); } .progress-bar .fill.crit { background: var(--danger); } +.keenetic-url-edit { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border); +} + +.keenetic-url-edit .form-group input { + font-size: 12px; +} + .server-card-actions { display: flex; gap: 8px; margin-top: 14px; + flex-wrap: wrap; padding-top: 14px; border-top: 1px solid var(--border); } @@ -722,6 +733,114 @@ main { text-decoration: underline; } +/* Loading state */ +.server-card.loading { + opacity: 0.5; + pointer-events: none; + position: relative; +} +.server-card.loading::after { + content: "⏳"; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 32px; + animation: pulse 1s infinite; +} +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +/* Card bottom buttons */ +.card-buttons { + display: flex; + gap: 6px; + padding: 8px 0 0; + margin-top: 8px; + border-top: 1px solid rgba(255,255,255,0.06); + flex-wrap: wrap; +} +.btn-card { + flex: 1; + padding: 6px 10px; + border: 1px solid rgba(255,255,255,0.1); + background: rgba(255,255,255,0.04); + color: var(--text); + border-radius: 6px; + font-size: 12px; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; + min-width: 0; +} +.btn-card:hover { background: rgba(255,255,255,0.1); } +.btn-card.btn-warn { + border-color: rgba(251,191,36,0.3); + color: #fbbf24; +} +.btn-card.btn-warn:hover { background: rgba(251,191,36,0.15); } +.btn-card.btn-danger-sm { + flex: 0; + border-color: rgba(239,68,68,0.3); + color: #ef4444; + padding: 6px 10px; +} +.btn-card.btn-danger-sm:hover { background: rgba(239,68,68,0.15); } + +/* VPN badges */ +.badge-vpn-up { + display: inline-block; + background: rgba(34,197,94,0.15); + color: #22c55e; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + border: 1px solid rgba(34,197,94,0.3); +} +.badge-vpn-down { + display: inline-block; + background: rgba(239,68,68,0.15); + color: #ef4444; + padding: 2px 8px; + border-radius: 4px; + font-size: 11px; + border: 1px solid rgba(239,68,68,0.3); +} + +/* Mute bell icon */ +.mute-bell { + position: absolute; + top: 10px; + right: 10px; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + border: 1px solid transparent; + background: rgba(255,255,255,0.04); + cursor: pointer; + font-size: 15px; + opacity: 0.35; + transition: all 0.2s; + z-index: 5; + padding: 0; + line-height: 1; +} +.mute-bell:hover { + opacity: 1; + background: rgba(255,255,255,0.1); + border-color: rgba(255,255,255,0.15); +} +.mute-bell.muted { + opacity: 1; + background: rgba(239,68,68,0.15); + border-color: rgba(239,68,68,0.4); +} + /* Responsive */ @media (max-width: 768px) { main { padding: 16px; } diff --git a/server/static/js/app.js b/server/static/js/app.js index 428c938..510886a 100644 --- a/server/static/js/app.js +++ b/server/static/js/app.js @@ -1226,6 +1226,9 @@ function renderKeenetic() { linksHtml += ''; } + const urlEditId = 'keen-url-' + dev.name.replace(/[^a-zA-Z0-9_-]/g, '_'); + const urlValue = escHtml(keeneticWebUrl(dev)); + return `