diff --git a/README.md b/README.md index a22fffe..a63083e 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,8 @@ apcaccess status - `GET/POST/PUT/DELETE /api/services` — управление проверками сервисов. - `GET /api/services/history` — семидневная история доступности и отклика. - `GET/PUT/DELETE /api/settings/maintenance` — состояние, запуск и досрочное завершение плановых работ. +- `GET/DELETE /api/agents` — подключённые гостевые агенты. +- `POST /api/agents/enrollment-token` — одноразовый токен регистрации на 15 минут. ## Maintenance-режим @@ -332,6 +334,23 @@ apcaccess status с причиной плановых работ. После окончания окна обычный мониторинг включается автоматически. +## Агенты VM и LXC + +Read-only агент показывает состояние Linux внутри VM/LXC: load average, память, +корневой раздел, uptime, версию агента, Docker-контейнеры, health-check и число +перезапусков. Агент сам подключается к Dashboard каждые 10 секунд; открывать +входящий порт внутри гостевой системы не нужно. + +Откройте `Настройки → Агенты`, укажите адрес Dashboard, доступный из VM/LXC, и +VMID. Кнопка создаст готовую команду установки. Одноразовый токен действует 15 +минут и после регистрации заменяется индивидуальным секретом агента. + +В первой версии агент не выполняет команды и не перезапускает службы или +контейнеры. Для чтения Docker ему требуется доступ к `/var/run/docker.sock`, +поэтому служба устанавливается от root. Сам Dashboard пока не имеет +авторизации: используйте локальную сеть или HTTPS через доверенный reverse +proxy и не публикуйте агентские API напрямую в интернет. + ## Сборка ```bash diff --git a/agent.go b/agent.go new file mode 100644 index 0000000..44f584f --- /dev/null +++ b/agent.go @@ -0,0 +1,457 @@ +package main + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "time" +) + +const agentOfflineAfter = 45 * time.Second + +type agentOptions struct { + Server, EnrollmentToken, Name, ConfigPath string + VMID int +} + +type agentConfig struct { + Server string `json:"server"` + ID string `json:"id"` + Secret string `json:"secret"` + Name string `json:"name"` + VMID int `json:"vmid"` +} + +type AgentContainer struct { + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + State string `json:"state"` + Status string `json:"status"` + Health string `json:"health,omitempty"` + Restarts int64 `json:"restarts"` + CPUPercent float64 `json:"cpuPercent"` + MemoryUsage uint64 `json:"memoryUsage"` + Ports []string `json:"ports,omitempty"` +} + +type AgentReport struct { + Hostname string `json:"hostname"` + MachineID string `json:"machineId"` + Version string `json:"version"` + OS string `json:"os"` + Arch string `json:"arch"` + UptimeSeconds int64 `json:"uptimeSeconds"` + Load1 float64 `json:"load1"` + CPUPercent float64 `json:"cpuPercent"` + MemoryTotal uint64 `json:"memoryTotal"` + MemoryUsed uint64 `json:"memoryUsed"` + RootTotal uint64 `json:"rootTotal"` + RootUsed uint64 `json:"rootUsed"` + DockerAvailable bool `json:"dockerAvailable"` + DockerError string `json:"dockerError,omitempty"` + Containers []AgentContainer `json:"containers"` + CollectedAt int64 `json:"collectedAt"` +} + +type ManagedAgent struct { + ID string `json:"id"` + Name string `json:"name"` + VMID int `json:"vmid"` + Hostname string `json:"hostname"` + Version string `json:"version"` + LastSeen int64 `json:"lastSeen"` + EnrolledAt int64 `json:"enrolledAt"` + Online bool `json:"online"` + RemoteAddress string `json:"remoteAddress"` + Report AgentReport `json:"report"` +} + +type agentEnrollmentRequest struct { + Token, Name, Hostname, MachineID string + VMID int +} + +func randomHex(bytesCount int) (string, error) { + b := make([]byte, bytesCount) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func tokenHash(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +func (s *Store) CreateAgentEnrollmentToken() (string, error) { + token, err := randomHex(24) + if err != nil { + return "", err + } + _, err = s.db.Exec(`INSERT INTO agent_enrollment_tokens(token_hash,created_at,expires_at) VALUES(?,?,?)`, tokenHash(token), time.Now().Unix(), time.Now().Add(15*time.Minute).Unix()) + return token, err +} + +func (s *Store) EnrollAgent(req agentEnrollmentRequest) (agentConfig, error) { + tx, err := s.db.Begin() + if err != nil { + return agentConfig{}, err + } + defer tx.Rollback() + var expires int64 + if err = tx.QueryRow(`SELECT expires_at FROM agent_enrollment_tokens WHERE token_hash=? AND used_at IS NULL`, tokenHash(req.Token)).Scan(&expires); err != nil || expires < time.Now().Unix() { + return agentConfig{}, errors.New("одноразовый токен недействителен или истёк") + } + id, err := randomHex(12) + if err != nil { + return agentConfig{}, err + } + secret, err := randomHex(32) + if err != nil { + return agentConfig{}, err + } + name := strings.TrimSpace(req.Name) + if name == "" { + name = req.Hostname + } + now := time.Now().Unix() + if _, err = tx.Exec(`INSERT INTO agents(id,name,vmid,hostname,machine_id,secret_hash,enrolled_at,last_seen,report_json,remote_address) VALUES(?,?,?,?,?,?,?,?,?,?)`, id, name, req.VMID, req.Hostname, req.MachineID, tokenHash(secret), now, now, `{}`, ""); err != nil { + return agentConfig{}, err + } + if _, err = tx.Exec(`UPDATE agent_enrollment_tokens SET used_at=? WHERE token_hash=?`, now, tokenHash(req.Token)); err != nil { + return agentConfig{}, err + } + if err = tx.Commit(); err != nil { + return agentConfig{}, err + } + return agentConfig{ID: id, Secret: secret, Name: name, VMID: req.VMID}, nil +} + +func (s *Store) SaveAgentReport(id, secret, remote string, report AgentReport) error { + var expected string + if s.db.QueryRow(`SELECT secret_hash FROM agents WHERE id=?`, id).Scan(&expected) != nil || !hmac.Equal([]byte(expected), []byte(tokenHash(secret))) { + return errors.New("агент не авторизован") + } + 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 +} + +func (s *Store) Agents() ([]ManagedAgent, error) { + rows, err := s.db.Query(`SELECT id,name,vmid,hostname,version,enrolled_at,last_seen,report_json,remote_address FROM agents ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + result := []ManagedAgent{} + for rows.Next() { + var a ManagedAgent + var raw string + if err = rows.Scan(&a.ID, &a.Name, &a.VMID, &a.Hostname, &a.Version, &a.EnrolledAt, &a.LastSeen, &raw, &a.RemoteAddress); err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(raw), &a.Report) + a.Online = time.Since(time.Unix(a.LastSeen, 0)) < agentOfflineAfter + result = append(result, a) + } + return result, rows.Err() +} + +func (s *Store) DeleteAgent(id string) error { + _, err := s.db.Exec(`DELETE FROM agents WHERE id=?`, id) + return err +} + +func runGuestAgent(opts agentOptions) error { + config, err := loadAgentConfig(opts.ConfigPath) + if err != nil && !os.IsNotExist(err) { + return err + } + if opts.Server != "" { + config.Server = strings.TrimRight(opts.Server, "/") + } + if opts.Name != "" { + config.Name = opts.Name + } + if opts.VMID > 0 { + config.VMID = opts.VMID + } + if config.Server == "" { + return errors.New("укажите --agent-server") + } + if config.ID == "" { + if opts.EnrollmentToken == "" { + return errors.New("для первого запуска нужен --agent-enroll-token") + } + config, err = enrollRemote(config, opts.EnrollmentToken) + if err != nil { + return err + } + if err = saveAgentConfig(opts.ConfigPath, config); err != nil { + return err + } + } + client := &http.Client{Timeout: 12 * time.Second} + for { + report := collectAgentReport() + data, _ := json.Marshal(report) + req, _ := http.NewRequest(http.MethodPost, config.Server+"/api/agent/report", bytes.NewReader(data)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+config.ID+"."+config.Secret) + response, requestErr := client.Do(req) + if requestErr == nil { + io.Copy(io.Discard, response.Body) + response.Body.Close() + if response.StatusCode >= 300 { + requestErr = fmt.Errorf("сервер вернул %s", response.Status) + } + } + if requestErr != nil { + fmt.Fprintf(os.Stderr, "agent: %v\n", requestErr) + } + time.Sleep(10 * time.Second) + } +} + +func enrollRemote(config agentConfig, token string) (agentConfig, error) { + hostname, _ := os.Hostname() + machineID, _ := os.ReadFile("/etc/machine-id") + payload := agentEnrollmentRequest{Token: token, Name: config.Name, VMID: config.VMID, Hostname: hostname, MachineID: strings.TrimSpace(string(machineID))} + data, _ := json.Marshal(payload) + response, err := http.Post(config.Server+"/api/agent/enroll", "application/json", bytes.NewReader(data)) + if err != nil { + return config, err + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + message, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) + return config, fmt.Errorf("регистрация: %s", strings.TrimSpace(string(message))) + } + var credentials agentConfig + if err = json.NewDecoder(response.Body).Decode(&credentials); err != nil { + return config, err + } + credentials.Server = config.Server + return credentials, nil +} + +func loadAgentConfig(path string) (agentConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return agentConfig{}, err + } + var c agentConfig + err = json.Unmarshal(data, &c) + return c, err +} +func saveAgentConfig(path string, c agentConfig) error { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + data, _ := json.MarshalIndent(c, "", " ") + return os.WriteFile(path, data, 0600) +} + +func collectAgentReport() AgentReport { + hostname, _ := os.Hostname() + machineID, _ := os.ReadFile("/etc/machine-id") + report := AgentReport{Hostname: hostname, MachineID: strings.TrimSpace(string(machineID)), Version: version, OS: runtime.GOOS, Arch: runtime.GOARCH, CollectedAt: time.Now().Unix()} + if data, err := os.ReadFile("/proc/uptime"); err == nil { + fields := strings.Fields(string(data)) + if len(fields) > 0 { + value, _ := strconv.ParseFloat(fields[0], 64) + report.UptimeSeconds = int64(value) + } + } + if data, err := os.ReadFile("/proc/loadavg"); err == nil { + fields := strings.Fields(string(data)) + if len(fields) > 0 { + report.Load1, _ = strconv.ParseFloat(fields[0], 64) + } + } + report.CPUPercent = sampleAgentCPU() + if data, err := os.ReadFile("/proc/meminfo"); err == nil { + values := map[string]uint64{} + for _, line := range strings.Split(string(data), "\n") { + f := strings.Fields(line) + if len(f) >= 2 { + v, _ := strconv.ParseUint(f[1], 10, 64) + values[strings.TrimSuffix(f[0], ":")] = v * 1024 + } + } + report.MemoryTotal = values["MemTotal"] + available := values["MemAvailable"] + if report.MemoryTotal > available { + report.MemoryUsed = report.MemoryTotal - available + } + } + var stat syscall.Statfs_t + if syscall.Statfs("/", &stat) == nil { + report.RootTotal = stat.Blocks * uint64(stat.Bsize) + free := stat.Bavail * uint64(stat.Bsize) + if report.RootTotal > free { + report.RootUsed = report.RootTotal - free + } + } + report.Containers, report.DockerError = collectDockerContainers() + report.DockerAvailable = report.DockerError == "" + return report +} + +func sampleAgentCPU() float64 { + total1, idle1 := readAgentCPU() + time.Sleep(100 * time.Millisecond) + total2, idle2 := readAgentCPU() + if total2 <= total1 { + return 0 + } + totalDelta, idleDelta := total2-total1, idle2-idle1 + return float64(totalDelta-idleDelta) / float64(totalDelta) * 100 +} + +func readAgentCPU() (uint64, uint64) { + data, err := os.ReadFile("/proc/stat") + if err != nil { + return 0, 0 + } + line := strings.SplitN(string(data), "\n", 2)[0] + fields := strings.Fields(line) + if len(fields) < 5 { + return 0, 0 + } + var total uint64 + values := make([]uint64, 0, len(fields)-1) + for _, field := range fields[1:] { + v, _ := strconv.ParseUint(field, 10, 64) + values = append(values, v) + total += v + } + idle := values[3] + if len(values) > 4 { + idle += values[4] + } + return total, idle +} + +func collectDockerContainers() ([]AgentContainer, string) { + if _, err := os.Stat("/var/run/docker.sock"); err != nil { + return []AgentContainer{}, "Docker socket недоступен" + } + transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", "/var/run/docker.sock") + }} + client := &http.Client{Transport: transport, Timeout: 5 * time.Second} + response, err := client.Get("http://docker/containers/json?all=1") + if err != nil { + return nil, err.Error() + } + defer response.Body.Close() + if response.StatusCode != 200 { + return nil, response.Status + } + var raw []struct { + ID, Image, State, Status string + Names []string + Ports []struct { + IP string + PrivatePort, PublicPort int + Type string + } + } + if err = json.NewDecoder(response.Body).Decode(&raw); err != nil { + return nil, err.Error() + } + result := make([]AgentContainer, 0, len(raw)) + for _, v := range raw { + name := strings.TrimPrefix(firstString(v.Names), "/") + item := AgentContainer{ID: shortID(v.ID), Name: name, Image: v.Image, State: v.State, Status: v.Status} + for _, p := range v.Ports { + if p.PublicPort > 0 { + item.Ports = append(item.Ports, fmt.Sprintf("%d:%d/%s", p.PublicPort, p.PrivatePort, p.Type)) + } + } + inspect, inspectErr := client.Get("http://docker/containers/" + v.ID + "/json") + if inspectErr == nil { + var detail struct { + RestartCount int64 + State struct{ Health *struct{ Status string } } + } + if json.NewDecoder(inspect.Body).Decode(&detail) == nil { + item.Restarts = detail.RestartCount + if detail.State.Health != nil { + item.Health = detail.State.Health.Status + } + } + inspect.Body.Close() + } + statsResponse, statsErr := client.Get("http://docker/containers/" + v.ID + "/stats?stream=false") + if statsErr == nil { + var stats struct { + CPUStats struct { + CPUUsage struct { + TotalUsage uint64 `json:"total_usage"` + } `json:"cpu_usage"` + SystemCPUUsage uint64 `json:"system_cpu_usage"` + OnlineCPUs uint64 `json:"online_cpus"` + } `json:"cpu_stats"` + PreCPUStats struct { + CPUUsage struct { + TotalUsage uint64 `json:"total_usage"` + } `json:"cpu_usage"` + SystemCPUUsage uint64 `json:"system_cpu_usage"` + } `json:"precpu_stats"` + MemoryStats struct { + Usage uint64 `json:"usage"` + Stats map[string]uint64 `json:"stats"` + } `json:"memory_stats"` + } + if json.NewDecoder(statsResponse.Body).Decode(&stats) == nil { + cpuDelta := stats.CPUStats.CPUUsage.TotalUsage - stats.PreCPUStats.CPUUsage.TotalUsage + systemDelta := stats.CPUStats.SystemCPUUsage - stats.PreCPUStats.SystemCPUUsage + cpus := stats.CPUStats.OnlineCPUs + if cpus == 0 { + cpus = 1 + } + if systemDelta > 0 { + item.CPUPercent = float64(cpuDelta) / float64(systemDelta) * float64(cpus) * 100 + } + item.MemoryUsage = stats.MemoryStats.Usage + if cache := stats.MemoryStats.Stats["inactive_file"]; item.MemoryUsage > cache { + item.MemoryUsage -= cache + } + } + statsResponse.Body.Close() + } + result = append(result, item) + } + return result, "" +} +func firstString(v []string) string { + if len(v) > 0 { + return v[0] + } + return "" +} +func shortID(v string) string { + if len(v) > 12 { + return v[:12] + } + return v +} diff --git a/agent_test.go b/agent_test.go new file mode 100644 index 0000000..487713a --- /dev/null +++ b/agent_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "testing" + "time" +) + +func TestAgentEnrollmentTokenIsSingleUse(t *testing.T) { + store, err := openStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + token, err := store.CreateAgentEnrollmentToken() + if err != nil { + t.Fatal(err) + } + credentials, err := store.EnrollAgent(agentEnrollmentRequest{Token: token, Name: "docker-01", Hostname: "vm105", VMID: 105}) + if err != nil { + t.Fatal(err) + } + if credentials.ID == "" || credentials.Secret == "" { + t.Fatal("credentials were not generated") + } + if _, err = store.EnrollAgent(agentEnrollmentRequest{Token: token, Hostname: "duplicate"}); err == nil { + t.Fatal("token was accepted twice") + } +} + +func TestAgentReportAuthenticationAndOfflineState(t *testing.T) { + store, err := openStore(":memory:") + if err != nil { + t.Fatal(err) + } + defer store.Close() + token, _ := store.CreateAgentEnrollmentToken() + credentials, _ := store.EnrollAgent(agentEnrollmentRequest{Token: token, Name: "docker", Hostname: "vm", VMID: 101}) + report := AgentReport{Hostname: "vm", Version: "test", DockerAvailable: true, Containers: []AgentContainer{{ID: "abc", Name: "web", State: "running"}}} + if err = store.SaveAgentReport(credentials.ID, "wrong", "127.0.0.1", report); err == nil { + t.Fatal("invalid secret accepted") + } + if err = store.SaveAgentReport(credentials.ID, credentials.Secret, "127.0.0.1", report); err != nil { + t.Fatal(err) + } + agents, err := store.Agents() + if err != nil { + t.Fatal(err) + } + if len(agents) != 1 || !agents[0].Online || len(agents[0].Report.Containers) != 1 { + t.Fatalf("unexpected agents: %#v", agents) + } + _, _ = store.db.Exec(`UPDATE agents SET last_seen=? WHERE id=?`, time.Now().Add(-time.Minute).Unix(), credentials.ID) + agents, _ = store.Agents() + if agents[0].Online { + t.Fatal("stale agent is online") + } +} diff --git a/alerts.go b/alerts.go index 28657ea..fcad311 100644 --- a/alerts.go +++ b/alerts.go @@ -197,6 +197,22 @@ func evaluateAlerts(metrics dashboardMetrics, thresholds AlertThresholds) []Aler add("guests-stopped", "info", "VM/LXC", "Есть остановленные гости", fmt.Sprintf("Остановлено VM/LXC: %d. Это уведомление информационное.", stopped)) } } + for _, agent := range metrics.Agents { + vmSuffix := fmt.Sprintf("-%d", agent.VMID) + if !agent.Online { + add("agent-offline-"+agent.ID+vmSuffix, "critical", "Агенты", agent.Name+": агент не отвечает", fmt.Sprintf("Последний heartbeat: %s назад. VMID %d.", time.Since(time.Unix(agent.LastSeen, 0)).Round(time.Second), agent.VMID)) + continue + } + if agent.Report.DockerAvailable { + for _, container := range agent.Report.Containers { + if container.State != "running" { + add("agent-container-stopped-"+agent.ID+"-"+container.ID+vmSuffix, "warning", "Docker", agent.Name+": контейнер "+container.Name+" остановлен", fmt.Sprintf("%s · VMID %d.", container.Status, agent.VMID)) + } else if container.Health == "unhealthy" { + add("agent-container-health-"+agent.ID+"-"+container.ID+vmSuffix, "critical", "Docker", agent.Name+": контейнер "+container.Name+" unhealthy", fmt.Sprintf("Docker health-check не проходит · VMID %d.", agent.VMID)) + } + } + } + } for _, service := range metrics.Services.Services { for _, endpoint := range service.Endpoints { if !endpoint.Up { diff --git a/main.go b/main.go index c76be3f..352ef5b 100644 --- a/main.go +++ b/main.go @@ -20,11 +20,23 @@ var version = "dev" func main() { showVersion := flag.Bool("version", false, "show version") + agentMode := flag.Bool("agent", false, "run as a read-only guest agent") + agentServer := flag.String("agent-server", "", "dashboard URL used by the guest agent") + agentEnrollToken := flag.String("agent-enroll-token", "", "one-time enrollment token") + agentName := flag.String("agent-name", "", "agent display name") + agentVMID := flag.Int("agent-vmid", 0, "Proxmox VM/LXC ID") + agentConfig := flag.String("agent-config", "/var/lib/proxmox-dashboard-agent/agent.json", "agent configuration path") flag.Parse() if *showVersion { fmt.Println(version) return } + if *agentMode { + if err := runGuestAgent(agentOptions{Server: *agentServer, EnrollmentToken: *agentEnrollToken, Name: *agentName, VMID: *agentVMID, ConfigPath: *agentConfig}); err != nil { + log.Fatalf("Ошибка агента: %v", err) + } + return + } address := os.Getenv("DASHBOARD_ADDR") if address == "" { diff --git a/scripts/install-agent.sh b/scripts/install-agent.sh new file mode 100755 index 0000000..ac82159 --- /dev/null +++ b/scripts/install-agent.sh @@ -0,0 +1,63 @@ +#!/bin/sh +set -eu + +if [ "$(id -u)" -ne 0 ]; then + echo "Запустите команду установки от root." >&2 + exit 1 +fi +if [ -z "${DASHBOARD_URL:-}" ] || [ -z "${ENROLL_TOKEN:-}" ]; then + echo "Не заданы DASHBOARD_URL и ENROLL_TOKEN. Создайте команду в настройках Dashboard." >&2 + exit 1 +fi + +GITEA_URL=${GITEA_URL:-https://git.myown.center} +GITEA_REPOSITORY=${GITEA_REPOSITORY:-maxim/ProxmoxDash} +VMID=${VMID:-0} +AGENT_NAME=${AGENT_NAME:-$(hostname)} + +release_json=$(curl -fsSL "$GITEA_URL/api/v1/repos/$GITEA_REPOSITORY/releases/latest") +download_url=$(printf '%s' "$release_json" | sed -n 's/.*"browser_download_url":"\([^"]*\/proxmox-dashboard-linux-amd64\)".*/\1/p' | head -n 1) +if [ -z "$download_url" ]; then + echo "В последнем релизе не найден Linux-бинарник." >&2 + exit 1 +fi + +install -d -m 0700 /var/lib/proxmox-dashboard-agent +temporary_binary=$(mktemp /tmp/proxmox-dashboard-agent.XXXXXX) +trap 'rm -f "$temporary_binary"' EXIT +curl -fsSL "$download_url" -o "$temporary_binary" +install -m 0755 "$temporary_binary" /usr/local/bin/proxmox-dashboard-agent + +umask 077 +{ + printf 'DASHBOARD_URL=%s\n' "$DASHBOARD_URL" + printf 'ENROLL_TOKEN=%s\n' "$ENROLL_TOKEN" + printf 'AGENT_NAME=%s\n' "$AGENT_NAME" + printf 'VMID=%s\n' "$VMID" +} > /etc/proxmox-dashboard-agent.env + +cat > /etc/systemd/system/proxmox-dashboard-agent.service <<'UNIT' +[Unit] +Description=Proxmox Dashboard read-only guest agent +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=simple +EnvironmentFile=/etc/proxmox-dashboard-agent.env +ExecStart=/usr/local/bin/proxmox-dashboard-agent --agent --agent-server ${DASHBOARD_URL} --agent-enroll-token ${ENROLL_TOKEN} --agent-name ${AGENT_NAME} --agent-vmid ${VMID} +Restart=always +RestartSec=5 +NoNewPrivileges=true +ProtectHome=true +PrivateTmp=true +ProtectSystem=strict +ReadWritePaths=/var/lib/proxmox-dashboard-agent + +[Install] +WantedBy=multi-user.target +UNIT + +systemctl daemon-reload +systemctl enable --now proxmox-dashboard-agent.service +echo "Агент установлен. Через несколько секунд он появится в Dashboard." diff --git a/store.go b/store.go index eec9a70..636ced4 100644 --- a/store.go +++ b/store.go @@ -138,6 +138,8 @@ func openStore(path string) (*Store, error) { `CREATE INDEX IF NOT EXISTS service_checks_service_ts_idx ON service_checks(service_id,ts)`, `CREATE TABLE IF NOT EXISTS entity_history (kind TEXT NOT NULL,name TEXT NOT NULL,ts INTEGER NOT NULL,value1 REAL,value2 REAL,PRIMARY KEY(kind,name,ts))`, `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 '')`, } for _, statement := range statements { if _, err := db.Exec(statement); err != nil { @@ -233,7 +235,7 @@ func (s *Store) SaveThresholds(value AlertThresholds) error { } func defaultEmailSettings() EmailSettings { - return EmailSettings{Port: 587, IntervalMinutes: 60, Severities: []string{"critical", "warning"}, Sources: []string{"CPU", "Память", "Storage", "Диски", "ZFS", "UPS", "VM/LXC", "Proxmox", "Dashboard", "Сервисы", "Система", "Сеть"}} + return EmailSettings{Port: 587, IntervalMinutes: 60, Severities: []string{"critical", "warning"}, Sources: []string{"CPU", "Память", "Storage", "Диски", "ZFS", "UPS", "VM/LXC", "Proxmox", "Dashboard", "Сервисы", "Система", "Сеть", "Агенты", "Docker"}} } func (s *Store) EmailSettings(includePassword bool) EmailSettings { diff --git a/web.go b/web.go index 48dda2c..a94b644 100644 --- a/web.go +++ b/web.go @@ -31,6 +31,7 @@ type dashboardMetrics struct { Monitor MonitorHealth `json:"monitor"` UPSShutdown UPSShutdownSettings `json:"upsShutdown"` Trends []TrendAnomaly `json:"trends"` + Agents []ManagedAgent `json:"agents"` Timestamp time.Time `json:"timestamp"` } @@ -64,6 +65,7 @@ func collectDashboard(cpuCollector *cpuCollector, networkCollector *networkColle metrics.Activity = activity.collect(metrics.Guests) attributeDiskIO(&metrics, ioAttribution.collect(metrics.Guests)) metrics.Trends = trends.collect(metrics, store) + metrics.Agents, _ = store.Agents() decorateNetworkInterfaces(&metrics.Network, metrics.Guests) metrics.MaintenanceWindow = store.MaintenanceWindow() allAlerts := correlateAlerts(metrics, evaluateAlerts(metrics, thresholds)) @@ -214,6 +216,69 @@ func routes(collector *cpuCollector, store *Store) http.Handler { w.WriteHeader(http.StatusMethodNotAllowed) } }) + mux.HandleFunc("/api/agents", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + agents, err := store.Agents() + writeJSON(w, agents, err) + case http.MethodDelete: + id := strings.TrimSpace(r.URL.Query().Get("id")) + if id == "" { + http.Error(w, "Не указан агент", http.StatusBadRequest) + return + } + writeJSON(w, map[string]bool{"deleted": true}, store.DeleteAgent(id)) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + }) + mux.HandleFunc("/api/agents/enrollment-token", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + token, err := store.CreateAgentEnrollmentToken() + writeJSON(w, map[string]any{"token": token, "expiresInSeconds": 900}, err) + }) + mux.HandleFunc("/api/agent/enroll", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + var request agentEnrollmentRequest + if json.NewDecoder(io.LimitReader(r.Body, 16<<10)).Decode(&request) != nil || strings.TrimSpace(request.Token) == "" { + http.Error(w, "Некорректная регистрация", http.StatusBadRequest) + return + } + credentials, err := store.EnrollAgent(request) + if err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + writeJSON(w, credentials, nil) + }) + mux.HandleFunc("/api/agent/report", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + credentials := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + id, secret, ok := strings.Cut(credentials, ".") + if !ok || id == "" || secret == "" { + http.Error(w, "Агент не авторизован", http.StatusUnauthorized) + return + } + var report AgentReport + if json.NewDecoder(io.LimitReader(r.Body, 2<<20)).Decode(&report) != nil { + http.Error(w, "Некорректный отчёт", http.StatusBadRequest) + return + } + if err := store.SaveAgentReport(id, secret, r.RemoteAddr, report); err != nil { + http.Error(w, err.Error(), http.StatusUnauthorized) + return + } + writeJSON(w, map[string]bool{"accepted": true}, nil) + }) mux.HandleFunc("/api/alerts/history", func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) @@ -419,6 +484,7 @@ footer{color:#657581;font-size:12px;margin-top:17px} .snapshots .fill{background:linear-gradient(90deg,#9d8cff,#ff78b8)}.tasks .fill{background:linear-gradient(90deg,#43c6d9,#6ba8ff)}.homelab-map .fill{background:linear-gradient(90deg,#55d58a,#9d8cff)}.map-board{display:grid;grid-template-columns:170px 1fr 1fr 1fr;gap:14px;padding:18px;min-width:680px}.map-column{display:grid;align-content:start;gap:9px}.map-column-title{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.1em}.map-node{padding:12px;border:1px solid var(--line);border-radius:12px;background:var(--panel)}.map-node.good{border-color:#2d6245}.map-node.bad{border-color:#71362f}.map-node.warn{border-color:#6d4c2c}.map-node strong{display:block}.map-node small{display:block;color:var(--muted);margin-top:3px}.map-scroll{overflow-x:auto}.backup-check{margin-top:9px;color:var(--muted);font-size:12px}.task-filter{display:flex;gap:6px;padding:12px 16px;overflow-x:auto} .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}
@@ -477,6 +543,12 @@ footer{color:#657581;font-size:12px;margin-top:17px}
CPU VM и LXC
Память VM и LXC
Нажмите, чтобы посмотреть подробности →
+
Получение snapshots…
Получение задач…
Порт от локального адреса отбрасывается. Полученный IP сравнивается с адресами VM/LXC из Proxmox. Storage берётся из конфигурации каждой VM и LXC.
+
Подключённые агенты появятся здесь.
-
+
Тема оформления
Видимые карточки
@@ -636,7 +709,7 @@ footer{color:#657581;font-size:12px;margin-top:17px} - +
Порядок карточек
Перетаскивайте строки или используйте стрелки.
@@ -674,6 +747,11 @@ footer{color:#657581;font-size:12px;margin-top:17px}
Проверка выполняется каждые 15 секунд, история хранится 7 дней.
Добавленные сервисы
Список пуст.
+ @@ -722,9 +800,12 @@ function renderTasks(tasks,filter='all'){currentTasks=tasks;const shown=tasks.fi function renderActivity(activity,guests,services,zfs){const snapshots=activity.snapshots||[],tasks=activity.tasks||[],old=snapshots.filter(v=>v.ageSeconds>30*86400),guestCount=new Set(snapshots.map(v=>v.vmid)).size,failed=tasks.filter(v=>v.status&&v.status!=='OK');el('snapshotCount').textContent=snapshots.length;el('oldSnapshots').textContent=old.length;el('snapshotGuests').textContent=guestCount;el('snapshotBar').style.width=Math.min(old.length*20,100)+'%';el('snapshotList').innerHTML=snapshots.length?snapshots.map(v=>'
'+safe(v.guestName||('VMID '+v.vmid))+' · '+safe(v.name)+'
VMID '+v.vmid+' · '+safe(v.guestType)+(v.createdAt?' · '+new Date(v.createdAt*1000).toLocaleString('ru-RU'):'')+'
'+(v.ageSeconds?uptime(v.ageSeconds):'Без даты')+'
Размер'+(v.sizeBytes?size(v.sizeBytes):'Storage не сообщает')+'
Состояние VM'+(v.running?'Запущена':'Остановлена')+'
Описание'+safe(v.description||'—')+'
').join(''):'Snapshots не найдены.';el('taskCount').textContent=tasks.length;el('failedTasks').textContent=failed.length;el('lastTask').textContent=tasks.length?taskLabel(tasks[0].type):'—';el('taskBar').style.width=Math.min(failed.length*20,100)+'%';renderTasks(tasks,currentTaskFilter);renderHomelabMap(guests,services,zfs)} 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 ''+safe(s.name)+''},storageChip=name=>{const pool=pools.find(p=>p.name===name),healthy=!pool||pool.health==='ONLINE';return ''+safe(name)+(pool?' · '+fixed(pool.capacityPercent)+'%':'')+''};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 '
'+safe(g.name||('VMID '+g.vmid))+'
VMID '+g.vmid+' · '+safe(g.type==='qemu'?'VM':'LXC')+' · '+safe((g.ipAddresses||[]).join(', ')||'IP не получен')+'
'+(running?'Работает':'Остановлена')+'
Сервисы
'+(owned.length?owned.map(serviceChip).join(''):'Не сопоставлены')+'
Storage
'+(storage.length?storage.map(storageChip).join(''):'Не определён')+'
'}).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='
Proxmox host'+safe(location.hostname)+' · '+guestItems.length+' VM/LXC
'+groups+'
'+((unassigned.length||unassignedPools.length)?'
Без автоматической привязкиПроверьте связи
Сервисы
'+unassigned.map(serviceChip).join('')+'
Остальные ZFS-пулы
'+unassignedPools.map(p=>storageChip(p.name)).join('')+'
':'');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||[],memoryPercent=r.memoryTotal?r.memoryUsed/r.memoryTotal*100:0,diskPercent=r.rootTotal?r.rootUsed/r.rootTotal*100:0,problems=containers.filter(v=>v.state!=='running'||v.health==='unhealthy');return '
'+safe(a.name||a.hostname)+'
'+safe(a.hostname)+' · VMID '+(a.vmid||'не указан')+' · агент '+safe(a.version||'—')+' · '+(a.online?'heartbeat сейчас':'последний '+new Date(a.lastSeen*1000).toLocaleString('ru-RU'))+'
'+(a.online?'Онлайн':'Не отвечает')+'
CPU / Load'+fixed(r.cpuPercent||0)+'% / '+fixed(r.load1||0)+'
Память'+size(r.memoryUsed)+' · '+fixed(memoryPercent)+'%
Раздел /'+size(r.rootUsed)+' · '+fixed(diskPercent)+'%
Uptime'+uptime(r.uptimeSeconds||0)+'
Docker'+(r.dockerAvailable?containers.length+' контейнеров':safe(r.dockerError||'Нет данных'))+'
Проблемы'+problems.length+'
'+(containers.length?containers.map(v=>''+safe(v.name)+' · '+safe(v.health||v.state)+' · CPU '+fixed(v.cpuPercent||0)+'% · RAM '+size(v.memoryUsage||0)+' · restarts '+v.restarts+'').join(''):'Docker-контейнеры не обнаружены')+'
'+(settings?'
':'')+'
'} +function renderAgents(items){const agents=items||[],online=agents.filter(v=>v.online),containers=agents.flatMap(v=>(v.report&&v.report.containers)||[]),problems=(agents.length-online.length)+containers.filter(v=>v.state!=='running'||v.health==='unhealthy').length;el('agentOnline').textContent=online.length;el('agentTotal').textContent=' / '+agents.length;el('agentBar').style.width=(agents.length?online.length/agents.length*100:0)+'%';el('agentContainers').textContent=containers.length;el('agentProblems').textContent=problems;el('agentList').innerHTML=agents.length?agents.map(v=>agentHTML(v)).join(''):'Агенты пока не подключены. Создайте команду установки в настройках.';el('agentSettingsList').innerHTML=agents.length?agents.map(v=>agentHTML(v,true)).join(''):'Агентов пока нет.'} function 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||{}); + renderAgents(x.agents||[]); const update=x.update||{},updating=['queued','installing'].includes(update.state);el('updateCurrent').textContent=update.currentVersion||'—';el('updateAvailable').textContent=update.availableVersion||'—';el('updateMode').textContent=update.mode==='auto'?'Автоматически':'Уведомлять';el('updateMessage').textContent=update.message||'Проверка ещё не выполнялась';el('updateChecked').textContent=update.checkedAt?'Последняя проверка: '+new Date(update.checkedAt).toLocaleString('ru-RU'):'Проверка запускается автоматически раз в сутки.';el('installUpdate').disabled=updating;el('installUpdate').textContent=updating?'Обновление выполняется…':'Проверить и установить обновление'; const critical=alerts.filter(a=>a.severity==='critical').length,warnings=alerts.filter(a=>a.severity==='warning').length,infos=alerts.filter(a=>a.severity==='info').length;el('alertCount').textContent=alerts.length;el('alertCritical').textContent=critical;el('alertWarnings').textContent=warnings;el('alertHeadline').textContent=critical?'Требуется срочное внимание':warnings?'Есть предупреждения':infos?'Есть информационные события':'Система в норме';el('alertBar').style.width=(critical?100:warnings?65:infos?30:0)+'%'; el('alertList').innerHTML=alerts.length?alerts.map(a=>'
'+safe(a.source)+' · '+(a.severity==='critical'?'Критическое':a.severity==='warning'?'Предупреждение':'Информация')+(a.firstSeen?' · С '+new Date(a.firstSeen*1000).toLocaleString('ru-RU'):'')+'
'+safe(a.title)+'
'+safe(a.message)+'
').join(''):'
Всё в порядкеНовых неподтверждённых событий нет.
'; @@ -772,7 +853,7 @@ el('backupList').onclick=async event=>{const button=event.target.closest('[data- el('alertList').onclick=async event=>{const button=event.target.closest('[data-ack]');if(!button)return;button.disabled=true;const response=await fetch('/api/alerts/acknowledge',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({eventId:Number(button.dataset.ack)})});if(response.ok)button.closest('.alert-item').remove();else button.disabled=false}; async function loadAlertHistory(){const response=await fetch('/api/alerts/history');if(!response.ok)return;const events=await response.json()||[];el('alertHistory').innerHTML=events.length?events.map(a=>{const state=a.planned?'Плановые работы · '+safe(a.maintenanceReason||'Причина не указана')+(a.resolvedAt?' · завершено '+new Date(a.resolvedAt*1000).toLocaleString('ru-RU'):''):a.acknowledgedAt?'Принято · '+new Date(a.acknowledgedAt*1000).toLocaleString('ru-RU'):a.resolvedAt?'Завершено · '+new Date(a.resolvedAt*1000).toLocaleString('ru-RU'):'Активно';return '
'+safe(a.source)+' · '+new Date(a.firstSeen*1000).toLocaleString('ru-RU')+'
'+safe(a.title)+'
'+safe(a.message)+'
'+state+'
'}).join(''):'
История пока пустаНовые события появятся здесь автоматически.
'} document.querySelectorAll('[data-alert-tab]').forEach(tab=>tab.onclick=()=>{document.querySelectorAll('[data-alert-tab]').forEach(t=>t.classList.toggle('active',t===tab));document.querySelectorAll('[data-alert-panel]').forEach(p=>p.hidden=p.dataset.alertPanel!==tab.dataset.alertTab);if(tab.dataset.alertTab==='history')loadAlertHistory()}); -const cardNames=['alerts','cpu','memory','storage','network','zfs','ups','monitor','guests','maintenance','backups','services','system','snapshots','tasks','map'],cardLabels={alerts:'Центр уведомлений',cpu:'CPU',memory:'Память',storage:'Диски',network:'Сеть',zfs:'ZFS',ups:'UPS',monitor:'Самоконтроль',guests:'VM и LXC',maintenance:'Proxmox',backups:'Резервные копии',services:'Сервисы',system:'Система и ядро',snapshots:'Snapshots',tasks:'Задачи Proxmox',map:'Карта homelab'};let hiddenCards=[],cardOrder=[];try{hiddenCards=JSON.parse(localStorage.getItem('proxmox-hidden-cards')||'[]');cardOrder=JSON.parse(localStorage.getItem('proxmox-card-order')||'[]')}catch(e){}cardOrder=[...cardOrder.filter(x=>cardNames.includes(x)),...cardNames.filter(x=>!cardOrder.includes(x))]; +const cardNames=['alerts','cpu','memory','storage','network','zfs','ups','monitor','guests','agents','maintenance','backups','services','system','snapshots','tasks','map'],cardLabels={alerts:'Центр уведомлений',cpu:'CPU',memory:'Память',storage:'Диски',network:'Сеть',zfs:'ZFS',ups:'UPS',monitor:'Самоконтроль',guests:'VM и LXC',agents:'Агенты VM и LXC',maintenance:'Proxmox',backups:'Резервные копии',services:'Сервисы',system:'Система и ядро',snapshots:'Snapshots',tasks:'Задачи Proxmox',map:'Карта homelab'};let hiddenCards=[],cardOrder=[];try{hiddenCards=JSON.parse(localStorage.getItem('proxmox-hidden-cards')||'[]');cardOrder=JSON.parse(localStorage.getItem('proxmox-card-order')||'[]')}catch(e){}cardOrder=[...cardOrder.filter(x=>cardNames.includes(x)),...cardNames.filter(x=>!cardOrder.includes(x))]; function renderCardOrder(){el('cardOrder').innerHTML=cardOrder.map(name=>'
'+cardLabels[name]+'
').join('')} function saveCardOrder(){localStorage.setItem('proxmox-card-order',JSON.stringify(cardOrder));applyCardSettings();renderCardOrder()} function applyCardSettings(){const container=document.querySelector('.cards');cardOrder.forEach(name=>{const card=document.querySelector('[data-card="'+name+'"]');if(card)container.appendChild(card)});cardNames.forEach(name=>{const visible=!hiddenCards.includes(name),card=document.querySelector('[data-card="'+name+'"]'),toggle=document.querySelector('[data-card-toggle="'+name+'"]');if(card)card.hidden=!visible;if(toggle)toggle.checked=visible})} @@ -780,6 +861,9 @@ document.querySelectorAll('[data-card-toggle]').forEach(toggle=>toggle.onchange= el('cardOrder').onclick=event=>{const button=event.target.closest('[data-move]'),item=event.target.closest('[data-order-card]');if(!button||!item)return;const index=cardOrder.indexOf(item.dataset.orderCard),next=button.dataset.move==='up'?index-1:index+1;if(next<0||next>=cardOrder.length)return;[cardOrder[index],cardOrder[next]]=[cardOrder[next],cardOrder[index]];saveCardOrder()};let draggedCard='';el('cardOrder').addEventListener('dragstart',event=>{const item=event.target.closest('[data-order-card]');if(!item)return;draggedCard=item.dataset.orderCard;item.classList.add('dragging')});el('cardOrder').addEventListener('dragend',event=>{event.target.closest('[data-order-card]')?.classList.remove('dragging');draggedCard=''});el('cardOrder').addEventListener('dragover',event=>event.preventDefault());el('cardOrder').addEventListener('drop',event=>{event.preventDefault();const target=event.target.closest('[data-order-card]');if(!target||!draggedCard||target.dataset.orderCard===draggedCard)return;const from=cardOrder.indexOf(draggedCard),to=cardOrder.indexOf(target.dataset.orderCard);cardOrder.splice(from,1);cardOrder.splice(to,0,draggedCard);saveCardOrder()}); function setTheme(theme){document.documentElement.dataset.theme=theme;localStorage.setItem('proxmox-theme',theme);document.querySelectorAll('[data-theme-choice]').forEach(b=>b.classList.toggle('active',b.dataset.themeChoice===theme));document.querySelectorAll('.chart-section:not([hidden])').forEach(showHistory)}document.querySelectorAll('[data-theme-choice]').forEach(b=>b.onclick=()=>setTheme(b.dataset.themeChoice));setTheme(document.documentElement.dataset.theme);el('settingsButton').onclick=()=>el('settingsDialog').showModal(); 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())}}; 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=[];