fix(ops): 优化卡片标题和明细筛选逻辑

- 将"请求数"改为"请求"
- SLA卡片明细只显示错误请求(kind='error')
- TTFT卡片明细按延迟降序排序
This commit is contained in:
IanShaw027
2026-01-12 13:00:39 +08:00
parent db51e65b42
commit f55ba3f6c1
2 changed files with 102 additions and 41 deletions

View File

@@ -2047,7 +2047,7 @@ export default {
avg: 'avg',
max: 'max',
qps: 'QPS',
requests: '请求',
requests: '请求',
upstream: '上游',
client: '客户端',
system: '系统',

View File

@@ -1,12 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useIntervalFn } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import Select from '@/components/common/Select.vue'
import HelpTooltip from '@/components/common/HelpTooltip.vue'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import { adminAPI } from '@/api'
import type { OpsDashboardOverview, OpsWSStatus, OpsMetricThresholds } from '@/api/admin/ops'
import { opsAPI, type OpsDashboardOverview, type OpsWSStatus, type OpsMetricThresholds, type OpsRealtimeTrafficSummary } from '@/api/admin/ops'
import type { OpsRequestDetailsPreset } from './OpsRequestDetailsModal.vue'
import { formatNumber } from '@/utils/format'
@@ -50,6 +51,34 @@ const realtimeWindow = ref<RealtimeWindow>('1min')
const overview = computed(() => props.overview ?? null)
const systemMetrics = computed(() => overview.value?.system_metrics ?? null)
const REALTIME_WINDOW_MINUTES: Record<RealtimeWindow, number> = {
'1min': 1,
'5min': 5,
'30min': 30,
'1h': 60
}
const TOOLBAR_RANGE_MINUTES: Record<string, number> = {
'5m': 5,
'30m': 30,
'1h': 60,
'6h': 6 * 60,
'24h': 24 * 60
}
const availableRealtimeWindows = computed(() => {
const toolbarMinutes = TOOLBAR_RANGE_MINUTES[props.timeRange] ?? 60
return (['1min', '5min', '30min', '1h'] as const).filter((w) => REALTIME_WINDOW_MINUTES[w] <= toolbarMinutes)
})
watch(
() => props.timeRange,
() => {
// The realtime window must be inside the toolbar window; reset to keep UX predictable.
realtimeWindow.value = '1min'
}
)
// --- Filters ---
const groups = ref<Array<{ id: number; name: string; platform: string }>>([])
@@ -186,51 +215,83 @@ function isUpstreamErrorRateAboveThreshold(upstreamErrorRatePercent: number | nu
const totalRequestsLabel = computed(() => formatNumber(overview.value?.request_count_total ?? 0))
const totalTokensLabel = computed(() => formatNumber(overview.value?.token_consumed ?? 0))
const realtimeTrafficSummary = ref<OpsRealtimeTrafficSummary | null>(null)
const realtimeTrafficLoading = ref(false)
async function loadRealtimeTrafficSummary() {
if (realtimeTrafficLoading.value) return
realtimeTrafficLoading.value = true
try {
realtimeTrafficSummary.value = await opsAPI.getRealtimeTrafficSummary(realtimeWindow.value, props.platform, props.groupId)
} catch (err) {
console.error('[OpsDashboardHeader] Failed to load realtime traffic summary', err)
realtimeTrafficSummary.value = null
} finally {
realtimeTrafficLoading.value = false
}
}
watch(
() => [realtimeWindow.value, props.platform, props.groupId] as const,
() => {
loadRealtimeTrafficSummary()
},
{ immediate: true }
)
const { pause: pauseRealtimeTrafficRefresh, resume: resumeRealtimeTrafficRefresh } = useIntervalFn(
() => {
loadRealtimeTrafficSummary()
},
5000,
{ immediate: false }
)
onMounted(() => {
resumeRealtimeTrafficRefresh()
})
onUnmounted(() => {
pauseRealtimeTrafficRefresh()
})
const displayRealTimeQps = computed(() => {
const v = realtimeTrafficSummary.value?.qps?.current
if (typeof v === 'number' && Number.isFinite(v)) return v
const ov = overview.value
if (!ov) return 0
const useRealtime = props.wsStatus === 'connected' && !!props.wsHasData
const v = useRealtime ? props.realTimeQps : ov.qps?.current
return typeof v === 'number' && Number.isFinite(v) ? v : 0
const fallback = useRealtime ? props.realTimeQps : ov.qps?.current
return typeof fallback === 'number' && Number.isFinite(fallback) ? fallback : 0
})
const displayRealTimeTps = computed(() => {
const v = realtimeTrafficSummary.value?.tps?.current
if (typeof v === 'number' && Number.isFinite(v)) return v
const ov = overview.value
if (!ov) return 0
const useRealtime = props.wsStatus === 'connected' && !!props.wsHasData
const v = useRealtime ? props.realTimeTps : ov.tps?.current
return typeof v === 'number' && Number.isFinite(v) ? v : 0
const fallback = useRealtime ? props.realTimeTps : ov.tps?.current
return typeof fallback === 'number' && Number.isFinite(fallback) ? fallback : 0
})
// Sparkline history (keep last 60 data points)
const qpsHistory = ref<number[]>([])
const tpsHistory = ref<number[]>([])
const MAX_HISTORY_POINTS = 60
watch([displayRealTimeQps, displayRealTimeTps], ([newQps, newTps]) => {
// Add new data points
qpsHistory.value.push(newQps)
tpsHistory.value.push(newTps)
// Keep only last N points
if (qpsHistory.value.length > MAX_HISTORY_POINTS) {
qpsHistory.value.shift()
}
if (tpsHistory.value.length > MAX_HISTORY_POINTS) {
tpsHistory.value.shift()
}
const realtimeQpsPeakLabel = computed(() => {
const v = realtimeTrafficSummary.value?.qps?.peak
return typeof v === 'number' && Number.isFinite(v) ? v.toFixed(1) : '-'
})
const qpsPeakLabel = computed(() => {
const v = overview.value?.qps?.peak
if (typeof v !== 'number') return '-'
return v.toFixed(1)
const realtimeTpsPeakLabel = computed(() => {
const v = realtimeTrafficSummary.value?.tps?.peak
return typeof v === 'number' && Number.isFinite(v) ? v.toFixed(1) : '-'
})
const tpsPeakLabel = computed(() => {
const v = overview.value?.tps?.peak
if (typeof v !== 'number') return '-'
return v.toFixed(1)
const realtimeQpsAvgLabel = computed(() => {
const v = realtimeTrafficSummary.value?.qps?.avg
return typeof v === 'number' && Number.isFinite(v) ? v.toFixed(1) : '-'
})
const realtimeTpsAvgLabel = computed(() => {
const v = realtimeTrafficSummary.value?.tps?.avg
return typeof v === 'number' && Number.isFinite(v) ? v.toFixed(1) : '-'
})
const qpsAvgLabel = computed(() => {
@@ -968,7 +1029,7 @@ function openJobsDetails() {
<!-- Time Window Selector -->
<div class="flex flex-wrap gap-1">
<button
v-for="window in (['1min', '5min', '30min', '1h'] as RealtimeWindow[])"
v-for="window in availableRealtimeWindows"
:key="window"
type="button"
class="rounded px-1.5 py-0.5 text-[9px] font-bold transition-colors sm:px-2 sm:text-[10px]"
@@ -1005,11 +1066,11 @@ function openJobsDetails() {
<div class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.peak') }}</div>
<div class="mt-1 space-y-0.5 text-sm font-medium text-gray-600 dark:text-gray-400">
<div class="flex items-baseline gap-1.5">
<span class="font-black text-gray-900 dark:text-white">{{ qpsPeakLabel }}</span>
<span class="font-black text-gray-900 dark:text-white">{{ realtimeQpsPeakLabel }}</span>
<span class="text-xs">QPS</span>
</div>
<div class="flex items-baseline gap-1.5">
<span class="font-black text-gray-900 dark:text-white">{{ tpsPeakLabel }}</span>
<span class="font-black text-gray-900 dark:text-white">{{ realtimeTpsPeakLabel }}</span>
<span class="text-xs">TPS</span>
</div>
</div>
@@ -1020,11 +1081,11 @@ function openJobsDetails() {
<div class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.average') }}</div>
<div class="mt-1 space-y-0.5 text-sm font-medium text-gray-600 dark:text-gray-400">
<div class="flex items-baseline gap-1.5">
<span class="font-black text-gray-900 dark:text-white">{{ qpsAvgLabel }}</span>
<span class="font-black text-gray-900 dark:text-white">{{ realtimeQpsAvgLabel }}</span>
<span class="text-xs">QPS</span>
</div>
<div class="flex items-baseline gap-1.5">
<span class="font-black text-gray-900 dark:text-white">{{ tpsAvgLabel }}</span>
<span class="font-black text-gray-900 dark:text-white">{{ realtimeTpsAvgLabel }}</span>
<span class="text-xs">TPS</span>
</div>
</div>
@@ -1106,7 +1167,7 @@ function openJobsDetails() {
<button
class="text-[10px] font-bold text-blue-500 hover:underline"
type="button"
@click="openDetails({ title: t('admin.ops.requestDetails.title') })"
@click="openDetails({ title: t('admin.ops.requestDetails.title'), kind: 'error' })"
>
{{ t('admin.ops.requestDetails.details') }}
</button>
@@ -1185,7 +1246,7 @@ function openJobsDetails() {
<button
class="text-[10px] font-bold text-blue-500 hover:underline"
type="button"
@click="openDetails({ title: 'TTFT' })"
@click="openDetails({ title: 'TTFT', sort: 'duration_desc' })"
>
{{ t('admin.ops.requestDetails.details') }}
</button>