fix(ops): 优化卡片标题和明细筛选逻辑
- 将"请求数"改为"请求" - SLA卡片明细只显示错误请求(kind='error') - TTFT卡片明细按延迟降序排序
This commit is contained in:
@@ -2047,7 +2047,7 @@ export default {
|
|||||||
avg: 'avg',
|
avg: 'avg',
|
||||||
max: 'max',
|
max: 'max',
|
||||||
qps: 'QPS',
|
qps: 'QPS',
|
||||||
requests: '请求数',
|
requests: '请求',
|
||||||
upstream: '上游',
|
upstream: '上游',
|
||||||
client: '客户端',
|
client: '客户端',
|
||||||
system: '系统',
|
system: '系统',
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<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 { useI18n } from 'vue-i18n'
|
||||||
import Select from '@/components/common/Select.vue'
|
import Select from '@/components/common/Select.vue'
|
||||||
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
import Icon from '@/components/icons/Icon.vue'
|
import Icon from '@/components/icons/Icon.vue'
|
||||||
import { adminAPI } from '@/api'
|
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 type { OpsRequestDetailsPreset } from './OpsRequestDetailsModal.vue'
|
||||||
import { formatNumber } from '@/utils/format'
|
import { formatNumber } from '@/utils/format'
|
||||||
|
|
||||||
@@ -50,6 +51,34 @@ const realtimeWindow = ref<RealtimeWindow>('1min')
|
|||||||
const overview = computed(() => props.overview ?? null)
|
const overview = computed(() => props.overview ?? null)
|
||||||
const systemMetrics = computed(() => overview.value?.system_metrics ?? 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 ---
|
// --- Filters ---
|
||||||
|
|
||||||
const groups = ref<Array<{ id: number; name: string; platform: string }>>([])
|
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 totalRequestsLabel = computed(() => formatNumber(overview.value?.request_count_total ?? 0))
|
||||||
const totalTokensLabel = computed(() => formatNumber(overview.value?.token_consumed ?? 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 displayRealTimeQps = computed(() => {
|
||||||
|
const v = realtimeTrafficSummary.value?.qps?.current
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) return v
|
||||||
|
|
||||||
const ov = overview.value
|
const ov = overview.value
|
||||||
if (!ov) return 0
|
if (!ov) return 0
|
||||||
const useRealtime = props.wsStatus === 'connected' && !!props.wsHasData
|
const useRealtime = props.wsStatus === 'connected' && !!props.wsHasData
|
||||||
const v = useRealtime ? props.realTimeQps : ov.qps?.current
|
const fallback = useRealtime ? props.realTimeQps : ov.qps?.current
|
||||||
return typeof v === 'number' && Number.isFinite(v) ? v : 0
|
return typeof fallback === 'number' && Number.isFinite(fallback) ? fallback : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const displayRealTimeTps = computed(() => {
|
const displayRealTimeTps = computed(() => {
|
||||||
|
const v = realtimeTrafficSummary.value?.tps?.current
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) return v
|
||||||
|
|
||||||
const ov = overview.value
|
const ov = overview.value
|
||||||
if (!ov) return 0
|
if (!ov) return 0
|
||||||
const useRealtime = props.wsStatus === 'connected' && !!props.wsHasData
|
const useRealtime = props.wsStatus === 'connected' && !!props.wsHasData
|
||||||
const v = useRealtime ? props.realTimeTps : ov.tps?.current
|
const fallback = useRealtime ? props.realTimeTps : ov.tps?.current
|
||||||
return typeof v === 'number' && Number.isFinite(v) ? v : 0
|
return typeof fallback === 'number' && Number.isFinite(fallback) ? fallback : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sparkline history (keep last 60 data points)
|
const realtimeQpsPeakLabel = computed(() => {
|
||||||
const qpsHistory = ref<number[]>([])
|
const v = realtimeTrafficSummary.value?.qps?.peak
|
||||||
const tpsHistory = ref<number[]>([])
|
return typeof v === 'number' && Number.isFinite(v) ? v.toFixed(1) : '-'
|
||||||
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 realtimeTpsPeakLabel = computed(() => {
|
||||||
const qpsPeakLabel = computed(() => {
|
const v = realtimeTrafficSummary.value?.tps?.peak
|
||||||
const v = overview.value?.qps?.peak
|
return typeof v === 'number' && Number.isFinite(v) ? v.toFixed(1) : '-'
|
||||||
if (typeof v !== 'number') return '-'
|
|
||||||
return v.toFixed(1)
|
|
||||||
})
|
})
|
||||||
|
const realtimeQpsAvgLabel = computed(() => {
|
||||||
const tpsPeakLabel = computed(() => {
|
const v = realtimeTrafficSummary.value?.qps?.avg
|
||||||
const v = overview.value?.tps?.peak
|
return typeof v === 'number' && Number.isFinite(v) ? v.toFixed(1) : '-'
|
||||||
if (typeof v !== 'number') return '-'
|
})
|
||||||
return 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(() => {
|
const qpsAvgLabel = computed(() => {
|
||||||
@@ -968,7 +1029,7 @@ function openJobsDetails() {
|
|||||||
<!-- Time Window Selector -->
|
<!-- Time Window Selector -->
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
<button
|
<button
|
||||||
v-for="window in (['1min', '5min', '30min', '1h'] as RealtimeWindow[])"
|
v-for="window in availableRealtimeWindows"
|
||||||
:key="window"
|
:key="window"
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded px-1.5 py-0.5 text-[9px] font-bold transition-colors sm:px-2 sm:text-[10px]"
|
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="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="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">
|
<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>
|
<span class="text-xs">QPS</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-baseline gap-1.5">
|
<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>
|
<span class="text-xs">TPS</span>
|
||||||
</div>
|
</div>
|
||||||
</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="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="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">
|
<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>
|
<span class="text-xs">QPS</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-baseline gap-1.5">
|
<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>
|
<span class="text-xs">TPS</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1106,7 +1167,7 @@ function openJobsDetails() {
|
|||||||
<button
|
<button
|
||||||
class="text-[10px] font-bold text-blue-500 hover:underline"
|
class="text-[10px] font-bold text-blue-500 hover:underline"
|
||||||
type="button"
|
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') }}
|
{{ t('admin.ops.requestDetails.details') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -1185,7 +1246,7 @@ function openJobsDetails() {
|
|||||||
<button
|
<button
|
||||||
class="text-[10px] font-bold text-blue-500 hover:underline"
|
class="text-[10px] font-bold text-blue-500 hover:underline"
|
||||||
type="button"
|
type="button"
|
||||||
@click="openDetails({ title: 'TTFT' })"
|
@click="openDetails({ title: 'TTFT', sort: 'duration_desc' })"
|
||||||
>
|
>
|
||||||
{{ t('admin.ops.requestDetails.details') }}
|
{{ t('admin.ops.requestDetails.details') }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user