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
73 changes: 73 additions & 0 deletions frontend/src/components/usage/CallHistogram.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<template>
<div data-test="usage-call-histogram">
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold text-sm">Calls per tool</h3>
<span class="text-xs opacity-60">{{ tools.length }} tool{{ tools.length === 1 ? '' : 's' }}</span>
</div>
<div v-if="tools.length === 0" class="text-sm opacity-60 py-8 text-center" data-test="usage-call-histogram-empty">
No tool calls in this window.
</div>
<div v-else class="relative" :style="{ height: chartHeight }">
<Bar :data="chartData" :options="chartOptions" />
</div>
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
} from 'chart.js'
import type { ChartOptions } from 'chart.js'
import type { UsageToolStat } from '@/types'
import { formatNumber, toolLabel, paletteColor } from '@/utils/usageFormat'

ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend)

const props = defineProps<{ tools: UsageToolStat[] }>()

// Horizontal bars so long server:tool labels stay readable on high cardinality.
const chartHeight = computed(() => `${Math.max(160, props.tools.length * 28 + 40)}px`)

const chartData = computed(() => ({
labels: props.tools.map(t => toolLabel(t.server, t.tool)),
datasets: [
{
label: 'Calls',
data: props.tools.map(t => t.calls),
backgroundColor: props.tools.map((_, i) => paletteColor(i)),
borderWidth: 0,
borderRadius: 3,
},
],
}))

const chartOptions = computed<ChartOptions<'bar'>>(() => ({
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => {
const t = props.tools[ctx.dataIndex]
if (!t) return ''
const errPart = t.errors > 0 ? ` · ${formatNumber(t.errors)} errors` : ''
return `${formatNumber(t.calls)} calls${errPart}`
},
},
},
},
scales: {
x: { beginAtZero: true, ticks: { callback: (v) => formatNumber(Number(v)) } },
y: { ticks: { autoSkip: false, font: { size: 11 } } },
},
}))
</script>
99 changes: 99 additions & 0 deletions frontend/src/components/usage/ErrorRateChart.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<template>
<div data-test="usage-error-rate-chart">
<h3 class="font-semibold text-sm mb-2">Errors &amp; latency</h3>
<div v-if="tools.length === 0" class="text-sm opacity-60 py-8 text-center" data-test="usage-error-rate-empty">
No tool calls in this window.
</div>
<template v-else>
<!-- Error rate per tool -->
<div class="relative mb-4" :style="{ height: chartHeight }">
<Bar :data="chartData" :options="chartOptions" />
</div>
<!-- Per-tool p50/p95 latency (FR: tail-latency visibility, T019) -->
<div class="overflow-x-auto">
<table class="table table-xs" data-test="usage-latency-table">
<thead>
<tr>
<th>Tool</th>
<th class="text-right">p50</th>
<th class="text-right">p95</th>
<th class="text-right">Err%</th>
</tr>
</thead>
<tbody>
<tr v-for="t in latencyRows" :key="`${t.server}:${t.tool}`">
<td class="font-mono text-xs truncate max-w-[12rem]">{{ toolLabel(t.server, t.tool) }}</td>
<td class="text-right font-mono text-xs">{{ formatLatency(t.p50_ms) }}</td>
<td class="text-right font-mono text-xs">{{ formatLatency(t.p95_ms) }}</td>
<td class="text-right font-mono text-xs" :class="t.error_rate > 0 ? 'text-error' : 'opacity-60'">
{{ (t.error_rate * 100).toFixed(1) }}%
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
} from 'chart.js'
import type { ChartOptions } from 'chart.js'
import type { UsageToolStat } from '@/types'
import { formatNumber, formatLatency, toolLabel } from '@/utils/usageFormat'

ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend)

const props = defineProps<{ tools: UsageToolStat[] }>()

const chartHeight = computed(() => `${Math.max(140, props.tools.length * 24 + 40)}px`)

// Worst-offenders first in the latency table; cap the visible rows.
const latencyRows = computed(() =>
[...props.tools].sort((a, b) => b.p95_ms - a.p95_ms).slice(0, 12)
)

const chartData = computed(() => ({
labels: props.tools.map(t => toolLabel(t.server, t.tool)),
datasets: [
{
label: 'Error rate %',
data: props.tools.map(t => +(t.error_rate * 100).toFixed(2)),
backgroundColor: props.tools.map(t => (t.error_rate > 0 ? '#ef4444' : '#22c55e')),
borderWidth: 0,
borderRadius: 3,
},
],
}))

