diff --git a/web.go b/web.go index cda628a..0f1e57b 100644 --- a/web.go +++ b/web.go @@ -518,7 +518,7 @@ footer{color:#657581;font-size:12px;margin-top:17px} .monitor .fill{background:linear-gradient(90deg,#55d58a,#43c6d9)}.shutdown-plan{display:grid;gap:8px;margin-top:12px}.shutdown-step{padding:10px 12px;border:1px solid var(--line);border-radius:10px}.infra-status{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:14px 0}.infra-status>div{padding:11px;border:1px solid var(--line);border-radius:10px}.infra-status b{display:block;margin-top:4px}@media(max-width:700px){.infra-status{grid-template-columns:1fr 1fr}} .agents .fill{background:linear-gradient(90deg,#55d58a,#6ba8ff)}.agent-command{display:block;width:100%;padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--text);font:12px/1.5 ui-monospace,monospace;overflow-wrap:anywhere;white-space:pre-wrap}.agent-containers{display:flex;flex-wrap:wrap;gap:7px;margin-top:13px} .agent-detail-panel{padding:16px}.agent-detail-panel[hidden]{display:none}.agent-detail-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}.agent-detail-summary>div{padding:14px;border:1px solid var(--line);border-radius:12px}.agent-detail-summary b{display:block;margin-top:5px;font:600 16px ui-monospace,monospace}.agent-filter{display:flex;gap:6px;margin-bottom:12px}.agent-filter button{padding:7px 10px;border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--text);cursor:pointer}.agent-filter button.active{border-color:var(--green);color:var(--green)}@media(max-width:700px){.agent-detail-summary{grid-template-columns:1fr 1fr}} -.diagnostics .fill{background:linear-gradient(90deg,#43c6d9,#9d8cff)}.diagnostic-chain{display:flex;align-items:stretch;gap:8px;overflow-x:auto;padding:3px 0}.diagnostic-node{min-width:150px;padding:11px;border:1px solid var(--line);border-radius:11px;background:var(--panel)}.diagnostic-node.bad{border-color:#71362f}.diagnostic-arrow{align-self:center;color:var(--muted)}.service-log{margin-top:10px;padding:9px 11px;border-left:2px solid var(--line);background:var(--bg);font:11px/1.5 ui-monospace,monospace;overflow-wrap:anywhere}.process-table{display:grid;gap:7px}.process-row{display:grid;grid-template-columns:minmax(140px,2fr) repeat(4,minmax(70px,1fr));gap:8px;padding:10px;border:1px solid var(--line);border-radius:10px;align-items:center}.process-row small{color:var(--muted);overflow-wrap:anywhere}@media(max-width:700px){.process-row{grid-template-columns:1fr 1fr}.agent-filter{overflow-x:auto}} +.diagnostics .fill{background:linear-gradient(90deg,#43c6d9,#9d8cff)}#diagnosticsDialog{width:min(1180px,calc(100% - 28px))}.diagnostic-chain{display:grid;grid-template-columns:minmax(175px,1fr) 24px minmax(210px,1.35fr) 24px minmax(120px,.7fr) 24px minmax(160px,.9fr);align-items:stretch;gap:8px;padding:8px 0}.diagnostic-node{min-width:0;padding:14px;border:1px solid var(--line);border-radius:11px;background:var(--panel);overflow:hidden}.diagnostic-node .label,.diagnostic-node strong{display:block}.diagnostic-node strong{margin-top:6px;font-size:16px;line-height:1.25;overflow-wrap:anywhere}.diagnostic-node.bad{border-color:#71362f}.diagnostic-arrow{align-self:center;text-align:center;color:var(--muted);font-size:20px}.service-log{margin-top:10px;padding:9px 11px;border-left:2px solid var(--line);background:var(--bg);font:11px/1.5 ui-monospace,monospace;overflow-wrap:anywhere}.process-table{display:grid;gap:7px}.process-row{display:grid;grid-template-columns:minmax(140px,2fr) repeat(4,minmax(70px,1fr));gap:8px;padding:10px;border:1px solid var(--line);border-radius:10px;align-items:center}.process-row small{display:block;color:var(--muted);overflow-wrap:anywhere}.agent-problem-summary{display:flex;flex-wrap:wrap;gap:7px;margin-top:12px}@media(max-width:850px){.diagnostic-chain{display:flex;overflow-x:auto}.diagnostic-node{min-width:190px}.diagnostic-arrow{min-width:24px}}@media(max-width:700px){.process-row{grid-template-columns:1fr 1fr}.agent-filter{overflow-x:auto}}
@@ -736,7 +736,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Получение задач…
Порт от локального адреса отбрасывается. Полученный IP сравнивается с адресами VM/LXC из Proxmox. Storage берётся из конфигурации каждой VM и LXC.
Связи строятся по VMID, IP, слушающему порту, PID systemd и портам Docker. Предполагаемые связи явно помечаются.
Ожидаем данные агентов…
-
Подключённые агенты появятся здесь.
+
Подключённые агенты появятся здесь.
@@ -854,6 +854,10 @@ function agentHTML(a,settings=false){ return '
'+safe(a.name||a.hostname)+'
'+safe(a.hostname)+' · VMID '+(a.vmid||'не указан')+' · '+safe(r.osName||r.os||'Linux')+' · '+safe(r.kernel||'ядро не определено')+' · агент '+safe(a.version||'—')+'
'+(a.online?'Онлайн':'Не отвечает')+'
CPU / Load'+fixed(r.cpuPercent||0)+'% / '+fixed(r.load1||0)+'
Память'+size(r.memoryUsed)+' · '+fixed(memoryPercent)+'%
Раздел /'+size(r.rootUsed)+' · '+fixed(diskPercent)+'%
Uptime'+uptime(r.uptimeSeconds||0)+'
Обновления'+(r.updatesAvailable||0)+' пакетов'+(r.rebootRequired?' · нужен reboot':'')+'
Проблемы'+failed.length+' служб · '+issues.length+' ошибок
IP-адреса и интерфейсы
'+(interfaces.length?interfaces.map(v=>''+safe(v.name)+' · '+safe((v.addresses||[]).join(', '))+'').join(''):'IP-адреса не найдены')+'
'+(settings?'':'')+'
' } let currentAgents=[];function renderAgents(items){const agents=currentAgents=items||[],online=agents.filter(v=>v.online),containers=agents.flatMap(v=>(v.report&&v.report.containers)||[]),failedServices=agents.flatMap(v=>(v.report&&v.report.services)||[]).filter(v=>v.state==='failed'||v.subState==='failed'),problems=(agents.length-online.length)+failedServices.length+containers.filter(v=>v.state!=='running'||v.health==='unhealthy').length;el('agentOnline').textContent=online.length;el('agentTotal').textContent=' / '+agents.length;el('agentBar').style.width=(agents.length?online.length/agents.length*100:0)+'%';el('agentContainers').textContent=containers.length;el('agentProblems').textContent=problems;el('agentList').innerHTML=agents.length?agents.map(v=>agentHTML(v)).join(''):'Агенты пока не подключены. Создайте команду установки в разделе «Управление».';el('agentSettingsList').innerHTML=agents.length?agents.map(v=>agentHTML(v,true)).join(''):'Агентов пока нет.'} +function collectAgentProblems(agent){const r=agent.report||[],now=agent.lastSeen||Math.floor(Date.now()/1000),out=[],add=(severity,title,message,time=now,area='Система')=>out.push({agent,severity,title,message,time,area});if(!agent.online)add('critical','Агент не отвечает','Последний heartbeat был '+(agent.lastSeen?new Date(agent.lastSeen*1000).toLocaleString('ru-RU'):'неизвестно'),agent.lastSeen,'Связь');(r.services||[]).forEach(v=>{if(v.state==='failed'||v.subState==='failed'){const log=(v.logs||[])[0];add('critical','Упала служба '+v.name,log?.message||v.description||('Exit code '+(v.exitCode||0)),log?.time||now,'systemd')}else if(v.flapping)add('warning','Служба часто перезапускается: '+v.name,(v.restarts||0)+' рестартов; последний запуск '+(v.startedAt?new Date(v.startedAt*1000).toLocaleString('ru-RU'):'неизвестен'),v.startedAt||now,'systemd')});(r.containers||[]).forEach(v=>{if(v.state!=='running')add('warning','Docker-контейнер остановлен: '+v.name,v.status||v.state,now,'Docker');else if(v.health==='unhealthy')add('critical','Docker-контейнер unhealthy: '+v.name,'Встроенный health-check Docker не проходит',now,'Docker')});(r.filesystems||[]).forEach(v=>{if(v.usedPercent>=90)add('critical','Заканчивается место: '+v.mountpoint,'Заполнено '+fixed(v.usedPercent)+'% · '+size(v.usedBytes)+' из '+size(v.totalBytes),now,'Хранилище');if(v.inodesPercent>=90)add('critical','Заканчиваются inode: '+v.mountpoint,'Использовано '+fixed(v.inodesPercent)+'% inode',now,'Хранилище')});if((r.oomKills||[]).length){const v=r.oomKills[0];add('critical','OOM-kill: ядро завершило процесс',v.message,v.time||now,'Память')}(r.processes||[]).filter(v=>String(v.state||'').startsWith('D')).forEach(v=>add('warning','Процесс завис в D-state: '+v.name,'PID '+v.pid+' · '+v.command,now,'Процессы'));return out} +const agentHTMLBase=agentHTML;agentHTML=function(agent,settings=false){const problems=collectAgentProblems(agent),markup=agentHTMLBase(agent,settings);return markup.replace(/(Проблемы<\/span>)[^<]*(<\/b>)/,'$1'+problems.length+' активных$2')} +function renderAgentProblems(agents){const problems=(agents||[]).flatMap(collectAgentProblems).sort((a,b)=>(a.severity==='critical'?0:1)-(b.severity==='critical'?0:1)||b.time-a.time);el('agentProblems').textContent=problems.length;el('agentProblemTabCount').textContent=problems.length;el('agentProblemList').innerHTML=problems.length?problems.map(p=>'
'+safe(p.title)+'
'+safe(p.agent.name||p.agent.hostname)+' · VMID '+(p.agent.vmid||'—')+' · '+safe(p.area)+' · '+(p.time?new Date(p.time*1000).toLocaleString('ru-RU'):'время неизвестно')+'
'+(p.severity==='critical'?'Критично':'Внимание')+'
'+safe(p.message)+'
').join(''):'
Проблем нетВсе агенты, службы и Docker-контейнеры работают штатно.
'} +const renderAgentsBase=renderAgents;renderAgents=function(items){renderAgentsBase(items);renderAgentProblems(currentAgents)} function renderAgentServices(services,filter='all'){const failed=v=>v.state==='failed'||v.subState==='failed',custom=v=>['added','local','runtime'].includes(v.origin),filtered=(services||[]).filter(v=>filter==='all'||filter==='failed'&&failed(v)||filter==='system'&&v.origin==='system'||filter==='custom'&&custom(v)),originLabel=v=>v.origin==='local'?'Локальная':v.origin==='added'?'Добавленный пакет':v.origin==='runtime'?'Временная':'Системная';el('agentServiceDetails').innerHTML='
'+(filtered.length?filtered.map(v=>'
'+safe(v.name)+'
'+safe(v.description||'Описание отсутствует')+'
'+originLabel(v)+(v.package?' · пакет '+safe(v.package):'')+(v.unitPath?' · '+safe(v.unitPath):'')+'
'+safe(v.subState||v.state)+'
Запущена'+(v.startedAt?new Date(v.startedAt*1000).toLocaleString('ru-RU'):'—')+'
Рестарты'+(v.restarts||0)+'
Exit code'+(v.exitCode||0)+'
Память'+(v.memoryBytes?size(v.memoryBytes):'Нет accounting')+'
CPU time'+(v.cpuSeconds?fixed(v.cpuSeconds)+' сек.':'Нет accounting')+'
').join(''):'Служб в этой группе нет.')+'
';el('agentServiceDetails').querySelectorAll('[data-agent-service-filter]').forEach(button=>button.onclick=()=>renderAgentServices(services,button.dataset.agentServiceFilter))} function renderAgentServicesV2(services,filter='all'){const failed=v=>v.state==='failed'||v.subState==='failed',custom=v=>['added','local','runtime'].includes(v.origin),filtered=(services||[]).filter(v=>filter==='all'||filter==='failed'&&failed(v)||filter==='system'&&v.origin==='system'||filter==='custom'&&custom(v)||filter==='flapping'&&v.flapping),originLabel=v=>v.origin==='local'?'Локальная':v.origin==='added'?'Добавленный пакет':v.origin==='runtime'?'Временная':'Системная',chip=(label,value)=>value?''+label+' · '+safe(value)+'':'';el('agentServiceDetails').innerHTML='
'+(filtered.length?filtered.map(v=>'
'+safe(v.name)+'
'+safe(v.description||'Описание отсутствует')+'
'+originLabel(v)+(v.package?' · пакет '+safe(v.package):'')+(v.unitPath?' · '+safe(v.unitPath):'')+'
'+(v.flapping?'Частые рестарты':safe(v.subState||v.state))+'
Запущена'+(v.startedAt?new Date(v.startedAt*1000).toLocaleString('ru-RU'):'—')+'
PID / команда'+(v.mainPid||'—')+' · '+safe(v.execStart||'—')+'
Рестарты / exit'+(v.restarts||0)+' / '+(v.exitCode||0)+'
Память / CPU time'+(v.memoryBytes?size(v.memoryBytes):'Нет accounting')+' / '+(v.cpuSeconds?fixed(v.cpuSeconds)+' сек.':'—')+'
Порты и зависимости
'+(v.listenPorts||[]).map(p=>chip('порт',p)).join('')+(v.requires||[]).map(d=>chip('requires',d)).join('')+(v.after||[]).slice(0,6).map(d=>chip('after',d)).join('')+'
'+((v.logs||[]).length?'
Последние сообщения журнала
'+v.logs.map(l=>'
'+(l.time?new Date(l.time*1000).toLocaleString('ru-RU')+' · ':'')+safe(l.message)+'
').join('')+'
':'')+'
').join(''):'Служб в этой группе нет.')+'
';el('agentServiceDetails').querySelectorAll('[data-agent-service-filter]').forEach(button=>button.onclick=()=>renderAgentServicesV2(services,button.dataset.agentServiceFilter))} function openAgentDetail(agent){const r=agent.report||{},services=r.services||[],issues=r.issues||[],containers=r.containers||[],interfaces=r.interfaces||[],filesystems=r.filesystems||[],processes=r.processes||[],ports=r.listenPorts||[],oom=r.oomKills||[],pressure=r.pressure||{},memoryPercent=r.memoryTotal?r.memoryUsed/r.memoryTotal*100:0,diskPercent=r.rootTotal?r.rootUsed/r.rootTotal*100:0;el('agentDetailTitle').textContent=agent.name||agent.hostname;el('agentDetailMeta').textContent=(agent.hostname||'')+' · VMID '+(agent.vmid||'не указан')+' · '+(r.osName||r.os||'Linux')+' · '+(r.kernel||'ядро не определено')+' · агент '+(agent.version||'—');el('agentOverview').innerHTML='
Состояние'+(agent.online?'Онлайн':'Не отвечает')+'
CPU / Load'+fixed(r.cpuPercent||0)+'% / '+fixed(r.load1||0)+'
Память'+size(r.memoryUsed)+' · '+fixed(memoryPercent)+'%
Раздел /'+size(r.rootUsed)+' · '+fixed(diskPercent)+'%
Uptime'+uptime(r.uptimeSeconds||0)+'
Обновления'+(r.updatesAvailable||0)+' пакетов'+(r.rebootRequired?' · нужен reboot':'')+'
IP-адреса
'+(interfaces.length?interfaces.map(v=>''+safe(v.name)+' · '+safe((v.addresses||[]).join(', '))+'').join(''):'Не найдены')+'
Последний heartbeat: '+new Date(agent.lastSeen*1000).toLocaleString('ru-RU')+' · системный инвентарь: '+(r.inventoryAt?new Date(r.inventoryAt*1000).toLocaleString('ru-RU'):'ещё не собран')+'
';renderAgentServicesV2(services);el('agentResourceDetails').innerHTML='
CPU pressure · 10/60/300с'+fixed(pressure.cpu?.avg10||0)+' / '+fixed(pressure.cpu?.avg60||0)+' / '+fixed(pressure.cpu?.avg300||0)+'
Memory pressure'+fixed(pressure.memory?.avg10||0)+' / '+fixed(pressure.memory?.avg60||0)+' / '+fixed(pressure.memory?.avg300||0)+'
I/O pressure'+fixed(pressure.io?.avg10||0)+' / '+fixed(pressure.io?.avg60||0)+' / '+fixed(pressure.io?.avg300||0)+'
'+(filesystems.length?filesystems.map(v=>'
'+safe(v.mountpoint)+'
'+safe(v.device)+' · '+safe(v.type)+'
'+fixed(v.usedPercent)+'%
Занято / всего'+size(v.usedBytes)+' / '+size(v.totalBytes)+'
Inode'+fixed(v.inodesPercent||0)+'% · '+(v.inodesUsed||0)+' / '+(v.inodesTotal||0)+'
').join(''):'Файловые системы ещё не получены.')+'
';el('agentProcessDetails').innerHTML='
Слушающие порты
'+(ports.length?ports.map(v=>''+safe(v.protocol)+' · '+safe(v.address)+':'+v.port+' · '+safe(v.process||'PID '+(v.pid||'—'))+'').join(''):'Не найдены или ss недоступен.')+'
Топ процессов
'+(processes.length?processes.map(v=>'
'+safe(v.name)+'PID '+v.pid+' · '+safe(v.command)+'
CPU '+fixed(v.cpuPercent)+'%RAM '+size(v.memoryBytes)+'Read '+size(v.readBytes)+'Write '+size(v.writeBytes)+'
').join(''):'Процессы ещё не получены.')+'
';el('agentIssueDetails').innerHTML='
'+(oom.map(v=>'
OOM kill
Критично
'+new Date(v.time*1000).toLocaleString('ru-RU')+' · '+safe(v.message)+'
').join(''))+(issues.length?issues.map(v=>'
'+safe(v.unit||'system')+'
'+(v.time?new Date(v.time*1000).toLocaleString('ru-RU'):'Время не указано')+'
Ошибка
'+safe(v.message)+'
').join(''):'Ошибок уровня err и выше за 24 часа нет.')+'
';el('agentDockerDetails').innerHTML='
'+(containers.length?containers.map(v=>'
'+safe(v.name)+'
'+safe(v.image)+' · '+safe(v.id)+'
'+safe(v.health||v.state)+'
Статус'+safe(v.status)+'
CPU'+fixed(v.cpuPercent||0)+'%
Память'+size(v.memoryUsage||0)+'
Рестарты'+v.restarts+'
Порты'+safe((v.ports||[]).join(', ')||'—')+'
').join(''):'Docker-контейнеры не обнаружены.')+'
';const first=document.querySelector('[data-agent-tab="overview"]');document.querySelectorAll('[data-agent-tab]').forEach(t=>t.classList.toggle('active',t===first));document.querySelectorAll('[data-agent-panel]').forEach(p=>p.hidden=p.dataset.agentPanel!=='overview');el('agentDetailDialog').showModal()} @@ -904,7 +908,9 @@ document.querySelectorAll('dialog').forEach(d=>{d.querySelector('.close').onclic 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('systemSort').onclick=()=>{systemSortNewest=!systemSortNewest;renderSystemHealth(currentSystemHealth)}; document.querySelectorAll('[data-agent-tab]').forEach(tab=>tab.onclick=()=>{document.querySelectorAll('[data-agent-tab]').forEach(v=>v.classList.toggle('active',v===tab));document.querySelectorAll('[data-agent-panel]').forEach(v=>v.hidden=v.dataset.agentPanel!==tab.dataset.agentTab)}); +document.querySelectorAll('[data-agent-list-tab]').forEach(tab=>tab.onclick=()=>{document.querySelectorAll('[data-agent-list-tab]').forEach(v=>v.classList.toggle('active',v===tab));document.querySelectorAll('[data-agent-list-panel]').forEach(v=>v.hidden=v.dataset.agentListPanel!==tab.dataset.agentListTab)}); el('agentList').onclick=event=>{const button=event.target.closest('[data-agent-detail]');if(!button)return;const agent=currentAgents.find(v=>v.id===button.dataset.agentDetail);if(agent)openAgentDetail(agent)}; +el('agentProblemList').onclick=event=>{const button=event.target.closest('[data-agent-detail]');if(!button)return;const agent=currentAgents.find(v=>v.id===button.dataset.agentDetail);if(agent)openAgentDetail(agent)}; 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=()=>{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}};