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 {
|
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 := 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.",
|
||||||
|
|||||||
@@ -58,8 +58,14 @@ 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 {
|
if len(snapshot.Metrics.TopIPs) != 1 || len(snapshot.Metrics.TopAgents) != 1 || len(snapshot.Metrics.TopTools) != 1 {
|
||||||
t.Fatalf("Top breakdowns should be hidden in counts-only status: %+v", snapshot.Metrics)
|
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"`
|
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 {
|
type AccessTracker struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
entries map[string]AccessSnapshot
|
entries map[string]AccessSnapshot
|
||||||
ipCounts map[string]int
|
ipCounts map[string]int
|
||||||
agentCounts map[string]int
|
agentCounts map[string]int
|
||||||
toolCounts map[string]int
|
toolCounts map[string]int
|
||||||
|
recentLog []AccessLogEntry
|
||||||
totalRequests int
|
totalRequests int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,6 +44,7 @@ func NewAccessTracker() *AccessTracker {
|
|||||||
ipCounts: make(map[string]int),
|
ipCounts: make(map[string]int),
|
||||||
agentCounts: make(map[string]int),
|
agentCounts: make(map[string]int),
|
||||||
toolCounts: 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 != "" {
|
if tool := strings.TrimSpace(toolName); tool != "" {
|
||||||
t.toolCounts[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 {
|
func normalizeRemoteAddr(value string) string {
|
||||||
@@ -130,6 +156,7 @@ type AccessMetrics struct {
|
|||||||
TopIPs []RequestAggregate `json:"top_ips"`
|
TopIPs []RequestAggregate `json:"top_ips"`
|
||||||
TopAgents []RequestAggregate `json:"top_agents"`
|
TopAgents []RequestAggregate `json:"top_agents"`
|
||||||
TopTools []RequestAggregate `json:"top_tools"`
|
TopTools []RequestAggregate `json:"top_tools"`
|
||||||
|
RecentLog []AccessLogEntry `json:"recent_log"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *AccessTracker) Metrics(topN int) AccessMetrics {
|
func (t *AccessTracker) Metrics(topN int) AccessMetrics {
|
||||||
@@ -143,6 +170,9 @@ func (t *AccessTracker) Metrics(topN int) AccessMetrics {
|
|||||||
t.mu.RLock()
|
t.mu.RLock()
|
||||||
defer t.mu.RUnlock()
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
log := make([]AccessLogEntry, len(t.recentLog))
|
||||||
|
copy(log, t.recentLog)
|
||||||
|
|
||||||
return AccessMetrics{
|
return AccessMetrics{
|
||||||
TotalRequests: t.totalRequests,
|
TotalRequests: t.totalRequests,
|
||||||
UniquePrincipals: len(t.entries),
|
UniquePrincipals: len(t.entries),
|
||||||
@@ -152,6 +182,7 @@ func (t *AccessTracker) Metrics(topN int) AccessMetrics {
|
|||||||
TopIPs: topAggregates(t.ipCounts, topN),
|
TopIPs: topAggregates(t.ipCounts, topN),
|
||||||
TopAgents: topAggregates(t.agentCounts, topN),
|
TopAgents: topAggregates(t.agentCounts, topN),
|
||||||
TopTools: topAggregates(t.toolCounts, topN),
|
TopTools: topAggregates(t.toolCounts, topN),
|
||||||
|
RecentLog: log,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import type { StatusResponse } from '../../types';
|
import type { StatusResponse } from '../../types';
|
||||||
import AccessTable from './AccessTable.svelte';
|
import AccessTable from './AccessTable.svelte';
|
||||||
import ConnectionBreakdown from './ConnectionBreakdown.svelte';
|
import ConnectionBreakdown from './ConnectionBreakdown.svelte';
|
||||||
|
import RecentActivityLog from './RecentActivityLog.svelte';
|
||||||
import StatusCards from './StatusCards.svelte';
|
import StatusCards from './StatusCards.svelte';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -67,4 +68,7 @@
|
|||||||
emptyLabel="No MCP tool calls recorded yet."
|
emptyLabel="No MCP tool calls recorded yet."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-6">
|
||||||
|
<RecentActivityLog entries={data.metrics.recent_log ?? []} />
|
||||||
|
</div>
|
||||||
{/if}
|
{/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;
|
request_count: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AccessLogEntry = {
|
||||||
|
timestamp: string;
|
||||||
|
key_id: string;
|
||||||
|
ip: string;
|
||||||
|
user_agent: string;
|
||||||
|
tool: string;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type AccessMetrics = {
|
export type AccessMetrics = {
|
||||||
total_requests: number;
|
total_requests: number;
|
||||||
unique_principals: number;
|
unique_principals: number;
|
||||||
@@ -21,6 +30,7 @@ export type AccessMetrics = {
|
|||||||
top_ips: RequestAggregate[];
|
top_ips: RequestAggregate[];
|
||||||
top_agents: RequestAggregate[];
|
top_agents: RequestAggregate[];
|
||||||
top_tools: RequestAggregate[];
|
top_tools: RequestAggregate[];
|
||||||
|
recent_log: AccessLogEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StatusResponse = {
|
export type StatusResponse = {
|
||||||
|
|||||||
Reference in New Issue
Block a user