Files
nano_metrics/docs/superpowers/plans/2026-05-22-nanometrics-server-go.md
T
Gilles Soulier a0f47bf966 feat: add plans, design system, CONSIGNE and brainstorm assets
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>
2026-05-22 08:13:53 +02:00

1747 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"
```