diff --git a/SPECS/ARCHIVE/FU-P12-T1-3_Show_multi-client_widgets_in_Web_UI_instead_of_single_overwritten_active_client/FU-P12-T1-3_Show_multi-client_widgets_in_Web_UI_instead_of_single_overwritten_active_client.md b/SPECS/ARCHIVE/FU-P12-T1-3_Show_multi-client_widgets_in_Web_UI_instead_of_single_overwritten_active_client/FU-P12-T1-3_Show_multi-client_widgets_in_Web_UI_instead_of_single_overwritten_active_client.md new file mode 100644 index 00000000..c42dc3fb --- /dev/null +++ b/SPECS/ARCHIVE/FU-P12-T1-3_Show_multi-client_widgets_in_Web_UI_instead_of_single_overwritten_active_client/FU-P12-T1-3_Show_multi-client_widgets_in_Web_UI_instead_of_single_overwritten_active_client.md @@ -0,0 +1,97 @@ +# PRD: FU-P12-T1-3 — Show multi-client widgets in Web UI instead of single overwritten active client + +**Created:** 2026-02-18 +**Priority:** P2 +**Branch:** `codex/feature/FU-P12-T1-3-multi-client-widgets` +**Status:** PLAN + +--- + +## 1. Problem Statement + +The dashboard currently surfaces one `Active Client` value that is overwritten by +whichever `initialize` handshake happened most recently. This hides concurrent +or recently active clients and makes client attribution harder when multiple MCP +clients are used. + +--- + +## 2. Scope + +### In Scope +- Extend metrics summaries to include per-client aggregates (name/version, + last seen, and initialize count). +- Keep existing `client_name`/`client_version` fields for compatibility. +- Update dashboard UI/JS to render one card per detected client. +- Add/adjust unit tests for metrics, shared metrics, and server API behavior. + +### Out of Scope +- Historical backfill from old logs. +- Authentication or transport changes. +- Session timeline redesign. + +--- + +## 3. Deliverables + +1. Backend metrics data model +- `src/mcpbridge_wrapper/webui/metrics.py` +- `src/mcpbridge_wrapper/webui/shared_metrics.py` +- Summary payload includes `clients` array for dashboard consumption. + +2. Web UI rendering updates +- `src/mcpbridge_wrapper/webui/static/index.html` +- `src/mcpbridge_wrapper/webui/static/dashboard.js` +- `src/mcpbridge_wrapper/webui/static/dashboard.css` + +3. Test coverage +- `tests/unit/webui/test_metrics.py` +- `tests/unit/webui/test_shared_metrics.py` +- `tests/unit/webui/test_server.py` + +4. Validation artifact +- `SPECS/INPROGRESS/FU-P12-T1-3_Validation_Report.md` + +--- + +## 4. Acceptance Criteria + +- [ ] Dashboard shows multiple clients simultaneously when more than one client connects. +- [ ] Existing single-client behavior remains correct when only one client is present. +- [ ] Client widgets update in real time with the same refresh cadence as other KPIs. +- [ ] `pytest` passes. +- [ ] `ruff check src/` passes. +- [ ] `mypy src/` passes. +- [ ] `pytest --cov` reports coverage >= 90%. + +--- + +## 5. Dependencies + +- P12-T1 ✅ + +--- + +## 6. Risks and Mitigations + +- **Risk:** Shared SQLite schema migration could break older DB files. + - **Mitigation:** Use additive table creation (`CREATE TABLE IF NOT EXISTS`) and + non-destructive upserts. +- **Risk:** UI changes regress KPI rendering. + - **Mitigation:** Keep existing KPI IDs used by JS and add focused rendering + helpers for new client cards. + +--- + +## 7. Validation Plan + +1. Add `clients` summary support in in-memory and shared metrics collectors. +2. Render per-client cards in dashboard from `summary.clients`. +3. Add tests for multi-client summary/API behavior. +4. Run required quality gates and document results. + +--- + +--- +**Archived:** 2026-02-18 +**Verdict:** PASS diff --git a/SPECS/ARCHIVE/FU-P12-T1-3_Show_multi-client_widgets_in_Web_UI_instead_of_single_overwritten_active_client/FU-P12-T1-3_Validation_Report.md b/SPECS/ARCHIVE/FU-P12-T1-3_Show_multi-client_widgets_in_Web_UI_instead_of_single_overwritten_active_client/FU-P12-T1-3_Validation_Report.md new file mode 100644 index 00000000..615b1591 --- /dev/null +++ b/SPECS/ARCHIVE/FU-P12-T1-3_Show_multi-client_widgets_in_Web_UI_instead_of_single_overwritten_active_client/FU-P12-T1-3_Validation_Report.md @@ -0,0 +1,46 @@ +# Validation Report — FU-P12-T1-3 + +**Task:** FU-P12-T1-3 — Show multi-client widgets in Web UI instead of single overwritten active client +**Date:** 2026-02-18 +**Verdict:** PASS + +## Scope + +- Added multi-client summary support in in-memory and shared SQLite metrics. +- Added dashboard rendering for one widget per detected client. +- Preserved existing `client_name` / `client_version` summary compatibility. +- Added unit coverage for multi-client summary behavior. + +## Files Changed + +- `src/mcpbridge_wrapper/webui/metrics.py` +- `src/mcpbridge_wrapper/webui/shared_metrics.py` +- `src/mcpbridge_wrapper/webui/static/index.html` +- `src/mcpbridge_wrapper/webui/static/dashboard.js` +- `src/mcpbridge_wrapper/webui/static/dashboard.css` +- `tests/unit/webui/test_metrics.py` +- `tests/unit/webui/test_shared_metrics.py` +- `tests/unit/webui/test_server.py` + +## Required Quality Gates + +- `pytest` + - Result: **PASS** (`585 passed, 5 skipped, 2 warnings`) +- `ruff check src/` + - Result: **PASS** (`All checks passed!`) +- `mypy src/` + - Result: **PASS** (`Success: no issues found in 18 source files`) +- `pytest --cov` + - Result: **PASS** (`585 passed, 5 skipped, 2 warnings`; total coverage **92.18%**, threshold 90%) + +## Acceptance Criteria Status + +- [x] Dashboard shows multiple clients simultaneously when more than one client connects. +- [x] Existing single-client behavior remains correct when only one client is present. +- [x] Client widgets update in real time with the same refresh cadence as other KPIs. +- [x] `pytest` suite remains green. + +## Notes + +- Existing third-party deprecation warnings from `websockets` / `uvicorn` were + observed during tests and are unrelated to this task. diff --git a/SPECS/ARCHIVE/INDEX.md b/SPECS/ARCHIVE/INDEX.md index 15b72698..8d853a58 100644 --- a/SPECS/ARCHIVE/INDEX.md +++ b/SPECS/ARCHIVE/INDEX.md @@ -1,6 +1,6 @@ # mcpbridge-wrapper Tasks Archive -**Last Updated:** 2026-02-18 (REVIEW_fu_p12_t1_2_stdin_capture_comment_archived) +**Last Updated:** 2026-02-18 (REVIEW_fu_p12_t1_3_multi_client_widgets_archived) ## Archived Tasks @@ -117,6 +117,7 @@ | FU-BUG-T7-1 | [FU-BUG-T7-1_Cap_pending_methods_map_to_guard_unbounded_growth/](FU-BUG-T7-1_Cap_pending_methods_map_to_guard_unbounded_growth/) | 2026-02-18 | PASS | | FU-P12-T1-1 | [FU-P12-T1-1_Remove_or_document_MCPInitializeParams_in_schemas/](FU-P12-T1-1_Remove_or_document_MCPInitializeParams_in_schemas/) | 2026-02-18 | PASS | | FU-P12-T1-2 | [FU-P12-T1-2_Add_code_comment_clarifying_stdin-only_client_capture_in_on_request/](FU-P12-T1-2_Add_code_comment_clarifying_stdin-only_client_capture_in_on_request/) | 2026-02-18 | PASS | +| FU-P12-T1-3 | [FU-P12-T1-3_Show_multi-client_widgets_in_Web_UI_instead_of_single_overwritten_active_client/](FU-P12-T1-3_Show_multi-client_widgets_in_Web_UI_instead_of_single_overwritten_active_client/) | 2026-02-18 | PASS | ## Historical Artifacts @@ -197,6 +198,7 @@ | [REVIEW_fu_bug_t7_1_pending_methods_cap.md](_Historical/REVIEW_fu_bug_t7_1_pending_methods_cap.md) | Review report for FU-BUG-T7-1 | | [REVIEW_FU-P12-T1-1_mcpinitializeparams.md](_Historical/REVIEW_FU-P12-T1-1_mcpinitializeparams.md) | Review report for FU-P12-T1-1 | | [REVIEW_FU-P12-T1-2_stdin_capture_comment.md](_Historical/REVIEW_FU-P12-T1-2_stdin_capture_comment.md) | Review report for FU-P12-T1-2 | +| [REVIEW_FU-P12-T1-3_multi_client_widgets.md](_Historical/REVIEW_FU-P12-T1-3_multi_client_widgets.md) | Review report for FU-P12-T1-3 | ## Archive Log @@ -348,3 +350,5 @@ | 2026-02-18 | FU-P12-T1-1 | Archived REVIEW_FU-P12-T1-1_mcpinitializeparams report | | 2026-02-18 | FU-P12-T1-2 | Archived Add_code_comment_clarifying_stdin-only_client_capture_in_on_request (PASS) | | 2026-02-18 | FU-P12-T1-2 | Archived REVIEW_FU-P12-T1-2_stdin_capture_comment report | +| 2026-02-18 | FU-P12-T1-3 | Archived Show_multi-client_widgets_in_Web_UI_instead_of_single_overwritten_active_client (PASS) | +| 2026-02-18 | FU-P12-T1-3 | Archived REVIEW_FU-P12-T1-3_multi_client_widgets report | diff --git a/SPECS/ARCHIVE/_Historical/REVIEW_FU-P12-T1-3_multi_client_widgets.md b/SPECS/ARCHIVE/_Historical/REVIEW_FU-P12-T1-3_multi_client_widgets.md new file mode 100644 index 00000000..9f59fbfe --- /dev/null +++ b/SPECS/ARCHIVE/_Historical/REVIEW_FU-P12-T1-3_multi_client_widgets.md @@ -0,0 +1,35 @@ +## REVIEW REPORT — FU-P12-T1-3 multi-client widgets + +**Scope:** origin/main..HEAD +**Files:** 13 + +### Summary Verdict +- [x] Approve +- [ ] Approve with comments +- [ ] Request changes +- [ ] Block + +### Critical Issues +- None. + +### Secondary Issues +- None. + +### Architectural Notes +- The summary contract remains backward compatible (`client_name`/`client_version` + preserved) while adding a `clients` array for richer UI rendering. +- Shared-metrics changes use additive schema evolution (`client_identities` table) + and maintain existing `client_info` behavior. +- Dashboard now presents per-client cards and updates through the same websocket + and polling paths used by existing KPIs. + +### Tests +- Full quality gates were executed during EXECUTE: + - `pytest` (585 passed, 5 skipped) + - `ruff check src/` (pass) + - `mypy src/` (pass) + - `pytest --cov` (92.18%, threshold 90%) + +### Next Steps +- No actionable findings. +- FOLLOW-UP step is skipped per FLOW rules. diff --git a/SPECS/INPROGRESS/REVIEW_FU-P12-T1-3_multi_client_widgets_v2.md b/SPECS/INPROGRESS/REVIEW_FU-P12-T1-3_multi_client_widgets_v2.md new file mode 100644 index 00000000..ad2754a2 --- /dev/null +++ b/SPECS/INPROGRESS/REVIEW_FU-P12-T1-3_multi_client_widgets_v2.md @@ -0,0 +1,47 @@ +## REVIEW REPORT — FU-P12-T1-3 multi-client widgets (v2) + +**Scope:** origin/main..HEAD +**Files:** 14 (8 implementation/test, 6 workflow artifacts) + +### Summary Verdict +- [ ] Approve +- [ ] Approve with comments +- [x] Request changes +- [ ] Block + +### Critical Issues + +- [High] **Unbounded `_clients` dict in `MetricsCollector` (metrics.py:83,103-113).** + The in-memory `_clients` dict grows without limit — every unique `(name, version)` pair adds an entry that is never evicted. In the `SharedMetricsStore` the same is true: the `client_identities` table has no pruning. In practice the cardinality is very low (handful of MCP clients), so this is unlikely to cause real problems, but it is inconsistent with the project's existing pattern of capping unbounded maps (see FU-BUG-T7-1 `pending_methods` cap). + **Suggestion:** Add a soft cap (e.g. 50 entries, evict oldest by `last_seen`) to `_clients` in `MetricsCollector`, and consider a `WHERE last_seen > ?` pruning clause for `client_identities` on write. + +### Secondary Issues + +- [Medium] **`innerHTML` string-building in `renderClientWidgets` (dashboard.js:225-235).** + The `name` and `version` fields are escaped via `escapeHtml`, which is correct. However `count` (an integer) and `lastSeen` (returned from `formatRelativeAge`) are interpolated without escaping. `count` is always a number so this is safe, but `lastSeen` passes through `escapeHtml` already — the asymmetry makes the pattern harder to audit. Consider escaping all interpolated values uniformly for consistency. + +- [Low] **Redundant `int()` / `float()` / `str()` casts in `get_summary` (metrics.py:247-254).** + The values stored in `_clients` are already typed correctly at insertion time (lines 106-110). The explicit `str(data["name"])`, `float(data["last_seen"])`, `int(data["initialize_count"])` casts in the summary builder are defensive but add noise. Same applies to the `int(existing["initialize_count"]) + 1` on line 113 — the value is always an `int`. Not wrong, but could be simplified. + +- [Low] **`client_identities` table has no index on `last_seen` (shared_metrics.py:84-92).** + The `ORDER BY last_seen DESC` query in `get_summary` (line 306) performs a full table scan. At expected cardinalities (<10 rows) this is negligible, but adding a covering index would be consistent with the indexing style used for `requests` and `param_patterns`. + +### Architectural Notes + +- The summary contract remains backward compatible: `client_name`/`client_version` are preserved alongside the new `clients` array. This is a clean additive evolution. +- The JS fallback path (lines 199-204) correctly synthesizes a single-element `clients` array from legacy `client_name`/`client_version` when the `clients` array is absent or empty. This ensures backward compatibility with older server versions. +- The `client_identities` SQLite table uses `ON CONFLICT ... DO UPDATE SET initialize_count = client_identities.initialize_count + 1`, which is atomic and correct for concurrent multi-process writes. +- CSS uses `auto-fit` grid with a reasonable `minmax(220px, 1fr)`, which scales well for 1-4 clients. + +### Tests + +- New tests cover: multi-client summary in `MetricsCollector`, `SharedMetricsStore`, and the `/api/metrics` endpoint. +- Tests verify: empty clients list, single client, multiple clients, increment of `initialize_count`, and reset behavior. +- Full quality gates passed during EXECUTE: 585 tests, 92.18% coverage, ruff + mypy clean. +- No test coverage gaps observed for the new code paths. + +### Next Steps + +- [Actionable] Cap `_clients` dict and prune `client_identities` table to prevent unbounded growth (High). +- [Optional] Add `last_seen` index to `client_identities` table (Low). +- [Optional] Uniform escaping in `renderClientWidgets` for consistency (Medium). diff --git a/SPECS/INPROGRESS/next.md b/SPECS/INPROGRESS/next.md index 32d2a809..143ee348 100644 --- a/SPECS/INPROGRESS/next.md +++ b/SPECS/INPROGRESS/next.md @@ -2,15 +2,15 @@ ## Recently Archived +- 2026-02-18 — FU-P12-T1-3: Show multi-client widgets in Web UI instead of single overwritten active client (PASS) - 2026-02-18 — FU-P12-T1-2: Add code comment clarifying stdin-only client capture in `on_request` (PASS) - 2026-02-18 — FU-P12-T1-1: Remove or document `MCPInitializeParams` in schemas (PASS) - 2026-02-18 — FU-BUG-T7-1: Cap `pending_methods` map to guard against unbounded growth (PASS) - 2026-02-18 — FU-P13-T2-2: Move PID file write to after successful upstream launch (PASS) - 2026-02-18 — FU-P13-T2-1: Replace run_forever() polling loop with asyncio.Event-based wait (PASS) -- 2026-02-18 — FU-P13-T4-2: Implement or remove reconnect parameter in BrokerProxy (PASS) ## Suggested Next Tasks - P13-T5 follow-up — Complete interactive prompt verification in a desktop session (P1) -- FU-P12-T1-3 — Show multi-client widgets in Web UI instead of single overwritten active client (P2) +- FU-P12-T1-4 — Make `IN FLIGHT` KPI reflect real in-flight requests in shared-metrics mode (P2) - FU-P12-T3-2 — Add `error_code` column to audit CSV export (P3) diff --git a/SPECS/Workplan.md b/SPECS/Workplan.md index cf1cb046..7ba0b258 100644 --- a/SPECS/Workplan.md +++ b/SPECS/Workplan.md @@ -2231,7 +2231,8 @@ Phase 9 Follow-up Backlog --- -#### FU-P12-T1-3: Show multi-client widgets in Web UI instead of single overwritten active client +#### ✅ FU-P12-T1-3: Show multi-client widgets in Web UI instead of single overwritten active client +- **Status:** ✅ Completed (2026-02-18) - **Description:** The dashboard currently displays one `ACTIVE CLIENT` value that is overwritten by the most recent `initialize` handshake. Add multi-client visibility so the UI can show one widget/card per detected client (e.g., Codex, Zed, Cursor) with useful metadata (last seen and/or call counts), rather than a single global value. - **Priority:** P2 - **Dependencies:** P12-T1 @@ -2242,9 +2243,40 @@ Phase 9 Follow-up Backlog - Updated `src/mcpbridge_wrapper/webui/static/index.html` and `src/mcpbridge_wrapper/webui/static/dashboard.js` to render one widget per client - Updated Web UI tests covering multi-client display behavior - **Acceptance Criteria:** - - [ ] Dashboard shows multiple clients simultaneously when more than one client connects - - [ ] Existing single-client behavior remains correct when only one client is present - - [ ] Client widgets update in real time with the same refresh cadence as other KPIs + - [x] Dashboard shows multiple clients simultaneously when more than one client connects + - [x] Existing single-client behavior remains correct when only one client is present + - [x] Client widgets update in real time with the same refresh cadence as other KPIs + - [x] `pytest` suite remains green + +--- + +#### FU-P12-T1-5: Cap `_clients` dict and prune `client_identities` to prevent unbounded growth +- **Description:** The in-memory `_clients` dict in `MetricsCollector` and the `client_identities` SQLite table in `SharedMetricsStore` grow without limit — every unique `(name, version)` pair adds an entry that is never evicted. Add a soft cap (e.g. 50 entries, evict oldest by `last_seen`) to `_clients`, and add a `WHERE last_seen > ?` pruning clause for `client_identities` on write. This aligns with the project pattern established by FU-BUG-T7-1 (`pending_methods` cap). +- **Priority:** P2 +- **Dependencies:** FU-P12-T1-3 +- **Parallelizable:** yes +- **Outputs/Artifacts:** + - Updated `src/mcpbridge_wrapper/webui/metrics.py` — soft cap on `_clients` dict + - Updated `src/mcpbridge_wrapper/webui/shared_metrics.py` — pruning old `client_identities` rows + - Updated tests covering eviction behavior +- **Acceptance Criteria:** + - [ ] `_clients` dict never exceeds the configured cap + - [ ] Stale `client_identities` rows are pruned on write + - [ ] Existing multi-client dashboard behavior is preserved + - [ ] `pytest` suite remains green + +--- + +#### FU-P12-T1-6: Uniform HTML escaping in `renderClientWidgets` +- **Description:** In `dashboard.js` `renderClientWidgets`, the `count` integer and `lastSeen` string are interpolated into innerHTML without `escapeHtml()`, while `name` and `version` are escaped. Although `count` is always a number and `lastSeen` already passes through `escapeHtml` inside `formatRelativeAge`, the asymmetric pattern makes security auditing harder. Apply `escapeHtml()` uniformly to all interpolated values for consistency. +- **Priority:** P3 +- **Dependencies:** FU-P12-T1-3 +- **Parallelizable:** yes +- **Outputs/Artifacts:** + - Updated `src/mcpbridge_wrapper/webui/static/dashboard.js` — uniform escaping in `renderClientWidgets` +- **Acceptance Criteria:** + - [ ] All interpolated values in `renderClientWidgets` are passed through `escapeHtml()` + - [ ] No visual regression in client widget rendering - [ ] `pytest` suite remains green --- diff --git a/src/mcpbridge_wrapper/webui/metrics.py b/src/mcpbridge_wrapper/webui/metrics.py index b125ec17..35464431 100644 --- a/src/mcpbridge_wrapper/webui/metrics.py +++ b/src/mcpbridge_wrapper/webui/metrics.py @@ -80,6 +80,7 @@ def __init__(self, window_seconds: int = 3600, max_datapoints: int = 3600) -> No # MCP client identification self._client_name: str = "unknown" self._client_version: str = "unknown" + self._clients: Dict[Tuple[str, str], Dict[str, Any]] = {} # Error breakdown by code self._error_counts_by_code: Dict[int, int] = {} @@ -95,8 +96,21 @@ def set_client_info(self, name: str, version: str) -> None: version: Client version string (e.g. "1.2.3"). """ with self._lock: + now = time.time() self._client_name = name self._client_version = version + key = (name, version) + existing = self._clients.get(key) + if existing is None: + self._clients[key] = { + "name": name, + "version": version, + "last_seen": now, + "initialize_count": 1, + } + else: + existing["last_seen"] = now + existing["initialize_count"] = existing["initialize_count"] + 1 def record_request(self, tool_name: str, request_id: Optional[str] = None) -> None: """Record an incoming request for a tool. @@ -228,6 +242,18 @@ def get_summary(self) -> Dict[str, Any]: "count": n, } + clients: List[Dict[str, Any]] = [] + for data in self._clients.values(): + clients.append( + { + "name": data["name"], + "version": data["version"], + "last_seen": data["last_seen"], + "initialize_count": data["initialize_count"], + } + ) + clients.sort(key=lambda item: item["last_seen"], reverse=True) + return { "uptime_seconds": round(uptime, 1), "total_requests": self._total_requests, @@ -240,6 +266,7 @@ def get_summary(self) -> Dict[str, Any]: "in_flight": len(self._in_flight), "client_name": self._client_name, "client_version": self._client_version, + "clients": clients, "error_counts_by_code": dict(self._error_counts_by_code), } @@ -313,5 +340,6 @@ def reset(self) -> None: self._in_flight.clear() self._client_name = "unknown" self._client_version = "unknown" + self._clients.clear() self._error_counts_by_code.clear() self._param_patterns.clear() diff --git a/src/mcpbridge_wrapper/webui/shared_metrics.py b/src/mcpbridge_wrapper/webui/shared_metrics.py index 6567f405..ba8b2a90 100644 --- a/src/mcpbridge_wrapper/webui/shared_metrics.py +++ b/src/mcpbridge_wrapper/webui/shared_metrics.py @@ -81,6 +81,19 @@ def _ensure_db(self) -> None: updated_at REAL ) """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS client_identities ( + client_name TEXT NOT NULL, + client_version TEXT NOT NULL, + last_seen REAL NOT NULL, + initialize_count INTEGER NOT NULL DEFAULT 1, + PRIMARY KEY (client_name, client_version) + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_client_identities_last_seen + ON client_identities(last_seen) + """) # Param patterns table: stores frequency of argument key combinations per tool conn.execute(""" CREATE TABLE IF NOT EXISTS param_patterns ( @@ -190,6 +203,7 @@ def set_client_info(self, name: str, version: str) -> None: version: Client version string (e.g. "1.2.3"). """ with self._transaction() as conn: + now = time.time() conn.execute( """INSERT INTO client_info (id, client_name, client_version, updated_at) VALUES (1, ?, ?, ?) @@ -197,7 +211,16 @@ def set_client_info(self, name: str, version: str) -> None: client_name=excluded.client_name, client_version=excluded.client_version, updated_at=excluded.updated_at""", - (name, version, time.time()), + (name, version, now), + ) + conn.execute( + """INSERT INTO client_identities + (client_name, client_version, last_seen, initialize_count) + VALUES (?, ?, ?, 1) + ON CONFLICT(client_name, client_version) DO UPDATE SET + last_seen=excluded.last_seen, + initialize_count=client_identities.initialize_count + 1""", + (name, version, now), ) def get_summary(self, window_seconds: int = 3600) -> Dict[str, Any]: @@ -276,6 +299,19 @@ def get_summary(self, window_seconds: int = 3600) -> Dict[str, Any]: ).fetchone() client_name = (client_row["client_name"] if client_row else None) or "unknown" client_version = (client_row["client_version"] if client_row else None) or "unknown" + clients = [ + { + "name": row["client_name"], + "version": row["client_version"], + "last_seen": row["last_seen"], + "initialize_count": row["initialize_count"], + } + for row in conn.execute( + """SELECT client_name, client_version, last_seen, initialize_count + FROM client_identities + ORDER BY last_seen DESC""" + ) + ] return { "uptime_seconds": round(time.time() - self._start_time, 1), @@ -289,6 +325,7 @@ def get_summary(self, window_seconds: int = 3600) -> Dict[str, Any]: "in_flight": 0, # Can't track across processes easily "client_name": client_name, "client_version": client_version, + "clients": clients, "error_counts_by_code": error_counts_by_code, } @@ -412,6 +449,7 @@ def reset(self) -> None: with self._transaction() as conn: conn.execute("DELETE FROM requests") conn.execute("DELETE FROM client_info") + conn.execute("DELETE FROM client_identities") conn.execute("DELETE FROM param_patterns") def close(self) -> None: diff --git a/src/mcpbridge_wrapper/webui/static/dashboard.css b/src/mcpbridge_wrapper/webui/static/dashboard.css index 293cb3b9..b200472d 100644 --- a/src/mcpbridge_wrapper/webui/static/dashboard.css +++ b/src/mcpbridge_wrapper/webui/static/dashboard.css @@ -122,6 +122,43 @@ main { font-variant-numeric: tabular-nums; } +.client-widgets-section { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 16px; + margin-bottom: 24px; +} + +.client-widgets-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} + +.client-widget-card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + padding: 12px; +} + +.client-widget-title { + font-size: 0.95rem; + font-weight: 600; + margin-bottom: 4px; +} + +.client-widget-meta { + color: var(--text-secondary); + font-size: 0.8rem; +} + +.clients-empty { + color: var(--text-secondary); + font-size: 0.9rem; +} + /* Charts */ .charts-row { display: grid; diff --git a/src/mcpbridge_wrapper/webui/static/dashboard.js b/src/mcpbridge_wrapper/webui/static/dashboard.js index 9d76d386..b8347ba3 100644 --- a/src/mcpbridge_wrapper/webui/static/dashboard.js +++ b/src/mcpbridge_wrapper/webui/static/dashboard.js @@ -195,9 +195,44 @@ el("kpi-error-rate").textContent = (summary.error_rate * 100).toFixed(2) + "%"; el("kpi-total-errors").textContent = summary.total_errors.toLocaleString(); el("kpi-in-flight").textContent = summary.in_flight; - var clientName = summary.client_name || "unknown"; - var clientVersion = summary.client_version || "unknown"; - el("kpi-client").textContent = clientName === "unknown" ? "unknown" : clientName + " " + clientVersion; + var clients = Array.isArray(summary.clients) ? summary.clients.slice() : []; + if (!clients.length && summary.client_name && summary.client_name !== "unknown") { + clients = [{ + name: summary.client_name, + version: summary.client_version || "unknown", + initialize_count: 1, + }]; + } + renderClientWidgets(clients); + } + + function formatRelativeAge(epochSeconds) { + if (typeof epochSeconds !== "number" || !isFinite(epochSeconds)) return "unknown"; + var age = Math.max(0, Math.round((Date.now() / 1000) - epochSeconds)); + if (age < 60) return age + "s ago"; + if (age < 3600) return Math.floor(age / 60) + "m ago"; + return Math.floor(age / 3600) + "h ago"; + } + + function renderClientWidgets(clients) { + var container = el("client-widgets-grid"); + if (!container) return; + if (!clients || !clients.length) { + container.innerHTML = '

