mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-10-28 08:35:33 +08:00
fix(pricing): show recent status samples without gaps
Fetch the latest 24 nonempty hourly samples within 7 days for the visible models, while preserving the 24-hour summary metrics. Render only available samples without gray placeholders. Validated perf-metrics tests with SQLite 3.50.4, MySQL 5.7.44, and PostgreSQL 9.6.24; Go build; 36 frontend tests; frontend typecheck, lint, formatting, and production build.
This commit is contained in:
10 files changed
+418
-116
No files matched your search
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
perfmetrics "github.com/QuantumNous/new-api/pkg/perf_metrics"
|
||||
"github.com/QuantumNous/new-api/setting/ratio_setting"
|
||||
|
||||
@@ -19,8 +20,13 @@ func GetPerfMetricsSummary(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
recentModels := c.QueryArray("recent_model")
|
||||
if len(recentModels) > perfmetrics.MaxRecentModels {
|
||||
common.ApiErrorStatus(c, http.StatusBadRequest, common.NewMessage("Invalid parameters"))
|
||||
return
|
||||
}
|
||||
activeGroups := append(lo.Keys(ratio_setting.GetGroupRatioCopy()), "auto")
|
||||
result, err := perfmetrics.QuerySummaryAll(hours, activeGroups)
|
||||
result, err := perfmetrics.QuerySummaryAll(hours, activeGroups, recentModels...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
|
||||
@@ -115,6 +115,25 @@ func GetPerfMetricsSummaryBucketsAll(startTs int64, endTs int64, groups []string
|
||||
return summaries, err
|
||||
}
|
||||
|
||||
// GetRecentPerfMetricSuccessBuckets reads the latest nonempty hours within
|
||||
// the supplied history window, independently of the summary's reporting window.
|
||||
func GetRecentPerfMetricSuccessBuckets(modelName string, startTs int64, endTs int64, groups []string, limit int) ([]PerfMetricSummaryBucket, error) {
|
||||
if modelName == "" || limit <= 0 || (groups != nil && len(groups) == 0) {
|
||||
return nil, nil
|
||||
}
|
||||
const hourBucket = "bucket_ts - (bucket_ts % 3600)"
|
||||
query := DB.Model(&PerfMetric{}).
|
||||
Select("model_name, "+hourBucket+" AS bucket_ts, SUM(request_count) AS request_count, SUM(success_count) AS success_count").
|
||||
Where("model_name = ? AND bucket_ts >= ? AND bucket_ts <= ? AND request_count > 0", modelName, startTs, endTs)
|
||||
if groups != nil {
|
||||
query = query.Where(commonGroupCol+" IN ?", groups)
|
||||
}
|
||||
var buckets []PerfMetricSummaryBucket
|
||||
err := query.Group("model_name, " + hourBucket).
|
||||
Order("bucket_ts DESC").Limit(limit).Find(&buckets).Error
|
||||
return buckets, err
|
||||
}
|
||||
|
||||
func DeletePerfMetricsBefore(cutoffTs int64) error {
|
||||
if cutoffTs <= 0 {
|
||||
return nil
|
||||
|
||||
@@ -24,6 +24,11 @@ var hotBuckets sync.Map
|
||||
// hiding fields or making response-only privacy hardening changes.
|
||||
const seriesSchema = "dbcd0a3c01b55203"
|
||||
|
||||
// MaxRecentModels bounds the historical lookups made by one summary request.
|
||||
const MaxRecentModels = 50
|
||||
const recentSuccessSampleLimit = 24
|
||||
const recentSuccessHistoryHours = 7 * 24
|
||||
|
||||
func Init() {
|
||||
go flushLoop()
|
||||
}
|
||||
@@ -186,8 +191,13 @@ func Query(params QueryParams) (QueryResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
|
||||
startTs, endTs := queryWindow(time.Now(), hours)
|
||||
func QuerySummaryAll(hours int, groups []string, recentModels ...string) (SummaryAllResult, error) {
|
||||
if len(recentModels) > MaxRecentModels {
|
||||
return SummaryAllResult{}, common.NewMessage("Invalid parameters")
|
||||
}
|
||||
now := time.Now()
|
||||
startTs, endTs := queryWindow(now, hours)
|
||||
recentStartTs, _ := queryWindow(now, recentSuccessHistoryHours)
|
||||
allowedGroups := allowedGroupSet(groups)
|
||||
|
||||
rows, err := model.GetPerfMetricsSummaryBucketsAll(startTs, endTs, groups)
|
||||
@@ -195,6 +205,27 @@ func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
|
||||
return SummaryAllResult{}, err
|
||||
}
|
||||
|
||||
recentBuckets := make(map[string]map[int64]counters, len(recentModels))
|
||||
for _, name := range recentModels {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := recentBuckets[name]; exists {
|
||||
continue
|
||||
}
|
||||
recentBuckets[name] = map[int64]counters{}
|
||||
buckets, err := model.GetRecentPerfMetricSuccessBuckets(name, recentStartTs, endTs, groups, recentSuccessSampleLimit)
|
||||
if err != nil {
|
||||
return SummaryAllResult{}, err
|
||||
}
|
||||
for _, bucket := range buckets {
|
||||
mergeModelBucket(recentBuckets, name, bucket.BucketTs, counters{
|
||||
requestCount: bucket.RequestCount,
|
||||
successCount: bucket.SuccessCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
totals := map[string]counters{}
|
||||
modelBuckets := map[string]map[int64]counters{}
|
||||
for _, row := range rows {
|
||||
@@ -211,7 +242,7 @@ func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
|
||||
|
||||
hotBuckets.Range(func(key, value any) bool {
|
||||
k := key.(bucketKey)
|
||||
if k.bucketTs < startTs || k.bucketTs > endTs {
|
||||
if k.bucketTs > endTs {
|
||||
return true
|
||||
}
|
||||
if allowedGroups != nil {
|
||||
@@ -223,6 +254,12 @@ func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
|
||||
if snap.requestCount == 0 {
|
||||
return true
|
||||
}
|
||||
if _, requested := recentBuckets[k.model]; requested && k.bucketTs >= recentStartTs {
|
||||
mergeModelBucket(recentBuckets, k.model, k.bucketTs, snap)
|
||||
}
|
||||
if k.bucketTs < startTs {
|
||||
return true
|
||||
}
|
||||
mergeModelTotals(totals, k.model, snap)
|
||||
mergeModelBucket(modelBuckets, k.model, k.bucketTs, snap)
|
||||
return true
|
||||
@@ -258,7 +295,18 @@ func QuerySummaryAll(hours int, groups []string) (SummaryAllResult, error) {
|
||||
return models[i].RequestCount > models[j].RequestCount
|
||||
})
|
||||
|
||||
return SummaryAllResult{Summary: summarize(all), WindowStart: startTs, WindowEnd: endTs, Models: models}, nil
|
||||
recentSeries := make(map[string][]SuccessRatePoint, len(recentBuckets))
|
||||
for name, buckets := range recentBuckets {
|
||||
points := recentSuccessSeries(buckets)
|
||||
if len(points) == 0 {
|
||||
continue
|
||||
}
|
||||
recentSeries[name] = points[max(0, len(points)-recentSuccessSampleLimit):]
|
||||
}
|
||||
return SummaryAllResult{
|
||||
Summary: summarize(all), WindowStart: startTs, WindowEnd: endTs,
|
||||
Models: models, RecentSuccessSeries: recentSeries,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func mergeModelTotals(totals map[string]counters, modelName string, value counters) {
|
||||
|
||||
@@ -109,6 +109,11 @@ func TestHourlySuccessSeriesWeightsSmallerBuckets(t *testing.T) {
|
||||
assert.Equal(t, []SuccessRatePoint{{Ts: 3600, SuccessRate: 99.01}, {Ts: 7200, SuccessRate: 100}}, points)
|
||||
}
|
||||
|
||||
func TestRecentHistoryRejectsOversizedModelLists(t *testing.T) {
|
||||
_, err := QuerySummaryAll(24, nil, make([]string, 51)...)
|
||||
require.EqualError(t, err, "Invalid parameters")
|
||||
}
|
||||
|
||||
// Terminal task sampling: success/failure counts, end-to-end latency, and
|
||||
// token throughput only for successful tasks that report tokens.
|
||||
func TestRecordTaskResultSamplesTerminalTasks(t *testing.T) {
|
||||
@@ -181,6 +186,13 @@ func TestPerformanceAggregationAndFlush(t *testing.T) {
|
||||
t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
|
||||
require.NoError(t, db.Migrator().DropTable(&model.PerfMetric{}))
|
||||
require.NoError(t, db.AutoMigrate(&model.PerfMetric{}))
|
||||
versionQuery := "SELECT version()"
|
||||
if dialect.name == "sqlite" {
|
||||
versionQuery = "SELECT sqlite_version()"
|
||||
}
|
||||
var version string
|
||||
require.NoError(t, db.Raw(versionQuery).Scan(&version).Error)
|
||||
t.Logf("database version: %s", version)
|
||||
|
||||
now := time.Now()
|
||||
start, _ := queryWindow(now, 24)
|
||||
@@ -249,6 +261,87 @@ func TestPerformanceAggregationAndFlush(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 98.04, combined.Summary.SuccessRate)
|
||||
assert.Equal(t, 99.01, combined.Models[0].SuccessRate)
|
||||
|
||||
t.Run("recent history does not change window summaries", func(t *testing.T) {
|
||||
for i := range 26 {
|
||||
require.NoError(t, model.UpsertPerfMetric(&model.PerfMetric{
|
||||
ModelName: "history-model", Group: "a", BucketTs: hour - int64(i+1)*6*3600,
|
||||
RequestCount: 1, SuccessCount: 1,
|
||||
}))
|
||||
}
|
||||
for _, row := range []model.PerfMetric{
|
||||
{ModelName: "history-model", Group: "a", BucketTs: hour + 60, RequestCount: 100, SuccessCount: 100},
|
||||
{ModelName: "history-model", Group: "inactive", BucketTs: hour + 120, RequestCount: 100},
|
||||
{ModelName: "history-model", Group: "a", BucketTs: hour + 2*3600, RequestCount: 1},
|
||||
{ModelName: "history-idle", Group: "a", BucketTs: hour - 3*24*3600, RequestCount: 1},
|
||||
{ModelName: "history-hidden", Group: "inactive", BucketTs: hour - 3*24*3600, RequestCount: 1},
|
||||
} {
|
||||
require.NoError(t, model.UpsertPerfMetric(&row))
|
||||
}
|
||||
require.NoError(t, db.Create(&model.PerfMetric{ModelName: "history-model", Group: "a", BucketTs: hour - 3600}).Error)
|
||||
hotFailure := &atomicBucket{}
|
||||
hotFailure.add(Sample{})
|
||||
hotBuckets.Store(bucketKey{model: "history-model", group: "b", bucketTs: hour + 120}, hotFailure)
|
||||
hotSuccess := &atomicBucket{}
|
||||
hotSuccess.add(Sample{Success: true})
|
||||
hotBuckets.Store(bucketKey{model: "history-hot", group: "a", bucketTs: hour}, hotSuccess)
|
||||
hiddenFailure := &atomicBucket{}
|
||||
hiddenFailure.add(Sample{})
|
||||
hotBuckets.Store(bucketKey{model: "history-hidden", group: "inactive", bucketTs: hour}, hiddenFailure)
|
||||
|
||||
window, err := QuerySummaryAll(24, groups)
|
||||
require.NoError(t, err)
|
||||
history, err := QuerySummaryAll(24, groups, "history-model", "history-idle", "history-hidden", "history-hot", "missing")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, window.Summary, history.Summary)
|
||||
assert.ElementsMatch(t, window.Models, history.Models)
|
||||
assert.Equal(t, window.WindowStart, history.WindowStart)
|
||||
points := history.RecentSuccessSeries["history-model"]
|
||||
require.Len(t, points, 24)
|
||||
expected := make([]SuccessRatePoint, 0, 24)
|
||||
for i := 23; i > 0; i-- {
|
||||
expected = append(expected, SuccessRatePoint{Ts: hour - int64(i)*6*3600, SuccessRate: 100})
|
||||
}
|
||||
expected = append(expected, SuccessRatePoint{Ts: hour, SuccessRate: 99.01})
|
||||
assert.Equal(t, expected, points)
|
||||
assert.Equal(t, []SuccessRatePoint{{Ts: hour - 3*24*3600, SuccessRate: 0}}, history.RecentSuccessSeries["history-idle"])
|
||||
assert.Equal(t, []SuccessRatePoint{{Ts: hour, SuccessRate: 100}}, history.RecentSuccessSeries["history-hot"])
|
||||
assert.Empty(t, history.RecentSuccessSeries["history-hidden"])
|
||||
assert.Empty(t, history.RecentSuccessSeries["missing"])
|
||||
|
||||
denied, err := QuerySummaryAll(24, []string{}, "history-model", "history-hidden")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, denied.RecentSuccessSeries)
|
||||
flushCompletedBuckets()
|
||||
persisted, err := QuerySummaryAll(24, groups, "history-model", "history-hot")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, points, persisted.RecentSuccessSeries["history-model"])
|
||||
assert.Equal(t, history.RecentSuccessSeries["history-hot"], persisted.RecentSuccessSeries["history-hot"])
|
||||
})
|
||||
|
||||
t.Run("recent history stops at the 7 day boundary even with fewer than 24 samples", func(t *testing.T) {
|
||||
historyStart, _ := queryWindow(now, 7*24)
|
||||
for _, row := range []model.PerfMetric{
|
||||
{ModelName: "history-boundary", Group: "a", BucketTs: historyStart, RequestCount: 1, SuccessCount: 1},
|
||||
{ModelName: "history-boundary", Group: "a", BucketTs: historyStart - 3600, RequestCount: 1},
|
||||
{ModelName: "history-expired", Group: "a", BucketTs: historyStart - 3600, RequestCount: 1},
|
||||
} {
|
||||
require.NoError(t, model.UpsertPerfMetric(&row))
|
||||
}
|
||||
hotExpired := &atomicBucket{}
|
||||
hotExpired.add(Sample{Success: true})
|
||||
hotBuckets.Store(bucketKey{model: "history-hot-expired", group: "a", bucketTs: historyStart - 3600}, hotExpired)
|
||||
hotBoundary := &atomicBucket{}
|
||||
hotBoundary.add(Sample{Success: true})
|
||||
hotBuckets.Store(bucketKey{model: "history-hot-boundary", group: "a", bucketTs: historyStart}, hotBoundary)
|
||||
|
||||
history, err := QuerySummaryAll(24, groups, "history-boundary", "history-expired", "history-hot-expired", "history-hot-boundary")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []SuccessRatePoint{{Ts: historyStart, SuccessRate: 100}}, history.RecentSuccessSeries["history-boundary"])
|
||||
assert.Equal(t, []SuccessRatePoint{{Ts: historyStart, SuccessRate: 100}}, history.RecentSuccessSeries["history-hot-boundary"])
|
||||
assert.Empty(t, history.RecentSuccessSeries["history-expired"])
|
||||
assert.Empty(t, history.RecentSuccessSeries["history-hot-expired"])
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,9 @@ type SummaryAllResult struct {
|
||||
WindowStart int64 `json:"window_start"`
|
||||
WindowEnd int64 `json:"window_end"`
|
||||
Models []ModelSummary `json:"models"`
|
||||
// RecentSuccessSeries contains up to 24 nonempty hours from the last 7 days,
|
||||
// including requested models with no traffic in the summary window.
|
||||
RecentSuccessSeries map[string][]SuccessRatePoint `json:"recent_success_series,omitempty"`
|
||||
}
|
||||
|
||||
type bucketKey struct {
|
||||
|
||||
@@ -21,10 +21,12 @@ import { api } from '@/lib/api'
|
||||
import type { PerformanceMetricsData, PerfSummaryAllData } from './types'
|
||||
|
||||
export async function getPerfMetricsSummary(
|
||||
hours = 24
|
||||
hours = 24,
|
||||
recentModels?: string[]
|
||||
): Promise<PerfSummaryAllData> {
|
||||
const res = await api.get<PerfSummaryAllData>('/api/perf-metrics/summary', {
|
||||
params: { hours },
|
||||
params: { hours, recent_model: recentModels },
|
||||
paramsSerializer: { indexes: null },
|
||||
})
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -72,5 +72,6 @@ export type PerfSummaryAllData = {
|
||||
window_start?: number
|
||||
window_end?: number
|
||||
models: PerfModelSummary[]
|
||||
recent_success_series?: Record<string, SuccessRatePoint[]>
|
||||
}
|
||||
}
|
||||
@@ -156,33 +156,105 @@ describe('model cards', () => {
|
||||
expect(onClick).toHaveBeenCalledWith(name)
|
||||
})
|
||||
|
||||
it('retains a neutral health strip and missing values when metrics are unavailable', () => {
|
||||
it('shows missing values without placeholder bars when metrics are unavailable', () => {
|
||||
render(<ModelCard model={pricingModel()} onClick={vi.fn()} />)
|
||||
const metrics = screen.getByLabelText(
|
||||
'Performance metrics for the last 24 hours'
|
||||
)
|
||||
expect(within(metrics).getByText('—')).toBeVisible()
|
||||
expect(within(metrics).getAllByText('—')).toHaveLength(2)
|
||||
expect(within(metrics).getByText('—s')).toBeVisible()
|
||||
expect(within(metrics).getByText('—t/s')).toBeVisible()
|
||||
expect(within(metrics).queryByText(/100/)).not.toBeInTheDocument()
|
||||
expect(
|
||||
within(metrics).getByRole('img', {
|
||||
name: 'Recent success-rate samples; gray bars indicate missing data.',
|
||||
})
|
||||
).toBeVisible()
|
||||
const statusStrip = within(metrics).getByRole('img', {
|
||||
name: 'No performance data available',
|
||||
})
|
||||
expect(statusStrip).toHaveTextContent('—')
|
||||
expect(statusStrip.querySelectorAll('[aria-hidden]')).toHaveLength(0)
|
||||
expect(screen.getByText('No description available.')).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: 'Details' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('uses fixed spacing between hourly status bars', () => {
|
||||
render(<ModelCard model={pricingModel()} onClick={vi.fn()} />)
|
||||
it('uses fixed spacing between available status samples', () => {
|
||||
render(
|
||||
<ModelCard
|
||||
model={pricingModel()}
|
||||
onClick={vi.fn()}
|
||||
perf={{
|
||||
avg_latency_ms: 1200,
|
||||
avg_tps: 42,
|
||||
success_rate: 50,
|
||||
recent_success_series: [
|
||||
{ ts: 3600, success_rate: 100 },
|
||||
{ ts: 18000, success_rate: 0 },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)
|
||||
const statusStrip = screen.getByRole('img', {
|
||||
name: 'Recent success-rate samples; gray bars indicate missing data.',
|
||||
name: 'Success rate',
|
||||
})
|
||||
expect(statusStrip).toHaveClass('gap-px')
|
||||
expect(statusStrip).not.toHaveClass('justify-between')
|
||||
})
|
||||
|
||||
it('shows server history for idle models while preserving the current window metrics', async () => {
|
||||
const request = vi.spyOn(api, 'get').mockResolvedValue({
|
||||
data: {
|
||||
success: true,
|
||||
data: {
|
||||
models: [
|
||||
{
|
||||
model_name: 'active-model',
|
||||
avg_latency_ms: 1200,
|
||||
avg_tps: 42,
|
||||
success_rate: 99.8,
|
||||
recent_success_series: [{ ts: 7200, success_rate: 0 }],
|
||||
},
|
||||
],
|
||||
recent_success_series: {
|
||||
'active-model': [{ ts: 3600, success_rate: 100 }],
|
||||
'idle-model': [
|
||||
{ ts: 3600, success_rate: 0 },
|
||||
{ ts: 7200, success_rate: 100 },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ModelCardGrid
|
||||
models={[
|
||||
pricingModel({ model_name: 'active-model' }),
|
||||
pricingModel({ id: 2, model_name: 'idle-model' }),
|
||||
]}
|
||||
onModelClick={vi.fn()}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
await screen.findByText('99.80%')
|
||||
expect(request).toHaveBeenCalledWith('/api/perf-metrics/summary', {
|
||||
params: { hours: 24, recent_model: ['active-model', 'idle-model'] },
|
||||
paramsSerializer: { indexes: null },
|
||||
})
|
||||
const [active, idle] = screen.getAllByLabelText(
|
||||
'Performance metrics for the last 24 hours'
|
||||
)
|
||||
expect(within(active).getByText('1.20s')).toBeVisible()
|
||||
expect(
|
||||
within(active).getByRole('img', { name: 'Success rate' }).children[0]
|
||||
).toHaveClass('bg-emerald-500')
|
||||
const samples = within(idle).getByRole('img', {
|
||||
name: 'Success rate',
|
||||
}).children
|
||||
expect(samples).toHaveLength(2)
|
||||
expect(samples[0]).toHaveClass('bg-red-500')
|
||||
expect(samples[1]).toHaveClass('bg-emerald-500')
|
||||
expect(within(idle).getByText('—')).toBeVisible()
|
||||
expect(within(idle).getByText('—s')).toBeVisible()
|
||||
expect(within(idle).getByText('—t/s')).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps group, endpoint and tag overflow counts with their own metadata', () => {
|
||||
const groups = ['default-with-a-long-group-name', 'premium', 'internal']
|
||||
const endpoints = ['openai-response', 'openai', 'claude', 'gemini', 'jina']
|
||||
@@ -262,7 +334,7 @@ describe('model cards', () => {
|
||||
const metrics = screen.getByLabelText(
|
||||
'Performance metrics for the last 24 hours'
|
||||
)
|
||||
expect(within(metrics).getByText(expected)).toBeVisible()
|
||||
expect(within(metrics).getAllByText(expected)[0]).toBeVisible()
|
||||
expect(within(metrics).getByText('Status')).toBeVisible()
|
||||
expect(within(metrics).getByText('1.20s')).toBeVisible()
|
||||
expect(within(metrics).getByText('42.0t/s')).toBeVisible()
|
||||
@@ -419,7 +491,7 @@ describe('model cards', () => {
|
||||
expect(screen.getByText(expression)).toBeVisible()
|
||||
})
|
||||
|
||||
it('keeps browsing and neutral health placeholders available after the metrics request fails', async () => {
|
||||
it('keeps browsing and missing metric values available after the metrics request fails', async () => {
|
||||
const request = vi
|
||||
.spyOn(api, 'get')
|
||||
.mockRejectedValue(new Error('metrics unavailable'))
|
||||
@@ -431,26 +503,30 @@ describe('model cards', () => {
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
queryClient.getQueryState(['perf-metrics-summary', 24])?.status
|
||||
queryClient.getQueryState([
|
||||
'perf-metrics-summary',
|
||||
24,
|
||||
['example-model'],
|
||||
])?.status
|
||||
).toBe('error')
|
||||
)
|
||||
expect(request).toHaveBeenCalledWith('/api/perf-metrics/summary', {
|
||||
params: { hours: 24 },
|
||||
params: { hours: 24, recent_model: ['example-model'] },
|
||||
paramsSerializer: { indexes: null },
|
||||
})
|
||||
expect(
|
||||
within(
|
||||
screen.getByLabelText('Performance metrics for the last 24 hours')
|
||||
).getAllByText(/^—/)
|
||||
).toHaveLength(3)
|
||||
).toHaveLength(4)
|
||||
const user = userEvent.setup()
|
||||
await user.click(screen.getByRole('button', { name: 'Details' }))
|
||||
expect(onModelClick).toHaveBeenCalledWith('example-model')
|
||||
})
|
||||
|
||||
it('paginates the model cards and disables navigation at both boundaries', async () => {
|
||||
queryClient.setQueryData(['perf-metrics-summary', 24], {
|
||||
success: true,
|
||||
data: { models: [] },
|
||||
const request = vi.spyOn(api, 'get').mockResolvedValue({
|
||||
data: { success: true, data: { models: [] } },
|
||||
})
|
||||
const models = Array.from({ length: 21 }, (_, index) =>
|
||||
pricingModel({ id: index + 1, model_name: `model-${index + 1}` })
|
||||
@@ -463,16 +539,27 @@ describe('model cards', () => {
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Previous page' })).toBeDisabled()
|
||||
expect(screen.getAllByRole('heading', { level: 3 })).toHaveLength(20)
|
||||
expect(request).toHaveBeenCalledWith('/api/perf-metrics/summary', {
|
||||
params: {
|
||||
hours: 24,
|
||||
recent_model: models.slice(0, 20).map((model) => model.model_name),
|
||||
},
|
||||
paramsSerializer: { indexes: null },
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: 'Next page' }))
|
||||
expect(screen.getAllByRole('heading', { level: 3 })).toHaveLength(1)
|
||||
expect(screen.getByRole('heading', { name: 'model-21' })).toBeVisible()
|
||||
expect(request).toHaveBeenLastCalledWith('/api/perf-metrics/summary', {
|
||||
params: { hours: 24, recent_model: ['model-21'] },
|
||||
paramsSerializer: { indexes: null },
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'Next page' })).toBeDisabled()
|
||||
await user.click(screen.getByRole('button', { name: 'Previous page' }))
|
||||
expect(screen.getByRole('heading', { name: 'model-1' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('switches the card grid to three columns at the xl breakpoint instead of 2xl', () => {
|
||||
queryClient.setQueryData(['perf-metrics-summary', 24], {
|
||||
queryClient.setQueryData(['perf-metrics-summary', 24, ['example-model']], {
|
||||
success: true,
|
||||
data: { models: [] },
|
||||
})
|
||||
@@ -489,7 +576,7 @@ describe('model cards', () => {
|
||||
expect(grid).not.toHaveClass('min-[1440px]:grid-cols-3')
|
||||
})
|
||||
|
||||
it('lights slots 23 and 18 when series has the current hour and five hours earlier', () => {
|
||||
it('shows only sparse valid samples in chronological order without placeholder bars', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-09-07T12:00:00.000Z'))
|
||||
const currentHourStart = Math.floor(Date.now() / 1000 / 3600) * 3600
|
||||
@@ -506,6 +593,7 @@ describe('model cards', () => {
|
||||
recent_success_series: [
|
||||
{ ts: currentHourStart, success_rate: 100 },
|
||||
{ ts: currentHourStart - 5 * 3600, success_rate: 80 },
|
||||
{ ts: currentHourStart - 3 * 3600, success_rate: 0 },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
@@ -513,25 +601,63 @@ describe('model cards', () => {
|
||||
|
||||
const spans = [
|
||||
...screen.getByRole('img', {
|
||||
name: 'Recent success-rate samples; gray bars indicate missing data.',
|
||||
name: 'Success rate',
|
||||
}).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)
|
||||
})
|
||||
expect(spans).toHaveLength(3)
|
||||
expect(spans[0]).toHaveClass('bg-amber-500')
|
||||
expect(spans[1]).toHaveClass('bg-red-500')
|
||||
expect(spans[2]).toHaveClass('bg-emerald-500')
|
||||
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
|
||||
it('skips invalid samples without inserting gaps or dropping a zero success rate', () => {
|
||||
const windowStart = Date.parse('2026-09-07T00:00:00.000Z') / 1000
|
||||
render(
|
||||
<ModelCard
|
||||
model={pricingModel()}
|
||||
onClick={vi.fn()}
|
||||
perf={{
|
||||
avg_latency_ms: 1200,
|
||||
avg_tps: 42,
|
||||
success_rate: 50,
|
||||
window_start: windowStart,
|
||||
recent_success_series: [
|
||||
{ ts: windowStart, success_rate: 100 },
|
||||
{ ts: windowStart + 3600, success_rate: Number.NaN },
|
||||
{ ts: windowStart + 2 * 3600, success_rate: -1 },
|
||||
{ ts: windowStart + 3 * 3600, success_rate: 101 },
|
||||
{
|
||||
ts: windowStart + 4 * 3600,
|
||||
success_rate: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
{ ts: windowStart + 5 * 3600, success_rate: 0 },
|
||||
{ ts: Number.NaN, success_rate: 100 },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const spans = [
|
||||
...screen.getByRole('img', {
|
||||
name: 'Success rate',
|
||||
}).children,
|
||||
]
|
||||
expect(spans).toHaveLength(2)
|
||||
expect(spans[0]).toHaveClass('bg-emerald-500')
|
||||
expect(spans[1]).toHaveClass('bg-red-500')
|
||||
})
|
||||
|
||||
it('shows the latest 24 valid samples without requiring hourly window metadata or mutating the series', () => {
|
||||
const recentSeries = [
|
||||
{ ts: 0, success_rate: 0 },
|
||||
...Array.from({ length: 24 }, (_, index) => ({
|
||||
ts: (index + 1) * 7200,
|
||||
success_rate: 100,
|
||||
})),
|
||||
{ ts: 25 * 7200, success_rate: Number.NaN },
|
||||
].reverse()
|
||||
const originalSeries = [...recentSeries]
|
||||
render(
|
||||
<ModelCard
|
||||
model={pricingModel()}
|
||||
@@ -540,47 +666,48 @@ describe('model cards', () => {
|
||||
avg_latency_ms: 1200,
|
||||
avg_tps: 42,
|
||||
success_rate: 100,
|
||||
window_start: currentHourStart - 23 * 3600,
|
||||
recent_success_series: [
|
||||
{ ts: currentHourStart - 24 * 3600, success_rate: 100 },
|
||||
],
|
||||
recent_success_series: recentSeries,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const spans = [
|
||||
...screen.getByRole('img', {
|
||||
name: 'Recent success-rate samples; gray bars indicate missing data.',
|
||||
name: 'Success rate',
|
||||
}).children,
|
||||
]
|
||||
expect(spans).toHaveLength(24)
|
||||
spans.forEach((slot) => {
|
||||
expect(slot.classList.contains('bg-muted-foreground/15')).toBe(true)
|
||||
spans.forEach((sample) => {
|
||||
expect(sample).toHaveClass('bg-emerald-500')
|
||||
})
|
||||
vi.useRealTimers()
|
||||
expect(recentSeries).toEqual(originalSeries)
|
||||
})
|
||||
|
||||
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 }}
|
||||
/>
|
||||
)
|
||||
it.each([undefined, [], [{ ts: 3600, success_rate: Number.NaN }]])(
|
||||
'shows an empty state without bars when the series has no valid samples: %j',
|
||||
(series) => {
|
||||
render(
|
||||
<ModelCard
|
||||
model={pricingModel()}
|
||||
onClick={vi.fn()}
|
||||
perf={{
|
||||
avg_latency_ms: 1200,
|
||||
avg_tps: 42,
|
||||
success_rate: 100,
|
||||
recent_success_series: series,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
const statusStrip = screen.getByRole('img', {
|
||||
name: 'No performance data available',
|
||||
})
|
||||
expect(statusStrip).toHaveTextContent('—')
|
||||
expect(statusStrip.querySelectorAll('[aria-hidden]')).toHaveLength(0)
|
||||
}
|
||||
)
|
||||
|
||||
it('uses the server window even when the browser clock is a day ahead', () => {
|
||||
it('shows a single server sample without padding when the browser clock is a day ahead', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-09-07T12:37:00.000Z'))
|
||||
const currentHourStart = Math.floor(Date.now() / 1000 / 3600) * 3600
|
||||
@@ -603,17 +730,11 @@ describe('model cards', () => {
|
||||
|
||||
const spans = [
|
||||
...screen.getByRole('img', {
|
||||
name: 'Recent success-rate samples; gray bars indicate missing data.',
|
||||
name: 'Success rate',
|
||||
}).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)
|
||||
})
|
||||
expect(spans).toHaveLength(1)
|
||||
expect(spans[0]).toHaveClass('bg-amber-500')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
@@ -50,17 +50,19 @@ export const ModelCardGrid = memo(function ModelCardGrid(
|
||||
const totalPages = Math.max(1, Math.ceil(props.models.length / pageSize))
|
||||
const currentPage = Math.min(page, totalPages)
|
||||
|
||||
const perfQuery = useQuery({
|
||||
queryKey: ['perf-metrics-summary', 24],
|
||||
queryFn: async () => requireServerSuccess(await getPerfMetricsSummary(24)),
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const pagedModels = useMemo(() => {
|
||||
const start = (currentPage - 1) * pageSize
|
||||
return props.models.slice(start, start + pageSize)
|
||||
}, [currentPage, pageSize, props.models])
|
||||
const recentModels = pagedModels.map((model) => model.model_name || '')
|
||||
const perfQuery = useQuery({
|
||||
queryKey: ['perf-metrics-summary', 24, recentModels],
|
||||
queryFn: async () =>
|
||||
requireServerSuccess(await getPerfMetricsSummary(24, recentModels)),
|
||||
enabled: recentModels.length > 0,
|
||||
staleTime: 60 * 1000,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const perfMap = useMemo(() => {
|
||||
const map = new Map<string, ModelPerfBadgeData>()
|
||||
@@ -71,6 +73,17 @@ export const ModelCardGrid = memo(function ModelCardGrid(
|
||||
window_end: perfQuery.data?.data.window_end,
|
||||
})
|
||||
}
|
||||
for (const [name, series] of Object.entries(
|
||||
perfQuery.data?.data?.recent_success_series ?? {}
|
||||
)) {
|
||||
map.set(name, {
|
||||
avg_latency_ms: Number.NaN,
|
||||
avg_tps: Number.NaN,
|
||||
success_rate: Number.NaN,
|
||||
...map.get(name),
|
||||
recent_success_series: series,
|
||||
})
|
||||
}
|
||||
return map
|
||||
}, [perfQuery.data])
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export interface ModelPerfBadgeProps extends React.HTMLAttributes<HTMLDivElement
|
||||
perf: ModelPerfBadgeData | undefined
|
||||
}
|
||||
|
||||
const STATUS_SLOTS = Array.from({ length: 24 }, (_, slot) => slot)
|
||||
const STATUS_SAMPLE_LIMIT = 24
|
||||
|
||||
export const ModelPerfBadge = memo(function ModelPerfBadge(
|
||||
props: ModelPerfBadgeProps
|
||||
@@ -57,20 +57,24 @@ export const ModelPerfBadge = memo(function ModelPerfBadge(
|
||||
Number.isFinite(successRate) &&
|
||||
successRate >= 0 &&
|
||||
successRate <= 100
|
||||
// Hourly points use the server window, including the current partial hour.
|
||||
// Hours without traffic stay gray. Slot 23 is the current, partial hour.
|
||||
const statusRates = useMemo(() => {
|
||||
const windowStart = props.perf?.window_start
|
||||
if (windowStart == null) return STATUS_SLOTS.map(() => undefined)
|
||||
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 = windowStart + slot * 3600
|
||||
return ratesByHour.get(hourStart)
|
||||
})
|
||||
}, [props.perf?.recent_success_series, props.perf?.window_start])
|
||||
const statusSamples = useMemo(
|
||||
() =>
|
||||
(props.perf?.recent_success_series ?? [])
|
||||
.filter(
|
||||
(point) =>
|
||||
Number.isFinite(point.ts) &&
|
||||
Number.isFinite(point.success_rate) &&
|
||||
point.success_rate >= 0 &&
|
||||
point.success_rate <= 100
|
||||
)
|
||||
.sort((a, b) => a.ts - b.ts)
|
||||
.slice(-STATUS_SAMPLE_LIMIT),
|
||||
[props.perf?.recent_success_series]
|
||||
)
|
||||
const statusLabel =
|
||||
statusSamples.length > 0
|
||||
? t('Success rate')
|
||||
: t('No performance data available')
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -95,32 +99,24 @@ export const ModelPerfBadge = memo(function ModelPerfBadge(
|
||||
</dt>
|
||||
<dd
|
||||
role='img'
|
||||
aria-label={t(
|
||||
'Recent success-rate samples; gray bars indicate missing data.'
|
||||
)}
|
||||
title={t(
|
||||
'Recent success-rate samples; gray bars indicate missing data.'
|
||||
)}
|
||||
aria-label={statusLabel}
|
||||
title={statusLabel}
|
||||
className='mt-1 flex h-3 w-24 items-center gap-px'
|
||||
>
|
||||
{STATUS_SLOTS.map((slot) => {
|
||||
const rate = statusRates[slot]
|
||||
return (
|
||||
{statusSamples.length > 0 ? (
|
||||
statusSamples.map((sample) => (
|
||||
<span
|
||||
key={slot}
|
||||
key={sample.ts}
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'h-full w-[3px] shrink-0 rounded-xs',
|
||||
rate != null &&
|
||||
Number.isFinite(rate) &&
|
||||
rate >= 0 &&
|
||||
rate <= 100
|
||||
? getSuccessRateDotClass(rate)
|
||||
: 'bg-muted-foreground/15'
|
||||
getSuccessRateDotClass(sample.success_rate)
|
||||
)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
))
|
||||
) : (
|
||||
<span className='text-muted-foreground'>—</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div title={t('Average latency')} className='shrink-0'>
|
||||
|
||||
Reference in new issue
Block a user