From bceee08ce49bf98783b40221a88c3c99c3a1d7e0 Mon Sep 17 00:00:00 2001 From: Gilles Soulier Date: Fri, 22 May 2026 12:06:11 +0200 Subject: [PATCH] feat(server): DB SQLite CRUD agents/metrics/config/icons + tests Co-Authored-By: Claude Sonnet 4.6 --- server/db/db.go | 240 +++++++++++++++++++++++++++++++++++++++++++ server/db/db_test.go | 88 ++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 server/db/db.go create mode 100644 server/db/db_test.go diff --git a/server/db/db.go b/server/db/db.go new file mode 100644 index 0000000..1716064 --- /dev/null +++ b/server/db/db.go @@ -0,0 +1,240 @@ +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 +} + +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); +` + +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) + 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 +} + +func (d *DB) Close() { _ = d.conn.Close() } + +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 +} + +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 +} + +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 +} + +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 +} + +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 +} + +func (d *DB) UpsertAgentConfig(agentID string, cfg *models.AgentConfig) error { + raw, err := json.Marshal(cfg) + if err != nil { + return err + } + 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 +} + +func (d *DB) GetServerConfig() (models.ServerConfig, error) { + cfg := models.DefaultServerConfig() + var raw string + if err := d.conn.QueryRow(`SELECT value FROM server_config WHERE key='ui'`).Scan(&raw); err == nil { + _ = json.Unmarshal([]byte(raw), &cfg) + } + return cfg, nil +} + +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 +} + +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 +} + +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 +} + +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 +} + +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 +} + +func init() { + log.SetFlags(log.LstdFlags | log.Lshortfile) +} diff --git a/server/db/db_test.go b/server/db/db_test.go new file mode 100644 index 0000000..a108c46 --- /dev/null +++ b/server/db/db_test.go @@ -0,0 +1,88 @@ +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) + } +}