diff --git a/Trsr.Api/Controllers/HealthController.cs b/Trsr.Api/Controllers/HealthController.cs new file mode 100644 index 000000000..cf40c07eb --- /dev/null +++ b/Trsr.Api/Controllers/HealthController.cs @@ -0,0 +1,11 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Trsr.Api.Controllers; + +[ApiController] +[Route("api/health")] +public class HealthController : ControllerBase +{ + [HttpGet] + public IActionResult Get() => Ok(new { status = "ok" }); +} diff --git a/frontend/src/app/app.scss b/frontend/src/app/app.scss index e69de29bb..d4b234700 100644 --- a/frontend/src/app/app.scss +++ b/frontend/src/app/app.scss @@ -0,0 +1,5 @@ +:host { + display: block; + width: 100%; + height: 100%; +} diff --git a/frontend/src/app/core/api/health.service.ts b/frontend/src/app/core/api/health.service.ts new file mode 100644 index 000000000..59c19bf86 --- /dev/null +++ b/frontend/src/app/core/api/health.service.ts @@ -0,0 +1,23 @@ +import { Injectable, inject, signal } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +const POLL_INTERVAL_MS = 10_000; + +@Injectable({ providedIn: 'root' }) +export class HealthService { + private readonly http = inject(HttpClient); + + readonly isOnline = signal(false); + + constructor() { + this.check(); + setInterval(() => this.check(), POLL_INTERVAL_MS); + } + + private check() { + this.http.get('/api/health').subscribe({ + next: () => this.isOnline.set(true), + error: () => this.isOnline.set(false), + }); + } +} diff --git a/frontend/src/app/core/api/models.ts b/frontend/src/app/core/api/models.ts index d1bd2bd88..a401ef690 100644 --- a/frontend/src/app/core/api/models.ts +++ b/frontend/src/app/core/api/models.ts @@ -55,6 +55,16 @@ export interface ModelBreakdownDto { avgDurationMs: number; } +export interface LatencyStatDto { + endpointId: string; + p50Ms: number; + p95Ms: number; + p99Ms: number; + minMs: number; + maxMs: number; + sampleCount: number; +} + export interface AgentCallFilter { projectId?: string; agentId?: string; diff --git a/frontend/src/app/core/api/statistics.service.ts b/frontend/src/app/core/api/statistics.service.ts index aea7ff390..0beca2dbf 100644 --- a/frontend/src/app/core/api/statistics.service.ts +++ b/frontend/src/app/core/api/statistics.service.ts @@ -1,7 +1,7 @@ import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; -import { SummaryDto, ModelBreakdownDto } from './models'; +import { SummaryDto, ModelBreakdownDto, LatencyStatDto } from './models'; @Injectable({ providedIn: 'root' }) export class StatisticsService { @@ -13,9 +13,17 @@ export class StatisticsService { return this.http.get('/api/statistics/summary', { params }); } - getModelBreakdown(from?: string): Observable { + getLatency(filter: { from?: string; agentId?: string } = {}): Observable { let params = new HttpParams(); - if (from) params = params.set('from', from); + if (filter.from) params = params.set('from', filter.from); + if (filter.agentId) params = params.set('agentId', filter.agentId); + return this.http.get('/api/statistics/latency', { params }); + } + + getModelBreakdown(filter: { from?: string; agentId?: string } = {}): Observable { + let params = new HttpParams(); + if (filter.from) params = params.set('from', filter.from); + if (filter.agentId) params = params.set('agentId', filter.agentId); return this.http.get('/api/statistics/model-breakdown', { params }); } } diff --git a/frontend/src/app/core/shell/shell.html b/frontend/src/app/core/shell/shell.html index fadd87ff4..740b44229 100644 --- a/frontend/src/app/core/shell/shell.html +++ b/frontend/src/app/core/shell/shell.html @@ -1,5 +1,4 @@ -
- diff --git a/frontend/src/app/core/shell/shell.ts b/frontend/src/app/core/shell/shell.ts index db073d375..880b6a43b 100644 --- a/frontend/src/app/core/shell/shell.ts +++ b/frontend/src/app/core/shell/shell.ts @@ -18,9 +18,12 @@ interface NavItem { styles: ` :host { display: flex; + width: 100%; height: 100vh; overflow: hidden; background-color: var(--bg-primary); + position: relative; + z-index: 1; } `, }) diff --git a/frontend/src/app/features/agents/agents.ts b/frontend/src/app/features/agents/agents.ts index 81d2a28b9..a9908c18b 100644 --- a/frontend/src/app/features/agents/agents.ts +++ b/frontend/src/app/features/agents/agents.ts @@ -122,7 +122,7 @@ Output ONLY the JSON. No prose. No markdown.`, @Component({ selector: 'app-agents', templateUrl: './agents.html', - styles: ``, + styles: `:host { display: block; flex: 1; min-height: 0; overflow-y: auto; }`, }) export class Agents { readonly agentColors = AGENT_COLORS; diff --git a/frontend/src/app/features/dashboard/dashboard.html b/frontend/src/app/features/dashboard/dashboard.html index fc42c0e4c..890dfea74 100644 --- a/frontend/src/app/features/dashboard/dashboard.html +++ b/frontend/src/app/features/dashboard/dashboard.html @@ -7,13 +7,16 @@

Dashboard

- - Proxy online + " [style.color]="health.isOnline() ? 'var(--success)' : 'var(--danger)'" + [style.background]="health.isOnline() ? 'var(--success-subtle)' : 'var(--danger-subtle)'" + [style.border]="health.isOnline() ? '1px solid rgba(16,185,129,0.2)' : '1px solid rgba(239,68,68,0.2)'"> + + {{ health.isOnline() ? 'Proxy online' : 'Proxy offline' }}

Overview of your agent tracing and evaluation activity.

diff --git a/frontend/src/app/features/dashboard/dashboard.ts b/frontend/src/app/features/dashboard/dashboard.ts index 439feedef..2614cf4d1 100644 --- a/frontend/src/app/features/dashboard/dashboard.ts +++ b/frontend/src/app/features/dashboard/dashboard.ts @@ -2,6 +2,7 @@ import { Component, OnInit, inject, signal } from '@angular/core'; import { RouterLink } from '@angular/router'; import { StatisticsService } from '../../core/api/statistics.service'; import { AgentCallsService } from '../../core/api/agent-calls.service'; +import { HealthService } from '../../core/api/health.service'; import { SummaryDto, AgentCallDto } from '../../core/api/models'; type LoadState = 'loading' | 'loaded' | 'error'; @@ -34,12 +35,13 @@ interface AgentCard { selector: 'app-dashboard', imports: [RouterLink], templateUrl: './dashboard.html', - styles: ``, + styles: `:host { display: block; flex: 1; min-height: 0; overflow-y: auto; }`, }) export class Dashboard implements OnInit { readonly Math = Math; private readonly statisticsService = inject(StatisticsService); private readonly agentCallsService = inject(AgentCallsService); + readonly health = inject(HealthService); readonly summaryState = signal('loading'); readonly summary = signal(null); diff --git a/frontend/src/app/features/providers/providers.ts b/frontend/src/app/features/providers/providers.ts index f919df018..8a09cf480 100644 --- a/frontend/src/app/features/providers/providers.ts +++ b/frontend/src/app/features/providers/providers.ts @@ -24,6 +24,7 @@ const PROVIDER_COLORS: Record = { selector: 'app-providers', imports: [FormsModule], templateUrl: './providers.html', + styles: `:host { display: block; flex: 1; min-height: 0; overflow-y: auto; }`, }) export class Providers implements OnInit { private readonly svc = inject(ProvidersService); diff --git a/frontend/src/app/features/runs/runs.ts b/frontend/src/app/features/runs/runs.ts index d92264c37..66e049108 100644 --- a/frontend/src/app/features/runs/runs.ts +++ b/frontend/src/app/features/runs/runs.ts @@ -79,7 +79,7 @@ const RUNS_DATA: Run[] = [ @Component({ selector: 'app-runs', templateUrl: './runs.html', - styles: ``, + styles: `:host { display: block; flex: 1; min-height: 0; overflow-y: auto; }`, }) export class Runs { readonly Math = Math; diff --git a/frontend/src/app/features/suites/suites.ts b/frontend/src/app/features/suites/suites.ts index 1f918a3a7..d4f82c317 100644 --- a/frontend/src/app/features/suites/suites.ts +++ b/frontend/src/app/features/suites/suites.ts @@ -32,7 +32,7 @@ const SUITES_DATA: Suite[] = [ @Component({ selector: 'app-suites', templateUrl: './suites.html', - styles: ``, + styles: `:host { display: block; flex: 1; min-height: 0; overflow-y: auto; }`, }) export class Suites { readonly Math = Math; diff --git a/frontend/src/app/features/traces/traces.html b/frontend/src/app/features/traces/traces.html index 61fa01748..9563d8d3a 100644 --- a/frontend/src/app/features/traces/traces.html +++ b/frontend/src/app/features/traces/traces.html @@ -1,13 +1,18 @@ -
+

Traces

- - - Live + + + {{ health.isOnline() ? 'Live' : 'Offline' }}

Every LLM call captured by the proxy, grouped by model.

@@ -46,13 +51,40 @@

T
Latency - p95 · 4.1s + p95 · {{ p95Label() }}
- - @for (b of histBars; track b.x) { - + +
+ @if (hoveredBar(); as bar) { +
+
{{ bar.label }}
+
+ ~{{ bar.pct.toFixed(0) }}% of requests + @if (bar.count > 0) { · {{ bar.count }} calls } +
+
} - + + @for (b of histBars(); track b.x) { + + } + +

@@ -121,10 +153,33 @@

T

}
- + +
+ + @if (rangeDropdownOpen()) { +
+
+ @for (r of ranges; track r.key) { + + } +
+ } +
@@ -137,9 +192,9 @@

T } -
+
-
+
Trace ID Model Status diff --git a/frontend/src/app/features/traces/traces.ts b/frontend/src/app/features/traces/traces.ts index 264a9b3bc..3fdff5bbc 100644 --- a/frontend/src/app/features/traces/traces.ts +++ b/frontend/src/app/features/traces/traces.ts @@ -2,38 +2,55 @@ import { Component, OnInit, OnDestroy, inject, signal, computed } from '@angular import { AgentCallsService } from '../../core/api/agent-calls.service'; import { AgentsService } from '../../core/api/agents.service'; import { StatisticsService } from '../../core/api/statistics.service'; -import { AgentCallDto, AgentDto } from '../../core/api/models'; +import { HealthService } from '../../core/api/health.service'; +import { AgentCallDto, AgentDto, LatencyStatDto } from '../../core/api/models'; import { TraceDetail } from './trace-detail/trace-detail'; type LoadState = 'loading' | 'loaded' | 'error'; const PAGE_SIZE = 20; const POLL_INTERVAL_MS = 5000; +const HIST_W = 280, HIST_H = 56, HIST_BUCKETS = 10; -interface HistBar { x: number; y: number; w: number; h: number; } +interface HistBar { x: number; y: number; w: number; h: number; label: string; pct: number; count: number; } @Component({ selector: 'app-traces', imports: [TraceDetail], templateUrl: './traces.html', - styles: ``, + styles: `:host { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: hidden; }`, }) export class Traces implements OnInit, OnDestroy { private readonly agentCallsService = inject(AgentCallsService); private readonly agentsService = inject(AgentsService); private readonly statisticsService = inject(StatisticsService); + readonly health = inject(HealthService); readonly searchQuery = signal(''); readonly agentFilter = signal(null); readonly agentDropdownOpen = signal(false); + readonly rangeDropdownOpen = signal(false); + readonly rangeKey = signal('24h'); readonly page = signal(1); + + readonly ranges: Array<{ key: string; label: string }> = [ + { key: '1h', label: 'Last 1 hour' }, + { key: '24h', label: 'Last 24 hours' }, + { key: '7d', label: 'Last 7 days' }, + { key: '30d', label: 'Last 30 days' }, + { key: 'all', label: 'All time' }, + ]; readonly loadState = signal('loading'); readonly traces = signal([]); readonly total = signal(0); readonly agents = signal([]); readonly selectedTrace = signal(null); readonly modelSummaries = signal<{ model: string; count: number }[]>([]); + readonly latencyStats = signal(null); + readonly hoveredBar = signal(null); + private pollTimer: ReturnType | null = null; + private searchTimer: ReturnType | null = null; readonly totalPages = computed(() => Math.ceil(this.total() / PAGE_SIZE)); readonly hasPrev = computed(() => this.page() > 1); @@ -41,23 +58,24 @@ export class Traces implements OnInit, OnDestroy { readonly rangeStart = computed(() => this.total() === 0 ? 0 : (this.page() - 1) * PAGE_SIZE + 1); readonly rangeEnd = computed(() => Math.min(this.page() * PAGE_SIZE, this.total())); - readonly histBars: HistBar[]; - private searchTimer: ReturnType | null = null; + // Histogram bars derived from real latency percentile data. + // Uses a piecewise-linear CDF over [min, p50, p95, p99, max] to estimate + // per-bucket density, giving a faithful shape without needing raw samples. + readonly histBars = computed((): HistBar[] => { + const s = this.latencyStats(); + if (!s || s.sampleCount === 0) return this.emptyHistBars(); + return this.histFromPercentiles(s); + }); - constructor() { - const hist = [4, 12, 28, 42, 32, 22, 11, 7, 3, 2]; - const W = 280, H = 56; - const max = Math.max(...hist) * 1.1; - const bw = W / hist.length * 0.86, gap = W / hist.length * 0.14; - this.histBars = hist.map((v, i) => ({ - x: i * (bw + gap) + gap / 2, w: bw, - y: H - (v / max) * H, h: (v / max) * H, - })); - } + readonly p95Label = computed((): string => { + const s = this.latencyStats(); + if (!s) return '—'; + return this.formatLatency(s.p95Ms); + }); ngOnInit() { this.agentsService.getAll().subscribe({ next: (r) => this.agents.set(r.items) }); - this.loadModelBreakdown(); + this.loadStats(); this.load(); this.pollTimer = setInterval(() => this.refresh(), POLL_INTERVAL_MS); } @@ -84,12 +102,27 @@ export class Traces implements OnInit, OnDestroy { this.agentFilter.set(agent); this.agentDropdownOpen.set(false); this.page.set(1); - this.loadModelBreakdown(); + this.loadStats(); this.load(); } - toggleAgentDropdown() { this.agentDropdownOpen.update(v => !v); } + toggleAgentDropdown() { this.agentDropdownOpen.update(v => !v); this.rangeDropdownOpen.set(false); } closeAgentDropdown() { this.agentDropdownOpen.set(false); } + toggleRangeDropdown() { this.rangeDropdownOpen.update(v => !v); this.agentDropdownOpen.set(false); } + closeRangeDropdown() { this.rangeDropdownOpen.set(false); } + setRange(key: string) { this.rangeKey.set(key); this.rangeDropdownOpen.set(false); this.page.set(1); this.loadStats(); this.load(); } + rangeLabelFor(key: string): string { return this.ranges.find(r => r.key === key)?.label ?? key; } + + private fromIso(): string | undefined { + const now = new Date(); + switch (this.rangeKey()) { + case '1h': now.setHours(now.getHours() - 1); return now.toISOString(); + case '24h': now.setHours(now.getHours() - 24); return now.toISOString(); + case '7d': now.setDate(now.getDate() - 7); return now.toISOString(); + case '30d': now.setDate(now.getDate() - 30); return now.toISOString(); + default: return undefined; + } + } agentLabel(agent: AgentDto): string { const text = agent.systemMessage.trim(); @@ -102,20 +135,27 @@ export class Traces implements OnInit, OnDestroy { prevPage() { if (this.hasPrev()) { this.page.update(p => p - 1); this.load(); } } nextPage() { if (this.hasNext()) { this.page.update(p => p + 1); this.load(); } } + private statsFilter() { + return { from: this.fromIso(), agentId: this.agentFilter()?.id }; + } + private buildFilter() { return { model: this.searchQuery().trim() || undefined, agentId: this.agentFilter()?.id ?? undefined, + from: this.fromIso(), page: this.page(), pageSize: PAGE_SIZE, }; } - private loadModelBreakdown() { - this.statisticsService.getModelBreakdown().subscribe({ - next: (items) => this.modelSummaries.set( - items.map(i => ({ model: i.modelName, count: i.callCount })) - ), + private loadStats() { + const filter = this.statsFilter(); + this.statisticsService.getModelBreakdown(filter).subscribe({ + next: (items) => this.modelSummaries.set(items.map(i => ({ model: i.modelName, count: i.callCount }))), + }); + this.statisticsService.getLatency(filter).subscribe({ + next: (items) => this.latencyStats.set(this.aggregateLatency(items)), }); } @@ -133,6 +173,80 @@ export class Traces implements OnInit, OnDestroy { }); } + // Aggregate per-endpoint latency stats into one overall stat. + // Percentiles are weighted by sample count; min/max are global extremes. + private aggregateLatency(items: LatencyStatDto[]): LatencyStatDto | null { + if (items.length === 0) return null; + const total = items.reduce((s, i) => s + i.sampleCount, 0); + if (total === 0) return null; + const w = (field: keyof LatencyStatDto) => + items.reduce((s, i) => s + (i[field] as number) * i.sampleCount, 0) / total; + return { + endpointId: '', + p50Ms: w('p50Ms'), + p95Ms: w('p95Ms'), + p99Ms: w('p99Ms'), + minMs: Math.min(...items.map(i => i.minMs)), + maxMs: Math.max(...items.map(i => i.maxMs)), + sampleCount: total, + }; + } + + // Build histogram bars from percentile landmarks using a piecewise-linear CDF. + // Buckets span [0, chartMax] evenly; each bucket height = CDF(right) - CDF(left). + private histFromPercentiles(s: LatencyStatDto): HistBar[] { + const chartMax = Math.max(s.maxMs, s.p99Ms * 1.3); + const bucketMs = chartMax / HIST_BUCKETS; + + // CDF landmark pairs [ms, percentile] + const cdf: [number, number][] = [ + [0, 0], + [s.minMs, 0], + [s.p50Ms, 50], + [s.p95Ms, 95], + [s.p99Ms, 99], + [chartMax, 100], + ]; + + const pctAt = (x: number): number => { + for (let i = 1; i < cdf.length; i++) { + const [x0, p0] = cdf[i - 1], [x1, p1] = cdf[i]; + if (x <= x1) { + const t = x1 === x0 ? 1 : (x - x0) / (x1 - x0); + return p0 + t * (p1 - p0); + } + } + return 100; + }; + + const buckets = Array.from({ length: HIST_BUCKETS }, (_, i) => { + const lo = i * bucketMs, hi = (i + 1) * bucketMs; + return { + pct: pctAt(hi) - pctAt(lo), + label: `${this.formatLatency(lo)} – ${this.formatLatency(hi)}`, + count: Math.round((pctAt(hi) - pctAt(lo)) / 100 * s.sampleCount), + }; + }); + return this.barsFromBuckets(buckets); + } + + private emptyHistBars(): HistBar[] { + const buckets = Array.from({ length: HIST_BUCKETS }, (_, i) => ({ + pct: 0, count: 0, label: `bucket ${i + 1}`, + })); + return this.barsFromBuckets(buckets); + } + + private barsFromBuckets(buckets: { pct: number; label: string; count: number }[]): HistBar[] { + const maxPct = Math.max(...buckets.map(b => b.pct), 1); + const bw = HIST_W / buckets.length * 0.86; + const gap = HIST_W / buckets.length * 0.14; + return buckets.map((b, i) => { + const h = (b.pct / maxPct) * HIST_H; + return { x: i * (bw + gap) + gap / 2, w: bw, y: HIST_H - h, h, label: b.label, pct: b.pct, count: b.count }; + }); + } + truncateId(id: string) { return id.substring(0, 8) + '…' + id.substring(id.length - 4); } formatLatency(ms: number) { return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`; } formatDate(iso: string) {