Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1036,6 +1036,6 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—`
| [106-security-residual-fixes](./specs/106-security-residual-fixes/) | `shipped` | 18/19 (95%) |
| [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `shipped` | 126/126 (100%) |
| [108-profiles-v3](./specs/108-profiles-v3/) | `shipped` | 185/186 (99%) |
| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `shipped` | 200/201 (100%) |
| [109-ux-navigation-consistency](./specs/109-ux-navigation-consistency/) | `shipped` | 206/207 (100%) |
| [110-catalog-popularity](./specs/110-catalog-popularity/) | `in-flight` | 19/23 (83%) |
| [112-client-header-forwarding](./specs/112-client-header-forwarding/) | `shipped` | 38/40 (95%) |
6 changes: 3 additions & 3 deletions docs/web-ui/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ The tray application opens the Web UI with the API key automatically.

Home answers one question first: **what needs me?**

- **Needs attention** is the one list MCPProxy computes for every surface: sign-in prompts, servers waiting for review, missing secrets, configuration problems and clients that connected but were never seen. Items are ordered by rank, each with one button that goes to the screen that fixes it (Sign in, Review, Add secret). With nothing to do Home reads "All clear" and the usage strip moves to the top. See [Needs Attention](/features/needs-attention).
- The **usage strip** shows calls today, blocked calls, errors and the estimated tokens kept out of every request; each number links to the matching [Activity](/web-ui/activity-log) view.
- The **topology** shows your clients, MCPProxy and your servers.
- **Needs attention** is the one list MCPProxy computes for every surface: sign-in prompts, servers waiting for review, missing secrets, configuration problems and clients that connected but were never seen. Items are ordered by rank, each with one button that goes to the screen that fixes it (Sign in, Review, Add secret). With nothing to do Home reads "All clear" and the usage strip moves to the top, except on a fresh instance with no servers, where a "Get started" card (Add a server, Connect a client, Run setup wizard) replaces it. See [Needs Attention](/features/needs-attention).
- The **usage strip** shows calls, blocked calls and errors; each number links to the matching [Activity](/web-ui/activity-log) view.
- The **topology** shows your clients, MCPProxy and your servers. The chip above the hub shows the estimated per-request token saving, marked "estimate" until a real `retrieve_tools` call has been observed.

The same count appears on the **header pill** (hidden at 0) and as the **badge** on the sidebar's Home entry. `mcpproxy attention` and the macOS tray show the same list; see [Attention Command](/cli/attention-command).

Expand Down
11 changes: 7 additions & 4 deletions frontend/src/composables/useScopeQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,8 +381,11 @@ export interface UseScopeQueryResult {
/** Rule 8: null when the active parameters are contradictory (today, an
* explicit `server` that disagrees with `tool`'s server prefix) — the
* caller must issue no request and render the conflict empty state instead
* (chips carry `conflicting: true` for the pair). */
toRest: () => Record<string, string> | null
* (chips carry `conflicting: true` for the pair). `ignoreConflict` returns
* the map the non-conflicting parameters produce anyway, for a caller that
* reads only parameters independent of the conflicting pair (a page whose own
* state already resolved it while the URL write-back is still in flight). */
toRest: (opts?: { ignoreConflict?: boolean }) => Record<string, string> | null
linkTo: (page: PageId, patch?: Record<string, string>) => RouteLocationRaw
chips: ComputedRef<ScopeChip[]>
}
Expand Down Expand Up @@ -436,7 +439,7 @@ export function useScopeQuery(page: PageId): UseScopeQueryResult {
set(patch)
}

function toRest(): Record<string, string> | null {
function toRest(opts?: { ignoreConflict?: boolean }): Record<string, string> | null {
const out: Record<string, string> = {}
const snapshot: Record<string, string | undefined> = {}
for (const def of defsForPage(page)) snapshot[def.name] = state[def.name]
Expand Down Expand Up @@ -470,7 +473,7 @@ export function useScopeQuery(page: PageId): UseScopeQueryResult {
// Rule 8 ("Contradictory parameters"): no REST query could express both
// active values, so no request is issued at all — never a partial one
// built from whichever fields didn't conflict.
if (conflicted) return null
if (conflicted && !opts?.ignoreConflict) return null

// Usage `window` (url-filter-contract.md `from`/`to` row): computed from
// BOTH values together, including when neither is present (window=all) —
Expand Down
56 changes: 52 additions & 4 deletions frontend/src/views/Activity.vue
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@
filterStatus === part.status && part.status !== '' ? 'font-semibold underline' : '',
]"
:aria-pressed="filterStatus === part.status"
:title="part.key === 'blocked' && activeView === 'calls' ? BLOCKED_CALLS_TITLE : undefined"
@click="applySummaryFilter(part)"
>
{{ part.label }}
Expand Down Expand Up @@ -260,7 +261,7 @@
-->
<div class="stat-title">Events (24h)</div>
<div class="stat-value text-2xl">{{ summary.total_count }}</div>
<div class="stat-desc">{{ summary.call_count }} calls</div>
<div class="stat-desc">{{ summary.call_count }} {{ summary.call_count === 1 ? 'call' : 'calls' }}</div>
</button>
<button
v-for="tile in statusTiles"
Expand Down Expand Up @@ -543,6 +544,20 @@
</svg>
<p class="text-lg">{{ hasActiveFilters || scopeApplied ? 'No matching activities' : 'No activity records found' }}</p>
<p class="text-sm mt-1">{{ hasActiveFilters || scopeApplied ? 'Try adjusting your filters, or remove a profile, client or token chip above' : 'Activity will appear here as tools are called and actions are taken' }}</p>
<!-- T168: a refused attempt is a `policy_decision` row, which the Tool
calls view lists only under status=blocked. When the window holds
some, say so instead of leaving a bare empty table. -->
<div v-if="showBlockedOffer" class="mt-3" data-test="activity-empty-blocked-offer">
<p class="text-sm">Refused attempts are not tool calls that ran; they are listed under Blocked.</p>
<button
type="button"
class="btn btn-sm btn-outline mt-2"
data-test="activity-empty-show-blocked"
@click="filterStatus = 'blocked'"
>
Show {{ blockedAttemptCount }} blocked attempt{{ blockedAttemptCount === 1 ? '' : 's' }}
</button>
</div>
</div>

<!-- Activity Table.
Expand Down Expand Up @@ -2038,6 +2053,15 @@ watch(showFilterPanel, expanded => {
/** "54 calls · 6 errors · 1 blocked" — zeros omitted. */
const summaryParts = computed(() => compactSummaryParts(summary.value))

// T168: refused attempts (`policy_decision`, status `blocked`) are listed in the
// Tool calls view only under status=blocked, so the blocked chip says so there.
const BLOCKED_CALLS_TITLE =
'Blocked call attempts in the last 24 h, including calls a profile or token refused. Click to list them.'
const blockedAttemptCount = computed(() => summary.value?.blocked_count ?? 0)
const showBlockedOffer = computed(
() => activeView.value === 'calls' && !filterStatus.value && blockedAttemptCount.value > 0
)

/**
* The status tiles, as a partition of the Events total beside them (F2, #1046).
* The list — including whether the "Other / internal" tile is warranted — is
Expand Down Expand Up @@ -2109,10 +2133,22 @@ const clearChip = (chip: ActiveFilterChip) => {
void clearParentFilter()
break
case 'server':
filterServer.value = ''
// Removing either side of a server/tool conflict resolves the
// contradiction (contract rule 8). The conflicting `tool` is the raw
// "server:tool" value, so split it the way the URL round trip would:
// one request for what remains, not a stuck empty state.
if (scopeConflict.value) {
const split = splitScopeTool(filterTool.value, undefined)
filterServer.value = split.server ?? ''
filterTool.value = split.tool ?? ''
scopeConflict.value = false
} else {
filterServer.value = ''
}
break
case 'tool':
filterTool.value = ''
scopeConflict.value = false
break
case 'status':
filterStatus.value = ''
Expand Down Expand Up @@ -2381,7 +2417,12 @@ const displayRows = computed((): ActivityDisplayRow[] => {

/** The three REST names the page applies right now (rule 7: none while the build
* does not advertise them; a server/tool conflict is null, so none either). */
const activityScopeParams = computed(() => pickScopeParams(scopeQuery.toRest()))
// profile/client/token do not depend on the server/tool pair, so read them past
// a conflict: while the URL still carries one (a chip just removed, the
// router.replace not settled) the scope must not drop out and back in, which
// would send a second request (review F1.1). loadActivities() itself still
// issues nothing while `scopeConflict` is set.
const activityScopeParams = computed(() => pickScopeParams(scopeQuery.toRest({ ignoreConflict: true })))
const scopeApplied = computed(() => Object.keys(activityScopeParams.value).length > 0)
const scopeKey = computed(() => scopeParamsKey(activityScopeParams.value))

Expand Down Expand Up @@ -2605,6 +2646,10 @@ const loadActivities = async () => {

// Clear filters
const clearFilters = () => {
// First, so the conflict banner leaves in the same tick and the refetch
// watch (which also tracks scopeConflict) runs loadActivities() once, past
// its conflict guard.
scopeConflict.value = false
selectedTypes.value = []
filterServer.value = ''
filterTool.value = ''
Expand Down Expand Up @@ -2947,7 +2992,10 @@ watch([effectiveTypes, filterServer, filterTool, filterStatus, filterSensitiveDa
// applyRouteFilters() already ran during setup (top of this file), so the
// initial values it wrote never trigger this watch — only a later, genuine
// change does; the first fetch is onMounted's explicit call below.
watch([effectiveTypes, filterServer, filterTool, filterStatus, filterStartDate, filterEndDate], () => {
// `scopeConflict` is a source too: a conflict that resolves without any other
// filter ref changing (a URL edit, or "Clear filters" setting everything in one
// tick) must refetch, and a callback runs once per flush so it never doubles up.
watch([effectiveTypes, filterServer, filterTool, filterStatus, filterStartDate, filterEndDate, scopeConflict], () => {
void loadActivities()
}, { deep: true })

Expand Down
86 changes: 82 additions & 4 deletions frontend/src/views/Home.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,42 @@
<!-- Spec 109 FR-051/FR-001/FR-003: the ONE needs-attention list, replacing
the two bespoke Dashboard.vue banners (servers needing attention,
tools pending approval) that duplicated FR-001's logic locally. -->
<AttentionList data-test="home-attention-list" />
<AttentionList v-if="!showGettingStarted && !holdAllClear" data-test="home-attention-list" />

<!-- A fresh instance (no server configured) has nothing to be "all clear"
about: the green banner would be false reassurance. Any attention item
still wins (showGettingStarted needs an empty list). -->
<div
v-if="showGettingStarted"
class="card bg-base-100 border border-primary/30 shadow-sm"
data-test="home-getting-started"
>
<div class="card-body p-4 gap-3">
<div>
<h3 class="font-bold text-lg">Get started</h3>
<p class="text-sm text-base-content/70">MCPProxy has no servers yet. Add one and connect an AI client to start using tools through the proxy.</p>
</div>
<ol class="space-y-2 text-sm">
<li class="flex flex-wrap items-center gap-3">
<span class="font-medium">1. Add a server</span>
<button type="button" class="btn btn-xs btn-primary" data-test="home-getting-started-add-server" @click="router.push('/add-server')">Add a server</button>
</li>
<li class="flex flex-wrap items-center gap-3">
<span class="font-medium">2. Connect a client</span>
<button type="button" class="btn btn-xs btn-primary btn-outline" data-test="home-getting-started-connect-client" @click="router.push('/clients')">Connect a client</button>
<span v-if="clientConnected" class="badge badge-success badge-sm" data-test="home-getting-started-client-done">&#10003; connected</span>
</li>
</ol>
<div>
<button type="button" class="link link-hover text-sm" data-test="home-getting-started-wizard" @click="onboardingStore.openWizard()">Run setup wizard</button>
</div>
</div>
</div>

<!-- Usage summary strip: normally sits below the topology, but moves
above it when the attention list is empty (FR-051) so an otherwise-
calm landing page still opens on something live. -->
<UsageSummaryStrip v-if="authStore.principalKind !== 'tenant' && attentionStore.loaded && attentionStore.count === 0" data-test="home-usage-strip-top" />
<UsageSummaryStrip v-if="authStore.principalKind !== 'tenant' && attentionStore.loaded && attentionStore.count === 0 && !showGettingStarted" data-test="home-usage-strip-top" />

<!-- Topology (moved from Dashboard.vue's Overview panel). Always shown —
Home no longer switches between an Overview and a Usage panel;
Expand Down Expand Up @@ -150,6 +180,12 @@
</svg>
<span class="text-lg font-bold">{{ tokenSavingsData.saved_tokens_percentage >= 99.995 ? '99.99' : tokenSavingsData.saved_tokens_percentage >= 10 ? tokenSavingsData.saved_tokens_percentage.toFixed(1) : tokenSavingsData.saved_tokens_percentage.toFixed(0) }}%</span>
<span class="text-xs font-medium">smaller tool context per request</span>
<span
v-if="tokenSavingsData.estimated"
class="badge badge-ghost badge-xs"
data-test="dashboard-token-savings-estimate"
:title="TOKEN_ESTIMATE_TITLE"
>estimate</span>
</button>
</div>

Expand Down Expand Up @@ -296,7 +332,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
</svg>
Token Savings Details
<span class="badge badge-success badge-sm ml-auto">{{ formatNumber(tokenSavingsData.saved_tokens) }} saved</span>
<span class="badge badge-success badge-sm ml-auto" data-test="dashboard-token-savings-saved-badge">{{ formatNumber(tokenSavingsData.saved_tokens) }} saved{{ tokenSavingsData.estimated ? ' · estimate' : '' }}</span>
</div>
<div class="collapse-content">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 pt-2">
Expand All @@ -305,7 +341,15 @@
<div class="grid grid-cols-3 gap-4">
<div :title="tokensSavedExplainer">
<div class="text-sm opacity-60">Tokens Saved / request</div>
<div class="text-2xl font-bold text-success">{{ formatNumber(tokenSavingsData.saved_tokens) }}</div>
<div class="text-2xl font-bold text-success">
{{ formatNumber(tokenSavingsData.saved_tokens) }}
<span
v-if="tokenSavingsData.estimated"
class="badge badge-ghost badge-sm align-middle"
data-test="dashboard-token-savings-details-estimate"
:title="TOKEN_ESTIMATE_TITLE"
>estimate</span>
</div>
<div class="text-xs opacity-60">{{ tokenSavingsData.saved_tokens_percentage.toFixed(1) }}% reduction</div>
</div>
<div>
Expand Down Expand Up @@ -566,6 +610,11 @@ const tokensSavedExplainer =
'returns for one query. It is a property of your current tool catalog, so it ' +
'changes when you add, remove or reconnect servers — not with each call.'

// Same wording as Usage.vue and the macOS Home. Kept local because hoisting it
// into a shared constant touches Usage.vue (follow-up).
const TOKEN_ESTIMATE_TITLE =
'No retrieve_tools call has been observed yet — this is a simulated estimate from the current tool catalog, not a measured average'

const tokenDetailsOpen = ref(false)
const tokenSavingsDetails = ref<HTMLElement | null>(null)

Expand All @@ -577,6 +626,35 @@ const openTokenSavingsDetails = () => {
})
}

// --- Fresh instance (no server configured) ---
//
// "All clear" with zero servers is false reassurance, so the getting-started
// card takes its place. The trigger is "no server configured" alone; the client
// state is a checklist step. Any attention item wins, and nothing renders until
// both the server list and the attention list have loaded.
const showGettingStarted = computed(
() =>
authStore.principalKind !== 'tenant' &&
serversStore.loaded &&
serversStore.serverCount.total === 0 &&
attentionStore.loaded &&
attentionStore.count === 0
)

// While the server count is still unknown an empty attention list would flash
// "All clear" only to be replaced by the card, so hold the list back. A failed
// server fetch does not hold it back forever.
const holdAllClear = computed(
() =>
authStore.principalKind !== 'tenant' &&
attentionStore.loaded &&
attentionStore.count === 0 &&
!serversStore.loaded &&
!serversStore.loading.error
)

const clientConnected = computed(() => onboardingStore.hasConnectedClient || liveClients.value.length > 0)

// --- Disabled server count ---
//
// Disabled and quarantined must be MUTUALLY EXCLUSIVE here (audit finding F27,
Expand Down
Loading
Loading