docs/qa/ux-audit-webui-2026-08.md
Scope: every routed view of the personal-edition Web UI (frontend/src/views/), in light and dark
themes, in first-run (empty) and populated states, plus reachable error states.
Build audited: v0.60.0, main @ 20a3e50 (make build, embedded frontend).
Method: two isolated cores (127.0.0.1:18412 populated, 127.0.0.1:18413 first-run) on scratch
--data-dir and --config; Playwright/Chromium 1440×900 (plus 820px and 390px passes); ~90
screenshots; programmatic WCAG contrast sampling (canvas-resolved computed colors); MCP traffic
generated over the real /mcp endpoint (reads, writes, destructive calls, failures, a sensitive-data
hit, repeated identical calls).
Screenshots are not committed — each finding names the state to reproduce.
Status — this is the audit record, not an open backlog. Everything below describes v0.60.0 @
20a3e50and is written in the present tense as audited. Eight PRs merged on 2026-08-25 (#1044, #1048, #1049, #1050, #1051, #1052, #1053, #1054) close the backlog. § Resolution at the end records what a live re-check on v0.61.0 actually confirmed, what it could not reach, and five defects found onmainthat are not in this audit. Re-verify against currentmainbefore re-filing anything from here.
Standard: Nielsen's 10, information scent, empty/error/loading-state completeness, WCAG 2.1 AA contrast and keyboard operation, responsive behaviour, cross-view data consistency.
House rules (from Algis's standing activity/tray UX preferences — treated as normative here):
| # | Rule |
|---|---|
| H1 | Group consecutive same-tool calls into runs |
| H2 | Surface metadata.intent.reason as the user-facing context line |
| H3 | Mark only failures — no success checkmarks |
| H4 | Activity belongs at the top level |
| H5 | Show recently-seen clients as idle, never an empty "no clients" |
| H6 | Native forms over deep links |
| H7 | Never merge ok + error into one run; split runs on status change |
| H8 | Summary/histogram must visibly match the call log |
| H9 | code_execution sub-calls are first-class, with parent ↔ child navigation |
The Web UI is in good shape structurally — the sidebar's Workspace / Observability / System grouping
gives real information scent, the server-detail error card (code + plain-language cause + copyable
remediation + docs link) is best-in-class, and the Agent Tokens empty state is a model for the rest of
the product. Recent work has already landed the hard parts of H2 and H9: the Activity view does
render intent.reason and does carry parent_id linkage for code_execution sub-calls.
Three problems dominate, and all three sit directly under the epics this audit gates.
1. The numbers disagree with each other. Within seconds of each other, on the same 24-hour window,
the Activity Log header reported 70 calls · 6 errors while the Dashboard's Usage tab reported
42 tool calls · 6 errors · 14.3% error rate, and the Activity filter panel's own tiles read
Total 42 / Success 15 / Errors 4 — which do not sum. Per-call latency is 3–5 ms in the Activity
table and a flat 10 ms p50/p95 for every tool in Usage. This is H8 at product scale: it is the fastest
way to lose a user's trust in an observability surface, and action-log-transparency cannot be built
on top of it. (F1, F2, F22)
2. The default landing page answers no question. The Dashboard is a static hub diagram with
animated dots. The only real analytics — Calls per tool, Token sinks, Errors & latency, Activity over
time — live in a v-show tab that has no route, no sidebar entry, no deep link, and no bookmark. The
hero metric next to it ("32.4% tokens saved / 519 saved") did not move after 16 further tool calls.
This is exactly the gap analytics-default-landing exists to close. (F4, F23)
3. First run leaks users at the empty state. Given that ~48% of installs are one-and-done, the Servers page — the page a new user lands on after closing the wizard — shows "No servers found / No servers available" with no call to action at all, while the Tools and Agent Tokens pages in the same build have exemplary empty states with buttons. The Secrets page goes further: with zero secrets there is no way to add one from the Web UI at all. (F3, F8)
Alongside those: the Add Server modal's submit button sits 594px below the viewport and Escape does not close it (F6); the security-critical "Docker isolation disabled" warning is the least legible text on the Dashboard at 1.28:1 (F9); the sensitive-data drawer prints the AWS key it just flagged in cleartext (F13); and at 390px the telemetry banner renders as a ~10-character-wide column ~440px tall (F14).
Counts: 4 P0 · 12 P1 · 12 P2 · 8 P3 (36 findings).
Suggested first slice (all S/M, unblocks the three downstream epics):
F1 + F2 (one shared 24h aggregate behind both surfaces) → F4 + F23 (route /usage, put it in the
sidebar, consider it the default landing) → F3 + F8 (empty-state CTAs) → F5 + F7 (grouping + failure-only
status) → F9 (contrast tokens).
Activity.vue header; Dashboard.vue → Usage tab (Usage.vue)70 calls · 6 errors.
Usage 24h tiles: Tool calls 42, Errors 6, 14.3% overall error rate. Activity's own paginator
agreed with 70 (Showing 1-25 of 70); Usage's "Activity over time" hour buckets summed to ~42.
Root cause is visible in the data: Activity counts every type (including security_scan and
system_start) as a "call"; Usage counts only tool_call.70 events · 6 errors and add a 42 tool calls sub-count.Activity.vue → FiltersTotal (24h) 42 · Success 15 · Errors 4 · Blocked 0 · Rejected 0.
15 + 4 + 0 + 0 = 19, not 42. Simultaneously the header above said 42 calls. On a later pass the
header said 70 calls · 6 errors while the tiles still used a different denominator.42 tool calls in the last 24h);
add an Other / internal bucket for whatever is currently unaccounted. Add a unit test asserting the
buckets sum to the total.Servers.vue, first-run instance, zero serversTools.vue empty state offers Manage Servers
and AgentTokens.vue offers Create Your First Token with a one-line explanation. Above the empty
state sit four zero-valued stat tiles and four zero-count filter pills (All (0) Connected (0) Enabled (0) Quarantined (0)) plus a "Search servers…" box with nothing to search.Dashboard.vue (Overview) / Usage.vue32.4% tokens saved chip.
All real analytics live under the Usage tab, which is v-show-toggled inside Dashboard — Usage.vue
has no entry in router/index.ts and no sidebar item, so it cannot be linked, bookmarked, or
returned to after a reload./usage) and a sidebar entry under Observability; make the
Dashboard's default panel a compact answer to "what happened and what needs me" (24h calls/errors
sparkline, top tools, servers needing attention, recent failures) with the hub diagram demoted.Activity.vue tableeverything:echo calls with an identical reason produced 12 separate
rows, each repeating the same 3-line absolute timestamp, the same 📖 Polling the echo endpoint while the user …, and the word Success. On one 900px screen: 11 × "Success", 1 × "Error". Real logs are
far worse — Algis's own export was 1479 calls → 809 runs, with runs up to 100×.echo ×12, expandable),
splitting on status change so a run never mixes ok and error. Show a status chip only for
failed/blocked/rejected rows; leave successes blank.AddServerModal.vue from Servers.vue / header + Add ServerscrollHeight 1494 vs clientHeight 900 — 594px of the
form, including the submit/cancel row, is below the fold with no sticky footer and no visible close
affordance in the header. After opening, document.activeElement was still the triggering
button.btn.btn-primary; pressing Escape left .modal-box in the DOM.Servers.vue card for a quarantined server; ServerCard.vueQuarantined 1 — Need security review.title on the disabled Scan explaining why.Secrets.vueAddSecretModal
exists, but showAddSecretModal() is only reachable from a per-row Set button that renders when a
secret is referenced in config but not yet set (Secrets.vue:138). With zero rows, the page's stated
purpose ("Manage secrets stored in your system's secure keyring") is unreachable.View: Dashboard hub chips; ServerDetail.vue error card; global primary buttons
Evidence (canvas-resolved computed colors, effective ratio incl. opacity; AA needs 4.5:1 for normal text):
| Text | Theme | Ratio |
|---|---|---|
Docker isolation disabled — enable Docker to protect your system | corporate | 1.28 |
dig <hostname> remediation box | dark | 1.07 |
https://docs.mcpproxy.app/errors/MCPX_HTTP_DNS_FAILED | corporate / dark | 1.41 / 1.63 |
Manage in Settings (telemetry banner) | dark | 2.07 |
Quarantine protection active | corporate | 2.69 |
4 errors (Activity header) | corporate | 2.92 |
32.4% tokens saved | corporate / dark | 3.37 / 3.60 |
Host not found badge | dark | 3.67 |
Add Server, Connect Clients, Restart (white on primary) | both | 4.13 |
The 4.13 figure is systemic: every filled primary button in the app fails AA by the same margin.
Heuristic: WCAG 2.1 AA (1.4.3).
Fix: Darken the primary token one step (4.13 → ≥4.5 buys AA on every button at once); give warning and error surfaces AA-checked on-color foregrounds instead of tinted text on tinted fill; add a contrast assertion to the Playwright sweep so this cannot regress.
Effort: M
Dashboard.vue AI Agents box vs Sessions.vueAvailable: Claude Code, Claude Desktop, Cursor, VS Code, Codex CLI, Gemini CLI, OpenCode and no
Connected group, while /sessions listed ux-audit-client v1.0 — Active — 13 tool calls — Last active 11m ago./sessions; show recently-seen clients as
idle with a last-seen time rather than omitting them, and make each name link to its session.Dashboard.vue attention banner; Servers.vue card; ServerDetail.vue headerbroken-remote (dial tcp: lookup example.invalid: no such host) the offered
action is Restart on both the Dashboard banner and the card. On the detail page one row of tiles
reads Status: Enabled / Active with a green check, Connection: Offline, and the page badge
reads Disconnected — three vocabularies, one of them green, for a server that is down.health.action for DNS/URL failures to Edit URL (opens the config tab focused on the
URL field). Collapse Enabled/Active, Online/Offline and Connected/Disconnected into one
admin-state + one health-level vocabulary, and never render a success glyph on an unhealthy server.Servers.vue cardHost not found (good) and a full-width red block:
failed to connect: MCP initialize failed during no-auth strategy: transport error: failed to send request: failed to send request: Post "https://example.invalid/mcp": dial tcp: lookup example.invalid: no such host — note the duplicated failed to send request:. The detail page already renders this
correctly as code + summary + Cause + remediation.Activity.vue detail drawercritical · aws_access_key · in arguments · in response renders, a few
hundred pixels below the red detection panel, "message": "AKIAIOSFODNN7EXAMPLE aws key here" in
Request Arguments and "Echo: AKIAIOSFODNN7EXAMPLE aws key here" in Response Body, both in cleartext
with a Copy button. The same panel also exposes the internal field _auth_auth_type: "admin".AKIA…MPLE) with an explicit Reveal that is logged;
strip _auth_* internals from the user-facing argument view.Host not found onto 3 lines and the View All Servers label onto 3 lines. The Activity table shows
only Time and Type; Server, Details, Sensitive, Intent, Status and Duration are clipped off
the right with no horizontal-scroll affordance, so failures are invisible on a phone. The footer
overlaps the last table row.min-w-0 / flex-1 on the banner's text child; below md, render Activity as stacked cards
(time + tool + status + reason) instead of a table; make the footer static, not overlaying.Security.vueScanners Installed 0 · Total Scans 0 · Active Scans 0 · Findings 0 above a
"Loading security data…" spinner, with Scan All Servers greyed and Refreshing… spinning; the
Deep-scan card read "Checking configuration…". The same page, once loaded, reads
1 · 2 · 0 · 0 · Signatures 7. The stat row sits outside the v-if="loading && !initialized" guard
(Security.vue:222), so overview = {} renders as real zeros. Warm reloads settle in ~130 ms; the
cold first paint is the one users see.initialized; never render a numeral for
an unknown value.enable_code_execution does not take effectSettings.vue → Advanced → Enable code execution toolsettings/fields.ts:191 declares the field with no restart flag.
PATCH /api/v1/config {"enable_code_execution": true} (the same call api.patchConfig makes) returned
applied_immediately: true, requires_restart: false, and GET /api/v1/config then reported true —
yet the code_execution tool still answered "Code execution is disabled", including from a
brand-new MCP session. (The PATCH response also mislabelled changed_fields as ["mcpServers"].)restart: true so the badge
appears; make changed_fields report the field actually changed.Repositories.vueConnectModal.vueConfig not found with no action, and one offering Review & connect + Check access.
The footer's primary action is Connect All while every actionable row already says Disconnect. More
seriously, an instance on :18412 reported those clients as connected because their configs contain a
mcpproxy entry — pointing at a different endpoint (:8080). The modal never shows which endpoint a
client is registered to.remote-access-tunnel, where "which endpoint am I connected to" is the question.OnboardingWizard.vue, first-run▸ Runtime isolation and MCP server quarantine
disclosure. The server list is clipped mid-row with no scrollbar affordance, and there is no Back./search is an orphan and duplicates two other search surfacesSearch.vue/search but absent from the sidebar. ?q=echo does not prefill — the page
renders its empty state regardless. Its layout is marketing-style (centered "Powerful Tool Search"
with three feature cards: Natural Language / Relevance Scoring / Cross-Server) unlike every other view.
Meanwhile the header has a Search tools box and a second greyed Search button, and Tools.vue
has its own search + four filter dropdowns.Tools.vue (or make /search the canonical page, add it
to the sidebar, honour ?q=, and drop the marketing panel). Remove the duplicate header button or make
its enabled state obvious.ServerDetail.vue → Logsmessage field contains a second,
already-formatted log line with escaped quotes:
"message": "2026-08-25T06:49:51.879+03:00 | INFO | core/connection_lifecycle.go:235 | Disconnecting from server | {\"server\": \"everything\", \"was_connec… — clipped at the right edge, no wrap, no
horizontal scrollbar. Two different timestamps per entry (read time and event time). Controls are only
Last 100 lines and Refresh — no level filter, no text filter, no follow/tail.time · level · message) with the structured fields behind a
disclosure; unescape the inner payload; add level + text filters and a follow toggle.Usage.vueeverything:doesnotexist
and broken-remote:whatever — names from failed calls to tools that never existed — as first-class
tools with 100.0% error rates. Every row of the p50/p95 table reads exactly 10 ms, while the Activity
table shows 3 ms / 4 ms / 5 ms for those same calls. The Errors & latency bar chart has no axis title
distinguishing error-rate bars from latency.Dashboard.vue chip; Usage.vue tile32.4% tokens saved · 519 saved read identically before and after 16 additional tool
calls, with the panel stamped Updated 21s ago. The chip has no tooltip and no drill-down.Activity.vue header1 call and the single row is
🚀 System Start. Populated: 70 calls includes two security_scan rows and a system_start row.tool_call for "calls" and label the rest as events (see F1).Activity.vue drawer for a security_scan row/security/scans/:jobId exists. On tool-call rows
the drawer shows a Session ID as plain text with no link to /sessions, even though Sessions links the
other way with View Activity.security_scan → its report and Session ID → /sessions.Activity.vue table☢ 1 badge. No legend, no visible key, and colour+emoji is the only encoding.read / write / destructive), or a legend in the filter panel.
(The sr-only label is already present — surface it visually.)Servers.vue stat rowTotal Servers 4 (3 enabled) · Connected 2 (50% online) · Quarantined 1 (Need security review). 50% is 2-of-4 (all servers) rather than 2-of-3 (enabled). The one server that is both disabled
and quarantined is counted in the Dashboard's right rail as both 1 disabled and 1 in quarantine,
reading as two problems.2 of 3 enabled online); make quarantine and disabled mutually
exclusive in the summary, or label the overlap.AuthErrorModal.vuemcpproxy status/config-file path alongside the tray instruction.SidebarNav.vue footer → Theme; stores/system.ts:28-44, 350loadTheme() falls back to corporate (light), so a user on a dark OS gets a light UI on first run
and must find the footer dropdown.prefers-color-scheme); group the rest under a
"More themes" section.Servers.vue, Activity.vue/servers have no accessible name (no aria-label, no placeholder,
no associated <label>, not wrapped in one). /activity auto-refreshes a live table with no
aria-live region (0 found) and no <caption>, so a screen-reader user gets no announcement when
rows change. Escape does not close modals (F6).aria-live="polite"; add a table caption.
Add these three as assertions to the committed Playwright sweep.Mode: Retrieve and the per-card Manual badge appear with no tooltip. Fix: add title text
(Retrieve = agents search for tools first; Manual = added by you, not imported). Effort: S
The Search button next to the header box renders greyed until text is typed, reading as broken next to an empty box. Fix: keep it enabled and no-op on empty, or merge it into the input as an icon button. Effort: S
/ui prefix returns a raw Go 404http://127.0.0.1:18412/activity returns a plain-text 404 page not found instead of the styled
NotFound.vue, while /ui/activity works. Fix: redirect unknown non-API paths to /ui/<path>.
Effort: S
0/100 appears twice (top-right and in the body), plus a bare green dot with 0 beside it.
Scan IDs are truncated to scan-eve with no copy affordance. Fix: show the score once; label or
drop the dot; make the ID copyable. Effort: S
The Activity table prints 8/25/2026, 6:51:38 AM (US) while the From/To filters use native
dd/mm/yyyy, --:-- inputs. Fix: one locale source of truth; consider relative-only in the table with
absolute on hover. Effort: S
MCPPROXY active / just started on the Dashboard reads as a stuck state; View Activity in Sessions
clips its label onto two cut-off lines; the Tools table's Approval column showed approved on all 22
rows (zero variance, wasted width) while descriptions truncate with no tooltip; the collapsed sidebar is
icon-only, relying on hover title alone to disambiguate six similar glyphs; the telemetry banner takes
the top of the Dashboard on every visit until dismissed. Effort: S each
The backlog was closed by eight PRs merged on 2026-08-25:
| PR | Findings it names |
|---|---|
#1044 feat(webui): make the analytics dashboard the default landing page | F4 |
#1048 fix(activity): count calls once, so Activity and Usage agree | F1, F24 |
#1049 fix(webui): first-run call to action … | F3, F4, F19, F36 |
#1050 fix(webui): forms & modals UX audit | F6, F8, F16, F36 |
#1051 fix(ui): mask flagged secrets server-side … | F13, F15, F20, F31, F32, F33, F36 |
#1052 fix(webui): make the Activity and Usage numbers say what they mean | F2, F5, F22, F23, F25, F26, F27, F36 |
#1053 fix(webui): honest server, connection and connect-modal states | F7 and F12 (verified live, below); by its diff and test names also F10 (live-clients.spec.ts), F11 (internal/health/calculator.go, ConnectionStatus.vue), F17 (repositories-card-selector.spec.ts), F18 (connect-modal.spec.ts, connect-endpoint-truth.spec.ts) and F28 (auth-single-surface.spec.ts). Its body text names F1–F16 — that is a mis-paste of the tray audit's numbering; the tray PR is #1055 and #1053 touches no Swift file. F34's coverage is not evidenced either way. |
#1054 fix(webui): AA contrast, system theme, mobile layout and a11y gaps | F9, F14, F21, F29, F30, F32, F35, F36 |
Confirmed fixed against a running core (isolated instance, scratch --config and --data-dir, real
/mcp traffic — reads, writes, destructive, failures, a sensitive-data hit, repeated identical calls):
22 calls · 3 errors · 1 blocked reconciles with the Usage tiles Calls 22 / Errors 4; Usage folds blocked into errors, Activity splits it, and both match
GET /api/v1/activity/usage (total_calls: 22, total_errors: 4). The 70-vs-42 split is gone./ui/ opens the Usage analytics panel; /ui/overview and /ui/usage are real routes with
their own titles.Group repeats control folds runs (echo ×5, 2ms–5ms) and the footer reads
1-25 of 56 (63 rows, repeats folded). No visible Success badge: only Error and Blocked are
marked, and the word "Success" is present as an sr-only label, which is H3-compliant.Technical details disclosure under a plain-language summary.AKIA…**** and states that full values come only
from mcpproxy activity export --include-bodies./ui/search redirects to /ui/tools.events from calls./servers (no /ui prefix) redirects to /ui/servers; no raw Go 404.F6 deserves a note, because re-checking it produced a false negative first. Escape sent through the
browser-automation layer appeared not to close the Add Server dialog; it never reached the page's
document keydown listener. Dispatching the same event in-page
(document.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'}))) closes the dialog immediately.
F6 is fixed in full by #1050:
frontend/src/composables/useModalA11y.ts supplies Escape-to-close, initial focus into the dialog, a Tab
trap and focus restoration for the five <dialog :open> modals, which get none of that from the browser
because they are opened by the open attribute rather than showModal(). Verified live: opening Add
Server or Add Secret moves focus into the dialog, and an in-page Escape closes it.
The lesson for the sweep this document proposes: assert modal Escape with an in-page dispatch, not a synthetic key press, or the assertion silently tests the automation layer instead of the app.
F2, F9, F10, F11, F14, F15, F16, F17, F18, F19, F21, F22, F23, F25, F27, F28, F29, F30, F31, F32, F34, F35, F36 were not re-verified live; they are recorded as closed on the strength of the PRs above. F28 is the exception in that list: its "key called optional" half is confirmed fixed, but the three-surfaces-at-once state is still reproducible (see new defect 5). F3 and F19 need a first-run (empty-config) instance, and F9/F14/F29/F30 need the contrast/responsive/a11y sweep, neither of which this pass ran.
Two findings were not quite right as written, independent of whether they were fixed:
runtime.CalculateTokenSavings → tokens.CalculateProxySavings), so it is window-independent by
design and correctly does not move when call volume changes. The real complaint is that the label reads
like a windowed metric — which is what the fix addressed.Manual half is wrong. That per-card badge is the spec-088 trust-mode badge and it already
carried a title tooltip (ServerCard.vue / utils/trustMode.ts) at the time of the audit. Only the
Mode: Retrieve half was a real gap.These are on main today and are not regressions of anything listed above:
memory quarantined (quarantined: true written to the config file, hot-reloaded), retrieve_tools
still returns all nine of its tools — name, full description and a call_with variant — and the call
is then refused with SECURITY BLOCK. Quarantine exists to keep an unreviewed server's tool
descriptions away from the agent, which is where a Tool Poisoning Attack lives, so this defeats its
purpose on the file path. POST /api/v1/servers/{id}/quarantine does not have the bug:
Runtime.QuarantineServer (internal/runtime/lifecycle.go) calls indexManager.DeleteServerTools
explicitly. The reload path (LoadConfiguredServers, same file) detects the Quarantined transition
— it is in the hasChanged test and logged as quarantined_changed: true — but only acts on
oauthChanged, so no purge runs. Disabling via the config file is unaffected (a disabled server drops
out of the active set the orphan sweep uses). The purge belongs on the transition, not on the two API
handlers that currently carry it.Total Tools 22 — Available across all servers (and the header chip 22 Tools) while memory is
quarantined; on a second instance the same total counted a server that was quarantined and another
that was disabled and disconnected. GET /api/v1/tools returns them too, while
GET /api/v1/index/search correctly returns nothing for them — so the count contradicts the index it
claims to describe, and none of those tools is callable.memory renders a green
Clean scan verdict directly above a yellow Quarantined — needs security review banner.Quarantine Change, Policy Decision, Internal Tool Call) and raw snake_case (tool_quarantine_change) in the same column, on
the same screen./ui/activity shows the
Authentication Required modal plus an inline red Invalid or missing API key / Retry panel plus a
Disconnected chip. Entering a valid key and pressing Set Key re-authenticates the header (server
counts, tool count and the Live badge all come back) but the view body keeps the red panel and
renders zero rows until the user clicks Retry by hand. The key half of F28 that is fixed is the
wording — the field now reads API key (required), not optional.make build
# populated instance
./mcpproxy serve --config=<scratch>/pop/mcp_config.json --data-dir=<scratch>/pop --listen=127.0.0.1:18412
# first-run instance: point --config at a path that does NOT exist yet
./mcpproxy serve --config=<scratch>/empty/mcp_config.json --data-dir=<scratch>/empty --listen=127.0.0.1:18413
Populated config used four servers: two healthy stdio (@modelcontextprotocol/server-everything,
@modelcontextprotocol/server-memory), one unreachable HTTP (https://example.invalid/mcp) and one
disabled/quarantined stdio. Traffic was generated over POST /mcp with call_tool_read /
call_tool_write / call_tool_destructive, including intent_reason, repeated identical calls, failures
mid-run, and one payload containing a fake AWS key. The Web UI is served under /ui/ — Playwright specs
must use http://127.0.0.1:<port>/ui/<route>?apikey=<key>; the bare route returns a Go 404 (F33).
The scrollable element is main.overflow-y-auto, not the document.
Worth folding into the committed sweep (e2e/web-ui-sweep/): the contrast assertion from F9, the
accessible-name and aria-live checks from F30, the modal Escape/focus check from F6, and a
cross-view total-consistency check for F1.