const chartOptions = computed<ChartOptions<'bar'>>(() => ({
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => {
const t = props.tools[ctx.dataIndex]
if (!t) return ''
return `${(t.error_rate * 100).toFixed(1)}% (${formatNumber(t.errors)}/${formatNumber(t.calls)})`
},
},
},
},
scales: {
x: { beginAtZero: true, max: 100, ticks: { callback: (v) => `${v}%` } },
y: { ticks: { autoSkip: false, font: { size: 11 } } },
},
}))
</script>
84 changes: 84 additions & 0 deletions frontend/src/components/usage/ResponseSizeRanking.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<template>
<div data-test="usage-response-size-ranking">
<div class="flex items-center justify-between mb-1">
<h3 class="font-semibold text-sm">Token sinks</h3>
<span class="text-xs opacity-60">by response size</span>
</div>
<!-- FR-006: response size is a size-based proxy for token cost, not a real
tokenizer count. Make that explicit so the number isn't mistaken for tokens. -->
<p class="text-xs opacity-50 mb-2">
Ranked by total response bytes (size-based proxy for token cost).
</p>
<div v-if="ranked.length === 0" class="text-sm opacity-60 py-8 text-center" data-test="usage-response-size-ranking-empty">
No sized responses in this window.
</div>
<div v-else class="relative" :style="{ height: chartHeight }">
<Bar :data="chartData" :options="chartOptions" />
</div>
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
} from 'chart.js'
import type { ChartOptions } from 'chart.js'
import type { UsageToolStat } from '@/types'
import { formatBytes, toolLabel, paletteColor } from '@/utils/usageFormat'

ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend)

const props = defineProps<{ tools: UsageToolStat[] }>()

// Rank by total response bytes descending; drop tools with no sized output.
const ranked = computed(() =>
[...props.tools]
.filter(t => t.total_resp_bytes > 0)
.sort((a, b) => b.total_resp_bytes - a.total_resp_bytes)
)

const chartHeight = computed(() => `${Math.max(160, ranked.value.length * 28 + 40)}px`)

const chartData = computed(() => ({
labels: ranked.value.map(t => toolLabel(t.server, t.tool)),
datasets: [
{
label: 'Total response bytes',
data: ranked.value.map(t => t.total_resp_bytes),
backgroundColor: ranked.value.map((_, i) => paletteColor(i)),
borderWidth: 0,
borderRadius: 3,
},
],
}))

const chartOptions = computed<ChartOptions<'bar'>>(() => ({
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx) => {
const t = ranked.value[ctx.dataIndex]
if (!t) return ''
const avg = t.avg_resp_bytes != null ? ` · avg ${formatBytes(t.avg_resp_bytes)}` : ''
return `${formatBytes(t.total_resp_bytes)} total${avg}`
},
},
},
},
scales: {
x: { beginAtZero: true, ticks: { callback: (v) => formatBytes(Number(v)) } },
y: { ticks: { autoSkip: false, font: { size: 11 } } },
},
}))
</script>
92 changes: 92 additions & 0 deletions frontend/src/components/usage/Timeline.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<template>
<div data-test="usage-timeline">
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold text-sm">Activity over time</h3>
<span class="text-xs opacity-60">{{ windowLabel }}</span>
</div>
<div v-if="buckets.length === 0" class="text-sm opacity-60 py-8 text-center" data-test="usage-timeline-empty">
No activity in this window.
</div>
<div v-else class="relative" style="height: 220px">
<Bar :data="chartData" :options="chartOptions" />
</div>
</div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { Bar } from 'vue-chartjs'
import {
Chart as ChartJS,
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
} from 'chart.js'
import type { ChartOptions } from 'chart.js'
import type { UsageTimeBucket, UsageWindow } from '@/types'
import { formatNumber } from '@/utils/usageFormat'

ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend)

const props = defineProps<{ buckets: UsageTimeBucket[]; window: UsageWindow }>()

const windowLabel = computed(() => {
switch (props.window) {
case '24h': return 'Last 24 hours'
case '7d': return 'Last 7 days'
default: return 'All time'
}
})

// Coarser label for wider windows; the buckets themselves come from the backend.
function bucketLabel(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
if (props.window === '24h') {
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
}

const chartData = computed(() => ({
labels: props.buckets.map(b => bucketLabel(b.start)),
datasets: [
{
label: 'Calls',
data: props.buckets.map(b => b.calls - b.errors),
backgroundColor: '#3b82f6',
borderWidth: 0,
stack: 'activity',
},
{
label: 'Errors',
data: props.buckets.map(b => b.errors),
backgroundColor: '#ef4444',
borderWidth: 0,
stack: 'activity',
},
],
}))

const chartOptions = computed<ChartOptions<'bar'>>(() => ({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true, position: 'bottom', labels: { boxWidth: 12, font: { size: 11 } } },
tooltip: {
callbacks: {
footer: (items) => {
const b = props.buckets[items[0]?.dataIndex ?? -1]
return b ? `Total: ${formatNumber(b.calls)} calls` : ''
},
},
},
},
scales: {
x: { stacked: true, ticks: { maxRotation: 0, autoSkip: true, font: { size: 10 } } },
y: { stacked: true, beginAtZero: true, ticks: { callback: (v) => formatNumber(Number(v)) } },
},
}))
</script>
28 changes: 17 additions & 11 deletions frontend/src/services/api.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -758,17 +758,23 @@ class APIService {
return this.request<ActivitySummaryResponse>(`/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<APIResponse<UsageAggregateResponse>> {
// 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<APIResponse<UsageAggregateResponse>> {
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<UsageAggregateResponse>(`/api/v1/activity/usage${qs ? '?' + qs : ''}`)
}
Expand Down
Loading
Loading