diff --git a/server/handlers/agents.go b/server/handlers/agents.go new file mode 100644 index 0000000..47ace01 --- /dev/null +++ b/server/handlers/agents.go @@ -0,0 +1,20 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "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) + } +} diff --git a/server/handlers/config.go b/server/handlers/config.go new file mode 100644 index 0000000..69d7fef --- /dev/null +++ b/server/handlers/config.go @@ -0,0 +1,83 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/user/nanometrics/server/db" + "github.com/user/nanometrics/server/models" +) + +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 + } + if pushConfig != nil { + go pushConfig(agentID, &cfg) + } + w.WriteHeader(http.StatusNoContent) + + default: + http.Error(w, "method not allowed", 405) + } + } +} + +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) + } + } +} diff --git a/server/handlers/config_test.go b/server/handlers/config_test.go new file mode 100644 index 0000000..b9ceb87 --- /dev/null +++ b/server/handlers/config_test.go @@ -0,0 +1,72 @@ +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) + + 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) + } + + 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) + } + + 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) + } +} + +func TestAgentsHandler(t *testing.T) { + d := testDB(t) + h := handlers.AgentsHandler(d) + + r := httptest.NewRequest(http.MethodGet, "/api/agents", nil) + w := httptest.NewRecorder() + h(w, r) + if w.Code != 200 { + t.Fatalf("status: %d", w.Code) + } + var agents []models.Agent + json.NewDecoder(w.Body).Decode(&agents) + if agents == nil { + // tableau vide attendu, pas d'erreur + } +} diff --git a/server/handlers/icons.go b/server/handlers/icons.go new file mode 100644 index 0000000..5709554 --- /dev/null +++ b/server/handlers/icons.go @@ -0,0 +1,89 @@ +package handlers + +import ( + "bytes" + "image" + _ "image/jpeg" + "image/png" + "net/http" + "strings" + + "github.com/disintegration/imaging" + "github.com/user/nanometrics/server/db" +) + +const maxIconSize = 128 + +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) + file, header, err := r.FormFile("icon") + if err != nil { + http.Error(w, "fichier manquant", 400) + return + } + defer file.Close() + + mime := header.Header.Get("Content-Type") + if mime == "" { + mime = "image/png" + } + + if strings.Contains(mime, "svg") { + var buf 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 + } + + 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) + } +} + +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) + } +} diff --git a/server/handlers/metrics.go b/server/handlers/metrics.go new file mode 100644 index 0000000..a530fc7 --- /dev/null +++ b/server/handlers/metrics.go @@ -0,0 +1,45 @@ +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) { + 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 + 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) + } +} diff --git a/server/transport/mqtt.go b/server/transport/mqtt.go new file mode 100644 index 0000000..dadeeec --- /dev/null +++ b/server/transport/mqtt.go @@ -0,0 +1,61 @@ +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 +} + +func StartMQTT(broker, topicBase string, handler func(*models.AgentMetrics)) (*MQTTClient, error) { + mc := &MQTTClient{topicBase: topicBase} + + opts := mqtt.NewClientOptions(). + AddBroker(broker). + SetClientID("nanometrics-server"). + SetAutoReconnect(true). + SetOnConnectHandler(func(c mqtt.Client) { + log.Printf("[mqtt] connecté à %s", broker) + 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) + }). + 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 +} + +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() +}