diff --git a/frontend/src/components/usage/CallHistogram.vue b/frontend/src/components/usage/CallHistogram.vue new file mode 100644 index 000000000..8cb0de74c --- /dev/null +++ b/frontend/src/components/usage/CallHistogram.vue @@ -0,0 +1,73 @@ + + + + Calls per tool + {{ tools.length }} tool{{ tools.length === 1 ? '' : 's' }} + + + No tool calls in this window. + + + + + + + + diff --git a/frontend/src/components/usage/ErrorRateChart.vue b/frontend/src/components/usage/ErrorRateChart.vue new file mode 100644 index 000000000..0637fa0ad --- /dev/null +++ b/frontend/src/components/usage/ErrorRateChart.vue @@ -0,0 +1,99 @@ + + + Errors & latency + + No tool calls in this window. + + + + + + + + + + + + Tool + p50 + p95 + Err% + + + + + {{ toolLabel(t.server, t.tool) }} + {{ formatLatency(t.p50_ms) }} + {{ formatLatency(t.p95_ms) }} + + {{ (t.error_rate * 100).toFixed(1) }}% + + + + + + + + + + diff --git a/frontend/src/components/usage/ResponseSizeRanking.vue b/frontend/src/components/usage/ResponseSizeRanking.vue new file mode 100644 index 000000000..88ab8fc42 --- /dev/null +++ b/frontend/src/components/usage/ResponseSizeRanking.vue @@ -0,0 +1,84 @@ + + + + Token sinks + by response size + + + + Ranked by total response bytes (size-based proxy for token cost). + + + No sized responses in this window. + + + + + + + + diff --git a/frontend/src/components/usage/Timeline.vue b/frontend/src/components/usage/Timeline.vue new file mode 100644 index 000000000..f2e7ccc7d --- /dev/null +++ b/frontend/src/components/usage/Timeline.vue @@ -0,0 +1,92 @@ + + + + Activity over time + {{ windowLabel }} + + + No activity in this window. + + + + + + + + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 1a5ba58d0..d9173c418 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,4 +1,4 @@ -import type { APIResponse, Server, Tool, ToolApproval, SearchResult, StatusUpdate, SecretRef, MigrationAnalysis, ConfigSecretsResponse, GetToolCallsResponse, GetToolCallDetailResponse, GetServerToolCallsResponse, GetConfigResponse, ValidateConfigResponse, ConfigApplyResult, ServerTokenMetrics, GetRegistriesResponse, SearchRegistryServersResponse, GetSessionsResponse, GetSessionDetailResponse, InfoResponse, ActivityListResponse, ActivityDetailResponse, ActivitySummaryResponse, ImportResponse, AgentTokenInfo, CreateAgentTokenRequest, CreateAgentTokenResponse, RoutingInfo, ConnectStatusResponse, ConnectResult, OnboardingStateResponse, OnboardingMarkRequest, DiagnosticFixResponse, GlobalToolsResponse, UsageQueryParams, UsageAggregateResponse } from '@/types' +import type { APIResponse, Server, Tool, ToolApproval, SearchResult, StatusUpdate, SecretRef, MigrationAnalysis, ConfigSecretsResponse, GetToolCallsResponse, GetToolCallDetailResponse, GetServerToolCallsResponse, GetConfigResponse, ValidateConfigResponse, ConfigApplyResult, ServerTokenMetrics, GetRegistriesResponse, SearchRegistryServersResponse, GetSessionsResponse, GetSessionDetailResponse, InfoResponse, ActivityListResponse, ActivityDetailResponse, ActivitySummaryResponse, ImportResponse, AgentTokenInfo, CreateAgentTokenRequest, CreateAgentTokenResponse, RoutingInfo, ConnectStatusResponse, ConnectResult, OnboardingStateResponse, OnboardingMarkRequest, DiagnosticFixResponse, GlobalToolsResponse, UsageAggregateResponse, UsageWindow, UsageSort, UsageStatus } from '@/types' // Event types for API service export interface APIAuthEvent { @@ -758,17 +758,23 @@ class APIService { return this.request(`/api/v1/activity/summary?period=${period}`) } - // Spec 069 (T017): actor-owned usage aggregate backing the Usage panel. - // Only the supplied params are forwarded; unset filters are omitted so the - // daemon applies its documented defaults (window=24h, sort=resp_bytes, top=20). - async getActivityUsage(params: UsageQueryParams = {}): Promise> { + // Usage statistics aggregate for the Web UI usage graphs (Spec 069). + async getActivityUsage(params?: { + window?: UsageWindow + server?: string + tool?: string + status?: UsageStatus + top?: number + sort?: UsageSort + }): Promise> { const searchParams = new URLSearchParams() - if (params.window) searchParams.append('window', params.window) - if (params.server) searchParams.append('server', params.server) - if (params.tool) searchParams.append('tool', params.tool) - if (params.status) searchParams.append('status', params.status) - if (params.top !== undefined) searchParams.append('top', String(params.top)) - if (params.sort) searchParams.append('sort', params.sort) + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== '') { + searchParams.append(key, String(value)) + } + }) + } const qs = searchParams.toString() return this.request(`/api/v1/activity/usage${qs ? '?' + qs : ''}`) } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 4530d9e6c..359b5dfe2 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -419,6 +419,55 @@ export interface ServerTokenMetrics { per_server_tool_list_sizes: Record } +// Usage statistics aggregate — GET /api/v1/activity/usage (Spec 069). +// Mirrors contracts.UsageAggregateResponse. Per-tool metrics are +// lifetime-cumulative; `window` scopes the timeline + the tool-list membership. +export interface UsageToolStat { + server: string + tool: string + calls: number + errors: number + error_rate: number + blocked: number + total_resp_bytes: number + avg_resp_bytes: number | null // null when only legacy 0-byte calls exist + total_req_bytes: number + avg_req_bytes: number | null + sized_calls: number + p50_ms: number + p95_ms: number + last_used: string +} + +export interface UsageOtherBucket { + tools_folded: number + calls: number + total_resp_bytes: number +} + +export interface UsageTimeBucket { + start: string + calls: number + errors: number + total_resp_bytes: number +} + +export interface UsageAggregateResponse { + window: string + generated_at: string + freshness_ms: number + token_source: string // "bytes" — size-based proxy (FR-006) + tokens_saved: number // echoed from ServerTokenMetrics (FR-007) + tokens_saved_percentage: number + tools: UsageToolStat[] + other?: UsageOtherBucket | null // present only when list truncated to top-N + timeline: UsageTimeBucket[] +} + +export type UsageWindow = '24h' | '7d' | 'all' +export type UsageSort = 'calls' | 'resp_bytes' | 'error_rate' | 'p95' +export type UsageStatus = 'success' | 'error' | 'blocked' + export interface ToolCallRecord { id: string server_id: string @@ -631,68 +680,6 @@ export interface ActivitySummaryResponse { end_time: string } -// Usage aggregate types (Spec 069 — GET /api/v1/activity/usage) - -export type UsageWindow = '24h' | '7d' | 'all' -export type UsageSort = 'calls' | 'resp_bytes' | 'error_rate' | 'p95' -export type UsageStatus = 'success' | 'error' | 'blocked' - -export interface UsageQueryParams { - window?: UsageWindow - server?: string - tool?: string - status?: UsageStatus - top?: number - sort?: UsageSort -} - -// Per-(server,tool) rollup row. `avg_resp_bytes`/`avg_req_bytes` are null when -// there are no sized (non-zero-byte) calls. `blocked` counts policy-prevented -// attempts that never executed (excluded from `calls`, latency and bytes). -export interface UsageToolStat { - server: string - tool: string - calls: number - errors: number - error_rate: number - blocked: number - total_resp_bytes: number - avg_resp_bytes: number | null - total_req_bytes: number - avg_req_bytes: number | null - sized_calls: number - p50_ms: number - p95_ms: number - last_used: string -} - -// Present only when the tool list was truncated to `top`. -export interface UsageOtherBucket { - tools_folded: number - calls: number - total_resp_bytes: number -} - -// One timeline bar (executed calls only; blocked attempts excluded). -export interface UsageTimeBucket { - start: string - calls: number - errors: number - total_resp_bytes: number -} - -export interface UsageAggregateResponse { - window: UsageWindow - generated_at: string - freshness_ms: number - token_source: string - tokens_saved: number - tokens_saved_percentage: number - tools: UsageToolStat[] - other?: UsageOtherBucket - timeline: UsageTimeBucket[] -} - // Agent Token types (Spec 028) export interface AgentTokenInfo { diff --git a/frontend/src/utils/usageFormat.ts b/frontend/src/utils/usageFormat.ts new file mode 100644 index 000000000..79177865a --- /dev/null +++ b/frontend/src/utils/usageFormat.ts @@ -0,0 +1,47 @@ +// Shared formatting helpers + colour palette for the Usage graphs (Spec 069 B2). +// Kept dependency-free so the usage chart components can import them without +// pulling in Dashboard internals. + +/** Compact number: 1234 -> "1.2K", 2_500_000 -> "2.5M". */ +export function formatNumber(num: number): string { + if (!Number.isFinite(num)) return '0' + if (Math.abs(num) >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M` + if (Math.abs(num) >= 1_000) return `${(num / 1_000).toFixed(1)}K` + return String(num) +} + +/** Human byte size: 0 -> "0 B", 2048 -> "2.0 KB". */ +export function formatBytes(bytes: number | null | undefined): string { + if (bytes == null || !Number.isFinite(bytes) || bytes <= 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + let i = 0 + let v = bytes + while (v >= 1024 && i < units.length - 1) { + v /= 1024 + i++ + } + return `${i === 0 ? v : v.toFixed(1)} ${units[i]}` +} + +/** Latency in ms: 0 -> "0 ms", 1500 -> "1.5 s". */ +export function formatLatency(ms: number | null | undefined): string { + if (ms == null || !Number.isFinite(ms) || ms <= 0) return '0 ms' + if (ms >= 1000) return `${(ms / 1000).toFixed(1)} s` + return `${Math.round(ms)} ms` +} + +/** A short, readable label for a (server, tool) pair. */ +export function toolLabel(server: string, tool: string): string { + return `${server}:${tool}` +} + +/** Stable, colour-blind-friendly palette shared across the usage charts. */ +export const USAGE_PALETTE = [ + '#3b82f6', '#10b981', '#f59e0b', '#ec4899', '#8b5cf6', + '#06b6d4', '#ef4444', '#14b8a6', '#f97316', '#a855f7', + '#6366f1', '#84cc16', '#f43f5e', '#0ea5e9', '#22c55e', '#eab308', +] + +export function paletteColor(index: number): string { + return USAGE_PALETTE[index % USAGE_PALETTE.length] +} diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 875595bf9..22fac07e5 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -1,38 +1,40 @@ - - - + + + + + - Overview - - Overview + - Usage - + @click="selectUsage" + >Usage - - - - + + + + + + + + + + + - - - - - - - - - {{ w.label }} - - - - updated {{ usageData.freshness_ms < 1000 ? 'just now' : `${Math.round(usageData.freshness_ms / 1000)}s ago` }} - - - - - - - Tokens saved - {{ formatNumber(usageData.tokens_saved) }} - {{ usageData.tokens_saved_percentage.toFixed(1) }}% reduction · size-based proxy - - - - - - - Loading usage… - - - - - {{ usageError }} - Retry - - - - - - - - - No tool usage yet - - Once your AI agents start calling tools through MCPProxy, per-tool call volume, - response sizes, error rates and latency will appear here. - - - - - - - - - - Tool - Calls - Errors - Resp bytes - p95 ms - - - - - {{ row.server }}:{{ row.tool }} - {{ row.calls }} - - {{ row.errors }} ({{ (row.error_rate * 100).toFixed(1) }}%) - - {{ formatNumber(row.total_resp_bytes) }} - {{ row.p95_ms }} - - - other ({{ usageData.other.tools_folded }} tools) - {{ usageData.other.calls }} - - {{ formatNumber(usageData.other.total_resp_bytes) }} - - - - - - - + @@ -526,7 +419,7 @@
+ Ranked by total response bytes (size-based proxy for token cost). +
- Once your AI agents start calling tools through MCPProxy, per-tool call volume, - response sizes, error rates and latency will appear here. -