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) { ...@@ -183,12 +183,12 @@ func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
avgTps = float64(total.outputTokens) / (float64(total.generationMs) / 1000.0) avgTps = float64(total.outputTokens) / (float64(total.generationMs) / 1000.0)
} }
models = append(models, ModelSummary{ models = append(models, ModelSummary{
ModelName: name, ModelName: name,
AvgLatencyMs: avgLatency, AvgLatencyMs: avgLatency,
SuccessRate: math.Round(successRate*100) / 100, SuccessRate: math.Round(successRate*100) / 100,
AvgTps: math.Round(avgTps*100) / 100, AvgTps: math.Round(avgTps*100) / 100,
RecentSuccessRates: recentSuccessRates(modelBuckets[name], 3), RecentSuccessSeries: recentSuccessSeries(modelBuckets[name]),
RequestCount: total.requestCount, RequestCount: total.requestCount,
}) })
} }
sort.Slice(models, func(i, j int) bool { sort.Slice(models, func(i, j int) bool {
...@@ -231,25 +231,39 @@ func mergeModelBucket(modelBuckets map[string]map[int64]counters, modelName stri ...@@ -231,25 +231,39 @@ func mergeModelBucket(modelBuckets map[string]map[int64]counters, modelName stri
modelBuckets[modelName][bucketTs] = current modelBuckets[modelName][bucketTs] = current
} }
func recentSuccessRates(buckets map[int64]counters, limit int) []float64 { func recentSuccessSeries(buckets map[int64]counters) []SuccessRatePoint {
if len(buckets) == 0 || limit <= 0 { if len(buckets) == 0 {
return nil return nil
} }
timestamps := make([]int64, 0, len(buckets)) hourly := map[int64]counters{}
for ts := range buckets { for ts, value := range buckets {
timestamps = append(timestamps, ts) 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 { sort.Slice(timestamps, func(i, j int) bool {
return timestamps[i] < timestamps[j] return timestamps[i] < timestamps[j]
}) })
if len(timestamps) > limit { points := make([]SuccessRatePoint, 0, len(timestamps))
timestamps = timestamps[len(timestamps)-limit:] for _, hourTs := range timestamps {
} points = append(points, SuccessRatePoint{
rates := make([]float64, 0, len(timestamps)) Ts: hourTs,
for _, ts := range timestamps { SuccessRate: math.Round(successRate(hourly[hourTs])*100) / 100,
rates = append(rates, math.Round(successRate(buckets[ts])*100)/100) })
} }
return rates return points
} }
func allowedGroupSet(groups []string) map[string]struct{} { func allowedGroupSet(groups []string) map[string]struct{} {
......
...@@ -47,13 +47,18 @@ type QueryResult struct { ...@@ -47,13 +47,18 @@ type QueryResult struct {
Groups []GroupResult `json:"groups"` Groups []GroupResult `json:"groups"`
} }
type SuccessRatePoint struct {
Ts int64 `json:"ts"`
SuccessRate float64 `json:"success_rate"`
}
type ModelSummary struct { type ModelSummary struct {
ModelName string `json:"model_name"` ModelName string `json:"model_name"`
AvgLatencyMs int64 `json:"avg_latency_ms"` AvgLatencyMs int64 `json:"avg_latency_ms"`
SuccessRate float64 `json:"success_rate"` SuccessRate float64 `json:"success_rate"`
AvgTps float64 `json:"avg_tps"` AvgTps float64 `json:"avg_tps"`
RecentSuccessRates []float64 `json:"recent_success_rates,omitempty"` RecentSuccessSeries []SuccessRatePoint `json:"recent_success_series,omitempty"`
RequestCount int64 `json:"-"` RequestCount int64 `json:"-"`
} }
type SummaryAllResult struct { type SummaryAllResult struct {
......
...@@ -43,12 +43,14 @@ export type PerformanceMetricsData = { ...@@ -43,12 +43,14 @@ export type PerformanceMetricsData = {
} }
} }
export type SuccessRatePoint = { ts: number; success_rate: number }
export type PerfModelSummary = { export type PerfModelSummary = {
model_name: string model_name: string
avg_latency_ms: number avg_latency_ms: number
success_rate: number success_rate: number
avg_tps: number avg_tps: number
recent_success_rates?: number[] recent_success_series?: SuccessRatePoint[]
request_count?: number request_count?: number
} }
......
...@@ -74,6 +74,7 @@ afterEach(() => { ...@@ -74,6 +74,7 @@ afterEach(() => {
useSystemConfigStore useSystemConfigStore
.getState() .getState()
.setConfig({ currency: { ...DEFAULT_CURRENCY_CONFIG } }) .setConfig({ currency: { ...DEFAULT_CURRENCY_CONFIG } })
vi.useRealTimers()
vi.unstubAllGlobals() vi.unstubAllGlobals()
useSystemConfigStore.persist.setOptions({ storage: originalStorage }) useSystemConfigStore.persist.setOptions({ storage: originalStorage })
}) })
...@@ -399,4 +400,129 @@ describe('model cards', () => { ...@@ -399,4 +400,129 @@ describe('model cards', () => {
await user.click(screen.getByRole('button', { name: 'Previous page' })) await user.click(screen.getByRole('button', { name: 'Previous page' }))
expect(screen.getByRole('heading', { name: 'model-1' })).toBeVisible() 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/>. ...@@ -16,7 +16,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com For commercial licensing, please contact support@quantumnous.com
*/ */
import { memo } from 'react' import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
...@@ -24,13 +24,14 @@ import { ...@@ -24,13 +24,14 @@ import {
formatThroughput, formatThroughput,
getSuccessRateDotClass, getSuccessRateDotClass,
} from '@/features/performance-metrics/lib/format' } from '@/features/performance-metrics/lib/format'
import type { SuccessRatePoint } from '@/features/performance-metrics/types'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
export type ModelPerfBadgeData = { export type ModelPerfBadgeData = {
avg_latency_ms: number avg_latency_ms: number
success_rate: number success_rate: number
avg_tps: number avg_tps: number
recent_success_rates?: number[] recent_success_series?: SuccessRatePoint[]
} }
export interface ModelPerfBadgeProps extends React.HTMLAttributes<HTMLDivElement> { export interface ModelPerfBadgeProps extends React.HTMLAttributes<HTMLDivElement> {
...@@ -54,13 +55,19 @@ export const ModelPerfBadge = memo(function ModelPerfBadge( ...@@ -54,13 +55,19 @@ export const ModelPerfBadge = memo(function ModelPerfBadge(
Number.isFinite(successRate) && Number.isFinite(successRate) &&
successRate >= 0 && successRate >= 0 &&
successRate <= 100 successRate <= 100
// Keep unreported history neutral; the summary API currently returns up to // Hourly points with timestamps, anchored to the client's current hour.
// three samples, without timestamps. Do not expand them into a fake timeline. // Hours without traffic stay gray. Slot 23 is the current, partial hour.
const recentRates = props.perf?.recent_success_rates?.slice(-24) ?? [] const statusRates = useMemo(() => {
const statusRates: (number | undefined)[] = [ const currentHourStart = Math.floor(Date.now() / 1000 / 3600) * 3600
...Array<undefined>(24 - recentRates.length).fill(undefined), const ratesByHour = new Map<number, number>()
...recentRates, 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 ( return (
<div <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