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
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'
399540import OnboardingWizard from ' @/components/OnboardingWizard.vue'
400541import { useOnboardingStore } from ' @/stores/onboarding'
401542import type { Hint } from ' @/components/CollapsibleHintsPanel.vue'
402- import type { ClientStatus } from ' @/types'
543+ import type { ClientStatus , UsageAggregateResponse , UsageWindow } from ' @/types'
403544
404545const serversStore = useServersStore ()
405546const systemStore = useSystemStore ()
@@ -409,6 +550,53 @@ const onboardingStore = useOnboardingStore()
409550const showConnectModal = ref (false )
410551const 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
413601let refreshInterval: ReturnType <typeof setInterval > | null = null
414602
0 commit comments