diff --git a/dashboard/js/app.js b/dashboard/js/app.js index 4546ba4..016e650 100644 --- a/dashboard/js/app.js +++ b/dashboard/js/app.js @@ -43,6 +43,31 @@ const App = (() => { new Date().toLocaleTimeString('fr-FR'); } + function updateServerStats(stats) { + const cpu = stats.cpu_percent ?? 0; + const memPct = stats.mem_total > 0 ? (stats.mem_used / stats.mem_total * 100) : 0; + const cpuEl = document.getElementById('srv-cpu'); + const memEl = document.getElementById('srv-mem'); + const cpuBar = document.getElementById('srv-cpu-bar'); + const memBar = document.getElementById('srv-mem-bar'); + if (cpuEl) { + cpuEl.textContent = cpu.toFixed(0) + '%'; + cpuEl.className = 'f-val' + (cpu >= 70 ? ' w' : ''); + } + if (cpuBar) { + cpuBar.style.width = cpu.toFixed(0) + '%'; + cpuBar.className = 'f-minifill' + (cpu >= 70 ? ' w' : ''); + } + if (memEl) { + memEl.textContent = memPct.toFixed(0) + '%'; + memEl.className = 'f-val' + (memPct >= 70 ? ' w' : ''); + } + if (memBar) { + memBar.style.width = memPct.toFixed(0) + '%'; + memBar.className = 'f-minifill' + (memPct >= 70 ? ' w' : ''); + } + } + function connectWS() { const proto = location.protocol === 'https:' ? 'wss' : 'ws'; _ws = new WebSocket(`${proto}://${location.host}/ws`); @@ -58,6 +83,10 @@ const App = (() => { if (msg.type === 'metrics_update') { Grid.update(msg.agent_id, msg.data); updateClock(); + } else if (msg.type === 'server_stats') { + updateServerStats(msg.data); + } else if (msg.type === 'status_update') { + Grid.updateStatus(msg.agent_id, msg.data.status); } } catch {} }; diff --git a/dashboard/js/grid.js b/dashboard/js/grid.js index 06dcf65..baee878 100644 --- a/dashboard/js/grid.js +++ b/dashboard/js/grid.js @@ -141,5 +141,14 @@ const Grid = (() => { function getAgent(id) { return _agents.get(id); } - return { refresh, update, getAgent, fmt, fmtPct }; + function updateStatus(agentId, status) { + const entry = _agents.get(agentId); + if (!entry) return; + entry.agent.status = status; + const el = document.getElementById('tile-' + agentId); + if (el) el.outerHTML = renderTile(entry.agent, entry.metrics); + updateStats(); + } + + return { refresh, update, updateStatus, getAgent, fmt, fmtPct }; })(); diff --git a/server/db/db.go b/server/db/db.go index 4afcf46..2252a05 100644 --- a/server/db/db.go +++ b/server/db/db.go @@ -234,13 +234,35 @@ func (d *DB) PruneOldMetrics(retentionDays int) error { } 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) + _, err := d.MarkOfflineAndGetIDs(timeoutSec) return err } +// 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) } diff --git a/server/main.go b/server/main.go index b39bd27..e489177 100644 --- a/server/main.go +++ b/server/main.go @@ -1,8 +1,13 @@ package main import ( + "bufio" + "fmt" "log" "net/http" + "os" + "strconv" + "strings" "time" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -59,13 +64,35 @@ func main() { } } + // Maintenance : nettoyage + détection offline avec notification WS go func() { ticker := time.NewTicker(time.Minute) defer ticker.Stop() for range ticker.C { srvCfg, _ := database.GetServerConfig() _ = database.PruneOldMetrics(srvCfg.RetentionDays) - _ = database.MarkOffline(30) + ids, _ := database.MarkOfflineAndGetIDs(30) + for _, id := range ids { + hub.Broadcast(models.WSMessage{ + Type: "status_update", + AgentID: id, + Data: map[string]string{"status": "offline"}, + }) + } + } + }() + + // Métriques du serveur lui-même → footer du dashboard + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + var prevIdle, prevTotal uint64 + for range ticker.C { + stats, err := collectServerStats(&prevIdle, &prevTotal) + if err != nil { + continue + } + hub.Broadcast(models.WSMessage{Type: "server_stats", Data: stats}) } }() @@ -96,3 +123,92 @@ func main() { func endsWith(path, suffix string) bool { return len(path) >= len(suffix) && path[len(path)-len(suffix):] == suffix } + +func collectServerStats(prevIdle, prevTotal *uint64) (*models.ServerStats, error) { + idle, total, err := readCPUStat() + if err != nil { + return nil, err + } + var cpuPct float64 + if *prevTotal > 0 && total > *prevTotal { + deltaIdle := float64(idle - *prevIdle) + deltaTotal := float64(total - *prevTotal) + cpuPct = 100.0 * (1.0 - deltaIdle/deltaTotal) + if cpuPct < 0 { + cpuPct = 0 + } + } + *prevIdle = idle + *prevTotal = total + + memTotal, memAvail, err := readMemInfo() + if err != nil { + return nil, err + } + return &models.ServerStats{ + CPUPercent: cpuPct, + MemUsed: memTotal - memAvail, + MemTotal: memTotal, + }, nil +} + +func readCPUStat() (idle, total uint64, err error) { + f, err := os.Open("/proc/stat") + if err != nil { + return 0, 0, err + } + defer f.Close() + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "cpu ") { + continue + } + fields := strings.Fields(line)[1:] + var vals [10]uint64 + for i, v := range fields { + if i >= 10 { + break + } + vals[i], _ = strconv.ParseUint(v, 10, 64) + } + // idle = idle + iowait + idle = vals[3] + vals[4] + for _, v := range vals { + total += v + } + return idle, total, nil + } + return 0, 0, fmt.Errorf("cpu line not found in /proc/stat") +} + +func readMemInfo() (totalBytes, availBytes int64, err error) { + f, err := os.Open("/proc/meminfo") + if err != nil { + return 0, 0, err + } + defer f.Close() + scanner := bufio.NewScanner(f) + var total, avail int64 + for scanner.Scan() { + line := scanner.Text() + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + val, _ := strconv.ParseInt(fields[1], 10, 64) + switch fields[0] { + case "MemTotal:": + total = val * 1024 + case "MemAvailable:": + avail = val * 1024 + } + if total > 0 && avail > 0 { + break + } + } + if total == 0 { + return 0, 0, fmt.Errorf("MemTotal not found in /proc/meminfo") + } + return total, avail, nil +} diff --git a/server/models/models.go b/server/models/models.go index 13e67e5..39759ff 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -103,3 +103,9 @@ type WSMessage struct { AgentID string `json:"agent_id"` Data interface{} `json:"data"` } + +type ServerStats struct { + CPUPercent float64 `json:"cpu_percent"` + MemUsed int64 `json:"mem_used"` + MemTotal int64 `json:"mem_total"` +}