No clients detected yet.

'; + return; + } + + container.innerHTML = clients.map(function (client) { + var name = client.name || "unknown"; + var version = client.version || "unknown"; + var count = client.initialize_count || 0; + var lastSeen = formatRelativeAge(client.last_seen); + return "
" + + "
" + escapeHtml(name) + " " + escapeHtml(version) + "
" + + "
Initialize calls: " + count + "
" + + "
Last seen: " + escapeHtml(lastSeen) + "
" + + "
"; + }).join(""); } function updateToolCharts(toolCounts) { diff --git a/src/mcpbridge_wrapper/webui/static/index.html b/src/mcpbridge_wrapper/webui/static/index.html index f3b4e2f7..20bcae0c 100644 --- a/src/mcpbridge_wrapper/webui/static/index.html +++ b/src/mcpbridge_wrapper/webui/static/index.html @@ -44,9 +44,14 @@

XcodeMCPWrapper Dashboard

In Flight
0
-
-
Active Client
-
--
+ + +
+
+

Detected Clients

+
+
+

No clients detected yet.

diff --git a/tests/unit/webui/test_metrics.py b/tests/unit/webui/test_metrics.py index 9a96d260..ac7b5fc4 100644 --- a/tests/unit/webui/test_metrics.py +++ b/tests/unit/webui/test_metrics.py @@ -209,6 +209,7 @@ def test_initial_client_info_unknown(self): summary = metrics.get_summary() assert summary["client_name"] == "unknown" assert summary["client_version"] == "unknown" + assert summary["clients"] == [] def test_set_client_info(self): """Test setting client info is reflected in summary.""" @@ -217,15 +218,29 @@ def test_set_client_info(self): summary = metrics.get_summary() assert summary["client_name"] == "Cursor" assert summary["client_version"] == "1.2.3" + assert len(summary["clients"]) == 1 + assert summary["clients"][0]["name"] == "Cursor" + assert summary["clients"][0]["version"] == "1.2.3" + assert summary["clients"][0]["initialize_count"] == 1 def test_set_client_info_overwrite(self): - """Test that set_client_info overwrites previous values.""" + """Test that latest client remains current while history is preserved.""" metrics = MetricsCollector() metrics.set_client_info("Cursor", "1.0.0") metrics.set_client_info("Claude", "2.0.0") summary = metrics.get_summary() assert summary["client_name"] == "Claude" assert summary["client_version"] == "2.0.0" + assert len(summary["clients"]) == 2 + + def test_set_client_info_increments_initialize_count(self): + """Repeated initialize handshakes for same client increment count.""" + metrics = MetricsCollector() + metrics.set_client_info("Cursor", "1.2.3") + metrics.set_client_info("Cursor", "1.2.3") + summary = metrics.get_summary() + assert len(summary["clients"]) == 1 + assert summary["clients"][0]["initialize_count"] == 2 def test_reset_clears_client_info(self): """Test that reset() clears client info back to 'unknown'.""" @@ -235,6 +250,7 @@ def test_reset_clears_client_info(self): summary = metrics.get_summary() assert summary["client_name"] == "unknown" assert summary["client_version"] == "unknown" + assert summary["clients"] == [] def test_error_counts_by_code_in_summary(self): """Test that error_counts_by_code appears in summary.""" diff --git a/tests/unit/webui/test_server.py b/tests/unit/webui/test_server.py index a2b135ab..b327f053 100644 --- a/tests/unit/webui/test_server.py +++ b/tests/unit/webui/test_server.py @@ -58,6 +58,18 @@ def test_get_metrics(self, client, metrics): data = response.json() assert data["total_requests"] == 1 assert data["tool_counts"]["XcodeRead"] == 1 + assert "clients" in data + + def test_get_metrics_includes_multi_client_summary(self, client, metrics): + """Metrics response includes all seen clients for dashboard widgets.""" + metrics.set_client_info("Cursor", "1.0.0") + metrics.set_client_info("Claude", "2.0.0") + response = client.get("/api/metrics") + assert response.status_code == 200 + data = response.json() + assert len(data["clients"]) == 2 + names = {entry["name"] for entry in data["clients"]} + assert names == {"Cursor", "Claude"} def test_get_timeseries(self, client, metrics): """Test getting timeseries data.""" diff --git a/tests/unit/webui/test_shared_metrics.py b/tests/unit/webui/test_shared_metrics.py index b66e743d..6c53b003 100644 --- a/tests/unit/webui/test_shared_metrics.py +++ b/tests/unit/webui/test_shared_metrics.py @@ -190,6 +190,7 @@ def test_initial_client_info_unknown(self, store): summary = store.get_summary() assert summary["client_name"] == "unknown" assert summary["client_version"] == "unknown" + assert summary["clients"] == [] def test_set_client_info(self, store): """Test that set_client_info stores and retrieves client identity.""" @@ -197,14 +198,27 @@ def test_set_client_info(self, store): summary = store.get_summary() assert summary["client_name"] == "Cursor" assert summary["client_version"] == "1.2.3" + assert len(summary["clients"]) == 1 + assert summary["clients"][0]["name"] == "Cursor" + assert summary["clients"][0]["version"] == "1.2.3" + assert summary["clients"][0]["initialize_count"] == 1 def test_set_client_info_upsert(self, store): - """Test that set_client_info upserts (overwrites) on repeated calls.""" + """Test latest-client overwrite while retaining multi-client history.""" store.set_client_info("Cursor", "1.0.0") store.set_client_info("Claude", "3.5.0") summary = store.get_summary() assert summary["client_name"] == "Claude" assert summary["client_version"] == "3.5.0" + assert len(summary["clients"]) == 2 + + def test_set_client_info_same_client_increments_count(self, store): + """Repeated initialize from same client increments initialize_count.""" + store.set_client_info("Cursor", "1.0.0") + store.set_client_info("Cursor", "1.0.0") + summary = store.get_summary() + assert len(summary["clients"]) == 1 + assert summary["clients"][0]["initialize_count"] == 2 def test_reset_clears_client_info(self, store): """Test that reset() clears client info back to 'unknown'.""" @@ -213,6 +227,7 @@ def test_reset_clears_client_info(self, store): summary = store.get_summary() assert summary["client_name"] == "unknown" assert summary["client_version"] == "unknown" + assert summary["clients"] == [] def test_error_counts_by_code_empty_by_default(self, store): """Test that error_counts_by_code is empty when no errors recorded."""