a0f47bf966
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>
1747 lines
47 KiB
Markdown
1747 lines
47 KiB
Markdown
# 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"
|
||
```
|