From 0227912325e18af524f803fa2814b46a3ceb14f1 Mon Sep 17 00:00:00 2001 From: Hein Date: Sun, 24 May 2026 16:36:59 +0200 Subject: [PATCH] feat(auth): add recent activity logging to access tracker * Introduced AccessLogEntry type for logging access details * Updated AccessTracker to maintain a recent activity log * Modified Metrics to include recent activity log in response * Added RecentActivityLog component to display logged activities --- internal/app/status.go | 3 -- internal/app/status_test.go | 10 +++- internal/auth/access_tracker.go | 31 +++++++++++ .../components/dashboard/DashboardPage.svelte | 4 ++ .../dashboard/RecentActivityLog.svelte | 53 +++++++++++++++++++ ui/src/types.ts | 10 ++++ 6 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 ui/src/components/dashboard/RecentActivityLog.svelte diff --git a/internal/app/status.go b/internal/app/status.go index 231d3bf..b72f633 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -44,9 +44,6 @@ type publicStatusResponse struct { 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.", diff --git a/internal/app/status_test.go b/internal/app/status_test.go index 686c32b..3fd2d2e 100644 --- a/internal/app/status_test.go +++ b/internal/app/status_test.go @@ -58,8 +58,14 @@ 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) + if len(snapshot.Metrics.TopIPs) != 1 || len(snapshot.Metrics.TopAgents) != 1 || len(snapshot.Metrics.TopTools) != 1 { + t.Fatalf("Top breakdowns not populated: %+v", snapshot.Metrics) + } + if len(snapshot.Metrics.RecentLog) != 1 { + t.Fatalf("RecentLog len = %d, want 1", len(snapshot.Metrics.RecentLog)) + } + if snapshot.Metrics.RecentLog[0].Tool != "list_projects" { + t.Fatalf("RecentLog[0].Tool = %q, want %q", snapshot.Metrics.RecentLog[0].Tool, "list_projects") } } diff --git a/internal/auth/access_tracker.go b/internal/auth/access_tracker.go index 06019ce..765ef31 100644 --- a/internal/auth/access_tracker.go +++ b/internal/auth/access_tracker.go @@ -17,12 +17,24 @@ type AccessSnapshot struct { LastAccessedAt time.Time `json:"last_accessed_at"` } +const maxRecentLog = 100 + +type AccessLogEntry struct { + Timestamp time.Time `json:"timestamp"` + KeyID string `json:"key_id"` + IP string `json:"ip"` + UserAgent string `json:"user_agent"` + Tool string `json:"tool"` + Path string `json:"path"` +} + type AccessTracker struct { mu sync.RWMutex entries map[string]AccessSnapshot ipCounts map[string]int agentCounts map[string]int toolCounts map[string]int + recentLog []AccessLogEntry totalRequests int } @@ -32,6 +44,7 @@ func NewAccessTracker() *AccessTracker { ipCounts: make(map[string]int), agentCounts: make(map[string]int), toolCounts: make(map[string]int), + recentLog: make([]AccessLogEntry, 0, maxRecentLog), } } @@ -64,6 +77,19 @@ func (t *AccessTracker) Record(keyID, path, remoteAddr, userAgent, toolName stri if tool := strings.TrimSpace(toolName); tool != "" { t.toolCounts[tool]++ } + + logEntry := AccessLogEntry{ + Timestamp: now.UTC(), + KeyID: keyID, + IP: normalizedRemoteAddr, + UserAgent: userAgent, + Tool: strings.TrimSpace(toolName), + Path: path, + } + t.recentLog = append([]AccessLogEntry{logEntry}, t.recentLog...) + if len(t.recentLog) > maxRecentLog { + t.recentLog = t.recentLog[:maxRecentLog] + } } func normalizeRemoteAddr(value string) string { @@ -130,6 +156,7 @@ type AccessMetrics struct { TopIPs []RequestAggregate `json:"top_ips"` TopAgents []RequestAggregate `json:"top_agents"` TopTools []RequestAggregate `json:"top_tools"` + RecentLog []AccessLogEntry `json:"recent_log"` } func (t *AccessTracker) Metrics(topN int) AccessMetrics { @@ -143,6 +170,9 @@ func (t *AccessTracker) Metrics(topN int) AccessMetrics { t.mu.RLock() defer t.mu.RUnlock() + log := make([]AccessLogEntry, len(t.recentLog)) + copy(log, t.recentLog) + return AccessMetrics{ TotalRequests: t.totalRequests, UniquePrincipals: len(t.entries), @@ -152,6 +182,7 @@ func (t *AccessTracker) Metrics(topN int) AccessMetrics { TopIPs: topAggregates(t.ipCounts, topN), TopAgents: topAggregates(t.agentCounts, topN), TopTools: topAggregates(t.toolCounts, topN), + RecentLog: log, } } diff --git a/ui/src/components/dashboard/DashboardPage.svelte b/ui/src/components/dashboard/DashboardPage.svelte index a756e32..bc865a4 100644 --- a/ui/src/components/dashboard/DashboardPage.svelte +++ b/ui/src/components/dashboard/DashboardPage.svelte @@ -2,6 +2,7 @@ import type { StatusResponse } from '../../types'; import AccessTable from './AccessTable.svelte'; import ConnectionBreakdown from './ConnectionBreakdown.svelte'; + import RecentActivityLog from './RecentActivityLog.svelte'; import StatusCards from './StatusCards.svelte'; const { @@ -67,4 +68,7 @@ emptyLabel="No MCP tool calls recorded yet." /> +
+ +
{/if} diff --git a/ui/src/components/dashboard/RecentActivityLog.svelte b/ui/src/components/dashboard/RecentActivityLog.svelte new file mode 100644 index 0000000..58adfb0 --- /dev/null +++ b/ui/src/components/dashboard/RecentActivityLog.svelte @@ -0,0 +1,53 @@ + + +
+

Recent activity (last {entries.length})

+ {#if entries.length === 0} +
+ No activity recorded yet. +
+ {:else} +
+
+ + + + + + + + + + + + + {#each entries as entry} + + + + + + + + + {/each} + +
TimePrincipalIPUser AgentToolPath
{formatTime(entry.timestamp)}{entry.key_id}{entry.ip || '—'}{entry.user_agent || '—'} + {#if entry.tool} + {entry.tool} + {:else} + + {/if} + {entry.path}
+
+
+ {/if} +
diff --git a/ui/src/types.ts b/ui/src/types.ts index 8deb6b9..de590f3 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -12,6 +12,15 @@ export type RequestAggregate = { request_count: number; }; +export type AccessLogEntry = { + timestamp: string; + key_id: string; + ip: string; + user_agent: string; + tool: string; + path: string; +}; + export type AccessMetrics = { total_requests: number; unique_principals: number; @@ -21,6 +30,7 @@ export type AccessMetrics = { top_ips: RequestAggregate[]; top_agents: RequestAggregate[]; top_tools: RequestAggregate[]; + recent_log: AccessLogEntry[]; }; export type StatusResponse = {