Files
virtuality/web/templates/dashboard.html
T
Claude 374bcbb29f Производительность панели была улучшена
Добавлен 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
2026-08-15 19:23:14 +00:00

255 строки
12 KiB
HTML
Исходник Ответственный История

Этот файл содержит неоднозначные символы Юникода
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">
<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>