diff --git a/SPECS/ARCHIVE/BUG-T11_Chart_Request_Timeline_never_shows_actual_events/BUG-T11_Chart_Request_Timeline_never_shows_actual_events.md b/SPECS/ARCHIVE/BUG-T11_Chart_Request_Timeline_never_shows_actual_events/BUG-T11_Chart_Request_Timeline_never_shows_actual_events.md new file mode 100644 index 00000000..c874e1f7 --- /dev/null +++ b/SPECS/ARCHIVE/BUG-T11_Chart_Request_Timeline_never_shows_actual_events/BUG-T11_Chart_Request_Timeline_never_shows_actual_events.md @@ -0,0 +1,48 @@ +# PRD: BUG-T11 — Chart Request Timeline never shows actual events + +## Objective +Fix the Request Timeline chart so it reflects real traffic patterns instead of appearing as a static `1 request / 0 errors` line. The chart must render the full time window with correct per-bucket request and error counts, including zero-activity gaps. + +## Background +The dashboard currently plots only non-empty request/error buckets, and the frontend re-buckets an already bucketed stream. In normal traffic patterns (often one request per active bucket), this compresses activity into a near-flat line that looks static and misleading. + +## Deliverables +- Backend timeseries generation updated to return full 5-second timeline buckets for the requested window. +- Request/error/latency series remain in the existing `{"requests": [...], "errors": [...], "latencies": [...]}` format. +- Frontend timeline rendering updated to consume pre-bucketed backend points directly (no secondary aggregation). +- Regression tests covering: + - Presence of zero-value buckets in shared metrics timeseries. + - Correct total request/error counts preserved after bucket expansion. + - Frontend update path references direct timeseries arrays for Request Timeline. + +## Dependencies +- None (bug-fix task on existing web UI architecture). + +## Acceptance Criteria +- [ ] Request Timeline data includes explicit zero-value buckets across the selected history window. +- [ ] Timeline chart request series no longer collapses to only active buckets. +- [ ] Error series aligns with backend bucket counts and remains 0 only when no errors occurred in that bucket. +- [ ] Existing API response shape is preserved for compatibility. +- [ ] Required quality gates pass: `pytest`, `ruff check src/`, `mypy src/`, `pytest --cov` (coverage >= 90%). + +## Validation Plan +1. Add/extend unit tests for `SharedMetricsStore.get_timeseries()` to verify full-window bucketing and totals. +2. Add static frontend assertion test for timeline update path in served `dashboard.js`. +3. Run full required quality gates and capture outcomes in `BUG-T11_Validation_Report.md`. + +## Implementation Plan +### Phase 1: Shared metrics bucketing fix +- Expand `SharedMetricsStore.get_timeseries()` to emit every 5-second bucket from window start to now. +- Preserve request/error totals while adding zero buckets for empty intervals. + +### Phase 2: Frontend timeline binding cleanup +- Remove double bucketing in `updateTimeline()` and bind datasets directly to backend points. +- Keep x-axis labels as seconds-ago values for compatibility. + +### Phase 3: Regression tests and validation +- Add/adjust tests for bucket behavior and frontend update logic. +- Run quality gates and record evidence. + +--- +**Archived:** 2026-02-25 +**Verdict:** PASS diff --git a/SPECS/ARCHIVE/BUG-T11_Chart_Request_Timeline_never_shows_actual_events/BUG-T11_Validation_Report.md b/SPECS/ARCHIVE/BUG-T11_Chart_Request_Timeline_never_shows_actual_events/BUG-T11_Validation_Report.md new file mode 100644 index 00000000..bec6a5e2 --- /dev/null +++ b/SPECS/ARCHIVE/BUG-T11_Chart_Request_Timeline_never_shows_actual_events/BUG-T11_Validation_Report.md @@ -0,0 +1,38 @@ +# Validation Report: BUG-T11 + +## Task +Chart Request Timeline never shows actual events. + +## Implementation Summary +- Updated `SharedMetricsStore.get_timeseries()` to pre-populate every 5-second bucket across the requested history window, including explicit zero-value request/error buckets. +- Clamped computed bucket keys to the configured window bounds to keep timeline data stable. +- Simplified frontend `updateTimeline()` in `dashboard.js` to bind directly to backend-provided timeline buckets (removed secondary bucketing). +- Added regression coverage for full-window zero-gap buckets and frontend timeline binding behavior. + +## Quality Gates + +### 1) `PYTHONPATH=src pytest` +- Result: PASS +- Evidence: `636 passed, 5 skipped` + +### 2) `ruff check src/` +- Result: PASS +- Evidence: `All checks passed!` + +### 3) `PYTHONPATH=src mypy src/` +- Result: PASS +- Evidence: `Success: no issues found in 18 source files` + +### 4) `PYTHONPATH=src pytest --cov` +- Result: PASS +- Evidence: + - `636 passed, 5 skipped` + - `Required test coverage of 90.0% reached` + - `Total coverage: 91.33%` + +## Manual Validation Notes +- Timeline now receives a complete bucketed window with explicit zero intervals, preventing sparse active-only visualization. +- Frontend consumes backend bucket values directly, avoiding aggregation artifacts that made the chart appear static. + +## Verdict +PASS diff --git a/SPECS/ARCHIVE/INDEX.md b/SPECS/ARCHIVE/INDEX.md index ca29bdaf..0c5f29ea 100644 --- a/SPECS/ARCHIVE/INDEX.md +++ b/SPECS/ARCHIVE/INDEX.md @@ -1,11 +1,12 @@ # mcpbridge-wrapper Tasks Archive -**Last Updated:** 2026-02-20 (BUG-T12_Audit_Log_does_not_show_new_calls) +**Last Updated:** 2026-02-25 (BUG-T11_Chart_Request_Timeline_never_shows_actual_events) ## Archived Tasks | Task ID | Folder | Archived | Verdict | |---------|--------|----------|---------| +| BUG-T11 | [BUG-T11_Chart_Request_Timeline_never_shows_actual_events/](BUG-T11_Chart_Request_Timeline_never_shows_actual_events/) | 2026-02-25 | PASS | | BUG-T12 | [BUG-T12_Audit_Log_does_not_show_new_calls/](BUG-T12_Audit_Log_does_not_show_new_calls/) | 2026-02-20 | PASS | | BUG-T10 | [BUG-T10_Tool_chart_colors_change_on_update_of_tool_type_count/](BUG-T10_Tool_chart_colors_change_on_update_of_tool_type_count/) | 2026-02-20 | PASS | | P1-T1 | [P1-T1_Create_project_directory_structure/](P1-T1_Create_project_directory_structure/) | 2026-02-07 | PASS | @@ -249,6 +250,7 @@ | [REVIEW_bug_t18_workplan_entry.md](_Historical/REVIEW_bug_t18_workplan_entry.md) | Review report for BUG-T18 | | [REVIEW_bug_t17_audit_log_rows_stay_unfolded.md](_Historical/REVIEW_bug_t17_audit_log_rows_stay_unfolded.md) | Review report for BUG-T17 | | [REVIEW_bug_t14_latency_rows.md](_Historical/REVIEW_bug_t14_latency_rows.md) | Review report for BUG-T14 | +| [REVIEW_bug_t11_request_timeline.md](_Historical/REVIEW_bug_t11_request_timeline.md) | Review report for BUG-T11 | | [REVIEW_bug_t10.md](_Historical/REVIEW_bug_t10.md) | Review report for BUG-T10 | | [REVIEW_BUG-T12_audit_log_live_updates.md](BUG-T12_Audit_Log_does_not_show_new_calls/REVIEW_BUG-T12_audit_log_live_updates.md) | Review report for BUG-T12 | @@ -257,6 +259,8 @@ | Date | Task ID | Action | |------|---------|--------| +| 2026-02-25 | BUG-T11 | Archived REVIEW_bug_t11_request_timeline report | +| 2026-02-25 | BUG-T11 | Archived Chart_Request_Timeline_never_shows_actual_events (PASS) | | 2026-02-20 | BUG-T12 | Archived REVIEW_BUG-T12_audit_log_live_updates report | | 2026-02-20 | BUG-T12 | Archived Audit_Log_does_not_show_new_calls (PASS) | | 2026-02-20 | BUG-T10 | Archived REVIEW_bug_t10 report | diff --git a/SPECS/ARCHIVE/_Historical/REVIEW_bug_t11_request_timeline.md b/SPECS/ARCHIVE/_Historical/REVIEW_bug_t11_request_timeline.md new file mode 100644 index 00000000..74380766 --- /dev/null +++ b/SPECS/ARCHIVE/_Historical/REVIEW_bug_t11_request_timeline.md @@ -0,0 +1,34 @@ +## REVIEW REPORT — BUG-T11 Request Timeline + +**Scope:** origin/main..HEAD +**Files:** 9 + +### Summary Verdict +- [x] Approve +- [ ] Approve with comments +- [ ] Request changes +- [ ] Block + +### Critical Issues +- None. + +### Secondary Issues +- None. + +### Architectural Notes +- Backend now emits explicit zero-value timeline buckets for the full requested window, which removes sparse active-only rendering artifacts. +- Frontend now binds directly to backend request/error buckets, avoiding double aggregation. +- The change keeps API shape stable while improving timeline observability. + +### Tests +- Added/updated regression coverage: + - `tests/unit/webui/test_shared_metrics.py` + - `tests/unit/webui/test_server.py` +- Quality gates all pass: + - `PYTHONPATH=src pytest` PASS (`636 passed, 5 skipped`) + - `ruff check src/` PASS + - `PYTHONPATH=src mypy src/` PASS + - `PYTHONPATH=src pytest --cov` PASS (`91.33%`, threshold 90%) + +### Next Steps +- FOLLOW-UP skipped: no actionable findings identified. diff --git a/SPECS/INPROGRESS/next.md b/SPECS/INPROGRESS/next.md index a00014bd..bff6cc1c 100644 --- a/SPECS/INPROGRESS/next.md +++ b/SPECS/INPROGRESS/next.md @@ -2,12 +2,12 @@ ## Recently Archived +- **BUG-T11** — Chart Request Timeline never shows actual events (2026-02-25, PASS) - **BUG-T12** — Audit Log does not show new calls (2026-02-20, PASS) - **BUG-T17** — Rows in Audit Log table automatically fold after user unfolds them (2026-02-20, PASS) -- **BUG-T14** — Rows in Per-Tool Latency Statistics fold automatically immediately after unfolding (2026-02-20, PASS) ## Suggested Next Tasks -- BUG-T11 — Chart Request Timeline never shows actual events - BUG-T13 — Per-Tool Latency Statistics does not show params when `capture_params` is false -- BUG-T18 — Error Breakdown widget must be full width streatched +- BUG-T19 — Audit Log and Session Timeline are inconsistent with tool charts in multi-process runs +- BUG-T20 — Session Timeline can show negative duration due to incorrect entry ordering diff --git a/SPECS/Workplan.md b/SPECS/Workplan.md index e6d6ef82..1ca6d88f 100644 --- a/SPECS/Workplan.md +++ b/SPECS/Workplan.md @@ -1311,9 +1311,10 @@ Refresh the browser page to reset the color assignment, but this persists only f ### BUG-T11: Chart Request Timeline never shows actual events - **Type:** Bug / Web UI / Chart Data -- **Status:** 🔴 Open +- **Status:** ✅ Fixed (2026-02-25) - **Priority:** P1 - **Discovered:** 2026-02-16 +- **Completed:** 2026-02-25 - **Component:** Web UI Dashboard (`webui/static/`, request timeline chart) - **Affected Clients:** All clients using Web UI dashboard - **Affected Surface:** Request Timeline chart on the Web UI dashboard @@ -1339,12 +1340,12 @@ The request timeline chart is likely not consuming live metrics data correctly. None. The chart is non-functional for monitoring purposes. Users must rely on the raw counters or audit log to observe request activity. #### Resolution Path -- [ ] Trace the data flow from `SharedMetricsStore.get_timeseries()` through the WebSocket payload to the frontend chart rendering for the request timeline -- [ ] Verify that the timeseries data returned by the API contains correct per-bucket request and error counts -- [ ] Verify that the frontend chart update function appends new data points rather than replacing the entire dataset with a summary -- [ ] Ensure the chart x-axis (time) and y-axis (counts) are correctly bound to the timeseries data -- [ ] Add integration test that simulates multiple requests and asserts the timeline chart data reflects actual counts -- [ ] Validate fix with live traffic to confirm the chart updates in real-time +- [x] Trace the data flow from `SharedMetricsStore.get_timeseries()` through the WebSocket payload to the frontend chart rendering for the request timeline +- [x] Verify that the timeseries data returned by the API contains correct per-bucket request and error counts +- [x] Verify that the frontend chart update function appends new data points rather than replacing the entire dataset with a summary +- [x] Ensure the chart x-axis (time) and y-axis (counts) are correctly bound to the timeseries data +- [x] Add integration test that simulates multiple requests and asserts the timeline chart data reflects actual counts +- [x] Validate fix with live traffic to confirm the chart updates in real-time #### Related Items - **P10-T1** — Web UI Control & Audit Dashboard; the request timeline chart is part of this component diff --git a/src/mcpbridge_wrapper/webui/shared_metrics.py b/src/mcpbridge_wrapper/webui/shared_metrics.py index ab5573ca..e48a351b 100644 --- a/src/mcpbridge_wrapper/webui/shared_metrics.py +++ b/src/mcpbridge_wrapper/webui/shared_metrics.py @@ -363,6 +363,7 @@ def get_timeseries(self, seconds: int = 300) -> Dict[str, List[Dict[str, Any]]]: cutoff = time.time() - seconds now = time.time() bucket_size = 5 # 5-second buckets to match frontend + max_bucket = (seconds // bucket_size) * bucket_size with self._transaction() as conn: # Query all records in time window @@ -374,19 +375,22 @@ def get_timeseries(self, seconds: int = 300) -> Dict[str, List[Dict[str, Any]]]: (cutoff,), ) - # Bucket data by time (seconds ago, 5-second buckets) - buckets: Dict[int, Dict[str, Any]] = {} + # Bucket data by time (seconds ago, 5-second buckets). + # Pre-populate the full window so the frontend receives explicit + # zero-value gaps instead of only non-empty buckets. + buckets: Dict[int, Dict[str, Any]] = { + bucket: { + "requests": 0, + "errors": 0, + "latencies": [], + } + for bucket in range(0, max_bucket + bucket_size, bucket_size) + } for row in cursor: timestamp = row["timestamp"] seconds_ago = int((now - timestamp) / bucket_size) * bucket_size - - if seconds_ago not in buckets: - buckets[seconds_ago] = { - "requests": 0, - "errors": 0, - "latencies": [], - } + seconds_ago = max(0, min(max_bucket, seconds_ago)) buckets[seconds_ago]["requests"] += 1 if row["error"]: diff --git a/src/mcpbridge_wrapper/webui/static/dashboard.js b/src/mcpbridge_wrapper/webui/static/dashboard.js index 37111763..329c43a9 100644 --- a/src/mcpbridge_wrapper/webui/static/dashboard.js +++ b/src/mcpbridge_wrapper/webui/static/dashboard.js @@ -478,35 +478,31 @@ } } - function bucketTimeseries(points, bucketSize) { - // Bucket points into time intervals and count per bucket - if (!points.length) return { labels: [], data: [] }; - var buckets = {}; - points.forEach(function (p) { - var key = Math.floor(p.t / bucketSize) * bucketSize; - buckets[key] = (buckets[key] || 0) + p.v; - }); - var keys = Object.keys(buckets).map(Number).sort(function (a, b) { return a - b; }); - return { - labels: keys.map(function (k) { return Math.round(k); }), - data: keys.map(function (k) { return buckets[k]; }), - }; - } - function updateTimeline(timeseries) { - var reqBuckets = bucketTimeseries(timeseries.requests, 5); - var errBuckets = bucketTimeseries(timeseries.errors, 5); - - // Union all labels - var labelSet = {}; - reqBuckets.labels.forEach(function (l) { labelSet[l] = true; }); - errBuckets.labels.forEach(function (l) { labelSet[l] = true; }); - var labels = Object.keys(labelSet).map(Number).sort(function (a, b) { return a - b; }); + var requestPoints = Array.isArray(timeseries && timeseries.requests) + ? timeseries.requests + : []; + var errorPoints = Array.isArray(timeseries && timeseries.errors) + ? timeseries.errors + : []; var reqMap = {}; - reqBuckets.labels.forEach(function (l, i) { reqMap[l] = reqBuckets.data[i]; }); + requestPoints.forEach(function (point) { + var label = Math.round(point.t); + reqMap[label] = (reqMap[label] || 0) + point.v; + }); + var errMap = {}; - errBuckets.labels.forEach(function (l, i) { errMap[l] = errBuckets.data[i]; }); + errorPoints.forEach(function (point) { + var label = Math.round(point.t); + errMap[label] = (errMap[label] || 0) + point.v; + }); + + // Union all labels from backend-provided buckets. + var labelSet = {}; + requestPoints.forEach(function (point) { labelSet[Math.round(point.t)] = true; }); + errorPoints.forEach(function (point) { labelSet[Math.round(point.t)] = true; }); + var labels = Object.keys(labelSet).map(Number).sort(function (a, b) { return b - a; }); charts.timeline.data.labels = labels; charts.timeline.data.datasets[0].data = labels.map(function (l) { return reqMap[l] || 0; }); diff --git a/tests/unit/webui/test_server.py b/tests/unit/webui/test_server.py index 4fb0df54..596a7a1a 100644 --- a/tests/unit/webui/test_server.py +++ b/tests/unit/webui/test_server.py @@ -221,6 +221,21 @@ def test_dashboard_js_refreshes_audit_log_on_live_request_updates(self, client): assert 'fetch(url, { cache: "no-store" })' in response.text assert "if (refreshRequestId !== latestAuditRefreshRequest) {" in response.text + def test_dashboard_js_timeline_uses_backend_bucket_series(self, client): + """Request timeline binds directly to backend buckets without re-bucketing.""" + response = client.get("/static/dashboard.js") + assert response.status_code == 200 + assert "function updateTimeline(timeseries) {" in response.text + expected_request_line = ( + "var requestPoints = Array.isArray(timeseries && timeseries.requests)" + ) + expected_error_line = "var errorPoints = Array.isArray(timeseries && timeseries.errors)" + assert expected_request_line in response.text + assert expected_error_line in response.text + assert "function bucketTimeseries(points, bucketSize)" not in response.text + assert "reqMap[label] = (reqMap[label] || 0) + point.v;" in response.text + assert "errMap[label] = (errMap[label] || 0) + point.v;" in response.text + def test_dashboard_js_preserves_latency_row_expansion_state(self, client): """Latency table parameter row state survives periodic table refreshes.""" response = client.get("/static/dashboard.js") diff --git a/tests/unit/webui/test_shared_metrics.py b/tests/unit/webui/test_shared_metrics.py index bf53f696..dddf3ec3 100644 --- a/tests/unit/webui/test_shared_metrics.py +++ b/tests/unit/webui/test_shared_metrics.py @@ -144,6 +144,47 @@ def test_get_timeseries_buckets_requests(self, store): total_requests = sum(p["v"] for p in result["requests"]) assert total_requests == 10 + def test_get_timeseries_includes_zero_value_gap_buckets(self, store): + """Timeseries should include explicit zero buckets across the full window.""" + store.record_request("BuildProject", request_id="1") + store.record_response("BuildProject", request_id="1", error=False, latency_ms=100.0) + + result = store.get_timeseries(seconds=20) + request_points = result["requests"] + error_points = result["errors"] + + assert [point["t"] for point in request_points] == [20, 15, 10, 5, 0] + assert [point["t"] for point in error_points] == [20, 15, 10, 5, 0] + assert sum(point["v"] for point in request_points) == 1 + assert any(point["v"] == 0 for point in request_points) + assert all(point["v"] == 0 for point in error_points) + + def test_get_timeseries_preserves_totals_with_full_window_buckets(self, store): + """Full-window buckets preserve request/error totals.""" + for i in range(3): + store.record_request("BuildProject", request_id=f"ok-{i}") + store.record_response( + "BuildProject", + request_id=f"ok-{i}", + error=False, + latency_ms=100.0, + ) + + for i in range(2): + store.record_request("BuildProject", request_id=f"err-{i}") + store.record_response( + "BuildProject", + request_id=f"err-{i}", + error=True, + latency_ms=50.0, + ) + + result = store.get_timeseries(seconds=30) + assert len(result["requests"]) == 7 + assert len(result["errors"]) == 7 + assert sum(point["v"] for point in result["requests"]) == 5 + assert sum(point["v"] for point in result["errors"]) == 2 + def test_get_timeseries_error_counting(self, store): """Test that errors are counted correctly in timeseries.""" # 3 successful requests