diff --git a/ui/service/handlers.go b/ui/service/handlers.go index 2232066..88f242d 100644 --- a/ui/service/handlers.go +++ b/ui/service/handlers.go @@ -1,13 +1,16 @@ package service import ( + "context" "fmt" "io" "log/slog" "net/http" "sort" + "time" featurev1 "github.com/dkrizic/feature/ui/repository/feature/v1" + metav1 "github.com/dkrizic/feature/ui/repository/meta/v1" workloadv1 "github.com/dkrizic/feature/ui/repository/workload/v1" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "go.opentelemetry.io/otel" @@ -39,6 +42,7 @@ func (s *Server) registerHandlers(mux *http.ServeMux) { mux.HandleFunc("POST "+prefix+"/features/update", s.requireAuth(otelhttp.NewHandler(http.HandlerFunc(s.handleFeatureUpdate), "handleFeatureUpdate").ServeHTTP)) mux.HandleFunc("POST "+prefix+"/features/delete", s.requireAuth(otelhttp.NewHandler(http.HandlerFunc(s.handleFeatureDelete), "handleFeatureDelete").ServeHTTP)) mux.HandleFunc("POST "+prefix+"/restart", s.requireAuth(otelhttp.NewHandler(http.HandlerFunc(s.handleRestart), "handleRestart").ServeHTTP)) + mux.HandleFunc("GET "+prefix+"/version", s.requireAuth(otelhttp.NewHandler(http.HandlerFunc(s.handleVersion), "handleVersion").ServeHTTP)) // Health check (no auth required) mux.HandleFunc("GET "+prefix+"/health", s.handleHealth) @@ -266,6 +270,32 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { w.Write([]byte("OK")) } +// handleVersion fetches the current backend version and returns it as plain text. +func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { + ctx, span := otel.Tracer("ui/service").Start(r.Context(), "handleVersion") + defer span.End() + + slog.InfoContext(ctx, "Fetching backend version") + + // Fetch backend version with a timeout + const grpcCallTimeout = 5 * time.Second + metaCtx, cancel := context.WithTimeout(ctx, grpcCallTimeout) + defer cancel() + + metaResp, err := s.metaClient.Meta(metaCtx, &metav1.MetaRequest{}) + if err != nil { + slog.WarnContext(ctx, "Failed to fetch backend version", "error", err) + // Return empty string on error to avoid breaking the UI + w.Write([]byte("")) + span.SetStatus(codes.Error, err.Error()) + return + } + + // Return just the version string as plain text for HTMX to swap + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(metaResp.Version)) +} + // handleWorkloadRestart handles workload restart requests func (s *Server) handleWorkloadRestart(w http.ResponseWriter, r *http.Request) { ctx, span := otel.Tracer("ui/service").Start(r.Context(), "handleWorkloadRestart") diff --git a/ui/service/handlers_test.go b/ui/service/handlers_test.go index 62ea849..fadd87e 100644 --- a/ui/service/handlers_test.go +++ b/ui/service/handlers_test.go @@ -377,6 +377,67 @@ func TestHandleFeatureDelete(t *testing.T) { } } +func TestHandleVersion(t *testing.T) { + tests := []struct { + name string + mockVersion string + mockError error + expectedStatus int + expectedBody string + }{ + { + name: "successful version fetch", + mockVersion: "v1.2.3", + mockError: nil, + expectedStatus: http.StatusOK, + expectedBody: "v1.2.3", + }, + { + name: "error fetching version", + mockVersion: "", + mockError: io.EOF, + expectedStatus: http.StatusOK, + expectedBody: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockMetaClient := new(MockMetaClient) + + if tt.mockError == nil { + mockMetaClient.On("Meta", mock.Anything, mock.Anything).Return(&metav1.MetaResponse{Version: tt.mockVersion}, nil) + } else { + mockMetaClient.On("Meta", mock.Anything, mock.Anything).Return(nil, tt.mockError) + } + + server := &Server{ + metaClient: mockMetaClient, + } + + req := httptest.NewRequest(http.MethodGet, "/version", nil) + w := httptest.NewRecorder() + + server.handleVersion(w, req) + + resp := w.Result() + defer resp.Body.Close() + + assert.Equal(t, tt.expectedStatus, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.Equal(t, tt.expectedBody, string(body)) + + if tt.expectedBody != "" { + assert.Equal(t, "text/plain", resp.Header.Get("Content-Type")) + } + + mockMetaClient.AssertExpectations(t) + }) + } +} + func TestRegisterHandlers(t *testing.T) { // Create minimal templates for testing tmpl := template.Must(template.New("index.gohtml").Parse(`Test`)) diff --git a/ui/service/templates/features_list.gohtml b/ui/service/templates/features_list.gohtml index 47257ac..080c505 100644 --- a/ui/service/templates/features_list.gohtml +++ b/ui/service/templates/features_list.gohtml @@ -129,13 +129,13 @@ function confirmDeleteFeature(key) { {{else}}
- ๐Ÿ”’ Read-only + ๐Ÿ”’ Read-only
{{end}} {{if $.RestrictionsActive}} - ๐Ÿ”’ Protected + ๐Ÿ”’ Protected {{else}}
div:first-child { @@ -75,15 +75,15 @@ background-color: var(--card-bg); border: 1px solid var(--border-color); border-radius: 0.375rem; - padding: 1rem; - margin-bottom: 1rem; + padding: 0.75rem; + margin-bottom: 0.75rem; } /* Theme toggle */ .theme-toggle { min-width: 120px; padding: 0.25rem 0.5rem; - font-size: 0.875rem; + font-size: 0.75rem; margin: 0; } @@ -92,14 +92,14 @@ display: inline-flex; align-items: center; gap: 0.25rem; - padding: 0.375rem 0.75rem; - font-size: 0.875rem; + padding: 0.25rem 0.5rem; + font-size: 0.75rem; } /* Error text */ .error-text { color: var(--danger-color); - font-size: 0.75rem; + font-size: 0.7rem; margin-top: 0.25rem; display: none; } @@ -147,16 +147,21 @@ /* Compact forms and tables */ input, select, button { - font-size: 0.875rem; + font-size: 0.75rem; + padding: 0.25rem 0.5rem; } table { - font-size: 0.875rem; + font-size: 0.75rem; + } + + table th, table td { + padding: 0.4rem 0.5rem; } /* Small text adjustments */ small { - font-size: 0.75rem; + font-size: 0.7rem; color: var(--muted-color); } @@ -179,7 +184,7 @@

UI {{.UIVersion}} - {{if .BackendVersion}} ยท Backend {{.BackendVersion}}{{end}} + {{if .BackendVersion}} ยท Backend {{.BackendVersion}}{{end}}