From 537e65ea6da0eacd3b05dc7c1bf1655897462a86 Mon Sep 17 00:00:00 2001 From: Hein Date: Mon, 27 Apr 2026 00:23:06 +0200 Subject: [PATCH] feat(ui): implement public status endpoint and update UI components * add public status handler and response types * modify status API to restrict access and update client tracking * adjust UI components to display public status information * update routing to include public status endpoint --- .gitignore | 3 +- internal/app/app.go | 4 +- internal/app/status.go | 63 +++++++++++++++++++- internal/app/status_test.go | 44 +++++++++++--- internal/app/ui/dist/placeholder.txt | 1 + ui/src/App.svelte | 58 ++++++++++++++---- ui/src/components/auth/LoginInfoPanel.svelte | 24 ++++++-- ui/src/components/auth/LoginPage.svelte | 4 +- ui/src/types.ts | 12 ++++ ui/vite.config.ts | 1 + 10 files changed, 182 insertions(+), 32 deletions(-) create mode 100644 internal/app/ui/dist/placeholder.txt diff --git a/.gitignore b/.gitignore index 11d4489..1492131 100644 --- a/.gitignore +++ b/.gitignore @@ -33,5 +33,6 @@ bin/ OB1/ ui/node_modules/ ui/.svelte-kit/ -internal/app/ui/dist/ +internal/app/ui/dist/* +!internal/app/ui/dist/placeholder.txt .codex diff --git a/internal/app/app.go b/internal/app/app.go index da0932b..1d2670c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -246,8 +246,8 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st mux.HandleFunc("/llms.txt", serveLLMSTXT) mux.HandleFunc("/.well-known/llms.txt", serveLLMSTXT) mux.HandleFunc("/robots.txt", serveRobotsTXT) - mux.HandleFunc("/api/status", statusAPIHandler(info, accessTracker, oauthEnabled)) - mux.HandleFunc("/status", statusAPIHandler(info, accessTracker, oauthEnabled)) + mux.Handle("/api/status", authMiddleware(statusAPIHandler(info, accessTracker, oauthEnabled))) + mux.HandleFunc("/status", publicStatusHandler(accessTracker)) mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/internal/app/status.go b/internal/app/status.go index 12a0d43..231d3bf 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -29,8 +29,24 @@ type statusAPIResponse struct { OAuthEnabled bool `json:"oauth_enabled"` } +type publicClientStatus struct { + KeyID string `json:"key_id"` + RequestCount int `json:"request_count"` + LastAccessedAt time.Time `json:"last_accessed_at"` +} + +type publicStatusResponse struct { + ConnectedCount int `json:"connected_count"` + ConnectedWindow string `json:"connected_window"` + Entries []publicClientStatus `json:"entries"` +} + func statusSnapshot(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool, now time.Time) statusAPIResponse { entries := tracker.Snapshot() + metrics := tracker.Metrics(20) + metrics.TopIPs = nil + metrics.TopAgents = nil + metrics.TopTools = nil return statusAPIResponse{ Title: "Avelon Memory Crystal Server (AMCS)", Description: "AMCS is a memory server that captures, links, and retrieves structured project thoughts for AI assistants using semantic search, summaries, and MCP tools.", @@ -40,8 +56,8 @@ func statusSnapshot(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabl ConnectedCount: tracker.ConnectedCount(now, connectedWindow), TotalKnown: len(entries), ConnectedWindow: "last 10 minutes", - Entries: entries, - Metrics: tracker.Metrics(20), + Entries: nil, + Metrics: metrics, OAuthEnabled: oauthEnabled, } } @@ -55,7 +71,7 @@ func fallback(value, defaultValue string) string { func statusAPIHandler(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/status" && r.URL.Path != "/status" { + if r.URL.Path != "/api/status" { http.NotFound(w, r) return } @@ -75,6 +91,47 @@ func statusAPIHandler(info buildinfo.Info, tracker *auth.AccessTracker, oauthEna } } +func publicStatusHandler(tracker *auth.AccessTracker) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/status" { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + now := time.Now() + cutoff := now.UTC().Add(-connectedWindow) + snapshot := tracker.Snapshot() + entries := make([]publicClientStatus, 0, len(snapshot)) + for _, item := range snapshot { + if item.LastAccessedAt.Before(cutoff) { + continue + } + entries = append(entries, publicClientStatus{ + KeyID: item.KeyID, + RequestCount: item.RequestCount, + LastAccessedAt: item.LastAccessedAt, + }) + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodHead { + return + } + + _ = json.NewEncoder(w).Encode(publicStatusResponse{ + ConnectedCount: len(entries), + ConnectedWindow: "last 10 minutes", + Entries: entries, + }) + } +} + func homeHandler(_ buildinfo.Info, _ *auth.AccessTracker, _ bool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { diff --git a/internal/app/status_test.go b/internal/app/status_test.go index 151f12c..686c32b 100644 --- a/internal/app/status_test.go +++ b/internal/app/status_test.go @@ -43,11 +43,8 @@ func TestStatusSnapshotShowsTrackedAccess(t *testing.T) { if snapshot.ConnectedCount != 1 { t.Fatalf("ConnectedCount = %d, want 1", snapshot.ConnectedCount) } - if len(snapshot.Entries) != 1 { - t.Fatalf("len(Entries) = %d, want 1", len(snapshot.Entries)) - } - if snapshot.Entries[0].KeyID != "client-a" || snapshot.Entries[0].LastPath != "/files" { - t.Fatalf("entry = %+v, want keyID client-a and path /files", snapshot.Entries[0]) + if len(snapshot.Entries) != 0 { + t.Fatalf("len(Entries) = %d, want 0 for counts-only status", len(snapshot.Entries)) } if snapshot.Metrics.TotalRequests != 1 { t.Fatalf("Metrics.TotalRequests = %d, want 1", snapshot.Metrics.TotalRequests) @@ -61,6 +58,9 @@ func TestStatusSnapshotShowsTrackedAccess(t *testing.T) { if snapshot.Metrics.UniqueTools != 1 { t.Fatalf("Metrics.UniqueTools = %d, want 1", snapshot.Metrics.UniqueTools) } + if len(snapshot.Metrics.TopIPs) != 0 || len(snapshot.Metrics.TopAgents) != 0 || len(snapshot.Metrics.TopTools) != 0 { + t.Fatalf("Top breakdowns should be hidden in counts-only status: %+v", snapshot.Metrics) + } } func TestStatusAPIHandlerReturnsJSON(t *testing.T) { @@ -86,23 +86,49 @@ func TestStatusAPIHandlerReturnsJSON(t *testing.T) { } } -func TestStatusAPIHandlerSupportsStatusPath(t *testing.T) { +func TestStatusAPIHandlerRejectsStatusPath(t *testing.T) { handler := statusAPIHandler(buildinfo.Info{Version: "v1"}, auth.NewAccessTracker(), true) req := httptest.NewRequest(http.MethodGet, "/status", nil) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestPublicStatusHandlerReturnsConnectedClientsOnly(t *testing.T) { + tracker := auth.NewAccessTracker() + now := time.Now().UTC() + tracker.Record("recent-client", "/mcp", "127.0.0.1:1234", "tester", "list_projects", now.Add(-2*time.Minute)) + tracker.Record("stale-client", "/mcp", "127.0.0.1:9999", "tester", "list_projects", now.Add(-30*time.Minute)) + + handler := publicStatusHandler(tracker) + req := httptest.NewRequest(http.MethodGet, "/status", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) } - var payload statusAPIResponse + var payload publicStatusResponse if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("json.Unmarshal() error = %v", err) } - if payload.Version != "v1" { - t.Fatalf("version = %q, want %q", payload.Version, "v1") + if payload.ConnectedCount != 1 { + t.Fatalf("ConnectedCount = %d, want 1", payload.ConnectedCount) + } + if len(payload.Entries) != 1 { + t.Fatalf("len(Entries) = %d, want 1", len(payload.Entries)) + } + if payload.Entries[0].KeyID != "recent-client" { + t.Fatalf("Entries[0].KeyID = %q, want %q", payload.Entries[0].KeyID, "recent-client") + } + if payload.Entries[0].LastAccessedAt.Before(now.Add(-11 * time.Minute)) { + t.Fatalf("Entries[0].LastAccessedAt = %v, expected recent timestamp", payload.Entries[0].LastAccessedAt) } } diff --git a/internal/app/ui/dist/placeholder.txt b/internal/app/ui/dist/placeholder.txt new file mode 100644 index 0000000..484c870 --- /dev/null +++ b/internal/app/ui/dist/placeholder.txt @@ -0,0 +1 @@ +placeholder file to keep ui/dist present for go:embed in test environments diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 9913d59..f48251a 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -2,7 +2,7 @@ import { onMount } from 'svelte'; import LoginPage from './components/auth/LoginPage.svelte'; import AdminShell from './components/shell/AdminShell.svelte'; - import type { ShellPage, StatusResponse } from './types'; + import type { PublicStatusResponse, ShellPage, StatusResponse } from './types'; import { fromStore } from 'svelte/store'; import { ensureApiURL, @@ -20,6 +20,9 @@ let data = $state(null); let loading = $state(false); let error = $state(''); + let publicStatus = $state(null); + let publicStatusLoading = $state(false); + let publicStatusError = $state(''); let currentPage = $state('dashboard'); ensureApiURL(import.meta.env.VITE_API_URL); @@ -57,7 +60,7 @@ const token = await loginWithCredentials(username, password); await GlobalStateStore.getState().login(token, { username }); authMessage = 'Login successful.'; - await loadStatus(); + await loadDashboardStatus(); } catch (err) { authError = err instanceof Error ? err.message : 'Login failed.'; } finally { @@ -88,7 +91,7 @@ authMessage = 'OAuth login complete. Welcome back.'; window.history.replaceState({}, '', '/'); - await loadStatus(); + await loadDashboardStatus(); } catch (err) { authError = err instanceof Error ? err.message : 'OAuth callback failed.'; } finally { @@ -100,14 +103,23 @@ await GlobalStateStore.getState().logout(); authMessage = 'Logged out.'; authError = ''; + await loadPublicStatus(); } - async function loadStatus(): Promise { + async function loadDashboardStatus(): Promise { loading = true; error = ''; try { - const response = await fetch('/api/status'); + const token = GlobalStateStore.getState().session?.authToken; + if (!token) { + throw new Error('Missing auth token for dashboard status.'); + } + const response = await fetch('/api/status', { + headers: { + Authorization: `Bearer ${token}` + } + }); if (!response.ok) { throw new Error(`Status request failed with ${response.status}`); } @@ -141,6 +153,28 @@ } } + async function loadPublicStatus(): Promise { + publicStatusLoading = true; + publicStatusError = ''; + + try { + const response = await fetch('/status'); + if (!response.ok) { + throw new Error(`Public status request failed with ${response.status}`); + } + const raw = (await response.json()) as Partial | null; + publicStatus = { + connected_count: raw?.connected_count ?? 0, + connected_window: raw?.connected_window ?? 'last 10 minutes', + entries: Array.isArray(raw?.entries) ? raw.entries : [] + }; + } catch (err) { + publicStatusError = err instanceof Error ? err.message : 'Failed to load public status'; + } finally { + publicStatusLoading = false; + } + } + onMount(async () => { if (typeof window !== 'undefined') { setCurrentPath(window.location.pathname); @@ -153,7 +187,11 @@ await GlobalStateStore.getState().fetchData(); - await loadStatus(); + if (GlobalStateStore.getState().isLoggedIn()) { + await loadDashboardStatus(); + return; + } + await loadPublicStatus(); }); @@ -169,9 +207,9 @@ {authBusy} {authError} {authMessage} - statusData={data} - statusLoading={loading} - statusError={error} + statusData={publicStatus} + statusLoading={publicStatusLoading} + statusError={publicStatusError} onlogin={handleCredentialLogin} /> {:else} @@ -182,7 +220,7 @@ {error} onlogout={logout} onnavigate={(page) => { currentPage = page; }} - onrefresh={loadStatus} + onrefresh={loadDashboardStatus} /> {/if} diff --git a/ui/src/components/auth/LoginInfoPanel.svelte b/ui/src/components/auth/LoginInfoPanel.svelte index fabc0e4..7cbca83 100644 --- a/ui/src/components/auth/LoginInfoPanel.svelte +++ b/ui/src/components/auth/LoginInfoPanel.svelte @@ -1,5 +1,5 @@