@@ -10,21 +10,22 @@ import (
)
type dashboardMetrics struct {
CPU CPUMetrics ` json:"cpu" `
Memory MemoryMetrics ` json:"memory" `
Storage StorageMetrics ` json:"storage" `
Disks [ ] DiskMetrics ` json:"disks" `
Network NetworkMetrics ` json:"network" `
ZFS ZFSMetrics ` json:"zfs" `
UPS UPSMetrics ` json:"ups" `
Guests GuestsMetrics ` json:"guests" `
Backups BackupMetrics ` json:"backups" `
Alerts [ ] Alert ` json:"alerts" `
Update UpdateStatus ` json:"update" `
Timestamp time . Time ` json:"timestamp " `
CPU CPUMetrics ` json:"cpu" `
Memory MemoryMetrics ` json:"memory" `
Storage StorageMetrics ` json:"storage" `
Disks [ ] DiskMetrics ` json:"disks" `
Network NetworkMetrics ` json:"network" `
ZFS ZFSMetrics ` json:"zfs" `
UPS UPSMetrics ` json:"ups" `
Guests GuestsMetrics ` json:"guests" `
Backups BackupMetrics ` json:"backups" `
Alerts [ ] Alert ` json:"alerts" `
Update UpdateStatus ` json:"update" `
Maintenance MaintenanceMetrics ` json:"maintenance " `
Timestamp time . Time ` json:"timestamp" `
}
func collectDashboard ( cpuCollector * cpuCollector , networkCollector * networkCollector , diskCollector * diskCollector , zfsCollector * zfsCollector , upsCollector * upsCollector , guestCollector * guestCollector , backupCollector * backupCollector , store * Store ) ( dashboardMetrics , error ) {
func collectDashboard ( cpuCollector * cpuCollector , networkCollector * networkCollector , diskCollector * diskCollector , zfsCollector * zfsCollector , upsCollector * upsCollector , guestCollector * guestCollector , backupCollector * backupCollector , maintenanceCollector * maintenanceCollector , store * Store ) ( dashboardMetrics , error ) {
cpu , err := cpuCollector . collect ( )
if err != nil {
return dashboardMetrics { } , err
@@ -45,7 +46,7 @@ func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkColle
CPU : cpu , Memory : memory , Storage : storage ,
Disks : diskCollector . collect ( ) , Network : network ,
ZFS : zfsCollector . collect ( ) , UPS : upsCollector . collect ( ) ,
Guests : guestCollector . collect ( ) , Backups : backupCollector . collect ( ) , Update : readUpdateStatus ( ) , Timestamp : time . Now ( ) ,
Guests : guestCollector . collect ( ) , Backups : backupCollector . collect ( ) , Maintenance : maintenanceCollector . collect ( ) , Update : readUpdateStatus ( ) , Timestamp : time . Now ( ) ,
}
metrics . Alerts = evaluateAlerts ( metrics , store . Thresholds ( ) )
metrics . Alerts , err = store . SyncAlerts ( metrics . Alerts )
@@ -63,8 +64,9 @@ func routes(collector *cpuCollector, store *Store) http.Handler {
ups := & upsCollector { }
guests := & guestCollector { }
backups := & backupCollector { }
maintenance := & maintenanceCollector { }
collect := func ( ) ( dashboardMetrics , error ) {
return collectDashboard ( collector , network , disks , zfs , ups , guests , backups , store )
return collectDashboard ( collector , network , disks , zfs , ups , guests , backups , maintenance , store )
}
go runHistoryCollector ( collect , store )
go runEmailNotifier ( collect , store )
@@ -241,6 +243,7 @@ html[data-theme="light"] body{background:radial-gradient(circle at 78% -8%,#cde9
footer { color:#657581;font-size:12px;margin-top:17px}
@media(max-width:700px) { main { padding:28px 0}.cards { grid-template-columns:1fr}header { align-items:flex-start;flex-direction:column;gap:14px}.detail-grid { grid-template-columns:repeat(2,1fr)}.detail:nth-child(3n) { border-right:1px solid var(--line)}.detail:nth-child(2n) { border-right:0}.name { max-width:220px}.disk-stats,.setting-list,.threshold-grid { grid-template-columns:repeat(2,1fr)}.chart-head { align-items:flex-start;flex-direction:column}.detail-tabs { overflow-x:auto}}
.save-settings:disabled { cursor:wait;opacity:.55}[data-settings-panel="email"] .threshold-item input { width:min(210px,52vw)}.ack-alert { margin-top:11px;padding:6px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);font-size:11px;cursor:pointer}.history-state { display:inline-block;margin-top:8px;color:var(--muted);font-size:11px}.alert-panel[hidden] { display:none}
.maintenance .fill { background:linear-gradient(90deg,#55d58a,#43c6d9)}.card-order { display:grid;gap:7px;margin-top:11px}.order-item { display:flex;align-items:center;gap:10px;padding:9px 10px;border:1px solid var(--line);border-radius:10px;background:var(--panel)}.order-item.dragging { opacity:.45}.order-handle { cursor:grab;color:var(--muted);font-size:17px}.order-item span:nth-child(2) { flex:1}.order-button { width:30px;height:28px;border:1px solid var(--line);border-radius:7px;background:var(--bg);color:var(--text);cursor:pointer}
</style>
</head>
<body><main>
@@ -294,6 +297,12 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<div class="quick"><div><span class="label">CPU гостей</span><strong id="guestCPU">—</strong></div><div><span class="label">Память гостей</span><strong id="guestMemory">—</strong></div></div>
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
</button>
<button class="card maintenance" data-card="maintenance" data-open="maintenanceDialog" aria-label="Открыть сведения Proxmox">
<div class="card-head"><div><div class="label">Proxmox</div><div class="name" id="maintenanceVersion">Обслуживание хоста</div></div><div class="percent"><span id="maintenanceUpdates">—</span><small> updates</small></div></div>
<div class="bar"><div class="fill" id="maintenanceBar"></div></div>
<div class="quick"><div><span class="label">Перезагрузка</span><strong id="maintenanceReboot">—</strong></div><div><span class="label">Ядро</span><strong id="maintenanceKernel">—</strong></div></div>
<div class="hint">Нажмите, чтобы посмотреть обслуживание →</div>
</button>
<button class="card backups" data-card="backups" data-open="backupsDialog" aria-label="Открыть подробности резервных копий">
<div class="card-head"><div><div class="label">Backups</div><div class="name">Резервные копии</div></div><div class="percent"><span id="backupProtected">—</span><small id="backupTotal"> / —</small></div></div>
<div class="bar"><div class="fill" id="backupBar"></div></div>
@@ -379,6 +388,15 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<div class="disk-list" id="backupList"><span class="empty">Получение списка резервных копий…</span></div>
</dialog>
<dialog id="maintenanceDialog">
<div class="modal-head"><div><div class="label">Proxmox maintenance</div><h2>Система и обслуживание</h2></div><button class="close" aria-label="Закрыть">× </button></div>
<div class="detail-grid">
<div class="detail"><span class="label">Proxmox</span><strong id="dPveVersion">—</strong></div><div class="detail"><span class="label">Запущенное ядро</span><strong id="dKernel">—</strong></div><div class="detail"><span class="label">Новое ядро</span><strong id="dLatestKernel">—</strong></div>
<div class="detail"><span class="label">Обновления</span><strong id="dUpdates">—</strong></div><div class="detail"><span class="label">Перезагрузка</span><strong id="dReboot">—</strong></div><div class="detail"><span class="label">Последнее обновление</span><strong id="dLastUpdate">—</strong></div>
</div>
<div class="wide"><span class="label">Ближайшие задания</span><div class="disk-stats"><div class="disk-stat"><span>Backup</span><b id="dNextBackup">—</b></div><div class="disk-stat"><span>ZFS scrub</span><b id="dNextScrub">—</b></div><div class="disk-stat"><span>Проверено</span><b id="dMaintenanceChecked">—</b></div></div></div>
</dialog>
<dialog id="settingsDialog">
<div class="modal-head"><div><div class="label">Dashboard</div><h2>Настройки</h2></div><button class="close" aria-label="Закрыть">× </button></div>
<div class="settings-tabs"><button class="settings-tab active" data-settings-tab="appearance">Интерфейс</button><button class="settings-tab" data-settings-tab="alerts">Пороги</button><button class="settings-tab" data-settings-tab="email">Почта</button><button class="settings-tab" data-settings-tab="updates">Обновления</button></div>
@@ -389,8 +407,9 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<label class="setting-item"><input type="checkbox" data-card-toggle="cpu"> CPU</label><label class="setting-item"><input type="checkbox" data-card-toggle="memory"> Память</label>
<label class="setting-item"><input type="checkbox" data-card-toggle="storage"> Диски</label><label class="setting-item"><input type="checkbox" data-card-toggle="network"> Сеть</label>
<label class="setting-item"><input type="checkbox" data-card-toggle="zfs"> ZFS</label><label class="setting-item"><input type="checkbox" data-card-toggle="ups"> UPS</label>
<label class="setting-item"><input type="checkbox" data-card-toggle="guests"> VM и LXC</label><label class="setting-item"><input type="checkbox" data-card-toggle="backups"> Резервные копии</label>
<label class="setting-item"><input type="checkbox" data-card-toggle="guests"> VM и LXC</label><label class="setting-item"><input type="checkbox" data-card-toggle="maintenance"> Proxmox</label><label class="setting-item"><input type="checkbox" data-card-toggle="backups"> Резервные копии</label>
</div></div>
<div class="settings-group"><div class="label">Порядок карточек</div><div class="settings-note">Перетаскивайте строки или используйте стрелки.</div><div class="card-order" id="cardOrder"></div></div>
</div>
<div class="settings-body settings-panel" data-settings-panel="alerts" hidden>
<div class="threshold-grid">
@@ -415,7 +434,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
</div></div>
<div class="settings-group"><div class="label">Важность</div><div class="setting-list"><label class="setting-item"><input type="checkbox" data-email-severity="critical"> Критические</label><label class="setting-item"><input type="checkbox" data-email-severity="warning"> Предупреждения</label><label class="setting-item"><input type="checkbox" data-email-severity="info"> Информация</label></div></div>
<div class="settings-group"><div class="label">Какие разделы отправлять</div><div class="setting-list">
<label class="setting-item"><input type="checkbox" data-email-source="CPU"> CPU</label><label class="setting-item"><input type="checkbox" data-email-source="Память"> Память</label><label class="setting-item"><input type="checkbox" data-email-source="Storage"> Storage</label><label class="setting-item"><input type="checkbox" data-email-source="Диски"> Диски</label><label class="setting-item"><input type="checkbox" data-email-source="ZFS"> ZFS</label><label class="setting-item"><input type="checkbox" data-email-source="UPS"> UPS</label><label class="setting-item"><input type="checkbox" data-email-source="VM/LXC"> VM и LXC</label><label class="setting-item"><input type="checkbox" data-email-source="Backups"> Резервные копии</label><label class="setting-item"><input type="checkbox" data-email-source="Dashboard"> Dashboard</label>
<label class="setting-item"><input type="checkbox" data-email-source="CPU"> CPU</label><label class="setting-item"><input type="checkbox" data-email-source="Память"> Память</label><label class="setting-item"><input type="checkbox" data-email-source="Storage"> Storage</label><label class="setting-item"><input type="checkbox" data-email-source="Диски"> Диски</label><label class="setting-item"><input type="checkbox" data-email-source="ZFS"> ZFS</label><label class="setting-item"><input type="checkbox" data-email-source="UPS"> UPS</label><label class="setting-item"><input type="checkbox" data-email-source="VM/LXC"> VM и LXC</label><label class="setting-item"><input type="checkbox" data-email-source="Proxmox"> Proxmox</label><label class="setting-item"><input type="checkbox" data-email-source="Backups"> Резервные копии</label><label class="setting-item"><input type="checkbox" data-email-source="Dashboard"> Dashboard</label>
</div></div>
<button class="save-settings" id="saveEmail">Сохранить настройки почты</button><button class="theme-option" id="testEmail" style="width:100%;margin-top:9px">Отправить тестовое письмо</button><div class="settings-note" id="emailStatus">Пароль хранится в локальной базе сервера и не возвращается в браузер.</div>
</div>
@@ -434,7 +453,7 @@ const rate=n=>size(n)+'/s';
const safe=v=>String(v??'').replace(/[&<>"']/g,c=>( { '&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const uptime=s=> { const d=Math.floor(s/86400),h=Math.floor(s%86400/3600),m=Math.floor(s%3600/60);return d?d+'д '+h+'ч':h?h+'ч '+m+'м':m+'м'};
let alertSettings= { backupMaxAgeHours:48};
function render(x) { const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs|| { available:false,pools:[]},u=x.ups|| { available:false},g=x.guests|| { available:false,guests:[]},b=x.backups|| { available:false,items:[]},alerts=x.alerts||[],disks=x.disks||[],avg=c.temperatures.length?c.temperatures.reduce((a,t)=>a+t.celsius,0)/c.temperatures.length:null;
function render(x) { const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs|| { available:false,pools:[]},u=x.ups|| { available:false},g=x.guests|| { available:false,guests:[]},b=x.backups|| { available:false,items:[]},mt=x.maintenance|| { }, alerts=x.alerts||[],disks=x.disks||[],avg=c.temperatures.length?c.temperatures.reduce((a,t)=>a+t.celsius,0)/c.temperatures.length:null;
const update=x.update|| { },updating=['queued','installing'].includes(update.state);el('updateCurrent').textContent=update.currentVersion||'—';el('updateAvailable').textContent=update.availableVersion||'—';el('updateMode').textContent=update.mode==='auto'?'Автоматически':'Уведомлять';el('updateMessage').textContent=update.message||'Проверка ещё не выполнялась';el('updateChecked').textContent=update.checkedAt?'Последняя проверка: '+new Date(update.checkedAt).toLocaleString('ru-RU'):'Проверка запускается автоматически раз в сутки.';el('installUpdate').disabled=updating;el('installUpdate').textContent=updating?'Обновление выполняется…':'Проверить и установить обновление';
const critical=alerts.filter(a=>a.severity==='critical').length,warnings=alerts.filter(a=>a.severity==='warning').length,infos=alerts.filter(a=>a.severity==='info').length;el('alertCount').textContent=alerts.length;el('alertCritical').textContent=critical;el('alertWarnings').textContent=warnings;el('alertHeadline').textContent=critical?'Требуется срочное внимание':warnings?'Есть предупреждения':infos?'Есть информационные события':'Система в норме';el('alertBar').style.width=(critical?100:warnings?65:infos?30:0)+'%';
el('alertList').innerHTML=alerts.length?alerts.map(a=>'<article class="alert-item '+safe(a.severity)+'"><i class="alert-icon"></i><div><div class="alert-source">'+safe(a.source)+' · '+(a.severity==='critical'?'Критическое':a.severity==='warning'?'Предупреждение':'Информация')+'</div><div class="alert-title">'+safe(a.title)+'</div><div class="alert-message">'+safe(a.message)+'</div><button class="ack-alert" data-ack="'+a.eventId+'">Принято</button></div></article>').join(''):'<div class="all-good"><strong>Всё в порядке</strong><span>Новых неподтверждённых событий нет.</span></div>';
@@ -449,7 +468,8 @@ function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{av
el('zfsList').innerHTML=pools.length?pools.map(p=> { const bad=p.health!=='ONLINE',reason=(p.attentionReasons||[]).join(' · '),tip=reason?' tabindex="0" data-reason="'+safe(reason)+'"':'';return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(p.name)+'</div><div class="disk-meta">'+safe(p.scan||'Данные scrub отсутствуют')+'</div></div><span class="badge '+(bad?'bad':'')+'"'+tip+'>'+safe(p.health)+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Объём</span><b>'+size(p.sizeBytes)+'</b></div><div class="disk-stat"><span>Занято</span><b>'+size(p.allocatedBytes)+'</b></div><div class="disk-stat"><span>Свободно</span><b>'+size(p.freeBytes)+'</b></div><div class="disk-stat"><span>Заполнение</span><b>'+fixed(p.capacityPercent)+'%</b></div><div class="disk-stat"><span>Фрагментация</span><b>'+fixed(p.fragmentationPercent)+'%</b></div><div class="disk-stat"><span>Dedup</span><b>'+safe(p.dedupRatio)+'</b></div></div></article>'}).join(''):'<span class="empty">'+(z.available?'ZFS-пулы не найдены':'Команда zpool недоступна')+'</span>';
const val=(v,suffix='')=>v==null?'—':fixed(v)+suffix;el('upsName').textContent=u.available?(u.model||u.name||'Источник бесперебойного питания'):'UPS не настроен';el('upsCharge').textContent=u.chargePercent==null?'—':fixed(u.chargePercent);el('upsBar').style.width=u.chargePercent==null?'0%':Math.min(u.chargePercent,100)+'%';el('upsStatus').textContent=u.available?u.statusLabel:'Н е найден';el('upsRuntime').textContent=u.runtimeSeconds==null?'—':uptime(u.runtimeSeconds);el('dUpsName').textContent=u.model||u.name||'Источник бесперебойного питания';el('dUpsStatus').textContent=u.available?u.statusLabel:'Н е настроен';el('dUpsCharge').textContent=val(u.chargePercent,'%');el('dUpsRuntime').textContent=u.runtimeSeconds==null?'—':uptime(u.runtimeSeconds);el('dUpsLoad').textContent=val(u.loadPercent,'%');el('dUpsInput').textContent=val(u.inputVoltage,' V');el('dUpsOutput').textContent=val(u.outputVoltage,' V');el('dUpsFrequency').textContent=val(u.inputFrequency,' Hz');el('dUpsBattery').textContent=val(u.batteryVoltage,' V');el('dUpsTemp').textContent=val(u.temperatureCelsius,' °C');el('dUpsSource').textContent=u.available?(u.source+' · '+(u.name||'UPS')):'Установите и настройте NUT или apcupsd';
const guests=(g.guests||[]).filter(v=>!v.template),running=guests.filter(v=>v.status==='running'),guestCores=running.reduce((a,v)=>a+v.maxcpu,0),guestCPU=guestCores?running.reduce((a,v)=>a+v.cpu*v.maxcpu,0)/guestCores*100:0,guestMem=running.reduce((a,v)=>a+v.mem,0),guestMaxMem=running.reduce((a,v)=>a+v.maxmem,0);el('guestRunning').textContent=running.length;el('guestTotal').textContent=' / '+guests.length;el('guestBar').style.width=guests.length?(running.length/guests.length*100)+'%':'0%';el('guestCPU').textContent=g.available?fixed(guestCPU)+'%':'Н е найдено';el('guestMemory').textContent=g.available?(size(guestMem)+' / '+size(guestMaxMem)):'—';
el('guestList').innerHTML=guests.length?guests.map(v=> { const live=v.status==='running',type=v.type==='qemu'?'VM':'LXC',memPercent=v.maxmem?v.mem/v.maxmem*100:0;return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name||('Guest '+v.vmid))+'</div><div class="disk-meta">VMID '+v.vmid+' · '+type+' · '+safe(v.node)+'</div></div><span class="badge '+(live?'':'warn')+'">'+(live?'Запущен':'Остановлен')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Тип </span><b>'+type +'</b></div><div class="disk-stat"><span>CPU </span><b>'+(live?fixed(v.cpu*100)+'%':'—' )+'</b></div><div class="disk-stat"><span>Ядра </span><b>'+v.maxcpu +'</b></div><div class="disk-stat"><span>Памя ть</span><b>'+(live? size(v.mem )+' · '+fixed(memPercent)+'%':size(v.maxmem))+'</b></div><div class="disk-stat"><span>Лимит RAM </span><b>'+siz e(v.maxmem )+'</b></div><div class="disk-stat"><span>Uptime</span><b>'+(live?uptime(v.uptime):'—')+'</b></div></div></article>'}).join(''):'<span class="empty">'+(g.available?'VM и LXC не найдены':'Команда pvesh недоступна')+'</span>';
el('guestList').innerHTML=guests.length?guests.map(v=> { const live=v.status==='running',type=v.type==='qemu'?'VM':'LXC',memPercent=v.maxmem?v.mem/v.maxmem*100:0,diskPercent=v.maxdisk?v.disk/v.maxdisk*100:0,ips=(v.ipAddresses||[]).join(', ')||'IP не найден' ;return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name||('Guest '+v.vmid))+'</div><div class="disk-meta">VMID '+v.vmid+' · '+type+' · '+safe(v.node)+' · '+safe(ips)+' </div></div><span class="badge '+(live?'':'warn')+'">'+(live?'Запущен':'Остановлен')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>CPU </span><b>'+(live?fixed(v.cpu*100)+'%':'—')+' / '+v.maxcpu +'</b></div><div class="disk-stat"><span>Память </span><b>'+(live?size(v.mem)+' · '+fixed(memPercent)+'%':size(v.maxmem) )+'</b></div><div class="disk-stat"><span>Диск </span><b>'+( v.maxdisk?size(v.disk)+' · '+fixed(diskPercent)+'%':'—') +'</b></div><div class="disk-stat"><span>С е ть</span><b>↓ '+size(v.netin )+' · ↑ '+size(v.netout)+'</b></div><div class="disk-stat"><span>Guest Agent</span><b>'+(v.agent?'Доступен':'Нет')+'</b></div><div class="disk-stat"><span>Автозапуск</span><b>'+(v.onboot?'Да':'Нет')+'</b></div><div class="disk-stat"><span>Storage </span><b>'+saf e(( v.storage||[]).join(', ')||'—' )+'</b></div><div class="disk-stat"><span>Uptime</span><b>'+(live?uptime(v.uptime):'—')+'</b></div></div></article>'}).join(''):'<span class="empty">'+(g.available?'VM и LXC не найдены':'Команда pvesh недоступна')+'</span>';
el('maintenanceVersion').textContent=mt.proxmoxVersion||'Proxmox VE';el('maintenanceUpdates').textContent=mt.availableUpdates??'—';el('maintenanceBar').style.width=Math.min((mt.availableUpdates||0)*5,100)+'%';el('maintenanceReboot').textContent=(mt.rebootRequired||mt.newKernelAvailable)?'Требуется':'Н е требуется';el('maintenanceKernel').textContent=mt.kernelVersion||'—';el('dPveVersion').textContent=mt.proxmoxVersion||'—';el('dKernel').textContent=mt.kernelVersion||'—';el('dLatestKernel').textContent=mt.latestInstalledKernel||'—';el('dUpdates').textContent=(mt.availableUpdates??'—')+' пакетов';el('dReboot').textContent=(mt.rebootRequired||mt.newKernelAvailable)?'Требуется':'Н е требуется';el('dLastUpdate').textContent=mt.lastSystemUpdate?new Date(mt.lastSystemUpdate).toLocaleString('ru-RU'):'—';el('dNextBackup').textContent=mt.nextBackup||'Расписание не найдено';el('dNextScrub').textContent=mt.nextScrub||'Таймер не найден';el('dMaintenanceChecked').textContent=mt.checkedAt?new Date(mt.checkedAt).toLocaleString('ru-RU'):'—';
const backupLimitHours=alertSettings.backupMaxAgeHours||48,backupLimit=backupLimitHours*3600,backupMap=new Map((b.items||[]).map(item=>[item.vmid,item])),now=Date.now()/1000,protectedGuests=guests.filter(v=>backupMap.has(v.vmid)),freshGuests=protectedGuests.filter(v=>now-backupMap.get(v.vmid).ctime<=backupLimit),missing=guests.length-protectedGuests.length;el('backupFreshLabel').textContent='Свежие · '+backupLimitHours+' часов';el('backupProtected').textContent=protectedGuests.length;el('backupTotal').textContent=' / '+guests.length;el('backupBar').style.width=guests.length?(protectedGuests.length/guests.length*100)+'%':'0%';el('backupFresh').textContent=b.available?freshGuests.length:'Н е найдено';el('backupMissing').textContent=b.available?missing:'—';
el('backupList').innerHTML=guests.length?guests.map(v=> { const item=backupMap.get(v.vmid),type=v.type==='qemu'?'VM':'LXC';if(!item)return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name||('Guest '+v.vmid))+'</div><div class="disk-meta">VMID '+v.vmid+' · '+type+'</div></div><span class="badge bad">Копии нет</span></div></article>';const age=now-item.ctime,fresh=age<=backupLimit,date=new Date(item.ctime*1000).toLocaleString('ru-RU');return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name||('Guest '+v.vmid))+'</div><div class="disk-meta">VMID '+v.vmid+' · '+type+' · '+date+'</div></div><span class="badge '+(fresh?'':'warn')+'">'+(fresh?'Актуальна':'Старше '+backupLimitHours+' ч')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Хранилище</span><b>'+safe(item.storage)+'</b></div><div class="disk-stat"><span>Размер</span><b>'+size(item.size)+'</b></div><div class="disk-stat"><span>Формат</span><b>'+safe(item.format)+'</b></div><div class="disk-stat"><span>Возраст</span><b>'+uptime(age)+'</b></div><div class="disk-stat"><span>Создана</span><b>'+date+'</b></div><div class="disk-stat"><span>VMID</span><b>'+v.vmid+'</b></div></div></article>'}).join(''):'<span class="empty">'+(b.available?'VM и LXC не найдены':'Данные резервных копий недоступны')+'</span>';
el('dot').classList.add('live');el('status').textContent='Данные поступают';
@@ -468,9 +488,12 @@ document.querySelectorAll('dialog').forEach(d=>{d.querySelector('.close').onclic
el('alertList').onclick=async event=> { const button=event.target.closest('[data-ack]');if(!button)return;button.disabled=true;const response=await fetch('/api/alerts/acknowledge', { method:'POST',headers: { 'Content-Type':'application/json'},body:JSON.stringify( { eventId:Number(button.dataset.ack)})});if(response.ok)button.closest('.alert-item').remove();else button.disabled=false};
async function loadAlertHistory() { const response=await fetch('/api/alerts/history');if(!response.ok)return;const events=await response.json()||[];el('alertHistory').innerHTML=events.length?events.map(a=> { const state=a.acknowledgedAt?'Принято · '+new Date(a.acknowledgedAt*1000).toLocaleString('ru-RU'):a.resolvedAt?'Завершено · '+new Date(a.resolvedAt*1000).toLocaleString('ru-RU'):'Активно';return '<article class="alert-item '+safe(a.severity)+'"><i class="alert-icon"></i><div><div class="alert-source">'+safe(a.source)+' · '+new Date(a.firstSeen*1000).toLocaleString('ru-RU')+'</div><div class="alert-title">'+safe(a.title)+'</div><div class="alert-message">'+safe(a.message)+'</div><span class="history-state">'+state+'</span></div></article>'}).join(''):'<div class="all-good"><strong>История пока пуста</strong><span>Новые события появятся здесь автоматически.</span></div>'}
document.querySelectorAll('[data-alert-tab]').forEach(tab=>tab.onclick=()=> { document.querySelectorAll('[data-alert-tab]').forEach(t=>t.classList.toggle('active',t===tab));document.querySelectorAll('[data-alert-panel]').forEach(p=>p.hidden=p.dataset.alertPanel!==tab.dataset.alertTab);if(tab.dataset.alertTab==='history')loadAlertHistory()});
const cardNames=['alerts','cpu','memory','storage','network','zfs','ups','guests','backups'] ;let hiddenCards=[];try { hiddenCards=JSON.parse(localStorage.getItem('proxmox-hidden-cards')||'[]')}catch(e) { }
function applyCardSettings() { cardNames.forEach(name=> { const visible=!hiddenCards.includes(name),card=document.querySelector('[data-card="'+name+'"]'),toggle=document.querySelector('[data-card-toggle="'+name+'"]');if(card)card.hidden=!visible;if(toggle)toggle.checked=visible} )}
document.querySelectorAll('[data-card-toggle]').forEach(toggle=>toggle.onchange=()=> { const name=toggle.dataset.cardToggle;hiddenCards=toggle.checked?hiddenCards.filter(v=>v!==name):[...new Set([...hiddenCards,name])]; localStorage.setItem('proxmox-hidden-cards ',JSON.stringify(hiddenCards ));applyCardSettings()});applyCardSettings ();
const cardNames=['alerts','cpu','memory','storage','network','zfs','ups','guests','maintenance','backups'],cardLabels= { alerts:'Центр уведомлений',cpu:'CPU',memory:'Память',storage:'Диски',network:'Сеть',zfs:'ZFS',ups:'UPS',guests:'VM и LXC',maintenance:'Proxmox',backups:'Резервные копии'} ;let hiddenCards=[],cardOrder=[] ;try { hiddenCards=JSON.parse(localStorage.getItem('proxmox-hidden-cards')||'[]');cardOrder=JSON.parse(localStorage.getItem('proxmox-card-order')||'[]')}catch(e) { }cardOrder=[...cardOrder.filter(x=>cardNames.includes(x)),...cardNames.filter(x=>!cardOrder.includes(x))];
function renderCardOrder() { el('cardOrder').innerHTML=cardOrder.map(name=>'<div class="order-item" draggable="true" data-order-card="'+name+'"><span class="order-handle">⠿</span><span>'+cardLabels[name]+'</span><button class="order-button" data-move="up" aria-label="Выше">↑</button><button class="order-button" data-move="down" aria-label="Ниже">↓</button></div>').join('' )}
function saveCardOrder() { localStorage.setItem('proxmox-card-order ',JSON.stringify(cardOrder ));applyCardSettings();renderCardOrder ()}
function applyCardSettings() { const container=document.querySelector('.cards');cardOrder.forEach(name=> { const card=document.querySelector('[data-card="'+name+'"]');if(card)container.appendChild(card)});cardNames.forEach(name=> { const visible=!hiddenCards.includes(name),card=document.querySelector('[data-card="'+name+'"]'),toggle=document.querySelector('[data-card-toggle="'+name+'"]');if(card)card.hidden=!visible;if(toggle)toggle.checked=visible})}
document.querySelectorAll('[data-card-toggle]').forEach(toggle=>toggle.onchange=()=> { const name=toggle.dataset.cardToggle;hiddenCards=toggle.checked?hiddenCards.filter(v=>v!==name):[...new Set([...hiddenCards,name])];localStorage.setItem('proxmox-hidden-cards',JSON.stringify(hiddenCards));applyCardSettings()});renderCardOrder();applyCardSettings();
el('cardOrder').onclick=event=> { const button=event.target.closest('[data-move]'),item=event.target.closest('[data-order-card]');if(!button||!item)return;const index=cardOrder.indexOf(item.dataset.orderCard),next=button.dataset.move==='up'?index-1:index+1;if(next<0||next>=cardOrder.length)return;[cardOrder[index],cardOrder[next]]=[cardOrder[next],cardOrder[index]];saveCardOrder()};let draggedCard='';el('cardOrder').addEventListener('dragstart',event=> { const item=event.target.closest('[data-order-card]');if(!item)return;draggedCard=item.dataset.orderCard;item.classList.add('dragging')});el('cardOrder').addEventListener('dragend',event=> { event.target.closest('[data-order-card]')?.classList.remove('dragging');draggedCard=''});el('cardOrder').addEventListener('dragover',event=>event.preventDefault());el('cardOrder').addEventListener('drop',event=> { event.preventDefault();const target=event.target.closest('[data-order-card]');if(!target||!draggedCard||target.dataset.orderCard===draggedCard)return;const from=cardOrder.indexOf(draggedCard),to=cardOrder.indexOf(target.dataset.orderCard);cardOrder.splice(from,1);cardOrder.splice(to,0,draggedCard);saveCardOrder()});
function setTheme(theme) { document.documentElement.dataset.theme=theme;localStorage.setItem('proxmox-theme',theme);document.querySelectorAll('[data-theme-choice]').forEach(b=>b.classList.toggle('active',b.dataset.themeChoice===theme));document.querySelectorAll('.chart-section:not([hidden])').forEach(showHistory)}document.querySelectorAll('[data-theme-choice]').forEach(b=>b.onclick=()=>setTheme(b.dataset.themeChoice));setTheme(document.documentElement.dataset.theme);el('settingsButton').onclick=()=>el('settingsDialog').showModal();
document.querySelectorAll('[data-settings-tab]').forEach(tab=>tab.onclick=()=> { document.querySelectorAll('[data-settings-tab]').forEach(t=>t.classList.toggle('active',t===tab));document.querySelectorAll('[data-settings-panel]').forEach(p=>p.hidden=p.dataset.settingsPanel!==tab.dataset.settingsTab)});
async function loadThresholds() { const response=await fetch('/api/settings/alerts');if(!response.ok)return;alertSettings=await response.json();document.querySelectorAll('[data-threshold]').forEach(input=>input.value=alertSettings[input.dataset.threshold]??'')}