diff --git a/.gitignore b/.gitignore index 5ed88d5..b8081c4 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ dist/ /example-unified /mdns-scanner /websocket-demo +/main # Environment configuration .env @@ -28,6 +29,7 @@ docker-compose.override.yml # Test coverage reports coverage.out +coverage*.out coverage.html *.prof diff --git a/cmd/soundtouch-service/main.go b/cmd/soundtouch-service/main.go index f25fe51..d6c753e 100644 --- a/cmd/soundtouch-service/main.go +++ b/cmd/soundtouch-service/main.go @@ -173,7 +173,7 @@ func main() { persisted.LogBodies = config.logBody persisted.RecordInteractions = config.record persisted.DiscoveryInterval = config.discoveryInterval.String() - persisted.DiscoveryDisabled = false + persisted.DiscoveryEnabled = true persisted.Shortcuts = map[string]int{ "/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound, "/sw.js": http.StatusNotFound, @@ -194,7 +194,7 @@ func main() { server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record) server.SetHTTPServerURL(config.httpsServerURL) server.SetVersionInfo(version, commit, date) - server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryDisabled) + server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled) server.SetShortcuts(persisted.Shortcuts) for path, status := range persisted.Shortcuts { @@ -443,8 +443,8 @@ func setupPythonProxy(targetURL string, redact, logBody bool, recorder *proxy.Re func startDeviceDiscovery(server *handlers.Server) { go func() { for { - currentInterval, disabled := server.GetDiscoverySettings() - if !disabled { + currentInterval, enabled := server.GetDiscoverySettings() + if enabled { server.DiscoverDevices(context.Background()) } @@ -525,6 +525,9 @@ func setupRouter(server *handlers.Server, pyProxy *httputil.ReverseProxy) *chi.M r.Get("/proxy-settings", server.HandleGetProxySettings) r.Post("/proxy-settings", server.HandleUpdateProxySettings) r.Get("/version", server.HandleGetVersionInfo) + r.Get("/interaction-stats", server.HandleGetInteractionStats) + r.Get("/interactions", server.HandleListInteractions) + r.Get("/interaction-content", server.HandleGetInteractionContent) r.Get("/devices/{deviceId}/events", server.HandleGetDeviceEvents) }) diff --git a/pkg/service/datastore/datastore.go b/pkg/service/datastore/datastore.go index cce897d..5ed7ee8 100644 --- a/pkg/service/datastore/datastore.go +++ b/pkg/service/datastore/datastore.go @@ -705,7 +705,7 @@ type Settings struct { LogBodies bool `json:"log_bodies"` RecordInteractions bool `json:"record_interactions"` DiscoveryInterval string `json:"discovery_interval,omitempty"` - DiscoveryDisabled bool `json:"discovery_disabled"` + DiscoveryEnabled bool `json:"discovery_enabled"` Shortcuts map[string]int `json:"shortcuts,omitempty"` } diff --git a/pkg/service/datastore/datastore_test.go b/pkg/service/datastore/datastore_test.go index b99d845..0291a64 100644 --- a/pkg/service/datastore/datastore_test.go +++ b/pkg/service/datastore/datastore_test.go @@ -385,7 +385,7 @@ func TestSettingsPersistence(t *testing.T) { ProxyURL: "http://myproxy:8001", LogBodies: true, DiscoveryInterval: "10m", - DiscoveryDisabled: true, + DiscoveryEnabled: true, } err = ds.SaveSettings(settings) @@ -407,7 +407,7 @@ func TestSettingsPersistence(t *testing.T) { if loaded.DiscoveryInterval != settings.DiscoveryInterval { t.Errorf("Expected DiscoveryInterval %s, got %s", settings.DiscoveryInterval, loaded.DiscoveryInterval) } - if loaded.DiscoveryDisabled != settings.DiscoveryDisabled { - t.Errorf("Expected DiscoveryDisabled %v, got %v", settings.DiscoveryDisabled, loaded.DiscoveryDisabled) + if loaded.DiscoveryEnabled != settings.DiscoveryEnabled { + t.Errorf("Expected DiscoveryEnabled %v, got %v", settings.DiscoveryEnabled, loaded.DiscoveryEnabled) } } diff --git a/pkg/service/handlers/handlers_setup.go b/pkg/service/handlers/handlers_setup.go index c7630e9..2a08366 100644 --- a/pkg/service/handlers/handlers_setup.go +++ b/pkg/service/handlers/handlers_setup.go @@ -144,7 +144,7 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { s.mu.RLock() serverURL, proxyURL, httpsServerURL := s.serverURL, s.proxyURL, s.httpsServerURL discoveryInterval := s.discoveryInterval.String() - discoveryDisabled := s.discoveryDisabled + discoveryEnabled := s.discoveryEnabled s.mu.RUnlock() if err := json.NewEncoder(w).Encode(map[string]interface{}{ @@ -152,7 +152,7 @@ func (s *Server) HandleGetSettings(w http.ResponseWriter, _ *http.Request) { "proxy_url": proxyURL, "https_server_url": httpsServerURL, "discovery_interval": discoveryInterval, - "discovery_disabled": discoveryDisabled, + "discovery_enabled": discoveryEnabled, }); err != nil { http.Error(w, "Failed to encode response", http.StatusInternalServerError) return @@ -165,7 +165,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { ServerURL string `json:"server_url"` ProxyURL string `json:"proxy_url"` DiscoveryInterval string `json:"discovery_interval"` - DiscoveryDisabled bool `json:"discovery_disabled"` + DiscoveryEnabled bool `json:"discovery_enabled"` } if err := json.NewDecoder(r.Body).Decode(&settings); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -186,7 +186,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { s.discoveryInterval = interval } - s.discoveryDisabled = settings.DiscoveryDisabled + s.discoveryEnabled = settings.DiscoveryEnabled if s.sm != nil { s.sm.ServerURL = settings.ServerURL @@ -208,7 +208,7 @@ func (s *Server) HandleUpdateSettings(w http.ResponseWriter, r *http.Request) { LogBodies: currentLogBody, RecordInteractions: currentRecord, DiscoveryInterval: s.discoveryInterval.String(), - DiscoveryDisabled: s.discoveryDisabled, + DiscoveryEnabled: s.discoveryEnabled, }) s.mu.Unlock() @@ -560,7 +560,7 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques // Access fields directly since we already hold the lock serverURL, proxyURL, httpsServerURL := s.serverURL, s.proxyURL, s.httpsServerURL discoveryInterval := s.discoveryInterval.String() - discoveryDisabled := s.discoveryDisabled + discoveryEnabled := s.discoveryEnabled log.Printf("Saving updated proxy settings to %s/settings.json", s.ds.DataDir) err := s.ds.SaveSettings(datastore.Settings{ @@ -571,7 +571,7 @@ func (s *Server) HandleUpdateProxySettings(w http.ResponseWriter, r *http.Reques LogBodies: s.proxyLogBody, RecordInteractions: s.recordEnabled, DiscoveryInterval: discoveryInterval, - DiscoveryDisabled: discoveryDisabled, + DiscoveryEnabled: discoveryEnabled, }) s.mu.Unlock() @@ -740,3 +740,72 @@ func (s *Server) HandleGetVersionInfo(w http.ResponseWriter, _ *http.Request) { return } } + +// HandleGetInteractionStats returns statistics about recorded interactions. +func (s *Server) HandleGetInteractionStats(w http.ResponseWriter, _ *http.Request) { + if s.recorder == nil { + http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable) + return + } + + stats, err := s.recorder.GetInteractionStats() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(stats); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + +// HandleListInteractions returns a list of recorded interactions. +func (s *Server) HandleListInteractions(w http.ResponseWriter, r *http.Request) { + if s.recorder == nil { + http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable) + return + } + + session := r.URL.Query().Get("session") + category := r.URL.Query().Get("category") + since := r.URL.Query().Get("since") + + interactions, err := s.recorder.ListInteractions(session, category, since) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(interactions); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + return + } +} + +// HandleGetInteractionContent returns the raw content of a recorded interaction. +func (s *Server) HandleGetInteractionContent(w http.ResponseWriter, r *http.Request) { + if s.recorder == nil { + http.Error(w, "Recorder not initialized", http.StatusServiceUnavailable) + return + } + + file := r.URL.Query().Get("file") + if file == "" { + http.Error(w, "File parameter is required", http.StatusBadRequest) + return + } + + content, err := s.recorder.GetInteractionContent(file) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/plain") + _, _ = w.Write(content) +} diff --git a/pkg/service/handlers/interactions_test.go b/pkg/service/handlers/interactions_test.go new file mode 100644 index 0000000..ef0b58a --- /dev/null +++ b/pkg/service/handlers/interactions_test.go @@ -0,0 +1,159 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" + "github.com/gesellix/bose-soundtouch/pkg/service/proxy" + "github.com/go-chi/chi/v5" +) + +func TestInteractionHandlers(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "interaction-handlers-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db")) + server := &Server{ds: ds} + + t.Run("HandleGetInteractionStats_NoRecorder", func(t *testing.T) { + req := httptest.NewRequest("GET", "/setup/interaction-stats", nil) + w := httptest.NewRecorder() + server.HandleGetInteractionStats(w, req) + if w.Code != http.StatusServiceUnavailable { + t.Errorf("Expected status 503, got %d", w.Code) + } + }) + + recorder := proxy.NewRecorder(tmpDir) + server.SetRecorder(recorder) + + // Create a dummy interaction file + sessionID := recorder.SessionID + relPath := filepath.Join(sessionID, "self", "test", "0001-12-00-00.000-GET.http") + fullPath := filepath.Join(tmpDir, "interactions", relPath) + os.MkdirAll(filepath.Dir(fullPath), 0755) + os.WriteFile(fullPath, []byte("### GET /test\n\n> {% \n // Response: 200 OK\n%}\n"), 0644) + + r := chi.NewRouter() + r.Route("/setup", func(r chi.Router) { + r.Get("/interaction-stats", server.HandleGetInteractionStats) + r.Get("/interactions", server.HandleListInteractions) + r.Get("/interaction-content", server.HandleGetInteractionContent) + }) + + t.Run("HandleGetInteractionStats", func(t *testing.T) { + req := httptest.NewRequest("GET", "/setup/interaction-stats", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var stats proxy.InteractionStats + if err := json.NewDecoder(w.Body).Decode(&stats); err != nil { + t.Fatalf("Failed to decode stats: %v", err) + } + + if stats.TotalRequests != 1 { + t.Errorf("Expected 1 total request, got %d", stats.TotalRequests) + } + }) + + t.Run("HandleListInteractions", func(t *testing.T) { + req := httptest.NewRequest("GET", "/setup/interactions?category=self", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var interactions []proxy.Interaction + if err := json.NewDecoder(w.Body).Decode(&interactions); err != nil { + t.Fatalf("Failed to decode interactions: %v", err) + } + + if len(interactions) != 1 { + t.Errorf("Expected 1 interaction, got %d", len(interactions)) + } + }) + + t.Run("HandleGetInteractionContent", func(t *testing.T) { + req := httptest.NewRequest("GET", "/setup/interaction-content?file="+relPath, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + if !strings.Contains(w.Body.String(), "### GET /test") { + t.Errorf("Unexpected content: %s", w.Body.String()) + } + }) + + t.Run("HandleGetInteractionContent_MissingFile", func(t *testing.T) { + req := httptest.NewRequest("GET", "/setup/interaction-content", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } + }) +} + +func TestRecordMiddleware(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "record-middleware-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + ds := datastore.NewDataStore(filepath.Join(tmpDir, "test.db")) + server := &Server{ + ds: ds, + recordEnabled: true, + } + recorder := proxy.NewRecorder(tmpDir) + server.SetRecorder(recorder) + + r := chi.NewRouter() + r.Use(server.RecordMiddleware) + r.Get("/test-middleware", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Test", "Value") + w.WriteHeader(http.StatusCreated) + w.Write([]byte("created")) + + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + }) + + req := httptest.NewRequest("GET", "/test-middleware", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("Expected status 201, got %d", w.Code) + } + + t.Run("HandleRecordMiddleware_Disabled", func(t *testing.T) { + server.recordEnabled = false + req := httptest.NewRequest("GET", "/test-middleware", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Errorf("Expected status 201, got %d", w.Code) + } + }) +} diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 8940b7e..dfc4e1d 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -26,7 +26,7 @@ type Server struct { proxyLogBody bool recordEnabled bool discoveryInterval time.Duration - discoveryDisabled bool + discoveryEnabled bool shortcuts map[string]int recorder *proxy.Recorder Version string @@ -59,12 +59,12 @@ func (s *Server) SetVersionInfo(version, commit, date string) { } // SetDiscoverySettings sets the discovery settings for the server. -func (s *Server) SetDiscoverySettings(interval time.Duration, disabled bool) { +func (s *Server) SetDiscoverySettings(interval time.Duration, enabled bool) { s.mu.Lock() defer s.mu.Unlock() s.discoveryInterval = interval - s.discoveryDisabled = disabled + s.discoveryEnabled = enabled } // SetShortcuts sets the request shortcuts for the server. @@ -88,7 +88,7 @@ func (s *Server) GetDiscoverySettings() (time.Duration, bool) { s.mu.RLock() defer s.mu.RUnlock() - return s.discoveryInterval, s.discoveryDisabled + return s.discoveryInterval, s.discoveryEnabled } // SetHTTPServerURL sets the external HTTPS URL of the service. diff --git a/pkg/service/handlers/web/css/style.css b/pkg/service/handlers/web/css/style.css index eddc047..de568e1 100644 --- a/pkg/service/handlers/web/css/style.css +++ b/pkg/service/handlers/web/css/style.css @@ -4,7 +4,7 @@ th, td { border: 1px solid #ddd; padding: 8px; text-align: left; } th { background-color: #f2f2f2; } button { padding: 5px 10px; cursor: pointer; } .status { margin-top: 10px; padding: 10px; border: 1px solid #ccc; display: none; } -.summary-box { margin-top: 20px; padding: 15px; border: 1px solid #aaa; background-color: #f9f9f9; display: none; } +.summary-box { margin-top: 20px; padding: 15px; border: 1px solid #aaa; background-color: #f9f9f9; } pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px; } .diff-container { display: flex; gap: 10px; } .diff-pane { flex: 1; min-width: 0; } @@ -76,3 +76,29 @@ pre { background-color: #eee; padding: 10px; overflow-x: auto; font-size: 12px; .btn-danger:hover { background-color: #d32f2f; } + +.badge { + padding: 2px 6px; + border-radius: 4px; + font-size: 0.85em; + font-weight: bold; +} +.stats-list { + list-style: none; + padding: 0; + margin: 0; +} +.stats-list li { + padding: 5px 0; + border-bottom: 1px solid #f0f0f0; + display: flex; + justify-content: space-between; + align-items: center; +} +.stats-list li:last-child { + border-bottom: none; +} +.category-self { background-color: #e3f2fd; color: #0d47a1; } +.category-upstream { background-color: #f3e5f5; color: #7b1fa2; } +.status-success { background-color: #e8f5e9; color: #2e7d32; } +.status-error { background-color: #ffebee; color: #c62828; } diff --git a/pkg/service/handlers/web/index.html b/pkg/service/handlers/web/index.html index edcdcea..70a3435 100644 --- a/pkg/service/handlers/web/index.html +++ b/pkg/service/handlers/web/index.html @@ -16,6 +16,7 @@ + @@ -92,17 +93,22 @@
- +
- Proxy Logging: - - - + Proxy Logging: +
+ + + +
@@ -272,6 +278,83 @@ + + +
+

