- 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
770 lines
32 KiB
HTML
770 lines
32 KiB
HTML
<!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> </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>
|