feat(ui): implement public status endpoint and update UI components
Some checks failed
CI / build-and-test (push) Failing after -30m49s
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:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
1
internal/app/ui/dist/placeholder.txt
vendored
Normal file
1
internal/app/ui/dist/placeholder.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
placeholder file to keep ui/dist present for go:embed in test environments
|
||||
Reference in New Issue
Block a user