diff --git a/web/static/panel.js b/web/static/panel.js
index 39897c5..1932601 100644
--- a/web/static/panel.js
+++ b/web/static/panel.js
@@ -115,6 +115,127 @@
applyTheme(getTheme());
})();
+/* VM visual cards from raw virsh output */
+(() => {
+ function qs(selector, root = document) { return root.querySelector(selector); }
+ function qsa(selector, root = document) { return Array.from(root.querySelectorAll(selector)); }
+ function escapeHtml(value) {
+ return String(value ?? '').replace(/[&<>"]/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[ch]));
+ }
+ function rawPreBySummary(summaryText) {
+ const details = qsa('.raw-details').find((item) => (qs('summary', item)?.textContent || '').includes(summaryText));
+ return qs('pre', details)?.textContent.trim() || '';
+ }
+ 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 sizeToGiB(value) {
+ const raw = String(value || '').trim();
+ const n = Number(raw.replace(',', '.').replace(/[^0-9.]/g, ''));
+ if (!Number.isFinite(n) || n <= 0) return 0;
+ if (/kib|kb/i.test(raw)) return n / 1024 / 1024;
+ if (/mib|mb/i.test(raw)) return n / 1024;
+ if (/gib|gb/i.test(raw)) return n;
+ return n / 1024 / 1024;
+ }
+ function renderInfo() {
+ const box = qs('#vm-info-visual');
+ if (!box) return;
+ const raw = rawPreBySummary('dominfo');
+ const data = parseKeyValues(raw);
+ const cpu = data['cpu(s)'] || data['cpu'] || '—';
+ const state = data.state || 'unknown';
+ const osType = data['os type'] || data['os'] || '—';
+ const maxMem = sizeToGiB(data['max memory']);
+ const usedMem = sizeToGiB(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));
+ const autostartText = (qsa('.top-meta .status').find((item) => item.textContent.toLowerCase().includes('autostart'))?.textContent || 'autostart: —').replace(/^autostart:\s*/i, '');
+ box.innerHTML = `
+
+
Состояние${escapeHtml(state)}
+
CPU${escapeHtml(cpu)}
+
Тип ОС${escapeHtml(osType)}
+
+
+
+
+
Память${usedMem ? usedMem.toFixed(1) : '—'} / ${maxMem ? maxMem.toFixed(1) : '—'} GB
+
vCPU capacity${escapeHtml(cpu)} vCPU
+
Автозапуск${escapeHtml(autostartText)}
+
+
`;
+ }
+ function parseDisks(raw) {
+ const rows = [];
+ raw.split('\n').map((line) => line.trim()).filter(Boolean).forEach((line) => {
+ if (/^(target|type|[-\s]+$)/i.test(line)) return;
+ const parts = line.split(/\s+/);
+ if (parts.length >= 4 && /^(file|block|dir|network|volume)$/i.test(parts[0])) {
+ rows.push({ type: parts[0], device: parts[1], target: parts[2], source: parts.slice(3).join(' ') });
+ } else if (parts.length >= 2) {
+ rows.push({ type: 'file', device: 'disk', target: parts[0], source: parts.slice(1).join(' ') });
+ }
+ });
+ return rows;
+ }
+ function renderDisks() {
+ const box = qs('#vm-disk-visual');
+ if (!box) return;
+ const rows = parseDisks(rawPreBySummary('domblklist'));
+ if (!rows.length) {
+ box.innerHTML = 'Диски не найдены в выводе libvirt.
';
+ return;
+ }
+ box.innerHTML = `${rows.map((row) => `
+
+
▣
+
${escapeHtml(row.target)}${escapeHtml(row.device)} · ${escapeHtml(row.type)}
${escapeHtml(row.source)}
+
disk
+
`).join('')}
`;
+ }
+ function parseInterfaces(raw) {
+ const rows = [];
+ raw.split('\n').map((line) => line.trim()).filter(Boolean).forEach((line) => {
+ if (/^(interface|[-\s]+$)/i.test(line)) return;
+ const parts = line.split(/\s+/);
+ if (parts.length >= 5) rows.push({ iface: parts[0], type: parts[1], source: parts[2], model: parts[3], mac: parts[4] });
+ else if (parts.length >= 2) rows.push({ iface: parts[0], type: parts[1] || '—', source: parts[2] || '—', model: parts[3] || '—', mac: parts[4] || '—' });
+ });
+ return rows;
+ }
+ function renderNetwork() {
+ const box = qs('#vm-network-visual');
+ if (!box) return;
+ const rows = parseInterfaces(rawPreBySummary('domiflist'));
+ if (!rows.length) {
+ box.innerHTML = 'Сетевые интерфейсы не найдены в выводе libvirt.
';
+ return;
+ }
+ box.innerHTML = `${rows.map((row) => `
+
+
⇄
+
${escapeHtml(row.iface)}${escapeHtml(row.type)} · ${escapeHtml(row.source)} · ${escapeHtml(row.model)}
${escapeHtml(row.mac)}
+
link
+
`).join('')}
`;
+ }
+ function renderVmVisuals() {
+ if (!qs('#vm-info-visual') && !qs('#vm-disk-visual') && !qs('#vm-network-visual')) return;
+ renderInfo();
+ renderDisks();
+ renderNetwork();
+ }
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', renderVmVisuals); else renderVmVisuals();
+})();
+
/* Virtuality live status + toast layer */
(() => {
if (window.VirtualityLiveStatus) return;