Add read-only guest monitoring agents

This commit is contained in:
Maxim
2026-08-06 20:24:13 +03:00
parent 5401b5b0f2
commit f1ff15e344
8 changed files with 715 additions and 5 deletions

View File

@@ -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

457
agent.go Normal file
View File

@@ -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
}

57
agent_test.go Normal file
View File

@@ -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")
}
}

View File

@@ -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 {

12
main.go
View File

@@ -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 == "" {

63
scripts/install-agent.sh Executable file
View File

@@ -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."

View File

@@ -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 {

92
web.go
View File

@@ -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}
</style>
</head>
<body><main>
@@ -477,6 +543,12 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<div class="quick"><div><span class="label">CPU VM и LXC</span><strong id="guestCPU">—</strong></div><div><span class="label">Память VM и LXC</span><strong id="guestMemory">—</strong></div></div>
<div class="hint">Нажмите, чтобы посмотреть подробности →</div>
</button>
<button class="card agents" data-card="agents" data-open="agentsDialog" aria-label="Открыть агенты VM и LXC">
<div class="card-head"><div><div class="label">Guest telemetry</div><div class="name">Агенты VM и LXC</div></div><div class="percent"><span id="agentOnline">—</span><small id="agentTotal"> / —</small></div></div>
<div class="bar"><div class="fill" id="agentBar"></div></div>
<div class="quick"><div><span class="label">Docker-контейнеры</span><strong id="agentContainers">—</strong></div><div><span class="label">Проблемы</span><strong id="agentProblems">—</strong></div></div>
<div class="hint">Системные метрики и Docker внутри VM/LXC →</div>
</button>
<button class="card maintenance" data-card="maintenance" data-open="maintenanceDialog" aria-label="Открыть сведения Proxmox">
<div class="card-head"><div><div class="label">Proxmox</div><div class="name" id="maintenanceVersion">Обслуживание хоста</div></div><div class="percent"><span id="maintenanceUpdates">—</span><small> updates</small></div></div>
<div class="bar"><div class="fill" id="maintenanceBar"></div></div>
@@ -625,10 +697,11 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<dialog id="snapshotsDialog"><div class="modal-head"><div><div class="label">Snapshots</div><h2>Снимки VM и LXC</h2></div><button class="close" aria-label="Закрыть">×</button></div><div class="disk-list" id="snapshotList"><span class="empty">Получение snapshots…</span></div></dialog>
<dialog id="tasksDialog"><div class="modal-head"><div><div class="label">Proxmox activity</div><h2>События и задачи</h2></div><button class="close" aria-label="Закрыть">×</button></div><div class="task-filter"><button class="detail-tab active" data-task-filter="all">Все</button><button class="detail-tab" data-task-filter="vzdump">Backup</button><button class="detail-tab" data-task-filter="qm">VM/LXC</button><button class="detail-tab" data-task-filter="error">Ошибки</button></div><div class="disk-list" id="taskList"><span class="empty">Получение задач…</span></div></dialog>
<dialog id="mapDialog"><div class="modal-head"><div><div class="label">Homelab topology</div><h2>Карта homelab</h2></div><button class="close" aria-label="Закрыть">×</button></div><div class="map-scroll"><div class="topology-board" id="mapBoard"></div></div><div class="wide settings-note">Порт от локального адреса отбрасывается. Полученный IP сравнивается с адресами VM/LXC из Proxmox. Storage берётся из конфигурации каждой VM и LXC.</div></dialog>
<dialog id="agentsDialog"><div class="modal-head"><div><div class="label">Guest telemetry</div><h2>Агенты VM и LXC</h2></div><button class="close" aria-label="Закрыть">×</button></div><div class="disk-list" id="agentList"><span class="empty">Подключённые агенты появятся здесь.</span></div></dialog>
<dialog id="settingsDialog">
<div class="modal-head"><div><div class="label">Dashboard</div><h2>Настройки</h2></div><button class="close" aria-label="Закрыть">×</button></div>
<div class="settings-tabs"><button class="settings-tab active" data-settings-tab="appearance">Интерфейс</button><button class="settings-tab" data-settings-tab="alerts">Пороги</button><button class="settings-tab" data-settings-tab="maintenance-mode">Maintenance</button><button class="settings-tab" data-settings-tab="services">Сервисы</button><button class="settings-tab" data-settings-tab="ups">UPS shutdown</button><button class="settings-tab" data-settings-tab="email">Почта</button><button class="settings-tab" data-settings-tab="updates">Обновления</button></div>
<div class="settings-tabs"><button class="settings-tab active" data-settings-tab="appearance">Интерфейс</button><button class="settings-tab" data-settings-tab="alerts">Пороги</button><button class="settings-tab" data-settings-tab="maintenance-mode">Maintenance</button><button class="settings-tab" data-settings-tab="services">Сервисы</button><button class="settings-tab" data-settings-tab="agents">Агенты</button><button class="settings-tab" data-settings-tab="ups">UPS shutdown</button><button class="settings-tab" data-settings-tab="email">Почта</button><button class="settings-tab" data-settings-tab="updates">Обновления</button></div>
<div class="settings-body settings-panel" data-settings-panel="appearance">
<div class="settings-group"><div class="label">Тема оформления</div><div class="theme-options"><button class="theme-option" data-theme-choice="dark">Тёмная</button><button class="theme-option" data-theme-choice="light">Светлая</button></div></div>
<div class="settings-group"><div class="label">Видимые карточки</div><div class="setting-list">
@@ -636,7 +709,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
<label class="setting-item"><input type="checkbox" data-card-toggle="cpu"> CPU</label><label class="setting-item"><input type="checkbox" data-card-toggle="memory"> Память</label>
<label class="setting-item"><input type="checkbox" data-card-toggle="storage"> Диски</label><label class="setting-item"><input type="checkbox" data-card-toggle="network"> Сеть</label>
<label class="setting-item"><input type="checkbox" data-card-toggle="zfs"> ZFS</label><label class="setting-item"><input type="checkbox" data-card-toggle="ups"> UPS</label><label class="setting-item"><input type="checkbox" data-card-toggle="monitor"> Самоконтроль</label>
<label class="setting-item"><input type="checkbox" data-card-toggle="guests"> VM и LXC</label><label class="setting-item"><input type="checkbox" data-card-toggle="maintenance"> Proxmox</label><label class="setting-item"><input type="checkbox" data-card-toggle="backups"> Резервные копии</label><label class="setting-item"><input type="checkbox" data-card-toggle="services"> Сервисы</label><label class="setting-item"><input type="checkbox" data-card-toggle="system"> Система</label><label class="setting-item"><input type="checkbox" data-card-toggle="snapshots"> Snapshots</label><label class="setting-item"><input type="checkbox" data-card-toggle="tasks"> Задачи Proxmox</label><label class="setting-item"><input type="checkbox" data-card-toggle="map"> Карта homelab</label>
<label class="setting-item"><input type="checkbox" data-card-toggle="guests"> VM и LXC</label><label class="setting-item"><input type="checkbox" data-card-toggle="agents"> Агенты VM и LXC</label><label class="setting-item"><input type="checkbox" data-card-toggle="maintenance"> Proxmox</label><label class="setting-item"><input type="checkbox" data-card-toggle="backups"> Резервные копии</label><label class="setting-item"><input type="checkbox" data-card-toggle="services"> Сервисы</label><label class="setting-item"><input type="checkbox" data-card-toggle="system"> Система</label><label class="setting-item"><input type="checkbox" data-card-toggle="snapshots"> Snapshots</label><label class="setting-item"><input type="checkbox" data-card-toggle="tasks"> Задачи Proxmox</label><label class="setting-item"><input type="checkbox" data-card-toggle="map"> Карта homelab</label>
</div></div>
<div class="settings-group"><div class="label">Порядок карточек</div><div class="settings-note">Перетаскивайте строки или используйте стрелки.</div><div class="card-order" id="cardOrder"></div></div>
</div>
@@ -674,6 +747,11 @@ footer{color:#657581;font-size:12px;margin-top:17px}
</div><button class="save-settings" id="saveService">Сохранить сервис</button><div class="settings-note" id="serviceStatus">Проверка выполняется каждые 15 секунд, история хранится 7 дней.</div></div>
<div class="settings-group"><div class="label">Добавленные сервисы</div><div class="disk-list" id="serviceSettingsList"><span class="empty">Список пуст.</span></div></div>
</div>
<div class="settings-body settings-panel" data-settings-panel="agents" hidden>
<div class="settings-group"><div class="label">Подключить новую VM или LXC</div><div class="settings-note">Токен одноразовый и действует 15 минут. Агент только читает системные метрики и Docker — команд управления в этой версии нет.</div><div class="threshold-grid" style="margin-top:12px"><label class="field"><span>Адрес Dashboard из VM/LXC</span><input id="agentServerURL" placeholder="https://dashboard.example.lan"></label><label class="field"><span>VMID · необязательно</span><input id="agentVMID" type="number" min="0" placeholder="105"></label></div><button class="save-settings" id="createAgentToken">Создать команду установки</button></div>
<div class="settings-group" id="agentInstallBlock" hidden><div class="label">Выполните внутри VM/LXC от root</div><code class="agent-command" id="agentInstallCommand"></code><div class="settings-note">Для Docker-метрик служба получает доступ к /var/run/docker.sock. Сам агент не принимает входящие подключения.</div></div>
<div class="settings-group"><div class="label">Подключённые агенты</div><div id="agentSettingsList" class="disk-list" style="padding:12px 0 0"><span class="empty">Агентов пока нет.</span></div></div>
</div>
<div class="settings-body settings-panel" data-settings-panel="email" hidden>
<div class="settings-group"><div class="setting-list"><label class="setting-item"><input type="checkbox" id="emailEnabled"> Включить почтовые дайджесты</label><label class="setting-item"><input type="checkbox" id="emailSkipTLS"> Не проверять TLS-сертификат</label></div><div class="settings-note">Только для доверенного сервера во внутренней сети.</div></div>
<div class="settings-group"><div class="label">SMTP и получатель</div><div class="threshold-grid">
@@ -684,7 +762,7 @@ footer{color:#657581;font-size:12px;margin-top:17px}
</div></div>
<div class="settings-group"><div class="label">Важность</div><div class="setting-list"><label class="setting-item"><input type="checkbox" data-email-severity="critical"> Критические</label><label class="setting-item"><input type="checkbox" data-email-severity="warning"> Предупреждения</label><label class="setting-item"><input type="checkbox" data-email-severity="info"> Информация</label></div></div>
<div class="settings-group"><div class="label">Какие разделы отправлять</div><div class="setting-list">
<label class="setting-item"><input type="checkbox" data-email-source="CPU"> CPU</label><label class="setting-item"><input type="checkbox" data-email-source="Память"> Память</label><label class="setting-item"><input type="checkbox" data-email-source="Storage"> Storage</label><label class="setting-item"><input type="checkbox" data-email-source="Диски"> Диски</label><label class="setting-item"><input type="checkbox" data-email-source="ZFS"> ZFS</label><label class="setting-item"><input type="checkbox" data-email-source="UPS"> UPS</label><label class="setting-item"><input type="checkbox" data-email-source="VM/LXC"> VM и LXC</label><label class="setting-item"><input type="checkbox" data-email-source="Proxmox"> Proxmox</label><label class="setting-item"><input type="checkbox" data-email-source="Backups"> Резервные копии</label><label class="setting-item"><input type="checkbox" data-email-source="Dashboard"> Dashboard</label><label class="setting-item"><input type="checkbox" data-email-source="Сервисы"> Сервисы</label><label class="setting-item"><input type="checkbox" data-email-source="Система"> Система</label><label class="setting-item"><input type="checkbox" data-email-source="Сеть"> Сеть</label>
<label class="setting-item"><input type="checkbox" data-email-source="CPU"> CPU</label><label class="setting-item"><input type="checkbox" data-email-source="Память"> Память</label><label class="setting-item"><input type="checkbox" data-email-source="Storage"> Storage</label><label class="setting-item"><input type="checkbox" data-email-source="Диски"> Диски</label><label class="setting-item"><input type="checkbox" data-email-source="ZFS"> ZFS</label><label class="setting-item"><input type="checkbox" data-email-source="UPS"> UPS</label><label class="setting-item"><input type="checkbox" data-email-source="VM/LXC"> VM и LXC</label><label class="setting-item"><input type="checkbox" data-email-source="Агенты"> Агенты</label><label class="setting-item"><input type="checkbox" data-email-source="Docker"> Docker</label><label class="setting-item"><input type="checkbox" data-email-source="Proxmox"> Proxmox</label><label class="setting-item"><input type="checkbox" data-email-source="Backups"> Резервные копии</label><label class="setting-item"><input type="checkbox" data-email-source="Dashboard"> Dashboard</label><label class="setting-item"><input type="checkbox" data-email-source="Сервисы"> Сервисы</label><label class="setting-item"><input type="checkbox" data-email-source="Система"> Система</label><label class="setting-item"><input type="checkbox" data-email-source="Сеть"> Сеть</label>
</div></div>
<button class="save-settings" id="saveEmail">Сохранить настройки почты</button><button class="theme-option" id="testEmail" style="width:100%;margin-top:9px">Отправить тестовое письмо</button><div class="settings-note" id="emailStatus">Пароль хранится в локальной базе сервера и не возвращается в браузер.</div>
</div>
@@ -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=>'<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(v.guestName||('VMID '+v.vmid))+' · '+safe(v.name)+'</div><div class="disk-meta">VMID '+v.vmid+' · '+safe(v.guestType)+(v.createdAt?' · '+new Date(v.createdAt*1000).toLocaleString('ru-RU'):'')+'</div></div><span class="badge '+(v.ageSeconds>30*86400?'warn':'')+'">'+(v.ageSeconds?uptime(v.ageSeconds):'Без даты')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>Размер</span><b>'+(v.sizeBytes?size(v.sizeBytes):'Storage не сообщает')+'</b></div><div class="disk-stat"><span>Состояние VM</span><b>'+(v.running?'Запущена':'Остановлена')+'</b></div><div class="disk-stat"><span>Описание</span><b>'+safe(v.description||'—')+'</b></div></div></article>').join(''):'<span class="empty">Snapshots не найдены.</span>';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 '<span class="topology-chip '+(healthy?'good':'bad')+'"'+(!healthy?' tabindex="0" data-reason="Один или несколько адресов сервиса недоступны"':'')+'>'+safe(s.name)+'</span>'},storageChip=name=>{const pool=pools.find(p=>p.name===name),healthy=!pool||pool.health==='ONLINE';return '<span class="topology-chip '+(healthy?'good':'bad')+'">'+safe(name)+(pool?' · '+fixed(pool.capacityPercent)+'%':'')+'</span>'};const groups=guestItems.map(g=>{const owned=serviceItems.filter(s=>serviceOwners.get(s.id)===g.vmid),storage=[...new Set(g.storage||[])],running=g.status==='running';return '<section class="topology-group"><div class="topology-group-head"><div><strong>'+safe(g.name||('VMID '+g.vmid))+'</strong><div class="disk-meta">VMID '+g.vmid+' · '+safe(g.type==='qemu'?'VM':'LXC')+' · '+safe((g.ipAddresses||[]).join(', ')||'IP не получен')+'</div></div><span class="badge '+(running?'':'warn')+'"'+(!running?' tabindex="0" data-reason="VM/LXC сейчас остановлена"':'')+'>'+(running?'Работает':'Остановлена')+'</span></div><div class="topology-section"><div class="topology-section-title">Сервисы</div><div class="topology-items">'+(owned.length?owned.map(serviceChip).join(''):'<span class="empty">Не сопоставлены</span>')+'</div></div><div class="topology-section"><div class="topology-section-title">Storage</div><div class="topology-items">'+(storage.length?storage.map(storageChip).join(''):'<span class="empty">Не определён</span>')+'</div></div></section>'}).join(''),unassigned=serviceItems.filter(s=>!serviceOwners.get(s.id)),usedStorage=new Set(guestItems.flatMap(g=>g.storage||[])),unassignedPools=pools.filter(p=>!usedStorage.has(p.name));el('mapBoard').innerHTML='<div class="topology-host map-node good"><strong>Proxmox host</strong><small>'+safe(location.hostname)+' · '+guestItems.length+' VM/LXC</small></div><div class="topology-grid">'+groups+'</div>'+((unassigned.length||unassignedPools.length)?'<section class="topology-group topology-unassigned"><div class="topology-group-head"><strong>Без автоматической привязки</strong><span class="badge warn" tabindex="0" data-reason="Для сервиса не найден совпадающий IP VM/LXC либо storage не используется в конфигурации VM/LXC">Проверьте связи</span></div><div class="topology-section"><div class="topology-section-title">Сервисы</div><div class="topology-items">'+unassigned.map(serviceChip).join('')+'</div></div><div class="topology-section"><div class="topology-section-title">Остальные ZFS-пулы</div><div class="topology-items">'+unassignedPools.map(p=>storageChip(p.name)).join('')+'</div></div></section>':'');const storage=new Set([...usedStorage,...pools.map(p=>p.name)]);el('mapGuests').textContent=guestItems.length;el('mapResources').textContent=serviceItems.length+' / '+storage.size;el('mapNodes').textContent=1+guestItems.length+serviceItems.length+storage.size;el('mapBar').style.width='100%'}
function agentHTML(a,settings=false){const r=a.report||{},containers=r.containers||[],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 '<article class="disk-item"><div class="disk-top"><div><div class="disk-model">'+safe(a.name||a.hostname)+'</div><div class="disk-meta">'+safe(a.hostname)+' · VMID '+(a.vmid||'не указан')+' · агент '+safe(a.version||'—')+' · '+(a.online?'heartbeat сейчас':'последний '+new Date(a.lastSeen*1000).toLocaleString('ru-RU'))+'</div></div><span class="badge '+(a.online?'':'bad')+'">'+(a.online?'Онлайн':'Не отвечает')+'</span></div><div class="disk-stats"><div class="disk-stat"><span>CPU / Load</span><b>'+fixed(r.cpuPercent||0)+'% / '+fixed(r.load1||0)+'</b></div><div class="disk-stat"><span>Память</span><b>'+size(r.memoryUsed)+' · '+fixed(memoryPercent)+'%</b></div><div class="disk-stat"><span>Раздел /</span><b>'+size(r.rootUsed)+' · '+fixed(diskPercent)+'%</b></div><div class="disk-stat"><span>Uptime</span><b>'+uptime(r.uptimeSeconds||0)+'</b></div><div class="disk-stat"><span>Docker</span><b>'+(r.dockerAvailable?containers.length+' контейнеров':safe(r.dockerError||'Нет данных'))+'</b></div><div class="disk-stat"><span>Проблемы</span><b>'+problems.length+'</b></div></div><div class="agent-containers">'+(containers.length?containers.map(v=>'<span class="topology-chip '+(v.state==='running'&&v.health!=='unhealthy'?'good':'bad')+'">'+safe(v.name)+' · '+safe(v.health||v.state)+' · CPU '+fixed(v.cpuPercent||0)+'% · RAM '+size(v.memoryUsage||0)+' · restarts '+v.restarts+'</span>').join(''):'<span class="empty">Docker-контейнеры не обнаружены</span>')+'</div>'+(settings?'<div class="service-actions"><button data-agent-delete="'+safe(a.id)+'">Удалить агент</button></div>':'')+'</article>'}
function renderAgents(items){const agents=items||[],online=agents.filter(v=>v.online),containers=agents.flatMap(v=>(v.report&&v.report.containers)||[]),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(''):'<span class="empty">Агенты пока не подключены. Создайте команду установки в настройках.</span>';el('agentSettingsList').innerHTML=agents.length?agents.map(v=>agentHTML(v,true)).join(''):'<span class="empty">Агентов пока нет.</span>'}
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=>'<article class="alert-item '+safe(a.severity)+'"><i class="alert-icon"></i><div><div class="alert-source">'+safe(a.source)+' · '+(a.severity==='critical'?'Критическое':a.severity==='warning'?'Предупреждение':'Информация')+(a.firstSeen?' · С '+new Date(a.firstSeen*1000).toLocaleString('ru-RU'):'')+'</div><div class="alert-title">'+safe(a.title)+'</div><div class="alert-message">'+safe(a.message)+'</div><button class="ack-alert" data-ack="'+a.eventId+'">Принято</button></div></article>').join(''):'<div class="all-good"><strong>Всё в порядке</strong><span>Новых неподтверждённых событий нет.</span></div>';
@@ -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 '<article class="alert-item '+safe(a.severity)+'"><i class="alert-icon"></i><div><div class="alert-source">'+safe(a.source)+' · '+new Date(a.firstSeen*1000).toLocaleString('ru-RU')+'</div><div class="alert-title">'+safe(a.title)+'</div><div class="alert-message">'+safe(a.message)+'</div><span class="history-state">'+state+'</span></div></article>'}).join(''):'<div class="all-good"><strong>История пока пуста</strong><span>Новые события появятся здесь автоматически.</span></div>'}
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=>'<div class="order-item" draggable="true" data-order-card="'+name+'"><span class="order-handle">⠿</span><span>'+cardLabels[name]+'</span><button class="order-button" data-move="up" aria-label="Выше">↑</button><button class="order-button" data-move="down" aria-label="Ниже">↓</button></div>').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=[];