From 0d888a22562f695faa4e6fae3cfbdd890031e371 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:47:05 +0000 Subject: [PATCH 1/6] Initial plan From 6c616f770b280665cb77f4758c1188d7f88ca385 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:51:29 +0000 Subject: [PATCH 2/6] Add /version endpoint to fetch backend version dynamically Co-authored-by: dkrizic <1181349+dkrizic@users.noreply.github.com> --- ui/service/handlers.go | 39 +++++++++++++++++++++++++++++++ ui/service/templates/index.gohtml | 32 ++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/ui/service/handlers.go b/ui/service/handlers.go index 2232066..b3a52c0 100644 --- a/ui/service/handlers.go +++ b/ui/service/handlers.go @@ -1,13 +1,17 @@ package service import ( + "context" + "encoding/json" "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 +43,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 +271,40 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { w.Write([]byte("OK")) } +// handleVersion fetches the current backend version and returns it as JSON. +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) + http.Error(w, "Failed to fetch backend version", http.StatusInternalServerError) + span.SetStatus(codes.Error, err.Error()) + return + } + + // Return the version as JSON + response := map[string]string{ + "backendVersion": metaResp.Version, + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + slog.ErrorContext(ctx, "Failed to encode version response", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + span.SetStatus(codes.Error, err.Error()) + return + } +} + // 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/templates/index.gohtml b/ui/service/templates/index.gohtml index 8f815cf..fd89f7e 100644 --- a/ui/service/templates/index.gohtml +++ b/ui/service/templates/index.gohtml @@ -179,7 +179,7 @@
UI {{.UIVersion}} - {{if .BackendVersion}} · Backend {{.BackendVersion}}{{end}} + {{if .BackendVersion}} · Backend {{.BackendVersion}}{{end}}
@@ -270,6 +270,36 @@ }); })(); + // Backend version refresh (every 5 minutes) + (function() { + const versionElement = document.getElementById('backend-version'); + if (!versionElement) return; // Exit if no backend version element + + const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes in milliseconds + const SUBPATH = '{{.Subpath}}'; + + function refreshBackendVersion() { + fetch(SUBPATH + '/version') + .then(function(response) { + if (!response.ok) { + throw new Error('Failed to fetch version'); + } + return response.json(); + }) + .then(function(data) { + if (data.backendVersion && data.backendVersion !== versionElement.textContent) { + versionElement.textContent = data.backendVersion; + } + }) + .catch(function(error) { + console.error('Error fetching backend version:', error); + }); + } + + // Refresh version every 5 minutes + setInterval(refreshBackendVersion, REFRESH_INTERVAL); + })(); + // HTMX error handler with toast document.body.addEventListener('htmx:responseError', function(evt) { const errorMsg = evt.detail.xhr.responseText || 'An error occurred'; From c0344a71e1c99cd24388f1e3e55643e69cbf4571 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:52:13 +0000 Subject: [PATCH 3/6] Add tests for /version endpoint Co-authored-by: dkrizic <1181349+dkrizic@users.noreply.github.com> --- ui/service/handlers_test.go | 60 +++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/ui/service/handlers_test.go b/ui/service/handlers_test.go index 62ea849..1ab2f35 100644 --- a/ui/service/handlers_test.go +++ b/ui/service/handlers_test.go @@ -377,6 +377,66 @@ 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: `{"backendVersion":"v1.2.3"}`, + }, + { + name: "error fetching version", + mockVersion: "", + mockError: io.EOF, + expectedStatus: http.StatusInternalServerError, + 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) + + if tt.expectedStatus == http.StatusOK { + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.JSONEq(t, tt.expectedBody, string(body)) + assert.Equal(t, "application/json", 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`)) From 6a7ce63dcd6f85a60924b86988b374284def427a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:53:58 +0000 Subject: [PATCH 4/6] Simplify version endpoint to use HTMX instead of JSON/fetch Co-authored-by: dkrizic <1181349+dkrizic@users.noreply.github.com> --- ui/service/handlers.go | 21 ++++++------------- ui/service/handlers_test.go | 15 ++++++------- ui/service/templates/index.gohtml | 35 ++++--------------------------- 3 files changed, 18 insertions(+), 53 deletions(-) diff --git a/ui/service/handlers.go b/ui/service/handlers.go index b3a52c0..c7472be 100644 --- a/ui/service/handlers.go +++ b/ui/service/handlers.go @@ -2,7 +2,6 @@ package service import ( "context" - "encoding/json" "fmt" "io" "log/slog" @@ -271,7 +270,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { w.Write([]byte("OK")) } -// handleVersion fetches the current backend version and returns it as JSON. +// handleVersion fetches the current backend version and returns it as HTML. func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { ctx, span := otel.Tracer("ui/service").Start(r.Context(), "handleVersion") defer span.End() @@ -286,23 +285,15 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { metaResp, err := s.metaClient.Meta(metaCtx, &metav1.MetaRequest{}) if err != nil { slog.WarnContext(ctx, "Failed to fetch backend version", "error", err) - http.Error(w, "Failed to fetch backend version", http.StatusInternalServerError) + // Return empty string on error to avoid breaking the UI + w.Write([]byte("")) span.SetStatus(codes.Error, err.Error()) return } - // Return the version as JSON - response := map[string]string{ - "backendVersion": metaResp.Version, - } - - w.Header().Set("Content-Type", "application/json") - if err := json.NewEncoder(w).Encode(response); err != nil { - slog.ErrorContext(ctx, "Failed to encode version response", "error", err) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - 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 diff --git a/ui/service/handlers_test.go b/ui/service/handlers_test.go index 1ab2f35..fadd87e 100644 --- a/ui/service/handlers_test.go +++ b/ui/service/handlers_test.go @@ -390,13 +390,13 @@ func TestHandleVersion(t *testing.T) { mockVersion: "v1.2.3", mockError: nil, expectedStatus: http.StatusOK, - expectedBody: `{"backendVersion":"v1.2.3"}`, + expectedBody: "v1.2.3", }, { name: "error fetching version", mockVersion: "", mockError: io.EOF, - expectedStatus: http.StatusInternalServerError, + expectedStatus: http.StatusOK, expectedBody: "", }, } @@ -425,11 +425,12 @@ func TestHandleVersion(t *testing.T) { assert.Equal(t, tt.expectedStatus, resp.StatusCode) - if tt.expectedStatus == http.StatusOK { - body, err := io.ReadAll(resp.Body) - assert.NoError(t, err) - assert.JSONEq(t, tt.expectedBody, string(body)) - assert.Equal(t, "application/json", resp.Header.Get("Content-Type")) + 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) diff --git a/ui/service/templates/index.gohtml b/ui/service/templates/index.gohtml index fd89f7e..cddf48e 100644 --- a/ui/service/templates/index.gohtml +++ b/ui/service/templates/index.gohtml @@ -179,7 +179,10 @@UI {{.UIVersion}} - {{if .BackendVersion}} · Backend {{.BackendVersion}}{{end}} + {{if .BackendVersion}} · Backend {{.BackendVersion}}{{end}}
@@ -270,36 +273,6 @@ }); })(); - // Backend version refresh (every 5 minutes) - (function() { - const versionElement = document.getElementById('backend-version'); - if (!versionElement) return; // Exit if no backend version element - - const REFRESH_INTERVAL = 5 * 60 * 1000; // 5 minutes in milliseconds - const SUBPATH = '{{.Subpath}}'; - - function refreshBackendVersion() { - fetch(SUBPATH + '/version') - .then(function(response) { - if (!response.ok) { - throw new Error('Failed to fetch version'); - } - return response.json(); - }) - .then(function(data) { - if (data.backendVersion && data.backendVersion !== versionElement.textContent) { - versionElement.textContent = data.backendVersion; - } - }) - .catch(function(error) { - console.error('Error fetching backend version:', error); - }); - } - - // Refresh version every 5 minutes - setInterval(refreshBackendVersion, REFRESH_INTERVAL); - })(); - // HTMX error handler with toast document.body.addEventListener('htmx:responseError', function(evt) { const errorMsg = evt.detail.xhr.responseText || 'An error occurred'; From 82d8e8a9a69fc279281dddc6c05ae277057f9b57 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:54:52 +0000 Subject: [PATCH 5/6] Address code review feedback: fix comment and improve template formatting Co-authored-by: dkrizic <1181349+dkrizic@users.noreply.github.com> --- ui/service/handlers.go | 2 +- ui/service/templates/index.gohtml | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/ui/service/handlers.go b/ui/service/handlers.go index c7472be..88f242d 100644 --- a/ui/service/handlers.go +++ b/ui/service/handlers.go @@ -270,7 +270,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { w.Write([]byte("OK")) } -// handleVersion fetches the current backend version and returns it as HTML. +// 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() diff --git a/ui/service/templates/index.gohtml b/ui/service/templates/index.gohtml index cddf48e..41d5b6b 100644 --- a/ui/service/templates/index.gohtml +++ b/ui/service/templates/index.gohtml @@ -179,10 +179,7 @@UI {{.UIVersion}} - {{if .BackendVersion}} · Backend {{.BackendVersion}}{{end}} + {{if .BackendVersion}} · Backend {{.BackendVersion}}{{end}}
From 74188ea58b57109275c38b9842316295681ef5f1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:57:16 +0000 Subject: [PATCH 6/6] Make UI fonts smaller to fit more content on screen Co-authored-by: dkrizic <1181349+dkrizic@users.noreply.github.com> --- ui/service/templates/features_list.gohtml | 4 +-- ui/service/templates/index.gohtml | 43 +++++++++++++---------- 2 files changed, 26 insertions(+), 21 deletions(-) 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}}