Добавлен virt_backend: одно постоянное libvirt-соединение вместо десятков вызовов virsh на каждую страницу, с автоматическим fallback на virsh, если libvirt-python недоступен. Обзорные запросы (список VM, пулы, сервисы) закешированы с коротким TTL и сбросом после действий. Появился фоновый сборщик метрик хоста и спарклайны CPU/RAM на дашборде. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PKUoZwHMCHnytfswFVRUHw
255 строки
12 KiB
HTML
255 строки
12 KiB
HTML
<!doctype html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>{{ app_name }} Panel</title>
|
||
<link rel="stylesheet" href="/static/app.css">
|
||
</head>
|
||
<body>
|
||
<div class="v-layout">
|
||
{% include "_sidebar.html" %}
|
||
|
||
<main class="v-main">
|
||
<header class="v-topbar">
|
||
<div>
|
||
<div class="v-title">Обзор</div>
|
||
<div class="v-subtitle">{{ system.hostname }} · {{ system.ip }} · {{ system.time }}</div>
|
||
</div>
|
||
<div class="top-meta">
|
||
<span>user: {{ user }}</span>
|
||
<form method="post" action="/logout" class="logout-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="ghost">Выйти</button></form>
|
||
</div>
|
||
</header>
|
||
|
||
{% if update_notice and update_notice.has_update %}
|
||
<section class="card update-banner">
|
||
<div>
|
||
<div class="label">Доступно обновление Virtuality</div>
|
||
<div class="value small">{{ update_notice.current_version }} → {{ update_notice.latest_version }}</div>
|
||
<div class="muted small-note">Пропущено версий: {{ update_notice.missing_count }} · новых коммитов: {{ update_notice.commit_count }}</div>
|
||
</div>
|
||
<div class="actions">
|
||
<a class="link-pill accent" href="/update">Посмотреть изменения</a>
|
||
</div>
|
||
</section>
|
||
{% endif %}
|
||
|
||
<section class="grid stats">
|
||
<article class="card stat">
|
||
<div class="label">Uptime</div>
|
||
<div class="value">{{ system.uptime }}</div>
|
||
</article>
|
||
<article class="card stat">
|
||
<div class="label">Load</div>
|
||
<div class="value">{{ system.load }}</div>
|
||
</article>
|
||
<article class="card stat">
|
||
<div class="label">Kernel</div>
|
||
<div class="value small">{{ system.kernel }}</div>
|
||
</article>
|
||
<article class="card stat">
|
||
<div class="label">VM</div>
|
||
<div class="value">{{ vms|length }}</div>
|
||
</article>
|
||
</section>
|
||
|
||
<section class="grid two">
|
||
<article class="card">
|
||
<div class="card-head">
|
||
<h2>CPU хоста</h2>
|
||
<span class="pill" id="cpu-now">—</span>
|
||
</div>
|
||
<svg id="cpu-spark" class="sparkline" viewBox="0 0 300 60" preserveAspectRatio="none" aria-label="График CPU за час">
|
||
<polyline points="" fill="none"></polyline>
|
||
</svg>
|
||
<p class="muted small-note">Загрузка процессора за последний час, обновляется каждые 15 секунд.</p>
|
||
</article>
|
||
<article class="card">
|
||
<div class="card-head">
|
||
<h2>Память хоста</h2>
|
||
<span class="pill" id="mem-now">—</span>
|
||
</div>
|
||
<svg id="mem-spark" class="sparkline" viewBox="0 0 300 60" preserveAspectRatio="none" aria-label="График памяти за час">
|
||
<polyline points="" fill="none"></polyline>
|
||
</svg>
|
||
<p class="muted small-note">Использование RAM за последний час. Хвост графика заполняется по мере работы панели.</p>
|
||
</article>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<div class="card-head">
|
||
<h2>Виртуальные машины</h2>
|
||
<div class="actions">
|
||
<a class="link-pill" href="/operations">Операции</a>
|
||
<a class="link-pill" href="/logs">Журналы</a>
|
||
</div>
|
||
</div>
|
||
<table class="vm-table">
|
||
<thead>
|
||
<tr><th>ID</th><th>Name</th><th>IP</th><th>State</th><th>Autostart</th><th>Actions</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for vm in vms %}
|
||
{% set is_running = 'running' in vm.state %}
|
||
<tr class="vm-row {{ 'is-running' if is_running else 'is-stopped' }}">
|
||
<td>{{ vm.id }}</td>
|
||
<td class="strong"><a class="table-link" href="/vm/{{ vm.name }}">{{ vm.name }}</a></td>
|
||
<td class="strong">{{ vm.ip|default('—') }}</td>
|
||
<td><span class="status {{ 'ok' if is_running else 'err' if 'shut' in vm.state else 'warn' }}">{{ vm.state }}</span></td>
|
||
<td class="autostart-cell">
|
||
{% if vm.autostart_enabled %}
|
||
<form method="post" action="/vm/{{ vm.name }}/autostart-disable" class="inline-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||
<button class="status status-button ok" title="Отключить автозапуск">{{ vm.autostart_label|default('enabled') }}</button>
|
||
</form>
|
||
{% else %}
|
||
<form method="post" action="/vm/{{ vm.name }}/autostart" class="inline-form"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||
<button class="status status-button err" title="Включить автозапуск">{{ vm.autostart_label|default('disabled') }}</button>
|
||
</form>
|
||
{% endif %}
|
||
</td>
|
||
<td class="vm-actions-cell">
|
||
<div class="vm-actions compact-actions">
|
||
<a class="icon-action" href="/vm/{{ vm.name }}" title="Подробнее"><span>ℹ</span><em>Детали</em></a>
|
||
<a class="icon-action accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener" title="Консоль"><span>▣</span><em>Консоль</em></a>
|
||
{% if is_running %}
|
||
<form method="post" action="/vm/{{ vm.name }}/reboot"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action" title="Перезагрузить"><span>↻</span><em>Reboot</em></button></form>
|
||
<form method="post" action="/vm/{{ vm.name }}/shutdown"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action" title="Мягко выключить"><span>⏻</span><em>Shutdown</em></button></form>
|
||
<form method="post" action="/vm/{{ vm.name }}/destroy"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action danger" title="Принудительно выключить"><span>⚡</span><em>Power</em></button></form>
|
||
{% else %}
|
||
<form method="post" action="/vm/{{ vm.name }}/start"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action success" title="Запустить"><span>▶</span><em>Start</em></button></form>
|
||
<form method="post" action="/vm/{{ vm.name }}/delete" onsubmit="return confirm('Удалить VM {{ vm.name }} вместе с дисками?');"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="icon-action danger" title="Удалить VM"><span>🗑</span><em>Delete</em></button></form>
|
||
{% endif %}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
{% else %}
|
||
<tr><td colspan="6" class="muted">Виртуальных машин пока нет</td></tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</section>
|
||
|
||
<section class="grid three dashboard-service-grid">
|
||
<article class="card">
|
||
<div class="card-head">
|
||
<h2>Сервисы</h2>
|
||
<span class="pill">systemd</span>
|
||
</div>
|
||
<div class="services">
|
||
{% for name, state in services.items() %}
|
||
<div class="service-row">
|
||
<span>{{ name }}</span>
|
||
<span class="status {{ 'ok' if state == 'active' else 'warn' }}">{{ state }}</span>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
</article>
|
||
|
||
<article class="card">
|
||
<div class="card-head">
|
||
<h2>Storage pools</h2>
|
||
<span class="pill">libvirt</span>
|
||
</div>
|
||
<table>
|
||
<thead>
|
||
<tr><th>Name</th><th>State</th><th>Autostart</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{% for pool in pools %}
|
||
<tr>
|
||
<td>{{ pool.name }}</td>
|
||
<td><span class="status {{ 'ok' if pool.state == 'active' else 'warn' }}">{{ pool.state }}</span></td>
|
||
<td>{{ pool.autostart }}</td>
|
||
</tr>
|
||
{% else %}
|
||
<tr><td colspan="3" class="muted">Storage pools не найдены</td></tr>
|
||
{% endfor %}
|
||
</tbody>
|
||
</table>
|
||
</article>
|
||
|
||
<article class="card">
|
||
<div class="card-head">
|
||
<h2>Последние операции</h2>
|
||
<a class="link-pill" href="/operations">Все операции</a>
|
||
</div>
|
||
<div class="services">
|
||
{% for operation in operations %}
|
||
<a class="service-row service-link" href="/operations/{{ operation.id }}">
|
||
<span>
|
||
<strong>{{ operation.title }}</strong>
|
||
<small>{{ operation.created_at }} · {{ operation.message }}</small>
|
||
</span>
|
||
<span class="status {{ operation_css(operation.status) }}">{{ operation.status }}</span>
|
||
</a>
|
||
{% else %}
|
||
<div class="muted">Операций пока нет.</div>
|
||
{% endfor %}
|
||
</div>
|
||
</article>
|
||
</section>
|
||
|
||
<section class="grid two">
|
||
<article class="card">
|
||
<div class="card-head">
|
||
<h2>Интерфейсы</h2>
|
||
<span class="pill">ip -br a</span>
|
||
</div>
|
||
<pre>{{ network.interfaces }}</pre>
|
||
</article>
|
||
<article class="card">
|
||
<div class="card-head">
|
||
<h2>Маршруты</h2>
|
||
<span class="pill">ip route</span>
|
||
</div>
|
||
<pre>{{ network.routes }}</pre>
|
||
</article>
|
||
</section>
|
||
</main>
|
||
</div>
|
||
<script>
|
||
(function () {
|
||
function drawSpark(svgId, values, max) {
|
||
const svg = document.getElementById(svgId);
|
||
if (!svg) return;
|
||
const line = svg.querySelector('polyline');
|
||
if (!values.length) { line.setAttribute('points', ''); return; }
|
||
const width = 300, height = 60, pad = 3;
|
||
const step = values.length > 1 ? (width - pad * 2) / (values.length - 1) : 0;
|
||
const points = values.map((value, index) => {
|
||
const x = pad + index * step;
|
||
const y = height - pad - Math.max(0, Math.min(1, value / max)) * (height - pad * 2);
|
||
return x.toFixed(1) + ',' + y.toFixed(1);
|
||
});
|
||
line.setAttribute('points', points.join(' '));
|
||
}
|
||
|
||
async function refreshMetrics() {
|
||
try {
|
||
const response = await fetch('/live/metrics', { cache: 'no-store' });
|
||
if (!response.ok) return;
|
||
const payload = await response.json();
|
||
if (!payload.ok || !payload.series) return;
|
||
const cpu = payload.series.map(sample => sample.cpu);
|
||
const mem = payload.series.map(sample => sample.mem_percent);
|
||
drawSpark('cpu-spark', cpu, 100);
|
||
drawSpark('mem-spark', mem, 100);
|
||
const current = payload.current;
|
||
if (current) {
|
||
const cpuNow = document.getElementById('cpu-now');
|
||
const memNow = document.getElementById('mem-now');
|
||
if (cpuNow) cpuNow.textContent = current.cpu.toFixed(1) + '% · load ' + current.load1.toFixed(2);
|
||
if (memNow) memNow.textContent = current.mem_used_mb + ' / ' + current.mem_total_mb + ' MB · ' + current.mem_percent.toFixed(0) + '%';
|
||
}
|
||
} catch (error) { /* сеть моргнула — попробуем в следующий цикл */ }
|
||
}
|
||
|
||
refreshMetrics();
|
||
setInterval(refreshMetrics, 15000);
|
||
})();
|
||
</script>
|
||
<script src="/static/panel.js" defer></script>
|
||
</body>
|
||
</html>
|