Ajoute les trois plans d'implémentation (agent Rust, serveur Go, dashboard), les consignes de design, les fichiers de brainstorming et le .gitignore. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
47 KiB
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
mkdir -p server && cd server
go mod init github.com/user/nanometrics/server
- Ajouter les dépendances
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
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)
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
cd server && go build ./...
- Commit
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
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
cd server && go build ./...
- Commit
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
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
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 :
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
cd server && rtk go test ./db/... -v
Résultat attendu : PASS pour tous les tests.
- Commit
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
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 :
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
cd server && rtk go test ./transport/... -run TestUDP -v
- Commit
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
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
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 :
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
cd server && rtk go test ./websocket/... -v
- Commit
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
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
cd server && go build ./...
- Commit
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
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
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
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
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 :
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) }
}
cd server && rtk go test ./handlers/... -v
- Commit
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
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
cd server && go build ./...
- Commit
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.gocomplet
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
cd server && go build -o nanometrics-server . && echo "OK"
- Lancer tous les tests
cd server && rtk go test ./... -v
- Commit
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
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
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
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
cd server && docker build -t nanometrics-server . && echo "Build OK"
- Commit
rtk git add server/Dockerfile server/docker-compose.yml server/nginx/
rtk git commit -m "feat(server): Docker + docker-compose + Nginx"