Блоки информации VM были заменены на графические карточки

Этот коммит содержится в:
Виктор
2026-05-07 21:19:06 +09:00
родитель 135fa971df
Коммит c8c3385142
+153 -30
Просмотреть файл
@@ -14,8 +14,35 @@
.vm-four-grid .compact-actions { justify-content: flex-start; } .vm-four-grid .compact-actions { justify-content: flex-start; }
.vm-four-grid .vm-control-panel { display: grid; grid-template-columns: 1fr; gap: 12px; } .vm-four-grid .vm-control-panel { display: grid; grid-template-columns: 1fr; gap: 12px; }
.vm-four-grid .autostart-indicator { width: 100%; } .vm-four-grid .autostart-indicator { width: 100%; }
@media (max-width: 1500px) { .vm-four-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } .vm-visual-grid { grid-template-columns: 1.15fr 1fr 1fr; align-items: stretch; margin-bottom: 14px; }
@media (max-width: 980px) { .vm-four-grid { grid-template-columns: 1fr; } } .visual-card-body { display: grid; gap: 12px; }
.metric-strip { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
.metric-tile { padding: 12px; border: 1px solid var(--line); border-radius: 16px; background: rgba(2,6,23,.32); min-width: 0; }
.metric-tile strong { display: block; margin-top: 5px; font-size: 20px; line-height: 1.1; letter-spacing: -.04em; overflow-wrap: anywhere; }
.metric-tile span { color: var(--muted); font-size: 11px; font-weight: 900; letter-spacing: .1em; text-transform: uppercase; }
.chart-row { display: grid; grid-template-columns: 96px 1fr; gap: 14px; align-items: center; }
.donut { --value: 0; --donut-color: var(--accent); width: 96px; height: 96px; border-radius: 50%; display: grid; place-items: center; background: conic-gradient(var(--donut-color) calc(var(--value) * 1%), rgba(148,163,184,.16) 0); position: relative; }
.donut::before { content: ""; position: absolute; inset: 12px; border-radius: 50%; background: var(--panel-solid); opacity: .95; }
.donut-value { position: relative; z-index: 1; font-size: 18px; font-weight: 950; }
.bar-list { display: grid; gap: 10px; }
.bar-item { display: grid; gap: 6px; }
.bar-head { display: flex; justify-content: space-between; gap: 10px; color: var(--muted); font-size: 12px; font-weight: 800; }
.mini-bar { height: 9px; overflow: hidden; border: 1px solid var(--line); border-radius: 999px; background: rgba(148,163,184,.12); }
.mini-bar span { display: block; height: 100%; width: var(--value, 0%); border-radius: inherit; background: linear-gradient(90deg, var(--accent), var(--green)); }
.visual-list { display: grid; gap: 9px; }
.visual-item { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; padding: 11px; border: 1px solid var(--line); border-radius: 15px; background: rgba(2,6,23,.32); }
.visual-icon { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 12px; background: rgba(56,189,248,.13); color: var(--accent); font-weight: 950; }
.visual-item b { display: block; font-size: 14px; overflow-wrap: anywhere; }
.visual-item small { color: var(--muted); line-height: 1.35; }
.visual-empty { padding: 12px; border: 1px dashed var(--line-strong); border-radius: 15px; color: var(--muted); }
.raw-details { margin-top: 12px; }
.raw-details summary { cursor: pointer; color: var(--muted); font-weight: 900; }
.raw-details pre { max-height: 260px; }
html[data-theme="macos"] .metric-tile, html[data-theme="macos"] .visual-item { background: #f7f7fa; border-color: rgba(60,60,67,.10); }
html[data-theme="macos"] .donut::before { background: #fff; }
html[data-theme="macos"] .visual-empty { border-color: rgba(60,60,67,.18); }
@media (max-width: 1500px) { .vm-four-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .vm-visual-grid { grid-template-columns: 1fr; } }
@media (max-width: 980px) { .vm-four-grid, .metric-strip { grid-template-columns: 1fr; } .chart-row { grid-template-columns: 1fr; } }
</style> </style>
</head> </head>
<body> <body>
@@ -179,16 +206,45 @@
</article> </article>
</section> </section>
<section class="grid two"> <section class="grid vm-visual-grid">
<article class="card"> <article class="card">
<div class="card-head"> <div class="card-head">
<h2>Информация</h2> <h2>Информация</h2>
<span class="pill">dominfo</span> <span class="pill">overview</span>
</div> </div>
<div class="visual-card-body" id="vm-info-visual"></div>
<details class="raw-details">
<summary>Технический вывод dominfo</summary>
<pre>{{ vm.dominfo }}</pre> <pre>{{ vm.dominfo }}</pre>
</details>
</article> </article>
<article class="card"> <article class="card">
<div class="card-head">
<h2>Диски</h2>
<span class="pill">storage</span>
</div>
<div class="visual-card-body" id="vm-disk-visual"></div>
<details class="raw-details">
<summary>Технический вывод domblklist</summary>
<pre>{{ vm.disks }}</pre>
</details>
</article>
<article class="card">
<div class="card-head">
<h2>Сеть</h2>
<span class="pill">network</span>
</div>
<div class="visual-card-body" id="vm-network-visual"></div>
<details class="raw-details">
<summary>Технический вывод domiflist</summary>
<pre>{{ vm.interfaces }}</pre>
</details>
</article>
</section>
<section class="card">
<div class="card-head"> <div class="card-head">
<h2>Консоль</h2> <h2>Консоль</h2>
<a class="link-pill accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener">noVNC ↗</a> <a class="link-pill accent" href="/vm/{{ vm.name }}/console" target="_blank" rel="noopener">noVNC ↗</a>
@@ -199,41 +255,20 @@
<div class="service-row"><span>Web console</span><span class="status ok">/vm/{{ vm.name }}/console</span></div> <div class="service-row"><span>Web console</span><span class="status ok">/vm/{{ vm.name }}/console</span></div>
</div> </div>
<p class="muted small-note">Консоль открывается в новой вкладке и занимает всё окно браузера. VNC наружу открывать не нужно.</p> <p class="muted small-note">Консоль открывается в новой вкладке и занимает всё окно браузера. VNC наружу открывать не нужно.</p>
</article>
</section>
<section class="grid two">
<article class="card">
<div class="card-head">
<h2>Диски</h2>
<span class="pill">domblklist</span>
</div>
<pre>{{ vm.disks }}</pre>
</article>
<article class="card">
<div class="card-head">
<h2>Сеть</h2>
<span class="pill">domiflist</span>
</div>
<pre>{{ vm.interfaces }}</pre>
</article>
</section> </section>
</main> </main>
</div> </div>
<template id="vm-dominfo-raw">{{ vm.dominfo }}</template>
<template id="vm-disks-raw">{{ vm.disks }}</template>
<template id="vm-interfaces-raw">{{ vm.interfaces }}</template>
<script> <script>
(function(){ (function(){
const list = document.getElementById('boot-order-list'); const list = document.getElementById('boot-order-list');
const input = document.getElementById('boot-order-value'); const input = document.getElementById('boot-order-value');
if (!list || !input) return; if (!list || !input) return;
const initialMap = { const initialMap = { cdrom_disk: ['cdrom','hd','network'], disk_cdrom: ['hd','cdrom','network'], network_disk: ['network','hd','cdrom'], disk: ['hd','cdrom','network'], auto: ['hd','cdrom','network'] };
cdrom_disk: ['cdrom','hd','network'],
disk_cdrom: ['hd','cdrom','network'],
network_disk: ['network','hd','cdrom'],
disk: ['hd','cdrom','network'],
auto: ['hd','cdrom','network']
};
const order = initialMap[list.dataset.current || 'auto'] || initialMap.auto; const order = initialMap[list.dataset.current || 'auto'] || initialMap.auto;
const nodes = Array.from(list.querySelectorAll('.boot-order-item')); const nodes = Array.from(list.querySelectorAll('.boot-order-item'));
order.forEach(device => { const node = nodes.find(item => item.dataset.device === device); if (node) list.appendChild(node); }); order.forEach(device => { const node = nodes.find(item => item.dataset.device === device); if (node) list.appendChild(node); });
@@ -260,6 +295,94 @@
}); });
updateValue(); updateValue();
})(); })();
(function(){
function text(id) { const node = document.getElementById(id); return node ? node.textContent.trim() : ''; }
function escapeHtml(value) { return String(value || '').replace(/[&<>"]/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[ch])); }
function parseKeyValues(raw) {
const data = {};
raw.split('\n').forEach(line => {
const idx = line.indexOf(':');
if (idx < 0) return;
const key = line.slice(0, idx).trim().toLowerCase();
const value = line.slice(idx + 1).trim();
if (key) data[key] = value;
});
return data;
}
function kibToGiB(value) {
const n = Number(String(value || '').replace(/[^0-9.]/g, ''));
if (!Number.isFinite(n) || n <= 0) return 0;
return n / 1024 / 1024;
}
function renderInfo() {
const box = document.getElementById('vm-info-visual');
if (!box) return;
const data = parseKeyValues(text('vm-dominfo-raw'));
const cpu = data['cpu(s)'] || data['cpu'] || '—';
const state = data['state'] || 'unknown';
const osType = data['os type'] || '—';
const maxMem = kibToGiB(data['max memory']);
const usedMem = kibToGiB(data['used memory']);
const memPct = maxMem > 0 ? Math.max(0, Math.min(100, Math.round((usedMem / maxMem) * 100))) : 0;
const cpuPct = Math.max(8, Math.min(100, Number(cpu) ? Number(cpu) * 12 : 18));
box.innerHTML = `
<div class="metric-strip">
<div class="metric-tile"><span>Состояние</span><strong>${escapeHtml(state)}</strong></div>
<div class="metric-tile"><span>CPU</span><strong>${escapeHtml(cpu)}</strong></div>
<div class="metric-tile"><span>Тип ОС</span><strong>${escapeHtml(osType)}</strong></div>
</div>
<div class="chart-row">
<div class="donut" style="--value:${memPct};--donut-color:var(--accent)"><div class="donut-value">${memPct}%</div></div>
<div class="bar-list">
<div class="bar-item"><div class="bar-head"><span>Память</span><b>${usedMem ? usedMem.toFixed(1) : '—'} / ${maxMem ? maxMem.toFixed(1) : '—'} GB</b></div><div class="mini-bar"><span style="--value:${memPct}%"></span></div></div>
<div class="bar-item"><div class="bar-head"><span>vCPU capacity</span><b>${escapeHtml(cpu)} vCPU</b></div><div class="mini-bar"><span style="--value:${cpuPct}%"></span></div></div>
<div class="bar-item"><div class="bar-head"><span>Автозапуск</span><b>${escapeHtml(data['autostart'] || '—')}</b></div><div class="mini-bar"><span style="--value:${String(data['autostart'] || '').includes('enable') ? 100 : 18}%"></span></div></div>
</div>
</div>`;
}
function renderDisks() {
const box = document.getElementById('vm-disk-visual');
if (!box) return;
const lines = text('vm-disks-raw').split('\n').map(line => line.trim()).filter(Boolean);
const rows = [];
lines.forEach(line => {
if (/^(type|[-\s]+$)/i.test(line)) return;
const parts = line.split(/\s+/);
if (parts.length < 4) return;
rows.push({ type: parts[0], device: parts[1], target: parts[2], source: parts.slice(3).join(' ') });
});
if (!rows.length) { box.innerHTML = '<div class="visual-empty">Диски не найдены в выводе libvirt.</div>'; return; }
box.innerHTML = `<div class="visual-list">${rows.map(row => `
<div class="visual-item">
<div class="visual-icon">▣</div>
<div><b>${escapeHtml(row.target)}</b><small>${escapeHtml(row.device)} · ${escapeHtml(row.type)}<br>${escapeHtml(row.source)}</small></div>
<span class="status ok">disk</span>
</div>`).join('')}</div>`;
}
function renderNetwork() {
const box = document.getElementById('vm-network-visual');
if (!box) return;
const lines = text('vm-interfaces-raw').split('\n').map(line => line.trim()).filter(Boolean);
const rows = [];
lines.forEach(line => {
if (/^(interface|[-\s]+$)/i.test(line)) return;
const parts = line.split(/\s+/);
if (parts.length < 5) return;
rows.push({ iface: parts[0], type: parts[1], source: parts[2], model: parts[3], mac: parts[4] });
});
if (!rows.length) { box.innerHTML = '<div class="visual-empty">Сетевые интерфейсы не найдены в выводе libvirt.</div>'; return; }
box.innerHTML = `<div class="visual-list">${rows.map(row => `
<div class="visual-item">
<div class="visual-icon">⇄</div>
<div><b>${escapeHtml(row.iface)}</b><small>${escapeHtml(row.type)} · ${escapeHtml(row.source)} · ${escapeHtml(row.model)}<br>${escapeHtml(row.mac)}</small></div>
<span class="status ok">link</span>
</div>`).join('')}</div>`;
}
renderInfo();
renderDisks();
renderNetwork();
})();
</script> </script>
<script src="/static/panel.js" defer></script> <script src="/static/panel.js" defer></script>
</body> </body>