Files
sub2api/frontend/src/views/admin/ops/components/OpsDashboardHeader.vue
IanShaw027 8b95d16220 refactor(ops): 简化自动刷新定时器逻辑
- 合并双定时器为单一倒计时定时器
- 倒计时归零时触发数据刷新
- 添加自定义时间范围的安全回退
2026-01-15 22:07:23 +08:00

1628 lines
69 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
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 { opsAPI, type OpsDashboardOverview, type OpsMetricThresholds, type OpsRealtimeTrafficSummary } from '@/api/admin/ops'
import type { OpsRequestDetailsPreset } from './OpsRequestDetailsModal.vue'
import { useAdminSettingsStore } from '@/stores'
import { formatNumber } from '@/utils/format'
type RealtimeWindow = '1min' | '5min' | '30min' | '1h'
interface Props {
overview?: OpsDashboardOverview | null
platform: string
groupId: number | null
timeRange: string
queryMode: string
loading: boolean
lastUpdated: Date | null
thresholds?: OpsMetricThresholds | null // 阈值配置
autoRefreshEnabled?: boolean
autoRefreshCountdown?: number
fullscreen?: boolean
customStartTime?: string | null
customEndTime?: string | null
}
interface Emits {
(e: 'update:platform', value: string): void
(e: 'update:group', value: number | null): void
(e: 'update:timeRange', value: string): void
(e: 'update:queryMode', value: string): void
(e: 'update:customTimeRange', startTime: string, endTime: string): void
(e: 'refresh'): void
(e: 'openRequestDetails', preset?: OpsRequestDetailsPreset): void
(e: 'openErrorDetails', kind: 'request' | 'upstream'): void
(e: 'openSettings'): void
(e: 'openAlertRules'): void
(e: 'enterFullscreen'): void
(e: 'exitFullscreen'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { t } = useI18n()
const adminSettingsStore = useAdminSettingsStore()
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'
// Keep realtime traffic consistent with toolbar changes even when the window is already 1min.
loadRealtimeTrafficSummary()
}
)
// --- Filters ---
const showCustomTimeRangeDialog = ref(false)
const customStartTimeInput = ref('')
const customEndTimeInput = ref('')
function formatCustomTimeRangeLabel(startTime: string, endTime: string): string {
const start = new Date(startTime)
const end = new Date(endTime)
const formatDate = (d: Date) => {
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hour = String(d.getHours()).padStart(2, '0')
const minute = String(d.getMinutes()).padStart(2, '0')
return `${month}-${day} ${hour}:${minute}`
}
return `${formatDate(start)} ~ ${formatDate(end)}`
}
const groups = ref<Array<{ id: number; name: string; platform: string }>>([])
const platformOptions = computed(() => [
{ value: '', label: t('common.all') },
{ value: 'openai', label: 'OpenAI' },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'antigravity', label: 'Antigravity' }
])
const timeRangeOptions = computed(() => [
{ value: '5m', label: t('admin.ops.timeRange.5m') },
{ value: '30m', label: t('admin.ops.timeRange.30m') },
{ value: '1h', label: t('admin.ops.timeRange.1h') },
{ value: '6h', label: t('admin.ops.timeRange.6h') },
{ value: '24h', label: t('admin.ops.timeRange.24h') },
{
value: 'custom',
label: props.timeRange === 'custom' && props.customStartTime && props.customEndTime
? `${t('admin.ops.timeRange.custom')} (${formatCustomTimeRangeLabel(props.customStartTime, props.customEndTime)})`
: t('admin.ops.timeRange.custom')
}
])
const queryModeOptions = computed(() => [
{ value: 'auto', label: t('admin.ops.queryMode.auto') },
{ value: 'raw', label: t('admin.ops.queryMode.raw') },
{ value: 'preagg', label: t('admin.ops.queryMode.preagg') }
])
const groupOptions = computed(() => {
const filtered = props.platform ? groups.value.filter((g) => g.platform === props.platform) : groups.value
return [{ value: null, label: t('common.all') }, ...filtered.map((g) => ({ value: g.id, label: g.name }))]
})
watch(
() => props.platform,
(newPlatform) => {
if (!newPlatform) return
const currentGroup = groups.value.find((g) => g.id === props.groupId)
if (currentGroup && currentGroup.platform !== newPlatform) {
emit('update:group', null)
}
}
)
onMounted(async () => {
try {
const list = await adminAPI.groups.getAll()
groups.value = list.map((g) => ({ id: g.id, name: g.name, platform: g.platform }))
} catch (e) {
console.error('[OpsDashboardHeader] Failed to load groups', e)
groups.value = []
}
})
function handlePlatformChange(val: string | number | boolean | null) {
emit('update:platform', String(val || ''))
}
function handleGroupChange(val: string | number | boolean | null) {
if (val === null || val === '' || typeof val === 'boolean') {
emit('update:group', null)
return
}
const id = typeof val === 'number' ? val : Number.parseInt(String(val), 10)
emit('update:group', Number.isFinite(id) && id > 0 ? id : null)
}
function handleTimeRangeChange(val: string | number | boolean | null) {
const newValue = String(val || '1h')
if (newValue === 'custom') {
// 初始化为最近1小时
const now = new Date()
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000)
customStartTimeInput.value = oneHourAgo.toISOString().slice(0, 16)
customEndTimeInput.value = now.toISOString().slice(0, 16)
showCustomTimeRangeDialog.value = true
} else {
emit('update:timeRange', newValue)
}
}
function handleCustomTimeRangeConfirm() {
if (!customStartTimeInput.value || !customEndTimeInput.value) return
const startTime = new Date(customStartTimeInput.value).toISOString()
const endTime = new Date(customEndTimeInput.value).toISOString()
// Emit custom time range first so the parent can build correct API params
// when it reacts to timeRange switching to "custom".
emit('update:customTimeRange', startTime, endTime)
emit('update:timeRange', 'custom')
showCustomTimeRangeDialog.value = false
}
function handleCustomTimeRangeCancel() {
showCustomTimeRangeDialog.value = false
// 如果当前不是 custom不需要做任何事
// 如果当前是 custom保持不变
}
function handleQueryModeChange(val: string | number | boolean | null) {
emit('update:queryMode', String(val || 'auto'))
}
function openDetails(preset?: OpsRequestDetailsPreset) {
emit('openRequestDetails', preset)
}
function openErrorDetails(kind: 'request' | 'upstream') {
emit('openErrorDetails', kind)
}
// --- Threshold checking helpers ---
type ThresholdLevel = 'normal' | 'warning' | 'critical'
function getSLAThresholdLevel(slaPercent: number | null): ThresholdLevel {
if (slaPercent == null) return 'normal'
const threshold = props.thresholds?.sla_percent_min
if (threshold == null) return 'normal'
// SLA is "higher is better":
// - below threshold => critical
// - within +0.1% buffer => warning
const warningBuffer = 0.1
if (slaPercent < threshold) return 'critical'
if (slaPercent < threshold + warningBuffer) return 'warning'
return 'normal'
}
function getTTFTThresholdLevel(ttftMs: number | null): ThresholdLevel {
if (ttftMs == null) return 'normal'
const threshold = props.thresholds?.ttft_p99_ms_max
if (threshold == null) return 'normal'
if (ttftMs >= threshold) return 'critical'
if (ttftMs >= threshold * 0.8) return 'warning'
return 'normal'
}
function getRequestErrorRateThresholdLevel(errorRatePercent: number | null): ThresholdLevel {
if (errorRatePercent == null) return 'normal'
const threshold = props.thresholds?.request_error_rate_percent_max
if (threshold == null) return 'normal'
if (errorRatePercent >= threshold) return 'critical'
if (errorRatePercent >= threshold * 0.8) return 'warning'
return 'normal'
}
function getUpstreamErrorRateThresholdLevel(upstreamErrorRatePercent: number | null): ThresholdLevel {
if (upstreamErrorRatePercent == null) return 'normal'
const threshold = props.thresholds?.upstream_error_rate_percent_max
if (threshold == null) return 'normal'
if (upstreamErrorRatePercent >= threshold) return 'critical'
if (upstreamErrorRatePercent >= threshold * 0.8) return 'warning'
return 'normal'
}
function getThresholdColorClass(level: ThresholdLevel): string {
switch (level) {
case 'critical':
return 'text-red-600 dark:text-red-400'
case 'warning':
return 'text-yellow-600 dark:text-yellow-400'
default:
return 'text-green-600 dark:text-green-400'
}
}
// --- Realtime / Overview labels ---
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)
function makeZeroRealtimeTrafficSummary(): OpsRealtimeTrafficSummary {
const now = new Date().toISOString()
return {
window: realtimeWindow.value,
start_time: now,
end_time: now,
platform: props.platform,
group_id: props.groupId,
qps: { current: 0, peak: 0, avg: 0 },
tps: { current: 0, peak: 0, avg: 0 }
}
}
async function loadRealtimeTrafficSummary() {
if (realtimeTrafficLoading.value) return
if (!adminSettingsStore.opsRealtimeMonitoringEnabled) {
realtimeTrafficSummary.value = makeZeroRealtimeTrafficSummary()
return
}
realtimeTrafficLoading.value = true
try {
const res = await opsAPI.getRealtimeTrafficSummary(realtimeWindow.value, props.platform, props.groupId)
if (res && res.enabled === false) {
adminSettingsStore.setOpsRealtimeMonitoringEnabledLocal(false)
}
realtimeTrafficSummary.value = res?.summary ?? null
} 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 }
)
watch(
() => adminSettingsStore.opsRealtimeMonitoringEnabled,
(enabled) => {
if (!enabled) {
// Keep UI stable when realtime monitoring is turned off.
realtimeTrafficSummary.value = makeZeroRealtimeTrafficSummary()
} else {
loadRealtimeTrafficSummary()
}
},
{ immediate: true }
)
// Realtime traffic refresh follows the parent (OpsDashboard) refresh cadence.
watch(
() => [props.autoRefreshEnabled, props.autoRefreshCountdown, props.loading] as const,
([enabled, countdown, loading]) => {
if (!enabled) return
if (loading) return
// Treat countdown reset (or reaching 0) as a refresh boundary.
if (countdown === 0) {
loadRealtimeTrafficSummary()
}
}
)
// no-op: parent controls refresh cadence
const displayRealTimeQps = computed(() => {
const v = realtimeTrafficSummary.value?.qps?.current
return typeof v === 'number' && Number.isFinite(v) ? v : 0
})
const displayRealTimeTps = computed(() => {
const v = realtimeTrafficSummary.value?.tps?.current
return typeof v === 'number' && Number.isFinite(v) ? v : 0
})
const realtimeQpsPeakLabel = computed(() => {
const v = realtimeTrafficSummary.value?.qps?.peak
return typeof v === 'number' && Number.isFinite(v) ? v.toFixed(1) : '-'
})
const realtimeTpsPeakLabel = computed(() => {
const v = realtimeTrafficSummary.value?.tps?.peak
return typeof v === 'number' && Number.isFinite(v) ? 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(() => {
const v = overview.value?.qps?.avg
if (typeof v !== 'number') return '-'
return v.toFixed(1)
})
const tpsAvgLabel = computed(() => {
const v = overview.value?.tps?.avg
if (typeof v !== 'number') return '-'
return v.toFixed(1)
})
const slaPercent = computed(() => {
const v = overview.value?.sla
if (typeof v !== 'number') return null
return v * 100
})
const errorRatePercent = computed(() => {
const v = overview.value?.error_rate
if (typeof v !== 'number') return null
return v * 100
})
const upstreamErrorRatePercent = computed(() => {
const v = overview.value?.upstream_error_rate
if (typeof v !== 'number') return null
return v * 100
})
const durationP99Ms = computed(() => overview.value?.duration?.p99_ms ?? null)
const durationP95Ms = computed(() => overview.value?.duration?.p95_ms ?? null)
const durationP90Ms = computed(() => overview.value?.duration?.p90_ms ?? null)
const durationP50Ms = computed(() => overview.value?.duration?.p50_ms ?? null)
const durationAvgMs = computed(() => overview.value?.duration?.avg_ms ?? null)
const durationMaxMs = computed(() => overview.value?.duration?.max_ms ?? null)
const ttftP99Ms = computed(() => overview.value?.ttft?.p99_ms ?? null)
const ttftP95Ms = computed(() => overview.value?.ttft?.p95_ms ?? null)
const ttftP90Ms = computed(() => overview.value?.ttft?.p90_ms ?? null)
const ttftP50Ms = computed(() => overview.value?.ttft?.p50_ms ?? null)
const ttftAvgMs = computed(() => overview.value?.ttft?.avg_ms ?? null)
const ttftMaxMs = computed(() => overview.value?.ttft?.max_ms ?? null)
// --- Health Score & Diagnosis (primary) ---
const isSystemIdle = computed(() => {
const ov = overview.value
if (!ov) return true
const qps = ov.qps?.current
const errorRate = ov.error_rate ?? 0
return (qps ?? 0) === 0 && errorRate === 0
})
const healthScoreValue = computed<number | null>(() => {
const v = overview.value?.health_score
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const healthScoreColor = computed(() => {
if (isSystemIdle.value) return '#9ca3af' // gray-400
const score = healthScoreValue.value
if (score == null) return '#9ca3af'
if (score >= 90) return '#10b981' // green
if (score >= 60) return '#f59e0b' // yellow
return '#ef4444' // red
})
const healthScoreClass = computed(() => {
if (isSystemIdle.value) return 'text-gray-400'
const score = healthScoreValue.value
if (score == null) return 'text-gray-400'
if (score >= 90) return 'text-green-500'
if (score >= 60) return 'text-yellow-500'
return 'text-red-500'
})
const circleSize = computed(() => props.fullscreen ? 140 : 100)
const strokeWidth = computed(() => props.fullscreen ? 10 : 8)
const radius = computed(() => (circleSize.value - strokeWidth.value) / 2)
const circumference = computed(() => 2 * Math.PI * radius.value)
const dashOffset = computed(() => {
if (isSystemIdle.value) return 0
if (healthScoreValue.value == null) return 0
const score = Math.max(0, Math.min(100, healthScoreValue.value))
return circumference.value - (score / 100) * circumference.value
})
interface DiagnosisItem {
type: 'critical' | 'warning' | 'info'
message: string
impact: string
action?: string
}
const diagnosisReport = computed<DiagnosisItem[]>(() => {
const ov = overview.value
if (!ov) return []
const report: DiagnosisItem[] = []
if (isSystemIdle.value) {
report.push({
type: 'info',
message: t('admin.ops.diagnosis.idle'),
impact: t('admin.ops.diagnosis.idleImpact')
})
return report
}
// Resource diagnostics (highest priority)
const sm = ov.system_metrics
if (sm) {
if (sm.db_ok === false) {
report.push({
type: 'critical',
message: t('admin.ops.diagnosis.dbDown'),
impact: t('admin.ops.diagnosis.dbDownImpact'),
action: t('admin.ops.diagnosis.dbDownAction')
})
}
if (sm.redis_ok === false) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.redisDown'),
impact: t('admin.ops.diagnosis.redisDownImpact'),
action: t('admin.ops.diagnosis.redisDownAction')
})
}
const cpuPct = sm.cpu_usage_percent ?? 0
if (cpuPct > 90) {
report.push({
type: 'critical',
message: t('admin.ops.diagnosis.cpuCritical', { usage: cpuPct.toFixed(1) }),
impact: t('admin.ops.diagnosis.cpuCriticalImpact'),
action: t('admin.ops.diagnosis.cpuCriticalAction')
})
} else if (cpuPct > 80) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.cpuHigh', { usage: cpuPct.toFixed(1) }),
impact: t('admin.ops.diagnosis.cpuHighImpact'),
action: t('admin.ops.diagnosis.cpuHighAction')
})
}
const memPct = sm.memory_usage_percent ?? 0
if (memPct > 90) {
report.push({
type: 'critical',
message: t('admin.ops.diagnosis.memoryCritical', { usage: memPct.toFixed(1) }),
impact: t('admin.ops.diagnosis.memoryCriticalImpact'),
action: t('admin.ops.diagnosis.memoryCriticalAction')
})
} else if (memPct > 85) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.memoryHigh', { usage: memPct.toFixed(1) }),
impact: t('admin.ops.diagnosis.memoryHighImpact'),
action: t('admin.ops.diagnosis.memoryHighAction')
})
}
}
const ttftP99 = ov.ttft?.p99_ms ?? 0
if (ttftP99 > 500) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.ttftHigh', { ttft: ttftP99.toFixed(0) }),
impact: t('admin.ops.diagnosis.ttftHighImpact'),
action: t('admin.ops.diagnosis.ttftHighAction')
})
}
// Error rate diagnostics (adjusted thresholds)
const upstreamRatePct = (ov.upstream_error_rate ?? 0) * 100
if (upstreamRatePct > 5) {
report.push({
type: 'critical',
message: t('admin.ops.diagnosis.upstreamCritical', { rate: upstreamRatePct.toFixed(2) }),
impact: t('admin.ops.diagnosis.upstreamCriticalImpact'),
action: t('admin.ops.diagnosis.upstreamCriticalAction')
})
} else if (upstreamRatePct > 2) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.upstreamHigh', { rate: upstreamRatePct.toFixed(2) }),
impact: t('admin.ops.diagnosis.upstreamHighImpact'),
action: t('admin.ops.diagnosis.upstreamHighAction')
})
}
const errorPct = (ov.error_rate ?? 0) * 100
if (errorPct > 3) {
report.push({
type: 'critical',
message: t('admin.ops.diagnosis.errorHigh', { rate: errorPct.toFixed(2) }),
impact: t('admin.ops.diagnosis.errorHighImpact'),
action: t('admin.ops.diagnosis.errorHighAction')
})
} else if (errorPct > 0.5) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.errorElevated', { rate: errorPct.toFixed(2) }),
impact: t('admin.ops.diagnosis.errorElevatedImpact'),
action: t('admin.ops.diagnosis.errorElevatedAction')
})
}
// SLA diagnostics
const slaPct = (ov.sla ?? 0) * 100
if (slaPct < 90) {
report.push({
type: 'critical',
message: t('admin.ops.diagnosis.slaCritical', { sla: slaPct.toFixed(2) }),
impact: t('admin.ops.diagnosis.slaCriticalImpact'),
action: t('admin.ops.diagnosis.slaCriticalAction')
})
} else if (slaPct < 98) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.slaLow', { sla: slaPct.toFixed(2) }),
impact: t('admin.ops.diagnosis.slaLowImpact'),
action: t('admin.ops.diagnosis.slaLowAction')
})
}
// Health score diagnostics (lowest priority)
if (healthScoreValue.value != null) {
if (healthScoreValue.value < 60) {
report.push({
type: 'critical',
message: t('admin.ops.diagnosis.healthCritical', { score: healthScoreValue.value }),
impact: t('admin.ops.diagnosis.healthCriticalImpact'),
action: t('admin.ops.diagnosis.healthCriticalAction')
})
} else if (healthScoreValue.value < 90) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.healthLow', { score: healthScoreValue.value }),
impact: t('admin.ops.diagnosis.healthLowImpact'),
action: t('admin.ops.diagnosis.healthLowAction')
})
}
}
if (report.length === 0) {
report.push({
type: 'info',
message: t('admin.ops.diagnosis.healthy'),
impact: t('admin.ops.diagnosis.healthyImpact')
})
}
return report
})
// --- System health (secondary) ---
function formatTimeShort(ts?: string | null): string {
if (!ts) return '-'
const d = new Date(ts)
if (Number.isNaN(d.getTime())) return '-'
return d.toLocaleTimeString()
}
const cpuPercentValue = computed<number | null>(() => {
const v = systemMetrics.value?.cpu_usage_percent
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const cpuPercentClass = computed(() => {
const v = cpuPercentValue.value
if (v == null) return 'text-gray-900 dark:text-white'
if (v >= 95) return 'text-rose-600 dark:text-rose-400'
if (v >= 80) return 'text-yellow-600 dark:text-yellow-400'
return 'text-emerald-600 dark:text-emerald-400'
})
const memPercentValue = computed<number | null>(() => {
const v = systemMetrics.value?.memory_usage_percent
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const memPercentClass = computed(() => {
const v = memPercentValue.value
if (v == null) return 'text-gray-900 dark:text-white'
if (v >= 95) return 'text-rose-600 dark:text-rose-400'
if (v >= 85) return 'text-yellow-600 dark:text-yellow-400'
return 'text-emerald-600 dark:text-emerald-400'
})
const dbConnActiveValue = computed<number | null>(() => {
const v = systemMetrics.value?.db_conn_active
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const dbConnIdleValue = computed<number | null>(() => {
const v = systemMetrics.value?.db_conn_idle
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const dbConnWaitingValue = computed<number | null>(() => {
const v = systemMetrics.value?.db_conn_waiting
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const dbConnOpenValue = computed<number | null>(() => {
if (dbConnActiveValue.value == null || dbConnIdleValue.value == null) return null
return dbConnActiveValue.value + dbConnIdleValue.value
})
const dbMaxOpenConnsValue = computed<number | null>(() => {
const v = systemMetrics.value?.db_max_open_conns
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const dbUsagePercent = computed<number | null>(() => {
if (dbConnOpenValue.value == null || dbMaxOpenConnsValue.value == null || dbMaxOpenConnsValue.value <= 0) return null
return Math.min(100, Math.max(0, (dbConnOpenValue.value / dbMaxOpenConnsValue.value) * 100))
})
const dbMiddleLabel = computed(() => {
if (systemMetrics.value?.db_ok === false) return 'FAIL'
if (dbUsagePercent.value != null) return `${dbUsagePercent.value.toFixed(0)}%`
if (systemMetrics.value?.db_ok === true) return t('admin.ops.ok')
return t('admin.ops.noData')
})
const dbMiddleClass = computed(() => {
if (systemMetrics.value?.db_ok === false) return 'text-rose-600 dark:text-rose-400'
if (dbUsagePercent.value != null) {
if (dbUsagePercent.value >= 90) return 'text-rose-600 dark:text-rose-400'
if (dbUsagePercent.value >= 70) return 'text-yellow-600 dark:text-yellow-400'
return 'text-emerald-600 dark:text-emerald-400'
}
if (systemMetrics.value?.db_ok === true) return 'text-emerald-600 dark:text-emerald-400'
return 'text-gray-900 dark:text-white'
})
const redisConnTotalValue = computed<number | null>(() => {
const v = systemMetrics.value?.redis_conn_total
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const redisConnIdleValue = computed<number | null>(() => {
const v = systemMetrics.value?.redis_conn_idle
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const redisConnActiveValue = computed<number | null>(() => {
if (redisConnTotalValue.value == null || redisConnIdleValue.value == null) return null
return Math.max(redisConnTotalValue.value - redisConnIdleValue.value, 0)
})
const redisPoolSizeValue = computed<number | null>(() => {
const v = systemMetrics.value?.redis_pool_size
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const redisUsagePercent = computed<number | null>(() => {
if (redisConnTotalValue.value == null || redisPoolSizeValue.value == null || redisPoolSizeValue.value <= 0) return null
return Math.min(100, Math.max(0, (redisConnTotalValue.value / redisPoolSizeValue.value) * 100))
})
const redisMiddleLabel = computed(() => {
if (systemMetrics.value?.redis_ok === false) return 'FAIL'
if (redisUsagePercent.value != null) return `${redisUsagePercent.value.toFixed(0)}%`
if (systemMetrics.value?.redis_ok === true) return t('admin.ops.ok')
return t('admin.ops.noData')
})
const redisMiddleClass = computed(() => {
if (systemMetrics.value?.redis_ok === false) return 'text-rose-600 dark:text-rose-400'
if (redisUsagePercent.value != null) {
if (redisUsagePercent.value >= 90) return 'text-rose-600 dark:text-rose-400'
if (redisUsagePercent.value >= 70) return 'text-yellow-600 dark:text-yellow-400'
return 'text-emerald-600 dark:text-emerald-400'
}
if (systemMetrics.value?.redis_ok === true) return 'text-emerald-600 dark:text-emerald-400'
return 'text-gray-900 dark:text-white'
})
const goroutineCountValue = computed<number | null>(() => {
const v = systemMetrics.value?.goroutine_count
return typeof v === 'number' && Number.isFinite(v) ? v : null
})
const goroutinesWarnThreshold = 8_000
const goroutinesCriticalThreshold = 15_000
const goroutineStatus = computed<'ok' | 'warning' | 'critical' | 'unknown'>(() => {
const n = goroutineCountValue.value
if (n == null) return 'unknown'
if (n >= goroutinesCriticalThreshold) return 'critical'
if (n >= goroutinesWarnThreshold) return 'warning'
return 'ok'
})
const goroutineStatusLabel = computed(() => {
switch (goroutineStatus.value) {
case 'ok':
return t('admin.ops.ok')
case 'warning':
return t('common.warning')
case 'critical':
return t('common.critical')
default:
return t('admin.ops.noData')
}
})
const goroutineStatusClass = computed(() => {
switch (goroutineStatus.value) {
case 'ok':
return 'text-emerald-600 dark:text-emerald-400'
case 'warning':
return 'text-yellow-600 dark:text-yellow-400'
case 'critical':
return 'text-rose-600 dark:text-rose-400'
default:
return 'text-gray-900 dark:text-white'
}
})
const jobHeartbeats = computed(() => overview.value?.job_heartbeats ?? [])
const jobsStatus = computed<'ok' | 'warn' | 'unknown'>(() => {
const list = jobHeartbeats.value
if (!list.length) return 'unknown'
for (const hb of list) {
if (!hb) continue
if (hb.last_error_at && (!hb.last_success_at || hb.last_error_at > hb.last_success_at)) return 'warn'
}
return 'ok'
})
const jobsWarnCount = computed(() => {
let warn = 0
for (const hb of jobHeartbeats.value) {
if (!hb) continue
if (hb.last_error_at && (!hb.last_success_at || hb.last_error_at > hb.last_success_at)) warn++
}
return warn
})
const jobsStatusLabel = computed(() => {
switch (jobsStatus.value) {
case 'ok':
return t('admin.ops.ok')
case 'warn':
return t('common.warning')
default:
return t('admin.ops.noData')
}
})
const jobsStatusClass = computed(() => {
switch (jobsStatus.value) {
case 'ok':
return 'text-emerald-600 dark:text-emerald-400'
case 'warn':
return 'text-yellow-600 dark:text-yellow-400'
default:
return 'text-gray-900 dark:text-white'
}
})
const showJobsDetails = ref(false)
function openJobsDetails() {
showJobsDetails.value = true
}
function handleToolbarRefresh() {
loadRealtimeTrafficSummary()
emit('refresh')
}
</script>
<template>
<div :class="['flex flex-col gap-4 rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700', props.fullscreen ? 'p-8' : 'p-6']">
<!-- Top Toolbar -->
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-gray-100 pb-4 dark:border-dark-700">
<div>
<h1 class="flex items-center gap-2 text-xl font-black text-gray-900 dark:text-white">
<svg class="h-6 w-6 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
{{ t('admin.ops.title') }}
</h1>
<div v-if="!props.fullscreen" class="mt-1 flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
<span class="flex items-center gap-1.5" :title="props.loading ? t('admin.ops.loadingText') : t('admin.ops.ready')">
<span class="relative flex h-2 w-2">
<span class="relative inline-flex h-2 w-2 rounded-full" :class="props.loading ? 'bg-gray-400' : 'bg-green-500'"></span>
</span>
{{ props.loading ? t('admin.ops.loadingText') : t('admin.ops.ready') }}
</span>
<span>·</span>
<span>{{ t('common.refresh') }}: {{ props.lastUpdated ? props.lastUpdated.toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).replace(/\//g, '-') : t('common.unknown') }}</span>
<template v-if="props.autoRefreshEnabled && props.autoRefreshCountdown !== undefined">
<span>·</span>
<span>剩余 {{ props.autoRefreshCountdown }}s</span>
</template>
</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<template v-if="!props.fullscreen">
<Select
:model-value="platform"
:options="platformOptions"
class="w-full sm:w-[140px]"
@update:model-value="handlePlatformChange"
/>
<Select
:model-value="groupId"
:options="groupOptions"
class="w-full sm:w-[160px]"
@update:model-value="handleGroupChange"
/>
<div class="mx-1 hidden h-4 w-[1px] bg-gray-200 dark:bg-dark-700 sm:block"></div>
<Select
:model-value="timeRange"
:options="timeRangeOptions"
class="relative w-full sm:w-[150px]"
@update:model-value="handleTimeRangeChange"
/>
</template>
<Select
v-if="false"
:model-value="queryMode"
:options="queryModeOptions"
class="relative w-full sm:w-[170px]"
@update:model-value="handleQueryModeChange"
/>
<button
v-if="!props.fullscreen"
type="button"
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gray-100 text-gray-500 transition-colors hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600"
:disabled="loading"
:title="t('common.refresh')"
@click="handleToolbarRefresh"
>
<svg class="h-4 w-4" :class="{ 'animate-spin': loading }" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
<div v-if="!props.fullscreen" class="mx-1 hidden h-4 w-[1px] bg-gray-200 dark:bg-dark-700 sm:block"></div>
<!-- Alert Rules Button (hidden in fullscreen) -->
<button
v-if="!props.fullscreen"
type="button"
class="flex h-8 items-center gap-1.5 rounded-lg bg-blue-100 px-3 text-xs font-bold text-blue-700 transition-colors hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400 dark:hover:bg-blue-900/50"
:title="t('admin.ops.alertRules.title')"
@click="emit('openAlertRules')"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<span class="hidden sm:inline">{{ t('admin.ops.alertRules.manage') }}</span>
</button>
<!-- Settings Button (hidden in fullscreen) -->
<button
v-if="!props.fullscreen"
type="button"
class="flex h-8 items-center gap-1.5 rounded-lg bg-gray-100 px-3 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
:title="t('admin.ops.settings.title')"
@click="emit('openSettings')"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span class="hidden sm:inline">{{ t('common.settings') }}</span>
</button>
<!-- Enter Fullscreen Button (hidden in fullscreen mode) -->
<button
v-if="!props.fullscreen"
type="button"
class="flex h-8 w-8 items-center justify-center rounded-lg bg-gray-100 text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
:title="t('admin.ops.fullscreen.enter')"
@click="emit('enterFullscreen')"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
</button>
</div>
</div>
<div v-if="overview" class="grid grid-cols-1 gap-6 lg:grid-cols-12">
<!-- Left: Health + Realtime -->
<div :class="['rounded-2xl bg-gray-50 dark:bg-dark-900 lg:col-span-5', props.fullscreen ? 'p-6' : 'p-4']">
<div class="grid h-full grid-cols-1 gap-6 md:grid-cols-[200px_1fr] md:items-center">
<!-- 1) Health Score -->
<div
class="group relative flex cursor-pointer flex-col items-center justify-center rounded-xl py-2 transition-all hover:bg-white/60 dark:hover:bg-dark-800/60 md:border-r md:border-gray-200 md:pr-6 dark:md:border-dark-700"
>
<!-- Diagnosis Popover (hover) -->
<div
class="pointer-events-none absolute left-1/2 top-full z-50 mt-2 w-72 -translate-x-1/2 opacity-0 transition-opacity duration-200 group-hover:pointer-events-auto group-hover:opacity-100 md:left-full md:top-0 md:ml-2 md:mt-0 md:translate-x-0"
>
<div class="rounded-xl bg-white p-4 shadow-xl ring-1 ring-black/5 dark:bg-gray-800 dark:ring-white/10">
<h4 class="mb-3 border-b border-gray-100 pb-2 text-sm font-bold text-gray-900 dark:border-gray-700 dark:text-white flex items-center gap-2">
<Icon name="brain" size="sm" class="text-blue-500" />
{{ t('admin.ops.diagnosis.title') }}
</h4>
<div class="space-y-3">
<div v-for="(item, idx) in diagnosisReport" :key="idx" class="flex gap-3">
<div class="mt-0.5 shrink-0">
<svg v-if="item.type === 'critical'" class="h-4 w-4 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
<svg v-else-if="item.type === 'warning'" class="h-4 w-4 text-yellow-500" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z"
clip-rule="evenodd"
/>
</svg>
<svg v-else class="h-4 w-4 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-3a1 1 0 100 2 1 1 0 000-2zm-1 3a1 1 0 012 0v4a1 1 0 11-2 0v-4z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="flex-1">
<div class="text-xs font-semibold text-gray-900 dark:text-white">{{ item.message }}</div>
<div class="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">{{ item.impact }}</div>
<div v-if="item.action" class="mt-1 text-[11px] text-blue-600 dark:text-blue-400 flex items-center gap-1">
<Icon name="lightbulb" size="xs" />
{{ item.action }}
</div>
</div>
</div>
</div>
<div class="mt-3 border-t border-gray-100 pt-2 text-[10px] text-gray-400 dark:border-gray-700">
{{ t('admin.ops.diagnosis.footer') }}
</div>
</div>
</div>
<div class="relative flex items-center justify-center">
<svg :width="circleSize" :height="circleSize" class="-rotate-90 transform">
<circle
:cx="circleSize / 2"
:cy="circleSize / 2"
:r="radius"
:stroke-width="strokeWidth"
fill="transparent"
class="text-gray-200 dark:text-dark-700"
stroke="currentColor"
/>
<circle
:cx="circleSize / 2"
:cy="circleSize / 2"
:r="radius"
:stroke-width="strokeWidth"
fill="transparent"
:stroke="healthScoreColor"
stroke-linecap="round"
:stroke-dasharray="circumference"
:stroke-dashoffset="dashOffset"
class="transition-all duration-1000 ease-out"
/>
</svg>
<div class="absolute flex flex-col items-center">
<span :class="[props.fullscreen ? 'text-5xl' : 'text-3xl', 'font-black', healthScoreClass]">
{{ isSystemIdle ? t('admin.ops.idleStatus') : (overview.health_score ?? '--') }}
</span>
<span :class="[props.fullscreen ? 'text-xs' : 'text-[10px]', 'font-bold uppercase tracking-wider text-gray-400']">{{ t('admin.ops.health') }}</span>
</div>
</div>
<div class="mt-4 text-center" v-if="!props.fullscreen">
<div class="flex items-center justify-center gap-1 text-xs font-medium text-gray-500">
{{ t('admin.ops.healthCondition') }}
<HelpTooltip :content="t('admin.ops.healthHelp')" />
</div>
<div class="mt-1 text-xs font-bold" :class="healthScoreClass">
{{
isSystemIdle
? t('admin.ops.idleStatus')
: typeof overview.health_score === 'number' && overview.health_score >= 90
? t('admin.ops.healthyStatus')
: t('admin.ops.riskyStatus')
}}
</div>
</div>
</div>
<!-- 2) Realtime Traffic -->
<div class="flex h-full flex-col justify-center py-2">
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<div class="flex items-center gap-2">
<div class="relative flex h-3 w-3 shrink-0">
<span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75"></span>
<span class="relative inline-flex h-3 w-3 rounded-full bg-blue-500"></span>
</div>
<h3 class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.realtime.title') }}</h3>
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.qps')" />
</div>
<!-- Time Window Selector -->
<div class="flex flex-wrap gap-1">
<button
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]"
:class="realtimeWindow === window
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-600 hover:bg-gray-300 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600'"
@click="realtimeWindow = window"
>
{{ window }}
</button>
</div>
</div>
<div :class="props.fullscreen ? 'space-y-4' : 'space-y-3'">
<!-- Row 1: Current -->
<div>
<div :class="[props.fullscreen ? 'text-xs' : 'text-[10px]', 'font-bold uppercase text-gray-400']">{{ t('admin.ops.current') }}</div>
<div class="mt-1 flex flex-wrap items-baseline gap-x-4 gap-y-2">
<div class="flex items-baseline gap-1.5">
<span :class="[props.fullscreen ? 'text-4xl' : 'text-xl sm:text-2xl', 'font-black text-gray-900 dark:text-white']">{{ displayRealTimeQps.toFixed(1) }}</span>
<span :class="[props.fullscreen ? 'text-sm' : 'text-xs', 'font-bold text-gray-500']">QPS</span>
</div>
<div class="flex items-baseline gap-1.5">
<span :class="[props.fullscreen ? 'text-4xl' : 'text-xl sm:text-2xl', 'font-black text-gray-900 dark:text-white']">{{ displayRealTimeTps.toFixed(1) }}</span>
<span :class="[props.fullscreen ? 'text-sm' : 'text-xs', 'font-bold text-gray-500']">{{ t('admin.ops.tps') }}</span>
</div>
</div>
</div>
<!-- Row 2: Peak + Average -->
<div class="grid grid-cols-2 gap-3">
<!-- Peak -->
<div>
<div :class="[props.fullscreen ? 'text-xs' : 'text-[10px]', 'font-bold uppercase text-gray-400']">{{ t('admin.ops.peak') }}</div>
<div :class="[props.fullscreen ? 'text-base' : 'text-sm', 'mt-1 space-y-0.5 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">{{ 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">{{ realtimeTpsPeakLabel }}</span>
<span class="text-xs">{{ t('admin.ops.tps') }}</span>
</div>
</div>
</div>
<!-- Average -->
<div>
<div :class="[props.fullscreen ? 'text-xs' : 'text-[10px]', 'font-bold uppercase text-gray-400']">{{ t('admin.ops.average') }}</div>
<div :class="[props.fullscreen ? 'text-base' : 'text-sm', 'mt-1 space-y-0.5 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">{{ 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">{{ realtimeTpsAvgLabel }}</span>
<span class="text-xs">{{ t('admin.ops.tps') }}</span>
</div>
</div>
</div>
</div>
<!-- Animated Pulse Line (Heart Beat Animation) -->
<div class="h-8 w-full overflow-hidden opacity-50">
<svg class="h-full w-full" viewBox="0 0 280 32" preserveAspectRatio="none">
<path
d="M0 16 Q 20 16, 40 16 T 80 16 T 120 10 T 160 22 T 200 16 T 240 16 T 280 16"
fill="none"
stroke="#3b82f6"
stroke-width="2"
vector-effect="non-scaling-stroke"
>
<animate
attributeName="d"
dur="2s"
repeatCount="indefinite"
values="M0 16 Q 20 16, 40 16 T 80 16 T 120 10 T 160 22 T 200 16 T 240 16 T 280 16;
M0 16 Q 20 16, 40 16 T 80 16 T 120 16 T 160 16 T 200 10 T 240 22 T 280 16;
M0 16 Q 20 16, 40 16 T 80 16 T 120 16 T 160 16 T 200 16 T 240 16 T 280 16"
keyTimes="0;0.5;1"
/>
</path>
</svg>
</div>
</div>
</div>
</div>
</div>
<!-- Right: 6 cards (3 cols x 2 rows) -->
<div class="grid h-full grid-cols-1 content-center gap-4 sm:grid-cols-2 lg:col-span-7 lg:grid-cols-3">
<!-- Card 1: Requests -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900" style="order: 1;">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.requestsTitle') }}</span>
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.totalRequests')" />
</div>
<button
v-if="!props.fullscreen"
class="text-[10px] font-bold text-blue-500 hover:underline"
type="button"
@click="openDetails({ title: t('admin.ops.requestDetails.title') })"
>
{{ t('admin.ops.requestDetails.details') }}
</button>
</div>
<div class="mt-2 space-y-2 text-xs">
<div class="flex justify-between">
<span class="text-gray-500">{{ t('admin.ops.requests') }}:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ totalRequestsLabel }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">{{ t('admin.ops.tokens') }}:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ totalTokensLabel }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">{{ t('admin.ops.avgQps') }}:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ qpsAvgLabel }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">{{ t('admin.ops.avgTps') }}:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ tpsAvgLabel }}</span>
</div>
</div>
</div>
<!-- Card 2: SLA -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900" style="order: 2;">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.sla') }}</span>
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.sla')" />
<span class="h-1.5 w-1.5 rounded-full" :class="getSLAThresholdLevel(slaPercent) === 'critical' ? 'bg-red-500' : getSLAThresholdLevel(slaPercent) === 'warning' ? 'bg-yellow-500' : 'bg-green-500'"></span>
</div>
<button
v-if="!props.fullscreen"
class="text-[10px] font-bold text-blue-500 hover:underline"
type="button"
@click="openDetails({ title: t('admin.ops.requestDetails.title'), kind: 'error' })"
>
{{ t('admin.ops.requestDetails.details') }}
</button>
</div>
<div class="mt-2 text-3xl font-black" :class="getThresholdColorClass(getSLAThresholdLevel(slaPercent))">
{{ slaPercent == null ? '-' : `${slaPercent.toFixed(3)}%` }}
</div>
<div class="mt-3 h-2 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-700">
<div class="h-full transition-all" :class="getSLAThresholdLevel(slaPercent) === 'critical' ? 'bg-red-500' : getSLAThresholdLevel(slaPercent) === 'warning' ? 'bg-yellow-500' : 'bg-green-500'" :style="{ width: `${Math.max((slaPercent ?? 0) - 90, 0) * 10}%` }"></div>
</div>
<div class="mt-3 text-xs">
<div class="flex justify-between">
<span class="text-gray-500">{{ t('admin.ops.exceptions') }}:</span>
<span class="font-bold text-red-600 dark:text-red-400">{{ formatNumber((overview.request_count_sla ?? 0) - (overview.success_count ?? 0)) }}</span>
</div>
</div>
</div>
<!-- Card 4: Request Duration -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900" style="order: 4;">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.latencyDuration') }}</span>
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.latency')" />
</div>
<button
v-if="!props.fullscreen"
class="text-[10px] font-bold text-blue-500 hover:underline"
type="button"
@click="openDetails({ title: t('admin.ops.latencyDuration'), sort: 'duration_desc' })"
>
{{ t('admin.ops.requestDetails.details') }}
</button>
</div>
<div class="mt-2 flex items-baseline gap-2">
<div class="text-3xl font-black text-gray-900 dark:text-white">
{{ durationP99Ms ?? '-' }}
</div>
<span class="text-xs font-bold text-gray-400">ms (P99)</span>
</div>
<div class="mt-3 grid grid-cols-1 gap-x-3 gap-y-1 text-xs 2xl:grid-cols-2">
<div class="flex items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P95:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ durationP95Ms ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
<div class="flex items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P90:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ durationP90Ms ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
<div class="flex items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P50:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ durationP50Ms ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
<div class="flex items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">Avg:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ durationAvgMs ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
<div class="flex items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">Max:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ durationMaxMs ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
</div>
</div>
<!-- Card 5: TTFT -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900" style="order: 5;">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">TTFT</span>
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.ttft')" />
</div>
<button
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' })"
>
{{ t('admin.ops.requestDetails.details') }}
</button>
</div>
<div class="mt-2 flex items-baseline gap-2">
<div class="text-3xl font-black" :class="getThresholdColorClass(getTTFTThresholdLevel(ttftP99Ms))">
{{ ttftP99Ms ?? '-' }}
</div>
<span class="text-xs font-bold text-gray-400">ms (P99)</span>
</div>
<div class="mt-3 grid grid-cols-1 gap-x-3 gap-y-1 text-xs 2xl:grid-cols-2">
<div class="flex items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P95:</span>
<span class="font-bold" :class="getThresholdColorClass(getTTFTThresholdLevel(ttftP95Ms))">{{ ttftP95Ms ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
<div class="flex items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P90:</span>
<span class="font-bold" :class="getThresholdColorClass(getTTFTThresholdLevel(ttftP90Ms))">{{ ttftP90Ms ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
<div class="flex items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">P50:</span>
<span class="font-bold" :class="getThresholdColorClass(getTTFTThresholdLevel(ttftP50Ms))">{{ ttftP50Ms ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
<div class="flex items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">Avg:</span>
<span class="font-bold" :class="getThresholdColorClass(getTTFTThresholdLevel(ttftAvgMs))">{{ ttftAvgMs ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
<div class="flex items-baseline gap-1 whitespace-nowrap">
<span class="text-gray-500">Max:</span>
<span class="font-bold" :class="getThresholdColorClass(getTTFTThresholdLevel(ttftMaxMs))">{{ ttftMaxMs ?? '-' }}</span>
<span class="text-gray-400">ms</span>
</div>
</div>
</div>
<!-- Card 3: Request Errors -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900" style="order: 3;">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.requestErrors') }}</span>
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.errors')" />
</div>
<button v-if="!props.fullscreen" class="text-[10px] font-bold text-blue-500 hover:underline" type="button" @click="openErrorDetails('request')">
{{ t('admin.ops.requestDetails.details') }}
</button>
</div>
<div class="mt-2 text-3xl font-black" :class="getThresholdColorClass(getRequestErrorRateThresholdLevel(errorRatePercent))">
{{ errorRatePercent == null ? '-' : `${errorRatePercent.toFixed(2)}%` }}
</div>
<div class="mt-3 space-y-1 text-xs">
<div class="flex justify-between">
<span class="text-gray-500">{{ t('admin.ops.errorCount') }}:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ formatNumber(overview.error_count_sla ?? 0) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">{{ t('admin.ops.businessLimited') }}:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ formatNumber(overview.business_limited_count ?? 0) }}</span>
</div>
</div>
</div>
<!-- Card 6: Upstream Errors -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900" style="order: 6;">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<span class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.upstreamErrors') }}</span>
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.upstreamErrors')" />
</div>
<button v-if="!props.fullscreen" class="text-[10px] font-bold text-blue-500 hover:underline" type="button" @click="openErrorDetails('upstream')">
{{ t('admin.ops.requestDetails.details') }}
</button>
</div>
<div class="mt-2 text-3xl font-black" :class="getThresholdColorClass(getUpstreamErrorRateThresholdLevel(upstreamErrorRatePercent))">
{{ upstreamErrorRatePercent == null ? '-' : `${upstreamErrorRatePercent.toFixed(2)}%` }}
</div>
<div class="mt-3 space-y-1 text-xs">
<div class="flex justify-between">
<span class="text-gray-500">{{ t('admin.ops.errorCountExcl429529') }}:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ formatNumber(overview.upstream_error_count_excl_429_529 ?? 0) }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">429/529:</span>
<span class="font-bold text-gray-900 dark:text-white">{{ formatNumber((overview.upstream_429_count ?? 0) + (overview.upstream_529_count ?? 0)) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- Integrated: System health (cards) -->
<div v-if="overview" class="mt-2 border-t border-gray-100 pt-4 dark:border-dark-700">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<!-- CPU -->
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
<div class="flex items-center gap-1">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">CPU</div>
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.cpu')" />
</div>
<div class="mt-1 text-lg font-black" :class="cpuPercentClass">
{{ cpuPercentValue == null ? '-' : `${cpuPercentValue.toFixed(1)}%` }}
</div>
<div v-if="!props.fullscreen" class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{{ t('common.warning') }} 80% · {{ t('common.critical') }} 95%
</div>
</div>
<!-- MEM -->
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
<div class="flex items-center gap-1">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.memory') }}</div>
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.memory')" />
</div>
<div class="mt-1 text-lg font-black" :class="memPercentClass">
{{ memPercentValue == null ? '-' : `${memPercentValue.toFixed(1)}%` }}
</div>
<div v-if="!props.fullscreen" class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{{
systemMetrics?.memory_used_mb == null || systemMetrics?.memory_total_mb == null
? '-'
: `${formatNumber(systemMetrics.memory_used_mb)} / ${formatNumber(systemMetrics.memory_total_mb)} MB`
}}
</div>
</div>
<!-- DB -->
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
<div class="flex items-center gap-1">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.db') }}</div>
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.db')" />
</div>
<div class="mt-1 text-lg font-black" :class="dbMiddleClass">
{{ dbMiddleLabel }}
</div>
<div v-if="!props.fullscreen" class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{{ t('admin.ops.conns') }} {{ dbConnOpenValue ?? '-' }} / {{ dbMaxOpenConnsValue ?? '-' }}
· {{ t('admin.ops.active') }} {{ dbConnActiveValue ?? '-' }}
· {{ t('admin.ops.idle') }} {{ dbConnIdleValue ?? '-' }}
<span v-if="dbConnWaitingValue != null"> · {{ t('admin.ops.waiting') }} {{ dbConnWaitingValue }} </span>
</div>
</div>
<!-- Redis -->
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
<div class="flex items-center gap-1">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">Redis</div>
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.redis')" />
</div>
<div class="mt-1 text-lg font-black" :class="redisMiddleClass">
{{ redisMiddleLabel }}
</div>
<div v-if="!props.fullscreen" class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{{ t('admin.ops.conns') }} {{ redisConnTotalValue ?? '-' }} / {{ redisPoolSizeValue ?? '-' }}
<span v-if="redisConnActiveValue != null"> · {{ t('admin.ops.active') }} {{ redisConnActiveValue }} </span>
<span v-if="redisConnIdleValue != null"> · {{ t('admin.ops.idle') }} {{ redisConnIdleValue }} </span>
</div>
</div>
<!-- Goroutines -->
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
<div class="flex items-center gap-1">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.goroutines') }}</div>
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.goroutines')" />
</div>
<div class="mt-1 text-lg font-black" :class="goroutineStatusClass">
{{ goroutineStatusLabel }}
</div>
<div v-if="!props.fullscreen" class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{{ t('admin.ops.current') }} <span class="font-mono">{{ goroutineCountValue ?? '-' }}</span>
· {{ t('common.warning') }} <span class="font-mono">{{ goroutinesWarnThreshold }}</span>
· {{ t('common.critical') }} <span class="font-mono">{{ goroutinesCriticalThreshold }}</span>
<span v-if="systemMetrics?.concurrency_queue_depth != null">
· {{ t('admin.ops.queue') }} <span class="font-mono">{{ systemMetrics.concurrency_queue_depth }}</span>
</span>
</div>
</div>
<!-- Jobs -->
<div class="rounded-xl bg-gray-50 p-3 dark:bg-dark-900">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-1">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.jobs') }}</div>
<HelpTooltip v-if="!props.fullscreen" :content="t('admin.ops.tooltips.jobs')" />
</div>
<button v-if="!props.fullscreen" class="text-[10px] font-bold text-blue-500 hover:underline" type="button" @click="openJobsDetails">
{{ t('admin.ops.requestDetails.details') }}
</button>
</div>
<div class="mt-1 text-lg font-black" :class="jobsStatusClass">
{{ jobsStatusLabel }}
</div>
<div v-if="!props.fullscreen" class="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
{{ t('common.total') }} <span class="font-mono">{{ jobHeartbeats.length }}</span>
· {{ t('common.warning') }} <span class="font-mono">{{ jobsWarnCount }}</span>
</div>
</div>
</div>
</div>
<BaseDialog :show="showJobsDetails" :title="t('admin.ops.jobs')" width="wide" @close="showJobsDetails = false">
<div v-if="!jobHeartbeats.length" class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.ops.noData') }}
</div>
<div v-else class="space-y-3">
<div
v-for="hb in jobHeartbeats"
:key="hb.job_name"
class="rounded-xl border border-gray-100 bg-white p-4 dark:border-dark-700 dark:bg-dark-900"
>
<div class="flex items-center justify-between gap-3">
<div class="truncate text-sm font-semibold text-gray-900 dark:text-white">{{ hb.job_name }}</div>
<div class="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400">
<span v-if="hb.last_duration_ms != null" class="font-mono">{{ hb.last_duration_ms }}ms</span>
<span>{{ formatTimeShort(hb.updated_at) }}</span>
</div>
</div>
<div class="mt-2 grid grid-cols-1 gap-2 text-xs text-gray-600 dark:text-gray-300 sm:grid-cols-2">
<div>
{{ t('admin.ops.lastSuccess') }} <span class="font-mono">{{ formatTimeShort(hb.last_success_at) }}</span>
</div>
<div>
{{ t('admin.ops.lastError') }} <span class="font-mono">{{ formatTimeShort(hb.last_error_at) }}</span>
</div>
<div>
{{ t('admin.ops.result') }} <span class="font-mono">{{ hb.last_result || '-' }}</span>
</div>
</div>
<div
v-if="hb.last_error"
class="mt-3 rounded-lg bg-rose-50 p-2 text-xs text-rose-700 dark:bg-rose-900/20 dark:text-rose-300"
>
{{ hb.last_error }}
</div>
</div>
</div>
</BaseDialog>
<!-- Custom Time Range Dialog -->
<BaseDialog :show="showCustomTimeRangeDialog" :title="t('admin.ops.timeRange.custom')" width="narrow" @close="handleCustomTimeRangeCancel">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ t('admin.ops.customTimeRange.startTime') }}
</label>
<input
v-model="customStartTimeInput"
type="datetime-local"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-dark-600 dark:bg-dark-800 dark:text-white"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
{{ t('admin.ops.customTimeRange.endTime') }}
</label>
<input
v-model="customEndTimeInput"
type="datetime-local"
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-dark-600 dark:bg-dark-800 dark:text-white"
/>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
@click="handleCustomTimeRangeCancel"
>
{{ t('common.cancel') }}
</button>
<button
type="button"
class="rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600"
@click="handleCustomTimeRangeConfirm"
>
{{ t('common.confirm') }}
</button>
</div>
</div>
</BaseDialog>
</div>
</template>