feat(auth): add recent activity logging to access tracker
CI / build-and-test (push) Failing after 0s
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:
@@ -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.",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user