Files
Veeam_dashboard/static/index.html
smolkik-code ba8a413e85 Initial commit: Veeam multi-instance monitoring dashboard
- FastAPI backend with CRUD for Veeam instances
- Veeam REST API client supporting v9.5-v13 (legacy + OAuth2)
- Multi-version detection and auth fallback
- SQLite caching layer
- Web dashboard with summary cards, instance list, detail tabs
- Docker packaging
2026-06-09 17:10:42 +07:00

770 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Veeam Dashboard</title>
<link rel="icon" href="data:,">
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--surface-2: #232736;
--border: #2a2e3d;
--text: #e1e4ed;
--text-muted: #8b90a0;
--green: #22c55e;
--red: #ef4444;
--yellow: #eab308;
--blue: #3b82f6;
--cyan: #06b6d4;
--accent: #6366f1;
--radius: 10px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 24px;
}
.container { max-width: 1400px; margin: 0 auto; }
/* header */
.header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 28px; flex-wrap: wrap; gap: 12px;
}
.header h1 {
font-size: 24px; font-weight: 700;
background: linear-gradient(135deg, var(--accent), var(--cyan));
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.header-actions { display: flex; gap: 10px; align-items: center; }
/* buttons */
.btn {
padding: 8px 18px; border-radius: 8px; border: 1px solid var(--border);
background: var(--surface); color: var(--text); cursor: pointer;
font-size: 14px; transition: all .15s; white-space: nowrap;
}
.btn:hover { background: var(--surface-2); border-color: var(--accent); }
.btn-primary {
background: var(--accent); border-color: var(--accent); color: #fff;
}
.btn-primary:hover { filter: brightness(1.15); }
.btn-danger { border-color: var(--red); color: var(--red); }
.btn-danger:hover { background: var(--red); color: #fff; }
.btn-sm { padding: 5px 12px; font-size: 12px; }
.btn-icon { padding: 6px 10px; line-height: 1; }
/* summary cards */
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px; margin-bottom: 28px;
}
.summary-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px; text-align: center;
}
.summary-card .value {
font-size: 32px; font-weight: 700; line-height: 1.2;
}
.summary-card .label {
font-size: 12px; color: var(--text-muted); margin-top: 4px;
text-transform: uppercase; letter-spacing: .5px;
}
.summary-card.green .value { color: var(--green); }
.summary-card.red .value { color: var(--red); }
.summary-card.yellow .value { color: var(--yellow); }
.summary-card.blue .value { color: var(--blue); }
.summary-card.cyan .value { color: var(--cyan); }
/* instance cards */
.instances { display: flex; flex-direction: column; gap: 10px; }
.instance-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius); overflow: hidden;
transition: border-color .15s;
}
.instance-card:hover { border-color: var(--accent); }
.instance-card.offline { opacity: .7; }
.instance-card.offline .instance-header { border-left: 3px solid var(--red); }
.instance-header {
display: flex; align-items: center; gap: 14px;
padding: 14px 18px; cursor: pointer;
border-left: 3px solid var(--green);
transition: background .15s;
}
.instance-header:hover { background: var(--surface-2); }
.instance-header .status-dot {
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
}
.status-dot.online { background: var(--green); box-shadow: 0 0 6px var(--green); }
.status-dot.offline { background: var(--red); box-shadow: 0 0 6px var(--red); }
.instance-header .info { flex: 1; min-width: 0; }
.instance-header .name {
font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis;
}
.instance-header .meta {
font-size: 12px; color: var(--text-muted); margin-top: 2px;
}
.instance-header .badge {
padding: 2px 10px; border-radius: 20px; font-size: 11px; font-weight: 600;
white-space: nowrap;
}
.badge.success { background: rgba(34,197,94,.15); color: var(--green); }
.badge.warning { background: rgba(234,179,8,.15); color: var(--yellow); }
.badge.danger { background: rgba(239,68,68,.15); color: var(--red); }
.badge.info { background: rgba(59,130,246,.15); color: var(--blue); }
.badge.neutral { background: rgba(139,144,160,.15); color: var(--text-muted); }
.instance-header .stats {
display: flex; gap: 16px; align-items: center;
}
.instance-header .stat-item {
text-align: center; font-size: 11px; color: var(--text-muted);
}
.instance-header .stat-item .stat-value {
font-size: 16px; font-weight: 600; color: var(--text);
}
/* instance detail */
.instance-detail {
border-top: 1px solid var(--border); padding: 18px;
display: none;
}
.instance-detail.open { display: block; }
.detail-tabs {
display: flex; gap: 4px; margin-bottom: 14px;
border-bottom: 1px solid var(--border); padding-bottom: 0;
}
.detail-tab {
padding: 8px 16px; border: none; background: none; color: var(--text-muted);
cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent;
transition: all .15s;
}
.detail-tab:hover { color: var(--text); }
.detail-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-content { display: none; }
.tab-content.active { display: block; }
/* tables */
.table-wrap { overflow-x: auto; }
table {
width: 100%; border-collapse: collapse; font-size: 13px;
}
th {
text-align: left; padding: 8px 12px; font-weight: 600;
color: var(--text-muted); font-size: 11px; text-transform: uppercase;
letter-spacing: .5px; border-bottom: 1px solid var(--border);
}
td {
padding: 8px 12px; border-bottom: 1px solid var(--border);
}
tr:hover td { background: var(--surface-2); }
.job-result { font-weight: 600; }
.job-result.success { color: var(--green); }
.job-result.failed, .job-result.error { color: var(--red); }
.job-result.warning { color: var(--yellow); }
.job-result.running { color: var(--blue); }
.job-disabled td { opacity: .45; }
.job-disabled td:last-child { opacity: 1; }
.jobs-summary { margin-bottom: 10px; font-size: 13px; display: flex; gap: 16px; align-items: center; }
/* modal */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.6);
display: flex; align-items: center; justify-content: center;
z-index: 100; padding: 20px;
opacity: 0; pointer-events: none; transition: opacity .2s;
}
.modal-overlay.open { opacity: 1; pointer-events: auto; }
.modal {
background: var(--surface); border: 1px solid var(--border);
border-radius: 12px; width: 100%; max-width: 520px;
max-height: 90vh; overflow-y: auto; padding: 24px;
}
.modal h2 { font-size: 18px; margin-bottom: 20px; }
.form-group { margin-bottom: 14px; }
.form-group label {
display: block; font-size: 12px; color: var(--text-muted);
margin-bottom: 4px; text-transform: uppercase; letter-spacing: .5px;
}
.form-group input, .form-group select, .form-group textarea {
width: 100%; padding: 9px 12px; border-radius: 8px;
border: 1px solid var(--border); background: var(--bg);
color: var(--text); font-size: 14px;
}
.form-group input:focus, .form-group select:focus {
outline: none; border-color: var(--accent);
}
.form-row { display: flex; gap: 12px; }
.form-row .form-group { flex: 1; }
.modal-actions {
display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;
}
/* status bar */
.status-bar {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 0; font-size: 12px; color: var(--text-muted);
margin-top: 20px; border-top: 1px solid var(--border);
}
.status-bar .spinner {
display: inline-block; width: 14px; height: 14px;
border: 2px solid var(--border); border-top-color: var(--accent);
border-radius: 50%; animation: spin .8s linear infinite;
margin-right: 8px; vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
.error-banner {
background: rgba(239,68,68,.1); border: 1px solid var(--red);
border-radius: var(--radius); padding: 12px 16px; margin-bottom: 14px;
font-size: 13px; color: var(--red);
}
.empty-state {
text-align: center; padding: 40px 20px; color: var(--text-muted);
}
.empty-state p { margin-bottom: 12px; }
.version-tag {
display: inline-block; padding: 1px 8px; border-radius: 4px;
background: var(--surface-2); font-size: 11px; font-family: monospace;
color: var(--text-muted);
}
@media (max-width: 768px) {
body { padding: 12px; }
.header h1 { font-size: 18px; }
.summary { grid-template-columns: repeat(2, 1fr); }
.instance-header { flex-wrap: wrap; }
.instance-header .stats { width: 100%; justify-content: space-around; margin-top: 6px; }
.instance-header .badge { display: none; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Veeam Dashboard</h1>
<div class="header-actions">
<button class="btn btn-sm" onclick="refresh()">Обновить</button>
<button class="btn btn-primary btn-sm" onclick="openAddModal()">+ Инстанс</button>
</div>
</div>
<div id="errorContainer"></div>
<div class="summary" id="summaryCards">
<div class="summary-card"><div class="value"></div><div class="label">Инстансы</div></div>
<div class="summary-card"><div class="value"></div><div class="label">Задания</div></div>
<div class="summary-card green"><div class="value"></div><div class="label">Успешно</div></div>
<div class="summary-card red"><div class="value"></div><div class="label">Ошибки</div></div>
<div class="summary-card yellow"><div class="value"></div><div class="label">Warning</div></div>
<div class="summary-card blue"><div class="value"></div><div class="label">Running</div></div>
</div>
<div id="instanceList"><div class="empty-state"><p>Нет добавленных инстансов</p><button class="btn btn-primary btn-sm" onclick="openAddModal()">Добавить первый инстанс</button></div></div>
<div class="status-bar">
<span id="statusText">Загрузка...</span>
<button class="btn btn-sm" onclick="toggleAutoRefresh()" id="autoRefreshBtn">Автообновление: вкл</button>
</div>
</div>
<!-- Modal -->
<div class="modal-overlay" id="modal">
<div class="modal">
<h2 id="modalTitle">Добавить инстанс</h2>
<input type="hidden" id="editId" value="">
<div class="form-group">
<label>Название</label>
<input id="fName" placeholder="Например: DC-Office-Prod">
</div>
<div class="form-row">
<div class="form-group">
<label>Хост / IP</label>
<input id="fHost" placeholder="veeam.office.local">
</div>
<div class="form-group" style="max-width:100px">
<label>Порт</label>
<input id="fPort" placeholder="auto" type="number">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Пользователь</label>
<input id="fUser" placeholder="administrator">
</div>
<div class="form-group">
<label>Пароль</label>
<input id="fPass" type="password">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Версия Veeam</label>
<select id="fVersion">
<option value="auto">Автоопределение</option>
<option value="9.5">9.5</option>
<option value="10">10</option>
<option value="11">11</option>
<option value="12">12</option>
<option value="12.1">12.1</option>
<option value="12.2">12.2</option>
<option value="12.3">12.3</option>
</select>
</div>
<div class="form-group" style="max-width:140px">
<label>&nbsp;</label>
<button class="btn btn-sm" id="testBtn" onclick="testConnection()" style="width:100%">Test</button>
</div>
</div>
<div class="form-group">
<label>Заметки</label>
<input id="fNotes" placeholder="Опционально">
</div>
<div id="testResult" style="font-size:13px;margin-bottom:10px"></div>
<div class="modal-actions">
<button class="btn" onclick="closeModal()">Отмена</button>
<button class="btn btn-danger btn-sm" id="deleteBtn" onclick="deleteInstance()" style="display:none">Удалить</button>
<button class="btn btn-primary" id="saveBtn" onclick="saveInstance()">Сохранить</button>
</div>
</div>
</div>
<script>
// ─── State ────────────────────────────────────────────────────────
let instances = [];
let autoRefresh = true;
let refreshInterval = null;
let currentDetailInstance = null;
// ─── Init ─────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
loadDashboard();
startAutoRefresh();
});
function startAutoRefresh() {
if (refreshInterval) clearInterval(refreshInterval);
refreshInterval = setInterval(() => {
if (autoRefresh) loadDashboard(true);
}, 30000);
}
function toggleAutoRefresh() {
autoRefresh = !autoRefresh;
document.getElementById('autoRefreshBtn').textContent = autoRefresh
? 'Автообновление: вкл' : 'Автообновление: выкл';
}
function setStatus(text, loading = false) {
const el = document.getElementById('statusText');
el.innerHTML = loading
? '<span class="spinner"></span>' + text
: text;
}
// ─── Load ─────────────────────────────────────────────────────────
async function loadDashboard(silent = false) {
if (!silent) setStatus('Загрузка данных...', true);
try {
const res = await fetch('/api/dashboard?force_refresh=' + (silent ? 'false' : 'true'), {
signal: AbortSignal.timeout(60000)
});
if (!res.ok) throw new Error(res.statusText);
const data = await res.json();
instances = data.instances || [];
renderSummary(data.summary);
renderInstances();
setStatus('Обновлено: ' + new Date(data.updated_at).toLocaleString('ru-RU'));
} catch (e) {
if (!silent) {
showError('Ошибка загрузки: ' + e.message);
}
setStatus('Ошибка: ' + e.message);
}
}
async function refresh() {
setStatus('Обновление...', true);
await loadDashboard(false);
}
// ─── Render summary ──────────────────────────────────────────────
function renderSummary(s) {
if (!s) return;
const cards = document.getElementById('summaryCards');
cards.innerHTML = `
<div class="summary-card cyan"><div class="value">${s.total_instances}</div><div class="label">Инстансы (${s.online_instances} online)</div></div>
<div class="summary-card blue"><div class="value">${s.total_jobs}</div><div class="label">Всего заданий</div></div>
<div class="summary-card green"><div class="value">${s.successful_jobs}</div><div class="label">Успешно</div></div>
<div class="summary-card red"><div class="value">${s.failed_jobs}</div><div class="label">Ошибки</div></div>
<div class="summary-card yellow"><div class="value">${s.warning_jobs}</div><div class="label">Warning</div></div>
<div class="summary-card blue"><div class="value">${s.running_jobs}</div><div class="label">Running</div></div>
<div class="summary-card purple"><div class="value">${s.total_repositories}</div><div class="label">Репозитории</div></div>
`;
}
// ─── Render instances ────────────────────────────────────────────
function renderInstances() {
const container = document.getElementById('instanceList');
if (!instances.length) {
container.innerHTML = '<div class="empty-state"><p>Нет добавленных инстансов</p><button class="btn btn-primary btn-sm" onclick="openAddModal()">Добавить первый инстанс</button></div>';
return;
}
container.innerHTML = '<div class="instances">' + instances.map(i => renderInstanceCard(i)).join('') + '</div>';
}
function renderInstanceCard(inst) {
const online = inst.is_online;
const js = inst.jobs_summary || {};
let badge = js.total > 0
? (js.failed > 0 ? '<span class="badge danger">' + js.failed + ' errors</span>'
: js.warning > 0 ? '<span class="badge warning">' + js.warning + ' warnings</span>'
: js.running > 0 ? '<span class="badge info">' + js.running + ' running</span>'
: js.success > 0 ? '<span class="badge success">' + js.success + ' OK</span>'
: '<span class="badge neutral">0 jobs</span>')
: '<span class="badge neutral">offline</span>';
if (js.disabled > 0) {
badge += ' <span class="badge danger">' + js.disabled + ' disabled</span>';
}
const errors = inst.errors && inst.errors.length
? ' <span style="color:var(--red);font-size:11px">⚠ ' + inst.errors.join('; ') + '</span>'
: '';
return `
<div class="instance-card ${online ? '' : 'offline'}" id="card-${inst.id}">
<div class="instance-header" onclick="toggleDetail(${inst.id})">
<span class="status-dot ${online ? 'online' : 'offline'}"></span>
<div class="info">
<div class="name">${esc(inst.name)} <span class="version-tag">v${esc(inst.version)}</span></div>
<div class="meta">${esc(inst.host)}:${inst.port}${errors}</div>
</div>
<div class="stats">
<div class="stat-item"><div class="stat-value">${js.total || 0}</div>jobs</div>
<div class="stat-item"><div class="stat-value" style="color:var(--green)">${(js.total || 0) - (js.disabled || 0)}</div>on</div>
<div class="stat-item"><div class="stat-value" style="color:var(--red)">${js.disabled || 0}</div>off</div>
</div>
${badge}
<button class="btn btn-icon btn-sm" onclick="event.stopPropagation(); openEditModal(${inst.id})">⚙</button>
</div>
<div class="instance-detail" id="detail-${inst.id}">
<div class="detail-tabs">
<button class="detail-tab active" onclick="switchTab(${inst.id}, 'jobs', this)">Политики</button>
<button class="detail-tab" onclick="switchTab(${inst.id}, 'sessions', this)">Сессии</button>
<button class="detail-tab" onclick="switchTab(${inst.id}, 'storage', this)">Хранилище</button>
<button class="detail-tab" onclick="switchTab(${inst.id}, 'agents', this)">Агенты</button>
<button class="detail-tab" onclick="switchTab(${inst.id}, 'alarms', this)">Алерты</button>
</div>
<div class="tab-content active" id="tab-${inst.id}-jobs">${renderJobsTable(inst)}</div>
<div class="tab-content" id="tab-${inst.id}-sessions">${renderSessionsTable(inst)}</div>
<div class="tab-content" id="tab-${inst.id}-storage">${renderRepoStatesTable(inst)}</div>
<div class="tab-content" id="tab-${inst.id}-agents">${renderAgentsTable(inst)}</div>
<div class="tab-content" id="tab-${inst.id}-alarms">${renderAlarmsTable(inst)}</div>
</div>
</div>
`;
}
function renderJobsTable(inst) {
const jobs = inst.jobs || [];
const js = inst.jobs_summary || {};
if (!jobs.length) return '<div class="empty-state">Нет данных о политиках</div>';
const enabled = (js.total || 0) - (js.disabled || 0);
let header = '<div class="jobs-summary"><span>Всего: <strong>' + (js.total || 0) + '</strong></span> <span>Включено: <strong style="color:var(--green)">' + enabled + '</strong></span> <span>Выключено: <strong style="color:var(--red)">' + (js.disabled || 0) + '</strong></span>';
if (js.disabled > 0) header += ' <span class="badge danger">⚠ ВНИМАНИЕ! ЕСТЬ ВЫКЛЮЧЕННЫЕ ПОЛИТИКИ!</span>';
header += '</div>';
let rows = '';
for (const j of jobs) {
const isDisabled = j.schedule_enabled === false || (j.state || '').toLowerCase() === 'disabled';
const pts = j.restore_points_to_keep != null ? j.restore_points_to_keep : (j.restorePointsToKeep != null ? j.restorePointsToKeep : '—');
rows += '<tr class="' + (isDisabled ? 'job-disabled' : '') + '">';
rows += '<td>' + esc(j.name || j.Name || '—') + '</td>';
rows += '<td>' + esc(j.type || j.jobType || '—') + '</td>';
rows += '<td>' + pts + '</td>';
rows += '<td>' + formatDate(j.last_backup) + '</td>';
rows += '<td>' + (isDisabled ? '<span class="badge danger">выключена</span>' : '<span class="badge success">включена</span>') + '</td>';
rows += '</tr>';
}
return header + '<div class="table-wrap"><table><thead><tr><th>Имя</th><th>Тип</th><th>Точки восст.</th><th>Последний бэкап</th><th>Расписание</th></tr></thead><tbody>' + rows + '</tbody></table></div>';
}
function renderSessionsTable(inst) {
const sessions = inst.recent_sessions || [];
if (!sessions.length) return '<div class="empty-state">Нет сессий</div>';
return `<div class="table-wrap"><table>
<thead><tr><th>Задание</th><th>Результат</th><th>Состояние</th><th>Начало</th><th>Конец</th></tr></thead>
<tbody>${sessions.map(s => {
const rawResult = typeof s.result === 'object' ? s.result.result || s.result.Result || '' : (s.result || '');
const result = (rawResult || '').toLowerCase();
const cls = ['success','failed','error','warning'].find(c => result === c || (c === 'failed' && result === 'error')) || '';
return `<tr>
<td>${esc(s.job_name || '—')}</td>
<td class="job-result ${cls}">${esc(rawResult || '—')}</td>
<td>${esc(s.state || '—')}</td>
<td>${formatDate(s.creation_time)}</td>
<td>${formatDate(s.end_time)}</td>
</tr>`;
}).join('')}</tbody>
</table></div>`;
}
function renderReposTable(inst) {
const repos = inst.repositories || [];
if (!repos.length) return '<div class="empty-state">Нет репозиториев</div>';
return `<div class="table-wrap"><table>
<thead><tr><th>Имя</th><th>Тип</th><th>Host</th></tr></thead>
<tbody>${repos.map(r => `<tr>
<td>${esc(r.name || r.Name || '—')}</td>
<td>${esc(r.type || r.Type || '—')}</td>
<td>${esc(r.host || r.Host || r.hostName || '—')}</td>
</tr>`).join('')}</tbody>
</table></div>`;
}
function renderAlarmsTable(inst) {
const alarms = inst.alarms || [];
if (!alarms.length) return '<div class="empty-state">Нет активных алертов</div>';
return `<div class="table-wrap"><table>
<thead><tr><th>Тип</th><th>Статус</th><th>Время</th><th>Сообщение</th></tr></thead>
<tbody>${alarms.map(a => `<tr>
<td>${esc(a.alarmType || a.type || '—')}</td>
<td class="job-result ${(a.status||'').toLowerCase() === 'error' ? 'error' : 'warning'}">${esc(a.status || '—')}</td>
<td>${formatDate(a.creationTime || a.time)}</td>
<td>${esc(a.message || a.description || '—')}</td>
</tr>`).join('')}</tbody>
</table></div>`;
}
function renderRepoStatesTable(inst) {
const states = inst.repository_states || [];
if (!states.length) return '<div class="empty-state">Нет данных о хранилище</div>';
return `<div class="table-wrap"><table>
<thead><tr><th>Имя</th><th>Тип</th><th>Ёмкость</th><th>Занято</th><th>Свободно</th><th>Online</th></tr></thead>
<tbody>${states.map(r => {
const pct = r.capacityGB > 0 ? ((r.usedSpaceGB / r.capacityGB) * 100).toFixed(0) : 0;
return `<tr>
<td>${esc(r.name || r.Name || '—')}</td>
<td>${esc(r.type || r.Type || '—')}</td>
<td>${formatGB(r.capacityGB)}</td>
<td>${formatGB(r.usedSpaceGB)} (${pct}%)</td>
<td>${formatGB(r.freeGB)}</td>
<td>${r.isOnline ? '✓' : '✗'}</td>
</tr>`;
}).join('')}</tbody>
</table></div>`;
}
function renderAgentsTable(inst) {
const agents = inst.agents || [];
if (!agents.length) return '<div class="empty-state">Нет агентов</div>';
return `<div class="table-wrap"><table>
<thead><tr><th>Имя</th><th>Тип</th><th>Статус</th></tr></thead>
<tbody>${agents.map(a => `<tr>
<td>${esc(a.name || a.Name || a.hostName || '—')}</td>
<td>${esc(a.type || a.Type || '—')}</td>
<td>${esc(a.status || a.Status || a.protectionStatus || '—')}</td>
</tr>`).join('')}</tbody>
</table></div>`;
}
// ─── Detail toggle ───────────────────────────────────────────────
function toggleDetail(id) {
const detail = document.getElementById('detail-' + id);
if (!detail) return;
const isOpen = detail.classList.toggle('open');
if (isOpen) {
currentDetailInstance = id;
}
}
function switchTab(instId, tab, btn) {
const parent = btn.closest('.instance-detail');
parent.querySelectorAll('.detail-tab').forEach(t => t.classList.remove('active'));
parent.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
btn.classList.add('active');
document.getElementById('tab-' + instId + '-' + tab).classList.add('active');
}
// ─── Modal ────────────────────────────────────────────────────────
function openAddModal() {
document.getElementById('editId').value = '';
document.getElementById('modalTitle').textContent = 'Добавить инстанс';
document.getElementById('saveBtn').textContent = 'Сохранить';
document.getElementById('deleteBtn').style.display = 'none';
document.getElementById('testResult').textContent = '';
document.getElementById('fName').value = '';
document.getElementById('fHost').value = '';
document.getElementById('fPort').value = '';
document.getElementById('fUser').value = '';
document.getElementById('fPass').value = '';
document.getElementById('fVersion').value = 'auto';
document.getElementById('fNotes').value = '';
document.getElementById('modal').classList.add('open');
}
async function openEditModal(id) {
document.getElementById('editId').value = id;
document.getElementById('modalTitle').textContent = 'Редактировать инстанс';
document.getElementById('saveBtn').textContent = 'Сохранить';
document.getElementById('deleteBtn').style.display = 'inline-block';
document.getElementById('testResult').textContent = '';
try {
const res = await fetch('/api/instances/' + id);
if (!res.ok) throw new Error('Not found');
const inst = await res.json();
document.getElementById('fName').value = inst.name;
document.getElementById('fHost').value = inst.host;
document.getElementById('fPort').value = inst.port;
document.getElementById('fUser').value = inst.username;
document.getElementById('fPass').value = '';
document.getElementById('fVersion').value = inst.version;
document.getElementById('fNotes').value = inst.notes;
} catch (e) {
showError('Ошибка загрузки данных инстанса');
}
document.getElementById('modal').classList.add('open');
}
function closeModal() {
document.getElementById('modal').classList.remove('open');
}
async function testConnection() {
const btn = document.getElementById('testBtn');
const result = document.getElementById('testResult');
btn.disabled = true;
btn.textContent = 'Тестирую...';
result.textContent = '';
try {
const res = await fetch('/api/test-connection', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name: document.getElementById('fName').value || 'test',
host: document.getElementById('fHost').value,
port: parseInt(document.getElementById('fPort').value) || null,
username: document.getElementById('fUser').value,
password: document.getElementById('fPass').value,
version: document.getElementById('fVersion').value,
})
});
const data = await res.json();
if (data.ok) {
result.style.color = 'var(--green)';
result.textContent = '✓ Успешно! Роль: ' + data.role + ', версия: ' + data.version;
} else {
result.style.color = 'var(--red)';
result.textContent = '✗ ' + (data.error || 'Ошибка подключения');
}
} catch (e) {
result.style.color = 'var(--red)';
result.textContent = '✗ ' + e.message;
}
btn.disabled = false;
btn.textContent = 'Test';
}
async function saveInstance() {
const id = document.getElementById('editId').value;
const payload = {
name: document.getElementById('fName').value,
host: document.getElementById('fHost').value,
port: parseInt(document.getElementById('fPort').value) || null,
username: document.getElementById('fUser').value,
password: document.getElementById('fPass').value,
version: document.getElementById('fVersion').value,
notes: document.getElementById('fNotes').value,
};
if (!payload.host || !payload.username || !payload.password) {
showError('Заполните хост, пользователя и пароль');
return;
}
try {
let res;
if (id) {
res = await fetch('/api/instances/' + id, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
} else {
res = await fetch('/api/instances', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
}
if (!res.ok) {
const err = await res.text();
throw new Error(err);
}
closeModal();
await loadDashboard(false);
} catch (e) {
showError('Ошибка сохранения: ' + e.message);
}
}
async function deleteInstance() {
const id = document.getElementById('editId').value;
if (!id || !confirm('Удалить этот инстанс?')) return;
try {
const res = await fetch('/api/instances/' + id, { method: 'DELETE' });
if (!res.ok) throw new Error('Delete failed');
closeModal();
await loadDashboard(false);
} catch (e) {
showError('Ошибка удаления: ' + e.message);
}
}
// ─── Utils ────────────────────────────────────────────────────────
function esc(s) {
if (s == null) return '—';
const div = document.createElement('div');
div.textContent = String(s);
return div.innerHTML;
}
function formatDate(d) {
if (!d || d === '—' || d === '') return '—';
try {
const dt = new Date(d);
if (isNaN(dt.getTime())) return d;
return dt.toLocaleString('ru-RU', {
day: '2-digit', month: '2-digit', year: '2-digit',
hour: '2-digit', minute: '2-digit'
});
} catch { return d; }
}
function formatGB(gb) {
if (gb == null || isNaN(gb)) return '—';
if (gb >= 1024) return (gb / 1024).toFixed(1) + ' TB';
return gb.toFixed(1) + ' GB';
}
function showError(msg) {
const container = document.getElementById('errorContainer');
container.innerHTML = '<div class="error-banner">' + esc(msg) + '</div>';
setTimeout(() => container.innerHTML = '', 8000);
}
</script>
</body>
</html>