Enhance interaction recording and analysis features

This commit is contained in:
Tobias Gesellchen
2026-02-15 16:52:44 +01:00
parent 505e6dd760
commit a453059d6d
13 changed files with 1182 additions and 29 deletions
+1 -1
View File
@@ -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"`
}
+3 -3
View File
@@ -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)
}
}
+76 -7
View File
@@ -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)
}
+159
View File
@@ -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)
}
})
}
+4 -4
View File
@@ -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.
+27 -1
View File
@@ -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; }
+88 -5
View File
@@ -16,6 +16,7 @@
<button class="tab-btn" onclick="openTab(event, 'tab-devices')">2. Devices</button>
<button class="tab-btn" onclick="openTab(event, 'tab-sync')">3. Data Sync</button>
<button class="tab-btn" onclick="openTab(event, 'tab-migration')">4. Migration</button>
<button class="tab-btn" onclick="openTab(event, 'tab-interactions')">5. Interactions</button>
</div>
<!-- Tab 0: Overview -->
@@ -92,17 +93,22 @@
<div style="margin-bottom: 20px;">
<label for="discovery-interval">Discovery Interval:</label>
<input type="text" id="discovery-interval" placeholder="5m" style="width: 100px;">
<label style="margin-left: 15px;"><input type="checkbox" id="discovery-disabled"> Disable Automated Discovery</label>
<label style="margin-left: 15px;"><input type="checkbox" id="discovery-enabled"> Enable Automated Discovery</label>
</div>
<div style="margin-bottom: 20px;">
<button onclick="updateSettings()">Save Settings</button>
<span id="settings-status" style="margin-left: 10px; font-size: 0.9em;"></span>
</div>
<div style="margin-bottom: 20px;">
Proxy Logging:
<label><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
<label style="margin-left: 15px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
<label style="margin-left: 15px;"><input type="checkbox" id="proxy-record" onchange="updateProxySettings()"> Record Interactions</label>
<strong>Proxy Logging:</strong>
<div style="margin-top: 5px;">
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-redact" onchange="updateProxySettings()"> Redact Sensitive Headers</label>
<label style="display: block; margin-bottom: 5px;"><input type="checkbox" id="proxy-log-body" onchange="updateProxySettings()"> Log Bodies</label>
<label style="display: block; margin-bottom: 5px;">
<input type="checkbox" id="proxy-record" onchange="updateProxySettings()"> Record Interactions
<span style="font-size: 0.85em; color: #666; margin-left: 5px;">(View in <strong>5. Interactions</strong> tab)</span>
</label>
</div>
</div>
</div>
@@ -272,6 +278,83 @@
</div>
</div>
</div>
<!-- Tab 5: Interactions -->
<div id="tab-interactions" class="tab-content">
<h2>Recorded Interactions</h2>
<p>Analysis of traffic handled by this service (self) and proxied to Bose (upstream).</p>
<div id="interaction-stats-container" class="summary-box">
<div style="display: flex; gap: 20px; align-items: center; margin-bottom: 15px;">
<p style="margin: 0;">Total Requests: <strong id="total-requests">0</strong></p>
<button onclick="fetchInteractionStats()">Refresh Stats</button>
</div>
<div style="display: flex; gap: 20px;">
<div style="flex: 1; border-right: 1px solid #eee; padding-right: 20px;">
<h3>By Service</h3>
<ul id="stats-by-service" class="stats-list"></ul>
</div>
<div style="flex: 2;">
<h3>Sessions</h3>
<div id="stats-by-session-container" style="max-height: 200px; overflow-y: auto; border: 1px solid #eee; padding: 5px; border-radius: 4px;">
<ul id="stats-by-session" class="stats-list"></ul>
</div>
</div>
</div>
</div>
<div id="browse-recordings" class="summary-box" style="margin-top: 20px;">
<h3>Browse Recordings</h3>
<div style="margin-bottom: 15px; display: flex; gap: 15px; align-items: center; background: #f9f9f9; padding: 10px; border-radius: 4px;">
<div>
<label for="filter-session">Session:</label>
<select id="filter-session" onchange="fetchInteractions()">
<option value="">All Sessions</option>
</select>
</div>
<div>
<label for="filter-category">Category:</label>
<select id="filter-category" onchange="fetchInteractions()">
<option value="">All Categories</option>
<option value="self">Self (Emulated)</option>
<option value="upstream">Upstream (Bose)</option>
</select>
</div>
<div>
<label for="filter-since">Since (YYYY-MM-DD HH:mm:ss):</label>
<input type="text" id="filter-since" placeholder="e.g. 2026-02-15 15:00:00" size="25" onchange="fetchInteractions()">
</div>
<button onclick="fetchInteractions()">Apply Filters</button>
</div>
<div id="interactions-list-container" style="max-height: 400px; overflow-y: auto;">
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="text-align: left; border-bottom: 2px solid #eee;">
<th style="padding: 8px;">#</th>
<th style="padding: 8px;">Time</th>
<th style="padding: 8px;">Method</th>
<th style="padding: 8px;">Path</th>
<th style="padding: 8px;">Status</th>
<th style="padding: 8px;">Category</th>
<th style="padding: 8px;">Action</th>
</tr>
</thead>
<tbody id="interactions-list">
<tr><td colspan="7" style="padding: 20px; text-align: center; color: #666;">No interactions found.</td></tr>
</tbody>
</table>
</div>
</div>
<div id="interaction-viewer" class="summary-box" style="margin-top: 20px; display: none; background: #2b2b2b; color: #a9b7c6;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
<h3 style="margin: 0; color: #fff;">Recording Viewer: <span id="viewer-filename" style="font-weight: normal; font-size: 0.8em;"></span></h3>
<button onclick="document.getElementById('interaction-viewer').style.display='none'" style="background: #444; color: #fff; border: 1px solid #666;">Close</button>
</div>
<pre id="interaction-content" style="white-space: pre-wrap; font-family: 'Courier New', Courier, monospace; font-size: 0.9em; margin: 0; padding: 10px; overflow-x: auto; max-height: 600px;"></pre>
</div>
</div>
</div>
<script src="/web/js/script.js"></script>
+198 -4
View File
@@ -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 = `<strong>${service || "unknown"}:</strong> ${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 = '<option value="">All Sessions</option>';
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 = `<span class="session-info"><strong>${sessionDisplay}:</strong> ${count || 0} requests</span> <button onclick="filterBySession('${session || ""}')" style="font-size: 0.8em; padding: 2px 5px;">Filter</button>`;
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 = '<tr><td colspan="7" style="padding: 20px; text-align: center; color: #666;">No interactions found for current filters.</td></tr>';
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 = `
<td style="padding: 8px; color: #888;">${counter}</td>
<td style="padding: 8px; font-size: 0.8em; white-space: nowrap;">${timestamp}</td>
<td style="padding: 8px; font-family: monospace;">${method}</td>
<td style="padding: 8px; font-size: 0.9em;">${path}</td>
<td style="padding: 8px;"><span class="badge ${statusClass}">${status || '???'}</span></td>
<td style="padding: 8px;"><span class="badge category-${category}">${category}</span></td>
<td style="padding: 8px;"><button onclick="viewInteraction('${file}')">View</button></td>
`;
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;
});
+45
View File
@@ -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")
}
}
+211
View File
@@ -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/<session>/<category>/...
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)
}
+361
View File
@@ -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))
}
})
}