feat(ui): implement public status endpoint and update UI components
Some checks failed
CI / build-and-test (push) Failing after -30m49s

* 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
This commit is contained in:
2026-04-27 00:23:06 +02:00
parent e208c62df3
commit 537e65ea6d
10 changed files with 182 additions and 32 deletions

3
.gitignore vendored
View File

@@ -33,5 +33,6 @@ bin/
OB1/ OB1/
ui/node_modules/ ui/node_modules/
ui/.svelte-kit/ ui/.svelte-kit/
internal/app/ui/dist/ internal/app/ui/dist/*
!internal/app/ui/dist/placeholder.txt
.codex .codex

View File

@@ -246,8 +246,8 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
mux.HandleFunc("/llms.txt", serveLLMSTXT) mux.HandleFunc("/llms.txt", serveLLMSTXT)
mux.HandleFunc("/.well-known/llms.txt", serveLLMSTXT) mux.HandleFunc("/.well-known/llms.txt", serveLLMSTXT)
mux.HandleFunc("/robots.txt", serveRobotsTXT) mux.HandleFunc("/robots.txt", serveRobotsTXT)
mux.HandleFunc("/api/status", statusAPIHandler(info, accessTracker, oauthEnabled)) mux.Handle("/api/status", authMiddleware(statusAPIHandler(info, accessTracker, oauthEnabled)))
mux.HandleFunc("/status", statusAPIHandler(info, accessTracker, oauthEnabled)) mux.HandleFunc("/status", publicStatusHandler(accessTracker))
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)

View File

@@ -29,8 +29,24 @@ type statusAPIResponse struct {
OAuthEnabled bool `json:"oauth_enabled"` 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 { func statusSnapshot(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool, now time.Time) statusAPIResponse {
entries := tracker.Snapshot() entries := tracker.Snapshot()
metrics := tracker.Metrics(20)
metrics.TopIPs = nil
metrics.TopAgents = nil
metrics.TopTools = nil
return statusAPIResponse{ return statusAPIResponse{
Title: "Avelon Memory Crystal Server (AMCS)", 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.", 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), ConnectedCount: tracker.ConnectedCount(now, connectedWindow),
TotalKnown: len(entries), TotalKnown: len(entries),
ConnectedWindow: "last 10 minutes", ConnectedWindow: "last 10 minutes",
Entries: entries, Entries: nil,
Metrics: tracker.Metrics(20), Metrics: metrics,
OAuthEnabled: oauthEnabled, OAuthEnabled: oauthEnabled,
} }
} }
@@ -55,7 +71,7 @@ func fallback(value, defaultValue string) string {
func statusAPIHandler(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool) http.HandlerFunc { func statusAPIHandler(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabled bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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) http.NotFound(w, r)
return 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 { func homeHandler(_ buildinfo.Info, _ *auth.AccessTracker, _ bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead { if r.Method != http.MethodGet && r.Method != http.MethodHead {

View File

@@ -43,11 +43,8 @@ func TestStatusSnapshotShowsTrackedAccess(t *testing.T) {
if snapshot.ConnectedCount != 1 { if snapshot.ConnectedCount != 1 {
t.Fatalf("ConnectedCount = %d, want 1", snapshot.ConnectedCount) t.Fatalf("ConnectedCount = %d, want 1", snapshot.ConnectedCount)
} }
if len(snapshot.Entries) != 1 { if len(snapshot.Entries) != 0 {
t.Fatalf("len(Entries) = %d, want 1", len(snapshot.Entries)) t.Fatalf("len(Entries) = %d, want 0 for counts-only status", 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 snapshot.Metrics.TotalRequests != 1 { if snapshot.Metrics.TotalRequests != 1 {
t.Fatalf("Metrics.TotalRequests = %d, want 1", snapshot.Metrics.TotalRequests) t.Fatalf("Metrics.TotalRequests = %d, want 1", snapshot.Metrics.TotalRequests)
@@ -61,6 +58,9 @@ func TestStatusSnapshotShowsTrackedAccess(t *testing.T) {
if snapshot.Metrics.UniqueTools != 1 { if snapshot.Metrics.UniqueTools != 1 {
t.Fatalf("Metrics.UniqueTools = %d, want 1", snapshot.Metrics.UniqueTools) 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) { 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) handler := statusAPIHandler(buildinfo.Info{Version: "v1"}, auth.NewAccessTracker(), true)
req := httptest.NewRequest(http.MethodGet, "/status", nil) req := httptest.NewRequest(http.MethodGet, "/status", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req) 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 { if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", 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 { if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err) t.Fatalf("json.Unmarshal() error = %v", err)
} }
if payload.Version != "v1" { if payload.ConnectedCount != 1 {
t.Fatalf("version = %q, want %q", payload.Version, "v1") 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)
} }
} }

1
internal/app/ui/dist/placeholder.txt vendored Normal file
View File

@@ -0,0 +1 @@
placeholder file to keep ui/dist present for go:embed in test environments

View File

@@ -2,7 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import LoginPage from './components/auth/LoginPage.svelte'; import LoginPage from './components/auth/LoginPage.svelte';
import AdminShell from './components/shell/AdminShell.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 { fromStore } from 'svelte/store';
import { import {
ensureApiURL, ensureApiURL,
@@ -20,6 +20,9 @@
let data = $state<StatusResponse | null>(null); let data = $state<StatusResponse | null>(null);
let loading = $state(false); let loading = $state(false);
let error = $state(''); let error = $state('');
let publicStatus = $state<PublicStatusResponse | null>(null);
let publicStatusLoading = $state(false);
let publicStatusError = $state('');
let currentPage = $state<ShellPage>('dashboard'); let currentPage = $state<ShellPage>('dashboard');
ensureApiURL(import.meta.env.VITE_API_URL); ensureApiURL(import.meta.env.VITE_API_URL);
@@ -57,7 +60,7 @@
const token = await loginWithCredentials(username, password); const token = await loginWithCredentials(username, password);
await GlobalStateStore.getState().login(token, { username }); await GlobalStateStore.getState().login(token, { username });
authMessage = 'Login successful.'; authMessage = 'Login successful.';
await loadStatus(); await loadDashboardStatus();
} catch (err) { } catch (err) {
authError = err instanceof Error ? err.message : 'Login failed.'; authError = err instanceof Error ? err.message : 'Login failed.';
} finally { } finally {
@@ -88,7 +91,7 @@
authMessage = 'OAuth login complete. Welcome back.'; authMessage = 'OAuth login complete. Welcome back.';
window.history.replaceState({}, '', '/'); window.history.replaceState({}, '', '/');
await loadStatus(); await loadDashboardStatus();
} catch (err) { } catch (err) {
authError = err instanceof Error ? err.message : 'OAuth callback failed.'; authError = err instanceof Error ? err.message : 'OAuth callback failed.';
} finally { } finally {
@@ -100,14 +103,23 @@
await GlobalStateStore.getState().logout(); await GlobalStateStore.getState().logout();
authMessage = 'Logged out.'; authMessage = 'Logged out.';
authError = ''; authError = '';
await loadPublicStatus();
} }
async function loadStatus(): Promise<void> { async function loadDashboardStatus(): Promise<void> {
loading = true; loading = true;
error = ''; error = '';
try { 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) { if (!response.ok) {
throw new Error(`Status request failed with ${response.status}`); throw new Error(`Status request failed with ${response.status}`);
} }
@@ -141,6 +153,28 @@
} }
} }
async function loadPublicStatus(): Promise<void> {
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<PublicStatusResponse> | 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 () => { onMount(async () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
setCurrentPath(window.location.pathname); setCurrentPath(window.location.pathname);
@@ -153,7 +187,11 @@
await GlobalStateStore.getState().fetchData(); await GlobalStateStore.getState().fetchData();
await loadStatus(); if (GlobalStateStore.getState().isLoggedIn()) {
await loadDashboardStatus();
return;
}
await loadPublicStatus();
}); });
</script> </script>
@@ -169,9 +207,9 @@
{authBusy} {authBusy}
{authError} {authError}
{authMessage} {authMessage}
statusData={data} statusData={publicStatus}
statusLoading={loading} statusLoading={publicStatusLoading}
statusError={error} statusError={publicStatusError}
onlogin={handleCredentialLogin} onlogin={handleCredentialLogin}
/> />
{:else} {:else}
@@ -182,7 +220,7 @@
{error} {error}
onlogout={logout} onlogout={logout}
onnavigate={(page) => { currentPage = page; }} onnavigate={(page) => { currentPage = page; }}
onrefresh={loadStatus} onrefresh={loadDashboardStatus}
/> />
{/if} {/if}
</div> </div>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { StatusResponse } from "../../types"; import type { PublicStatusResponse } from "../../types";
type IntelligenceCard = { type IntelligenceCard = {
id: string; id: string;
title: string; title: string;
@@ -16,7 +16,7 @@
error, error,
}: { }: {
isOAuthCallback: boolean; isOAuthCallback: boolean;
data: StatusResponse | null; data: PublicStatusResponse | null;
loading: boolean; loading: boolean;
error: string; error: string;
} = $props(); } = $props();
@@ -143,10 +143,24 @@
<p class="mt-2 text-sm font-medium text-rose-300">Unavailable</p> <p class="mt-2 text-sm font-medium text-rose-300">Unavailable</p>
<p class="mt-2 text-xs text-rose-200/80">{error}</p> <p class="mt-2 text-xs text-rose-200/80">{error}</p>
{:else if data} {:else if data}
<p class="mt-2 text-2xl font-semibold text-white">{data.version}</p> <p class="mt-2 text-2xl font-semibold text-white">
<p class="mt-2 text-sm text-slate-400"> {data.connected_count}
{data.connected_count} connected in {data.connected_window}
</p> </p>
<p class="mt-2 text-sm text-slate-400">
connected in {data.connected_window}
</p>
{#if data.entries.length > 0}
<p class="mt-2 text-xs text-slate-400">
Clients:
{#each data.entries.slice(0, 3) as client, idx}
<code class="text-slate-200">{client.key_id}</code>{idx <
Math.min(data.entries.length, 3) - 1
? ", "
: ""}
{/each}
{data.entries.length > 3 ? " …" : ""}
</p>
{/if}
<a <a
href="/status" href="/status"
class="mt-3 inline-flex items-center rounded-lg border border-emerald-300/30 bg-emerald-400/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.12em] text-emerald-100 transition hover:border-emerald-300/50 hover:bg-emerald-400/20" class="mt-3 inline-flex items-center rounded-lg border border-emerald-300/30 bg-emerald-400/10 px-3 py-1.5 text-xs font-semibold uppercase tracking-[0.12em] text-emerald-100 transition hover:border-emerald-300/50 hover:bg-emerald-400/20"

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import LoginInfoPanel from './LoginInfoPanel.svelte'; import LoginInfoPanel from './LoginInfoPanel.svelte';
import LoginPanel from './LoginPanel.svelte'; import LoginPanel from './LoginPanel.svelte';
import type { StatusResponse } from '../../types'; import type { PublicStatusResponse } from '../../types';
const { const {
isOAuthCallback, isOAuthCallback,
@@ -19,7 +19,7 @@
authBusy: boolean; authBusy: boolean;
authError: string; authError: string;
authMessage: string; authMessage: string;
statusData: StatusResponse | null; statusData: PublicStatusResponse | null;
statusLoading: boolean; statusLoading: boolean;
statusError: string; statusError: string;
onlogin: (username: string, password: string) => void; onlogin: (username: string, password: string) => void;

View File

@@ -37,6 +37,18 @@ export type StatusResponse = {
metrics: AccessMetrics; metrics: AccessMetrics;
}; };
export type PublicStatusClient = {
key_id: string;
request_count: number;
last_accessed_at: string;
};
export type PublicStatusResponse = {
connected_count: number;
connected_window: string;
entries: PublicStatusClient[];
};
export type NavItem = { export type NavItem = {
id: string; id: string;
label: string; label: string;

View File

@@ -11,6 +11,7 @@ export default defineConfig({
port: 5173, port: 5173,
proxy: { proxy: {
'/api': backendTarget, '/api': backendTarget,
'/status': backendTarget,
'/healthz': backendTarget, '/healthz': backendTarget,
'/readyz': backendTarget, '/readyz': backendTarget,
'/llm': backendTarget, '/llm': backendTarget,