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:
Андрей Бобырев
2026-05-22 01:02:01 +03:00
parent 746ecfc343
commit 8f8e9a3234
4 changed files with 228 additions and 0 deletions

View File

@@ -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; }

View File

@@ -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 {