From 86dd94769fcbdf3ae609087fc3c98b5278f28454 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Cuevas?= Date: Fri, 6 Mar 2026 10:00:00 +0100 Subject: [PATCH 1/6] feat(admin): add instance registry, health poller, and fleet dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instance CRUD API (POST/GET/PUT/DELETE /api/instances) with SQLite store, background health poller with three-state machine (unknown → healthy → unreachable after 3 failures, instant recovery), test-connection endpoint, and Vue frontend with Pinia store, AddInstanceModal, InstanceCard/Table with density toggle, and staleness detection. --- .gitignore | 6 + admin/api/instance.go | 203 +++++++++ admin/api/instance_test.go | 415 +++++++++++++++++++ admin/api/respond.go | 35 ++ admin/cmd/chaperone-admin/main.go | 8 + admin/poller/poller.go | 240 +++++++++++ admin/poller/poller_test.go | 229 ++++++++++ admin/server.go | 5 + admin/store/instance.go | 178 ++++++++ admin/store/instance_test.go | 251 +++++++++++ admin/ui/src/components/AddInstanceModal.vue | 185 +++++++++ admin/ui/src/components/InstanceCard.vue | 130 ++++++ admin/ui/src/components/InstanceTable.vue | 113 +++++ admin/ui/src/components/StatusIndicator.vue | 12 +- admin/ui/src/stores/instances.js | 73 ++++ 15 files changed, 2082 insertions(+), 1 deletion(-) create mode 100644 admin/api/instance.go create mode 100644 admin/api/instance_test.go create mode 100644 admin/api/respond.go create mode 100644 admin/poller/poller.go create mode 100644 admin/poller/poller_test.go create mode 100644 admin/store/instance.go create mode 100644 admin/store/instance_test.go create mode 100644 admin/ui/src/components/AddInstanceModal.vue create mode 100644 admin/ui/src/components/InstanceCard.vue create mode 100644 admin/ui/src/components/InstanceTable.vue create mode 100644 admin/ui/src/stores/instances.js diff --git a/.gitignore b/.gitignore index f825046..8aca182 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,9 @@ test/load/results/ # Admin portal frontend admin/ui/node_modules/ admin/ui/dist/ + +# Playwright MCP output +.playwright-mcp/ + +# Admin QA test logs +.admin-qa-logs/ diff --git a/admin/api/instance.go b/admin/api/instance.go new file mode 100644 index 0000000..40cd80c --- /dev/null +++ b/admin/api/instance.go @@ -0,0 +1,203 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/cloudblue/chaperone/admin/poller" + "github.com/cloudblue/chaperone/admin/store" +) + +// InstanceHandler handles instance CRUD and test-connection endpoints. +type InstanceHandler struct { + store *store.Store + probeTimeout time.Duration +} + +// NewInstanceHandler creates a handler with the given store and probe timeout. +func NewInstanceHandler(st *store.Store, probeTimeout time.Duration) *InstanceHandler { + return &InstanceHandler{store: st, probeTimeout: probeTimeout} +} + +// Register mounts instance routes on the given mux. +func (h *InstanceHandler) Register(mux *http.ServeMux) { + mux.HandleFunc("GET /api/instances", h.list) + mux.HandleFunc("POST /api/instances", h.create) + mux.HandleFunc("POST /api/instances/test", h.testConnection) + mux.HandleFunc("GET /api/instances/{id}", h.get) + mux.HandleFunc("PUT /api/instances/{id}", h.update) + mux.HandleFunc("DELETE /api/instances/{id}", h.delete) +} + +func (h *InstanceHandler) list(w http.ResponseWriter, r *http.Request) { + instances, err := h.store.ListInstances(r.Context()) + if err != nil { + slog.Error("listing instances", "error", err) + respondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to list instances") + return + } + if instances == nil { + instances = []store.Instance{} + } + respondJSON(w, http.StatusOK, instances) +} + +func (h *InstanceHandler) get(w http.ResponseWriter, r *http.Request) { + id, ok := parseID(w, r) + if !ok { + return + } + + inst, err := h.store.GetInstance(r.Context(), id) + if errors.Is(err, store.ErrInstanceNotFound) { + respondError(w, http.StatusNotFound, "INSTANCE_NOT_FOUND", fmt.Sprintf("No instance with ID %d", id)) + return + } + if err != nil { + slog.Error("getting instance", "id", id, "error", err) + respondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to get instance") + return + } + respondJSON(w, http.StatusOK, inst) +} + +type instanceRequest struct { + Name string `json:"name"` + Address string `json:"address"` +} + +func (h *InstanceHandler) create(w http.ResponseWriter, r *http.Request) { + var req instanceRequest + if !decodeJSON(w, r, &req) { + return + } + if !validateInstanceRequest(w, &req) { + return + } + + inst, err := h.store.CreateInstance(r.Context(), req.Name, req.Address) + if errors.Is(err, store.ErrDuplicateAddress) { + respondError(w, http.StatusConflict, "DUPLICATE_ADDRESS", + fmt.Sprintf("An instance with address %q is already registered", req.Address)) + return + } + if err != nil { + slog.Error("creating instance", "error", err) + respondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to create instance") + return + } + respondJSON(w, http.StatusCreated, inst) +} + +func (h *InstanceHandler) update(w http.ResponseWriter, r *http.Request) { + id, ok := parseID(w, r) + if !ok { + return + } + + var req instanceRequest + if !decodeJSON(w, r, &req) { + return + } + if !validateInstanceRequest(w, &req) { + return + } + + inst, err := h.store.UpdateInstance(r.Context(), id, req.Name, req.Address) + if errors.Is(err, store.ErrInstanceNotFound) { + respondError(w, http.StatusNotFound, "INSTANCE_NOT_FOUND", fmt.Sprintf("No instance with ID %d", id)) + return + } + if errors.Is(err, store.ErrDuplicateAddress) { + respondError(w, http.StatusConflict, "DUPLICATE_ADDRESS", + fmt.Sprintf("An instance with address %q is already registered", req.Address)) + return + } + if err != nil { + slog.Error("updating instance", "id", id, "error", err) + respondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to update instance") + return + } + respondJSON(w, http.StatusOK, inst) +} + +func (h *InstanceHandler) delete(w http.ResponseWriter, r *http.Request) { + id, ok := parseID(w, r) + if !ok { + return + } + + err := h.store.DeleteInstance(r.Context(), id) + if errors.Is(err, store.ErrInstanceNotFound) { + respondError(w, http.StatusNotFound, "INSTANCE_NOT_FOUND", fmt.Sprintf("No instance with ID %d", id)) + return + } + if err != nil { + slog.Error("deleting instance", "id", id, "error", err) + respondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Failed to delete instance") + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *InstanceHandler) testConnection(w http.ResponseWriter, r *http.Request) { + var req struct { + Address string `json:"address"` + } + if !decodeJSON(w, r, &req) { + return + } + + addr := strings.TrimSpace(req.Address) + if addr == "" { + respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "address is required") + return + } + + result := poller.Probe(r.Context(), addr, h.probeTimeout) + respondJSON(w, http.StatusOK, result) +} + +// parseID extracts and validates the {id} path parameter. +func parseID(w http.ResponseWriter, r *http.Request) (int64, bool) { + raw := r.PathValue("id") + id, err := strconv.ParseInt(raw, 10, 64) + if err != nil || id <= 0 { + respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", fmt.Sprintf("Invalid instance ID: %q", raw)) + return 0, false + } + return id, true +} + +// decodeJSON reads and decodes a JSON request body. +func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { + if err := json.NewDecoder(r.Body).Decode(dst); err != nil { + respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid JSON request body") + return false + } + return true +} + +func validateInstanceRequest(w http.ResponseWriter, req *instanceRequest) bool { + req.Name = strings.TrimSpace(req.Name) + req.Address = strings.TrimSpace(req.Address) + + if req.Name == "" { + respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "name is required") + return false + } + if req.Address == "" { + respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "address is required") + return false + } + return true +} diff --git a/admin/api/instance_test.go b/admin/api/instance_test.go new file mode 100644 index 0000000..2fd17b3 --- /dev/null +++ b/admin/api/instance_test.go @@ -0,0 +1,415 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/cloudblue/chaperone/admin/store" +) + +func openTestStore(t *testing.T) *store.Store { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.db") + st, err := store.Open(context.Background(), dbPath) + if err != nil { + t.Fatalf("Open(%q) failed: %v", dbPath, err) + } + t.Cleanup(func() { st.Close() }) + return st +} + +func newTestHandler(t *testing.T) (*InstanceHandler, *http.ServeMux) { + t.Helper() + st := openTestStore(t) + h := NewInstanceHandler(st, 2*time.Second) + mux := http.NewServeMux() + h.Register(mux) + return h, mux +} + +func TestListInstances_Empty_ReturnsEmptyArray(t *testing.T) { + t.Parallel() + _, mux := newTestHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/instances", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } + if body := strings.TrimSpace(rec.Body.String()); body != "[]" { + t.Errorf("body = %s, want []", body) + } +} + +func TestCreateInstance_Success_Returns201(t *testing.T) { + t.Parallel() + _, mux := newTestHandler(t) + + body := `{"name":"proxy-1","address":"10.0.0.1:9090"}` + req := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + var inst store.Instance + if err := json.NewDecoder(rec.Body).Decode(&inst); err != nil { + t.Fatalf("decoding response: %v", err) + } + if inst.Name != "proxy-1" { + t.Errorf("Name = %q, want %q", inst.Name, "proxy-1") + } + if inst.Address != "10.0.0.1:9090" { + t.Errorf("Address = %q, want %q", inst.Address, "10.0.0.1:9090") + } +} + +func TestCreateInstance_DuplicateAddress_Returns409(t *testing.T) { + t.Parallel() + _, mux := newTestHandler(t) + + body := `{"name":"proxy-1","address":"10.0.0.1:9090"}` + req := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("first create: status = %d, want %d", rec.Code, http.StatusCreated) + } + + // Second create with same address. + req = httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(body)) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusConflict { + t.Errorf("status = %d, want %d", rec.Code, http.StatusConflict) + } + assertErrorCode(t, rec, "DUPLICATE_ADDRESS") +} + +func TestCreateInstance_MissingName_Returns400(t *testing.T) { + t.Parallel() + _, mux := newTestHandler(t) + + body := `{"name":"","address":"10.0.0.1:9090"}` + req := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } + assertErrorCode(t, rec, "VALIDATION_ERROR") +} + +func TestCreateInstance_MissingAddress_Returns400(t *testing.T) { + t.Parallel() + _, mux := newTestHandler(t) + + body := `{"name":"proxy-1","address":""}` + req := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestCreateInstance_InvalidJSON_Returns400(t *testing.T) { + t.Parallel() + _, mux := newTestHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader("not json")) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestGetInstance_Exists_Returns200(t *testing.T) { + t.Parallel() + _, mux := newTestHandler(t) + + // Create first. + body := `{"name":"proxy-1","address":"10.0.0.1:9090"}` + req := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + var created store.Instance + json.NewDecoder(rec.Body).Decode(&created) + + // Get by ID. + req = httptest.NewRequest(http.MethodGet, "/api/instances/1", nil) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestGetInstance_NotFound_Returns404(t *testing.T) { + t.Parallel() + _, mux := newTestHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/instances/999", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound) + } + assertErrorCode(t, rec, "INSTANCE_NOT_FOUND") +} + +func TestGetInstance_InvalidID_Returns400(t *testing.T) { + t.Parallel() + _, mux := newTestHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/instances/abc", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestUpdateInstance_Success_Returns200(t *testing.T) { + t.Parallel() + _, mux := newTestHandler(t) + + // Create. + create := `{"name":"proxy-1","address":"10.0.0.1:9090"}` + req := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(create)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + // Update. + update := `{"name":"proxy-1-updated","address":"10.0.0.2:9090"}` + req = httptest.NewRequest(http.MethodPut, "/api/instances/1", strings.NewReader(update)) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var inst store.Instance + json.NewDecoder(rec.Body).Decode(&inst) + if inst.Name != "proxy-1-updated" { + t.Errorf("Name = %q, want %q", inst.Name, "proxy-1-updated") + } +} + +func TestDeleteInstance_Success_Returns204(t *testing.T) { + t.Parallel() + _, mux := newTestHandler(t) + + // Create. + create := `{"name":"proxy-1","address":"10.0.0.1:9090"}` + req := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(create)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + // Delete. + req = httptest.NewRequest(http.MethodDelete, "/api/instances/1", nil) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Errorf("status = %d, want %d", rec.Code, http.StatusNoContent) + } + + // Verify gone. + req = httptest.NewRequest(http.MethodGet, "/api/instances/1", nil) + rec = httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("after delete: status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestDeleteInstance_NotFound_Returns404(t *testing.T) { + t.Parallel() + _, mux := newTestHandler(t) + + req := httptest.NewRequest(http.MethodDelete, "/api/instances/999", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestTestConnection_Success(t *testing.T) { + t.Parallel() + + // Start a fake proxy admin server. + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/_ops/health": + w.Write([]byte(`{"status":"alive"}`)) + case "/_ops/version": + w.Write([]byte(`{"version":"1.2.3"}`)) + default: + http.NotFound(w, r) + } + })) + defer proxy.Close() + + _, mux := newTestHandler(t) + addr := strings.TrimPrefix(proxy.URL, "http://") + body := `{"address":"` + addr + `"}` + + req := httptest.NewRequest(http.MethodPost, "/api/instances/test", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var result struct { + OK bool `json:"ok"` + Version string `json:"version"` + } + json.NewDecoder(rec.Body).Decode(&result) + + if !result.OK { + t.Error("expected ok=true") + } + if result.Version != "1.2.3" { + t.Errorf("Version = %q, want %q", result.Version, "1.2.3") + } +} + +func TestTestConnection_Unreachable(t *testing.T) { + t.Parallel() + _, mux := newTestHandler(t) + + body := `{"address":"127.0.0.1:1"}` + req := httptest.NewRequest(http.MethodPost, "/api/instances/test", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var result struct { + OK bool `json:"ok"` + Error string `json:"error"` + } + json.NewDecoder(rec.Body).Decode(&result) + + if result.OK { + t.Error("expected ok=false for unreachable address") + } + if result.Error == "" { + t.Error("expected non-empty error message") + } +} + +func TestTestConnection_EmptyAddress_Returns400(t *testing.T) { + t.Parallel() + _, mux := newTestHandler(t) + + body := `{"address":""}` + req := httptest.NewRequest(http.MethodPost, "/api/instances/test", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestCreateInstance_WhitespaceTrimmed(t *testing.T) { + t.Parallel() + _, mux := newTestHandler(t) + + body := `{"name":" proxy-1 ","address":" 10.0.0.1:9090 "}` + req := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusCreated, rec.Body.String()) + } + + var inst store.Instance + if err := json.NewDecoder(rec.Body).Decode(&inst); err != nil { + t.Fatalf("decoding response: %v", err) + } + if inst.Name != "proxy-1" { + t.Errorf("Name = %q, want %q (should be trimmed)", inst.Name, "proxy-1") + } + if inst.Address != "10.0.0.1:9090" { + t.Errorf("Address = %q, want %q (should be trimmed)", inst.Address, "10.0.0.1:9090") + } +} + +func TestListInstances_AfterCreate_ReturnsInstances(t *testing.T) { + t.Parallel() + _, mux := newTestHandler(t) + + // Create two instances. + for _, name := range []string{"alpha", "bravo"} { + body := `{"name":"` + name + `","address":"` + name + `:9090"}` + req := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("create %s: status = %d", name, rec.Code) + } + } + + // List. + req := httptest.NewRequest(http.MethodGet, "/api/instances", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var instances []store.Instance + json.NewDecoder(rec.Body).Decode(&instances) + if len(instances) != 2 { + t.Errorf("len = %d, want 2", len(instances)) + } +} + +// assertErrorCode checks that the response body contains the expected error code. +func assertErrorCode(t *testing.T, rec *httptest.ResponseRecorder, wantCode string) { + t.Helper() + var resp errorResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decoding error response: %v", err) + } + if resp.Error.Code != wantCode { + t.Errorf("error code = %q, want %q", resp.Error.Code, wantCode) + } +} diff --git a/admin/api/respond.go b/admin/api/respond.go new file mode 100644 index 0000000..6cb4e38 --- /dev/null +++ b/admin/api/respond.go @@ -0,0 +1,35 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "encoding/json" + "log/slog" + "net/http" +) + +type errorResponse struct { + Error errorDetail `json:"error"` +} + +type errorDetail struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// respondJSON writes a JSON response with the given status code. +func respondJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(v); err != nil { + slog.Error("encoding JSON response", "error", err) + } +} + +// respondError writes a structured error response matching the DR error format. +func respondError(w http.ResponseWriter, status int, code, message string) { + respondJSON(w, status, errorResponse{ + Error: errorDetail{Code: code, Message: message}, + }) +} diff --git a/admin/cmd/chaperone-admin/main.go b/admin/cmd/chaperone-admin/main.go index 75dc23d..24bd319 100644 --- a/admin/cmd/chaperone-admin/main.go +++ b/admin/cmd/chaperone-admin/main.go @@ -17,6 +17,7 @@ import ( "github.com/cloudblue/chaperone/admin" "github.com/cloudblue/chaperone/admin/config" + "github.com/cloudblue/chaperone/admin/poller" "github.com/cloudblue/chaperone/admin/store" ) @@ -67,6 +68,13 @@ func run() error { return fmt.Errorf("creating server: %w", err) } + // Start the background health poller. + pollerCtx, pollerCancel := context.WithCancel(context.Background()) + defer pollerCancel() + + p := poller.New(st, cfg.Scraper.Interval.Unwrap(), cfg.Scraper.Timeout.Unwrap()) + go p.Run(pollerCtx) + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() diff --git a/admin/poller/poller.go b/admin/poller/poller.go new file mode 100644 index 0000000..9d5a259 --- /dev/null +++ b/admin/poller/poller.go @@ -0,0 +1,240 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package poller + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "math/rand/v2" + "net" + "net/http" + "sync" + "time" + + "github.com/cloudblue/chaperone/admin/store" +) + +const ( + failuresUntilUnreachable = 3 + maxJitter = time.Second +) + +// ProbeResult holds the outcome of a single proxy probe. +type ProbeResult struct { + OK bool `json:"ok"` + Health string `json:"health,omitempty"` + Version string `json:"version,omitempty"` + Error string `json:"error,omitempty"` +} + +// Probe performs a one-off health and version check against a proxy admin port. +func Probe(ctx context.Context, address string, timeout time.Duration) ProbeResult { + client := &http.Client{Timeout: timeout} + + health, err := fetchHealth(ctx, client, address) + if err != nil { + return ProbeResult{OK: false, Error: friendlyError(err)} + } + + version, err := fetchVersion(ctx, client, address) + if err != nil { + return ProbeResult{OK: false, Error: friendlyError(err)} + } + + return ProbeResult{OK: true, Health: health, Version: version} +} + +// Poller periodically polls registered proxy instances for health and version. +type Poller struct { + store *store.Store + client *http.Client + interval time.Duration + timeout time.Duration + + mu sync.Mutex + failures map[int64]int // instance ID → consecutive failure count +} + +// New creates a Poller with the given configuration. +func New(st *store.Store, interval, timeout time.Duration) *Poller { + return &Poller{ + store: st, + client: &http.Client{Timeout: timeout}, + interval: interval, + timeout: timeout, + failures: make(map[int64]int), + } +} + +// Run starts the polling loop. It blocks until the context is cancelled. +func (p *Poller) Run(ctx context.Context) { + slog.Info("poller started", "interval", p.interval, "timeout", p.timeout) + + // Run an immediate first poll, then tick on interval. + p.pollAll(ctx) + + ticker := time.NewTicker(p.interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + slog.Info("poller stopped") + return + case <-ticker.C: + p.pollAll(ctx) + } + } +} + +func (p *Poller) pollAll(ctx context.Context) { + instances, err := p.store.ListInstances(ctx) + if err != nil { + slog.Error("poller: listing instances", "error", err) + return + } + if len(instances) == 0 { + return + } + + type result struct { + id int64 + probe ProbeResult + version string + } + + results := make(chan result, len(instances)) + var wg sync.WaitGroup + + for _, inst := range instances { + wg.Add(1) + go func(inst store.Instance) { + defer wg.Done() + // Jitter: ±1s random offset to spread scrapes. + jitter := time.Duration(rand.Int64N(int64(2*maxJitter))) - maxJitter + sleep(ctx, jitter) + + pr := Probe(ctx, inst.Address, p.timeout) + results <- result{id: inst.ID, probe: pr, version: pr.Version} + }(inst) + } + + go func() { + wg.Wait() + close(results) + }() + + for r := range results { + p.applyResult(ctx, r.id, r.probe) + } +} + +func (p *Poller) applyResult(ctx context.Context, id int64, pr ProbeResult) { + p.mu.Lock() + defer p.mu.Unlock() + + if pr.OK { + p.failures[id] = 0 + if err := p.store.SetInstanceHealthy(ctx, id, pr.Version); err != nil { + slog.Error("poller: setting instance healthy", "id", id, "error", err) + } + return + } + + p.failures[id]++ + count := p.failures[id] + slog.Debug("poller: probe failed", "id", id, "consecutive_failures", count, "error", pr.Error) + + if count >= failuresUntilUnreachable { + if err := p.store.SetInstanceUnreachable(ctx, id); err != nil { + slog.Error("poller: setting instance unreachable", "id", id, "error", err) + } + } +} + +// fetchHealth calls GET /_ops/health and returns the status field. +func fetchHealth(ctx context.Context, client *http.Client, address string) (string, error) { + url := fmt.Sprintf("http://%s/_ops/health", address) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("creating health request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("health endpoint returned %d", resp.StatusCode) + } + + var body struct { + Status string `json:"status"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", fmt.Errorf("decoding health response: %w", err) + } + return body.Status, nil +} + +// fetchVersion calls GET /_ops/version and returns the version field. +func fetchVersion(ctx context.Context, client *http.Client, address string) (string, error) { + url := fmt.Sprintf("http://%s/_ops/version", address) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("creating version request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("version endpoint returned %d", resp.StatusCode) + } + + var body struct { + Version string `json:"version"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", fmt.Errorf("decoding version response: %w", err) + } + return body.Version, nil +} + +// friendlyError converts network errors into user-facing messages. +func friendlyError(err error) string { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { + return "Connection timed out. Check that the proxy is running and the address is correct." + } + + var opErr *net.OpError + if errors.As(err, &opErr) { + return fmt.Sprintf("Connection failed: %s. The proxy admin server may be bound to localhost only; check admin_addr in the proxy configuration.", opErr.Err) + } + + return fmt.Sprintf("Connection failed: %s", err) +} + +// sleep waits for the given duration or until the context is cancelled. +// Negative durations return immediately. +func sleep(ctx context.Context, d time.Duration) { + if d <= 0 { + return + } + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + case <-t.C: + } +} diff --git a/admin/poller/poller_test.go b/admin/poller/poller_test.go new file mode 100644 index 0000000..55e634c --- /dev/null +++ b/admin/poller/poller_test.go @@ -0,0 +1,229 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package poller + +import ( + "context" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/cloudblue/chaperone/admin/store" +) + +func openTestStore(t *testing.T) *store.Store { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.db") + st, err := store.Open(context.Background(), dbPath) + if err != nil { + t.Fatalf("Open(%q) failed: %v", dbPath, err) + } + t.Cleanup(func() { st.Close() }) + return st +} + +func fakeProxy(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/_ops/health": + w.Write([]byte(`{"status":"alive"}`)) + case "/_ops/version": + w.Write([]byte(`{"version":"1.0.0"}`)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func TestProbe_HealthyProxy_ReturnsOK(t *testing.T) { + t.Parallel() + proxy := fakeProxy(t) + addr := strings.TrimPrefix(proxy.URL, "http://") + + result := Probe(context.Background(), addr, 2*time.Second) + + if !result.OK { + t.Fatalf("expected OK=true, got error: %s", result.Error) + } + if result.Health != "alive" { + t.Errorf("Health = %q, want %q", result.Health, "alive") + } + if result.Version != "1.0.0" { + t.Errorf("Version = %q, want %q", result.Version, "1.0.0") + } +} + +func TestProbe_UnreachableAddress_ReturnsError(t *testing.T) { + t.Parallel() + + result := Probe(context.Background(), "127.0.0.1:1", 1*time.Second) + + if result.OK { + t.Error("expected OK=false for unreachable address") + } + if result.Error == "" { + t.Error("expected non-empty error") + } +} + +func TestProbe_HealthEndpointError_ReturnsError(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + addr := strings.TrimPrefix(srv.URL, "http://") + + result := Probe(context.Background(), addr, 2*time.Second) + + if result.OK { + t.Error("expected OK=false for error status") + } +} + +func TestPoller_SinglePoll_SetsHealthy(t *testing.T) { + t.Parallel() + st := openTestStore(t) + proxy := fakeProxy(t) + addr := strings.TrimPrefix(proxy.URL, "http://") + + ctx := context.Background() + inst, err := st.CreateInstance(ctx, "test-proxy", addr) + if err != nil { + t.Fatalf("CreateInstance() error = %v", err) + } + + p := New(st, 1*time.Hour, 2*time.Second) // long interval; we call pollAll manually. + p.pollAll(ctx) + + got, err := st.GetInstance(ctx, inst.ID) + if err != nil { + t.Fatalf("GetInstance() error = %v", err) + } + if got.Status != "healthy" { + t.Errorf("Status = %q, want %q", got.Status, "healthy") + } + if got.Version != "1.0.0" { + t.Errorf("Version = %q, want %q", got.Version, "1.0.0") + } +} + +func TestPoller_ThreeFailures_SetsUnreachable(t *testing.T) { + t.Parallel() + st := openTestStore(t) + + ctx := context.Background() + inst, err := st.CreateInstance(ctx, "test-proxy", "127.0.0.1:1") + if err != nil { + t.Fatalf("CreateInstance() error = %v", err) + } + + p := New(st, 1*time.Hour, 500*time.Millisecond) + + // Poll 3 times to reach unreachable threshold. + for i := 0; i < failuresUntilUnreachable; i++ { + p.pollAll(ctx) + } + + got, err := st.GetInstance(ctx, inst.ID) + if err != nil { + t.Fatalf("GetInstance() error = %v", err) + } + if got.Status != "unreachable" { + t.Errorf("Status = %q, want %q after %d failures", got.Status, "unreachable", failuresUntilUnreachable) + } +} + +func TestPoller_TwoFailures_StaysUnknown(t *testing.T) { + t.Parallel() + st := openTestStore(t) + + ctx := context.Background() + inst, err := st.CreateInstance(ctx, "test-proxy", "127.0.0.1:1") + if err != nil { + t.Fatalf("CreateInstance() error = %v", err) + } + + p := New(st, 1*time.Hour, 500*time.Millisecond) + + // Poll only twice — should not yet transition to unreachable. + for i := 0; i < failuresUntilUnreachable-1; i++ { + p.pollAll(ctx) + } + + got, err := st.GetInstance(ctx, inst.ID) + if err != nil { + t.Fatalf("GetInstance() error = %v", err) + } + if got.Status != "unknown" { + t.Errorf("Status = %q, want %q after %d failures", got.Status, "unknown", failuresUntilUnreachable-1) + } +} + +func TestPoller_RecoveryAfterUnreachable_SetsHealthy(t *testing.T) { + t.Parallel() + st := openTestStore(t) + proxy := fakeProxy(t) + addr := strings.TrimPrefix(proxy.URL, "http://") + + ctx := context.Background() + inst, err := st.CreateInstance(ctx, "test-proxy", "127.0.0.1:1") + if err != nil { + t.Fatalf("CreateInstance() error = %v", err) + } + + p := New(st, 1*time.Hour, 500*time.Millisecond) + + // Drive to unreachable. + for i := 0; i < failuresUntilUnreachable; i++ { + p.pollAll(ctx) + } + + // Now point instance to the live proxy. + if _, err := st.UpdateInstance(ctx, inst.ID, "test-proxy", addr); err != nil { + t.Fatalf("UpdateInstance() error = %v", err) + } + + // Single success should recover. + p.pollAll(ctx) + + got, err := st.GetInstance(ctx, inst.ID) + if err != nil { + t.Fatalf("GetInstance() error = %v", err) + } + if got.Status != "healthy" { + t.Errorf("Status = %q, want %q after recovery", got.Status, "healthy") + } +} + +func TestPoller_RunStopsOnContextCancel(t *testing.T) { + t.Parallel() + st := openTestStore(t) + + p := New(st, 50*time.Millisecond, 500*time.Millisecond) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + p.Run(ctx) + close(done) + }() + + cancel() + + select { + case <-done: + // OK — Run returned. + case <-time.After(2 * time.Second): + t.Fatal("Run did not stop after context cancellation") + } +} diff --git a/admin/server.go b/admin/server.go index b4776a6..f2cdbeb 100644 --- a/admin/server.go +++ b/admin/server.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "github.com/cloudblue/chaperone/admin/api" "github.com/cloudblue/chaperone/admin/config" "github.com/cloudblue/chaperone/admin/store" ) @@ -61,6 +62,10 @@ func (s *Server) routes(mux *http.ServeMux) error { // API health check for the portal itself. mux.HandleFunc("GET /api/health", s.handleHealth) + // Instance CRUD + test connection. + instances := api.NewInstanceHandler(s.store, s.config.Scraper.Timeout.Unwrap()) + instances.Register(mux) + // SPA serving — all non-API routes serve the Vue app. assets, err := loadUIAssets() if err != nil { diff --git a/admin/store/instance.go b/admin/store/instance.go new file mode 100644 index 0000000..782b4de --- /dev/null +++ b/admin/store/instance.go @@ -0,0 +1,178 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +// Sentinel errors for instance operations. +var ( + ErrInstanceNotFound = errors.New("instance not found") + ErrDuplicateAddress = errors.New("duplicate instance address") +) + +// Instance represents a registered proxy instance. +type Instance struct { + ID int64 `json:"id"` + Name string `json:"name"` + Address string `json:"address"` + Status string `json:"status"` + Version string `json:"version"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ListInstances returns all registered instances ordered by name. +func (s *Store) ListInstances(ctx context.Context) ([]Instance, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, name, address, status, version, last_seen_at, created_at, updated_at + FROM instances ORDER BY name`) + if err != nil { + return nil, fmt.Errorf("listing instances: %w", err) + } + defer rows.Close() + + var instances []Instance + for rows.Next() { + inst, err := scanInstance(rows) + if err != nil { + return nil, err + } + instances = append(instances, inst) + } + return instances, rows.Err() +} + +// GetInstance returns a single instance by ID. +func (s *Store) GetInstance(ctx context.Context, id int64) (*Instance, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, name, address, status, version, last_seen_at, created_at, updated_at + FROM instances WHERE id = ?`, id) + + inst, err := scanInstanceRow(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrInstanceNotFound + } + if err != nil { + return nil, fmt.Errorf("getting instance %d: %w", id, err) + } + return &inst, nil +} + +// CreateInstance inserts a new instance and returns it. +func (s *Store) CreateInstance(ctx context.Context, name, address string) (*Instance, error) { + result, err := s.db.ExecContext(ctx, + `INSERT INTO instances (name, address) VALUES (?, ?)`, name, address) + if err != nil { + if isUniqueConstraintError(err) { + return nil, ErrDuplicateAddress + } + return nil, fmt.Errorf("creating instance: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return nil, fmt.Errorf("getting last insert ID: %w", err) + } + return s.GetInstance(ctx, id) +} + +// UpdateInstance updates instance name and address by ID and returns the updated instance. +func (s *Store) UpdateInstance(ctx context.Context, id int64, name, address string) (*Instance, error) { + result, err := s.db.ExecContext(ctx, + `UPDATE instances SET name = ?, address = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + name, address, id) + if err != nil { + if isUniqueConstraintError(err) { + return nil, ErrDuplicateAddress + } + return nil, fmt.Errorf("updating instance %d: %w", id, err) + } + + n, err := result.RowsAffected() + if err != nil { + return nil, fmt.Errorf("checking rows affected: %w", err) + } + if n == 0 { + return nil, ErrInstanceNotFound + } + return s.GetInstance(ctx, id) +} + +// DeleteInstance removes an instance by ID. +func (s *Store) DeleteInstance(ctx context.Context, id int64) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM instances WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("deleting instance %d: %w", id, err) + } + + n, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("checking rows affected: %w", err) + } + if n == 0 { + return ErrInstanceNotFound + } + return nil +} + +// SetInstanceHealthy marks an instance as healthy with the given version. +func (s *Store) SetInstanceHealthy(ctx context.Context, id int64, version string) error { + _, err := s.db.ExecContext(ctx, + `UPDATE instances SET status = 'healthy', version = ?, last_seen_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + version, id) + if err != nil { + return fmt.Errorf("setting instance %d healthy: %w", id, err) + } + return nil +} + +// SetInstanceUnreachable marks an instance as unreachable. +func (s *Store) SetInstanceUnreachable(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, + `UPDATE instances SET status = 'unreachable', updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + id) + if err != nil { + return fmt.Errorf("setting instance %d unreachable: %w", id, err) + } + return nil +} + +// scanner is satisfied by both *sql.Row and *sql.Rows. +type scanner interface { + Scan(dest ...any) error +} + +func scanInstance(s scanner) (Instance, error) { + var inst Instance + err := s.Scan( + &inst.ID, &inst.Name, &inst.Address, &inst.Status, + &inst.Version, &inst.LastSeenAt, &inst.CreatedAt, &inst.UpdatedAt, + ) + if err != nil { + return Instance{}, fmt.Errorf("scanning instance: %w", err) + } + return inst, nil +} + +func scanInstanceRow(row *sql.Row) (Instance, error) { + var inst Instance + err := row.Scan( + &inst.ID, &inst.Name, &inst.Address, &inst.Status, + &inst.Version, &inst.LastSeenAt, &inst.CreatedAt, &inst.UpdatedAt, + ) + return inst, err +} + +func isUniqueConstraintError(err error) bool { + return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") +} diff --git a/admin/store/instance_test.go b/admin/store/instance_test.go new file mode 100644 index 0000000..bb7178d --- /dev/null +++ b/admin/store/instance_test.go @@ -0,0 +1,251 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package store + +import ( + "context" + "errors" + "testing" +) + +func TestCreateInstance_Success(t *testing.T) { + t.Parallel() + st := openTestStore(t) + + inst, err := st.CreateInstance(context.Background(), "proxy-1", "10.0.0.1:9090") + if err != nil { + t.Fatalf("CreateInstance() error = %v", err) + } + + if inst.ID == 0 { + t.Error("expected non-zero ID") + } + if inst.Name != "proxy-1" { + t.Errorf("Name = %q, want %q", inst.Name, "proxy-1") + } + if inst.Address != "10.0.0.1:9090" { + t.Errorf("Address = %q, want %q", inst.Address, "10.0.0.1:9090") + } + if inst.Status != "unknown" { + t.Errorf("Status = %q, want %q", inst.Status, "unknown") + } +} + +func TestCreateInstance_DuplicateAddress_ReturnsError(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + + if _, err := st.CreateInstance(ctx, "proxy-1", "10.0.0.1:9090"); err != nil { + t.Fatalf("first CreateInstance() error = %v", err) + } + + _, err := st.CreateInstance(ctx, "proxy-2", "10.0.0.1:9090") + if !errors.Is(err, ErrDuplicateAddress) { + t.Errorf("error = %v, want %v", err, ErrDuplicateAddress) + } +} + +func TestGetInstance_Exists_ReturnsInstance(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + + created, err := st.CreateInstance(ctx, "proxy-1", "10.0.0.1:9090") + if err != nil { + t.Fatalf("CreateInstance() error = %v", err) + } + + got, err := st.GetInstance(ctx, created.ID) + if err != nil { + t.Fatalf("GetInstance() error = %v", err) + } + if got.Name != "proxy-1" { + t.Errorf("Name = %q, want %q", got.Name, "proxy-1") + } +} + +func TestGetInstance_NotFound_ReturnsError(t *testing.T) { + t.Parallel() + st := openTestStore(t) + + _, err := st.GetInstance(context.Background(), 999) + if !errors.Is(err, ErrInstanceNotFound) { + t.Errorf("error = %v, want %v", err, ErrInstanceNotFound) + } +} + +func TestListInstances_Empty_ReturnsNil(t *testing.T) { + t.Parallel() + st := openTestStore(t) + + instances, err := st.ListInstances(context.Background()) + if err != nil { + t.Fatalf("ListInstances() error = %v", err) + } + if instances != nil { + t.Errorf("expected nil, got %v", instances) + } +} + +func TestListInstances_Multiple_ReturnsSortedByName(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + + for _, name := range []string{"charlie", "alpha", "bravo"} { + if _, err := st.CreateInstance(ctx, name, name+":9090"); err != nil { + t.Fatalf("CreateInstance(%q) error = %v", name, err) + } + } + + instances, err := st.ListInstances(ctx) + if err != nil { + t.Fatalf("ListInstances() error = %v", err) + } + if len(instances) != 3 { + t.Fatalf("len = %d, want 3", len(instances)) + } + + want := []string{"alpha", "bravo", "charlie"} + for i, inst := range instances { + if inst.Name != want[i] { + t.Errorf("instances[%d].Name = %q, want %q", i, inst.Name, want[i]) + } + } +} + +func TestUpdateInstance_Success(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + + created, err := st.CreateInstance(ctx, "old-name", "10.0.0.1:9090") + if err != nil { + t.Fatalf("CreateInstance() error = %v", err) + } + + updated, err := st.UpdateInstance(ctx, created.ID, "new-name", "10.0.0.2:9090") + if err != nil { + t.Fatalf("UpdateInstance() error = %v", err) + } + + if updated.Name != "new-name" { + t.Errorf("Name = %q, want %q", updated.Name, "new-name") + } + if updated.Address != "10.0.0.2:9090" { + t.Errorf("Address = %q, want %q", updated.Address, "10.0.0.2:9090") + } +} + +func TestUpdateInstance_NotFound_ReturnsError(t *testing.T) { + t.Parallel() + st := openTestStore(t) + + _, err := st.UpdateInstance(context.Background(), 999, "name", "addr:9090") + if !errors.Is(err, ErrInstanceNotFound) { + t.Errorf("error = %v, want %v", err, ErrInstanceNotFound) + } +} + +func TestUpdateInstance_DuplicateAddress_ReturnsError(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + + if _, err := st.CreateInstance(ctx, "proxy-1", "10.0.0.1:9090"); err != nil { + t.Fatalf("CreateInstance(proxy-1) error = %v", err) + } + inst2, err := st.CreateInstance(ctx, "proxy-2", "10.0.0.2:9090") + if err != nil { + t.Fatalf("CreateInstance(proxy-2) error = %v", err) + } + + _, err = st.UpdateInstance(ctx, inst2.ID, "proxy-2", "10.0.0.1:9090") + if !errors.Is(err, ErrDuplicateAddress) { + t.Errorf("error = %v, want %v", err, ErrDuplicateAddress) + } +} + +func TestDeleteInstance_Success(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + + created, err := st.CreateInstance(ctx, "proxy-1", "10.0.0.1:9090") + if err != nil { + t.Fatalf("CreateInstance() error = %v", err) + } + + if err := st.DeleteInstance(ctx, created.ID); err != nil { + t.Fatalf("DeleteInstance() error = %v", err) + } + + _, err = st.GetInstance(ctx, created.ID) + if !errors.Is(err, ErrInstanceNotFound) { + t.Errorf("after delete: error = %v, want %v", err, ErrInstanceNotFound) + } +} + +func TestDeleteInstance_NotFound_ReturnsError(t *testing.T) { + t.Parallel() + st := openTestStore(t) + + err := st.DeleteInstance(context.Background(), 999) + if !errors.Is(err, ErrInstanceNotFound) { + t.Errorf("error = %v, want %v", err, ErrInstanceNotFound) + } +} + +func TestSetInstanceHealthy_UpdatesStatusAndVersion(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + + created, err := st.CreateInstance(ctx, "proxy-1", "10.0.0.1:9090") + if err != nil { + t.Fatalf("CreateInstance() error = %v", err) + } + + if err := st.SetInstanceHealthy(ctx, created.ID, "1.2.3"); err != nil { + t.Fatalf("SetInstanceHealthy() error = %v", err) + } + + got, err := st.GetInstance(ctx, created.ID) + if err != nil { + t.Fatalf("GetInstance() error = %v", err) + } + if got.Status != "healthy" { + t.Errorf("Status = %q, want %q", got.Status, "healthy") + } + if got.Version != "1.2.3" { + t.Errorf("Version = %q, want %q", got.Version, "1.2.3") + } + if got.LastSeenAt == nil { + t.Error("LastSeenAt should not be nil after healthy poll") + } +} + +func TestSetInstanceUnreachable_UpdatesStatus(t *testing.T) { + t.Parallel() + st := openTestStore(t) + ctx := context.Background() + + created, err := st.CreateInstance(ctx, "proxy-1", "10.0.0.1:9090") + if err != nil { + t.Fatalf("CreateInstance() error = %v", err) + } + + if err := st.SetInstanceUnreachable(ctx, created.ID); err != nil { + t.Fatalf("SetInstanceUnreachable() error = %v", err) + } + + got, err := st.GetInstance(ctx, created.ID) + if err != nil { + t.Fatalf("GetInstance() error = %v", err) + } + if got.Status != "unreachable" { + t.Errorf("Status = %q, want %q", got.Status, "unreachable") + } +} diff --git a/admin/ui/src/components/AddInstanceModal.vue b/admin/ui/src/components/AddInstanceModal.vue new file mode 100644 index 0000000..aa0f530 --- /dev/null +++ b/admin/ui/src/components/AddInstanceModal.vue @@ -0,0 +1,185 @@ + + + + + diff --git a/admin/ui/src/components/InstanceCard.vue b/admin/ui/src/components/InstanceCard.vue new file mode 100644 index 0000000..808c3b7 --- /dev/null +++ b/admin/ui/src/components/InstanceCard.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/admin/ui/src/components/InstanceTable.vue b/admin/ui/src/components/InstanceTable.vue new file mode 100644 index 0000000..f2faa62 --- /dev/null +++ b/admin/ui/src/components/InstanceTable.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/admin/ui/src/components/StatusIndicator.vue b/admin/ui/src/components/StatusIndicator.vue index 07c8af9..026f4eb 100644 --- a/admin/ui/src/components/StatusIndicator.vue +++ b/admin/ui/src/components/StatusIndicator.vue @@ -16,7 +16,7 @@ const props = defineProps({ status: { type: String, default: "unknown", - validator: (v) => ["healthy", "unreachable", "unknown"].includes(v), + validator: (v) => ["healthy", "unreachable", "unknown", "stale"].includes(v), }, label: { type: String, @@ -28,6 +28,7 @@ const statusLabels = { healthy: "Healthy", unreachable: "Unreachable", unknown: "Unknown", + stale: "Stale", }; const computedAriaLabel = computed(() => { @@ -81,4 +82,13 @@ const computedAriaLabel = computed(() => { .unknown .label { color: var(--color-text-tertiary); } + +.stale .dot { + background-color: var(--color-warning); + box-shadow: 0 0 0 3px var(--color-warning-bg); +} + +.stale .label { + color: var(--color-warning); +} diff --git a/admin/ui/src/stores/instances.js b/admin/ui/src/stores/instances.js new file mode 100644 index 0000000..b363d0c --- /dev/null +++ b/admin/ui/src/stores/instances.js @@ -0,0 +1,73 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; + +export const useInstanceStore = defineStore("instances", () => { + const instances = ref([]); + const loading = ref(false); + + async function fetchInstances() { + loading.value = true; + try { + const res = await fetch("/api/instances"); + if (!res.ok) throw new Error("Failed to fetch instances"); + instances.value = await res.json(); + } finally { + loading.value = false; + } + } + + async function createInstance(name, address) { + const res = await fetch("/api/instances", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, address }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error?.message || "Failed to create instance"); + } + const inst = await res.json(); + await fetchInstances(); + return inst; + } + + async function updateInstance(id, name, address) { + const res = await fetch(`/api/instances/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, address }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error?.message || "Failed to update instance"); + } + const inst = await res.json(); + await fetchInstances(); + return inst; + } + + async function deleteInstance(id) { + const res = await fetch(`/api/instances/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Failed to delete instance"); + await fetchInstances(); + } + + async function testConnection(address) { + const res = await fetch("/api/instances/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ address }), + }); + return await res.json(); + } + + return { + instances, + loading, + fetchInstances, + createInstance, + updateInstance, + deleteInstance, + testConnection, + }; +}); From 8413c953866c6f0473c484b0ea67a73cddbc2e7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Cuevas?= Date: Mon, 9 Mar 2026 11:00:00 +0100 Subject: [PATCH 2/6] feat(admin): add welcome screen and confirm dialog Replace minimal empty state with a guided welcome screen (portal description + 3-step onboarding flow). Replace window.confirm() with a ConfirmDialog component for instance removal. Add ghost button variant for the Remove action. --- admin/ui/src/components/BaseButton.vue | 13 +- admin/ui/src/components/ConfirmDialog.vue | 93 ++++++ admin/ui/src/views/DashboardView.vue | 368 ++++++++++++++++++++-- 3 files changed, 449 insertions(+), 25 deletions(-) create mode 100644 admin/ui/src/components/ConfirmDialog.vue diff --git a/admin/ui/src/components/BaseButton.vue b/admin/ui/src/components/BaseButton.vue index 086adf4..14c64c6 100644 --- a/admin/ui/src/components/BaseButton.vue +++ b/admin/ui/src/components/BaseButton.vue @@ -13,7 +13,7 @@ defineProps({ variant: { type: String, default: "primary", - validator: (v) => ["primary", "secondary", "danger"].includes(v), + validator: (v) => ["primary", "secondary", "danger", "ghost"].includes(v), }, size: { type: String, @@ -87,4 +87,15 @@ defineProps({ .danger:hover:not(:disabled) { background-color: var(--color-error-hover); } + +.ghost { + background-color: transparent; + color: var(--color-text-tertiary); + border-color: transparent; +} + +.ghost:hover:not(:disabled) { + background-color: var(--color-error-bg); + color: var(--color-error); +} diff --git a/admin/ui/src/components/ConfirmDialog.vue b/admin/ui/src/components/ConfirmDialog.vue new file mode 100644 index 0000000..747d287 --- /dev/null +++ b/admin/ui/src/components/ConfirmDialog.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/admin/ui/src/views/DashboardView.vue b/admin/ui/src/views/DashboardView.vue index cbd2293..4d29a46 100644 --- a/admin/ui/src/views/DashboardView.vue +++ b/admin/ui/src/views/DashboardView.vue @@ -2,39 +2,214 @@

