Skip to content

Commit c2f1f9d

Browse files
authored
feat(069-B1): Dashboard Overview↔Usage switcher + usage API client (#570)
Spec 069 Stream B1 (T016–T017). Adds the Overview↔Usage switcher to the dashboard and the typed client for the A3 GET /api/v1/activity/usage endpoint (#565). - api: getActivityUsage(params) forwards only supplied filters (window/server/tool/status/top/sort); UsageAggregateResponse + UsageToolStat/UsageOtherBucket/UsageTimeBucket types mirror the contract. - Dashboard: tabbed Overview↔Usage switcher rendered with v-show (never v-if) so Overview state survives a switch-back (SC-006). Usage panel has a 24h/7d/all window selector, tokens-saved headline (FR-007), loading/error/empty states (FR-009) and a baseline per-tool rollup table; the rich charts arrive in B2 (T018–T022). - Lazy load: the aggregate is fetched on first Usage activation and on window change only, so the Overview first paint is never blocked (SC-004). - Tests (TDD): activity-usage.spec (client param forwarding) + dashboard-usage-switcher.spec (default tab, lazy fetch, v-show preserve, window refetch). All 91 frontend unit tests green; vue-tsc clean. Related #745
1 parent c3baf9b commit c2f1f9d

6 files changed

Lines changed: 515 additions & 4 deletions

File tree

frontend/src/services/api.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
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 } from '@/types'
1+
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'
22

33
// Event types for API service
44
export interface APIAuthEvent {
@@ -758,6 +758,21 @@ class APIService {
758758
return this.request<ActivitySummaryResponse>(`/api/v1/activity/summary?period=${period}`)
759759
}
760760

761+
// Spec 069 (T017): actor-owned usage aggregate backing the Usage panel.
762+
// Only the supplied params are forwarded; unset filters are omitted so the
763+
// daemon applies its documented defaults (window=24h, sort=resp_bytes, top=20).
764+
async getActivityUsage(params: UsageQueryParams = {}): Promise<APIResponse<UsageAggregateResponse>> {
765+
const searchParams = new URLSearchParams()
766+
if (params.window) searchParams.append('window', params.window)
767+
if (params.server) searchParams.append('server', params.server)
768+
if (params.tool) searchParams.append('tool', params.tool)
769+
if (params.status) searchParams.append('status', params.status)
770+
if (params.top !== undefined) searchParams.append('top', String(params.top))
771+
if (params.sort) searchParams.append('sort', params.sort)
772+
const qs = searchParams.toString()
773+
return this.request<UsageAggregateResponse>(`/api/v1/activity/usage${qs ? '?' + qs : ''}`)
774+
}
775+
761776
getActivityExportUrl(params: {
762777
format: 'json' | 'csv'
763778
type?: string

frontend/src/types/api.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,6 +631,68 @@ export interface ActivitySummaryResponse {
631631
end_time: string
632632
}
633633

634+
// Usage aggregate types (Spec 069 — GET /api/v1/activity/usage)
635+
636+
export type UsageWindow = '24h' | '7d' | 'all'
637+
export type UsageSort = 'calls' | 'resp_bytes' | 'error_rate' | 'p95'
638+
export type UsageStatus = 'success' | 'error' | 'blocked'
639+
640+
export interface UsageQueryParams {
641+
window?: UsageWindow
642+
server?: string
643+
tool?: string
644+
status?: UsageStatus
645+
top?: number
646+
sort?: UsageSort
647+
}
648+
649+
// Per-(server,tool) rollup row. `avg_resp_bytes`/`avg_req_bytes` are null when
650+
// there are no sized (non-zero-byte) calls. `blocked` counts policy-prevented
651+
// attempts that never executed (excluded from `calls`, latency and bytes).
652+
export interface UsageToolStat {
653+
server: string
654+
tool: string
655+
calls: number
656+
errors: number
657+
error_rate: number
658+
blocked: number
659+
total_resp_bytes: number
660+
avg_resp_bytes: number | null
661+
total_req_bytes: number
662+
avg_req_bytes: number | null
663+
sized_calls: number
664+
p50_ms: number
665+
p95_ms: number
666+
last_used: string
667+
}
668+
669+
// Present only when the tool list was truncated to `top`.
670+
export interface UsageOtherBucket {
671+
tools_folded: number
672+
calls: number
673+
total_resp_bytes: number
674+
}
675+
676+
// One timeline bar (executed calls only; blocked attempts excluded).
677+
export interface UsageTimeBucket {
678+
start: string
679+
calls: number
680+
errors: number
681+
total_resp_bytes: number
682+
}
683+
684+
export interface UsageAggregateResponse {
685+
window: UsageWindow
686+
generated_at: string
687+
freshness_ms: number
688+
token_source: string
689+
tokens_saved: number
690+
tokens_saved_percentage: number
691+
tools: UsageToolStat[]
692+
other?: UsageOtherBucket
693+
timeline: UsageTimeBucket[]
694+
}
695+
634696
// Agent Token types (Spec 028)
635697

636698
export interface AgentTokenInfo {

frontend/src/views/Dashboard.vue

Lines changed: 189 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,35 @@
11
<template>
22
<div class="space-y-6">
3+
<!-- Overview ↔ Usage switcher (Spec 069 B1 / T016). The two panels are
4+
rendered with v-show (never v-if) so the Overview subtree stays mounted
5+
and its state survives a switch-back (SC-006). -->
6+
<div role="tablist" class="tabs tabs-boxed w-fit" data-test="dashboard-tabs">
7+
<button
8+
role="tab"
9+
type="button"
10+
class="tab"
11+
:class="{ 'tab-active': activeTab === 'overview' }"
12+
:aria-selected="activeTab === 'overview'"
13+
data-test="dashboard-tab-overview"
14+
@click="selectTab('overview')"
15+
>
16+
Overview
17+
</button>
18+
<button
19+
role="tab"
20+
type="button"
21+
class="tab"
22+
:class="{ 'tab-active': activeTab === 'usage' }"
23+
:aria-selected="activeTab === 'usage'"
24+
data-test="dashboard-tab-usage"
25+
@click="selectTab('usage')"
26+
>
27+
Usage
28+
</button>
29+
</div>
30+
31+
<!-- ===== Overview panel ===== -->
32+
<div v-show="activeTab === 'overview'" class="space-y-6" data-test="dashboard-overview-panel">
333
<!-- Telemetry Notice Banner -->
434
<TelemetryBanner />
535

@@ -376,6 +406,117 @@
376406

377407
<!-- Hints Panel (Bottom of Page) -->
378408
<CollapsibleHintsPanel :hints="dashboardHints" />
409+
</div>
410+
<!-- ===== /Overview panel ===== -->
411+
412+
<!-- ===== Usage panel (Spec 069 B1). The aggregate is fetched lazily on
413+
first activation and on window change so the Overview first paint is
414+
never blocked (SC-004). The rich charts arrive in B2 (T018–T022); B1
415+
establishes the switcher, window selector, API wiring and headline. -->
416+
<div v-show="activeTab === 'usage'" class="space-y-6" data-test="dashboard-usage-panel">
417+
<!-- Window selector (24h / 7d / all) -->
418+
<div class="flex items-center gap-3 flex-wrap">
419+
<div role="tablist" class="tabs tabs-boxed" data-test="usage-window-selector">
420+
<button
421+
v-for="w in usageWindows"
422+
:key="w.value"
423+
role="tab"
424+
type="button"
425+
class="tab"
426+
:class="{ 'tab-active': usageWindow === w.value }"
427+
:aria-selected="usageWindow === w.value"
428+
:data-test="`usage-window-${w.value}`"
429+
@click="setUsageWindow(w.value)"
430+
>
431+
{{ w.label }}
432+
</button>
433+
</div>
434+
<span v-if="usageData" class="text-xs opacity-50" data-test="usage-freshness">
435+
updated {{ usageData.freshness_ms < 1000 ? 'just now' : `${Math.round(usageData.freshness_ms / 1000)}s ago` }}
436+
</span>
437+
</div>
438+
439+
<!-- Tokens-saved headline (FR-007) -->
440+
<div
441+
v-if="usageData && usageData.tokens_saved > 0"
442+
class="stats shadow bg-base-100 border border-base-300"
443+
data-test="usage-tokens-saved"
444+
>
445+
<div class="stat">
446+
<div class="stat-title">Tokens saved</div>
447+
<div class="stat-value text-success">{{ formatNumber(usageData.tokens_saved) }}</div>
448+
<div class="stat-desc">{{ usageData.tokens_saved_percentage.toFixed(1) }}% reduction · size-based proxy</div>
449+
</div>
450+
</div>
451+
452+
<!-- Loading state -->
453+
<div v-if="usageLoading" class="flex items-center gap-2 text-sm opacity-60 py-8 justify-center" data-test="usage-loading">
454+
<span class="loading loading-spinner loading-sm"></span>
455+
Loading usage…
456+
</div>
457+
458+
<!-- Error state -->
459+
<div v-else-if="usageError" class="alert alert-error" data-test="usage-error">
460+
<span>{{ usageError }}</span>
461+
<button type="button" class="btn btn-sm" @click="loadUsage()">Retry</button>
462+
</div>
463+
464+
<!-- Empty / low-data state (FR-009 / SC-007) -->
465+
<div
466+
v-else-if="usageData && usageData.tools.length === 0"
467+
class="card bg-base-100 border border-base-300 shadow-sm"
468+
data-test="usage-empty"
469+
>
470+
<div class="card-body items-center text-center py-10">
471+
<svg class="w-10 h-10 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
472+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
473+
</svg>
474+
<h3 class="font-semibold mt-2">No tool usage yet</h3>
475+
<p class="text-sm opacity-60 max-w-md">
476+
Once your AI agents start calling tools through MCPProxy, per-tool call volume,
477+
response sizes, error rates and latency will appear here.
478+
</p>
479+
</div>
480+
</div>
481+
482+
<!-- Per-tool rollup (B1 baseline table; B2 replaces with charts) -->
483+
<div
484+
v-else-if="usageData"
485+
class="card bg-base-100 border border-base-300 shadow-sm overflow-x-auto"
486+
data-test="usage-tools-table"
487+
>
488+
<table class="table table-sm">
489+
<thead>
490+
<tr>
491+
<th>Tool</th>
492+
<th class="text-right">Calls</th>
493+
<th class="text-right">Errors</th>
494+
<th class="text-right">Resp bytes</th>
495+
<th class="text-right">p95 ms</th>
496+
</tr>
497+
</thead>
498+
<tbody>
499+
<tr v-for="row in usageData.tools" :key="`${row.server}:${row.tool}`" data-test="usage-tool-row">
500+
<td class="font-mono text-xs">{{ row.server }}:{{ row.tool }}</td>
501+
<td class="text-right">{{ row.calls }}</td>
502+
<td class="text-right" :class="row.errors > 0 ? 'text-error' : 'opacity-50'">
503+
{{ row.errors }}<span class="opacity-50"> ({{ (row.error_rate * 100).toFixed(1) }}%)</span>
504+
</td>
505+
<td class="text-right font-mono text-xs">{{ formatNumber(row.total_resp_bytes) }}</td>
506+
<td class="text-right font-mono text-xs">{{ row.p95_ms }}</td>
507+
</tr>
508+
<tr v-if="usageData.other" class="opacity-60" data-test="usage-other-row">
509+
<td class="italic">other ({{ usageData.other.tools_folded }} tools)</td>
510+
<td class="text-right">{{ usageData.other.calls }}</td>
511+
<td></td>
512+
<td class="text-right font-mono text-xs">{{ formatNumber(usageData.other.total_resp_bytes) }}</td>
513+
<td></td>
514+
</tr>
515+
</tbody>
516+
</table>
517+
</div>
518+
</div>
519+
<!-- ===== /Usage panel ===== -->
379520

380521
<!-- Modals -->
381522
<ConnectModal :show="showConnectModal" @close="showConnectModal = false" />
@@ -399,7 +540,7 @@ import AddServerModal from '@/components/AddServerModal.vue'
399540
import OnboardingWizard from '@/components/OnboardingWizard.vue'
400541
import { useOnboardingStore } from '@/stores/onboarding'
401542
import type { Hint } from '@/components/CollapsibleHintsPanel.vue'
402-
import type { ClientStatus } from '@/types'
543+
import type { ClientStatus, UsageAggregateResponse, UsageWindow } from '@/types'
403544
404545
const serversStore = useServersStore()
405546
const systemStore = useSystemStore()
@@ -409,6 +550,53 @@ const onboardingStore = useOnboardingStore()
409550
const showConnectModal = ref(false)
410551
const showAddServer = ref(false)
411552
553+
// --- Overview ↔ Usage switcher (Spec 069 B1 / T016) ---
554+
const activeTab = ref<'overview' | 'usage'>('overview')
555+
556+
const usageWindows: { value: UsageWindow; label: string }[] = [
557+
{ value: '24h', label: '24h' },
558+
{ value: '7d', label: '7d' },
559+
{ value: 'all', label: 'All' },
560+
]
561+
const usageWindow = ref<UsageWindow>('24h')
562+
const usageData = ref<UsageAggregateResponse | null>(null)
563+
const usageLoading = ref(false)
564+
const usageError = ref<string | null>(null)
565+
// Lazy-load guard: the aggregate is fetched on first Usage activation only, so
566+
// the Overview first paint (SC-004) and switch-backs never trigger a refetch.
567+
let usageLoadedOnce = false
568+
569+
const loadUsage = async () => {
570+
usageLoading.value = true
571+
usageError.value = null
572+
try {
573+
const response = await api.getActivityUsage({ window: usageWindow.value })
574+
if (response.success && response.data) {
575+
usageData.value = response.data
576+
} else {
577+
usageError.value = response.error || 'Failed to load usage data'
578+
}
579+
} catch (error) {
580+
usageError.value = error instanceof Error ? error.message : 'Failed to load usage data'
581+
} finally {
582+
usageLoading.value = false
583+
}
584+
}
585+
586+
const selectTab = (tab: 'overview' | 'usage') => {
587+
activeTab.value = tab
588+
if (tab === 'usage' && !usageLoadedOnce) {
589+
usageLoadedOnce = true
590+
void loadUsage()
591+
}
592+
}
593+
594+
const setUsageWindow = (w: UsageWindow) => {
595+
if (w === usageWindow.value) return
596+
usageWindow.value = w
597+
void loadUsage()
598+
}
599+
412600
// Auto-refresh interval
413601
let refreshInterval: ReturnType<typeof setInterval> | null = null
414602

0 commit comments

Comments
 (0)