# Nanometrics Serveur Go — Plan d'implémentation > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Créer le serveur central Go qui reçoit les métriques des agents (UDP + MQTT), les persiste en SQLite, expose une API REST + WebSocket temps réel et un endpoint Prometheus /metrics. **Architecture:** Goroutine par datagramme UDP. Subscriber MQTT unique. Hub WebSocket avec broadcast. SQLite (CGO-free via modernc.org/sqlite) pour persistance et config agents. Configuration par agent stockée côté serveur, pushée aux agents via MQTT. **Tech Stack:** Go 1.22+, `modernc.org/sqlite`, `github.com/gorilla/websocket`, `github.com/eclipse/paho.mqtt.golang`, `github.com/prometheus/client_golang`, `github.com/disintegration/imaging` (resize icônes) --- ## Structure des fichiers ``` server/ ├── go.mod ├── go.sum ├── main.go — setup + démarrage des composants ├── config/ │ └── config.go — struct Config + chargement env/fichier ├── db/ │ ├── schema.sql — DDL SQLite │ └── db.go — init + fonctions CRUD ├── models/ │ └── models.go — structs partagées (AgentMetrics, AgentConfig…) ├── handlers/ │ ├── agents.go — GET/PUT /api/agents │ ├── metrics.go — GET /api/agents/{id}/history │ ├── config.go — GET/PUT /api/agents/{id}/config + GET/PUT /api/config │ └── icons.go — POST/GET /api/agents/{id}/icon ├── transport/ │ ├── udp.go — listener UDP + dispatch goroutines │ └── mqtt.go — subscriber + publisher MQTT ├── websocket/ │ ├── hub.go — Hub de broadcast │ └── handler.go — handler HTTP WebSocket ├── prometheus/ │ └── metrics.go — Gauges Prometheus ├── docker-compose.yml └── nginx/ └── nginx.conf ``` --- ### Task 1 : go.mod + config **Files:** - Create: `server/go.mod` - Create: `server/config/config.go` - [ ] **Initialiser le module Go** ```bash mkdir -p server && cd server go mod init github.com/user/nanometrics/server ``` - [ ] **Ajouter les dépendances** ```bash cd server go get modernc.org/sqlite go get github.com/gorilla/websocket go get github.com/eclipse/paho.mqtt.golang go get github.com/prometheus/client_golang/prometheus go get github.com/prometheus/client_golang/prometheus/promhttp go get github.com/disintegration/imaging ``` - [ ] **Créer `server/config/config.go`** ```go package config import ( "os" "strconv" ) type Config struct { UDPAddr string // ex: "0.0.0.0:9999" DBPath string // ex: "/data/nanometrics.db" HTTPAddr string // ex: "0.0.0.0:8080" MQTTBroker string // ex: "tcp://10.0.0.3:1883" MQTTTopicBase string // ex: "nanometrics/agents" } func Load() Config { return Config{ UDPAddr: getEnv("UDP_ADDR", "0.0.0.0:9999"), DBPath: getEnv("DB_PATH", "/data/nanometrics.db"), HTTPAddr: getEnv("HTTP_ADDR", "0.0.0.0:8080"), MQTTBroker: getEnv("MQTT_BROKER", "tcp://10.0.0.3:1883"), MQTTTopicBase: getEnv("MQTT_TOPIC_BASE", "nanometrics/agents"), } } func getEnv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } // Pour les tests func Default() Config { return Config{ UDPAddr: "127.0.0.1:19999", DBPath: ":memory:", HTTPAddr: "127.0.0.1:18080", MQTTBroker: "tcp://127.0.0.1:11883", MQTTTopicBase: "test/nanometrics", } } ``` - [ ] **Créer `server/main.go` (squelette)** ```go package main import ( "log" "github.com/user/nanometrics/server/config" ) func main() { cfg := config.Load() log.Printf("nanometrics-server démarrage sur %s", cfg.HTTPAddr) } ``` - [ ] **Vérifier la compilation** ```bash cd server && go build ./... ``` - [ ] **Commit** ```bash rtk git add server/ rtk git commit -m "feat(server): go.mod + config + squelette" ``` --- ### Task 2 : Modèles de données partagés **Files:** - Create: `server/models/models.go` - [ ] **Créer `server/models/models.go`** ```go package models // AgentMetrics — reçu de l'agent via UDP ou MQTT. type AgentMetrics struct { Hostname string `json:"hostname"` IP string `json:"ip"` Status string `json:"status"` CPUPercent *float64 `json:"cpu_percent"` MemoryUsed *int64 `json:"memory_used"` MemoryFree *int64 `json:"memory_free"` MemoryTotal *int64 `json:"memory_total"` HDDUsed *int64 `json:"hdd_used"` HDDFree *int64 `json:"hdd_free"` HDDTotal *int64 `json:"hdd_total"` Uptime *int64 `json:"uptime"` NetworkRX *int64 `json:"network_rx"` NetworkTX *int64 `json:"network_tx"` Temperature *float64 `json:"temperature"` Smart *SmartMetrics `json:"smart"` } type SmartMetrics struct { Passed bool `json:"passed"` Temperature *int64 `json:"temperature"` ReallocatedSectors *int64 `json:"reallocated_sectors"` PowerOnHours *int64 `json:"power_on_hours"` WearLevel *int64 `json:"wear_level"` } // Agent — état courant d'un agent (enrichi, pour l'API REST). type Agent struct { ID string `json:"id"` // = hostname Hostname string `json:"hostname"` IP string `json:"ip"` Status string `json:"status"` LastSeen int64 `json:"last_seen"` // unix timestamp LastMetrics *AgentMetrics `json:"last_metrics,omitempty"` } // AgentConfig — config d'un agent stockée sur le serveur. type AgentConfig struct { Metrics MetricsConfig `json:"metrics"` Protocols ProtocolsConfig `json:"protocols"` } type ProtocolsConfig struct { UDP UDPConfig `json:"udp"` MQTT MQTTConfig `json:"mqtt"` } type UDPConfig struct { Enabled bool `json:"enabled"` } type MQTTConfig struct { Enabled bool `json:"enabled"` Host string `json:"host"` Port int `json:"port"` TopicBase string `json:"topic_base"` AutoDiscovery bool `json:"auto_discovery"` BirthMessage bool `json:"birth_message"` LastWill bool `json:"last_will"` } type MetricsConfig struct { CPU MetricProto `json:"cpu"` Memory MetricProto `json:"memory"` Disk MetricProto `json:"disk"` Network MetricProto `json:"network"` Uptime MetricProto `json:"uptime"` Temperature MetricProto `json:"temperature"` Smart MetricProto `json:"smart"` } type MetricProto struct { UDP bool `json:"udp"` MQTT bool `json:"mqtt"` } // ServerConfig — paramètres UI/serveur. type ServerConfig struct { TileMinWidth int `json:"tile_min_width"` FontSize int `json:"font_size"` WarnCPU int `json:"warn_cpu"` ErrCPU int `json:"err_cpu"` WarnDisk int `json:"warn_disk"` RetentionDays int `json:"retention_days"` ChartDurationMin int `json:"chart_duration_min"` HideOffline bool `json:"hide_offline"` Notifications bool `json:"notifications"` PopupDetailW int `json:"popup_detail_w"` PopupDetailH int `json:"popup_detail_h"` } func DefaultServerConfig() ServerConfig { return ServerConfig{ TileMinWidth: 220, FontSize: 13, WarnCPU: 70, ErrCPU: 85, WarnDisk: 75, RetentionDays: 30, ChartDurationMin: 30, HideOffline: false, Notifications: true, PopupDetailW: 560, PopupDetailH: 600, } } // WSMessage — message WebSocket envoyé au dashboard. type WSMessage struct { Type string `json:"type"` // "metrics_update" | "agent_status" AgentID string `json:"agent_id"` Data interface{} `json:"data"` } ``` - [ ] **Vérifier la compilation** ```bash cd server && go build ./... ``` - [ ] **Commit** ```bash rtk git add server/models/ rtk git commit -m "feat(server): modèles AgentMetrics, Agent, Config" ``` --- ### Task 3 : Base de données SQLite **Files:** - Create: `server/db/schema.sql` - Create: `server/db/db.go` - [ ] **Créer `server/db/schema.sql`** ```sql CREATE TABLE IF NOT EXISTS agents ( id TEXT PRIMARY KEY, hostname TEXT NOT NULL, ip TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'offline', last_seen INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS metrics ( id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id TEXT NOT NULL, ts INTEGER NOT NULL, cpu_percent REAL, memory_used INTEGER, memory_free INTEGER, memory_total INTEGER, hdd_used INTEGER, hdd_free INTEGER, hdd_total INTEGER, uptime INTEGER, network_rx INTEGER, network_tx INTEGER, temperature REAL, smart_passed INTEGER, smart_temp INTEGER, smart_realloc INTEGER, smart_hours INTEGER, smart_wear INTEGER, FOREIGN KEY (agent_id) REFERENCES agents(id) ); CREATE INDEX IF NOT EXISTS idx_metrics_agent_ts ON metrics(agent_id, ts); CREATE TABLE IF NOT EXISTS agent_configs ( agent_id TEXT PRIMARY KEY, config_json TEXT NOT NULL DEFAULT '{}', FOREIGN KEY (agent_id) REFERENCES agents(id) ); CREATE TABLE IF NOT EXISTS agent_icons ( agent_id TEXT PRIMARY KEY, data BLOB NOT NULL, mime_type TEXT NOT NULL DEFAULT 'image/png', FOREIGN KEY (agent_id) REFERENCES agents(id) ); CREATE TABLE IF NOT EXISTS server_config ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); ``` - [ ] **Créer `server/db/db.go`** ```go package db import ( "database/sql" "encoding/json" "fmt" "log" "time" _ "modernc.org/sqlite" "github.com/user/nanometrics/server/models" ) type DB struct { conn *sql.DB } func Open(path string) (*DB, error) { conn, err := sql.Open("sqlite", path) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) } conn.SetMaxOpenConns(1) // SQLite : une seule connexion en écriture d := &DB{conn: conn} if err := d.migrate(); err != nil { return nil, fmt.Errorf("migrate: %w", err) } return d, nil } func (d *DB) migrate() error { _, err := d.conn.Exec(schema) return err } // UpsertAgent crée ou met à jour l'agent et sa dernière métrique. func (d *DB) UpsertAgent(m *models.AgentMetrics) error { ts := time.Now().Unix() _, err := d.conn.Exec(` INSERT INTO agents (id, hostname, ip, status, last_seen) VALUES (?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET ip=excluded.ip, status=excluded.status, last_seen=excluded.last_seen`, m.Hostname, m.Hostname, m.IP, m.Status, ts) return err } // InsertMetrics enregistre une ligne de métriques horodatée. func (d *DB) InsertMetrics(m *models.AgentMetrics) error { ts := time.Now().Unix() var smartPassed, smartTemp, smartRealloc, smartHours, smartWear interface{} if m.Smart != nil { b := 0 if m.Smart.Passed { b = 1 } smartPassed = b smartTemp = m.Smart.Temperature smartRealloc = m.Smart.ReallocatedSectors smartHours = m.Smart.PowerOnHours smartWear = m.Smart.WearLevel } _, err := d.conn.Exec(` INSERT INTO metrics (agent_id, ts, cpu_percent, memory_used, memory_free, memory_total, hdd_used, hdd_free, hdd_total, uptime, network_rx, network_tx, temperature, smart_passed, smart_temp, smart_realloc, smart_hours, smart_wear) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, m.Hostname, ts, m.CPUPercent, m.MemoryUsed, m.MemoryFree, m.MemoryTotal, m.HDDUsed, m.HDDFree, m.HDDTotal, m.Uptime, m.NetworkRX, m.NetworkTX, m.Temperature, smartPassed, smartTemp, smartRealloc, smartHours, smartWear) return err } // GetAgents retourne tous les agents avec leur dernière métrique. func (d *DB) GetAgents() ([]models.Agent, error) { rows, err := d.conn.Query(`SELECT id, hostname, ip, status, last_seen FROM agents`) if err != nil { return nil, err } defer rows.Close() var agents []models.Agent for rows.Next() { var a models.Agent if err := rows.Scan(&a.ID, &a.Hostname, &a.IP, &a.Status, &a.LastSeen); err != nil { return nil, err } agents = append(agents, a) } return agents, nil } // GetMetricsHistory retourne les métriques d'un agent entre from et to (unix ts). func (d *DB) GetMetricsHistory(agentID string, from, to int64) ([]map[string]interface{}, error) { rows, err := d.conn.Query(` SELECT ts, cpu_percent, memory_used, memory_total, hdd_used, hdd_total FROM metrics WHERE agent_id = ? AND ts >= ? AND ts <= ? ORDER BY ts ASC`, agentID, from, to) if err != nil { return nil, err } defer rows.Close() var result []map[string]interface{} for rows.Next() { var ts int64 var cpu, memUsed, memTotal, hddUsed, hddTotal interface{} if err := rows.Scan(&ts, &cpu, &memUsed, &memTotal, &hddUsed, &hddTotal); err != nil { return nil, err } result = append(result, map[string]interface{}{ "ts": ts, "cpu_percent": cpu, "memory_used": memUsed, "memory_total": memTotal, "hdd_used": hddUsed, "hdd_total": hddTotal, }) } return result, nil } // GetAgentConfig retourne la config stockée d'un agent. func (d *DB) GetAgentConfig(agentID string) (*models.AgentConfig, error) { var raw string err := d.conn.QueryRow( `SELECT config_json FROM agent_configs WHERE agent_id = ?`, agentID, ).Scan(&raw) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, err } var cfg models.AgentConfig if err := json.Unmarshal([]byte(raw), &cfg); err != nil { return nil, err } return &cfg, nil } // UpsertAgentConfig sauvegarde la config d'un agent. func (d *DB) UpsertAgentConfig(agentID string, cfg *models.AgentConfig) error { raw, err := json.Marshal(cfg) if err != nil { return err } // S'assurer que l'agent existe d.conn.Exec(`INSERT OR IGNORE INTO agents (id, hostname, ip, status, last_seen) VALUES (?,?,?,?,?)`, agentID, agentID, "", "offline", 0) _, err = d.conn.Exec(` INSERT INTO agent_configs (agent_id, config_json) VALUES (?, ?) ON CONFLICT(agent_id) DO UPDATE SET config_json=excluded.config_json`, agentID, string(raw)) return err } // GetServerConfig retourne la config serveur/UI. func (d *DB) GetServerConfig() (models.ServerConfig, error) { cfg := models.DefaultServerConfig() row := d.conn.QueryRow(`SELECT value FROM server_config WHERE key='ui'`) var raw string if err := row.Scan(&raw); err == nil { _ = json.Unmarshal([]byte(raw), &cfg) } return cfg, nil } // SetServerConfig sauvegarde la config serveur/UI. func (d *DB) SetServerConfig(cfg models.ServerConfig) error { raw, err := json.Marshal(cfg) if err != nil { return err } _, err = d.conn.Exec(` INSERT INTO server_config (key, value) VALUES ('ui', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`, string(raw)) return err } // SaveIcon stocke l'icône d'un agent. func (d *DB) SaveIcon(agentID string, data []byte, mimeType string) error { d.conn.Exec(`INSERT OR IGNORE INTO agents (id, hostname, ip, status, last_seen) VALUES (?,?,?,?,?)`, agentID, agentID, "", "offline", 0) _, err := d.conn.Exec(` INSERT INTO agent_icons (agent_id, data, mime_type) VALUES (?,?,?) ON CONFLICT(agent_id) DO UPDATE SET data=excluded.data, mime_type=excluded.mime_type`, agentID, data, mimeType) return err } // GetIcon retourne l'icône d'un agent. func (d *DB) GetIcon(agentID string) ([]byte, string, error) { var data []byte var mime string err := d.conn.QueryRow( `SELECT data, mime_type FROM agent_icons WHERE agent_id=?`, agentID, ).Scan(&data, &mime) if err != nil { return nil, "", err } return data, mime, nil } // PruneOldMetrics supprime les métriques plus anciennes que retentionDays. func (d *DB) PruneOldMetrics(retentionDays int) error { cutoff := time.Now().Unix() - int64(retentionDays)*86400 _, err := d.conn.Exec(`DELETE FROM metrics WHERE ts < ?`, cutoff) return err } // MarkOffline passe en "offline" les agents sans nouvelles depuis timeoutSec. func (d *DB) MarkOffline(timeoutSec int64) error { cutoff := time.Now().Unix() - timeoutSec _, err := d.conn.Exec( `UPDATE agents SET status='offline' WHERE last_seen < ? AND status != 'offline'`, cutoff) return err } // schema DDL const schema = ` CREATE TABLE IF NOT EXISTS agents ( id TEXT PRIMARY KEY, hostname TEXT NOT NULL, ip TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'offline', last_seen INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS metrics ( id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id TEXT NOT NULL, ts INTEGER NOT NULL, cpu_percent REAL, memory_used INTEGER, memory_free INTEGER, memory_total INTEGER, hdd_used INTEGER, hdd_free INTEGER, hdd_total INTEGER, uptime INTEGER, network_rx INTEGER, network_tx INTEGER, temperature REAL, smart_passed INTEGER, smart_temp INTEGER, smart_realloc INTEGER, smart_hours INTEGER, smart_wear INTEGER, FOREIGN KEY (agent_id) REFERENCES agents(id) ); CREATE INDEX IF NOT EXISTS idx_metrics_agent_ts ON metrics(agent_id, ts); CREATE TABLE IF NOT EXISTS agent_configs ( agent_id TEXT PRIMARY KEY, config_json TEXT NOT NULL DEFAULT '{}', FOREIGN KEY (agent_id) REFERENCES agents(id) ); CREATE TABLE IF NOT EXISTS agent_icons ( agent_id TEXT PRIMARY KEY, data BLOB NOT NULL, mime_type TEXT NOT NULL DEFAULT 'image/png', FOREIGN KEY (agent_id) REFERENCES agents(id) ); CREATE TABLE IF NOT EXISTS server_config (key TEXT PRIMARY KEY, value TEXT NOT NULL); ` // Pour les tests func (d *DB) Close() { _ = d.conn.Close() } func init() { log.SetFlags(log.LstdFlags | log.Lshortfile) } ``` - [ ] **Écrire les tests DB** Créer `server/db/db_test.go` : ```go package db_test import ( "testing" "github.com/user/nanometrics/server/db" "github.com/user/nanometrics/server/models" ) func newTestDB(t *testing.T) *db.DB { t.Helper() d, err := db.Open(":memory:") if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { d.Close() }) return d } func TestUpsertAndGetAgents(t *testing.T) { d := newTestDB(t) m := &models.AgentMetrics{Hostname: "srv-01", IP: "10.0.0.1", Status: "online"} if err := d.UpsertAgent(m); err != nil { t.Fatalf("upsert: %v", err) } agents, err := d.GetAgents() if err != nil { t.Fatalf("get agents: %v", err) } if len(agents) != 1 { t.Fatalf("attendu 1 agent, eu %d", len(agents)) } if agents[0].Hostname != "srv-01" { t.Errorf("hostname: attendu srv-01, eu %s", agents[0].Hostname) } } func TestInsertMetrics(t *testing.T) { d := newTestDB(t) cpu := 42.5 m := &models.AgentMetrics{Hostname: "srv-01", IP: "10.0.0.1", Status: "online", CPUPercent: &cpu} _ = d.UpsertAgent(m) if err := d.InsertMetrics(m); err != nil { t.Fatalf("insert metrics: %v", err) } history, err := d.GetMetricsHistory("srv-01", 0, 9999999999) if err != nil { t.Fatalf("history: %v", err) } if len(history) != 1 { t.Fatalf("attendu 1 entrée, eu %d", len(history)) } } func TestAgentConfig(t *testing.T) { d := newTestDB(t) cfg := &models.AgentConfig{ Metrics: models.MetricsConfig{ CPU: models.MetricProto{UDP: true, MQTT: false}, }, } if err := d.UpsertAgentConfig("srv-01", cfg); err != nil { t.Fatalf("upsert config: %v", err) } got, err := d.GetAgentConfig("srv-01") if err != nil || got == nil { t.Fatalf("get config: %v", err) } if !got.Metrics.CPU.UDP { t.Error("CPU.UDP devrait être true") } } func TestServerConfig(t *testing.T) { d := newTestDB(t) cfg := models.DefaultServerConfig() cfg.TileMinWidth = 300 if err := d.SetServerConfig(cfg); err != nil { t.Fatalf("set config: %v", err) } got, err := d.GetServerConfig() if err != nil { t.Fatalf("get config: %v", err) } if got.TileMinWidth != 300 { t.Errorf("tile_min_width: attendu 300, eu %d", got.TileMinWidth) } } ``` - [ ] **Lancer les tests DB** ```bash cd server && rtk go test ./db/... -v ``` Résultat attendu : `PASS` pour tous les tests. - [ ] **Commit** ```bash rtk git add server/db/ server/models/ rtk git commit -m "feat(server): DB SQLite + CRUD agents, metrics, config, icons" ``` --- ### Task 4 : Transport UDP **Files:** - Create: `server/transport/udp.go` - [ ] **Créer `server/transport/udp.go`** ```go package transport import ( "encoding/json" "log" "net" "github.com/user/nanometrics/server/models" ) // UDPListener écoute les datagrammes UDP et appelle le handler pour chaque métrique. func StartUDP(addr string, handler func(*models.AgentMetrics)) error { conn, err := net.ListenPacket("udp", addr) if err != nil { return err } log.Printf("[udp] écoute sur %s", addr) go func() { buf := make([]byte, 65535) for { n, _, err := conn.ReadFrom(buf) if err != nil { log.Printf("[udp] erreur lecture: %v", err) continue } // Goroutine par datagramme data := make([]byte, n) copy(data, buf[:n]) go processUDP(data, handler) } }() return nil } func processUDP(data []byte, handler func(*models.AgentMetrics)) { var m models.AgentMetrics if err := json.Unmarshal(data, &m); err != nil { log.Printf("[udp] JSON invalide: %v", err) return } if m.Hostname == "" { log.Printf("[udp] métrique sans hostname ignorée") return } handler(&m) } ``` - [ ] **Écrire le test UDP** Créer `server/transport/udp_test.go` : ```go package transport_test import ( "encoding/json" "net" "testing" "time" "github.com/user/nanometrics/server/models" "github.com/user/nanometrics/server/transport" ) func TestUDPReceive(t *testing.T) { received := make(chan *models.AgentMetrics, 1) err := transport.StartUDP("127.0.0.1:29999", func(m *models.AgentMetrics) { received <- m }) if err != nil { t.Fatalf("start UDP: %v", err) } // Envoyer un datagramme conn, err := net.Dial("udp", "127.0.0.1:29999") if err != nil { t.Fatalf("dial: %v", err) } defer conn.Close() cpu := 55.0 m := models.AgentMetrics{Hostname: "test-01", IP: "127.0.0.1", Status: "online", CPUPercent: &cpu} data, _ := json.Marshal(m) conn.Write(data) select { case got := <-received: if got.Hostname != "test-01" { t.Errorf("hostname: attendu test-01, eu %s", got.Hostname) } if got.CPUPercent == nil || *got.CPUPercent != 55.0 { t.Errorf("cpu_percent incorrect") } case <-time.After(time.Second): t.Error("timeout: aucune métrique reçue") } } ``` - [ ] **Lancer le test** ```bash cd server && rtk go test ./transport/... -run TestUDP -v ``` - [ ] **Commit** ```bash rtk git add server/transport/ rtk git commit -m "feat(server): listener UDP + test" ``` --- ### Task 5 : Hub WebSocket **Files:** - Create: `server/websocket/hub.go` - Create: `server/websocket/handler.go` - [ ] **Créer `server/websocket/hub.go`** ```go package websocket import ( "encoding/json" "log" "sync" "github.com/gorilla/websocket" ) type Hub struct { mu sync.RWMutex clients map[*websocket.Conn]struct{} } func NewHub() *Hub { return &Hub{clients: make(map[*websocket.Conn]struct{})} } func (h *Hub) Register(conn *websocket.Conn) { h.mu.Lock() h.clients[conn] = struct{}{} h.mu.Unlock() log.Printf("[ws] client connecté, total: %d", h.Count()) } func (h *Hub) Unregister(conn *websocket.Conn) { h.mu.Lock() delete(h.clients, conn) h.mu.Unlock() log.Printf("[ws] client déconnecté, total: %d", h.Count()) } // Broadcast envoie un message JSON à tous les clients connectés. func (h *Hub) Broadcast(msg interface{}) { data, err := json.Marshal(msg) if err != nil { log.Printf("[ws] marshal: %v", err) return } h.mu.RLock() defer h.mu.RUnlock() for conn := range h.clients { if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { log.Printf("[ws] write error: %v", err) } } } func (h *Hub) Count() int { h.mu.RLock() defer h.mu.RUnlock() return len(h.clients) } ``` - [ ] **Créer `server/websocket/handler.go`** ```go package websocket import ( "log" "net/http" "github.com/gorilla/websocket" ) var upgrader = websocket.Upgrader{ ReadBufferSize: 1024, WriteBufferSize: 4096, CheckOrigin: func(r *http.Request) bool { return true }, // LAN sans auth } // Handler retourne un http.HandlerFunc qui upgradet en WebSocket et gère le cycle de vie. func Handler(hub *Hub) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Printf("[ws] upgrade: %v", err) return } hub.Register(conn) defer func() { hub.Unregister(conn) conn.Close() }() // Lecture pour détecter la déconnexion (messages client ignorés) for { if _, _, err := conn.ReadMessage(); err != nil { break } } } } ``` - [ ] **Écrire le test WebSocket** Créer `server/websocket/hub_test.go` : ```go package websocket_test import ( "encoding/json" "net/http/httptest" "strings" "testing" "time" wslib "github.com/gorilla/websocket" "github.com/user/nanometrics/server/websocket" ) func TestHubBroadcast(t *testing.T) { hub := websocket.NewHub() srv := httptest.NewServer(websocket.Handler(hub)) defer srv.Close() url := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws" conn, _, err := wslib.DefaultDialer.Dial(url, nil) if err != nil { t.Fatalf("dial: %v", err) } defer conn.Close() time.Sleep(50 * time.Millisecond) // laisser Register se faire hub.Broadcast(map[string]string{"type": "test", "msg": "hello"}) conn.SetReadDeadline(time.Now().Add(time.Second)) _, data, err := conn.ReadMessage() if err != nil { t.Fatalf("read: %v", err) } var got map[string]string json.Unmarshal(data, &got) if got["msg"] != "hello" { t.Errorf("attendu hello, eu %s", got["msg"]) } } ``` - [ ] **Lancer le test** ```bash cd server && rtk go test ./websocket/... -v ``` - [ ] **Commit** ```bash rtk git add server/websocket/ rtk git commit -m "feat(server): hub WebSocket broadcast + test" ``` --- ### Task 6 : Prometheus /metrics **Files:** - Create: `server/prometheus/metrics.go` - [ ] **Créer `server/prometheus/metrics.go`** ```go package prometheus import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/user/nanometrics/server/models" ) var ( agentCPU = promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "nanometrics_cpu_percent", Help: "Pourcentage CPU de l'agent", }, []string{"agent"}) agentMemUsed = promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "nanometrics_memory_used_bytes", Help: "RAM utilisée en octets", }, []string{"agent"}) agentMemTotal = promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "nanometrics_memory_total_bytes", Help: "RAM totale en octets", }, []string{"agent"}) agentDiskUsed = promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "nanometrics_disk_used_bytes", Help: "Disque utilisé en octets", }, []string{"agent"}) agentDiskTotal = promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "nanometrics_disk_total_bytes", Help: "Disque total en octets", }, []string{"agent"}) agentUptime = promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "nanometrics_uptime_seconds", Help: "Uptime de l'agent en secondes", }, []string{"agent"}) agentStatus = promauto.NewGaugeVec(prometheus.GaugeOpts{ Name: "nanometrics_agent_online", Help: "1 si l'agent est en ligne, 0 sinon", }, []string{"agent"}) ) // Update met à jour toutes les Gauges pour un agent. func Update(m *models.AgentMetrics) { l := prometheus.Labels{"agent": m.Hostname} if m.CPUPercent != nil { agentCPU.With(l).Set(*m.CPUPercent) } if m.MemoryUsed != nil { agentMemUsed.With(l).Set(float64(*m.MemoryUsed)) } if m.MemoryTotal != nil { agentMemTotal.With(l).Set(float64(*m.MemoryTotal)) } if m.HDDUsed != nil { agentDiskUsed.With(l).Set(float64(*m.HDDUsed)) } if m.HDDTotal != nil { agentDiskTotal.With(l).Set(float64(*m.HDDTotal)) } if m.Uptime != nil { agentUptime.With(l).Set(float64(*m.Uptime)) } online := 0.0 if m.Status == "online" { online = 1.0 } agentStatus.With(l).Set(online) } ``` - [ ] **Vérifier la compilation** ```bash cd server && go build ./... ``` - [ ] **Commit** ```bash rtk git add server/prometheus/ rtk git commit -m "feat(server): Gauges Prometheus" ``` --- ### Task 7 : Handlers REST **Files:** - Create: `server/handlers/agents.go` - Create: `server/handlers/metrics.go` - Create: `server/handlers/config.go` - Create: `server/handlers/icons.go` - [ ] **Créer `server/handlers/agents.go`** ```go package handlers import ( "encoding/json" "net/http" "strings" "github.com/user/nanometrics/server/db" ) func AgentsHandler(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { agents, err := database.GetAgents() if err != nil { http.Error(w, err.Error(), 500) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(agents) } } ``` - [ ] **Créer `server/handlers/metrics.go`** ```go package handlers import ( "encoding/json" "net/http" "strconv" "strings" "time" "github.com/user/nanometrics/server/db" ) func MetricsHistoryHandler(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // URL: /api/agents/{id}/history parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") if len(parts) < 4 { http.Error(w, "invalid path", 400) return } agentID := parts[2] now := time.Now().Unix() from := now - 3600 // défaut : 1 heure to := now if v := r.URL.Query().Get("from"); v != "" { if n, err := strconv.ParseInt(v, 10, 64); err == nil { from = n } } if v := r.URL.Query().Get("to"); v != "" { if n, err := strconv.ParseInt(v, 10, 64); err == nil { to = n } } history, err := database.GetMetricsHistory(agentID, from, to) if err != nil { http.Error(w, err.Error(), 500) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(history) } } ``` - [ ] **Créer `server/handlers/config.go`** ```go package handlers import ( "encoding/json" "net/http" "strings" "github.com/user/nanometrics/server/db" "github.com/user/nanometrics/server/models" ) // AgentConfigHandler — GET/PUT /api/agents/{id}/config func AgentConfigHandler(database *db.DB, pushConfig func(agentID string, cfg *models.AgentConfig)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") if len(parts) < 4 { http.Error(w, "invalid path", 400) return } agentID := parts[2] switch r.Method { case http.MethodGet: cfg, err := database.GetAgentConfig(agentID) if err != nil { http.Error(w, err.Error(), 500) return } if cfg == nil { cfg = &models.AgentConfig{} } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(cfg) case http.MethodPut: var cfg models.AgentConfig if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { http.Error(w, err.Error(), 400) return } if err := database.UpsertAgentConfig(agentID, &cfg); err != nil { http.Error(w, err.Error(), 500) return } // Pousser la config à l'agent via MQTT si disponible if pushConfig != nil { go pushConfig(agentID, &cfg) } w.WriteHeader(http.StatusNoContent) default: http.Error(w, "method not allowed", 405) } } } // ServerConfigHandler — GET/PUT /api/config func ServerConfigHandler(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: cfg, err := database.GetServerConfig() if err != nil { http.Error(w, err.Error(), 500) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(cfg) case http.MethodPut: var cfg models.ServerConfig if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { http.Error(w, err.Error(), 400) return } if err := database.SetServerConfig(cfg); err != nil { http.Error(w, err.Error(), 500) return } w.WriteHeader(http.StatusNoContent) default: http.Error(w, "method not allowed", 405) } } } ``` - [ ] **Créer `server/handlers/icons.go`** ```go package handlers import ( "bytes" "image" _ "image/jpeg" _ "image/png" "image/png" "net/http" "strings" "github.com/disintegration/imaging" "github.com/user/nanometrics/server/db" ) const maxIconSize = 128 // IconUploadHandler — POST /api/agents/{id}/icon func IconUploadHandler(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) return } parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") if len(parts) < 4 { http.Error(w, "invalid path", 400) return } agentID := parts[2] r.ParseMultipartForm(2 << 20) // 2 Mo max file, header, err := r.FormFile("icon") if err != nil { http.Error(w, "fichier manquant", 400) return } defer file.Close() // Détecter le type MIME mime := header.Header.Get("Content-Type") if mime == "" { mime = "image/png" } // SVG : stocker tel quel (pas de redimensionnement) if strings.Contains(mime, "svg") { buf := new(bytes.Buffer) buf.ReadFrom(file) if err := database.SaveIcon(agentID, buf.Bytes(), "image/svg+xml"); err != nil { http.Error(w, err.Error(), 500) return } w.WriteHeader(http.StatusNoContent) return } // Raster : décoder + redimensionner max 128×128 img, _, err := image.Decode(file) if err != nil { http.Error(w, "image invalide", 400) return } resized := imaging.Fit(img, maxIconSize, maxIconSize, imaging.Lanczos) var buf bytes.Buffer if err := png.Encode(&buf, resized); err != nil { http.Error(w, err.Error(), 500) return } if err := database.SaveIcon(agentID, buf.Bytes(), "image/png"); err != nil { http.Error(w, err.Error(), 500) return } w.WriteHeader(http.StatusNoContent) } } // IconGetHandler — GET /api/agents/{id}/icon func IconGetHandler(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/") if len(parts) < 4 { http.Error(w, "invalid path", 400) return } agentID := parts[2] data, mime, err := database.GetIcon(agentID) if err != nil { http.Error(w, "not found", 404) return } w.Header().Set("Content-Type", mime) w.Write(data) } } ``` - [ ] **Lancer les tests handlers** Créer `server/handlers/config_test.go` : ```go package handlers_test import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/user/nanometrics/server/db" "github.com/user/nanometrics/server/handlers" "github.com/user/nanometrics/server/models" ) func testDB(t *testing.T) *db.DB { d, _ := db.Open(":memory:") t.Cleanup(func() { d.Close() }) return d } func TestServerConfigGetPut(t *testing.T) { d := testDB(t) h := handlers.ServerConfigHandler(d) // GET défaut r := httptest.NewRequest(http.MethodGet, "/api/config", nil) w := httptest.NewRecorder() h(w, r) if w.Code != 200 { t.Fatalf("GET status: %d", w.Code) } var got models.ServerConfig json.NewDecoder(w.Body).Decode(&got) if got.TileMinWidth != 220 { t.Errorf("tile_min_width défaut: %d", got.TileMinWidth) } // PUT cfg := models.DefaultServerConfig() cfg.TileMinWidth = 300 body, _ := json.Marshal(cfg) r2 := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewReader(body)) w2 := httptest.NewRecorder() h(w2, r2) if w2.Code != 204 { t.Fatalf("PUT status: %d", w2.Code) } // Vérifier r3 := httptest.NewRequest(http.MethodGet, "/api/config", nil) w3 := httptest.NewRecorder() h(w3, r3) var got2 models.ServerConfig json.NewDecoder(w3.Body).Decode(&got2) if got2.TileMinWidth != 300 { t.Errorf("tile_min_width après PUT: %d", got2.TileMinWidth) } } ``` ```bash cd server && rtk go test ./handlers/... -v ``` - [ ] **Commit** ```bash rtk git add server/handlers/ server/prometheus/ rtk git commit -m "feat(server): handlers REST agents, metrics, config, icons" ``` --- ### Task 8 : Transport MQTT serveur **Files:** - Create: `server/transport/mqtt.go` - [ ] **Créer `server/transport/mqtt.go`** ```go package transport import ( "encoding/json" "fmt" "log" mqtt "github.com/eclipse/paho.mqtt.golang" "github.com/user/nanometrics/server/models" ) type MQTTClient struct { client mqtt.Client topicBase string } // StartMQTT se connecte au broker et subscribe sur topicBase/#. // handler est appelé pour chaque métrique reçue. func StartMQTT(broker, topicBase string, handler func(*models.AgentMetrics)) (*MQTTClient, error) { opts := mqtt.NewClientOptions(). AddBroker(broker). SetClientID("nanometrics-server"). SetAutoReconnect(true). SetOnConnectHandler(func(c mqtt.Client) { log.Printf("[mqtt] connecté à %s", broker) // Subscribe sur topicBase/+/metrics topic := fmt.Sprintf("%s/+/metrics", topicBase) if tok := c.Subscribe(topic, 0, nil); tok.Wait() && tok.Error() != nil { log.Printf("[mqtt] subscribe error: %v", tok.Error()) } }). SetConnectionLostHandler(func(c mqtt.Client, err error) { log.Printf("[mqtt] connexion perdue: %v", err) }) mc := &MQTTClient{topicBase: topicBase} opts.SetDefaultPublishHandler(func(_ mqtt.Client, msg mqtt.Message) { var m models.AgentMetrics if err := json.Unmarshal(msg.Payload(), &m); err != nil { log.Printf("[mqtt] JSON invalide sur %s: %v", msg.Topic(), err) return } if m.Hostname != "" { handler(&m) } }) mc.client = mqtt.NewClient(opts) if tok := mc.client.Connect(); tok.Wait() && tok.Error() != nil { return nil, fmt.Errorf("mqtt connect: %w", tok.Error()) } return mc, nil } // PushConfig publie la config d'un agent sur {topicBase}/{hostname}/config. func (mc *MQTTClient) PushConfig(hostname string, cfg *models.AgentConfig) error { topic := fmt.Sprintf("%s/%s/config", mc.topicBase, hostname) data, err := json.Marshal(cfg) if err != nil { return err } tok := mc.client.Publish(topic, 1, false, data) tok.Wait() return tok.Error() } ``` - [ ] **Vérifier la compilation** ```bash cd server && go build ./... ``` - [ ] **Commit** ```bash rtk git add server/transport/mqtt.go rtk git commit -m "feat(server): subscriber MQTT + push config agent" ``` --- ### Task 9 : main.go — assemblage complet **Files:** - Modify: `server/main.go` - [ ] **Écrire `server/main.go` complet** ```go package main import ( "log" "net/http" "time" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/user/nanometrics/server/config" "github.com/user/nanometrics/server/db" "github.com/user/nanometrics/server/handlers" "github.com/user/nanometrics/server/models" prom "github.com/user/nanometrics/server/prometheus" "github.com/user/nanometrics/server/transport" ws "github.com/user/nanometrics/server/websocket" ) func main() { cfg := config.Load() // DB database, err := db.Open(cfg.DBPath) if err != nil { log.Fatalf("DB: %v", err) } // WebSocket hub hub := ws.NewHub() // Handler metrics (UDP + MQTT partagent le même) onMetrics := func(m *models.AgentMetrics) { if err := database.UpsertAgent(m); err != nil { log.Printf("[ingest] upsert agent: %v", err) } if err := database.InsertMetrics(m); err != nil { log.Printf("[ingest] insert metrics: %v", err) } prom.Update(m) hub.Broadcast(models.WSMessage{ Type: "metrics_update", AgentID: m.Hostname, Data: m, }) } // UDP if err := transport.StartUDP(cfg.UDPAddr, onMetrics); err != nil { log.Fatalf("UDP: %v", err) } // MQTT (optionnel — erreur non fatale) var mqttClient *transport.MQTTClient mc, err := transport.StartMQTT(cfg.MQTTBroker, cfg.MQTTTopicBase, onMetrics) if err != nil { log.Printf("[mqtt] non disponible: %v", err) } else { mqttClient = mc } pushConfig := func(agentID string, agentCfg *models.AgentConfig) { if mqttClient != nil { if err := mqttClient.PushConfig(agentID, agentCfg); err != nil { log.Printf("[mqtt] push config to %s: %v", agentID, err) } } } // Maintenance périodique go func() { for range time.Tick(time.Minute) { srvCfg, _ := database.GetServerConfig() _ = database.PruneOldMetrics(srvCfg.RetentionDays) _ = database.MarkOffline(30) // offline après 30s sans nouvelles } }() // Routes HTTP mux := http.NewServeMux() mux.Handle("/metrics", promhttp.Handler()) mux.Handle("/ws", ws.Handler(hub)) mux.HandleFunc("/api/agents", handlers.AgentsHandler(database)) mux.HandleFunc("/api/agents/", func(w http.ResponseWriter, r *http.Request) { switch { case endsWith(r.URL.Path, "/history"): handlers.MetricsHistoryHandler(database)(w, r) case endsWith(r.URL.Path, "/config"): handlers.AgentConfigHandler(database, pushConfig)(w, r) case endsWith(r.URL.Path, "/icon") && r.Method == http.MethodPost: handlers.IconUploadHandler(database)(w, r) case endsWith(r.URL.Path, "/icon") && r.Method == http.MethodGet: handlers.IconGetHandler(database)(w, r) default: http.NotFound(w, r) } }) mux.HandleFunc("/api/config", handlers.ServerConfigHandler(database)) log.Printf("[http] écoute sur %s", cfg.HTTPAddr) log.Fatal(http.ListenAndServe(cfg.HTTPAddr, mux)) } func endsWith(path, suffix string) bool { return len(path) >= len(suffix) && path[len(path)-len(suffix):] == suffix } ``` - [ ] **Compiler** ```bash cd server && go build -o nanometrics-server . && echo "OK" ``` - [ ] **Lancer tous les tests** ```bash cd server && rtk go test ./... -v ``` - [ ] **Commit** ```bash rtk git add server/main.go rtk git commit -m "feat(server): assemblage complet main.go" ``` --- ### Task 10 : Docker Compose + Nginx **Files:** - Create: `server/docker-compose.yml` - Create: `server/nginx/nginx.conf` - Create: `server/Dockerfile` - [ ] **Créer `server/Dockerfile`** ```dockerfile FROM golang:1.22-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o nanometrics-server . FROM alpine:3.19 RUN apk add --no-cache ca-certificates WORKDIR /app COPY --from=builder /app/nanometrics-server . VOLUME /data EXPOSE 8080 9999/udp CMD ["./nanometrics-server"] ``` - [ ] **Créer `server/nginx/nginx.conf`** ```nginx server { listen 80; root /usr/share/nginx/html; index index.html; location /api/ { proxy_pass http://server:8080; proxy_set_header Host $host; } location /ws { proxy_pass http://server:8080/ws; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } location /metrics { proxy_pass http://server:8080/metrics; } location / { try_files $uri $uri/ /index.html; } } ``` - [ ] **Créer `server/docker-compose.yml`** ```yaml version: '3.8' services: server: build: . restart: unless-stopped environment: UDP_ADDR: "0.0.0.0:9999" DB_PATH: "/data/nanometrics.db" HTTP_ADDR: "0.0.0.0:8080" MQTT_BROKER: "tcp://10.0.0.3:1883" MQTT_TOPIC_BASE: "nanometrics/agents" volumes: - nanometrics_data:/data ports: - "9999:9999/udp" dashboard: image: nginx:alpine restart: unless-stopped volumes: - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro - ../dashboard:/usr/share/nginx/html:ro ports: - "80:80" depends_on: - server volumes: nanometrics_data: ``` - [ ] **Tester le build Docker** ```bash cd server && docker build -t nanometrics-server . && echo "Build OK" ``` - [ ] **Commit** ```bash rtk git add server/Dockerfile server/docker-compose.yml server/nginx/ rtk git commit -m "feat(server): Docker + docker-compose + Nginx" ```