Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions ui/service/handlers.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
61 changes: 61 additions & 0 deletions ui/service/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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`))
Expand Down
4 changes: 2 additions & 2 deletions ui/service/templates/features_list.gohtml
Original file line number Diff line number Diff line change
Expand Up @@ -129,13 +129,13 @@ function confirmDeleteFeature(key) {
{{else}}
<div style="margin: 0; display: flex; gap: 0.5rem; align-items: center;">
<input type="text" value="{{.Value}}" readonly aria-label="Read-only value for {{.Key}}" style="margin: 0; flex: 1 1 auto; min-width: 220px; background-color: #f5f5f5; cursor: not-allowed;" title="This field is read-only">
<span style="margin: 0; padding: 0.5rem 1rem; color: #999; font-size: 0.875rem;">🔒 Read-only</span>
<span style="margin: 0; padding: 0.25rem 0.5rem; color: #999; font-size: 0.7rem;">🔒 Read-only</span>
</div>
{{end}}
</td>
<td>
{{if $.RestrictionsActive}}
<span style="color: #999; font-size: 0.875rem;">🔒 Protected</span>
<span style="color: #999; font-size: 0.7rem;">🔒 Protected</span>
{{else}}
<form hx-post="{{$.Subpath}}/features/delete"
hx-target="#feature-list"
Expand Down
45 changes: 25 additions & 20 deletions ui/service/templates/index.gohtml
Original file line number Diff line number Diff line change
Expand Up @@ -30,40 +30,40 @@

/* Compact layout */
body {
font-size: 0.875rem;
font-size: 0.75rem;
background-color: var(--bg-color);
color: var(--fg-color);
}

main.container {
max-width: 960px;
margin: 0 auto;
padding: 1rem;
padding: 0.75rem;
}

h1 {
font-size: 1.75rem;
margin-bottom: 0.5rem;
font-size: 1.25rem;
margin-bottom: 0.25rem;
}

h2 {
font-size: 1.25rem;
margin-bottom: 0.75rem;
font-size: 1rem;
margin-bottom: 0.5rem;
}

h3 {
font-size: 1.1rem;
margin-bottom: 0.5rem;
font-size: 0.875rem;
margin-bottom: 0.25rem;
}

/* Header layout */
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
gap: 1rem;
gap: 0.5rem;
}

header > div:first-child {
Expand All @@ -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;
}

Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
}

Expand All @@ -179,7 +184,7 @@
<p>
<small>
UI {{.UIVersion}}
{{if .BackendVersion}} · Backend {{.BackendVersion}}{{end}}
{{if .BackendVersion}} · Backend <span id="backend-version" hx-get="{{.Subpath}}/version" hx-trigger="every 5m" hx-swap="innerHTML">{{.BackendVersion}}</span>{{end}}
</small>
</p>
</div>
Expand Down