diff --git a/agent_actions.go b/agent_actions.go index 75de612..c75d8b8 100644 --- a/agent_actions.go +++ b/agent_actions.go @@ -8,7 +8,6 @@ import ( "io" "net" "net/http" - "os" "os/exec" "path/filepath" "regexp" @@ -72,7 +71,7 @@ func (s *Store) QueueAgentAction(agentID, action, target string) (AgentAction, e } } } - case "updates-refresh", "system-update": + case "updates-refresh", "system-update", "system-reboot": valid = target == "" } if !valid { @@ -172,6 +171,8 @@ func executeAgentCommand(command AgentCommand) AgentActionResult { output = removeAgentContainer(command.Target) case "system-update": output = updateAgentSystem() + case "system-reboot": + output = scheduleAgentReboot() case "updates-refresh": guestInventory.Lock() guestInventory.value.CollectedAt = time.Time{} @@ -210,25 +211,59 @@ func removeAgentContainer(target string) string { } func updateAgentSystem() string { - if output := runAgentCombinedCommand(3*time.Minute, "apt-get", "update"); strings.HasPrefix(output, "ERROR: ") { - return output - } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) - defer cancel() - command := exec.CommandContext(ctx, "apt-get", "-y", "upgrade") - command.Env = append(os.Environ(), "DEBIAN_FRONTEND=noninteractive") - output, err := command.CombinedOutput() - message := strings.TrimSpace(string(output)) - if len(message) > 3500 { - message = message[len(message)-3500:] - } + aptGet, err := exec.LookPath("apt-get") if err != nil { - return "ERROR: " + err.Error() + ": " + message + return "ERROR: apt-get не найден; обновление поддерживается для Debian/Ubuntu" + } + if output := runPrivilegedTransient(5*time.Minute, "update-lists", aptGet, "update"); strings.HasPrefix(output, "ERROR: ") { + return explainSystemUpdateError(output) + } + message := runPrivilegedTransient(30*time.Minute, "upgrade", aptGet, "-y", "upgrade") + if strings.HasPrefix(message, "ERROR: ") { + return explainSystemUpdateError(message) } guestInventory.Lock() guestInventory.value.CollectedAt = time.Time{} guestInventory.Unlock() - return firstNonEmpty(message, "Обновления системы установлены") + return "Обновления системы установлены; список пакетов будет обновлён в следующем отчёте агента" +} + +func explainSystemUpdateError(message string) string { + if strings.Contains(strings.ToLower(message), "read-only file system") { + return "ERROR: файловая система агента доступна только для чтения. Обновите Dashboard и агент до v0.19.2 или новее, затем повторите операцию." + } + if len(message) > 1800 { + message = message[len(message)-1800:] + } + return message +} + +func runPrivilegedTransient(timeout time.Duration, suffix, name string, args ...string) string { + systemdRun, err := exec.LookPath("systemd-run") + if err != nil { + return "ERROR: systemd-run не найден; невозможно безопасно выйти из read-only sandbox агента" + } + unit := fmt.Sprintf("proxmox-dashboard-%s-%d", suffix, time.Now().UnixNano()) + commandArgs := []string{"--quiet", "--wait", "--collect", "--pipe", "--unit=" + unit, "--setenv=DEBIAN_FRONTEND=noninteractive", name} + commandArgs = append(commandArgs, args...) + return runAgentCombinedCommand(timeout, systemdRun, commandArgs...) +} + +func scheduleAgentReboot() string { + systemdRun, err := exec.LookPath("systemd-run") + if err != nil { + return "ERROR: systemd-run не найден" + } + systemctl, err := exec.LookPath("systemctl") + if err != nil { + return "ERROR: systemctl не найден" + } + unit := fmt.Sprintf("proxmox-dashboard-reboot-%d", time.Now().UnixNano()) + output := runAgentCombinedCommand(15*time.Second, systemdRun, "--quiet", "--collect", "--unit="+unit, "--on-active=20s", systemctl, "reboot") + if strings.HasPrefix(output, "ERROR: ") { + return output + } + return "Перезагрузка запланирована через 20 секунд; кратковременный offline агента ожидаем" } func updateAgentContainer(target string, recreate bool) string { diff --git a/agent_actions_test.go b/agent_actions_test.go index d398a5d..ec1eebf 100644 --- a/agent_actions_test.go +++ b/agent_actions_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "strings" "testing" "time" ) @@ -42,6 +43,12 @@ func TestAgentActionAllowlistAndLifecycle(t *testing.T) { if err != nil || len(items) != 3 || items[0].Status != "completed" { t.Fatalf("items=%#v err=%v", items, err) } + if _, err = store.QueueAgentAction("a", "system-reboot", ""); err != nil { + t.Fatalf("system reboot rejected: %v", err) + } + if _, err = store.QueueAgentAction("a", "system-reboot", "now"); err == nil { + t.Fatal("system reboot with unexpected target accepted") + } } func TestManualServiceLinkGroupsOfflineAlerts(t *testing.T) { @@ -61,3 +68,10 @@ func TestManualServiceLinkGroupsOfflineAlerts(t *testing.T) { t.Fatalf("group alert missing: %#v", grouped) } } + +func TestExplainReadOnlySystemUpdateError(t *testing.T) { + message := explainSystemUpdateError("ERROR: apt failed: Read-only file system") + if !strings.Contains(message, "v0.19.2") { + t.Fatalf("read-only failure is not actionable: %s", message) + } +} diff --git a/web.go b/web.go index 934288c..27fe425 100644 --- a/web.go +++ b/web.go @@ -933,9 +933,9 @@ const renderAgentsBase=renderAgents;renderAgents=function(items){renderAgentsBas 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()} -let currentDetailAgent=null;const openAgentDetailBase=openAgentDetail;openAgentDetail=function(agent){currentDetailAgent=agent;el('agentActionStatus')?.remove();openAgentDetailBase(agent);const r=agent.report||{},processes=r.processes||[],oom=r.oomKills||[],summary='
Swap'+size(r.swapUsed||0)+' / '+size(r.swapTotal||0)+'
Процессы в D-state'+processes.filter(v=>String(v.state||'').startsWith('D')).length+'
OOM за 24 часа'+oom.length+'
';el('agentResourceDetails').insertAdjacentHTML('afterbegin',summary);addAgentActionButtons(agent);const actionStatus=el('agentActionStatus');actionStatus.style.margin='12px 24px';el('agentDetailDialog').querySelector('.system-tabs').after(actionStatus);loadAgentHistory(agent,24)} +let currentDetailAgent=null;const openAgentDetailBase=openAgentDetail;openAgentDetail=function(agent){currentDetailAgent=agent;el('agentActionStatus')?.remove();openAgentDetailBase(agent);const r=agent.report||{},processes=r.processes||[],oom=r.oomKills||[],summary='
Swap'+size(r.swapUsed||0)+' / '+size(r.swapTotal||0)+'
Процессы в D-state'+processes.filter(v=>String(v.state||'').startsWith('D')).length+'
OOM за 24 часа'+oom.length+'
';el('agentResourceDetails').insertAdjacentHTML('afterbegin',summary);addAgentActionButtons(agent);el('agentOverview').querySelector('.service-actions')?.insertAdjacentHTML('beforeend','');const actionStatus=el('agentActionStatus');actionStatus.style.margin='12px 24px';el('agentDetailDialog').querySelector('.system-tabs').after(actionStatus);loadAgentHistory(agent,24)} function addAgentActionButtons(agent){el('agentOverview').insertAdjacentHTML('beforeend','
');el('agentServiceDetails').querySelectorAll('.disk-item').forEach(article=>{const name=article.querySelector('.disk-model')?.textContent;if(!name)return;article.insertAdjacentHTML('beforeend','
')});el('agentDockerDetails').querySelectorAll('.disk-item').forEach((article,index)=>{const container=(agent.report?.containers||[])[index],name=article.querySelector('.disk-model')?.textContent;if(!name)return;const current=(container?.currentVersion||container?.image||'не определена')+(container?.currentDigest?' · '+container.currentDigest:''),available=(container?.availableVersion||container?.image||'не определена')+(container?.availableDigest?' · '+container.availableDigest:'');article.insertAdjacentHTML('beforeend','
Версия Docker-образа
Запущена'+safe(current)+'
Последняя проверенная'+safe(available)+'
'+(container?.updateAvailable?'Доступен новый образ':'Запущен текущий проверенный образ')+'
'+(container?.compose?'':'Обновление из панели доступно для Docker Compose')+(container?.state!=='running'?'':'')+'
')})} -async function queueAgentAction(action,target){const labels={'system-update':'установить обновления операционной системы','updates-refresh':'проверить обновления операционной системы','docker-check':'проверить и загрузить свежий Docker-образ','docker-update':'пересоздать Compose-контейнер на свежем образе','docker-restart':'перезапустить Docker-контейнер','docker-remove':'удалить остановленный Docker-контейнер без удаления volumes','systemd-restart':'перезапустить службу','systemd-reset-failed':'сбросить состояние failed'};if(!currentDetailAgent||!confirm('Выполнить действие «'+(labels[action]||action)+'» для '+(target||currentDetailAgent.name)+'?'))return;const status=el('agentActionStatus');status.hidden=false;status.className='settings-note';status.textContent='Отправляем команду агенту…';document.querySelectorAll('#agentDetailDialog [data-agent-action]').forEach(v=>v.disabled=true);const response=await fetch('/api/agent-actions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agentId:currentDetailAgent.id,action,target})});if(!response.ok){status.className='settings-note status-down';status.textContent=await response.text();document.querySelectorAll('#agentDetailDialog [data-agent-action]').forEach(v=>v.disabled=false);return}const queued=await response.json();status.textContent='Команда в очереди · агент проверяет команды каждые 3 секунды';await watchAgentAction(queued.id,status)} +async function queueAgentAction(action,target){const labels={'system-update':'установить обновления операционной системы','system-reboot':'перезагрузить операционную систему VM/LXC','updates-refresh':'проверить обновления операционной системы','docker-check':'проверить и загрузить свежий Docker-образ','docker-update':'пересоздать Compose-контейнер на свежем образе','docker-restart':'перезапустить Docker-контейнер','docker-remove':'удалить остановленный Docker-контейнер без удаления volumes','systemd-restart':'перезапустить службу','systemd-reset-failed':'сбросить состояние failed'},warning=action==='system-reboot'?'\n\nVM/LXC станет недоступна на время загрузки. Несохранённые данные приложений могут быть потеряны.':'';if(!currentDetailAgent||!confirm('Выполнить действие «'+(labels[action]||action)+'» для '+(target||currentDetailAgent.name)+'?'+warning))return;const status=el('agentActionStatus');status.hidden=false;status.className='settings-note';status.textContent='Отправляем команду агенту…';document.querySelectorAll('#agentDetailDialog [data-agent-action]').forEach(v=>v.disabled=true);const response=await fetch('/api/agent-actions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agentId:currentDetailAgent.id,action,target})});if(!response.ok){status.className='settings-note status-down';status.textContent=await response.text();document.querySelectorAll('#agentDetailDialog [data-agent-action]').forEach(v=>v.disabled=false);return}const queued=await response.json();status.textContent='Команда в очереди · агент проверяет команды каждые 3 секунды';await watchAgentAction(queued.id,status)} async function watchAgentAction(id,status){for(let attempt=0;attempt<180;attempt++){await new Promise(resolve=>setTimeout(resolve,1000));const response=await fetch('/api/agent-actions');if(!response.ok)continue;const items=await response.json()||[],item=items.find(v=>v.id===id);if(!item)continue;status.textContent=item.status==='queued'?'Ожидаем агента…':item.status==='running'?'Выполняется на агенте…':item.status==='completed'?'Готово: '+(item.result||'успешно'):'Ошибка: '+(item.result||'команда не выполнена');if(item.status==='completed'||item.status==='failed'){status.className='settings-note '+(item.status==='failed'?'status-down':'status-up');document.querySelectorAll('#agentDetailDialog [data-agent-action]').forEach(v=>v.disabled=false);await loadAgentActions();return}}status.className='settings-note status-down';status.textContent='Агент не вернул результат за 3 минуты. Проверьте его состояние в истории действий.';document.querySelectorAll('#agentDetailDialog [data-agent-action]').forEach(v=>v.disabled=false)} async function loadAgentHistory(agent,hours=24){const kinds=['agent-resource','agent-swap'],responses=await Promise.all(kinds.map(kind=>fetch('/api/entity-history?kind='+kind+'&hours='+hours).then(r=>r.json()))),key=agent.id+' · ',resource=(responses[0]||[]).find(v=>v.name.startsWith(key)),swap=(responses[1]||[]).find(v=>v.name.startsWith(key)),points=new Map();(resource?.points||[]).forEach(p=>points.set(p.timestamp,{timestamp:p.timestamp,cpu:p.value1,ram:p.value2}));(swap?.points||[]).forEach(p=>{const item=points.get(p.timestamp)||{timestamp:p.timestamp};item.swap=p.value1;points.set(p.timestamp,item)});chartConfigs.agentHistoryChart={series:[{label:'CPU',color:'#55d58a',value:p=>p.cpu,format:percent},{label:'RAM',color:'#9d8cff',value:p=>p.ram,format:percent},{label:'Swap',color:'#ff78b8',value:p=>p.swap,format:percent}]};const canvas=el('agentHistoryChart');drawChart('agentHistoryChart',[...points.values()].sort((a,b)=>a.timestamp-b.timestamp));if(!canvas.dataset.agentHover){setupAgentHistoryHover(canvas);canvas.dataset.agentHover='1'}} function setupAgentHistoryHover(canvas){canvas.addEventListener('mousemove',event=>{const state=canvas._chartState,tooltip=el('agentHistoryDetails').querySelector('.chart-tooltip');if(!state||!tooltip)return;const rect=canvas.getBoundingClientRect(),x=event.clientX-rect.left,index=Math.max(0,Math.min(state.points.length-1,Math.round((x-state.pad.l)/state.pw*(state.points.length-1)))),point=state.points[index];tooltip.innerHTML='
'+new Date(point.timestamp*1000).toLocaleString('ru-RU')+'
'+state.series.map(s=>'
'+s.label+''+(s.value(point)==null?'—':s.format(s.value(point)))+'
').join('');tooltip.classList.add('visible');tooltip.style.left=Math.min(x+12,canvas.clientWidth-tooltip.offsetWidth-10)+'px';tooltip.style.top='55px'});canvas.addEventListener('mouseleave',()=>el('agentHistoryDetails').querySelector('.chart-tooltip').classList.remove('visible'))}