Commit 5c7cca01 by CaIon

fix(perf): return hourly success-rate series for model health bar

The perf summary API only returned the last three non-empty buckets as
bare success rates, so the 24-slot status strip on model cards never lit
more than three bars and could not show hours without traffic.

Replace recent_success_rates with recent_success_series: one timestamped
point per hour that had requests, aligned to hour start regardless of
the configured bucket size. The badge now anchors its 24 slots to the
client's current hour and places each point by timestamp, leaving hours
without data gray.
parent 387a4091
......@@ -183,12 +183,12 @@ func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
avgTps = float64(total.outputTokens) / (float64(total.generationMs) / 1000.0)
}
models = append(models, ModelSummary{
ModelName: name,
AvgLatencyMs: avgLatency,
SuccessRate: math.Round(successRate*100) / 100,
AvgTps: math.Round(avgTps*100) / 100,
RecentSuccessRates: recentSuccessRates(modelBuckets[name], 3),
RequestCount: total.requestCount,
ModelName: name,
AvgLatencyMs: avgLatency,
SuccessRate: math.Round(successRate*100) / 100,
AvgTps: math.Round(avgTps*100) / 100,
RecentSuccessSeries: recentSuccessSeries(modelBuckets[name]),
RequestCount: total.requestCount,
})
}
sort.Slice(models, func(i, j int) bool {
......@@ -231,25 +231,39 @@ func mergeModelBucket(modelBuckets map[string]map[int64]counters, modelName stri
modelBuckets[modelName][bucketTs] = current
}
func recentSuccessRates(buckets map[int64]counters, limit int) []float64 {
if len(buckets) == 0 || limit <= 0 {
func recentSuccessSeries(buckets map[int64]counters) []SuccessRatePoint {
if len(buckets) == 0 {
return nil
}
timestamps := make([]int64, 0, len(buckets))
for ts := range buckets {
timestamps = append(timestamps, ts)
hourly := map[int64]counters{}
for ts, value := range buckets {
hourTs := ts - ts%3600
merged := hourly[hourTs]
merged.requestCount += value.requestCount
merged.successCount += value.successCount
hourly[hourTs] = merged
}
timestamps := make([]int64, 0, len(hourly))
for hourTs, value := range hourly {
if value.requestCount == 0 {
continue
}
timestamps = append(timestamps, hourTs)
}
if len(timestamps) == 0 {
return nil
}
sort.Slice(timestamps, func(i, j int) bool {
return timestamps[i] < timestamps[j]
})
if len(timestamps) > limit {
timestamps = timestamps[len(timestamps)-limit:]
}
rates := make([]float64, 0, len(timestamps))
for _, ts := range timestamps {
rates = append(rates, math.Round(successRate(buckets[ts])*100)/100)
points := make([]SuccessRatePoint, 0, len(timestamps))
for _, hourTs := range timestamps {
points = append(points, SuccessRatePoint{
Ts: hourTs,
SuccessRate: math.Round(successRate(hourly[hourTs])*100) / 100,
})
}
return rates
return points
}
func allowedGroupSet(groups []string) map[string]struct{} {
......
......@@ -47,13 +47,18 @@ type QueryResult struct {
Groups []GroupResult `json:"groups"`
}
type SuccessRatePoint struct {
Ts int64 `json:"ts"`
SuccessRate float64 `json:"success_rate"`
}
type ModelSummary struct {
ModelName string `json:"model_name"`
AvgLatencyMs int64 `json:"avg_latency_ms"`
SuccessRate float64 `json:"success_rate"`
AvgTps float64 `json:"avg_tps"`
RecentSuccessRates []float64 `json:"recent_success_rates,omitempty"`
RequestCount int64 `json:"-"`
ModelName string `json:"model_name"`
AvgLatencyMs int64 `json:"avg_latency_ms"`
SuccessRate float64 `json:"success_rate"`
AvgTps float64 `json:"avg_tps"`
RecentSuccessSeries []SuccessRatePoint `json:"recent_success_series,omitempty"`
RequestCount int64 `json:"-"`
}
type SummaryAllResult struct {
......
......@@ -43,12 +43,14 @@ export type PerformanceMetricsData = {
}
}
export type SuccessRatePoint = { ts: number; success_rate: number }
export type PerfModelSummary = {
model_name: string
avg_latency_ms: number
success_rate: number
avg_tps: number
recent_success_rates?: number[]
recent_success_series?: SuccessRatePoint[]
request_count?: number
}
......
......@@ -74,6 +74,7 @@ afterEach(() => {
useSystemConfigStore
.getState()
.setConfig({ currency: { ...DEFAULT_CURRENCY_CONFIG } })
vi.useRealTimers()
vi.unstubAllGlobals()
useSystemConfigStore.persist.setOptions({ storage: originalStorage })
})
......@@ -399,4 +400,129 @@ describe('model cards', () => {
await user.click(screen.getByRole('button', { name: 'Previous page' }))
expect(screen.getByRole('heading', { name: 'model-1' })).toBeVisible()
})
it('lights slots 23 and 18 when series has the current hour and five hours earlier', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-07T12:00:00.000Z'))
const currentHourStart = Math.floor(Date.now() / 1000 / 3600) * 3600
render(
<ModelCard
model={pricingModel()}
onClick={vi.fn()}
perf={{
avg_latency_ms: 1200,
avg_tps: 42,
success_rate: 100,
recent_success_series: [
{ ts: currentHourStart, success_rate: 100 },
{ ts: currentHourStart - 5 * 3600, success_rate: 80 },
],
}}
/>
)
const spans = [
...screen.getByRole('img', {
name: 'Recent success-rate samples; gray bars indicate missing data.',
}).children,
]
expect(spans).toHaveLength(24)
spans.forEach((slot, index) => {
if (index === 18 || index === 23) {
expect(slot.classList.contains('bg-muted-foreground/15')).toBe(false)
return
}
expect(slot.classList.contains('bg-muted-foreground/15')).toBe(true)
})
vi.useRealTimers()
})
it('keeps all 24 slots gray when a series point is 24 hours before the current hour', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-07T12:00:00.000Z'))
const currentHourStart = Math.floor(Date.now() / 1000 / 3600) * 3600
render(
<ModelCard
model={pricingModel()}
onClick={vi.fn()}
perf={{
avg_latency_ms: 1200,
avg_tps: 42,
success_rate: 100,
recent_success_series: [
{ ts: currentHourStart - 24 * 3600, success_rate: 100 },
],
}}
/>
)
const spans = [
...screen.getByRole('img', {
name: 'Recent success-rate samples; gray bars indicate missing data.',
}).children,
]
expect(spans).toHaveLength(24)
spans.forEach((slot) => {
expect(slot.classList.contains('bg-muted-foreground/15')).toBe(true)
})
vi.useRealTimers()
})
it('keeps all 24 slots gray when recent_success_series is undefined', () => {
render(
<ModelCard
model={pricingModel()}
onClick={vi.fn()}
perf={{ avg_latency_ms: 1200, avg_tps: 42, success_rate: 100 }}
/>
)
const spans = [
...screen.getByRole('img', {
name: 'Recent success-rate samples; gray bars indicate missing data.',
}).children,
]
expect(spans).toHaveLength(24)
spans.forEach((slot) => {
expect(slot.classList.contains('bg-muted-foreground/15')).toBe(true)
})
})
it('places a five-hour-old point in slot 18 when now is mid-hour', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-07T12:37:00.000Z'))
const currentHourStart = Math.floor(Date.now() / 1000 / 3600) * 3600
render(
<ModelCard
model={pricingModel()}
onClick={vi.fn()}
perf={{
avg_latency_ms: 1200,
avg_tps: 42,
success_rate: 80,
recent_success_series: [
{ ts: currentHourStart - 5 * 3600, success_rate: 80 },
],
}}
/>
)
const spans = [
...screen.getByRole('img', {
name: 'Recent success-rate samples; gray bars indicate missing data.',
}).children,
]
expect(spans).toHaveLength(24)
spans.forEach((slot, index) => {
if (index === 18) {
expect(slot.classList.contains('bg-muted-foreground/15')).toBe(false)
return
}
expect(slot.classList.contains('bg-muted-foreground/15')).toBe(true)
})
vi.useRealTimers()
})
})
......@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { memo } from 'react'
import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import {
......@@ -24,13 +24,14 @@ import {
formatThroughput,
getSuccessRateDotClass,
} from '@/features/performance-metrics/lib/format'
import type { SuccessRatePoint } from '@/features/performance-metrics/types'
import { cn } from '@/lib/utils'
export type ModelPerfBadgeData = {
avg_latency_ms: number
success_rate: number
avg_tps: number
recent_success_rates?: number[]
recent_success_series?: SuccessRatePoint[]
}
export interface ModelPerfBadgeProps extends React.HTMLAttributes<HTMLDivElement> {
......@@ -54,13 +55,19 @@ export const ModelPerfBadge = memo(function ModelPerfBadge(
Number.isFinite(successRate) &&
successRate >= 0 &&
successRate <= 100
// Keep unreported history neutral; the summary API currently returns up to
// three samples, without timestamps. Do not expand them into a fake timeline.
const recentRates = props.perf?.recent_success_rates?.slice(-24) ?? []
const statusRates: (number | undefined)[] = [
...Array<undefined>(24 - recentRates.length).fill(undefined),
...recentRates,
]
// Hourly points with timestamps, anchored to the client's current hour.
// Hours without traffic stay gray. Slot 23 is the current, partial hour.
const statusRates = useMemo(() => {
const currentHourStart = Math.floor(Date.now() / 1000 / 3600) * 3600
const ratesByHour = new Map<number, number>()
for (const point of props.perf?.recent_success_series ?? []) {
ratesByHour.set(point.ts, point.success_rate)
}
return STATUS_SLOTS.map((slot) => {
const hourStart = currentHourStart - (23 - slot) * 3600
return ratesByHour.get(hourStart)
})
}, [props.perf?.recent_success_series])
return (
<div
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment