diff --git a/backend/internal/repository/ops_repo_request_details.go b/backend/internal/repository/ops_repo_request_details.go index d8d5d111b3..c5290495fa 100644 --- a/backend/internal/repository/ops_repo_request_details.go +++ b/backend/internal/repository/ops_repo_request_details.go @@ -93,6 +93,7 @@ WITH combined AS ( COALESCE(NULLIF(g.platform, ''), NULLIF(a.platform, ''), '') AS platform, ul.model AS model, ul.duration_ms AS duration_ms, + ul.first_token_ms AS first_token_ms, NULL::INT AS status_code, NULL::BIGINT AS error_id, NULL::TEXT AS phase, @@ -117,6 +118,7 @@ WITH combined AS ( COALESCE(NULLIF(o.platform, ''), NULLIF(g.platform, ''), NULLIF(a.platform, ''), '') AS platform, o.model AS model, o.duration_ms AS duration_ms, + o.time_to_first_token_ms AS first_token_ms, o.status_code AS status_code, o.id AS error_id, o.error_phase AS phase, @@ -152,6 +154,8 @@ WITH combined AS ( // default case "duration_desc": sort = "ORDER BY duration_ms DESC NULLS LAST, created_at DESC" + case "ttft_desc": + sort = "ORDER BY first_token_ms DESC NULLS LAST, created_at DESC" default: return nil, 0, fmt.Errorf("invalid sort") } @@ -166,6 +170,7 @@ SELECT platform, model, duration_ms, + first_token_ms, status_code, error_id, phase, @@ -213,9 +218,10 @@ LIMIT $%d OFFSET $%d platform sql.NullString model sql.NullString - durationMs sql.NullInt64 - statusCode sql.NullInt64 - errorID sql.NullInt64 + durationMs sql.NullInt64 + firstTokenMs sql.NullInt64 + statusCode sql.NullInt64 + errorID sql.NullInt64 phase sql.NullString severity sql.NullString @@ -236,6 +242,7 @@ LIMIT $%d OFFSET $%d &platform, &model, &durationMs, + &firstTokenMs, &statusCode, &errorID, &phase, @@ -257,12 +264,13 @@ LIMIT $%d OFFSET $%d Platform: strings.TrimSpace(platform.String), Model: strings.TrimSpace(model.String), - DurationMs: toIntPtr(durationMs), - StatusCode: toIntPtr(statusCode), - ErrorID: toInt64Ptr(errorID), - Phase: phase.String, - Severity: severity.String, - Message: message.String, + DurationMs: toIntPtr(durationMs), + FirstTokenMs: toIntPtr(firstTokenMs), + StatusCode: toIntPtr(statusCode), + ErrorID: toInt64Ptr(errorID), + Phase: phase.String, + Severity: severity.String, + Message: message.String, UserID: toInt64Ptr(userID), APIKeyID: toInt64Ptr(apiKeyID), diff --git a/backend/internal/repository/ops_repo_request_details_test.go b/backend/internal/repository/ops_repo_request_details_test.go new file mode 100644 index 0000000000..e22c69c327 --- /dev/null +++ b/backend/internal/repository/ops_repo_request_details_test.go @@ -0,0 +1,63 @@ +package repository + +import ( + "context" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/stretchr/testify/require" +) + +func TestOpsRepositoryListRequestDetails_LatencySort(t *testing.T) { + for _, tc := range []struct { + name string + sort string + order string + }{ + {name: "TTFT", sort: "ttft_desc", order: "first_token_ms DESC NULLS LAST, created_at DESC"}, + {name: "duration", sort: "duration_desc", order: "duration_ms DESC NULLS LAST, created_at DESC"}, + {name: "default", order: "created_at DESC"}, + } { + t.Run(tc.name, func(t *testing.T) { + db, mock := newSQLMock(t) + repo := &opsRepository{db: db} + start := time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC) + end := start.Add(time.Hour) + filter := &service.OpsRequestDetailFilter{ + StartTime: &start, + EndTime: &end, + Sort: tc.sort, + Page: 2, + PageSize: 10, + } + + mock.ExpectQuery(`SELECT COUNT\(1\) FROM combined`). + WithArgs(start, end). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(13)) + rows := sqlmock.NewRows([]string{ + "kind", "created_at", "request_id", "platform", "model", "duration_ms", "first_token_ms", + "status_code", "error_id", "phase", "severity", "message", "user_id", "api_key_id", "account_id", "group_id", "stream", + }). + AddRow("success", start, "req-slow", "openai", "gpt-5.5", 12000, 800, nil, nil, nil, nil, nil, 1, 2, 3, 4, true). + AddRow("error", start, "req-zero", "openai", "gpt-5.5", 9000, 0, 502, 5, "upstream", "error", "failed", 1, 2, 3, 4, true). + AddRow("success", start, "req-missing", "openai", "gpt-5.5", 5000, nil, nil, nil, nil, nil, nil, 1, 2, 3, 4, false) + mock.ExpectQuery(`(?s)ul\.first_token_ms AS first_token_ms.*o\.time_to_first_token_ms AS first_token_ms.*SELECT.*duration_ms,\s+first_token_ms,.*ORDER BY ` + tc.order + `\s+LIMIT \$3 OFFSET \$4`). + WithArgs(start, end, 10, 10). + WillReturnRows(rows) + + items, total, err := repo.ListRequestDetails(context.Background(), filter) + require.NoError(t, err) + require.EqualValues(t, 13, total) + require.Len(t, items, 3) + require.NotNil(t, items[0].FirstTokenMs) + require.Equal(t, 800, *items[0].FirstTokenMs) + require.Equal(t, 12000, *items[0].DurationMs) + require.NotNil(t, items[1].FirstTokenMs) + require.Zero(t, *items[1].FirstTokenMs) + require.Nil(t, items[2].FirstTokenMs) + require.NoError(t, mock.ExpectationsWereMet()) + }) + } +} diff --git a/backend/internal/service/ops_request_details.go b/backend/internal/service/ops_request_details.go index 12b9aa1be4..532cac6252 100644 --- a/backend/internal/service/ops_request_details.go +++ b/backend/internal/service/ops_request_details.go @@ -22,8 +22,9 @@ type OpsRequestDetail struct { Platform string `json:"platform,omitempty"` Model string `json:"model,omitempty"` - DurationMs *int `json:"duration_ms,omitempty"` - StatusCode *int `json:"status_code,omitempty"` + DurationMs *int `json:"duration_ms,omitempty"` + FirstTokenMs *int `json:"first_token_ms,omitempty"` + StatusCode *int `json:"status_code,omitempty"` // When Kind == "error", ErrorID links to /admin/ops/errors/:id. ErrorID *int64 `json:"error_id,omitempty"` @@ -61,7 +62,7 @@ type OpsRequestDetailFilter struct { MinDurationMs *int MaxDurationMs *int - // Sort: created_at_desc (default) or duration_desc. + // Sort: created_at_desc (default), duration_desc or ttft_desc. Sort string Page int diff --git a/frontend/src/api/admin/ops.ts b/frontend/src/api/admin/ops.ts index d9f818b385..a5ca947e11 100644 --- a/frontend/src/api/admin/ops.ts +++ b/frontend/src/api/admin/ops.ts @@ -107,7 +107,7 @@ export interface OpsThroughputTrendResponse { export type OpsRequestKind = 'success' | 'error' export type OpsRequestDetailsKind = OpsRequestKind | 'all' -export type OpsRequestDetailsSort = 'created_at_desc' | 'duration_desc' +export type OpsRequestDetailsSort = 'created_at_desc' | 'duration_desc' | 'ttft_desc' export interface OpsRequestDetail { kind: OpsRequestKind @@ -117,6 +117,7 @@ export interface OpsRequestDetail { platform?: string model?: string duration_ms?: number | null + first_token_ms?: number | null status_code?: number | null error_id?: number | null diff --git a/frontend/src/views/admin/ops/components/OpsDashboardHeader.vue b/frontend/src/views/admin/ops/components/OpsDashboardHeader.vue index c9849bc287..41040ed773 100644 --- a/frontend/src/views/admin/ops/components/OpsDashboardHeader.vue +++ b/frontend/src/views/admin/ops/components/OpsDashboardHeader.vue @@ -1337,7 +1337,7 @@ function handleToolbarRefresh() { v-if="!props.fullscreen" class="text-[10px] font-bold text-blue-500 hover:underline" type="button" - @click="openDetails({ title: t('admin.ops.ttftLabel'), sort: 'duration_desc' })" + @click="openDetails({ title: t('admin.ops.ttftLabel'), kind: 'success', sort: 'ttft_desc' })" > {{ t('admin.ops.requestDetails.details') }} diff --git a/frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue b/frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue index 2d8e885a0c..d388829897 100644 --- a/frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue +++ b/frontend/src/views/admin/ops/components/OpsRequestDetailsModal.vue @@ -47,6 +47,14 @@ const pageSize = ref(10) const close = () => emit('update:modelValue', false) +const showTTFT = computed(() => props.preset.sort === 'ttft_desc') +const latencyLabel = computed(() => t(showTTFT.value ? 'admin.ops.ttftLabel' : 'admin.ops.requestDetails.table.duration')) + +function formatLatency(row: OpsRequestDetail): string { + const value = showTTFT.value ? row.first_token_ms : row.duration_ms + return typeof value === 'number' ? `${value} ms` : '-' +} + const rangeLabel = computed(() => { const minutes = parseTimeRangeMinutes(props.timeRange) if (minutes >= 60) return t('admin.ops.requestDetails.rangeHours', { n: Math.round(minutes / 60) }) @@ -205,7 +213,7 @@ const kindBadgeClass = (kind: string) => {