# 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"
```
