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`, agentID, time.Now().Add(-5*time.Minute).Unix())
+ rows, err := tx.Query(`SELECT id,action,target FROM agent_actions WHERE agent_id=? AND status='queued' ORDER BY id LIMIT 3`, agentID)
+ if err != nil {
+ return nil, err
+ }
+ var commands []AgentCommand
+ for rows.Next() {
+ var c AgentCommand
+ if err = rows.Scan(&c.ID, &c.Action, &c.Target); err != nil {
+ rows.Close()
+ return nil, err
+ }
+ commands = append(commands, c)
+ }
+ rows.Close()
+ now := time.Now().Unix()
+ for _, c := range commands {
+ if _, err = tx.Exec(`UPDATE agent_actions SET status='running',started_at=? WHERE id=?`, now, c.ID); err != nil {
+ return nil, err
+ }
+ }
+ return commands, tx.Commit()
+}
+
+func (s *Store) CompleteAgentActions(agentID string, results []AgentActionResult) {
+ now := time.Now().Unix()
+ for _, result := range results {
+ status := "failed"
+ if result.Success {
+ status = "completed"
+ }
+ message := result.Message
+ if len(message) > 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`
+ args = append(args, from, to)
+ }
+ query += ` ORDER BY first_seen DESC LIMIT ?`
+ args = append(args, limit)
+ rows, err := s.db.Query(query, args...)
if err != nil {
return nil, err
}
diff --git a/history.go b/history.go
index 2f31296..5f8f5cd 100644
--- a/history.go
+++ b/history.go
@@ -64,6 +64,30 @@ func (s *Store) AddEntityMetrics(metrics dashboardMetrics) {
s.AddEntityHistory("backup-duration", "VMID "+task.ID, ts, &duration, nil)
}
}
+ for _, agent := range metrics.Agents {
+ if !agent.Online {
+ continue
+ }
+ name := agent.ID + " · " + agent.Name
+ cpu := agent.Report.CPUPercent
+ memory := float64(0)
+ if agent.Report.MemoryTotal > 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}
Notification center
Центр уведомлений
Проверка состояния системы…
- Загрузка истории…
+