diff --git a/controller/perf_metrics.go b/controller/perf_metrics.go index feb99c6a4a..5e2654acf8 100644 --- a/controller/perf_metrics.go +++ b/controller/perf_metrics.go @@ -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, diff --git a/model/perf_metric.go b/model/perf_metric.go index f1ae6f941b..911fb992a3 100644 --- a/model/perf_metric.go +++ b/model/perf_metric.go @@ -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 diff --git a/pkg/perf_metrics/metrics.go b/pkg/perf_metrics/metrics.go index 07d042ebc7..cf6af6d7db 100644 --- a/pkg/perf_metrics/metrics.go +++ b/pkg/perf_metrics/metrics.go @@ -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) { diff --git a/pkg/perf_metrics/metrics_test.go b/pkg/perf_metrics/metrics_test.go index 72395dd747..b4ac25a4e5 100644 --- a/pkg/perf_metrics/metrics_test.go +++ b/pkg/perf_metrics/metrics_test.go @@ -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"]) + }) }) } } diff --git a/pkg/perf_metrics/types.go b/pkg/perf_metrics/types.go index 852cdf39af..c7802019e3 100644 --- a/pkg/perf_metrics/types.go +++ b/pkg/perf_metrics/types.go @@ -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 { diff --git a/web/src/features/performance-metrics/api.ts b/web/src/features/performance-metrics/api.ts index e27ba8499e..340cd860be 100644 --- a/web/src/features/performance-metrics/api.ts +++ b/web/src/features/performance-metrics/api.ts @@ -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 { const res = await api.get('/api/perf-metrics/summary', { - params: { hours }, + params: { hours, recent_model: recentModels }, + paramsSerializer: { indexes: null }, }) return res.data } diff --git a/web/src/features/performance-metrics/types.ts b/web/src/features/performance-metrics/types.ts index df6b948d3a..4618470a0f 100644 --- a/web/src/features/performance-metrics/types.ts +++ b/web/src/features/performance-metrics/types.ts @@ -72,5 +72,6 @@ export type PerfSummaryAllData = { window_start?: number window_end?: number models: PerfModelSummary[] + recent_success_series?: Record } } diff --git a/web/src/features/pricing/__tests__/model-cards.test.tsx b/web/src/features/pricing/__tests__/model-cards.test.tsx index 65d27844ac..4300be96ff 100644 --- a/web/src/features/pricing/__tests__/model-cards.test.tsx +++ b/web/src/features/pricing/__tests__/model-cards.test.tsx @@ -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() 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() + it('uses fixed spacing between available status samples', () => { + render( + + ) 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( + + + + ) + 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( + + ) + 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( { 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( - - ) + 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( + + ) - 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() }) }) diff --git a/web/src/features/pricing/components/model-card-grid.tsx b/web/src/features/pricing/components/model-card-grid.tsx index 74bb565aea..0e6963fe36 100644 --- a/web/src/features/pricing/components/model-card-grid.tsx +++ b/web/src/features/pricing/components/model-card-grid.tsx @@ -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() @@ -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]) diff --git a/web/src/features/pricing/components/model-perf-badge.tsx b/web/src/features/pricing/components/model-perf-badge.tsx index d58427d17a..ee35a2e9b7 100644 --- a/web/src/features/pricing/components/model-perf-badge.tsx +++ b/web/src/features/pricing/components/model-perf-badge.tsx @@ -40,7 +40,7 @@ export interface ModelPerfBadgeProps extends React.HTMLAttributes 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() - 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 (
- {STATUS_SLOTS.map((slot) => { - const rate = statusRates[slot] - return ( + {statusSamples.length > 0 ? ( + statusSamples.map((sample) => ( = 0 && - rate <= 100 - ? getSuccessRateDotClass(rate) - : 'bg-muted-foreground/15' + getSuccessRateDotClass(sample.success_rate) )} /> - ) - })} + )) + ) : ( + — + )}