Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2525b53f0 | ||
|
|
331f0ee39a |
@@ -342,6 +342,11 @@ APT-обновления и необходимость перезагрузки.
|
||||
health-check, число перезапусков, CPU и память. Агент сам подключается к Dashboard
|
||||
каждые 10 секунд; открывать входящий порт внутри гостевой системы не нужно.
|
||||
|
||||
В подробностях агента systemd-службы разделяются на системные, установленные
|
||||
дополнительно и локальные unit-файлы. Для каждой службы показываются пакет и путь
|
||||
unit-файла, время запуска, число перезапусков, последний exit code, память и
|
||||
накопленное CPU-время, когда соответствующий accounting доступен в systemd.
|
||||
|
||||
Откройте `Настройки → Агенты`, укажите адрес Dashboard, доступный из VM/LXC, и
|
||||
VMID. Кнопка создаст готовую команду установки. Одноразовый токен действует 15
|
||||
минут и после регистрации заменяется индивидуальным секретом агента.
|
||||
|
||||
166
agent.go
166
agent.go
@@ -58,10 +58,24 @@ type AgentInterface struct {
|
||||
}
|
||||
|
||||
type AgentService struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
SubState string `json:"subState"`
|
||||
Description string `json:"description"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
SubState string `json:"subState"`
|
||||
Description string `json:"description"`
|
||||
Origin string `json:"origin"`
|
||||
UnitPath string `json:"unitPath,omitempty"`
|
||||
Package string `json:"package,omitempty"`
|
||||
Restarts uint64 `json:"restarts"`
|
||||
ExitCode int `json:"exitCode"`
|
||||
StartedAt int64 `json:"startedAt,omitempty"`
|
||||
MemoryBytes uint64 `json:"memoryBytes,omitempty"`
|
||||
CPUSeconds float64 `json:"cpuSeconds,omitempty"`
|
||||
}
|
||||
|
||||
type AgentIssue struct {
|
||||
Time int64 `json:"time"`
|
||||
Unit string `json:"unit"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type AgentReport struct {
|
||||
@@ -84,6 +98,7 @@ type AgentReport struct {
|
||||
Containers []AgentContainer `json:"containers"`
|
||||
Interfaces []AgentInterface `json:"interfaces"`
|
||||
Services []AgentService `json:"services"`
|
||||
Issues []AgentIssue `json:"issues"`
|
||||
UpdatesAvailable int `json:"updatesAvailable"`
|
||||
RebootRequired bool `json:"rebootRequired"`
|
||||
InventoryAt int64 `json:"inventoryAt"`
|
||||
@@ -115,6 +130,12 @@ var guestInventory struct {
|
||||
value agentInventory
|
||||
}
|
||||
|
||||
var guestIssues struct {
|
||||
sync.Mutex
|
||||
value []AgentIssue
|
||||
collectedAt time.Time
|
||||
}
|
||||
|
||||
var agentBinaryInfo struct {
|
||||
sync.Once
|
||||
manifest AgentUpdateManifest
|
||||
@@ -507,6 +528,7 @@ func collectAgentReport() AgentReport {
|
||||
report.DockerAvailable = report.DockerError == ""
|
||||
inventory := cachedAgentInventory()
|
||||
report.OSName, report.Kernel, report.Services = inventory.OSName, inventory.Kernel, inventory.Services
|
||||
report.Issues = cachedAgentIssues()
|
||||
report.UpdatesAvailable, report.RebootRequired, report.InventoryAt = inventory.UpdatesAvailable, inventory.RebootRequired, inventory.CollectedAt.Unix()
|
||||
return report
|
||||
}
|
||||
@@ -554,6 +576,16 @@ func cachedAgentInventory() agentInventory {
|
||||
return value
|
||||
}
|
||||
|
||||
func cachedAgentIssues() []AgentIssue {
|
||||
guestIssues.Lock()
|
||||
defer guestIssues.Unlock()
|
||||
if time.Since(guestIssues.collectedAt) >= time.Minute {
|
||||
guestIssues.value = collectAgentIssues()
|
||||
guestIssues.collectedAt = time.Now()
|
||||
}
|
||||
return guestIssues.value
|
||||
}
|
||||
|
||||
func readOSPrettyName() string {
|
||||
data, err := os.ReadFile("/etc/os-release")
|
||||
if err != nil {
|
||||
@@ -571,7 +603,7 @@ func runAgentCommand(timeout time.Duration, name string, args ...string) string
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
output, err := exec.CommandContext(ctx, name, args...).Output()
|
||||
if err != nil {
|
||||
if err != nil && len(output) == 0 {
|
||||
return ""
|
||||
}
|
||||
return string(output)
|
||||
@@ -594,10 +626,132 @@ func collectAgentServices() []AgentService {
|
||||
description = strings.Join(fields[offset+4:], " ")
|
||||
}
|
||||
result = append(result, AgentService{Name: fields[offset], State: fields[offset+2], SubState: fields[offset+3], Description: description})
|
||||
if len(result) >= 150 {
|
||||
if len(result) >= 300 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return enrichAgentServices(result)
|
||||
}
|
||||
|
||||
func enrichAgentServices(services []AgentService) []AgentService {
|
||||
if len(services) == 0 {
|
||||
return services
|
||||
}
|
||||
args := []string{"show", "--no-pager", "--property=Id", "--property=FragmentPath", "--property=NRestarts", "--property=ExecMainStatus", "--property=ActiveEnterTimestampUSec", "--property=MemoryCurrent", "--property=CPUUsageNSec"}
|
||||
for _, service := range services {
|
||||
args = append(args, service.Name)
|
||||
}
|
||||
show := runAgentCommand(8*time.Second, "systemctl", args...)
|
||||
properties := map[string]map[string]string{}
|
||||
paths := map[string]string{}
|
||||
for _, record := range strings.Split(show, "\n\n") {
|
||||
values := map[string]string{}
|
||||
for _, line := range strings.Split(record, "\n") {
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if ok {
|
||||
values[key] = value
|
||||
}
|
||||
}
|
||||
if id := values["Id"]; id != "" {
|
||||
properties[id] = values
|
||||
paths[id] = values["FragmentPath"]
|
||||
}
|
||||
}
|
||||
pathArgs := []string{"-S"}
|
||||
for _, service := range services {
|
||||
if path := paths[service.Name]; path != "" {
|
||||
pathArgs = append(pathArgs, path)
|
||||
}
|
||||
}
|
||||
owners := map[string]string{}
|
||||
packages := []string{}
|
||||
if len(pathArgs) > 1 {
|
||||
output := runAgentCommand(8*time.Second, "dpkg-query", pathArgs...)
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
left, path, ok := strings.Cut(line, ": ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pkg := strings.TrimSpace(strings.Split(left, ",")[0])
|
||||
path = strings.TrimSpace(path)
|
||||
if pkg != "" && path != "" {
|
||||
owners[path] = pkg
|
||||
packages = append(packages, pkg)
|
||||
}
|
||||
}
|
||||
}
|
||||
priorities := map[string]string{}
|
||||
if len(packages) > 0 {
|
||||
packageArgs := []string{"-W", "-f=${binary:Package}\t${Priority}\n"}
|
||||
packageArgs = append(packageArgs, packages...)
|
||||
output := runAgentCommand(8*time.Second, "dpkg-query", packageArgs...)
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
priorities[fields[0]] = fields[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range services {
|
||||
service := &services[i]
|
||||
service.UnitPath = paths[service.Name]
|
||||
service.Package = owners[service.UnitPath]
|
||||
service.Origin = classifyAgentService(service.UnitPath, service.Package, priorities[service.Package])
|
||||
values := properties[service.Name]
|
||||
service.Restarts, _ = strconv.ParseUint(values["NRestarts"], 10, 64)
|
||||
exit, _ := strconv.ParseInt(values["ExecMainStatus"], 10, 32)
|
||||
service.ExitCode = int(exit)
|
||||
started, _ := strconv.ParseInt(values["ActiveEnterTimestampUSec"], 10, 64)
|
||||
service.StartedAt = started / 1_000_000
|
||||
service.MemoryBytes, _ = strconv.ParseUint(values["MemoryCurrent"], 10, 64)
|
||||
cpu, _ := strconv.ParseUint(values["CPUUsageNSec"], 10, 64)
|
||||
service.CPUSeconds = float64(cpu) / 1_000_000_000
|
||||
}
|
||||
return services
|
||||
}
|
||||
|
||||
func classifyAgentService(path, pkg, priority string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/etc/systemd/") || strings.HasPrefix(path, "/usr/local/"):
|
||||
return "local"
|
||||
case priority == "required" || priority == "important" || priority == "standard":
|
||||
return "system"
|
||||
case pkg != "":
|
||||
return "added"
|
||||
case strings.HasPrefix(path, "/run/systemd/"):
|
||||
return "runtime"
|
||||
default:
|
||||
return "system"
|
||||
}
|
||||
}
|
||||
|
||||
func collectAgentIssues() []AgentIssue {
|
||||
output := runAgentCommand(8*time.Second, "journalctl", "-p", "err..alert", "--since", "24 hours ago", "--reverse", "-n", "50", "--no-pager", "-o", "json")
|
||||
return parseAgentIssues(output)
|
||||
}
|
||||
|
||||
func parseAgentIssues(output string) []AgentIssue {
|
||||
result := []AgentIssue{}
|
||||
for _, line := range strings.Split(output, "\n") {
|
||||
var entry struct {
|
||||
Message any `json:"MESSAGE"`
|
||||
Unit string `json:"_SYSTEMD_UNIT"`
|
||||
Timestamp string `json:"__REALTIME_TIMESTAMP"`
|
||||
}
|
||||
if json.Unmarshal([]byte(line), &entry) != nil {
|
||||
continue
|
||||
}
|
||||
message, ok := entry.Message.(string)
|
||||
if !ok || strings.TrimSpace(message) == "" {
|
||||
continue
|
||||
}
|
||||
microseconds, _ := strconv.ParseInt(entry.Timestamp, 10, 64)
|
||||
unit := entry.Unit
|
||||
if unit == "" {
|
||||
unit = "system"
|
||||
}
|
||||
result = append(result, AgentIssue{Time: microseconds / 1_000_000, Unit: unit, Message: message})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,22 @@ func TestAgentOnlyInstallsNewerVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAgentIssues(t *testing.T) {
|
||||
issues := parseAgentIssues(`{"MESSAGE":"disk error","_SYSTEMD_UNIT":"worker.service","__REALTIME_TIMESTAMP":"1722960000000000"}` + "\n" + `{"MESSAGE":"network error","__REALTIME_TIMESTAMP":"1722960001000000"}`)
|
||||
if len(issues) != 2 || issues[0].Unit != "worker.service" || issues[1].Unit != "system" || issues[0].Time != 1722960000 {
|
||||
t.Fatalf("unexpected issues: %#v", issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyAgentService(t *testing.T) {
|
||||
cases := []struct{ path, pkg, priority, want string }{{"/lib/systemd/system/cron.service", "cron", "important", "system"}, {"/lib/systemd/system/jellyfin.service", "jellyfin", "optional", "added"}, {"/etc/systemd/system/my.service", "", "", "local"}, {"/run/systemd/system/transient.service", "", "", "runtime"}}
|
||||
for _, tc := range cases {
|
||||
if got := classifyAgentService(tc.path, tc.pkg, tc.priority); got != tc.want {
|
||||
t.Errorf("%s: got %s want %s", tc.path, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentReportAuthenticationAndOfflineState(t *testing.T) {
|
||||
store, err := openStore(":memory:")
|
||||
if err != nil {
|
||||
|
||||
16
web.go
16
web.go
@@ -517,6 +517,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
|
||||
.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}}
|
||||
.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}}
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
@@ -730,6 +731,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
|
||||
<dialog id="tasksDialog"><div class="modal-head"><div><div class="label">Proxmox activity</div><h2>События и задачи</h2></div><button class="close" aria-label="Закрыть">×</button></div><div class="task-filter"><button class="detail-tab active" data-task-filter="all">Все</button><button class="detail-tab" data-task-filter="vzdump">Backup</button><button class="detail-tab" data-task-filter="qm">VM/LXC</button><button class="detail-tab" data-task-filter="error">Ошибки</button></div><div class="disk-list" id="taskList"><span class="empty">Получение задач…</span></div></dialog>
|
||||
<dialog id="mapDialog"><div class="modal-head"><div><div class="label">Homelab topology</div><h2>Карта homelab</h2></div><button class="close" aria-label="Закрыть">×</button></div><div class="map-scroll"><div class="topology-board" id="mapBoard"></div></div><div class="wide settings-note">Порт от локального адреса отбрасывается. Полученный IP сравнивается с адресами VM/LXC из Proxmox. Storage берётся из конфигурации каждой VM и LXC.</div></dialog>
|
||||
<dialog id="agentsDialog"><div class="modal-head"><div><div class="label">Guest telemetry</div><h2>Агенты VM и LXC</h2></div><button class="close" aria-label="Закрыть">×</button></div><div class="disk-list" id="agentList"><span class="empty">Подключённые агенты появятся здесь.</span></div></dialog>
|
||||
<dialog id="agentDetailDialog"><div class="modal-head"><div><div class="label">Guest details</div><h2 id="agentDetailTitle">Агент</h2><div class="disk-meta" id="agentDetailMeta"></div></div><button class="close" aria-label="Закрыть">×</button></div><div class="system-tabs"><button class="detail-tab active" data-agent-tab="overview">Обзор</button><button class="detail-tab" data-agent-tab="services">Службы</button><button class="detail-tab" data-agent-tab="issues">Ошибки</button><button class="detail-tab" data-agent-tab="docker">Docker</button></div><div class="agent-detail-panel" data-agent-panel="overview" id="agentOverview"></div><div class="agent-detail-panel" data-agent-panel="services" id="agentServiceDetails" hidden></div><div class="agent-detail-panel" data-agent-panel="issues" id="agentIssueDetails" hidden></div><div class="agent-detail-panel" data-agent-panel="docker" id="agentDockerDetails" hidden></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>
|
||||
@@ -838,12 +840,12 @@ function renderActivity(activity,guests,services,zfs){const snapshots=activity.s
|
||||
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 '<span class="topology-chip '+(healthy?'good':'bad')+'"'+(!healthy?' tabindex="0" data-reason="Один или несколько адресов сервиса недоступны"':'')+'>'+safe(s.name)+'</span>'},storageChip=name=>{const pool=pools.find(p=>p.name===name),healthy=!pool||pool.health==='ONLINE';return '<span class="topology-chip '+(healthy?'good':'bad')+'">'+safe(name)+(pool?' · '+fixed(pool.capacityPercent)+'%':'')+'</span>'};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 '<section class="topology-group"><div class="topology-group-head"><div><strong>'+safe(g.name||('VMID '+g.vmid))+'</strong><div class="disk-meta">VMID '+g.vmid+' · '+safe(g.type==='qemu'?'VM':'LXC')+' · '+safe((g.ipAddresses||[]).join(', ')||'IP не получен')+'</div></div><span class="badge '+(running?'':'warn')+'"'+(!running?' tabindex="0" data-reason="VM/LXC сейчас остановлена"':'')+'>'+(running?'Работает':'Остановлена')+'</span></div><div class="topology-section"><div class="topology-section-title">Сервисы</div><div class="topology-items">'+(owned.length?owned.map(serviceChip).join(''):'<span class="empty">Не сопоставлены</span>')+'</div></div><div class="topology-section"><div class="topology-section-title">Storage</div><div class="topology-items">'+(storage.length?storage.map(storageChip).join(''):'<span class="empty">Не определён</span>')+'</div></div></section>'}).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='<div class="topology-host map-node good"><strong>Proxmox host</strong><small>'+safe(location.hostname)+' · '+guestItems.length+' VM/LXC</small></div><div class="topology-grid">'+groups+'</div>'+((unassigned.length||unassignedPools.length)?'<section class="topology-group topology-unassigned"><div class="topology-group-head"><strong>Без автоматической привязки</strong><span class="badge warn" tabindex="0" data-reason="Для сервиса не найден совпадающий IP VM/LXC либо storage не используется в конфигурации VM/LXC">Проверьте связи</span></div><div class="topology-section"><div class="topology-section-title">Сервисы</div><div class="topology-items">'+unassigned.map(serviceChip).join('')+'</div></div><div class="topology-section"><div class="topology-section-title">Остальные ZFS-пулы</div><div class="topology-items">'+unassignedPools.map(p=>storageChip(p.name)).join('')+'</div></div></section>':'');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 agentHTML(a,settings=false){
|
||||
const r=a.report||{},containers=r.containers||[],services=r.services||[],failed=services.filter(v=>v.state==='failed'||v.subState==='failed'),runningServices=services.filter(v=>v.state!=='failed'&&v.subState!=='failed'),visibleServices=[...failed,...runningServices.slice(0,12)],interfaces=r.interfaces||[],memoryPercent=r.memoryTotal?r.memoryUsed/r.memoryTotal*100:0,diskPercent=r.rootTotal?r.rootUsed/r.rootTotal*100:0;
|
||||
const docker=containers.length?containers.map(v=>'<span class="topology-chip '+(v.state==='running'&&v.health!=='unhealthy'?'good':'bad')+'">'+safe(v.name)+' · '+safe(v.health||v.state)+' · CPU '+fixed(v.cpuPercent||0)+'% · RAM '+size(v.memoryUsage||0)+' · рестартов '+v.restarts+'</span>').join(''):'<span class="empty">Docker-контейнеры не обнаружены</span>';
|
||||
const hiddenServices=Math.max(0,services.length-visibleServices.length),systemd=services.length?visibleServices.map(v=>'<span class="topology-chip '+(v.state==='failed'||v.subState==='failed'?'bad':'good')+'" title="'+safe(v.description||'')+'">'+safe(v.name)+' · '+safe(v.subState)+'</span>').join('')+(hiddenServices?'<span class="topology-chip">ещё '+hiddenServices+' работают</span>':''):'<span class="empty">systemd-службы не получены</span>';
|
||||
return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(a.name||a.hostname)+'</div><div class="disk-meta">'+safe(a.hostname)+' · VMID '+(a.vmid||'не указан')+' · '+safe(r.osName||r.os||'Linux')+' · '+safe(r.kernel||'ядро не определено')+' · агент '+safe(a.version||'—')+'</div></div><span class="badge '+(a.online?'':'bad')+'">'+(a.online?'Онлайн':'Не отвечает')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>CPU / Load</span><b>'+fixed(r.cpuPercent||0)+'% / '+fixed(r.load1||0)+'</b></div><div class="disk-stat"><span>Память</span><b>'+size(r.memoryUsed)+' · '+fixed(memoryPercent)+'%</b></div><div class="disk-stat"><span>Раздел /</span><b>'+size(r.rootUsed)+' · '+fixed(diskPercent)+'%</b></div><div class="disk-stat"><span>Uptime</span><b>'+uptime(r.uptimeSeconds||0)+'</b></div><div class="disk-stat"><span>Обновления</span><b>'+(r.updatesAvailable||0)+' пакетов'+(r.rebootRequired?' · нужен reboot':'')+'</b></div><div class="disk-stat"><span>Службы</span><b>'+services.length+' · ошибок '+failed.length+'</b></div></div><div class="topology-section"><div class="topology-section-title">IP-адреса и интерфейсы</div><div class="topology-items">'+(interfaces.length?interfaces.map(v=>'<span class="topology-chip">'+safe(v.name)+' · '+safe((v.addresses||[]).join(', '))+'</span>').join(''):'<span class="empty">IP-адреса не найдены</span>')+'</div></div><div class="topology-section"><div class="topology-section-title">Docker</div><div class="agent-containers">'+docker+'</div></div><div class="topology-section"><div class="topology-section-title">Systemd · работают и упали</div><div class="agent-containers">'+systemd+'</div></div>'+(settings?'<div class="service-actions"><button data-agent-delete="'+safe(a.id)+'">Удалить агент</button></div>':'')+'</article>'
|
||||
const r=a.report||{},containers=r.containers||[],services=r.services||[],issues=r.issues||[],failed=services.filter(v=>v.state==='failed'||v.subState==='failed'),interfaces=r.interfaces||[],memoryPercent=r.memoryTotal?r.memoryUsed/r.memoryTotal*100:0,diskPercent=r.rootTotal?r.rootUsed/r.rootTotal*100:0;
|
||||
return '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(a.name||a.hostname)+'</div><div class="disk-meta">'+safe(a.hostname)+' · VMID '+(a.vmid||'не указан')+' · '+safe(r.osName||r.os||'Linux')+' · '+safe(r.kernel||'ядро не определено')+' · агент '+safe(a.version||'—')+'</div></div><span class="badge '+(a.online?'':'bad')+'">'+(a.online?'Онлайн':'Не отвечает')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>CPU / Load</span><b>'+fixed(r.cpuPercent||0)+'% / '+fixed(r.load1||0)+'</b></div><div class="disk-stat"><span>Память</span><b>'+size(r.memoryUsed)+' · '+fixed(memoryPercent)+'%</b></div><div class="disk-stat"><span>Раздел /</span><b>'+size(r.rootUsed)+' · '+fixed(diskPercent)+'%</b></div><div class="disk-stat"><span>Uptime</span><b>'+uptime(r.uptimeSeconds||0)+'</b></div><div class="disk-stat"><span>Обновления</span><b>'+(r.updatesAvailable||0)+' пакетов'+(r.rebootRequired?' · нужен reboot':'')+'</b></div><div class="disk-stat"><span>Проблемы</span><b>'+failed.length+' служб · '+issues.length+' ошибок</b></div></div><div class="topology-section"><div class="topology-section-title">IP-адреса и интерфейсы</div><div class="topology-items">'+(interfaces.length?interfaces.map(v=>'<span class="topology-chip">'+safe(v.name)+' · '+safe((v.addresses||[]).join(', '))+'</span>').join(''):'<span class="empty">IP-адреса не найдены</span>')+'</div></div><div class="service-actions"><button data-agent-detail="'+safe(a.id)+'">Подробнее</button>'+(settings?'<button data-agent-delete="'+safe(a.id)+'">Удалить агент</button>':'')+'</div></article>'
|
||||
}
|
||||
function renderAgents(items){const agents=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(''):'<span class="empty">Агенты пока не подключены. Создайте команду установки в разделе «Управление».</span>';el('agentSettingsList').innerHTML=agents.length?agents.map(v=>agentHTML(v,true)).join(''):'<span class="empty">Агентов пока нет.</span>'}
|
||||
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(''):'<span class="empty">Агенты пока не подключены. Создайте команду установки в разделе «Управление».</span>';el('agentSettingsList').innerHTML=agents.length?agents.map(v=>agentHTML(v,true)).join(''):'<span class="empty">Агентов пока нет.</span>'}
|
||||
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='<div class="agent-filter"><button data-agent-service-filter="all" class="'+(filter==='all'?'active':'')+'">Все · '+services.length+'</button><button data-agent-service-filter="system" class="'+(filter==='system'?'active':'')+'">Системные · '+services.filter(v=>v.origin==='system').length+'</button><button data-agent-service-filter="custom" class="'+(filter==='custom'?'active':'')+'">Добавленные · '+services.filter(custom).length+'</button><button data-agent-service-filter="failed" class="'+(filter==='failed'?'active':'')+'">Упали · '+services.filter(failed).length+'</button></div><div class="disk-list" style="padding:0">'+(filtered.length?filtered.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name)+'</div><div class="disk-meta">'+safe(v.description||'Описание отсутствует')+'</div><div class="disk-meta">'+originLabel(v)+(v.package?' · пакет '+safe(v.package):'')+(v.unitPath?' · '+safe(v.unitPath):'')+'</div></div><span class="badge '+(failed(v)?'bad':'')+'">'+safe(v.subState||v.state)+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Запущена</span><b>'+(v.startedAt?new Date(v.startedAt*1000).toLocaleString('ru-RU'):'—')+'</b></div><div class="disk-stat"><span>Рестарты</span><b>'+(v.restarts||0)+'</b></div><div class="disk-stat"><span>Exit code</span><b>'+(v.exitCode||0)+'</b></div><div class="disk-stat"><span>Память</span><b>'+(v.memoryBytes?size(v.memoryBytes):'Нет accounting')+'</b></div><div class="disk-stat"><span>CPU time</span><b>'+(v.cpuSeconds?fixed(v.cpuSeconds)+' сек.':'Нет accounting')+'</b></div></div></article>').join(''):'<span class="empty">Служб в этой группе нет.</span>')+'</div>';el('agentServiceDetails').querySelectorAll('[data-agent-service-filter]').forEach(button=>button.onclick=()=>renderAgentServices(services,button.dataset.agentServiceFilter))}
|
||||
function openAgentDetail(agent){const r=agent.report||{},services=r.services||[],issues=r.issues||[],containers=r.containers||[],interfaces=r.interfaces||[],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='<div class="agent-detail-summary"><div><span class="label">Состояние</span><b>'+(agent.online?'Онлайн':'Не отвечает')+'</b></div><div><span class="label">CPU / Load</span><b>'+fixed(r.cpuPercent||0)+'% / '+fixed(r.load1||0)+'</b></div><div><span class="label">Память</span><b>'+size(r.memoryUsed)+' · '+fixed(memoryPercent)+'%</b></div><div><span class="label">Раздел /</span><b>'+size(r.rootUsed)+' · '+fixed(diskPercent)+'%</b></div><div><span class="label">Uptime</span><b>'+uptime(r.uptimeSeconds||0)+'</b></div><div><span class="label">Обновления</span><b>'+(r.updatesAvailable||0)+' пакетов'+(r.rebootRequired?' · нужен reboot':'')+'</b></div></div><div class="topology-section"><div class="topology-section-title">IP-адреса</div><div class="topology-items">'+(interfaces.length?interfaces.map(v=>'<span class="topology-chip">'+safe(v.name)+' · '+safe((v.addresses||[]).join(', '))+'</span>').join(''):'<span class="empty">Не найдены</span>')+'</div></div><div class="settings-note">Последний heartbeat: '+new Date(agent.lastSeen*1000).toLocaleString('ru-RU')+' · системный инвентарь: '+(r.inventoryAt?new Date(r.inventoryAt*1000).toLocaleString('ru-RU'):'ещё не собран')+'</div>';renderAgentServices(services);el('agentIssueDetails').innerHTML='<div class="disk-list" style="padding:0">'+(issues.length?issues.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.unit||'system')+'</div><div class="disk-meta">'+(v.time?new Date(v.time*1000).toLocaleString('ru-RU'):'Время не указано')+'</div></div><span class="badge bad">Ошибка</span></div><div class="settings-note issue-message">'+safe(v.message)+'</div></article>').join(''):'<span class="empty">Ошибок уровня err и выше за 24 часа нет.</span>')+'</div>';el('agentDockerDetails').innerHTML='<div class="disk-list" style="padding:0">'+(containers.length?containers.map(v=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.name)+'</div><div class="disk-meta">'+safe(v.image)+' · '+safe(v.id)+'</div></div><span class="badge '+(v.state==='running'&&v.health!=='unhealthy'?'':'bad')+'">'+safe(v.health||v.state)+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Статус</span><b>'+safe(v.status)+'</b></div><div class="disk-stat"><span>CPU</span><b>'+fixed(v.cpuPercent||0)+'%</b></div><div class="disk-stat"><span>Память</span><b>'+size(v.memoryUsage||0)+'</b></div><div class="disk-stat"><span>Рестарты</span><b>'+v.restarts+'</b></div><div class="disk-stat"><span>Порты</span><b>'+safe((v.ports||[]).join(', ')||'—')+'</b></div></div></article>').join(''):'<span class="empty">Docker-контейнеры не обнаружены.</span>')+'</div>';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()}
|
||||
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;
|
||||
lastDashboardEvent=Date.now();
|
||||
maintenanceGuests=(g.guests||[]).filter(v=>!v.template);applyMaintenanceState(x.maintenanceWindow||{});
|
||||
@@ -889,6 +891,8 @@ 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('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)});
|
||||
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('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=>'<a class="theme-option" href="'+safe(v.address)+'" target="_blank" rel="noopener">'+safe(v.label)+'<small style="display:block;color:var(--muted);margin-top:4px">'+safe(v.address)+'</small></a>').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}};
|
||||
@@ -907,7 +911,7 @@ function selectManagement(tab){document.querySelectorAll('[data-management-tab]'
|
||||
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)});
|
||||
el('agentServerURL').value=location.origin;
|
||||
el('createAgentToken').onclick=async()=>{const button=el('createAgentToken'),server=el('agentServerURL').value.trim().replace(/\/$/,''),vmid=Number(el('agentVMID').value)||0;if(!/^https?:\/\//.test(server)){alert('Укажите полный адрес Dashboard, доступный из VM/LXC.');return}button.disabled=true;button.textContent='Создаём токен…';try{const response=await fetch('/api/agents/enrollment-token',{method:'POST'});if(!response.ok)throw new Error(await response.text());const value=await response.json(),quote=v=>"'"+String(v).replaceAll("'","'\"'\"'")+"'";el('agentInstallCommand').textContent='curl -fsSL https://git.myown.center/maxim/ProxmoxDash/raw/branch/main/scripts/install-agent.sh | DASHBOARD_URL='+quote(server)+' ENROLL_TOKEN='+quote(value.token)+' VMID='+quote(vmid)+' sh';el('agentInstallBlock').hidden=false}catch(e){alert('Не удалось создать токен: '+e.message)}finally{button.disabled=false;button.textContent='Создать команду установки или обновления'}};
|
||||
el('agentSettingsList').onclick=async event=>{const button=event.target.closest('[data-agent-delete]');if(!button||!confirm('Удалить агент? Для повторного подключения потребуется новый токен.'))return;button.disabled=true;const response=await fetch('/api/agents?id='+encodeURIComponent(button.dataset.agentDelete),{method:'DELETE'});if(response.ok)button.closest('.disk-item').remove();else{button.disabled=false;alert(await response.text())}};
|
||||
el('agentSettingsList').onclick=async event=>{const detail=event.target.closest('[data-agent-detail]');if(detail){const agent=currentAgents.find(v=>v.id===detail.dataset.agentDetail);if(agent)openAgentDetail(agent);return}const button=event.target.closest('[data-agent-delete]');if(!button||!confirm('Удалить агент? Для повторного подключения потребуется новый токен.'))return;button.disabled=true;const response=await fetch('/api/agents?id='+encodeURIComponent(button.dataset.agentDelete),{method:'DELETE'});if(response.ok)button.closest('.disk-item').remove();else{button.disabled=false;alert(await response.text())}};
|
||||
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]??'')}
|
||||
el('saveThresholds').onclick=async()=>{const values={};document.querySelectorAll('[data-threshold]').forEach(input=>values[input.dataset.threshold]=input.dataset.threshold==='diskIoExcluded'?input.value:Number(input.value));const status=el('thresholdStatus');status.textContent='Сохранение…';const response=await fetch('/api/settings/alerts',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(values)});if(response.ok){alertSettings=await response.json();status.textContent='Пороги сохранены и уже применяются.'}else{status.textContent=await response.text()||'Не удалось сохранить настройки.'}};loadThresholds().catch(()=>{});
|
||||
let configuredServices=[];
|
||||
|
||||
Reference in New Issue
Block a user