diff --git a/.github/workflows/ci-admin.yml b/.github/workflows/ci-admin.yml index d0c6374..8aeec6b 100644 --- a/.github/workflows/ci-admin.yml +++ b/.github/workflows/ci-admin.yml @@ -49,6 +49,7 @@ jobs: uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: version: ${{ env.GOLANGCI_LINT_VERSION }} + install-mode: goinstall working-directory: admin args: --build-tags=dev @@ -71,6 +72,10 @@ jobs: working-directory: admin/ui run: pnpm install --frozen-lockfile + - name: Check formatting + working-directory: admin/ui + run: pnpm prettier --check "src/**/*.{js,vue,css}" + - name: Lint working-directory: admin/ui run: pnpm lint @@ -113,7 +118,7 @@ jobs: - name: Run tests working-directory: admin/ui - run: pnpm test --passWithNoTests + run: pnpm test build: name: Build diff --git a/.gitignore b/.gitignore index f825046..58fc04b 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,10 @@ test/load/results/ # Admin portal frontend admin/ui/node_modules/ admin/ui/dist/ +admin/ui/.vite/ + +# Playwright MCP output +.playwright-mcp/ + +# Admin QA test logs +.admin-qa-logs/ diff --git a/.golangci.yml b/.golangci.yml index 4ef5482..3af5231 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -145,6 +145,11 @@ linters: path: internal/context/ text: "var-naming: avoid package names" + - linters: + - revive + path: admin/api/ + text: "var-naming: avoid meaningless package names" + # Allow pkg/crypto name even though it conflicts with stdlib. # This package handles certificate generation, not general crypto. # TODO: Consider renaming to pkg/certs in future refactor. diff --git a/Makefile b/Makefile index 8a1b96b..8e78b5a 100644 --- a/Makefile +++ b/Makefile @@ -51,7 +51,14 @@ LDFLAGS_DEV := -ldflags "\ all: lint test build .PHONY: ci -ci: fmt license-check lint test-race gosec govulncheck build ## Run all CI checks locally +ci: fmt license-check lint ci-admin-ui test-race gosec govulncheck build build-onboard build-admin-dev ## Run all CI checks locally + +.PHONY: ci-admin-ui +ci-admin-ui: ## Run admin UI checks (format, lint, test) + cd $(ADMIN_UI_DIR) && pnpm install --frozen-lockfile + cd $(ADMIN_UI_DIR) && pnpm prettier --check "src/**/*.{js,vue,css}" + cd $(ADMIN_UI_DIR) && pnpm lint + cd $(ADMIN_UI_DIR) && pnpm test # ============================================================================ # Build @@ -343,7 +350,7 @@ gosec: ## Run gosec security scanner (all modules) @if [ -x "$(GOSEC)" ]; then \ $(GOSEC) -exclude=G706 -exclude-dir=sdk -exclude-dir=plugins -exclude-dir=admin ./... && \ (cd sdk && $(GOSEC) ./...) && \ - (cd admin && $(GOSEC) -tags=dev ./...); \ + (cd admin && $(GOSEC) -exclude=G706 -tags=dev ./...); \ else \ echo "gosec not installed. Run: make tools"; \ exit 1; \ diff --git a/admin/api/instance.go b/admin/api/instance.go new file mode 100644 index 0000000..3d60dae --- /dev/null +++ b/admin/api/instance.go @@ -0,0 +1,233 @@ +// Copyright 2026 CloudBlue LLC +// SPDX-License-Identifier: Apache-2.0 + +package api + +import ( + "encoding/json" + "errors" + "fmt" + "log/slog" + "net" + "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 + 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, + client: &http.Client{Timeout: 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 + } + if err := validHostPort(addr); err != nil { + respondError(w, http.StatusBadRequest, "VALIDATION_ERROR", err.Error()) + return + } + + result := poller.Probe(r.Context(), h.client, addr) + 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 (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 + } + 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 + } + 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 new file mode 100644 index 0000000..d424975 --- /dev/null +++ b/admin/api/instance_test.go @@ -0,0 +1,469 @@ +// 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) *http.ServeMux { + t.Helper() + st := openTestStore(t) + h := NewInstanceHandler(st, 2*time.Second) + mux := http.NewServeMux() + h.Register(mux) + return 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_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) + + 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..55d3997 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" ) @@ -50,11 +51,7 @@ func run() error { configureLogging(cfg) - slog.Info("starting chaperone-admin", - "version", Version, - "commit", GitCommit, - "built", BuildDate, - ) + slog.Info("starting chaperone-admin", "version", Version, "commit", GitCommit, "built", BuildDate) st, err := store.Open(context.Background(), cfg.Database.Path) if err != nil { @@ -67,6 +64,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/go.mod b/admin/go.mod index e4d6f62..4d4b61c 100644 --- a/admin/go.mod +++ b/admin/go.mod @@ -1,6 +1,6 @@ module github.com/cloudblue/chaperone/admin -go 1.25.7 +go 1.26.1 require ( gopkg.in/yaml.v3 v3.0.1 diff --git a/admin/poller/poller.go b/admin/poller/poller.go new file mode 100644 index 0000000..efcbbd9 --- /dev/null +++ b/admin/poller/poller.go @@ -0,0 +1,259 @@ +// 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, client *http.Client, address string) ProbeResult { + 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 + } + // 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 + } + + results := make(chan result, len(instances)) + var wg sync.WaitGroup + + for i := range instances { + inst := &instances[i] + wg.Add(1) + go func() { + defer wg.Done() + // Jitter: ±1s random offset to spread scrapes. + jitter := time.Duration(rand.Int64N(int64(2*maxJitter))) - maxJitter // #nosec G404 -- jitter doesn't need cryptographic randomness //nolint:gosec + sleep(ctx, jitter) + + pr := Probe(ctx, p.client, inst.Address) + results <- result{id: inst.ID, probe: pr} + }() + } + + go func() { + wg.Wait() + close(results) + }() + + for r := range results { + p.applyResult(ctx, r.id, r.probe) + } +} + +func (p *Poller) pruneFailures(active []store.Instance) { + p.mu.Lock() + defer p.mu.Unlock() + + for id := range p.failures { + found := false + for j := range active { + if active[j].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() + + 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, http.NoBody) + if err != nil { + return "", fmt.Errorf("creating health request: %w", err) + } + + resp, err := client.Do(req) // #nosec G704 -- address comes from admin-managed instance registry + 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, http.NoBody) + if err != nil { + return "", fmt.Errorf("creating version request: %w", err) + } + + resp, err := client.Do(req) // #nosec G704 -- address comes from admin-managed instance registry + 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..e81947a --- /dev/null +++ b/admin/poller/poller_test.go @@ -0,0 +1,266 @@ +// 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(), &http.Client{Timeout: 2 * time.Second}, addr) + + 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(), &http.Client{Timeout: 1 * time.Second}, "127.0.0.1:1") + + 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(), &http.Client{Timeout: 2 * time.Second}, addr) + + 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. + _, updateErr := st.UpdateInstance(ctx, inst.ID, "test-proxy", addr) + if updateErr != nil { + t.Fatalf("UpdateInstance() error = %v", updateErr) + } + + // 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_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) + + 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..2d4345a --- /dev/null +++ b/admin/store/instance.go @@ -0,0 +1,169 @@ +// 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 := scanInstance(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 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..df7dc6d --- /dev/null +++ b/admin/store/instance_test.go @@ -0,0 +1,254 @@ +// 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) + } + + err = st.DeleteInstance(ctx, created.ID) + if 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) + } + + err = st.SetInstanceHealthy(ctx, created.ID, "1.2.3") + if 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) + } + + err = st.SetInstanceUnreachable(ctx, created.ID) + if 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/.prettierrc b/admin/ui/.prettierrc index aa5c2fb..a50db1a 100644 --- a/admin/ui/.prettierrc +++ b/admin/ui/.prettierrc @@ -1,6 +1,6 @@ { "semi": true, - "singleQuote": false, + "singleQuote": true, "useTabs": true, "tabWidth": 2, "trailingComma": "all" diff --git a/admin/ui/src/App.vue b/admin/ui/src/App.vue index 7a7f92a..f80ecb1 100644 --- a/admin/ui/src/App.vue +++ b/admin/ui/src/App.vue @@ -5,6 +5,6 @@ diff --git a/admin/ui/src/assets/variables.css b/admin/ui/src/assets/variables.css index 6d9b4fe..9b55bf6 100644 --- a/admin/ui/src/assets/variables.css +++ b/admin/ui/src/assets/variables.css @@ -37,8 +37,8 @@ /* Typography */ --font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", Roboto, sans-serif; - --font-family-mono: "SF Mono", "Fira Code", "Cascadia Code", monospace; + -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Inter', Roboto, sans-serif; + --font-family-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; --font-size-xs: 0.6875rem; /* 11px */ --font-size-sm: 0.8125rem; /* 13px */ diff --git a/admin/ui/src/components/AddInstanceModal.vue b/admin/ui/src/components/AddInstanceModal.vue new file mode 100644 index 0000000..ffbe252 --- /dev/null +++ b/admin/ui/src/components/AddInstanceModal.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/admin/ui/src/components/BaseButton.vue b/admin/ui/src/components/BaseButton.vue index 086adf4..de08318 100644 --- a/admin/ui/src/components/BaseButton.vue +++ b/admin/ui/src/components/BaseButton.vue @@ -12,13 +12,13 @@ defineProps({ variant: { type: String, - default: "primary", - validator: (v) => ["primary", "secondary", "danger"].includes(v), + default: 'primary', + validator: (v) => ['primary', 'secondary', 'danger', 'ghost'].includes(v), }, size: { type: String, - default: "md", - validator: (v) => ["sm", "md"].includes(v), + default: 'md', + validator: (v) => ['sm', 'md'].includes(v), }, disabled: { type: Boolean, @@ -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/BaseInput.vue b/admin/ui/src/components/BaseInput.vue index 611d3db..3adc0b8 100644 --- a/admin/ui/src/components/BaseInput.vue +++ b/admin/ui/src/components/BaseInput.vue @@ -1,8 +1,6 @@ diff --git a/admin/ui/src/main.js b/admin/ui/src/main.js index e9b5ead..776b771 100644 --- a/admin/ui/src/main.js +++ b/admin/ui/src/main.js @@ -1,11 +1,11 @@ -import { createApp } from "vue"; -import { createPinia } from "pinia"; -import App from "./App.vue"; -import router from "./router"; -import "./assets/variables.css"; -import "./assets/global.css"; +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import App from './App.vue'; +import router from './router'; +import './assets/variables.css'; +import './assets/global.css'; const app = createApp(App); app.use(createPinia()); app.use(router); -app.mount("#app"); +app.mount('#app'); diff --git a/admin/ui/src/router/index.js b/admin/ui/src/router/index.js index 0804c53..7be5fa7 100644 --- a/admin/ui/src/router/index.js +++ b/admin/ui/src/router/index.js @@ -1,22 +1,22 @@ -import { createRouter, createWebHistory } from "vue-router"; -import DashboardView from "../views/DashboardView.vue"; -import AuditLogView from "../views/AuditLogView.vue"; +import { createRouter, createWebHistory } from 'vue-router'; +import DashboardView from '../views/DashboardView.vue'; +import AuditLogView from '../views/AuditLogView.vue'; const routes = [ { - path: "/", - name: "dashboard", + path: '/', + name: 'dashboard', component: DashboardView, }, { - path: "/audit-log", - name: "audit-log", + path: '/audit-log', + name: 'audit-log', component: AuditLogView, }, { - path: "/:pathMatch(.*)*", - name: "not-found", - redirect: "/", + path: '/:pathMatch(.*)*', + name: 'not-found', + redirect: '/', }, ]; diff --git a/admin/ui/src/stores/instances.js b/admin/ui/src/stores/instances.js new file mode 100644 index 0000000..f2fc230 --- /dev/null +++ b/admin/ui/src/stores/instances.js @@ -0,0 +1,48 @@ +import { defineStore } from 'pinia'; +import { ref } from 'vue'; +import * as api from '../utils/api.js'; + +export const useInstanceStore = defineStore('instances', () => { + const instances = ref([]); + const loading = ref(false); + + async function fetchInstances() { + loading.value = true; + try { + instances.value = await api.get('/api/instances'); + } finally { + loading.value = false; + } + } + + async function createInstance(name, address) { + const inst = await api.post('/api/instances', { name, address }); + await fetchInstances(); + return inst; + } + + async function updateInstance(id, name, address) { + const inst = await api.put(`/api/instances/${id}`, { name, address }); + await fetchInstances(); + return inst; + } + + async function deleteInstance(id) { + await api.del(`/api/instances/${id}`); + await fetchInstances(); + } + + async function testConnection(address) { + return api.post('/api/instances/test', { address }); + } + + return { + instances, + loading, + fetchInstances, + createInstance, + updateInstance, + deleteInstance, + testConnection, + }; +}); diff --git a/admin/ui/src/stores/instances.test.js b/admin/ui/src/stores/instances.test.js new file mode 100644 index 0000000..39b3b24 --- /dev/null +++ b/admin/ui/src/stores/instances.test.js @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { useInstanceStore } from './instances.js'; + +vi.mock('../utils/api.js', () => ({ + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + del: vi.fn(), +})); + +import * as api from '../utils/api.js'; + +describe('useInstanceStore', () => { + let store; + + beforeEach(() => { + vi.restoreAllMocks(); + setActivePinia(createPinia()); + store = useInstanceStore(); + }); + + describe('fetchInstances', () => { + it('populates instances from API', async () => { + const data = [{ id: 1, name: 'proxy-1' }]; + api.get.mockResolvedValue(data); + await store.fetchInstances(); + expect(api.get).toHaveBeenCalledWith('/api/instances'); + expect(store.instances).toEqual(data); + }); + + it('sets loading true during fetch and false after', async () => { + let resolve; + api.get.mockReturnValue(new Promise((r) => (resolve = r))); + const p = store.fetchInstances(); + expect(store.loading).toBe(true); + resolve([]); + await p; + expect(store.loading).toBe(false); + }); + + it('sets loading false even on error', async () => { + api.get.mockRejectedValue(new Error('network error')); + await store.fetchInstances().catch(() => {}); + expect(store.loading).toBe(false); + }); + }); + + describe('createInstance', () => { + it('calls api.post and refreshes instances', async () => { + api.post.mockResolvedValue({ + id: 1, + name: 'proxy-1', + address: '10.0.0.1:9090', + }); + api.get.mockResolvedValue([{ id: 1 }]); + const result = await store.createInstance('proxy-1', '10.0.0.1:9090'); + expect(api.post).toHaveBeenCalledWith('/api/instances', { + name: 'proxy-1', + address: '10.0.0.1:9090', + }); + expect(result).toEqual({ + id: 1, + name: 'proxy-1', + address: '10.0.0.1:9090', + }); + expect(api.get).toHaveBeenCalledWith('/api/instances'); + }); + + it('throws on API error', async () => { + api.post.mockRejectedValue(new Error('Address already registered')); + await expect(store.createInstance('x', '1:2')).rejects.toThrow( + 'Address already registered', + ); + }); + }); + + describe('updateInstance', () => { + it('calls api.put and refreshes instances', async () => { + api.put.mockResolvedValue({ id: 1, name: 'updated' }); + api.get.mockResolvedValue([{ id: 1 }]); + const result = await store.updateInstance(1, 'updated', '10.0.0.1:9090'); + expect(api.put).toHaveBeenCalledWith('/api/instances/1', { + name: 'updated', + address: '10.0.0.1:9090', + }); + expect(result).toEqual({ id: 1, name: 'updated' }); + }); + }); + + describe('deleteInstance', () => { + it('calls api.del and refreshes instances', async () => { + api.del.mockResolvedValue(null); + api.get.mockResolvedValue([]); + await store.deleteInstance(1); + expect(api.del).toHaveBeenCalledWith('/api/instances/1'); + expect(api.get).toHaveBeenCalledWith('/api/instances'); + }); + }); + + describe('testConnection', () => { + it('calls api.post and returns result', async () => { + api.post.mockResolvedValue({ ok: true, version: '1.0.0' }); + const result = await store.testConnection('10.0.0.1:9090'); + expect(api.post).toHaveBeenCalledWith('/api/instances/test', { + address: '10.0.0.1:9090', + }); + expect(result).toEqual({ ok: true, version: '1.0.0' }); + }); + }); +}); diff --git a/admin/ui/src/utils/api.js b/admin/ui/src/utils/api.js new file mode 100644 index 0000000..1582b1b --- /dev/null +++ b/admin/ui/src/utils/api.js @@ -0,0 +1,52 @@ +class ApiError extends Error { + constructor(message, status, code) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.code = code; + } +} + +async function request(path, options = {}) { + const res = await fetch(path, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + if (!res.ok) { + let message = `Request failed (${res.status})`; + let code; + try { + const data = await res.json(); + if (data.error?.message) message = data.error.message; + if (data.error?.code) code = data.error.code; + } catch { + // response body not JSON — keep generic message + } + throw new ApiError(message, res.status, code); + } + + if (res.status === 204) return null; + return res.json(); +} + +export function get(path) { + return request(path); +} + +export function post(path, body) { + return request(path, { method: 'POST', body: JSON.stringify(body) }); +} + +export function put(path, body) { + return request(path, { method: 'PUT', body: JSON.stringify(body) }); +} + +export function del(path) { + return request(path, { method: 'DELETE' }); +} + +export { ApiError }; diff --git a/admin/ui/src/utils/api.test.js b/admin/ui/src/utils/api.test.js new file mode 100644 index 0000000..fa7c1e2 --- /dev/null +++ b/admin/ui/src/utils/api.test.js @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { get, post, put, del, ApiError } from './api.js'; + +describe('api client', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + function mockFetch(status, body, { json = true } = {}) { + const res = { + ok: status >= 200 && status < 300, + status, + json: json + ? vi.fn().mockResolvedValue(body) + : vi.fn().mockRejectedValue(new Error('not json')), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(res); + return res; + } + + describe('get', () => { + it('returns parsed JSON on success', async () => { + mockFetch(200, [{ id: 1 }]); + const result = await get('/api/instances'); + expect(result).toEqual([{ id: 1 }]); + expect(globalThis.fetch).toHaveBeenCalledWith('/api/instances', { + headers: { 'Content-Type': 'application/json' }, + }); + }); + + it('throws ApiError with server message on failure', async () => { + mockFetch(404, { + error: { + code: 'INSTANCE_NOT_FOUND', + message: 'No instance with ID 42', + }, + }); + const err = await get('/api/instances/42').catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err.message).toBe('No instance with ID 42'); + expect(err.status).toBe(404); + expect(err.code).toBe('INSTANCE_NOT_FOUND'); + }); + + it('throws ApiError with generic message when response is not JSON', async () => { + mockFetch(500, null, { json: false }); + const err = await get('/api/instances').catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err.message).toBe('Request failed (500)'); + expect(err.status).toBe(500); + }); + }); + + describe('post', () => { + it('sends JSON body and returns parsed response', async () => { + mockFetch(201, { id: 1, name: 'proxy-1' }); + const result = await post('/api/instances', { + name: 'proxy-1', + address: '10.0.0.1:9090', + }); + expect(result).toEqual({ id: 1, name: 'proxy-1' }); + + const [url, opts] = globalThis.fetch.mock.calls[0]; + expect(url).toBe('/api/instances'); + expect(opts.method).toBe('POST'); + expect(JSON.parse(opts.body)).toEqual({ + name: 'proxy-1', + address: '10.0.0.1:9090', + }); + }); + + it('throws ApiError with server message on conflict', async () => { + mockFetch(409, { + error: { + code: 'DUPLICATE_ADDRESS', + message: 'Address already registered', + }, + }); + const err = await post('/api/instances', { + name: 'x', + address: '1:2', + }).catch((e) => e); + expect(err).toBeInstanceOf(ApiError); + expect(err.message).toBe('Address already registered'); + expect(err.code).toBe('DUPLICATE_ADDRESS'); + }); + }); + + describe('put', () => { + it('sends JSON body with PUT method', async () => { + mockFetch(200, { id: 1, name: 'updated' }); + const result = await put('/api/instances/1', { name: 'updated' }); + expect(result).toEqual({ id: 1, name: 'updated' }); + + const [, opts] = globalThis.fetch.mock.calls[0]; + expect(opts.method).toBe('PUT'); + }); + }); + + describe('del', () => { + it('returns null for 204 No Content', async () => { + const res = { + ok: true, + status: 204, + json: vi.fn(), + }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(res); + const result = await del('/api/instances/1'); + expect(result).toBeNull(); + expect(res.json).not.toHaveBeenCalled(); + }); + + it('sends DELETE method', async () => { + const res = { ok: true, status: 204, json: vi.fn() }; + vi.spyOn(globalThis, 'fetch').mockResolvedValue(res); + await del('/api/instances/1'); + + const [url, opts] = globalThis.fetch.mock.calls[0]; + expect(url).toBe('/api/instances/1'); + expect(opts.method).toBe('DELETE'); + }); + }); +}); diff --git a/admin/ui/src/utils/instance.js b/admin/ui/src/utils/instance.js new file mode 100644 index 0000000..260eff2 --- /dev/null +++ b/admin/ui/src/utils/instance.js @@ -0,0 +1,34 @@ +export const STALE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes + +export function isInstanceStale(instance) { + if (instance.status !== 'healthy' || !instance.last_seen_at) return false; + return ( + Date.now() - new Date(instance.last_seen_at).getTime() > STALE_THRESHOLD_MS + ); +} + +export 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(); +} + +const STATUS_LABELS = { + healthy: 'Healthy', + unreachable: 'Unreachable', + unknown: 'Unknown', + stale: 'Stale', +}; + +export function getStatusLabel(status, isStale) { + if (isStale) return STATUS_LABELS.stale; + return STATUS_LABELS[status] || STATUS_LABELS.unknown; +} + +export function filterStaleInstances(instances) { + return instances.filter(isInstanceStale); +} diff --git a/admin/ui/src/utils/instance.test.js b/admin/ui/src/utils/instance.test.js new file mode 100644 index 0000000..a811580 --- /dev/null +++ b/admin/ui/src/utils/instance.test.js @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + STALE_THRESHOLD_MS, + isInstanceStale, + formatTime, + getStatusLabel, + filterStaleInstances, +} from './instance.js'; + +describe('STALE_THRESHOLD_MS', () => { + it('is 2 minutes in milliseconds', () => { + expect(STALE_THRESHOLD_MS).toBe(120_000); + }); +}); + +describe('isInstanceStale', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns false for non-healthy instance', () => { + expect( + isInstanceStale({ + status: 'unreachable', + last_seen_at: '2020-01-01T00:00:00Z', + }), + ).toBe(false); + expect( + isInstanceStale({ + status: 'unknown', + last_seen_at: '2020-01-01T00:00:00Z', + }), + ).toBe(false); + }); + + it('returns false when last_seen_at is null', () => { + expect(isInstanceStale({ status: 'healthy', last_seen_at: null })).toBe( + false, + ); + }); + + it('returns false when last seen recently', () => { + const recent = new Date(Date.now() - 30_000).toISOString(); // 30s ago + expect(isInstanceStale({ status: 'healthy', last_seen_at: recent })).toBe( + false, + ); + }); + + it('returns true when last seen over 2 minutes ago', () => { + const old = new Date(Date.now() - STALE_THRESHOLD_MS - 1000).toISOString(); + expect(isInstanceStale({ status: 'healthy', last_seen_at: old })).toBe( + true, + ); + }); + + it('returns false at exactly the threshold boundary', () => { + vi.spyOn(Date, 'now').mockReturnValue(1000000); + const atBoundary = new Date(1000000 - STALE_THRESHOLD_MS).toISOString(); + expect( + isInstanceStale({ status: 'healthy', last_seen_at: atBoundary }), + ).toBe(false); + }); +}); + +describe('formatTime', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns empty string for null/undefined', () => { + expect(formatTime(null)).toBe(''); + expect(formatTime(undefined)).toBe(''); + }); + + it('returns "just now" for timestamps under 60 seconds ago', () => { + const ts = new Date(Date.now() - 5000).toISOString(); + expect(formatTime(ts)).toBe('just now'); + }); + + it('returns minutes ago for timestamps under 1 hour', () => { + const ts = new Date(Date.now() - 5 * 60 * 1000).toISOString(); + expect(formatTime(ts)).toBe('5m ago'); + }); + + it('returns hours ago for timestamps under 24 hours', () => { + const ts = new Date(Date.now() - 3 * 3600 * 1000).toISOString(); + expect(formatTime(ts)).toBe('3h ago'); + }); + + it('returns locale date string for timestamps over 24 hours', () => { + const d = new Date(Date.now() - 48 * 3600 * 1000); + expect(formatTime(d.toISOString())).toBe(d.toLocaleDateString()); + }); + + it('floors minutes correctly', () => { + // 90 seconds = 1.5 minutes → should show "1m ago" + const ts = new Date(Date.now() - 90_000).toISOString(); + expect(formatTime(ts)).toBe('1m ago'); + }); + + it('floors hours correctly', () => { + // 5400 seconds = 1.5 hours → should show "1h ago" + const ts = new Date(Date.now() - 5400 * 1000).toISOString(); + expect(formatTime(ts)).toBe('1h ago'); + }); +}); + +describe('getStatusLabel', () => { + it('returns correct label for each status', () => { + expect(getStatusLabel('healthy', false)).toBe('Healthy'); + expect(getStatusLabel('unreachable', false)).toBe('Unreachable'); + expect(getStatusLabel('unknown', false)).toBe('Unknown'); + }); + + it('returns "Stale" when isStale is true regardless of status', () => { + expect(getStatusLabel('healthy', true)).toBe('Stale'); + expect(getStatusLabel('unreachable', true)).toBe('Stale'); + expect(getStatusLabel('unknown', true)).toBe('Stale'); + }); + + it('falls back to "Unknown" for unrecognized status', () => { + expect(getStatusLabel('bogus', false)).toBe('Unknown'); + expect(getStatusLabel(undefined, false)).toBe('Unknown'); + }); +}); + +describe('filterStaleInstances', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns empty array when no instances are stale', () => { + const recent = new Date(Date.now() - 30_000).toISOString(); + const instances = [ + { status: 'healthy', last_seen_at: recent }, + { status: 'unreachable', last_seen_at: '2020-01-01T00:00:00Z' }, + ]; + expect(filterStaleInstances(instances)).toEqual([]); + }); + + it('returns only stale instances', () => { + const recent = new Date(Date.now() - 30_000).toISOString(); + const old = new Date(Date.now() - STALE_THRESHOLD_MS - 1000).toISOString(); + const stale = { status: 'healthy', last_seen_at: old }; + const fresh = { status: 'healthy', last_seen_at: recent }; + expect(filterStaleInstances([stale, fresh])).toEqual([stale]); + }); + + it('returns empty array for empty input', () => { + expect(filterStaleInstances([])).toEqual([]); + }); +}); diff --git a/admin/ui/src/utils/test-utils.js b/admin/ui/src/utils/test-utils.js new file mode 100644 index 0000000..ead8075 --- /dev/null +++ b/admin/ui/src/utils/test-utils.js @@ -0,0 +1,13 @@ +import { createApp } from 'vue'; + +export function withSetup(composable) { + let result; + const app = createApp({ + setup() { + result = composable(); + return () => {}; + }, + }); + app.mount(document.createElement('div')); + return { result, app }; +} diff --git a/admin/ui/src/utils/validation.js b/admin/ui/src/utils/validation.js new file mode 100644 index 0000000..d2402e1 --- /dev/null +++ b/admin/ui/src/utils/validation.js @@ -0,0 +1,6 @@ +export function validateInstanceForm(name, address) { + return { + name: name.trim() ? '' : 'Name is required', + address: address.trim() ? '' : 'Address is required', + }; +} diff --git a/admin/ui/src/utils/validation.test.js b/admin/ui/src/utils/validation.test.js new file mode 100644 index 0000000..4bfa41f --- /dev/null +++ b/admin/ui/src/utils/validation.test.js @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { validateInstanceForm } from './validation.js'; + +describe('validateInstanceForm', () => { + it('returns no errors for valid inputs', () => { + const errors = validateInstanceForm('proxy-1', '10.0.0.1:9090'); + expect(errors.name).toBe(''); + expect(errors.address).toBe(''); + }); + + it('returns name error when name is empty', () => { + const errors = validateInstanceForm('', '10.0.0.1:9090'); + expect(errors.name).toBe('Name is required'); + expect(errors.address).toBe(''); + }); + + it('returns address error when address is empty', () => { + const errors = validateInstanceForm('proxy-1', ''); + expect(errors.address).toBe('Address is required'); + expect(errors.name).toBe(''); + }); + + it('returns both errors when both are empty', () => { + const errors = validateInstanceForm('', ''); + expect(errors.name).toBe('Name is required'); + expect(errors.address).toBe('Address is required'); + }); + + it('treats whitespace-only as empty', () => { + const errors = validateInstanceForm(' ', ' \t '); + expect(errors.name).toBe('Name is required'); + expect(errors.address).toBe('Address is required'); + }); + + it('accepts values with leading/trailing whitespace', () => { + const errors = validateInstanceForm(' proxy-1 ', ' 10.0.0.1:9090 '); + expect(errors.name).toBe(''); + expect(errors.address).toBe(''); + }); +}); diff --git a/admin/ui/src/views/AuditLogView.vue b/admin/ui/src/views/AuditLogView.vue index b392f56..923247e 100644 --- a/admin/ui/src/views/AuditLogView.vue +++ b/admin/ui/src/views/AuditLogView.vue @@ -35,8 +35,8 @@