feat(auth): add recent activity logging to access tracker
CI / build-and-test (push) Failing after 0s

* 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
This commit is contained in:
2026-05-24 16:36:59 +02:00
parent e38a0377d5
commit 0227912325
6 changed files with 106 additions and 5 deletions
-3
View File
@@ -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.",
+8 -2
View File
@@ -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")
}
}
+31
View File
@@ -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,
}
}
@@ -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."
/>
</div>
<div class="mt-6">
<RecentActivityLog entries={data.metrics.recent_log ?? []} />
</div>
{/if}
@@ -0,0 +1,53 @@
<script lang="ts">
import type { AccessLogEntry } from '../../types';
const { entries }: { entries: AccessLogEntry[] } = $props();
function formatTime(value: string): string {
return new Date(value).toLocaleString();
}
</script>
<section class="rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20 sm:p-8">
<h3 class="text-xl font-semibold text-white">Recent activity <span class="ml-2 text-sm font-normal text-slate-500">(last {entries.length})</span></h3>
{#if entries.length === 0}
<div class="mt-6 rounded-2xl border border-dashed border-white/10 bg-slate-950/40 px-4 py-10 text-center text-slate-500">
No activity recorded yet.
</div>
{:else}
<div class="mt-6 overflow-hidden rounded-2xl border border-white/10">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-white/10 text-left text-sm text-slate-300">
<thead class="bg-white/5 text-xs uppercase tracking-[0.2em] text-slate-500">
<tr>
<th class="px-4 py-3 font-medium">Time</th>
<th class="px-4 py-3 font-medium">Principal</th>
<th class="px-4 py-3 font-medium">IP</th>
<th class="px-4 py-3 font-medium">User Agent</th>
<th class="px-4 py-3 font-medium">Tool</th>
<th class="px-4 py-3 font-medium">Path</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5 bg-slate-950/30">
{#each entries as entry}
<tr class="hover:bg-white/[0.03]">
<td class="whitespace-nowrap px-4 py-2.5 text-xs text-slate-400">{formatTime(entry.timestamp)}</td>
<td class="px-4 py-2.5"><code class="rounded bg-white/5 px-2 py-0.5 font-mono text-xs text-cyan-100">{entry.key_id}</code></td>
<td class="px-4 py-2.5"><code class="text-xs text-slate-200">{entry.ip || '—'}</code></td>
<td class="max-w-[16rem] truncate px-4 py-2.5 text-xs text-slate-400" title={entry.user_agent}>{entry.user_agent || '—'}</td>
<td class="px-4 py-2.5">
{#if entry.tool}
<code class="rounded bg-indigo-500/10 px-2 py-0.5 text-xs text-indigo-300">{entry.tool}</code>
{:else}
<span class="text-slate-600"></span>
{/if}
</td>
<td class="max-w-[14rem] truncate px-4 py-2.5"><code class="text-xs text-slate-300">{entry.path}</code></td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</section>
+10
View File
@@ -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 = {