From f6ac7eeb1a5ed2306e2e7faa63372f03ada153e4 Mon Sep 17 00:00:00 2001 From: Maxim Date: Sat, 1 Aug 2026 14:09:13 +0300 Subject: [PATCH] Fix task filters and improve homelab topology --- web.go | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/web.go b/web.go index ab6862d..aec6af2 100644 --- a/web.go +++ b/web.go @@ -346,6 +346,7 @@ footer{color:#657581;font-size:12px;margin-top:17px} .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} .services .fill{background:linear-gradient(90deg,#55d58a,#43c6d9)}.system-health .fill{background:linear-gradient(90deg,#6ba8ff,#9d8cff)}.service-form{display:grid;grid-template-columns:1fr 1fr;gap:12px}.field{display:grid;gap:6px;min-width:0}.field>span{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.07em}.field small{color:var(--muted);font-size:11px}.field input,.field select{width:100%;min-width:0;padding:11px 12px;border:1px solid var(--line);border-radius:9px;background:var(--bg);color:var(--text)}.check-field{display:flex;align-items:center;gap:10px;padding:12px;border:1px solid var(--line);border-radius:9px}.check-field input{width:18px;height:18px;accent-color:var(--green)}.service-actions{display:flex;flex-wrap:wrap;gap:7px;margin-top:10px}.service-actions button,.open-address{padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);cursor:pointer;text-decoration:none}.status-up{color:var(--green)}.status-down{color:#ff665c}.issue-message{overflow-wrap:anywhere}.full-span{grid-column:1/-1}.endpoint-list{display:grid;gap:10px;margin-top:16px}.endpoint{padding:14px;border:1px solid var(--line);border-radius:12px;background:rgba(100,130,150,.04)}.endpoint-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.endpoint-address{color:var(--muted);font:12px ui-monospace,monospace;overflow-wrap:anywhere}.certificate{margin-top:11px;padding-top:11px;border-top:1px solid var(--line);color:var(--muted);font-size:12px}.certificate b{color:var(--text)}.maintenance-grid .detail{min-width:0}.maintenance-grid .detail strong{font-size:clamp(15px,2.2vw,21px);overflow-wrap:anywhere;word-break:break-word}.system-tabs{display:flex;gap:6px;padding:12px 26px;border-bottom:1px solid var(--line)}.system-panel[hidden]{display:none} .snapshots .fill{background:linear-gradient(90deg,#9d8cff,#ff78b8)}.tasks .fill{background:linear-gradient(90deg,#43c6d9,#6ba8ff)}.homelab-map .fill{background:linear-gradient(90deg,#55d58a,#9d8cff)}.map-board{display:grid;grid-template-columns:170px 1fr 1fr 1fr;gap:14px;padding:18px;min-width:680px}.map-column{display:grid;align-content:start;gap:9px}.map-column-title{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.1em}.map-node{padding:12px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.map-node.good{border-color:#2d6245}.map-node.bad{border-color:#71362f}.map-node.warn{border-color:#6d4c2c}.map-node strong{display:block}.map-node small{display:block;color:var(--muted);margin-top:3px}.map-scroll{overflow-x:auto}.backup-check{margin-top:9px;color:var(--muted);font-size:12px}.task-filter{display:flex;gap:6px;padding:12px 16px;overflow-x:auto} +.topology-board{padding:18px;min-width:620px}.topology-host{margin-bottom:14px}.topology-grid{display:grid;grid-template-columns:repeat(2,minmax(270px,1fr));gap:12px}.topology-group{padding:14px;border:1px solid var(--line);border-radius:15px;background:rgba(100,130,150,.04)}.topology-group-head{display:flex;justify-content:space-between;gap:10px;padding-bottom:10px;border-bottom:1px solid var(--line)}.topology-section{margin-top:11px}.topology-section-title{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:6px}.topology-items{display:flex;flex-wrap:wrap;gap:6px}.topology-chip{padding:6px 9px;border:1px solid var(--line);border-radius:9px;background:var(--panel);font-size:12px}.topology-chip.good{border-color:#2d6245}.topology-chip.bad{border-color:#71362f}.topology-unassigned{margin-top:14px}@media(max-width:700px){.topology-grid{grid-template-columns:1fr}}
@@ -542,7 +543,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Получение snapshots…
Получение задач…
-
Связь сервиса с VM/LXC определяется по локальному IP. Storage берётся из конфигурации гостя.
+
Порт от локального адреса отбрасывается. Полученный IP сравнивается с адресами VM/LXC из Proxmox. Storage берётся из конфигурации каждого гостя.
@@ -614,10 +615,12 @@ function endpointHTML(v){const tls=v.tlsDays==null?'Нет TLS':v.tlsDays<0?'С function renderServices(svc){const items=svc.services||[];currentServiceStatuses=items;const avg=items.length?items.reduce((a,v)=>a+v.uptimeDay,0)/items.length:0,live=items.filter(v=>v.up),latency=live.length?live.reduce((a,v)=>a+v.latencyMs,0)/live.length:0;el('serviceUp').textContent=svc.up||0;el('serviceTotal').textContent=' / '+(svc.total||0);el('serviceBar').style.width=(svc.total?svc.up/svc.total*100:0)+'%';el('serviceUptime').textContent=svc.total?fixed(avg)+'%':'—';el('serviceLatency').textContent=svc.total?fixed(latency)+' ms':'—';el('serviceList').innerHTML=items.length?items.map(v=>'
'+safe(v.name)+'
'+(v.endpoints||[]).length+' '+((v.endpoints||[]).length===1?'адрес':'адреса')+' проверяются независимо
'+(v.up?'Всё доступно':'Есть проблема')+'
'+(v.endpoints||[]).map(endpointHTML).join('')+'
').join(''):'Добавьте первую проверку в настройках.'} function renderNetworkGroups(interfaces){const groups=new Map();interfaces.forEach(v=>{const key=v.vmid?'guest-'+v.vmid:'host',group=groups.get(key)||{vmid:v.vmid,name:v.vmid?(v.guestName||('VMID '+v.vmid)):'Интерфейсы хоста',type:v.guestType||'Host',items:[]};group.items.push(v);groups.set(key,group)});el('networkInterfacesList').innerHTML=[...groups.values()].map(g=>{const primary=g.vmid?g.items.filter(v=>v.name.startsWith('tap')||v.name.startsWith('veth')):g.items,rx=primary.reduce((a,v)=>a+v.receivePerSec,0),tx=primary.reduce((a,v)=>a+v.transmitPerSec,0),errors=g.items.reduce((a,v)=>a+v.receiveErrors+v.transmitErrors+v.receiveDrops+v.transmitDrops,0);return '
'+safe(g.name)+'
'+(g.vmid?'VMID '+g.vmid+' · ':'')+safe(g.type)+' · '+safe(g.items.map(v=>v.name).join(', '))+'
'+(errors?'Есть потери':'Без ошибок')+'
Сейчас ↓'+rate(rx)+'
Сейчас ↑'+rate(tx)+'
Получено'+size(primary.reduce((a,v)=>a+v.receiveBytes,0))+'
Передано'+size(primary.reduce((a,v)=>a+v.transmitBytes,0))+'
Ошибки / drops'+errors+'
'}).join('')} const taskLabel=type=>({vzdump:'Backup',qmstart:'Запуск VM',qmstop:'Остановка VM',qmshutdown:'Выключение VM',qmsnapshot:'Snapshot VM',vzstart:'Запуск LXC',vzstop:'Остановка LXC',vzsnapshot:'Snapshot LXC',qmigrate:'Миграция',aptupdate:'Обновление пакетов'})[type]||type; -let currentTasks=[]; +function explainWarningBadges(){el('snapshotList').querySelectorAll('.badge.warn').forEach(v=>{v.tabIndex=0;v.dataset.reason='Snapshot старше 30 дней. Проверьте, нужен ли он: старые снимки могут занимать место в storage.'});el('taskList').querySelectorAll('.badge.bad').forEach(v=>{v.tabIndex=0;v.dataset.reason='Задача Proxmox завершилась не со статусом OK. Откройте её журнал в интерфейсе Proxmox для подробностей.'})} +let currentTasks=[],currentTaskFilter='all'; function renderTasks(tasks,filter='all'){currentTasks=tasks;const shown=tasks.filter(v=>filter==='all'||filter==='error'&&v.status!=='OK'||filter==='qm'&&(/^(qm|vz)/.test(v.type))||v.type===filter);el('taskList').innerHTML=shown.length?shown.map(v=>'
'+safe(taskLabel(v.type))+(v.id?' · '+safe(v.id):'')+'
'+new Date(v.starttime*1000).toLocaleString('ru-RU')+' · '+safe(v.user||'—')+'
'+safe(v.status||'Выполняется')+'
Длительность'+uptime(v.duration||Math.max(0,Date.now()/1000-v.starttime))+'
Узел'+safe(v.node)+'
VMID'+safe(v.id||'—')+'
').join(''):'Задач этого типа нет.'} -function renderActivity(activity,guests,services,zfs){const snapshots=activity.snapshots||[],tasks=activity.tasks||[],old=snapshots.filter(v=>v.ageSeconds>30*86400),guestCount=new Set(snapshots.map(v=>v.vmid)).size,failed=tasks.filter(v=>v.status&&v.status!=='OK');el('snapshotCount').textContent=snapshots.length;el('oldSnapshots').textContent=old.length;el('snapshotGuests').textContent=guestCount;el('snapshotBar').style.width=Math.min(old.length*20,100)+'%';el('snapshotList').innerHTML=snapshots.length?snapshots.map(v=>'
'+safe(v.guestName||('VMID '+v.vmid))+' · '+safe(v.name)+'
VMID '+v.vmid+' · '+safe(v.guestType)+(v.createdAt?' · '+new Date(v.createdAt*1000).toLocaleString('ru-RU'):'')+'
'+(v.ageSeconds?uptime(v.ageSeconds):'Без даты')+'
Размер'+(v.sizeBytes?size(v.sizeBytes):'Storage не сообщает')+'
Состояние VM'+(v.running?'Запущена':'Остановлена')+'
Описание'+safe(v.description||'—')+'
').join(''):'Snapshots не найдены.';el('taskCount').textContent=tasks.length;el('failedTasks').textContent=failed.length;el('lastTask').textContent=tasks.length?taskLabel(tasks[0].type):'—';el('taskBar').style.width=Math.min(failed.length*20,100)+'%';renderTasks(tasks);renderHomelabMap(guests,services,zfs)} -function renderHomelabMap(guests,services,zfs){const guestItems=(guests.guests||[]).filter(v=>!v.template),serviceItems=services.services||[],pools=zfs.pools||[],serviceOwners=new Map();serviceItems.forEach(s=>{const address=(s.localAddress||'');const owner=guestItems.find(g=>(g.ipAddresses||[]).some(ip=>address.includes(ip)));serviceOwners.set(s.id,owner?.vmid||0)});const guestHTML=guestItems.map(g=>'
'+safe(g.name||('VMID '+g.vmid))+'VMID '+g.vmid+' · '+safe(g.type==='qemu'?'VM':'LXC')+'
').join(''),serviceHTML=serviceItems.map(s=>{const status=(s.endpoints||[]).every(v=>v.up);return '
'+safe(s.name)+''+(serviceOwners.get(s.id)?'VMID '+serviceOwners.get(s.id):'Связь не определена')+'
'}).join(''),storage=new Set(guestItems.flatMap(g=>g.storage||[]));pools.forEach(p=>storage.add(p.name));const storageHTML=[...storage].map(name=>{const pool=pools.find(p=>p.name===name);return '
'+safe(name)+''+(pool?'ZFS · '+fixed(pool.capacityPercent)+'%':'Storage Proxmox')+'
'}).join('');el('mapBoard').innerHTML='
Хост
Proxmox'+safe(location.hostname)+'
VM и LXC
'+guestHTML+'
Сервисы
'+serviceHTML+'
Storage
'+storageHTML+'
';el('mapGuests').textContent=guestItems.length;el('mapResources').textContent=serviceItems.length+' / '+storage.size;el('mapNodes').textContent=1+guestItems.length+serviceItems.length+storage.size;el('mapBar').style.width='100%'} +function renderActivity(activity,guests,services,zfs){const snapshots=activity.snapshots||[],tasks=activity.tasks||[],old=snapshots.filter(v=>v.ageSeconds>30*86400),guestCount=new Set(snapshots.map(v=>v.vmid)).size,failed=tasks.filter(v=>v.status&&v.status!=='OK');el('snapshotCount').textContent=snapshots.length;el('oldSnapshots').textContent=old.length;el('snapshotGuests').textContent=guestCount;el('snapshotBar').style.width=Math.min(old.length*20,100)+'%';el('snapshotList').innerHTML=snapshots.length?snapshots.map(v=>'
'+safe(v.guestName||('VMID '+v.vmid))+' · '+safe(v.name)+'
VMID '+v.vmid+' · '+safe(v.guestType)+(v.createdAt?' · '+new Date(v.createdAt*1000).toLocaleString('ru-RU'):'')+'
'+(v.ageSeconds?uptime(v.ageSeconds):'Без даты')+'
Размер'+(v.sizeBytes?size(v.sizeBytes):'Storage не сообщает')+'
Состояние VM'+(v.running?'Запущена':'Остановлена')+'
Описание'+safe(v.description||'—')+'
').join(''):'Snapshots не найдены.';el('taskCount').textContent=tasks.length;el('failedTasks').textContent=failed.length;el('lastTask').textContent=tasks.length?taskLabel(tasks[0].type):'—';el('taskBar').style.width=Math.min(failed.length*20,100)+'%';renderTasks(tasks,currentTaskFilter);renderHomelabMap(guests,services,zfs)} +function topologyHost(address){if(!address)return '';try{return new URL(address.includes('://')?address:'http://'+address).hostname.replace(/^\[|\]$/g,'')}catch(e){return address.replace(/^[a-z]+:\/\//i,'').split('/')[0].replace(/:\d+$/,'')}} +function renderHomelabMap(guests,services,zfs){const guestItems=(guests.guests||[]).filter(v=>!v.template),serviceItems=services.services||[],pools=zfs.pools||[],serviceOwners=new Map();serviceItems.forEach(s=>{const host=topologyHost(s.localAddress||'');const owner=guestItems.find(g=>(g.ipAddresses||[]).some(ip=>ip===host));serviceOwners.set(s.id,owner?.vmid||0)});const serviceChip=s=>{const healthy=(s.endpoints||[]).every(v=>v.up);return ''+safe(s.name)+''},storageChip=name=>{const pool=pools.find(p=>p.name===name),healthy=!pool||pool.health==='ONLINE';return ''+safe(name)+(pool?' · '+fixed(pool.capacityPercent)+'%':'')+''};const groups=guestItems.map(g=>{const owned=serviceItems.filter(s=>serviceOwners.get(s.id)===g.vmid),storage=[...new Set(g.storage||[])],running=g.status==='running';return '
'+safe(g.name||('VMID '+g.vmid))+'
VMID '+g.vmid+' · '+safe(g.type==='qemu'?'VM':'LXC')+' · '+safe((g.ipAddresses||[]).join(', ')||'IP не получен')+'
'+(running?'Работает':'Остановлена')+'
Сервисы
'+(owned.length?owned.map(serviceChip).join(''):'Не сопоставлены')+'
Storage
'+(storage.length?storage.map(storageChip).join(''):'Не определён')+'
'}).join(''),unassigned=serviceItems.filter(s=>!serviceOwners.get(s.id)),usedStorage=new Set(guestItems.flatMap(g=>g.storage||[])),unassignedPools=pools.filter(p=>!usedStorage.has(p.name));el('mapBoard').innerHTML='
Proxmox host'+safe(location.hostname)+' · '+guestItems.length+' VM/LXC
'+groups+'
'+((unassigned.length||unassignedPools.length)?'
Без автоматической привязкиПроверьте связи
Сервисы
'+unassigned.map(serviceChip).join('')+'
Остальные ZFS-пулы
'+unassignedPools.map(p=>storageChip(p.name)).join('')+'
':'');const storage=new Set([...usedStorage,...pools.map(p=>p.name)]);el('mapGuests').textContent=guestItems.length;el('mapResources').textContent=serviceItems.length+' / '+storage.size;el('mapNodes').textContent=1+guestItems.length+serviceItems.length+storage.size;el('mapBar').style.width='100%'} 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)+'%'; @@ -641,7 +644,7 @@ function render(x){const c=x.cpu,m=x.memory,s=x.storage,n=x.network,z=x.zfs||{av const sh=x.systemHealth||{},issueCount=sh.systemErrors||0;el('systemIssues').textContent=issueCount;el('systemBar').style.width=Math.min(issueCount*10,100)+'%';el('failedUnits').textContent=sh.failedUnits??'—';el('kernelErrors').textContent=sh.kernelErrors??'—';el('dSystemErrors').textContent=issueCount;el('dKernelErrors').textContent=sh.kernelErrors||0;el('dFailedUnits').textContent=sh.failedUnits||0;el('issueList').innerHTML=(sh.issues||[]).length?sh.issues.map(v=>'
'+safe(v.unit)+'
'+new Date(v.time*1000).toLocaleString('ru-RU')+'
Ошибка
'+safe(v.message)+'
').join(''):'Критических сообщений за 24 часа нет.';el('failedServiceList').innerHTML=(sh.failedServices||[]).length?sh.failedServices.map(v=>'
'+safe(v.unit)+'
'+safe(v.description||'Описание отсутствует')+'
'+safe(v.state)+'
Загрузка'+safe(v.load)+'
Состояние'+safe(v.active)+'
Подсостояние'+safe(v.state)+'
').join(''):'Упавших systemd-служб нет.'; el('issueList').querySelectorAll('.badge').forEach((badge,index)=>{const count=(sh.issues||[])[index]?.count||1;badge.textContent=count>1?'Повторений: '+count:'1 раз'}); renderNetworkGroups(n.interfaces||[]); - renderActivity(x.activity||{},g,svc,z); + renderActivity(x.activity||{},g,svc,z);explainWarningBadges(); el('dot').classList.add('live');el('status').textContent='Данные поступают'; } const percent=v=>fixed(v)+'%',temperature=v=>fixed(v)+' °C',megabytes=v=>fixed(v)+' MB/s'; @@ -659,7 +662,7 @@ document.querySelectorAll('[data-open]').forEach(b=>b.onclick=()=>el(b.dataset.o document.querySelectorAll('dialog').forEach(d=>{d.querySelector('.close').onclick=()=>d.close();d.onclick=e=>{if(e.target===d)d.close()}}); document.querySelectorAll('[data-system-tab]').forEach(tab=>tab.onclick=()=>{document.querySelectorAll('[data-system-tab]').forEach(v=>v.classList.toggle('active',v===tab));document.querySelectorAll('[data-system-panel]').forEach(v=>v.hidden=v.dataset.systemPanel!==tab.dataset.systemTab)}); el('serviceList').onclick=event=>{const button=event.target.closest('[data-service-open]');if(!button)return;const service=currentServiceStatuses.find(v=>v.id===Number(button.dataset.serviceOpen));if(!service)return;const choices=(service.endpoints||[]).filter(v=>/^https?:\/\//.test(v.address));if(choices.length===1){window.open(choices[0].address,'_blank','noopener');return}if(!choices.length)return;el('openServiceTitle').textContent=service.name;el('openServiceChoices').innerHTML=choices.map(v=>''+safe(v.label)+''+safe(v.address)+'').join('');el('openServiceDialog').showModal()}; -document.querySelectorAll('[data-task-filter]').forEach(button=>button.onclick=()=>{document.querySelectorAll('[data-task-filter]').forEach(v=>v.classList.toggle('active',v===button));renderTasks(currentTasks,button.dataset.taskFilter)}); +document.querySelectorAll('[data-task-filter]').forEach(button=>button.onclick=()=>{currentTaskFilter=button.dataset.taskFilter;document.querySelectorAll('[data-task-filter]').forEach(v=>v.classList.toggle('active',v===button));renderTasks(currentTasks,currentTaskFilter);explainWarningBadges()}); el('backupList').onclick=async event=>{const button=event.target.closest('[data-verify-backup]');if(!button)return;const target=el('backupList').querySelector('[data-backup-result="'+CSS.escape(button.dataset.verifyBackup)+'"]');button.disabled=true;target.textContent='Проверяем метаданные и читаемость…';try{const response=await fetch('/api/backups/verify',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({volid:button.dataset.verifyBackup})}),result=await response.json();target.textContent=(result.ok?'✓ ':'⚠ ')+result.message;target.className='backup-check '+(result.ok?'status-up':'status-down')}catch(e){target.textContent='Не удалось выполнить проверку.'}finally{button.disabled=false}}; 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 '
'+safe(a.source)+' · '+new Date(a.firstSeen*1000).toLocaleString('ru-RU')+'
'+safe(a.title)+'
'+safe(a.message)+'
'+state+'
'}).join(''):'
История пока пустаНовые события появятся здесь автоматически.
'}