Fleet Dashboard

+
+
+ + +
+ + Add Instance + +
+
+ + +
+ + {{ staleInstances.length === 1 ? '1 instance has' : `${staleInstances.length} instances have` }} + stale data — last seen over 2 minutes ago
+
- - - - + +
+ +

Welcome to Chaperone Admin

+

+ This portal gives you operational visibility into your Chaperone proxy fleet — + health status, live metrics, per-vendor traffic breakdown, and more. All from a single dashboard. +

+
+
+ 1 +
+ Register a proxy instance + Enter the admin address (host:port) of a running Chaperone proxy +
+
+
+ 2 +
+ Test the connection + Verify the portal can reach the proxy's admin port before saving +
+
+
+ 3 +
+ Monitor your fleet + Health, version, request rates, and latency updated every 10 seconds +
+
+
+ + Add Your First Instance + +
+ + +
+ +
+ + + +
+ + + + + +
From dd2762d018b549aa1d55c9e3d39e1e6bff31fc02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Cuevas?= Date: Tue, 10 Mar 2026 14:00:00 +0100 Subject: [PATCH 3/6] fix(admin): address review findings for instance registry Validate addresses as strict host:port (rejects :9090, host:abc, host:0). Cap JSON request bodies at 1 MB. Fix poller failures map leak for deleted instances. Make Probe() accept *http.Client to avoid per-call allocation. Extract shared formatTime, isInstanceStale, STALE_THRESHOLD_MS to utils/instance.js with tests. Fix stale-status inconsistency between table and card views. --- .gitignore | 1 + admin/api/instance.go | 40 +++++++++-- admin/api/instance_test.go | 54 +++++++++++++++ admin/poller/poller.go | 34 +++++++--- admin/poller/poller_test.go | 42 +++++++++++- admin/store/instance.go | 11 +-- admin/ui/src/components/InstanceCard.vue | 18 +---- admin/ui/src/components/InstanceTable.vue | 13 +--- admin/ui/src/utils/instance.js | 16 +++++ admin/ui/src/utils/instance.test.js | 82 +++++++++++++++++++++++ admin/ui/src/views/DashboardView.vue | 10 +-- 11 files changed, 260 insertions(+), 61 deletions(-) create mode 100644 admin/ui/src/utils/instance.js create mode 100644 admin/ui/src/utils/instance.test.js diff --git a/.gitignore b/.gitignore index 8aca182..58fc04b 100644 --- a/.gitignore +++ b/.gitignore @@ -80,6 +80,7 @@ test/load/results/ # Admin portal frontend admin/ui/node_modules/ admin/ui/dist/ +admin/ui/.vite/ # Playwright MCP output .playwright-mcp/ diff --git a/admin/api/instance.go b/admin/api/instance.go index 40cd80c..3d60dae 100644 --- a/admin/api/instance.go +++ b/admin/api/instance.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "log/slog" + "net" "net/http" "strconv" "strings" @@ -19,13 +20,16 @@ import ( // InstanceHandler handles instance CRUD and test-connection endpoints. type InstanceHandler struct { - store *store.Store - probeTimeout time.Duration + store *store.Store + client *http.Client } // NewInstanceHandler creates a handler with the given store and probe timeout. func NewInstanceHandler(st *store.Store, probeTimeout time.Duration) *InstanceHandler { - return &InstanceHandler{store: st, probeTimeout: probeTimeout} + return &InstanceHandler{ + store: st, + client: &http.Client{Timeout: probeTimeout}, + } } // Register mounts instance routes on the given mux. @@ -162,8 +166,12 @@ func (h *InstanceHandler) testConnection(w http.ResponseWriter, r *http.Request) respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "address is required") return } + if err := validHostPort(addr); err != nil { + respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error()) + return + } - result := poller.Probe(r.Context(), addr, h.probeTimeout) + result := poller.Probe(r.Context(), h.client, addr) respondJSON(w, http.StatusOK, result) } @@ -178,8 +186,9 @@ func parseID(w http.ResponseWriter, r *http.Request) (int64, bool) { return id, true } -// decodeJSON reads and decodes a JSON request body. +// decodeJSON reads and decodes a JSON request body (max 1 MB). func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) if err := json.NewDecoder(r.Body).Decode(dst); err != nil { respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "Invalid JSON request body") return false @@ -199,5 +208,26 @@ func validateInstanceRequest(w http.ResponseWriter, req *instanceRequest) bool { respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "address is required") return false } + if err := validHostPort(req.Address); err != nil { + respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error()) + return false + } return true } + +var errInvalidHostPort = errors.New("address must be a valid host:port (e.g. 192.168.1.10:9090)") + +func validHostPort(addr string) error { + host, portStr, err := net.SplitHostPort(addr) + if err != nil { + return errInvalidHostPort + } + if host == "" { + return errInvalidHostPort + } + port, err := strconv.ParseUint(portStr, 10, 16) + if err != nil || port == 0 { + return errInvalidHostPort + } + return nil +} diff --git a/admin/api/instance_test.go b/admin/api/instance_test.go index 2fd17b3..ebd0444 100644 --- a/admin/api/instance_test.go +++ b/admin/api/instance_test.go @@ -346,6 +346,60 @@ func TestTestConnection_EmptyAddress_Returns400(t *testing.T) { } } +func TestCreateInstance_InvalidAddress_Returns400(t *testing.T) { + t.Parallel() + _, mux := newTestHandler(t) + + tests := []struct { + name string + address string + }{ + {"no port", "not-a-host-port"}, + {"empty host", ":9090"}, + {"non-numeric port", "example.com:abc"}, + {"port zero", "example.com:0"}, + {"port too large", "example.com:70000"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := `{"name":"proxy-1","address":"` + tt.address + `"}` + req := httptest.NewRequest(http.MethodPost, "/api/instances", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("address %q: status = %d, want %d", tt.address, rec.Code, http.StatusBadRequest) + } + }) + } +} + +func TestTestConnection_InvalidAddress_Returns400(t *testing.T) { + t.Parallel() + _, mux := newTestHandler(t) + + tests := []struct { + name string + address string + }{ + {"no port", "no-port-here"}, + {"empty host", ":9090"}, + {"non-numeric port", "example.com:abc"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := `{"address":"` + tt.address + `"}` + req := httptest.NewRequest(http.MethodPost, "/api/instances/test", strings.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("address %q: status = %d, want %d", tt.address, rec.Code, http.StatusBadRequest) + } + }) + } +} + func TestCreateInstance_WhitespaceTrimmed(t *testing.T) { t.Parallel() _, mux := newTestHandler(t) diff --git a/admin/poller/poller.go b/admin/poller/poller.go index 9d5a259..2b95b2f 100644 --- a/admin/poller/poller.go +++ b/admin/poller/poller.go @@ -32,9 +32,7 @@ type ProbeResult struct { } // Probe performs a one-off health and version check against a proxy admin port. -func Probe(ctx context.Context, address string, timeout time.Duration) ProbeResult { - client := &http.Client{Timeout: timeout} - +func Probe(ctx context.Context, client *http.Client, address string) ProbeResult { health, err := fetchHealth(ctx, client, address) if err != nil { return ProbeResult{OK: false, Error: friendlyError(err)} @@ -97,14 +95,16 @@ func (p *Poller) pollAll(ctx context.Context) { slog.Error("poller: listing instances", "error", err) return } + // Prune failure counts for instances no longer in the registry. + p.pruneFailures(instances) + if len(instances) == 0 { return } type result struct { - id int64 - probe ProbeResult - version string + id int64 + probe ProbeResult } results := make(chan result, len(instances)) @@ -118,8 +118,8 @@ func (p *Poller) pollAll(ctx context.Context) { jitter := time.Duration(rand.Int64N(int64(2*maxJitter))) - maxJitter sleep(ctx, jitter) - pr := Probe(ctx, inst.Address, p.timeout) - results <- result{id: inst.ID, probe: pr, version: pr.Version} + pr := Probe(ctx, p.client, inst.Address) + results <- result{id: inst.ID, probe: pr} }(inst) } @@ -133,6 +133,24 @@ func (p *Poller) pollAll(ctx context.Context) { } } +func (p *Poller) pruneFailures(active []store.Instance) { + p.mu.Lock() + defer p.mu.Unlock() + + for id := range p.failures { + found := false + for _, inst := range active { + if inst.ID == id { + found = true + break + } + } + if !found { + delete(p.failures, id) + } + } +} + func (p *Poller) applyResult(ctx context.Context, id int64, pr ProbeResult) { p.mu.Lock() defer p.mu.Unlock() diff --git a/admin/poller/poller_test.go b/admin/poller/poller_test.go index 55e634c..b70f2b1 100644 --- a/admin/poller/poller_test.go +++ b/admin/poller/poller_test.go @@ -48,7 +48,7 @@ func TestProbe_HealthyProxy_ReturnsOK(t *testing.T) { proxy := fakeProxy(t) addr := strings.TrimPrefix(proxy.URL, "http://") - result := Probe(context.Background(), addr, 2*time.Second) + result := Probe(context.Background(), &http.Client{Timeout: 2 * time.Second}, addr) if !result.OK { t.Fatalf("expected OK=true, got error: %s", result.Error) @@ -64,7 +64,7 @@ func TestProbe_HealthyProxy_ReturnsOK(t *testing.T) { func TestProbe_UnreachableAddress_ReturnsError(t *testing.T) { t.Parallel() - result := Probe(context.Background(), "127.0.0.1:1", 1*time.Second) + result := Probe(context.Background(), &http.Client{Timeout: 1 * time.Second}, "127.0.0.1:1") if result.OK { t.Error("expected OK=false for unreachable address") @@ -83,7 +83,7 @@ func TestProbe_HealthEndpointError_ReturnsError(t *testing.T) { defer srv.Close() addr := strings.TrimPrefix(srv.URL, "http://") - result := Probe(context.Background(), addr, 2*time.Second) + result := Probe(context.Background(), &http.Client{Timeout: 2 * time.Second}, addr) if result.OK { t.Error("expected OK=false for error status") @@ -205,6 +205,42 @@ func TestPoller_RecoveryAfterUnreachable_SetsHealthy(t *testing.T) { } } +func TestPoller_DeletedInstance_PrunesFailures(t *testing.T) { + t.Parallel() + st := openTestStore(t) + + ctx := context.Background() + inst, err := st.CreateInstance(ctx, "test-proxy", "127.0.0.1:1") + if err != nil { + t.Fatalf("CreateInstance() error = %v", err) + } + + p := New(st, 1*time.Hour, 500*time.Millisecond) + + // Accumulate failures. + p.pollAll(ctx) + + p.mu.Lock() + count := p.failures[inst.ID] + p.mu.Unlock() + if count != 1 { + t.Fatalf("failures[%d] = %d, want 1", inst.ID, count) + } + + // Delete the instance and poll again. + if err := st.DeleteInstance(ctx, inst.ID); err != nil { + t.Fatalf("DeleteInstance() error = %v", err) + } + p.pollAll(ctx) + + p.mu.Lock() + _, exists := p.failures[inst.ID] + p.mu.Unlock() + if exists { + t.Errorf("failures[%d] still present after instance deletion", inst.ID) + } +} + func TestPoller_RunStopsOnContextCancel(t *testing.T) { t.Parallel() st := openTestStore(t) diff --git a/admin/store/instance.go b/admin/store/instance.go index 782b4de..2d4345a 100644 --- a/admin/store/instance.go +++ b/admin/store/instance.go @@ -57,7 +57,7 @@ func (s *Store) GetInstance(ctx context.Context, id int64) (*Instance, error) { `SELECT id, name, address, status, version, last_seen_at, created_at, updated_at FROM instances WHERE id = ?`, id) - inst, err := scanInstanceRow(row) + inst, err := scanInstance(row) if errors.Is(err, sql.ErrNoRows) { return nil, ErrInstanceNotFound } @@ -164,15 +164,6 @@ func scanInstance(s scanner) (Instance, error) { return inst, nil } -func scanInstanceRow(row *sql.Row) (Instance, error) { - var inst Instance - err := row.Scan( - &inst.ID, &inst.Name, &inst.Address, &inst.Status, - &inst.Version, &inst.LastSeenAt, &inst.CreatedAt, &inst.UpdatedAt, - ) - return inst, err -} - func isUniqueConstraintError(err error) bool { return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") } diff --git a/admin/ui/src/components/InstanceCard.vue b/admin/ui/src/components/InstanceCard.vue index 808c3b7..b0d5745 100644 --- a/admin/ui/src/components/InstanceCard.vue +++ b/admin/ui/src/components/InstanceCard.vue @@ -33,6 +33,7 @@ import { computed } from "vue"; import BaseCard from "./BaseCard.vue"; import BaseButton from "./BaseButton.vue"; import StatusIndicator from "./StatusIndicator.vue"; +import { isInstanceStale, formatTime } from "../utils/instance.js"; const props = defineProps({ instance: { type: Object, required: true }, @@ -40,28 +41,13 @@ const props = defineProps({ defineEmits(["edit", "delete"]); -const STALE_THRESHOLD_MS = 2 * 60 * 1000; - -const isStale = computed(() => { - if (props.instance.status !== "healthy" || !props.instance.last_seen_at) return false; - return Date.now() - new Date(props.instance.last_seen_at).getTime() > STALE_THRESHOLD_MS; -}); +const isStale = computed(() => isInstanceStale(props.instance)); const statusLabel = computed(() => { if (isStale.value) return "Stale"; const labels = { healthy: "Healthy", unreachable: "Unreachable", unknown: "Unknown" }; return labels[props.instance.status] || "Unknown"; }); - -function formatTime(ts) { - if (!ts) return ""; - const d = new Date(ts); - const secs = Math.floor((Date.now() - d.getTime()) / 1000); - if (secs < 60) return "just now"; - if (secs < 3600) return `${Math.floor(secs / 60)}m ago`; - if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`; - return d.toLocaleDateString(); -}