mirror of
https://github.com/andrey271192/vps_monitoring.git
synced 2026-09-20 11:55:34 +00:00
feat(keenetic): edit KeenDNS address from dashboard
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>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -1226,6 +1226,9 @@ function renderKeenetic() {
|
||||
linksHtml += '</div>';
|
||||
}
|
||||
|
||||
const urlEditId = 'keen-url-' + dev.name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
const urlValue = escHtml(keeneticWebUrl(dev));
|
||||
|
||||
return `
|
||||
<div class="server-card ${isOnline ? 'online' : 'offline'}" data-keen="${dev.name}" onclick="showKeeneticDetail('${dev.name}')">
|
||||
${bellHtml('keenetic', dev.name)}
|
||||
@@ -1259,7 +1262,15 @@ function renderKeenetic() {
|
||||
${uptime ? ' • ⏱ ' + uptime : ''}
|
||||
</div>
|
||||
` : `<div style="padding:20px 0;text-align:center;opacity:0.5">${error ? '⚠️ ' + error : 'Нет данных — нажмите 🔄'}</div>`}
|
||||
<div id="${urlEditId}" class="keenetic-url-edit" style="display:none" onclick="event.stopPropagation()">
|
||||
<div class="form-group" style="margin-bottom:8px">
|
||||
<label>Адрес KeenDNS</label>
|
||||
<input type="text" id="${urlEditId}-input" value="${urlValue}" placeholder="https://example.keenetic.pro:8443">
|
||||
</div>
|
||||
<button class="btn-primary" style="width:100%" onclick="saveKeeneticUrl(${JSON.stringify(dev.name)})">Сохранить</button>
|
||||
</div>
|
||||
<div class="server-card-actions">
|
||||
<button onclick="event.stopPropagation();toggleKeeneticUrlEdit(${JSON.stringify(dev.name)})">✏️ KeenDNS</button>
|
||||
<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>
|
||||
@@ -1537,6 +1548,52 @@ async function rebootKeenetic(name) {
|
||||
} catch (e) { alert('Error: ' + e.message); }
|
||||
}
|
||||
|
||||
function keeneticUrlEditId(name) {
|
||||
return 'keen-url-' + name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||
}
|
||||
|
||||
function toggleKeeneticUrlEdit(name) {
|
||||
const block = document.getElementById(keeneticUrlEditId(name));
|
||||
if (!block) return;
|
||||
const show = block.style.display === 'none';
|
||||
block.style.display = show ? 'block' : 'none';
|
||||
if (show) {
|
||||
const input = document.getElementById(keeneticUrlEditId(name) + '-input');
|
||||
if (input) input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveKeeneticUrl(name) {
|
||||
const input = document.getElementById(keeneticUrlEditId(name) + '-input');
|
||||
if (!input) return;
|
||||
const web_url = input.value.trim();
|
||||
if (!web_url) {
|
||||
alert('Укажите адрес KeenDNS');
|
||||
return;
|
||||
}
|
||||
const saveBtn = input.closest('.keenetic-url-edit')?.querySelector('.btn-primary');
|
||||
if (saveBtn) { saveBtn.disabled = true; saveBtn.textContent = '⏳ Сохранение...'; }
|
||||
try {
|
||||
const resp = await fetch(`/api/keenetic/${encodeURIComponent(name)}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({web_url, refresh: true}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.status !== 'ok') {
|
||||
alert('❌ ' + (data.detail || 'Ошибка сохранения'));
|
||||
return;
|
||||
}
|
||||
await loadKeenetic();
|
||||
toggleKeeneticUrlEdit(name);
|
||||
} catch (e) {
|
||||
alert('Ошибка: ' + e.message);
|
||||
} finally {
|
||||
if (saveBtn) { saveBtn.disabled = false; saveBtn.textContent = 'Сохранить'; }
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteKeenetic(name) {
|
||||
if (!confirm(`Удалить роутер "${name}"?`)) return;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user