From ed8b5e92e17e161ee6ed8c74559e3240109d1490 Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Thu, 18 Dec 2025 22:36:37 +0000 Subject: [PATCH] Display only current project statistics in statusline. (#3) --- internal/worker/handlers.go | 12 ++-- internal/worker/handlers_test.go | 117 +++++++++++++++++++++++++++---- internal/worker/service.go | 71 ++++++++++++++----- ui/package-lock.json | 4 +- ui/package.json | 2 +- ui/src/App.vue | 4 +- ui/src/composables/useStats.ts | 15 +++- ui/src/utils/api.ts | 7 +- 8 files changed, 184 insertions(+), 48 deletions(-) diff --git a/internal/worker/handlers.go b/internal/worker/handlers.go index 1aa7c3c..9fa7970 100644 --- a/internal/worker/handlers.go +++ b/internal/worker/handlers.go @@ -643,7 +643,8 @@ func (s *Service) handleGetTypes(w http.ResponseWriter, r *http.Request) { // handleGetStats returns worker statistics. func (s *Service) handleGetStats(w http.ResponseWriter, r *http.Request) { - retrievalStats := s.GetRetrievalStats() + project := r.URL.Query().Get("project") + retrievalStats := s.GetRetrievalStats(project) sessionsToday, _ := s.sessionStore.GetSessionsToday(r.Context()) response := map[string]interface{}{ @@ -658,7 +659,7 @@ func (s *Service) handleGetStats(w http.ResponseWriter, r *http.Request) { } // Include project-specific observation count if project is specified - if project := r.URL.Query().Get("project"); project != "" { + if project != "" { count, err := s.observationStore.GetObservationCount(r.Context(), project) if err == nil { response["projectObservations"] = count @@ -671,7 +672,8 @@ func (s *Service) handleGetStats(w http.ResponseWriter, r *http.Request) { // handleGetRetrievalStats returns detailed retrieval statistics. func (s *Service) handleGetRetrievalStats(w http.ResponseWriter, r *http.Request) { - stats := s.GetRetrievalStats() + project := r.URL.Query().Get("project") + stats := s.GetRetrievalStats(project) writeJSON(w, stats) } @@ -775,7 +777,7 @@ func (s *Service) handleSearchByPrompt(w http.ResponseWriter, r *http.Request) { clusteredObservations := clusterObservations(freshObservations, 0.4) // Record retrieval stats (no verification done, so verified=0, deleted=0) - s.recordRetrievalStats(int64(len(clusteredObservations)), 0, 0, true) + s.recordRetrievalStats(project, int64(len(clusteredObservations)), 0, 0, true) log.Info(). Str("project", project). @@ -853,7 +855,7 @@ func (s *Service) handleContextInject(w http.ResponseWriter, r *http.Request) { duplicatesRemoved := len(freshObservations) - len(clusteredObservations) // Record retrieval stats (no verification done) - s.recordRetrievalStats(int64(len(clusteredObservations)), 0, 0, false) + s.recordRetrievalStats(project, int64(len(clusteredObservations)), 0, 0, false) log.Info(). Str("project", project). diff --git a/internal/worker/handlers_test.go b/internal/worker/handlers_test.go index 5a4349d..88d2a0c 100644 --- a/internal/worker/handlers_test.go +++ b/internal/worker/handlers_test.go @@ -65,6 +65,7 @@ func testService(t *testing.T) (*Service, func()) { ctx: ctx, cancel: cancel, startTime: time.Now(), + retrievalStats: make(map[string]*RetrievalStats), } svc.setupRoutes() @@ -436,8 +437,8 @@ func TestRetrievalStats(t *testing.T) { assert.Equal(t, http.StatusOK, rec.Code) - // Check stats - stats := svc.GetRetrievalStats() + // Check stats for the specific project + stats := svc.GetRetrievalStats(project) assert.Equal(t, int64(1), stats.TotalRequests) assert.Equal(t, int64(1), stats.SearchRequests) assert.GreaterOrEqual(t, stats.ObservationsServed, int64(1)) @@ -1516,21 +1517,22 @@ func TestGetRetrievalStats(t *testing.T) { svc, cleanup := testService(t) defer cleanup() - // Initially all zeros - stats := svc.GetRetrievalStats() + project := "stats-test" + + // Initially all zeros for this project + stats := svc.GetRetrievalStats(project) assert.Equal(t, int64(0), stats.TotalRequests) assert.Equal(t, int64(0), stats.SearchRequests) // Make some requests to increment stats - project := "stats-test" createTestObservation(t, svc.observationStore, project, "Test", "Content", []string{"test"}) req := httptest.NewRequest(http.MethodGet, "/api/context/search?project="+project+"&query=test", nil) rec := httptest.NewRecorder() svc.router.ServeHTTP(rec, req) - // Stats should be updated - stats = svc.GetRetrievalStats() + // Stats should be updated for this project + stats = svc.GetRetrievalStats(project) assert.GreaterOrEqual(t, stats.TotalRequests, int64(1)) } @@ -1786,6 +1788,81 @@ func TestHandleGetStats_AllProjects(t *testing.T) { assert.Equal(t, http.StatusOK, rec.Code) } +// TestStatuslineRetrievalStatsAreProjectSpecific verifies that the stats endpoint +// (used by statusline hook) returns project-specific retrieval statistics. +// This is critical for correct statusline display in multi-project environments. +func TestStatuslineRetrievalStatsAreProjectSpecific(t *testing.T) { + svc, cleanup := testService(t) + defer cleanup() + + projectA := "statusline-project-a" + projectB := "statusline-project-b" + + // Create observations in both projects + createTestObservation(t, svc.observationStore, projectA, "Observation A", "Content for project A", []string{"test"}) + createTestObservation(t, svc.observationStore, projectB, "Observation B", "Content for project B", []string{"test"}) + + // Make search requests for projectA (simulates user activity in that project) + reqA := httptest.NewRequest(http.MethodGet, "/api/context/search?project="+projectA+"&query=test", nil) + recA := httptest.NewRecorder() + svc.router.ServeHTTP(recA, reqA) + assert.Equal(t, http.StatusOK, recA.Code) + + // Make multiple search requests for projectB + for i := 0; i < 3; i++ { + reqB := httptest.NewRequest(http.MethodGet, "/api/context/search?project="+projectB+"&query=test", nil) + recB := httptest.NewRecorder() + svc.router.ServeHTTP(recB, reqB) + assert.Equal(t, http.StatusOK, recB.Code) + } + + // Now verify stats endpoint returns project-specific stats (as statusline hook does) + // Check projectA stats + reqStatsA := httptest.NewRequest(http.MethodGet, "/api/stats?project="+projectA, nil) + recStatsA := httptest.NewRecorder() + svc.router.ServeHTTP(recStatsA, reqStatsA) + + assert.Equal(t, http.StatusOK, recStatsA.Code) + var responseA map[string]interface{} + err := json.Unmarshal(recStatsA.Body.Bytes(), &responseA) + require.NoError(t, err) + + // Verify projectA has 1 search request + retrievalA := responseA["retrieval"].(map[string]interface{}) + assert.Equal(t, float64(1), retrievalA["SearchRequests"], "projectA should have 1 search request") + assert.Equal(t, float64(1), retrievalA["TotalRequests"], "projectA should have 1 total request") + + // Check projectB stats + reqStatsB := httptest.NewRequest(http.MethodGet, "/api/stats?project="+projectB, nil) + recStatsB := httptest.NewRecorder() + svc.router.ServeHTTP(recStatsB, reqStatsB) + + assert.Equal(t, http.StatusOK, recStatsB.Code) + var responseB map[string]interface{} + err = json.Unmarshal(recStatsB.Body.Bytes(), &responseB) + require.NoError(t, err) + + // Verify projectB has 3 search requests + retrievalB := responseB["retrieval"].(map[string]interface{}) + assert.Equal(t, float64(3), retrievalB["SearchRequests"], "projectB should have 3 search requests") + assert.Equal(t, float64(3), retrievalB["TotalRequests"], "projectB should have 3 total requests") + + // Check aggregate stats (no project filter) + reqStatsAll := httptest.NewRequest(http.MethodGet, "/api/stats", nil) + recStatsAll := httptest.NewRecorder() + svc.router.ServeHTTP(recStatsAll, reqStatsAll) + + assert.Equal(t, http.StatusOK, recStatsAll.Code) + var responseAll map[string]interface{} + err = json.Unmarshal(recStatsAll.Body.Bytes(), &responseAll) + require.NoError(t, err) + + // Verify aggregate has sum of all requests (1 + 3 = 4) + retrievalAll := responseAll["retrieval"].(map[string]interface{}) + assert.Equal(t, float64(4), retrievalAll["SearchRequests"], "aggregate should have 4 search requests") + assert.Equal(t, float64(4), retrievalAll["TotalRequests"], "aggregate should have 4 total requests") +} + // TestHandleSubagentComplete_WithSession tests subagent completion with existing session. func TestHandleSubagentComplete_WithSession(t *testing.T) { svc, cleanup := testService(t) @@ -2249,25 +2326,35 @@ func TestRecordRetrievalStats(t *testing.T) { svc, cleanup := testService(t) defer cleanup() - // Initially all zeros - stats := svc.GetRetrievalStats() + project := "test-project" + + // Initially all zeros for this project + stats := svc.GetRetrievalStats(project) assert.Equal(t, int64(0), stats.TotalRequests) - // Record a search request - svc.recordRetrievalStats(10, 1, 0, true) - stats = svc.GetRetrievalStats() + // Record a search request for the project + svc.recordRetrievalStats(project, 10, 1, 0, true) + stats = svc.GetRetrievalStats(project) assert.Equal(t, int64(1), stats.TotalRequests) assert.Equal(t, int64(10), stats.ObservationsServed) assert.Equal(t, int64(1), stats.VerifiedStale) assert.Equal(t, int64(1), stats.SearchRequests) - // Record a context injection - svc.recordRetrievalStats(5, 0, 1, false) - stats = svc.GetRetrievalStats() + // Record a context injection for the project + svc.recordRetrievalStats(project, 5, 0, 1, false) + stats = svc.GetRetrievalStats(project) assert.Equal(t, int64(2), stats.TotalRequests) assert.Equal(t, int64(15), stats.ObservationsServed) assert.Equal(t, int64(1), stats.DeletedInvalid) assert.Equal(t, int64(1), stats.ContextInjections) + + // Different project should have zero stats + otherStats := svc.GetRetrievalStats("other-project") + assert.Equal(t, int64(0), otherStats.TotalRequests) + + // Aggregate stats (empty project) should show all + aggStats := svc.GetRetrievalStats("") + assert.Equal(t, int64(2), aggStats.TotalRequests) } // TestHandleSelfCheck_WithInitError tests self-check with init error. diff --git a/internal/worker/service.go b/internal/worker/service.go index 95e07f5..5732bd1 100644 --- a/internal/worker/service.go +++ b/internal/worker/service.go @@ -80,8 +80,9 @@ type Service struct { server *http.Server startTime time.Time - // Retrieval statistics - retrievalStats RetrievalStats + // Retrieval statistics (per-project) + retrievalStats map[string]*RetrievalStats + retrievalStatsMu sync.RWMutex // Lifecycle ctx context.Context @@ -137,6 +138,7 @@ func NewService(version string) (*Service, error) { cancel: cancel, startTime: time.Now(), updater: update.New(version, installDir), + retrievalStats: make(map[string]*RetrievalStats), } // Setup middleware and routes (health endpoint works immediately) @@ -673,29 +675,60 @@ func (s *Service) setupRoutes() { }) } -// recordRetrievalStats atomically updates retrieval statistics. -func (s *Service) recordRetrievalStats(served, verified, deleted int64, isSearch bool) { - atomic.AddInt64(&s.retrievalStats.TotalRequests, 1) - atomic.AddInt64(&s.retrievalStats.ObservationsServed, served) - atomic.AddInt64(&s.retrievalStats.VerifiedStale, verified) - atomic.AddInt64(&s.retrievalStats.DeletedInvalid, deleted) +// recordRetrievalStats atomically updates retrieval statistics for a project. +func (s *Service) recordRetrievalStats(project string, served, verified, deleted int64, isSearch bool) { + s.retrievalStatsMu.Lock() + stats := s.retrievalStats[project] + if stats == nil { + stats = &RetrievalStats{} + s.retrievalStats[project] = stats + } + s.retrievalStatsMu.Unlock() + + atomic.AddInt64(&stats.TotalRequests, 1) + atomic.AddInt64(&stats.ObservationsServed, served) + atomic.AddInt64(&stats.VerifiedStale, verified) + atomic.AddInt64(&stats.DeletedInvalid, deleted) if isSearch { - atomic.AddInt64(&s.retrievalStats.SearchRequests, 1) + atomic.AddInt64(&stats.SearchRequests, 1) } else { - atomic.AddInt64(&s.retrievalStats.ContextInjections, 1) + atomic.AddInt64(&stats.ContextInjections, 1) } } -// GetRetrievalStats returns a copy of the retrieval stats. -func (s *Service) GetRetrievalStats() RetrievalStats { - return RetrievalStats{ - TotalRequests: atomic.LoadInt64(&s.retrievalStats.TotalRequests), - ObservationsServed: atomic.LoadInt64(&s.retrievalStats.ObservationsServed), - VerifiedStale: atomic.LoadInt64(&s.retrievalStats.VerifiedStale), - DeletedInvalid: atomic.LoadInt64(&s.retrievalStats.DeletedInvalid), - SearchRequests: atomic.LoadInt64(&s.retrievalStats.SearchRequests), - ContextInjections: atomic.LoadInt64(&s.retrievalStats.ContextInjections), +// GetRetrievalStats returns a copy of the retrieval stats for a project. +// If project is empty, returns aggregate stats across all projects. +func (s *Service) GetRetrievalStats(project string) RetrievalStats { + s.retrievalStatsMu.RLock() + defer s.retrievalStatsMu.RUnlock() + + if project != "" { + // Return stats for specific project + stats := s.retrievalStats[project] + if stats == nil { + return RetrievalStats{} + } + return RetrievalStats{ + TotalRequests: atomic.LoadInt64(&stats.TotalRequests), + ObservationsServed: atomic.LoadInt64(&stats.ObservationsServed), + VerifiedStale: atomic.LoadInt64(&stats.VerifiedStale), + DeletedInvalid: atomic.LoadInt64(&stats.DeletedInvalid), + SearchRequests: atomic.LoadInt64(&stats.SearchRequests), + ContextInjections: atomic.LoadInt64(&stats.ContextInjections), + } } + + // Aggregate stats across all projects + var result RetrievalStats + for _, stats := range s.retrievalStats { + result.TotalRequests += atomic.LoadInt64(&stats.TotalRequests) + result.ObservationsServed += atomic.LoadInt64(&stats.ObservationsServed) + result.VerifiedStale += atomic.LoadInt64(&stats.VerifiedStale) + result.DeletedInvalid += atomic.LoadInt64(&stats.DeletedInvalid) + result.SearchRequests += atomic.LoadInt64(&stats.SearchRequests) + result.ContextInjections += atomic.LoadInt64(&stats.ContextInjections) + } + return result } // Start starts the worker service. diff --git a/ui/package-lock.json b/ui/package-lock.json index 4f3baa0..41875ca 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-mnemonic-dashboard", - "version": "v0.6.15-2-gd8a4939-dirty", + "version": "v0.6.33-3-gf38ce5c-dirty", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-mnemonic-dashboard", - "version": "v0.6.15-2-gd8a4939-dirty", + "version": "v0.6.33-3-gf38ce5c-dirty", "dependencies": { "vue": "^3.5.13" }, diff --git a/ui/package.json b/ui/package.json index b211444..df4513d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "claude-mnemonic-dashboard", - "version": "v0.6.15-2-gd8a4939-dirty", + "version": "v0.6.33-3-gf38ce5c-dirty", "private": true, "type": "module", "scripts": { diff --git a/ui/src/App.vue b/ui/src/App.vue index f9845b3..258a22f 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -8,9 +8,9 @@ import Sidebar from '@/components/Sidebar.vue' // Composables const { isConnected, isReconnecting, reconnectCountdown, isProcessing, queueDepth } = useSSE() -const { stats } = useStats() const { updateInfo, updateStatus, isUpdating, applyUpdate } = useUpdate() const { health } = useHealth() +// Initialize useTimeline first to get currentProject ref const { filteredItems, loading, @@ -27,6 +27,8 @@ const { setTypeFilter, setConceptFilter } = useTimeline() +// Pass currentProject ref to useStats for project-specific retrieval stats +const { stats } = useStats(currentProject) // Note: Timeline refresh is handled by useTimeline's SSE watcher diff --git a/ui/src/composables/useStats.ts b/ui/src/composables/useStats.ts index 6554116..5490868 100644 --- a/ui/src/composables/useStats.ts +++ b/ui/src/composables/useStats.ts @@ -1,4 +1,4 @@ -import { ref, onMounted, onUnmounted, watch } from 'vue' +import { ref, onMounted, onUnmounted, watch, type Ref } from 'vue' import type { Stats } from '@/types' import { fetchStats } from '@/utils/api' import { useSSE } from './useSSE' @@ -6,7 +6,7 @@ import { useSSE } from './useSSE' // Fallback poll interval when SSE is disconnected const FALLBACK_POLL_INTERVAL = 10000 // 10 seconds -export function useStats() { +export function useStats(projectRef?: Ref) { const stats = ref(null) const loading = ref(false) const error = ref(null) @@ -21,7 +21,8 @@ export function useStats() { error.value = null try { - stats.value = await fetchStats() + const project = projectRef?.value ?? null + stats.value = await fetchStats(project) } catch (err) { error.value = err instanceof Error ? err.message : 'Failed to fetch stats' console.error('[Stats] Error:', err) @@ -54,6 +55,14 @@ export function useStats() { } }) + // Watch for project filter changes + if (projectRef) { + watch(projectRef, () => { + console.log('[Stats] Project filter changed, refreshing stats') + refresh() + }) + } + // Switch between SSE-driven and fallback polling based on connection status watch(isConnected, (connected) => { if (connected) { diff --git a/ui/src/utils/api.ts b/ui/src/utils/api.ts index 58a022e..d391742 100644 --- a/ui/src/utils/api.ts +++ b/ui/src/utils/api.ts @@ -107,8 +107,11 @@ export async function fetchSummaries(limit: number = 50, project?: string, signa return fetchWithRetry(`${API_BASE}/summaries?${params}`, { signal }) } -export async function fetchStats(): Promise { - return fetchWithRetry(`${API_BASE}/stats`) +export async function fetchStats(project?: string | null): Promise { + const params = new URLSearchParams() + if (project) params.append('project', project) + const query = params.toString() + return fetchWithRetry(`${API_BASE}/stats${query ? '?' + query : ''}`) } export async function fetchProjects(): Promise {