ff6cf1cd5e
Serveur:
- Modèles Go: NetworkInterface, HardwareInfo dans Agent + AgentMetrics
- DB: migrations network_info_json + hardware_info_json dans agents
- UpsertAgent: stocke les données lentes si présentes dans le payload
- GetAgents: désérialise network_info_json + hardware_info_json
- GET /api/agents/{id}: endpoint single agent
- docker-compose: service iperf3 (port 5201)
Dashboard:
- Popup détail: section RÉSEAU (tableau interfaces: type, vitesse, MAC, WoL, iperf3)
- Popup détail: section HARDWARE (carte mère, CPU, RAM slots/type/vitesse)
- CSS: .net-table/.net-row pour le tableau réseau
- Font-size global appliqué sur html root (au lieu de body)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
366 lines
12 KiB
Go
366 lines
12 KiB
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
|
|
}
|
|
|
|
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, version TEXT NOT NULL DEFAULT ''
|
|
);
|
|
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 {
|
|
if _, err := d.conn.Exec(schema); err != nil {
|
|
return err
|
|
}
|
|
// Migrations additives — ignorées si la colonne existe déjà
|
|
_, _ = d.conn.Exec(`ALTER TABLE agents ADD COLUMN version TEXT NOT NULL DEFAULT ''`)
|
|
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_passed INTEGER`)
|
|
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_temp INTEGER`)
|
|
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_realloc INTEGER`)
|
|
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_hours INTEGER`)
|
|
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_wear INTEGER`)
|
|
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_json TEXT`)
|
|
_, _ = d.conn.Exec(`ALTER TABLE agents ADD COLUMN network_info_json TEXT`)
|
|
_, _ = d.conn.Exec(`ALTER TABLE agents ADD COLUMN hardware_info_json TEXT`)
|
|
return nil
|
|
}
|
|
|
|
func (d *DB) Close() { _ = d.conn.Close() }
|
|
|
|
func (d *DB) UpsertAgent(m *models.AgentMetrics) error {
|
|
ts := time.Now().Unix()
|
|
var netJSON, hwJSON interface{}
|
|
if len(m.NetworkInfo) > 0 {
|
|
if b, err := json.Marshal(m.NetworkInfo); err == nil {
|
|
netJSON = string(b)
|
|
}
|
|
}
|
|
if m.HardwareInfo != nil {
|
|
if b, err := json.Marshal(m.HardwareInfo); err == nil {
|
|
hwJSON = string(b)
|
|
}
|
|
}
|
|
_, err := d.conn.Exec(`
|
|
INSERT INTO agents (id, hostname, ip, status, last_seen, version,
|
|
network_info_json, hardware_info_json)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
ip=excluded.ip, status=excluded.status, last_seen=excluded.last_seen,
|
|
version=CASE WHEN excluded.version != '' THEN excluded.version ELSE version END,
|
|
network_info_json=CASE WHEN excluded.network_info_json IS NOT NULL THEN excluded.network_info_json ELSE network_info_json END,
|
|
hardware_info_json=CASE WHEN excluded.hardware_info_json IS NOT NULL THEN excluded.hardware_info_json ELSE hardware_info_json END`,
|
|
m.Hostname, m.Hostname, m.IP, m.Status, ts, m.Version, netJSON, hwJSON)
|
|
return err
|
|
}
|
|
|
|
func (d *DB) InsertMetrics(m *models.AgentMetrics) error {
|
|
ts := time.Now().Unix()
|
|
var smartJSON interface{}
|
|
if len(m.Smart) > 0 {
|
|
if b, err := json.Marshal(m.Smart); err == nil {
|
|
smartJSON = string(b)
|
|
}
|
|
}
|
|
_, 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_json)
|
|
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,
|
|
smartJSON)
|
|
return err
|
|
}
|
|
|
|
func (d *DB) GetAgents() ([]models.Agent, error) {
|
|
rows, err := d.conn.Query(`SELECT id, hostname, ip, status, last_seen, version,
|
|
network_info_json, hardware_info_json FROM agents`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var agents []models.Agent
|
|
for rows.Next() {
|
|
var a models.Agent
|
|
var netJSON, hwJSON *string
|
|
if err := rows.Scan(&a.ID, &a.Hostname, &a.IP, &a.Status, &a.LastSeen, &a.Version,
|
|
&netJSON, &hwJSON); err != nil {
|
|
return nil, err
|
|
}
|
|
if netJSON != nil {
|
|
_ = json.Unmarshal([]byte(*netJSON), &a.NetworkInfo)
|
|
}
|
|
if hwJSON != nil {
|
|
_ = json.Unmarshal([]byte(*hwJSON), &a.HardwareInfo)
|
|
}
|
|
agents = append(agents, a)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return agents, nil
|
|
}
|
|
|
|
func (d *DB) GetLastMetrics(agentID string) (*models.AgentMetrics, error) {
|
|
var cpu, temperature *float64
|
|
var memUsed, memFree, memTotal, hddUsed, hddFree, hddTotal *int64
|
|
var uptime, netRX, netTX *int64
|
|
var smartJSON *string
|
|
|
|
err := d.conn.QueryRow(`
|
|
SELECT
|
|
(SELECT cpu_percent FROM metrics WHERE agent_id=? AND cpu_percent IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
|
(SELECT memory_used FROM metrics WHERE agent_id=? AND memory_used IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
|
(SELECT memory_free FROM metrics WHERE agent_id=? AND memory_free IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
|
(SELECT memory_total FROM metrics WHERE agent_id=? AND memory_total IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
|
(SELECT hdd_used FROM metrics WHERE agent_id=? AND hdd_used IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
|
(SELECT hdd_free FROM metrics WHERE agent_id=? AND hdd_free IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
|
(SELECT hdd_total FROM metrics WHERE agent_id=? AND hdd_total IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
|
(SELECT uptime FROM metrics WHERE agent_id=? AND uptime IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
|
(SELECT network_rx FROM metrics WHERE agent_id=? AND network_rx IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
|
(SELECT network_tx FROM metrics WHERE agent_id=? AND network_tx IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
|
(SELECT temperature FROM metrics WHERE agent_id=? AND temperature IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
|
(SELECT smart_json FROM metrics WHERE agent_id=? AND smart_json IS NOT NULL ORDER BY ts DESC LIMIT 1)`,
|
|
agentID, agentID, agentID, agentID,
|
|
agentID, agentID, agentID,
|
|
agentID, agentID, agentID, agentID,
|
|
agentID).
|
|
Scan(&cpu, &memUsed, &memFree, &memTotal,
|
|
&hddUsed, &hddFree, &hddTotal,
|
|
&uptime, &netRX, &netTX, &temperature,
|
|
&smartJSON)
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m := &models.AgentMetrics{
|
|
CPUPercent: cpu,
|
|
MemoryUsed: memUsed,
|
|
MemoryFree: memFree,
|
|
MemoryTotal: memTotal,
|
|
HDDUsed: hddUsed,
|
|
HDDFree: hddFree,
|
|
HDDTotal: hddTotal,
|
|
Uptime: uptime,
|
|
NetworkRX: netRX,
|
|
NetworkTX: netTX,
|
|
Temperature: temperature,
|
|
}
|
|
if smartJSON != nil {
|
|
_ = json.Unmarshal([]byte(*smartJSON), &m.Smart)
|
|
}
|
|
return m, 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,
|
|
})
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
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 {
|
|
_, err := d.MarkOfflineAndGetIDs(timeoutSec)
|
|
return err
|
|
}
|
|
|
|
func (d *DB) DeleteAgent(agentID string) error {
|
|
for _, q := range []string{
|
|
`DELETE FROM metrics WHERE agent_id = ?`,
|
|
`DELETE FROM agent_configs WHERE agent_id = ?`,
|
|
`DELETE FROM agent_icons WHERE agent_id = ?`,
|
|
`DELETE FROM agents WHERE id = ?`,
|
|
} {
|
|
if _, err := d.conn.Exec(q, agentID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MarkOfflineAndGetIDs marque les agents inactifs et retourne leurs IDs.
|
|
func (d *DB) MarkOfflineAndGetIDs(timeoutSec int64) ([]string, error) {
|
|
cutoff := time.Now().Unix() - timeoutSec
|
|
rows, err := d.conn.Query(
|
|
`SELECT id FROM agents WHERE last_seen < ? AND status != 'offline'`, cutoff)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var ids []string
|
|
for rows.Next() {
|
|
var id string
|
|
_ = rows.Scan(&id)
|
|
ids = append(ids, id)
|
|
}
|
|
if err = rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
rows.Close()
|
|
if len(ids) > 0 {
|
|
_, err = d.conn.Exec(
|
|
`UPDATE agents SET status='offline' WHERE last_seen < ? AND status != 'offline'`, cutoff)
|
|
}
|
|
return ids, err
|
|
}
|
|
|
|
func init() {
|
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
|
}
|