Recorded Interactions

+

Analysis of traffic handled by this service (self) and proxied to Bose (upstream).

+ +
+
+

Total Requests: 0

+ +
+
+
+

By Service

+
    +
    +
    +

    Sessions

    +
    +
      +
      +
      +
      +
      + +
      +

      Browse Recordings

      +
      +
      + + +
      +
      + + +
      +
      + + +
      + +
      + +
      + + + + + + + + + + + + + + + +
      #TimeMethodPathStatusCategoryAction
      No interactions found.
      +
      +
      + + +
      diff --git a/pkg/service/handlers/web/js/script.js b/pkg/service/handlers/web/js/script.js index 34a3a48..849eb9c 100644 --- a/pkg/service/handlers/web/js/script.js +++ b/pkg/service/handlers/web/js/script.js @@ -11,8 +11,8 @@ async function fetchSettings() { if (settings.discovery_interval) { document.getElementById('discovery-interval').value = settings.discovery_interval; } - if (settings.discovery_disabled !== undefined) { - document.getElementById('discovery-disabled').checked = settings.discovery_disabled; + if (settings.discovery_enabled !== undefined) { + document.getElementById('discovery-enabled').checked = settings.discovery_enabled; } fetchProxySettings(); } catch (error) { @@ -54,7 +54,7 @@ async function updateSettings() { server_url: document.getElementById('target-domain').value, proxy_url: document.getElementById('proxy-domain').value, discovery_interval: document.getElementById('discovery-interval').value, - discovery_disabled: document.getElementById('discovery-disabled').checked + discovery_enabled: document.getElementById('discovery-enabled').checked }; const status = document.getElementById('settings-status'); status.innerText = 'Saving...'; @@ -168,6 +168,11 @@ function openTab(evt, tabId) { content.className += " active"; } + if (tabId === 'tab-interactions') { + fetchInteractionStats(); + fetchInteractions(); + } + if (evt) { evt.currentTarget.className += " active"; } else { @@ -229,13 +234,202 @@ async function fetchVersion() { } } +async function fetchInteractionStats() { + console.log('Fetching interaction stats...'); + try { + const response = await fetch('/setup/interaction-stats'); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const stats = await response.json(); + console.log('Fetched interaction stats:', stats); + + document.getElementById('total-requests').innerText = stats.total_requests || stats.TotalRequests || 0; + + const statsContainer = document.getElementById('interaction-stats-container'); + if (statsContainer) { + statsContainer.style.display = 'block'; + } + + const serviceList = document.getElementById('stats-by-service'); + serviceList.innerHTML = ''; + const byService = stats.by_service || stats.ByService; + if (byService) { + Object.entries(byService).forEach(([service, count]) => { + const li = document.createElement('li'); + li.innerHTML = `${service || "unknown"}: ${count || 0} requests`; + serviceList.appendChild(li); + }); + } + + const sessionList = document.getElementById('stats-by-session'); + const sessionFilter = document.getElementById('filter-session'); + const currentFilter = sessionFilter.value; + + sessionList.innerHTML = ''; + sessionFilter.innerHTML = ''; + + const bySession = stats.by_session || stats.BySession; + if (bySession) { + // Sort by session ID (timestamp) descending + const sortedSessions = Object.entries(bySession) + .sort((a, b) => { + const sessionA = a[0] || ""; + const sessionB = b[0] || ""; + return sessionB.localeCompare(sessionA); + }); + + sortedSessions.forEach(([session, count]) => { + // Session format is like 20260215-160705-99213 + // Try to make it more readable: 2026-02-15 16:07:05 (PID 99213) + let sessionDisplay = session || "unknown"; + if (session && session.includes('-')) { + const parts = session.split('-'); + if (parts.length >= 2) { + const date = parts[0]; // 20260215 + const time = parts[1]; // 160705 + if (date.length === 8 && time.length === 6) { + sessionDisplay = `${date.substring(0, 4)}-${date.substring(4, 6)}-${date.substring(6, 8)} ${time.substring(0, 2)}:${time.substring(2, 4)}:${time.substring(4, 6)}`; + if (parts.length >= 3) { + sessionDisplay += ` (PID ${parts[2]})`; + } + } + } + } + + const li = document.createElement('li'); + li.innerHTML = `${sessionDisplay}: ${count || 0} requests `; + sessionList.appendChild(li); + + const opt = document.createElement('option'); + opt.value = session || ""; + opt.innerText = sessionDisplay; + sessionFilter.appendChild(opt); + }); + + sessionFilter.value = currentFilter; + } + } catch (error) { + console.error('Failed to fetch interaction stats', error); + } +} + +function filterBySession(sessionId) { + document.getElementById('filter-session').value = sessionId; + fetchInteractions(); + const browseContainer = document.getElementById('browse-recordings'); + if (browseContainer) { + browseContainer.scrollIntoView({ behavior: 'smooth' }); + } +} + +async function fetchInteractions() { + console.log('Fetching interactions...'); + const session = document.getElementById('filter-session').value; + const category = document.getElementById('filter-category').value; + const since = document.getElementById('filter-since').value; + + let url = '/setup/interactions'; + const params = []; + if (session) params.push(`session=${encodeURIComponent(session)}`); + if (category) params.push(`category=${encodeURIComponent(category)}`); + if (since) params.push(`since=${encodeURIComponent(since)}`); + if (params.length > 0) url += '?' + params.join('&'); + + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const interactions = await response.json(); + console.log('Fetched interactions:', interactions); + const list = document.getElementById('interactions-list'); + if (!list) { + console.error('Could not find interactions-list element'); + return; + } + + // Show the parent summary box if it was hidden + const browseContainer = list.closest('.summary-box'); + if (browseContainer) { + browseContainer.style.display = 'block'; + } + + list.innerHTML = ''; + + if (!interactions || interactions.length === 0) { + list.innerHTML = 'No interactions found for current filters.'; + return; + } + + // Default sort: Session desc, then Counter asc + // If a specific session is selected, sort primarily by counter asc + interactions.sort((a, b) => { + const sessionA = a.session || a.Session || ""; + const sessionB = b.session || b.Session || ""; + if (sessionA !== sessionB) { + return sessionB.localeCompare(sessionA); + } + const counterA = a.counter || a.Counter || 0; + const counterB = b.counter || b.Counter || 0; + return counterA - counterB; + }); + + interactions.forEach(i => { + const tr = document.createElement('tr'); + tr.style.borderBottom = '1px solid #eee'; + + const counter = i.counter || i.Counter || 0; + const timestamp = i.timestamp || i.Timestamp || ""; + const method = i.method || i.Method || ""; + const path = i.path || i.Path || ""; + const status = i.status || i.Status || ""; + const category = i.category || i.Category || ""; + const session = i.session || i.Session || ""; + const file = i.file || i.File || ""; + + let statusClass = ''; + if (status >= 200 && status < 300) statusClass = 'status-success'; + else if (status >= 400) statusClass = 'status-error'; + + tr.innerHTML = ` + ${counter} + ${timestamp} + ${method} + ${path} + ${status || '???'} + ${category} + + `; + list.appendChild(tr); + }); + } catch (error) { + console.error('Failed to fetch interactions', error); + } +} + +async function viewInteraction(file) { + try { + const response = await fetch(`/setup/interaction-content?file=${encodeURIComponent(file)}`); + const content = await response.text(); + + document.getElementById('viewer-filename').innerText = file; + document.getElementById('interaction-content').innerText = content; + document.getElementById('interaction-viewer').style.display = 'block'; + document.getElementById('interaction-viewer').scrollIntoView({ behavior: 'smooth' }); + } catch (error) { + alert('Failed to load interaction content: ' + error); + } +} + document.addEventListener('DOMContentLoaded', () => { fetchSettings(); fetchDevices(); triggerDiscovery(); fetchVersion(); - document.getElementById('sync-now-btn').onclick = startSync; + const syncBtn = document.getElementById('sync-now-btn'); + if (syncBtn) syncBtn.onclick = startSync; }); diff --git a/pkg/service/proxy/proxy_test.go b/pkg/service/proxy/proxy_test.go index 11c6620..1e89897 100644 --- a/pkg/service/proxy/proxy_test.go +++ b/pkg/service/proxy/proxy_test.go @@ -4,6 +4,7 @@ import ( "io" "net/http/httptest" "os" + "path/filepath" "strings" "testing" ) @@ -64,6 +65,7 @@ func TestLoggingProxy_LogRequest(t *testing.T) { defer func() { _ = os.Unsetenv("LOG_PROXY_BODY") }() lp := NewLoggingProxy("http://example.com", true) + lp.LogBody = true body := "test body content" req := httptest.NewRequest("POST", "http://example.com/api", strings.NewReader(body)) @@ -77,4 +79,47 @@ func TestLoggingProxy_LogRequest(t *testing.T) { if string(readBody) != body { t.Errorf("Request body was consumed or changed, got %q, want %q", string(readBody), body) } + + // Test truncation + lp.MaxBodySize = 4 + req2 := httptest.NewRequest("POST", "http://example.com/api", strings.NewReader("1234567890")) + req2.Header.Set("Content-Type", "text/plain") + lp.LogRequest(req2) +} + +func TestLoggingProxy_LogResponse(t *testing.T) { + lp := NewLoggingProxy("http://example.com", true) + lp.LogBody = true + + body := "response content" + req := httptest.NewRequest("GET", "http://example.com/api", nil) + w := httptest.NewRecorder() + w.Header().Set("Content-Type", "text/plain") + _, _ = w.WriteString(body) + res := w.Result() + res.Request = req + + lp.LogResponse(res) + + // Check if body is still readable + readBody, _ := io.ReadAll(res.Body) + if string(readBody) != body { + t.Errorf("Response body was consumed or changed, got %q, want %q", string(readBody), body) + } + + // Test with recorder + tmpDir, _ := os.MkdirTemp("", "proxy-recorder-test") + defer os.RemoveAll(tmpDir) + recorder := NewRecorder(tmpDir) + lp.SetRecorder(recorder) + lp.RecordEnabled = true + + lp.LogResponse(res) + + // Verify recording exists + interactionsDir := filepath.Join(tmpDir, "interactions", recorder.SessionID, "upstream", "api") + files, _ := os.ReadDir(interactionsDir) + if len(files) == 0 { + t.Error("LogResponse did not record the interaction") + } } diff --git a/pkg/service/proxy/recorder.go b/pkg/service/proxy/recorder.go index 7506412..052f9c5 100644 --- a/pkg/service/proxy/recorder.go +++ b/pkg/service/proxy/recorder.go @@ -26,6 +26,26 @@ type Recorder struct { mu sync.Mutex } +// InteractionStats represents statistics for recorded interactions. +type InteractionStats struct { + TotalRequests int `json:"total_requests"` + ByService map[string]int `json:"by_service"` + BySession map[string]int `json:"by_session"` +} + +// Interaction represents a single recorded HTTP interaction. +type Interaction struct { + ID string `json:"id"` + Session string `json:"session"` + Category string `json:"category"` + Method string `json:"method"` + Path string `json:"path"` + File string `json:"file"` + Counter int `json:"counter"` + Status int `json:"status"` + Timestamp string `json:"timestamp"` +} + // NewRecorder creates a new HTTP interaction recorder. func NewRecorder(baseDir string) *Recorder { sessionID := time.Now().Format("20060102-150405") + "-" + fmt.Sprintf("%d", os.Getpid()) @@ -221,3 +241,194 @@ func (r *Recorder) updateEnvFile(newVars map[string]string) error { return os.WriteFile(envFile, data, 0644) } + +// GetInteractionStats returns statistics about recorded interactions. +func (r *Recorder) GetInteractionStats() (*InteractionStats, error) { + stats := &InteractionStats{ + ByService: make(map[string]int), + BySession: make(map[string]int), + } + + interactionsDir := filepath.Join(r.BaseDir, "interactions") + if _, err := os.Stat(interactionsDir); os.IsNotExist(err) { + return stats, nil + } + + err := filepath.Walk(interactionsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if !info.IsDir() && strings.HasSuffix(info.Name(), ".http") { + stats.TotalRequests++ + + // Extract category (self/upstream) and session from path + // Path is like: .../interactions///... + rel, err := filepath.Rel(interactionsDir, path) + if err != nil { + return err + } + + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) >= 2 { + sessionID := parts[0] + category := parts[1] + stats.BySession[sessionID]++ + stats.ByService[category]++ + } + } + + return nil + }) + + return stats, err +} + +// ListInteractions returns a list of recorded interactions. +func (r *Recorder) ListInteractions(sessionFilter, categoryFilter, sinceFilter string) ([]Interaction, error) { + interactions := make([]Interaction, 0) + interactionsDir := filepath.Join(r.BaseDir, "interactions") + + if _, err := os.Stat(interactionsDir); os.IsNotExist(err) { + return interactions, nil + } + + err := filepath.Walk(interactionsDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() || !strings.HasSuffix(info.Name(), ".http") { + return nil + } + + rel, err := filepath.Rel(interactionsDir, path) + if err != nil { + return err + } + + parts := strings.Split(rel, string(filepath.Separator)) + if len(parts) < 3 { + return nil + } + + sessionID, category := parts[0], parts[1] + if (sessionFilter != "" && sessionID != sessionFilter) || (categoryFilter != "" && category != categoryFilter) { + return nil + } + + interaction, ok := r.parseInteractionFile(rel, path, parts) + if !ok { + return nil + } + + if sinceFilter != "" && interaction.Timestamp != "" { + fullTS := r.getFullTimestamp(sessionID, interaction.ID) + + normalizedSince := strings.ReplaceAll(strings.ReplaceAll(sinceFilter, ":", "-"), " ", "-") + if fullTS != "" && fullTS < normalizedSince { + return nil + } + } + + interactions = append(interactions, interaction) + + return nil + }) + + return interactions, err +} + +func (r *Recorder) parseInteractionFile(rel, path string, parts []string) (Interaction, bool) { + sessionID, category := parts[0], parts[1] + filename := parts[len(parts)-1] + fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-") + + date := "" + if len(sessionID) >= 8 { + date = sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8] + } + + timestamp := "" + + if len(fnParts) >= 4 { + timeStr := fnParts[1] + ":" + fnParts[2] + ":" + fnParts[3] + timestamp = timeStr + + if date != "" { + timestamp = date + " " + timeStr + } + } + + requestPath := "/" + strings.Join(parts[2:len(parts)-1], "/") + if requestPath == "/root" { + requestPath = "/" + } + + method, counter := "UNKNOWN", 0 + if len(fnParts) >= 1 { + _, _ = fmt.Sscanf(fnParts[0], "%d", &counter) + } + + if len(fnParts) >= 5 { + method = fnParts[4] + } + + return Interaction{ + ID: filename, + Session: sessionID, + Category: category, + Method: method, + Path: requestPath, + File: rel, + Counter: counter, + Status: r.peekStatus(path), + Timestamp: timestamp, + }, true +} + +func (r *Recorder) getFullTimestamp(sessionID, filename string) string { + if len(sessionID) < 8 { + return "" + } + + date := sessionID[0:4] + "-" + sessionID[4:6] + "-" + sessionID[6:8] + fnParts := strings.Split(strings.TrimSuffix(filename, ".http"), "-") + + if len(fnParts) < 4 { + return "" + } + + return date + "-" + fnParts[1] + "-" + fnParts[2] + "-" + fnParts[3] +} + +func (r *Recorder) peekStatus(path string) int { + content, err := os.ReadFile(path) + if err != nil { + return 0 + } + + lines := strings.Split(string(content), "\n") + for _, line := range lines { + if !strings.Contains(line, "// Response:") { + continue + } + + trimmedLine := strings.TrimPrefix(strings.TrimSpace(line), "//") + trimmedLine = strings.TrimPrefix(strings.TrimSpace(trimmedLine), "Response:") + trimmedLine = strings.TrimSpace(trimmedLine) + + status := 0 + _, _ = fmt.Sscanf(trimmedLine, "%d", &status) + + return status + } + + return 0 +} + +// GetInteractionContent returns the raw content of a recorded interaction. +func (r *Recorder) GetInteractionContent(relPath string) ([]byte, error) { + fullPath := filepath.Join(r.BaseDir, "interactions", relPath) + return os.ReadFile(fullPath) +} diff --git a/pkg/service/proxy/recorder_test.go b/pkg/service/proxy/recorder_test.go index 0939f50..bd492bf 100644 --- a/pkg/service/proxy/recorder_test.go +++ b/pkg/service/proxy/recorder_test.go @@ -1,7 +1,9 @@ package proxy import ( + "bytes" "encoding/json" + "io" "net/http" "net/url" "os" @@ -339,3 +341,362 @@ func TestRecorder_EnvFile(t *testing.T) { t.Errorf("Expected ip to be 192.168.178.35, got %s", content["session"]["ip"]) } } + +func TestRecorder_GetInteractionStats(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "recorder-stats-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + r := NewRecorder(tmpDir) + r.SessionID = "20260215-120000-12345" + + // Create some dummy interactions + files := []string{ + "interactions/20260215-120000-12345/self/setup/0001-12-00-01.000-GET.http", + "interactions/20260215-120000-12345/upstream/marge/0002-12-00-02.000-POST.http", + "interactions/20260215-130000-67890/self/setup/0001-13-00-01.000-GET.http", + } + + for _, f := range files { + path := filepath.Join(tmpDir, f) + os.MkdirAll(filepath.Dir(path), 0755) + os.WriteFile(path, []byte("test"), 0644) + } + + stats, err := r.GetInteractionStats() + if err != nil { + t.Fatalf("GetInteractionStats failed: %v", err) + } + + if stats.TotalRequests != 3 { + t.Errorf("Expected 3 total requests, got %d", stats.TotalRequests) + } + + if stats.ByService["self"] != 2 { + t.Errorf("Expected 2 self requests, got %d", stats.ByService["self"]) + } + + if stats.ByService["upstream"] != 1 { + t.Errorf("Expected 1 upstream request, got %d", stats.ByService["upstream"]) + } + + if stats.BySession["20260215-120000-12345"] != 2 { + t.Errorf("Expected 2 requests for session 1, got %d", stats.BySession["20260215-120000-12345"]) + } +} + +func TestRecorder_ListInteractions(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "recorder-list-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + r := NewRecorder(tmpDir) + session1 := "20260215-120000-12345" + session2 := "20260215-130000-67890" + + // Create some dummy interactions + files := []struct { + path string + content string + }{ + { + path: filepath.Join("interactions", session1, "self", "setup", "0001-12-00-01.555-GET.http"), + content: "### GET /setup\n\n> {% \n // Response: 200 OK\n%}\n", + }, + { + path: filepath.Join("interactions", session1, "upstream", "marge", "0002-12-00-02.000-POST.http"), + content: "### POST /marge\n\n> {% \n // Response: 201 Created\n%}\n", + }, + { + path: filepath.Join("interactions", session2, "self", "info", "0001-13-00-05.000-GET.http"), + content: "### GET /info\n\n> {% \n // Response: 404 Not Found\n%}\n", + }, + } + + for _, f := range files { + path := filepath.Join(tmpDir, f.path) + os.MkdirAll(filepath.Dir(path), 0755) + os.WriteFile(path, []byte(f.content), 0644) + } + + t.Run("List_all", func(t *testing.T) { + list, err := r.ListInteractions("", "", "") + if err != nil { + t.Fatalf("ListInteractions failed: %v", err) + } + if len(list) != 3 { + t.Errorf("Expected 3 interactions, got %d", len(list)) + } + }) + + t.Run("Filter_by_session", func(t *testing.T) { + list, err := r.ListInteractions(session1, "", "") + if err != nil { + t.Fatalf("ListInteractions failed: %v", err) + } + if len(list) != 2 { + t.Errorf("Expected 2 interactions for session1, got %d", len(list)) + } + for _, i := range list { + if i.Session != session1 { + t.Errorf("Expected session %s, got %s", session1, i.Session) + } + } + }) + + t.Run("Filter_by_category", func(t *testing.T) { + list, err := r.ListInteractions("", "upstream", "") + if err != nil { + t.Fatalf("ListInteractions failed: %v", err) + } + if len(list) != 1 { + t.Errorf("Expected 1 upstream interaction, got %d", len(list)) + } + if list[0].Category != "upstream" { + t.Errorf("Expected category upstream, got %s", list[0].Category) + } + }) + + t.Run("Check_enhanced_fields", func(t *testing.T) { + list, err := r.ListInteractions(session1, "self", "") + if err != nil { + t.Fatalf("ListInteractions failed: %v", err) + } + if len(list) == 0 { + t.Fatal("Expected at least one interaction") + } + i := list[0] + if i.Counter != 1 { + t.Errorf("Expected counter 1, got %d", i.Counter) + } + if i.Status != 200 { + t.Errorf("Expected status 200, got %d", i.Status) + } + if i.Method != "GET" { + t.Errorf("Expected method GET, got %s", i.Method) + } + if i.Timestamp != "2026-02-15 12:00:01.555" { + t.Errorf("Expected timestamp 2026-02-15 12:00:01.555, got %s", i.Timestamp) + } + if i.Path != "/setup" { + t.Errorf("Expected path /setup, got %s", i.Path) + } + }) + + t.Run("Filter_by_since", func(t *testing.T) { + // session1 has 2026-02-15 12:00:01.555 and 12:00:02.000 + // session2 has 2026-02-15 13:00:05.000 + list, err := r.ListInteractions("", "", "2026-02-15 12:30:00") + if err != nil { + t.Fatalf("ListInteractions failed: %v", err) + } + if len(list) != 1 { + t.Errorf("Expected 1 interaction since 12:30:00, got %d", len(list)) + } + if list[0].Session != session2 { + t.Errorf("Expected session2, got %s", list[0].Session) + } + + list, err = r.ListInteractions("", "", "2026-02-15 12:00:01.600") + if err != nil { + t.Fatalf("ListInteractions failed: %v", err) + } + // Should include 12:00:02.000 and 13:00:05.000 + if len(list) != 2 { + t.Errorf("Expected 2 interactions since 12:00:01.600, got %d", len(list)) + } + }) +} + +func TestRecorder_GetInteractionContent(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "recorder-content-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + r := NewRecorder(tmpDir) + relPath := filepath.Join(r.SessionID, "self", "test", "0001-GET.http") + fullPath := filepath.Join(tmpDir, "interactions", relPath) + os.MkdirAll(filepath.Dir(fullPath), 0755) + + expectedContent := "test content" + os.WriteFile(fullPath, []byte(expectedContent), 0644) + + content, err := r.GetInteractionContent(relPath) + if err != nil { + t.Fatalf("GetInteractionContent failed: %v", err) + } + + if string(content) != expectedContent { + t.Errorf("Expected %s, got %s", expectedContent, string(content)) + } + + _, err = r.GetInteractionContent("non-existent") + if err == nil { + t.Error("Expected error for non-existent file, got nil") + } +} + +func TestRecorder_Record_FullExchange(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "recorder-full-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + r := NewRecorder(tmpDir) + + req := &http.Request{ + Method: "POST", + URL: &url.URL{ + Path: "/test", + }, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("request body")), + } + req.Header.Set("Content-Type", "text/plain") + + res := &http.Response{ + StatusCode: 200, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("response body")), + Request: req, + } + res.Header.Set("Content-Type", "application/json") + + err = r.Record("self", req, res) + if err != nil { + t.Fatalf("Record failed: %v", err) + } + + // Verify file content + interactionsDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "test") + files, _ := os.ReadDir(interactionsDir) + if len(files) == 0 { + t.Fatal("No recording file found") + } + + content, _ := os.ReadFile(filepath.Join(interactionsDir, files[0].Name())) + contentStr := string(content) + + if !strings.Contains(contentStr, "request body") { + t.Error("Recording does not contain request body") + } + if !strings.Contains(contentStr, "Response: 200 OK") { + t.Error("Recording does not contain response status") + } + if !strings.Contains(contentStr, "response body") { + t.Error("Recording does not contain response body") + } +} + +func TestRecorder_Record_BinaryResponse(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "recorder-binary-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + r := NewRecorder(tmpDir) + + req := &http.Request{ + Method: "GET", + URL: &url.URL{Path: "/image"}, + } + + res := &http.Response{ + StatusCode: 200, + Header: make(http.Header), + Body: io.NopCloser(bytes.NewBuffer([]byte{0x00, 0x01, 0x02, 0x03})), + Request: req, + } + res.Header.Set("Content-Type", "image/png") + + err = r.Record("self", req, res) + if err != nil { + t.Fatalf("Record failed: %v", err) + } + + interactionsDir := filepath.Join(tmpDir, "interactions", r.SessionID, "self", "image") + files, _ := os.ReadDir(interactionsDir) + content, _ := os.ReadFile(filepath.Join(interactionsDir, files[0].Name())) + contentStr := string(content) + + if !strings.Contains(contentStr, "[Binary response body: 4 bytes]") { + t.Error("Recording does not correctly report binary response") + } +} + +func TestRecorder_ListInteractions_FullTimestamp(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "recorder-full-ts-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + r := NewRecorder(tmpDir) + sessionID := "20260215-100000-12345" + r.SessionID = sessionID + + // Create some dummy recordings + basePath := filepath.Join(tmpDir, "interactions", sessionID, "self", "test") + os.MkdirAll(basePath, 0755) + + files := []string{ + "0001-10-00-01.000-GET.http", + "0002-11-00-00.000-GET.http", + } + + for _, f := range files { + os.WriteFile(filepath.Join(basePath, f), []byte("test"), 0644) + } + + t.Run("Check_Full_Timestamp_Display", func(t *testing.T) { + interactions, err := r.ListInteractions(sessionID, "", "") + if err != nil { + t.Fatalf("ListInteractions failed: %v", err) + } + + if len(interactions) != 2 { + t.Fatalf("Expected 2 interactions, got %d", len(interactions)) + } + + expectedTS := "2026-02-15 10:00:01.000" + if interactions[0].Timestamp != expectedTS { + t.Errorf("Expected timestamp %s, got %s", expectedTS, interactions[0].Timestamp) + } + }) + + t.Run("Filter_By_Full_Date_Time", func(t *testing.T) { + // Filter for interactions since 10:30:00 on that day + interactions, err := r.ListInteractions(sessionID, "", "2026-02-15 10:30:00") + if err != nil { + t.Fatalf("ListInteractions failed: %v", err) + } + + if len(interactions) != 1 { + t.Fatalf("Expected 1 interaction, got %d", len(interactions)) + } + + if interactions[0].ID != "0002-11-00-00.000-GET.http" { + t.Errorf("Expected 0002-..., got %s", interactions[0].ID) + } + }) + + t.Run("Filter_By_Date_Only", func(t *testing.T) { + // Filter for interactions since the day before + interactions, err := r.ListInteractions(sessionID, "", "2026-02-14") + if err != nil { + t.Fatalf("ListInteractions failed: %v", err) + } + + if len(interactions) != 2 { + t.Fatalf("Expected 2 interactions, got %d", len(interactions)) + } + }) +}