From 21b4f528e0829539ec5eb27a21f83bed36eb4686 Mon Sep 17 00:00:00 2001 From: Maxim Date: Fri, 7 Aug 2026 22:24:44 +0300 Subject: [PATCH] Add agent operations history and manual links --- README.md | 11 +++ agent.go | 92 +++++++++++------- agent_actions.go | 210 ++++++++++++++++++++++++++++++++++++++++++ agent_actions_test.go | 54 +++++++++++ alert_events.go | 14 ++- history.go | 24 +++++ service_links.go | 110 ++++++++++++++++++++++ store.go | 3 + web.go | 90 ++++++++++++++++-- 9 files changed, 565 insertions(+), 43 deletions(-) create mode 100644 agent_actions.go create mode 100644 agent_actions_test.go create mode 100644 service_links.go diff --git a/README.md b/README.md index e188f63..d211ceb 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,17 @@ CPU и RAM с накопленным I/O, swap, PSI pressure, все посто Связи по PID и порту считаются подтверждёнными; совпадения только по названию помечаются как предположение. +Для сложных reverse proxy и Docker-сетей в управлении сервисами можно сохранить +ручную связь `сервис → агент/VMID → systemd/Docker/процесс → порт`. Она имеет +приоритет над автоматическим определением и используется для группировки +связанных уведомлений по первопричине. + +Dashboard хранит семь дней истории CPU, RAM, swap, PSI и файловых систем каждого +агента. Из подробностей агента доступны только заранее разрешённые действия: +перезапуск известной systemd-службы или Docker-контейнера, `reset-failed` и +обновление списка пакетов. Агент не принимает произвольные shell-команды; каждое +действие требует подтверждения в браузере и сохраняется в журнале управления. + Откройте `Настройки → Агенты`, укажите адрес Dashboard, доступный из VM/LXC, и VMID. Кнопка создаст готовую команду установки. Одноразовый токен действует 15 минут и после регистрации заменяется индивидуальным секретом агента. diff --git a/agent.go b/agent.go index c6c5498..380206e 100644 --- a/agent.go +++ b/agent.go @@ -86,37 +86,38 @@ type AgentIssue struct { } type AgentReport struct { - Hostname string `json:"hostname"` - MachineID string `json:"machineId"` - Version string `json:"version"` - OS string `json:"os"` - Arch string `json:"arch"` - OSName string `json:"osName"` - Kernel string `json:"kernel"` - UptimeSeconds int64 `json:"uptimeSeconds"` - Load1 float64 `json:"load1"` - CPUPercent float64 `json:"cpuPercent"` - MemoryTotal uint64 `json:"memoryTotal"` - MemoryUsed uint64 `json:"memoryUsed"` - SwapTotal uint64 `json:"swapTotal"` - SwapUsed uint64 `json:"swapUsed"` - RootTotal uint64 `json:"rootTotal"` - RootUsed uint64 `json:"rootUsed"` - DockerAvailable bool `json:"dockerAvailable"` - DockerError string `json:"dockerError,omitempty"` - Containers []AgentContainer `json:"containers"` - Interfaces []AgentInterface `json:"interfaces"` - Services []AgentService `json:"services"` - Issues []AgentIssue `json:"issues"` - Processes []AgentProcess `json:"processes"` - Filesystems []AgentFilesystem `json:"filesystems"` - ListenPorts []AgentListenPort `json:"listenPorts"` - Pressure AgentPressure `json:"pressure"` - OOMKills []AgentLog `json:"oomKills"` - UpdatesAvailable int `json:"updatesAvailable"` - RebootRequired bool `json:"rebootRequired"` - InventoryAt int64 `json:"inventoryAt"` - CollectedAt int64 `json:"collectedAt"` + Hostname string `json:"hostname"` + MachineID string `json:"machineId"` + Version string `json:"version"` + OS string `json:"os"` + Arch string `json:"arch"` + OSName string `json:"osName"` + Kernel string `json:"kernel"` + UptimeSeconds int64 `json:"uptimeSeconds"` + Load1 float64 `json:"load1"` + CPUPercent float64 `json:"cpuPercent"` + MemoryTotal uint64 `json:"memoryTotal"` + MemoryUsed uint64 `json:"memoryUsed"` + SwapTotal uint64 `json:"swapTotal"` + SwapUsed uint64 `json:"swapUsed"` + RootTotal uint64 `json:"rootTotal"` + RootUsed uint64 `json:"rootUsed"` + DockerAvailable bool `json:"dockerAvailable"` + DockerError string `json:"dockerError,omitempty"` + Containers []AgentContainer `json:"containers"` + Interfaces []AgentInterface `json:"interfaces"` + Services []AgentService `json:"services"` + Issues []AgentIssue `json:"issues"` + Processes []AgentProcess `json:"processes"` + Filesystems []AgentFilesystem `json:"filesystems"` + ListenPorts []AgentListenPort `json:"listenPorts"` + Pressure AgentPressure `json:"pressure"` + OOMKills []AgentLog `json:"oomKills"` + UpdatesAvailable int `json:"updatesAvailable"` + RebootRequired bool `json:"rebootRequired"` + InventoryAt int64 `json:"inventoryAt"` + CollectedAt int64 `json:"collectedAt"` + ActionResults []AgentActionResult `json:"actionResults,omitempty"` } type agentInventory struct { @@ -137,6 +138,7 @@ type AgentUpdateManifest struct { type agentReportResponse struct { Accepted bool `json:"accepted"` Update *AgentUpdateManifest `json:"update,omitempty"` + Commands []AgentCommand `json:"commands,omitempty"` } var guestInventory struct { @@ -150,6 +152,11 @@ var guestIssues struct { collectedAt time.Time } +var guestActionResults struct { + sync.Mutex + value []AgentActionResult +} + var agentBinaryInfo struct { sync.Once manifest AgentUpdateManifest @@ -258,6 +265,8 @@ func (s *Store) SaveAgentReport(id, secret, remote string, report AgentReport) e if !s.AuthenticateAgent(id, secret) { return errors.New("агент не авторизован") } + s.CompleteAgentActions(id, report.ActionResults) + report.ActionResults = nil data, _ := json.Marshal(report) _, err := s.db.Exec(`UPDATE agents SET hostname=?,version=?,last_seen=?,report_json=?,remote_address=? WHERE id=?`, report.Hostname, report.Version, time.Now().Unix(), string(data), remote, id) return err @@ -325,6 +334,9 @@ func runGuestAgent(opts agentOptions) error { client := &http.Client{Timeout: 12 * time.Second} for { report := collectAgentReport() + guestActionResults.Lock() + report.ActionResults = append([]AgentActionResult(nil), guestActionResults.value...) + guestActionResults.Unlock() data, _ := json.Marshal(report) req, _ := http.NewRequest(http.MethodPost, config.Server+"/api/agent/report", bytes.NewReader(data)) req.Header.Set("Content-Type", "application/json") @@ -351,7 +363,23 @@ func runGuestAgent(opts agentOptions) error { } if response.StatusCode == http.StatusOK { var result agentReportResponse - if json.Unmarshal(responseData, &result) == nil && result.Update != nil && newerAgentVersion(result.Update.Version, version) { + if json.Unmarshal(responseData, &result) == nil { + if len(report.ActionResults) > 0 { + guestActionResults.Lock() + guestActionResults.value = nil + guestActionResults.Unlock() + } + if len(result.Commands) > 0 { + executed := make([]AgentActionResult, 0, len(result.Commands)) + for _, command := range result.Commands { + executed = append(executed, executeAgentCommand(command)) + } + guestActionResults.Lock() + guestActionResults.value = append(guestActionResults.value, executed...) + guestActionResults.Unlock() + } + } + if result.Update != nil && newerAgentVersion(result.Update.Version, version) { if updateErr := applyAgentUpdate(client, config, *result.Update); updateErr != nil { requestErr = fmt.Errorf("автообновление: %v", updateErr) } else { diff --git a/agent_actions.go b/agent_actions.go new file mode 100644 index 0000000..4a611fe --- /dev/null +++ b/agent_actions.go @@ -0,0 +1,210 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os/exec" + "regexp" + "strings" + "time" +) + +type AgentCommand struct { + ID int64 `json:"id"` + Action string `json:"action"` + Target string `json:"target,omitempty"` +} + +type AgentActionResult struct { + ID int64 `json:"id"` + Success bool `json:"success"` + Message string `json:"message"` +} + +type AgentAction struct { + ID int64 `json:"id"` + AgentID string `json:"agentId"` + AgentName string `json:"agentName"` + Action string `json:"action"` + Target string `json:"target"` + RequestedAt int64 `json:"requestedAt"` + StartedAt *int64 `json:"startedAt,omitempty"` + CompletedAt *int64 `json:"completedAt,omitempty"` + Status string `json:"status"` + Result string `json:"result"` +} + +var safeUnitName = regexp.MustCompile(`^[A-Za-z0-9_.@:-]+\.service$`) +var safeContainerName = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,128}$`) + +func (s *Store) QueueAgentAction(agentID, action, target string) (AgentAction, error) { + action, target = strings.TrimSpace(action), strings.TrimSpace(target) + var raw, name string + if err := s.db.QueryRow(`SELECT report_json,name FROM agents WHERE id=?`, agentID).Scan(&raw, &name); err != nil { + return AgentAction{}, fmt.Errorf("агент не найден") + } + var report AgentReport + _ = json.Unmarshal([]byte(raw), &report) + valid := false + switch action { + case "systemd-restart", "systemd-reset-failed": + if safeUnitName.MatchString(target) { + for _, service := range report.Services { + if service.Name == target { + valid = true + break + } + } + } + case "docker-restart": + if safeContainerName.MatchString(target) { + for _, container := range report.Containers { + if container.ID == target || container.Name == target { + valid = true + break + } + } + } + case "updates-refresh": + valid = target == "" + } + if !valid { + return AgentAction{}, fmt.Errorf("действие или цель не входят в разрешённый список") + } + now := time.Now().Unix() + result, err := s.db.Exec(`INSERT INTO agent_actions(agent_id,action,target,requested_at,status) VALUES(?,?,?,?, 'queued')`, agentID, action, target, now) + if err != nil { + return AgentAction{}, err + } + id, _ := result.LastInsertId() + return AgentAction{ID: id, AgentID: agentID, AgentName: name, Action: action, Target: target, RequestedAt: now, Status: "queued"}, nil +} + +func (s *Store) DispatchAgentActions(agentID string) ([]AgentCommand, error) { + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + _, _ = tx.Exec(`UPDATE agent_actions SET status='queued',started_at=NULL WHERE agent_id=? AND status='running' AND started_at 4000 { + message = message[:4000] + } + _, _ = s.db.Exec(`UPDATE agent_actions SET status=?,result=?,completed_at=? WHERE id=? AND agent_id=? AND status='running'`, status, message, now, result.ID, agentID) + } +} + +func (s *Store) AgentActions(limit int) ([]AgentAction, error) { + if limit < 1 || limit > 200 { + limit = 100 + } + rows, err := s.db.Query(`SELECT a.id,a.agent_id,g.name,a.action,a.target,a.requested_at,a.started_at,a.completed_at,a.status,a.result FROM agent_actions a JOIN agents g ON g.id=a.agent_id ORDER BY a.id DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []AgentAction + for rows.Next() { + var v AgentAction + if err = rows.Scan(&v.ID, &v.AgentID, &v.AgentName, &v.Action, &v.Target, &v.RequestedAt, &v.StartedAt, &v.CompletedAt, &v.Status, &v.Result); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +func executeAgentCommand(command AgentCommand) AgentActionResult { + result := AgentActionResult{ID: command.ID} + var output string + switch command.Action { + case "systemd-restart": + output = runAgentCombinedCommand(30*time.Second, "systemctl", "restart", command.Target) + case "systemd-reset-failed": + output = runAgentCombinedCommand(15*time.Second, "systemctl", "reset-failed", command.Target) + case "docker-restart": + output = restartAgentContainer(command.Target) + case "updates-refresh": + guestInventory.Lock() + guestInventory.value.CollectedAt = time.Time{} + guestInventory.Unlock() + result.Success = true + result.Message = "Проверка обновлений поставлена в очередь" + return result + default: + result.Message = "Команда не разрешена" + return result + } + result.Success = !strings.HasPrefix(output, "ERROR: ") + result.Message = strings.TrimSpace(output) + if result.Message == "" && result.Success { + result.Message = "Выполнено успешно" + } + return result +} + +func runAgentCombinedCommand(timeout time.Duration, name string, args ...string) string { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + output, err := exec.CommandContext(ctx, name, args...).CombinedOutput() + message := strings.TrimSpace(string(output)) + if err != nil { + return "ERROR: " + err.Error() + ": " + message + } + return message +} + +func restartAgentContainer(target string) string { + transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock") + }} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 35 * time.Second} + request, _ := http.NewRequest(http.MethodPost, "http://docker/containers/"+target+"/restart?t=10", bytes.NewReader(nil)) + response, err := client.Do(request) + if err != nil { + return "ERROR: " + err.Error() + } + defer response.Body.Close() + body, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + if response.StatusCode >= 300 { + return "ERROR: Docker вернул " + response.Status + ": " + strings.TrimSpace(string(body)) + } + return "Контейнер перезапущен" +} diff --git a/agent_actions_test.go b/agent_actions_test.go new file mode 100644 index 0000000..9b4eaa6 --- /dev/null +++ b/agent_actions_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "encoding/json" + "testing" + "time" +) + +func TestAgentActionAllowlistAndLifecycle(t *testing.T) { + store, err := openStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + report, _ := json.Marshal(AgentReport{Services: []AgentService{{Name: "gitea.service"}}, Containers: []AgentContainer{{ID: "abc123", Name: "gitea"}}}) + _, err = store.db.Exec(`INSERT INTO agents(id,name,secret_hash,enrolled_at,last_seen,report_json) VALUES('a','agent','x',?,?,?)`, time.Now().Unix(), time.Now().Unix(), string(report)) + if err != nil { + t.Fatal(err) + } + if _, err = store.QueueAgentAction("a", "systemd-restart", "../../bad.service"); err == nil { + t.Fatal("unsafe unit accepted") + } + action, err := store.QueueAgentAction("a", "systemd-restart", "gitea.service") + if err != nil { + t.Fatal(err) + } + commands, err := store.DispatchAgentActions("a") + if err != nil || len(commands) != 1 || commands[0].ID != action.ID { + t.Fatalf("commands=%#v err=%v", commands, err) + } + store.CompleteAgentActions("a", []AgentActionResult{{ID: action.ID, Success: true, Message: "ok"}}) + items, err := store.AgentActions(10) + if err != nil || len(items) != 1 || items[0].Status != "completed" { + t.Fatalf("items=%#v err=%v", items, err) + } +} + +func TestManualServiceLinkGroupsOfflineAlerts(t *testing.T) { + metrics := dashboardMetrics{Agents: []ManagedAgent{{ID: "a", Name: "app", VMID: 105, Online: false}}, ServiceLinks: []ServiceLink{{ServiceID: 7, AgentID: "a", VMID: 105}}, Services: ServicesMetrics{Services: []ServiceStatus{{MonitoredService: MonitoredService{ID: 7, Name: "Gitea"}, Endpoints: []ServiceEndpointStatus{{Kind: "local", Up: false}}}}}} + alerts := []Alert{{ID: "agent-offline-a-105"}, {ID: "service-7-local"}, {ID: "other"}} + grouped := groupLinkedAlerts(metrics, alerts) + if len(grouped) != 2 { + t.Fatalf("grouped=%#v", grouped) + } + found := false + for _, alert := range grouped { + if alert.ID == "group-agent-offline-a-105" { + found = true + } + } + if !found { + t.Fatalf("group alert missing: %#v", grouped) + } +} diff --git a/alert_events.go b/alert_events.go index 66441e8..277a944 100644 --- a/alert_events.go +++ b/alert_events.go @@ -124,10 +124,22 @@ func (s *Store) AcknowledgeAlert(eventID int64) error { } func (s *Store) AlertHistory(limit int) ([]AlertEvent, error) { + return s.AlertHistoryBetween(limit, 0, 0) +} + +func (s *Store) AlertHistoryBetween(limit int, from, to int64) ([]AlertEvent, error) { if limit < 1 || limit > 500 { limit = 200 } - rows, err := s.db.Query(`SELECT id,alert_key,severity,source,title,message,first_seen,last_seen,resolved_at,acknowledged_at,planned,maintenance_reason FROM alert_events ORDER BY first_seen DESC LIMIT ?`, limit) + query := `SELECT id,alert_key,severity,source,title,message,first_seen,last_seen,resolved_at,acknowledged_at,planned,maintenance_reason FROM alert_events` + args := []any{} + if from > 0 && to > from { + query += ` WHERE first_seen>=? AND first_seen 0 { + memory = float64(agent.Report.MemoryUsed) / float64(agent.Report.MemoryTotal) * 100 + } + swap := float64(0) + if agent.Report.SwapTotal > 0 { + swap = float64(agent.Report.SwapUsed) / float64(agent.Report.SwapTotal) * 100 + } + s.AddEntityHistory("agent-resource", name, ts, &cpu, &memory) + s.AddEntityHistory("agent-swap", name, ts, &swap, nil) + pressure := agent.Report.Pressure.IO.Avg10 + s.AddEntityHistory("agent-pressure", name, ts, &pressure, nil) + for _, filesystem := range agent.Report.Filesystems { + used := filesystem.UsedPercent + inodes := filesystem.InodesPercent + s.AddEntityHistory("agent-filesystem", name+" · "+filesystem.Mountpoint, ts, &used, &inodes) + } + } s.PruneEntityHistory() } diff --git a/service_links.go b/service_links.go new file mode 100644 index 0000000..0c9949e --- /dev/null +++ b/service_links.go @@ -0,0 +1,110 @@ +package main + +import ( + "fmt" + "sort" + "strings" +) + +type ServiceLink struct { + ServiceID int64 `json:"serviceId"` + AgentID string `json:"agentId"` + VMID int `json:"vmid"` + ComponentType string `json:"componentType"` + ComponentName string `json:"componentName"` + Port int `json:"port"` +} + +func (s *Store) ServiceLinks() ([]ServiceLink, error) { + rows, err := s.db.Query(`SELECT service_id,agent_id,vmid,component_type,component_name,port FROM service_links ORDER BY service_id`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []ServiceLink + for rows.Next() { + var v ServiceLink + if err = rows.Scan(&v.ServiceID, &v.AgentID, &v.VMID, &v.ComponentType, &v.ComponentName, &v.Port); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +func (s *Store) SaveServiceLink(v ServiceLink) (ServiceLink, error) { + v.AgentID = strings.TrimSpace(v.AgentID) + v.ComponentType = strings.TrimSpace(v.ComponentType) + v.ComponentName = strings.TrimSpace(v.ComponentName) + if v.ServiceID <= 0 { + return v, fmt.Errorf("выберите сервис") + } + if v.AgentID == "" && v.VMID <= 0 { + return v, fmt.Errorf("выберите агент или VMID") + } + if v.ComponentType != "" && v.ComponentType != "systemd" && v.ComponentType != "docker" && v.ComponentType != "process" { + return v, fmt.Errorf("неизвестный тип компонента") + } + if v.Port < 0 || v.Port > 65535 { + return v, fmt.Errorf("некорректный порт") + } + _, err := s.db.Exec(`INSERT INTO service_links(service_id,agent_id,vmid,component_type,component_name,port) VALUES(?,?,?,?,?,?) ON CONFLICT(service_id) DO UPDATE SET agent_id=excluded.agent_id,vmid=excluded.vmid,component_type=excluded.component_type,component_name=excluded.component_name,port=excluded.port`, v.ServiceID, v.AgentID, v.VMID, v.ComponentType, v.ComponentName, v.Port) + return v, err +} +func (s *Store) DeleteServiceLink(serviceID int64) error { + _, err := s.db.Exec(`DELETE FROM service_links WHERE service_id=?`, serviceID) + return err +} + +func groupLinkedAlerts(metrics dashboardMetrics, alerts []Alert) []Alert { + if len(metrics.ServiceLinks) == 0 { + return alerts + } + agentOffline := map[string]ManagedAgent{} + for _, agent := range metrics.Agents { + if !agent.Online { + agentOffline[agent.ID] = agent + } + } + affected := map[string][]string{} + remove := map[string]bool{} + for _, link := range metrics.ServiceLinks { + agent, offline := agentOffline[link.AgentID] + if !offline { + continue + } + for _, service := range metrics.Services.Services { + if service.ID != link.ServiceID { + continue + } + for _, endpoint := range service.Endpoints { + if !endpoint.Up { + key := fmt.Sprintf("service-%d-%s", service.ID, endpoint.Kind) + remove[key] = true + affected[agent.ID] = append(affected[agent.ID], service.Name) + } + } + } + } + if len(affected) == 0 { + return alerts + } + out := make([]Alert, 0, len(alerts)) + for _, alert := range alerts { + skip := remove[alert.ID] + for agentID := range affected { + if alert.ID == "agent-offline-"+agentID+fmt.Sprintf("-%d", agentOffline[agentID].VMID) { + skip = true + } + } + if !skip { + out = append(out, alert) + } + } + for agentID, names := range affected { + sort.Strings(names) + agent := agentOffline[agentID] + out = append(out, Alert{ID: "group-agent-offline-" + agentID + fmt.Sprintf("-%d", agent.VMID), Severity: "critical", Source: "Корреляция", Title: agent.Name + ": агент недоступен, связанные сервисы не отвечают", Message: fmt.Sprintf("VMID %d. Затронуто сервисов: %d — %s. Вероятная первопричина: недоступна гостевая система или агент.", agent.VMID, len(names), strings.Join(names, ", "))}) + } + return out +} diff --git a/store.go b/store.go index 636ced4..7727177 100644 --- a/store.go +++ b/store.go @@ -140,6 +140,9 @@ func openStore(path string) (*Store, error) { `CREATE INDEX IF NOT EXISTS entity_history_kind_ts_idx ON entity_history(kind,ts)`, `CREATE TABLE IF NOT EXISTS agent_enrollment_tokens (token_hash TEXT PRIMARY KEY, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used_at INTEGER)`, `CREATE TABLE IF NOT EXISTS agents (id TEXT PRIMARY KEY, name TEXT NOT NULL, vmid INTEGER NOT NULL DEFAULT 0, hostname TEXT NOT NULL DEFAULT '', machine_id TEXT NOT NULL DEFAULT '', secret_hash TEXT NOT NULL, enrolled_at INTEGER NOT NULL, last_seen INTEGER NOT NULL, version TEXT NOT NULL DEFAULT '', report_json TEXT NOT NULL DEFAULT '{}', remote_address TEXT NOT NULL DEFAULT '')`, + `CREATE TABLE IF NOT EXISTS agent_actions (id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id TEXT NOT NULL, action TEXT NOT NULL, target TEXT NOT NULL DEFAULT '', requested_at INTEGER NOT NULL, started_at INTEGER, completed_at INTEGER, status TEXT NOT NULL DEFAULT 'queued', result TEXT NOT NULL DEFAULT '', FOREIGN KEY(agent_id) REFERENCES agents(id) ON DELETE CASCADE)`, + `CREATE INDEX IF NOT EXISTS agent_actions_agent_status_idx ON agent_actions(agent_id,status)`, + `CREATE TABLE IF NOT EXISTS service_links (service_id INTEGER PRIMARY KEY, agent_id TEXT NOT NULL DEFAULT '', vmid INTEGER NOT NULL DEFAULT 0, component_type TEXT NOT NULL DEFAULT '', component_name TEXT NOT NULL DEFAULT '', port INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(service_id) REFERENCES services(id) ON DELETE CASCADE)`, } for _, statement := range statements { if _, err := db.Exec(statement); err != nil { diff --git a/web.go b/web.go index 0f1e57b..2ff9929 100644 --- a/web.go +++ b/web.go @@ -32,6 +32,7 @@ type dashboardMetrics struct { UPSShutdown UPSShutdownSettings `json:"upsShutdown"` Trends []TrendAnomaly `json:"trends"` Agents []ManagedAgent `json:"agents"` + ServiceLinks []ServiceLink `json:"serviceLinks"` Timestamp time.Time `json:"timestamp"` } @@ -66,9 +67,10 @@ func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkColle attributeDiskIO(&metrics, ioAttribution.collect(metrics.Guests)) metrics.Trends = trends.collect(metrics, store) metrics.Agents, _ = store.Agents() + metrics.ServiceLinks, _ = store.ServiceLinks() decorateNetworkInterfaces(&metrics.Network, metrics.Guests) metrics.MaintenanceWindow = store.MaintenanceWindow() - allAlerts := correlateAlerts(metrics, evaluateAlerts(metrics, thresholds)) + allAlerts := groupLinkedAlerts(metrics, correlateAlerts(metrics, evaluateAlerts(metrics, thresholds))) activeAlerts, plannedAlerts := splitMaintenanceAlerts(allAlerts, metrics.MaintenanceWindow) metrics.Alerts, err = store.SyncAlertsWithMaintenance(activeAlerts, plannedAlerts, metrics.MaintenanceWindow.Reason) if err != nil { @@ -277,6 +279,7 @@ func routes(collector *cpuCollector, store *Store) http.Handler { return } result := agentReportResponse{Accepted: true} + result.Commands, _ = store.DispatchAgentActions(id) if report.Version != version && version != "dev" { manifest, _, manifestErr := currentAgentUpdate() if manifestErr == nil { @@ -305,12 +308,40 @@ func routes(collector *cpuCollector, store *Store) http.Handler { w.Header().Set("Content-Disposition", `attachment; filename="proxmox-dashboard-agent"`) http.ServeFile(w, r, path) }) + mux.HandleFunc("/api/agent-actions", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + items, err := store.AgentActions(100) + writeJSON(w, items, err) + case http.MethodPost: + var request struct{ AgentID, Action, Target string } + if json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&request) != nil { + http.Error(w, "Некорректное действие", http.StatusBadRequest) + return + } + value, err := store.QueueAgentAction(request.AgentID, request.Action, request.Target) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, value, nil) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) mux.HandleFunc("/api/alerts/history", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) return } - events, err := store.AlertHistory(200) + var from, to int64 + if date := strings.TrimSpace(r.URL.Query().Get("date")); date != "" { + if day, parseErr := time.ParseInLocation("2006-01-02", date, time.Local); parseErr == nil { + from = day.Unix() + to = day.Add(24 * time.Hour).Unix() + } + } + events, err := store.AlertHistoryBetween(500, from, to) writeJSON(w, events, err) }) mux.HandleFunc("/api/alerts/acknowledge", func(w http.ResponseWriter, r *http.Request) { @@ -420,6 +451,30 @@ func routes(collector *cpuCollector, store *Store) http.Handler { points, err := store.ServiceHistory(id, hours) writeJSON(w, points, err) }) + mux.HandleFunc("/api/service-links", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + items, err := store.ServiceLinks() + writeJSON(w, items, err) + case http.MethodPut: + var value ServiceLink + if json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&value) != nil { + http.Error(w, "Некорректная связь", http.StatusBadRequest) + return + } + saved, err := store.SaveServiceLink(value) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, saved, nil) + case http.MethodDelete: + id, _ := strconv.ParseInt(r.URL.Query().Get("serviceId"), 10, 64) + writeJSON(w, map[string]bool{"deleted": true}, store.DeleteServiceLink(id)) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) mux.HandleFunc("/api/backups/verify", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) @@ -518,7 +573,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)}#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}} +.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}.disk-list[hidden]{display:none!important}#agentHistoryDetails{position:relative}@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}}
@@ -631,7 +686,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Проверка состояния системы…
- + @@ -737,7 +792,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Порт от локального адреса отбрасывается. Полученный IP сравнивается с адресами VM/LXC из Proxmox. Storage берётся из конфигурации каждой VM и LXC.
Связи строятся по VMID, IP, слушающему порту, PID systemd и портам Docker. Предполагаемые связи явно помечаются.
Ожидаем данные агентов…
Подключённые агенты появятся здесь.
-
+
@@ -786,11 +841,13 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Проверка выполняется каждые 15 секунд, история хранится 7 дней.
Добавленные сервисы
Список пуст.
+
Ручная связь сервиса
Имеет приоритет над автоматическим сопоставлением. Компонент можно выбрать как systemd-службу, Docker-контейнер или процесс.