feat(前端UI): 实现运维监控前端界面
- 新增帮助提示组件(HelpTooltip.vue) - 更新侧边栏添加 ops 监控菜单项 - 扩展设置视图集成 ops 配置面板 - 新增 ops 监控视图目录(dashboard, alerts, realtime, settings 等)
This commit is contained in:
854
frontend/src/views/admin/ops/OpsDashboard.vue
Normal file
854
frontend/src/views/admin/ops/OpsDashboard.vue
Normal file
@@ -0,0 +1,854 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6 pb-12">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-2xl bg-red-50 p-4 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<OpsDashboardSkeleton v-if="loading && !hasLoadedOnce" />
|
||||
|
||||
<OpsDashboardHeader
|
||||
v-else-if="opsEnabled"
|
||||
:overview="overview"
|
||||
:ws-status="wsStatus"
|
||||
:ws-reconnect-in-ms="wsReconnectInMs"
|
||||
:ws-has-data="wsHasData"
|
||||
:real-time-qps="realTimeQPS"
|
||||
:real-time-tps="realTimeTPS"
|
||||
:platform="platform"
|
||||
:group-id="groupId"
|
||||
:time-range="timeRange"
|
||||
:query-mode="queryMode"
|
||||
:loading="loading"
|
||||
:last-updated="lastUpdated"
|
||||
@update:time-range="onTimeRangeChange"
|
||||
@update:platform="onPlatformChange"
|
||||
@update:group="onGroupChange"
|
||||
@update:query-mode="onQueryModeChange"
|
||||
@refresh="fetchData"
|
||||
@open-request-details="handleOpenRequestDetails"
|
||||
@open-error-details="openErrorDetails"
|
||||
/>
|
||||
|
||||
<!-- Overview -->
|
||||
<div
|
||||
v-if="opsEnabled && !(loading && !hasLoadedOnce)"
|
||||
class="overflow-hidden rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700"
|
||||
>
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.systemHealth') }}</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div v-if="loadingOverview" class="flex items-center justify-center py-10">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!overview?.system_metrics" class="py-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.noSystemMetrics') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.collectedAt') }} {{ formatDateTime(overview.system_metrics.created_at) }} ({{ t('admin.ops.window') }}
|
||||
{{ overview.system_metrics.window_minutes }}m)
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-5">
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800/50">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.cpu') }}</div>
|
||||
<div class="mt-1 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ formatPercent0to100(overview.system_metrics.cpu_usage_percent) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800/50">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.memory') }}</div>
|
||||
<div class="mt-1 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ formatPercent0to100(overview.system_metrics.memory_usage_percent) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatMBPair(overview.system_metrics.memory_used_mb, overview.system_metrics.memory_total_mb) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800/50">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.db') }}</div>
|
||||
<div class="mt-1 text-xl font-semibold" :class="boolOkClass(overview.system_metrics.db_ok)">
|
||||
{{ boolOkLabel(overview.system_metrics.db_ok) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.active') }}: {{ overview.system_metrics.db_conn_active ?? '-' }}, {{ t('admin.ops.idle') }}:
|
||||
{{ overview.system_metrics.db_conn_idle ?? '-' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800/50">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.redis') }}</div>
|
||||
<div class="mt-1 text-xl font-semibold" :class="boolOkClass(overview.system_metrics.redis_ok)">
|
||||
{{ boolOkLabel(overview.system_metrics.redis_ok) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800/50">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.goroutines') }}</div>
|
||||
<div class="mt-1 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ overview.system_metrics.goroutine_count ?? '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="overview?.job_heartbeats?.length" class="rounded-xl border border-gray-100 dark:border-dark-700">
|
||||
<div class="border-b border-gray-100 px-4 py-3 text-sm font-semibold text-gray-900 dark:border-dark-700 dark:text-white">
|
||||
{{ t('admin.ops.jobs') }}
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-dark-700">
|
||||
<div
|
||||
v-for="job in overview.job_heartbeats"
|
||||
:key="job.job_name"
|
||||
class="flex flex-col gap-1 px-4 py-3 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ job.job_name }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.lastRun') }}: {{ job.last_run_at ? formatDateTime(job.last_run_at) : '-' }} · {{ t('admin.ops.lastSuccess') }}:
|
||||
{{ job.last_success_at ? formatDateTime(job.last_success_at) : '-' }} ·
|
||||
<span v-if="job.last_error" class="text-rose-600 dark:text-rose-400">
|
||||
{{ t('admin.ops.lastError') }}: {{ job.last_error }}
|
||||
</span>
|
||||
<span v-else class="text-emerald-600 dark:text-emerald-400">{{ t('admin.ops.ok') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="opsEnabled && !(loading && !hasLoadedOnce)" class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.overview') }}</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div v-if="loadingOverview" class="flex items-center justify-center py-10">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!overview" class="py-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.noData') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800/50">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.requestsTotal') }}</div>
|
||||
<div class="mt-1 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ formatInt(overview.request_count_total) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.slaScope') }} {{ formatInt(overview.request_count_sla) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800/50">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.tokens') }}</div>
|
||||
<div class="mt-1 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ formatInt(overview.token_consumed) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.tps') }} {{ overview.tps.current }} ({{ t('admin.ops.peak') }} {{ overview.tps.peak }})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800/50">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.sla') }}</div>
|
||||
<div class="mt-1 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ formatPercent(overview.sla) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.businessLimited') }}: {{ formatInt(overview.business_limited_count) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800/50">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.errors') }}</div>
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ t('admin.ops.errorRate') }}: <span class="font-semibold">{{ formatPercent(overview.error_rate) }}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ t('admin.ops.upstreamRate') }}: <span class="font-semibold">{{ formatPercent(overview.upstream_error_rate) }}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
429: {{ formatInt(overview.upstream_429_count) }} · 529:
|
||||
{{ formatInt(overview.upstream_529_count) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.latencyDuration') }}</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 text-xs text-gray-600 dark:text-gray-300 md:grid-cols-3">
|
||||
<div>{{ t('admin.ops.p50') }}: <span class="font-mono">{{ formatMs(overview.duration.p50_ms) }}</span></div>
|
||||
<div>{{ t('admin.ops.p90') }}: <span class="font-mono">{{ formatMs(overview.duration.p90_ms) }}</span></div>
|
||||
<div>{{ t('admin.ops.p95') }}: <span class="font-mono">{{ formatMs(overview.duration.p95_ms) }}</span></div>
|
||||
<div>{{ t('admin.ops.p99') }}: <span class="font-mono">{{ formatMs(overview.duration.p99_ms) }}</span></div>
|
||||
<div>{{ t('admin.ops.avg') }}: <span class="font-mono">{{ formatMs(overview.duration.avg_ms) }}</span></div>
|
||||
<div>{{ t('admin.ops.max') }}: <span class="font-mono">{{ formatMs(overview.duration.max_ms) }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.ttftLabel') }}</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 text-xs text-gray-600 dark:text-gray-300 md:grid-cols-3">
|
||||
<div>{{ t('admin.ops.p50') }}: <span class="font-mono">{{ formatMs(overview.ttft.p50_ms) }}</span></div>
|
||||
<div>{{ t('admin.ops.p90') }}: <span class="font-mono">{{ formatMs(overview.ttft.p90_ms) }}</span></div>
|
||||
<div>{{ t('admin.ops.p95') }}: <span class="font-mono">{{ formatMs(overview.ttft.p95_ms) }}</span></div>
|
||||
<div>{{ t('admin.ops.p99') }}: <span class="font-mono">{{ formatMs(overview.ttft.p99_ms) }}</span></div>
|
||||
<div>{{ t('admin.ops.avg') }}: <span class="font-mono">{{ formatMs(overview.ttft.avg_ms) }}</span></div>
|
||||
<div>{{ t('admin.ops.max') }}: <span class="font-mono">{{ formatMs(overview.ttft.max_ms) }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row: Concurrency + Throughput -->
|
||||
<div v-if="opsEnabled && !(loading && !hasLoadedOnce)" class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-1 min-h-[360px]">
|
||||
<OpsConcurrencyCard :platform-filter="platform" :group-id-filter="groupId" />
|
||||
</div>
|
||||
<div class="lg:col-span-2 min-h-[360px]">
|
||||
<OpsThroughputTrendChart
|
||||
:points="throughputTrend?.points ?? []"
|
||||
:by-platform="throughputTrend?.by_platform ?? []"
|
||||
:top-groups="throughputTrend?.top_groups ?? []"
|
||||
:loading="loadingTrend"
|
||||
:time-range="timeRange"
|
||||
@select-platform="handleThroughputSelectPlatform"
|
||||
@select-group="handleThroughputSelectGroup"
|
||||
@open-details="handleOpenRequestDetails"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row: Visual Analysis (baseline 3-up grid) -->
|
||||
<div v-if="opsEnabled && !(loading && !hasLoadedOnce)" class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<OpsLatencyChart :latency-data="latencyHistogram" :loading="loadingLatency" />
|
||||
<OpsErrorDistributionChart
|
||||
:data="errorDistribution"
|
||||
:loading="loadingErrorDistribution"
|
||||
@open-details="openErrorDetails('request')"
|
||||
/>
|
||||
<OpsErrorTrendChart
|
||||
:points="errorTrend?.points ?? []"
|
||||
:loading="loadingErrorTrend"
|
||||
:time-range="timeRange"
|
||||
@open-request-errors="openErrorDetails('request')"
|
||||
@open-upstream-errors="openErrorDetails('upstream')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Alert Events -->
|
||||
<OpsAlertEventsCard v-if="opsEnabled && !(loading && !hasLoadedOnce)" />
|
||||
|
||||
<OpsErrorDetailsModal
|
||||
:show="showErrorDetails"
|
||||
:time-range="timeRange"
|
||||
:platform="platform"
|
||||
:group-id="groupId"
|
||||
:error-type="errorDetailsType"
|
||||
@update:show="showErrorDetails = $event"
|
||||
@openErrorDetail="openError"
|
||||
/>
|
||||
|
||||
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" />
|
||||
|
||||
<OpsRequestDetailsModal
|
||||
v-model="showRequestDetails"
|
||||
:time-range="timeRange"
|
||||
:preset="requestDetailsPreset"
|
||||
:platform="platform"
|
||||
:group-id="groupId"
|
||||
@openErrorDetail="openError"
|
||||
/>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import {
|
||||
opsAPI,
|
||||
OPS_WS_CLOSE_CODES,
|
||||
type OpsWSStatus,
|
||||
type OpsDashboardOverview,
|
||||
type OpsErrorDistributionResponse,
|
||||
type OpsErrorTrendResponse,
|
||||
type OpsLatencyHistogramResponse,
|
||||
type OpsThroughputTrendResponse
|
||||
} from '@/api/admin/ops'
|
||||
import { useAdminSettingsStore, useAppStore } from '@/stores'
|
||||
import OpsDashboardHeader from './components/OpsDashboardHeader.vue'
|
||||
import OpsDashboardSkeleton from './components/OpsDashboardSkeleton.vue'
|
||||
import OpsConcurrencyCard from './components/OpsConcurrencyCard.vue'
|
||||
import OpsErrorDetailModal from './components/OpsErrorDetailModal.vue'
|
||||
import OpsErrorDistributionChart from './components/OpsErrorDistributionChart.vue'
|
||||
import OpsErrorDetailsModal from './components/OpsErrorDetailsModal.vue'
|
||||
import OpsErrorTrendChart from './components/OpsErrorTrendChart.vue'
|
||||
import OpsLatencyChart from './components/OpsLatencyChart.vue'
|
||||
import OpsThroughputTrendChart from './components/OpsThroughputTrendChart.vue'
|
||||
import OpsAlertEventsCard from './components/OpsAlertEventsCard.vue'
|
||||
import OpsRequestDetailsModal, { type OpsRequestDetailsPreset } from './components/OpsRequestDetailsModal.vue'
|
||||
import { formatDateTime, formatNumberLocaleString } from '@/utils/format'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
const adminSettingsStore = useAdminSettingsStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const opsEnabled = computed(() => adminSettingsStore.opsMonitoringEnabled)
|
||||
|
||||
type TimeRange = '5m' | '30m' | '1h' | '6h' | '24h'
|
||||
const allowedTimeRanges = new Set<TimeRange>(['5m', '30m', '1h', '6h', '24h'])
|
||||
|
||||
type QueryMode = 'auto' | 'raw' | 'preagg'
|
||||
const allowedQueryModes = new Set<QueryMode>(['auto', 'raw', 'preagg'])
|
||||
|
||||
const loading = ref(true)
|
||||
const hasLoadedOnce = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const lastUpdated = ref<Date | null>(new Date())
|
||||
|
||||
const timeRange = ref<TimeRange>('1h')
|
||||
const platform = ref<string>('')
|
||||
const groupId = ref<number | null>(null)
|
||||
const queryMode = ref<QueryMode>('auto')
|
||||
|
||||
const QUERY_KEYS = {
|
||||
timeRange: 'tr',
|
||||
platform: 'platform',
|
||||
groupId: 'group_id',
|
||||
queryMode: 'mode'
|
||||
} as const
|
||||
|
||||
const isApplyingRouteQuery = ref(false)
|
||||
const isSyncingRouteQuery = ref(false)
|
||||
|
||||
// WebSocket for realtime QPS/TPS
|
||||
const realTimeQPS = ref(0)
|
||||
const realTimeTPS = ref(0)
|
||||
const wsStatus = ref<OpsWSStatus>('closed')
|
||||
const wsReconnectInMs = ref<number | null>(null)
|
||||
const wsHasData = ref(false)
|
||||
let unsubscribeQPS: (() => void) | null = null
|
||||
|
||||
let dashboardFetchController: AbortController | null = null
|
||||
let dashboardFetchSeq = 0
|
||||
|
||||
function isCanceledRequest(err: unknown): boolean {
|
||||
return (
|
||||
!!err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
(err as Record<string, unknown>).code === 'ERR_CANCELED'
|
||||
)
|
||||
}
|
||||
|
||||
function abortDashboardFetch() {
|
||||
if (dashboardFetchController) {
|
||||
dashboardFetchController.abort()
|
||||
dashboardFetchController = null
|
||||
}
|
||||
}
|
||||
|
||||
function stopQPSSubscription(options?: { resetMetrics?: boolean }) {
|
||||
wsStatus.value = 'closed'
|
||||
wsReconnectInMs.value = null
|
||||
if (unsubscribeQPS) unsubscribeQPS()
|
||||
unsubscribeQPS = null
|
||||
|
||||
if (options?.resetMetrics) {
|
||||
realTimeQPS.value = 0
|
||||
realTimeTPS.value = 0
|
||||
wsHasData.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startQPSSubscription() {
|
||||
stopQPSSubscription()
|
||||
unsubscribeQPS = opsAPI.subscribeQPS(
|
||||
(payload) => {
|
||||
if (payload && typeof payload === 'object' && payload.type === 'qps_update' && payload.data) {
|
||||
realTimeQPS.value = payload.data.qps || 0
|
||||
realTimeTPS.value = payload.data.tps || 0
|
||||
wsHasData.value = true
|
||||
}
|
||||
},
|
||||
{
|
||||
onStatusChange: (status) => {
|
||||
wsStatus.value = status
|
||||
if (status === 'connected') wsReconnectInMs.value = null
|
||||
},
|
||||
onReconnectScheduled: ({ delayMs }) => {
|
||||
wsReconnectInMs.value = delayMs
|
||||
},
|
||||
onFatalClose: (event) => {
|
||||
// Server-side feature flag says realtime is disabled; keep UI consistent and avoid reconnect loops.
|
||||
if (event && event.code === OPS_WS_CLOSE_CODES.REALTIME_DISABLED) {
|
||||
adminSettingsStore.setOpsRealtimeMonitoringEnabledLocal(false)
|
||||
stopQPSSubscription({ resetMetrics: true })
|
||||
}
|
||||
},
|
||||
// QPS updates may be sparse in idle periods; keep the timeout conservative.
|
||||
staleTimeoutMs: 180_000
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const readQueryString = (key: string): string => {
|
||||
const value = route.query[key]
|
||||
if (typeof value === 'string') return value
|
||||
if (Array.isArray(value) && typeof value[0] === 'string') return value[0]
|
||||
return ''
|
||||
}
|
||||
|
||||
const readQueryNumber = (key: string): number | null => {
|
||||
const raw = readQueryString(key)
|
||||
if (!raw) return null
|
||||
const n = Number.parseInt(raw, 10)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
const applyRouteQueryToState = () => {
|
||||
const nextTimeRange = readQueryString(QUERY_KEYS.timeRange)
|
||||
if (nextTimeRange && allowedTimeRanges.has(nextTimeRange as TimeRange)) {
|
||||
timeRange.value = nextTimeRange as TimeRange
|
||||
}
|
||||
|
||||
platform.value = readQueryString(QUERY_KEYS.platform) || ''
|
||||
|
||||
const groupIdRaw = readQueryNumber(QUERY_KEYS.groupId)
|
||||
groupId.value = typeof groupIdRaw === 'number' && groupIdRaw > 0 ? groupIdRaw : null
|
||||
|
||||
const nextMode = readQueryString(QUERY_KEYS.queryMode)
|
||||
if (nextMode && allowedQueryModes.has(nextMode as QueryMode)) {
|
||||
queryMode.value = nextMode as QueryMode
|
||||
} else {
|
||||
const fallback = adminSettingsStore.opsQueryModeDefault || 'auto'
|
||||
queryMode.value = allowedQueryModes.has(fallback as QueryMode) ? (fallback as QueryMode) : 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
applyRouteQueryToState()
|
||||
|
||||
const buildQueryFromState = () => {
|
||||
const next: Record<string, any> = { ...route.query }
|
||||
|
||||
Object.values(QUERY_KEYS).forEach((k) => {
|
||||
delete next[k]
|
||||
})
|
||||
|
||||
if (timeRange.value !== '1h') next[QUERY_KEYS.timeRange] = timeRange.value
|
||||
if (platform.value) next[QUERY_KEYS.platform] = platform.value
|
||||
if (typeof groupId.value === 'number' && groupId.value > 0) next[QUERY_KEYS.groupId] = String(groupId.value)
|
||||
if (queryMode.value !== 'auto') next[QUERY_KEYS.queryMode] = queryMode.value
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
const syncQueryToRoute = useDebounceFn(async () => {
|
||||
if (isApplyingRouteQuery.value) return
|
||||
const nextQuery = buildQueryFromState()
|
||||
|
||||
const curr = route.query as Record<string, any>
|
||||
const nextKeys = Object.keys(nextQuery)
|
||||
const currKeys = Object.keys(curr)
|
||||
const sameLength = nextKeys.length === currKeys.length
|
||||
const sameValues = sameLength && nextKeys.every((k) => String(curr[k] ?? '') === String(nextQuery[k] ?? ''))
|
||||
if (sameValues) return
|
||||
|
||||
try {
|
||||
isSyncingRouteQuery.value = true
|
||||
await router.replace({ query: nextQuery })
|
||||
} finally {
|
||||
isSyncingRouteQuery.value = false
|
||||
}
|
||||
}, 250)
|
||||
|
||||
const overview = ref<OpsDashboardOverview | null>(null)
|
||||
const loadingOverview = ref(false)
|
||||
|
||||
const throughputTrend = ref<OpsThroughputTrendResponse | null>(null)
|
||||
const loadingTrend = ref(false)
|
||||
|
||||
const latencyHistogram = ref<OpsLatencyHistogramResponse | null>(null)
|
||||
const loadingLatency = ref(false)
|
||||
|
||||
const errorTrend = ref<OpsErrorTrendResponse | null>(null)
|
||||
const loadingErrorTrend = ref(false)
|
||||
|
||||
const errorDistribution = ref<OpsErrorDistributionResponse | null>(null)
|
||||
const loadingErrorDistribution = ref(false)
|
||||
|
||||
const selectedErrorId = ref<number | null>(null)
|
||||
const showErrorModal = ref(false)
|
||||
|
||||
const showErrorDetails = ref(false)
|
||||
const errorDetailsType = ref<'request' | 'upstream'>('request')
|
||||
|
||||
const showRequestDetails = ref(false)
|
||||
const requestDetailsPreset = ref<OpsRequestDetailsPreset>({
|
||||
title: '',
|
||||
kind: 'all',
|
||||
sort: 'created_at_desc'
|
||||
})
|
||||
|
||||
function handleThroughputSelectPlatform(nextPlatform: string) {
|
||||
platform.value = nextPlatform || ''
|
||||
groupId.value = null
|
||||
}
|
||||
|
||||
function handleThroughputSelectGroup(nextGroupId: number) {
|
||||
const id = Number.isFinite(nextGroupId) && nextGroupId > 0 ? nextGroupId : null
|
||||
groupId.value = id
|
||||
}
|
||||
|
||||
function handleOpenRequestDetails() {
|
||||
requestDetailsPreset.value = {
|
||||
title: t('admin.ops.requestDetails.title'),
|
||||
kind: 'all',
|
||||
sort: 'created_at_desc'
|
||||
}
|
||||
showRequestDetails.value = true
|
||||
}
|
||||
|
||||
function openErrorDetails(kind: 'request' | 'upstream') {
|
||||
errorDetailsType.value = kind
|
||||
showErrorDetails.value = true
|
||||
}
|
||||
|
||||
function onTimeRangeChange(v: string | number | boolean | null) {
|
||||
if (typeof v !== 'string') return
|
||||
if (!allowedTimeRanges.has(v as TimeRange)) return
|
||||
timeRange.value = v as TimeRange
|
||||
}
|
||||
|
||||
function onPlatformChange(v: string | number | boolean | null) {
|
||||
platform.value = typeof v === 'string' ? v : ''
|
||||
}
|
||||
|
||||
function onGroupChange(v: string | number | boolean | null) {
|
||||
if (v === null) {
|
||||
groupId.value = null
|
||||
return
|
||||
}
|
||||
if (typeof v === 'number') {
|
||||
groupId.value = v > 0 ? v : null
|
||||
return
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
const n = Number.parseInt(v, 10)
|
||||
groupId.value = Number.isFinite(n) && n > 0 ? n : null
|
||||
}
|
||||
}
|
||||
|
||||
function onQueryModeChange(v: string | number | boolean | null) {
|
||||
if (typeof v !== 'string') return
|
||||
if (!allowedQueryModes.has(v as QueryMode)) return
|
||||
queryMode.value = v as QueryMode
|
||||
}
|
||||
|
||||
function openError(id: number) {
|
||||
selectedErrorId.value = id
|
||||
showErrorModal.value = true
|
||||
}
|
||||
|
||||
function formatInt(v: number | null | undefined): string {
|
||||
if (typeof v !== 'number') return '0'
|
||||
return formatNumberLocaleString(v)
|
||||
}
|
||||
|
||||
function formatPercent(v: number | null | undefined): string {
|
||||
if (typeof v !== 'number') return '-'
|
||||
return `${(v * 100).toFixed(2)}%`
|
||||
}
|
||||
|
||||
function formatPercent0to100(v: number | null | undefined): string {
|
||||
if (typeof v !== 'number') return '-'
|
||||
return `${v.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatMBPair(used: number | null | undefined, total: number | null | undefined): string {
|
||||
if (typeof used !== 'number' || typeof total !== 'number') return '-'
|
||||
return `${formatNumberLocaleString(used)} / ${formatNumberLocaleString(total)} MB`
|
||||
}
|
||||
|
||||
function boolOkLabel(v: boolean | null | undefined): string {
|
||||
if (v === true) return 'OK'
|
||||
if (v === false) return 'FAIL'
|
||||
return '-'
|
||||
}
|
||||
|
||||
function boolOkClass(v: boolean | null | undefined): string {
|
||||
if (v === true) return 'text-emerald-600 dark:text-emerald-400'
|
||||
if (v === false) return 'text-rose-600 dark:text-rose-400'
|
||||
return 'text-gray-900 dark:text-white'
|
||||
}
|
||||
|
||||
function formatMs(v: number | null | undefined): string {
|
||||
if (v == null) return '-'
|
||||
return `${v}ms`
|
||||
}
|
||||
|
||||
async function refreshOverviewWithCancel(fetchSeq: number, signal: AbortSignal) {
|
||||
if (!opsEnabled.value) return
|
||||
loadingOverview.value = true
|
||||
try {
|
||||
const data = await opsAPI.getDashboardOverview(
|
||||
{
|
||||
time_range: timeRange.value,
|
||||
platform: platform.value || undefined,
|
||||
group_id: groupId.value ?? undefined,
|
||||
mode: queryMode.value
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
if (fetchSeq !== dashboardFetchSeq) return
|
||||
overview.value = data
|
||||
} catch (err: any) {
|
||||
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
|
||||
overview.value = null
|
||||
appStore.showError(err?.message || 'Failed to load overview')
|
||||
} finally {
|
||||
if (fetchSeq === dashboardFetchSeq) {
|
||||
loadingOverview.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshThroughputTrendWithCancel(fetchSeq: number, signal: AbortSignal) {
|
||||
if (!opsEnabled.value) return
|
||||
loadingTrend.value = true
|
||||
try {
|
||||
const data = await opsAPI.getThroughputTrend(
|
||||
{
|
||||
time_range: timeRange.value,
|
||||
platform: platform.value || undefined,
|
||||
group_id: groupId.value ?? undefined,
|
||||
mode: queryMode.value
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
if (fetchSeq !== dashboardFetchSeq) return
|
||||
throughputTrend.value = data
|
||||
} catch (err: any) {
|
||||
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
|
||||
throughputTrend.value = null
|
||||
appStore.showError(err?.message || 'Failed to load throughput trend')
|
||||
} finally {
|
||||
if (fetchSeq === dashboardFetchSeq) {
|
||||
loadingTrend.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLatencyHistogramWithCancel(fetchSeq: number, signal: AbortSignal) {
|
||||
if (!opsEnabled.value) return
|
||||
loadingLatency.value = true
|
||||
try {
|
||||
const data = await opsAPI.getLatencyHistogram(
|
||||
{
|
||||
time_range: timeRange.value,
|
||||
platform: platform.value || undefined,
|
||||
group_id: groupId.value ?? undefined,
|
||||
mode: queryMode.value
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
if (fetchSeq !== dashboardFetchSeq) return
|
||||
latencyHistogram.value = data
|
||||
} catch (err: any) {
|
||||
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
|
||||
latencyHistogram.value = null
|
||||
appStore.showError(err?.message || 'Failed to load latency histogram')
|
||||
} finally {
|
||||
if (fetchSeq === dashboardFetchSeq) {
|
||||
loadingLatency.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshErrorTrendWithCancel(fetchSeq: number, signal: AbortSignal) {
|
||||
if (!opsEnabled.value) return
|
||||
loadingErrorTrend.value = true
|
||||
try {
|
||||
const data = await opsAPI.getErrorTrend(
|
||||
{
|
||||
time_range: timeRange.value,
|
||||
platform: platform.value || undefined,
|
||||
group_id: groupId.value ?? undefined,
|
||||
mode: queryMode.value
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
if (fetchSeq !== dashboardFetchSeq) return
|
||||
errorTrend.value = data
|
||||
} catch (err: any) {
|
||||
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
|
||||
errorTrend.value = null
|
||||
appStore.showError(err?.message || 'Failed to load error trend')
|
||||
} finally {
|
||||
if (fetchSeq === dashboardFetchSeq) {
|
||||
loadingErrorTrend.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshErrorDistributionWithCancel(fetchSeq: number, signal: AbortSignal) {
|
||||
if (!opsEnabled.value) return
|
||||
loadingErrorDistribution.value = true
|
||||
try {
|
||||
const data = await opsAPI.getErrorDistribution(
|
||||
{
|
||||
time_range: timeRange.value,
|
||||
platform: platform.value || undefined,
|
||||
group_id: groupId.value ?? undefined,
|
||||
mode: queryMode.value
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
if (fetchSeq !== dashboardFetchSeq) return
|
||||
errorDistribution.value = data
|
||||
} catch (err: any) {
|
||||
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
|
||||
errorDistribution.value = null
|
||||
appStore.showError(err?.message || 'Failed to load error distribution')
|
||||
} finally {
|
||||
if (fetchSeq === dashboardFetchSeq) {
|
||||
loadingErrorDistribution.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isOpsDisabledError(err: unknown): boolean {
|
||||
return (
|
||||
!!err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
typeof (err as Record<string, unknown>).code === 'string' &&
|
||||
(err as Record<string, unknown>).code === 'OPS_DISABLED'
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
if (!opsEnabled.value) return
|
||||
|
||||
abortDashboardFetch()
|
||||
dashboardFetchSeq += 1
|
||||
const fetchSeq = dashboardFetchSeq
|
||||
dashboardFetchController = new AbortController()
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await Promise.all([
|
||||
refreshOverviewWithCancel(fetchSeq, dashboardFetchController.signal),
|
||||
refreshThroughputTrendWithCancel(fetchSeq, dashboardFetchController.signal),
|
||||
refreshLatencyHistogramWithCancel(fetchSeq, dashboardFetchController.signal),
|
||||
refreshErrorTrendWithCancel(fetchSeq, dashboardFetchController.signal),
|
||||
refreshErrorDistributionWithCancel(fetchSeq, dashboardFetchController.signal)
|
||||
])
|
||||
if (fetchSeq !== dashboardFetchSeq) return
|
||||
lastUpdated.value = new Date()
|
||||
} catch (err) {
|
||||
if (!isOpsDisabledError(err)) {
|
||||
console.error('[ops] failed to fetch dashboard data', err)
|
||||
errorMessage.value = t('admin.ops.failedToLoadData')
|
||||
}
|
||||
} finally {
|
||||
if (fetchSeq === dashboardFetchSeq) {
|
||||
loading.value = false
|
||||
hasLoadedOnce.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [timeRange.value, platform.value, groupId.value, queryMode.value] as const,
|
||||
() => {
|
||||
if (isApplyingRouteQuery.value) return
|
||||
if (opsEnabled.value) {
|
||||
fetchData()
|
||||
}
|
||||
syncQueryToRoute()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
() => {
|
||||
if (isSyncingRouteQuery.value) return
|
||||
|
||||
const prevTimeRange = timeRange.value
|
||||
const prevPlatform = platform.value
|
||||
const prevGroupId = groupId.value
|
||||
|
||||
isApplyingRouteQuery.value = true
|
||||
applyRouteQueryToState()
|
||||
isApplyingRouteQuery.value = false
|
||||
|
||||
const changed =
|
||||
prevTimeRange !== timeRange.value || prevPlatform !== platform.value || prevGroupId !== groupId.value
|
||||
if (changed) {
|
||||
if (opsEnabled.value) {
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await adminSettingsStore.fetch()
|
||||
if (!adminSettingsStore.opsMonitoringEnabled) {
|
||||
await router.replace('/admin/settings')
|
||||
return
|
||||
}
|
||||
|
||||
if (adminSettingsStore.opsRealtimeMonitoringEnabled) {
|
||||
startQPSSubscription()
|
||||
} else {
|
||||
stopQPSSubscription({ resetMetrics: true })
|
||||
}
|
||||
|
||||
if (opsEnabled.value) {
|
||||
await fetchData()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopQPSSubscription()
|
||||
abortDashboardFetch()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => adminSettingsStore.opsRealtimeMonitoringEnabled,
|
||||
(enabled) => {
|
||||
if (!opsEnabled.value) return
|
||||
if (enabled) {
|
||||
startQPSSubscription()
|
||||
} else {
|
||||
stopQPSSubscription({ resetMetrics: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
Reference in New Issue
Block a user