Commit c48dc097 authored by IanShaw027's avatar IanShaw027
Browse files

feat(运维监控): 重构仪表板布局和增强数据展示

主要改动:
- 重构仪表板为左右布局(5:7比例)
- 左侧:健康评分 + 实时信息(当前/峰值/平均 QPS/TPS)
- 右侧:6个卡片展示详细指标(3列x2行)
  - 总请求:请求数、Token数、平均QPS/TPS、平均延迟/TTFT
  - SLA:百分比、异常数、进度条
  - 延迟:P99/P95/P90/P50/Avg/Max(带颜色编码)
  - TTFT:P99/P95/P90/P50/Avg/Max(带颜色编码)
  - 请求错误:错误率、错误数、业务限制数
  - 上游错误:错误率、错误数(排除429/529)、429/529数
- 添加延迟/TTFT颜色编码(<500ms绿色,<1s黄色,<2s橙色,≥2s红色)
- 添加实时窗口选择器(1min/5min/30min/1h)
- 优化时间段选择器标签("近5分钟"等)
- 完善中英文i18n翻译
- 数据库:添加Redis连接池字段(redis_conn_total, redis_conn_idle)
parent 585257d3
......@@ -705,3 +705,13 @@ INSERT INTO ops_alert_rules (
'当错误率超过 20% 且持续 1 分钟时触发告警(服务严重异常)',
true, 'error_rate', '>', 20.0, 1, 1, 'P0', true, 15, NOW(), NOW()
) ON CONFLICT (name) DO NOTHING;
-- Ops Monitoring vNext: add Redis pool stats fields to system metrics snapshots.
-- This migration is intentionally idempotent.
ALTER TABLE ops_system_metrics
ADD COLUMN IF NOT EXISTS redis_conn_total INT,
ADD COLUMN IF NOT EXISTS redis_conn_idle INT;
COMMENT ON COLUMN ops_system_metrics.redis_conn_total IS 'Redis pool total connections (go-redis PoolStats.TotalConns).';
COMMENT ON COLUMN ops_system_metrics.redis_conn_idle IS 'Redis pool idle connections (go-redis PoolStats.IdleConns).';
......@@ -230,6 +230,10 @@ export interface OpsSystemMetricsSnapshot {
db_ok?: boolean | null
redis_ok?: boolean | null
// Config-derived limits (best-effort) for rendering "current vs max".
db_max_open_conns?: number | null
redis_pool_size?: number | null
redis_conn_total?: number | null
redis_conn_idle?: number | null
......
......@@ -1737,6 +1737,8 @@ export default {
active: 'active',
idle: 'idle',
waiting: 'waiting',
conns: 'conns',
queue: 'queue',
ok: 'ok',
lastRun: 'last_run:',
lastSuccess: 'last_success:',
......@@ -1750,6 +1752,17 @@ export default {
tps: 'TPS:',
current: 'current',
peak: 'peak',
average: 'average',
totalRequests: 'Total Requests',
avgQps: 'Avg QPS',
avgTps: 'Avg TPS',
avgLatency: 'Avg Latency',
avgTtft: 'Avg TTFT',
exceptions: 'Exceptions',
requestErrors: 'Request Errors',
errorCount: 'Error Count',
upstreamErrors: 'Upstream Errors',
errorCountExcl429529: 'Error Count (excl 429/529)',
sla: 'SLA (excl business limits)',
businessLimited: 'business_limited:',
errors: 'Errors',
......@@ -1792,6 +1805,42 @@ export default {
healthyStatus: 'Healthy',
riskyStatus: 'At Risk',
idleStatus: 'Idle',
realtime: {
title: 'Realtime',
connected: 'Connected',
connecting: 'Connecting',
reconnecting: 'Reconnecting',
offline: 'Offline',
closed: 'Closed',
reconnectIn: 'Reconnect in {seconds}s'
},
tooltips: {
qps: 'Queries per second - real-time request rate',
sla: 'Service Level Agreement - percentage of requests within acceptable latency',
latency: 'Request duration from start to finish',
ttft: 'Time to First Token - latency until first response token',
errors: 'Request errors within SLA scope',
upstreamErrors: 'Errors from upstream services (excluding rate limits)',
totalRequests: 'Total requests and tokens consumed in this time window',
cpu: 'CPU usage percentage',
memory: 'Memory usage percentage',
db: 'Database connection pool status',
redis: 'Redis connection pool status',
goroutines: 'Go routine count (concurrent tasks)',
jobs: 'Background job health status'
},
timeRange: {
'5m': 'Last 5 minutes',
'30m': 'Last 30 minutes',
'1h': 'Last 1 hour',
'6h': 'Last 6 hours',
'24h': 'Last 24 hours'
},
queryMode: {
auto: 'Auto',
raw: 'Raw Query',
preagg: 'Pre-aggregated'
},
diagnosis: {
title: 'Smart Diagnosis',
footer: 'Automated diagnostic suggestions based on current metrics',
......
......@@ -1882,6 +1882,8 @@ export default {
active: '活跃',
idle: '空闲',
waiting: '等待',
conns: '连接',
queue: '队列',
ok: '正常',
lastRun: '最近运行',
lastSuccess: '最近成功',
......@@ -1895,6 +1897,17 @@ export default {
tps: 'TPS',
current: '当前',
peak: '峰值',
average: '平均',
totalRequests: '总请求',
avgQps: '平均 QPS',
avgTps: '平均 TPS',
avgLatency: '平均延迟',
avgTtft: '平均首字延迟',
exceptions: '异常数',
requestErrors: '请求错误',
errorCount: '错误数',
upstreamErrors: '上游错误',
errorCountExcl429529: '错误数(排除429/529)',
sla: 'SLA(排除业务限制)',
businessLimited: '业务限制:',
errors: '错误',
......@@ -1937,6 +1950,42 @@ export default {
healthyStatus: '健康',
riskyStatus: '风险',
idleStatus: '待机',
realtime: {
title: '实时信息',
connected: '已连接',
connecting: '连接中',
reconnecting: '重连中',
offline: '离线',
closed: '已关闭',
reconnectIn: '{seconds}秒后重连'
},
tooltips: {
qps: '每秒查询数 - 实时请求速率',
sla: '服务等级协议 - 可接受延迟范围内的请求百分比',
latency: '从开始到结束的请求持续时间',
ttft: '首字延迟 - 直到第一个响应令牌的延迟',
errors: 'SLA 范围内的请求错误',
upstreamErrors: '上游服务错误(不包括速率限制)',
totalRequests: '此时间窗口内的总请求数和消耗的令牌数',
cpu: 'CPU 使用率',
memory: '内存使用率',
db: '数据库连接池状态',
redis: 'Redis 连接池状态',
goroutines: 'Go 协程数(并发任务)',
jobs: '后台任务健康状态'
},
timeRange: {
'5m': '近5分钟',
'30m': '近30分钟',
'1h': '近1小时',
'6h': '近6小时',
'24h': '近24小时'
},
queryMode: {
auto: '自动',
raw: '原始查询',
preagg: '预聚合'
},
diagnosis: {
title: '智能诊断',
footer: '基于当前指标的自动诊断建议',
......
......@@ -2,10 +2,15 @@
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 { adminAPI } from '@/api'
import type { OpsDashboardOverview, OpsWSStatus } from '@/api/admin/ops'
import type { OpsRequestDetailsPreset } from './OpsRequestDetailsModal.vue'
import { formatNumber } from '@/utils/format'
type RealtimeWindow = '1min' | '5min' | '30min' | '1h'
interface Props {
overview?: OpsDashboardOverview | null
wsStatus: OpsWSStatus
......@@ -27,7 +32,7 @@ interface Emits {
(e: 'update:timeRange', value: string): void
(e: 'update:queryMode', value: string): void
(e: 'refresh'): void
(e: 'openRequestDetails'): void
(e: 'openRequestDetails', preset?: OpsRequestDetailsPreset): void
(e: 'openErrorDetails', kind: 'request' | 'upstream'): void
}
......@@ -36,6 +41,13 @@ const emit = defineEmits<Emits>()
const { t } = useI18n()
const realtimeWindow = ref<RealtimeWindow>('1min')
const overview = computed(() => props.overview ?? null)
const systemMetrics = computed(() => overview.value?.system_metrics ?? null)
// --- Filters ---
const groups = ref<Array<{ id: number; name: string; platform: string }>>([])
const platformOptions = computed(() => [
......@@ -47,11 +59,11 @@ const platformOptions = computed(() => [
])
const timeRangeOptions = computed(() => [
{ value: '5m', label: '5m' },
{ value: '30m', label: '30m' },
{ value: '1h', label: '1h' },
{ value: '6h', label: '6h' },
{ value: '24h', label: '24h' }
{ 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') }
])
const queryModeOptions = computed(() => [
......@@ -107,65 +119,107 @@ 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)
}
const updatedAtLabel = computed(() => {
if (!props.lastUpdated) return t('common.unknown')
return props.lastUpdated.toLocaleTimeString()
})
const totalRequestsLabel = computed(() => {
const n = props.overview?.request_count_total ?? 0
return formatNumber(n)
})
// --- Color coding for latency/TTFT ---
function getLatencyColor(ms: number | null | undefined): string {
if (ms == null) return 'text-gray-900 dark:text-white'
if (ms < 500) return 'text-green-600 dark:text-green-400'
if (ms < 1000) return 'text-yellow-600 dark:text-yellow-400'
if (ms < 2000) return 'text-orange-600 dark:text-orange-400'
return 'text-red-600 dark:text-red-400'
}
const totalTokensLabel = computed(() => {
const n = props.overview?.token_consumed ?? 0
return formatNumber(n)
})
// --- Realtime / Overview labels ---
const totalRequestsLabel = computed(() => formatNumber(overview.value?.request_count_total ?? 0))
const totalTokensLabel = computed(() => formatNumber(overview.value?.token_consumed ?? 0))
const qpsLabel = computed(() => {
const displayRealTimeQps = computed(() => {
const ov = overview.value
if (!ov) return 0
const useRealtime = props.wsStatus === 'connected' && !!props.wsHasData
const n = useRealtime ? props.realTimeQps : props.overview?.qps?.current
if (typeof n !== 'number') return '-'
return n.toFixed(1)
const v = useRealtime ? props.realTimeQps : ov.qps?.current
return typeof v === 'number' && Number.isFinite(v) ? v : 0
})
const tpsLabel = computed(() => {
const displayRealTimeTps = computed(() => {
const ov = overview.value
if (!ov) return 0
const useRealtime = props.wsStatus === 'connected' && !!props.wsHasData
const n = useRealtime ? props.realTimeTps : props.overview?.tps?.current
if (typeof n !== 'number') return '-'
return n.toFixed(1)
const v = useRealtime ? props.realTimeTps : ov.tps?.current
return typeof v === 'number' && Number.isFinite(v) ? v : 0
})
const qpsPeakLabel = computed(() => {
const n = props.overview?.qps?.peak
if (typeof n !== 'number') return '-'
return n.toFixed(1)
const v = overview.value?.qps?.peak
if (typeof v !== 'number') return '-'
return v.toFixed(1)
})
const tpsPeakLabel = computed(() => {
const n = props.overview?.tps?.peak
if (typeof n !== 'number') return '-'
return n.toFixed(1)
const v = overview.value?.tps?.peak
if (typeof v !== 'number') return '-'
return v.toFixed(1)
})
const slaLabel = computed(() => {
const v = props.overview?.sla
const qpsAvgLabel = computed(() => {
const v = overview.value?.qps?.avg
if (typeof v !== 'number') return '-'
return `${(v * 100).toFixed(3)}%`
return v.toFixed(1)
})
const errorRateLabel = computed(() => {
const v = props.overview?.error_rate
const tpsAvgLabel = computed(() => {
const v = overview.value?.tps?.avg
if (typeof v !== 'number') return '-'
return `${(v * 100).toFixed(2)}%`
return v.toFixed(1)
})
const upstreamErrorRateLabel = computed(() => {
const v = props.overview?.upstream_error_rate
if (typeof v !== 'number') return '-'
return `${(v * 100).toFixed(2)}%`
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)
// --- WebSocket status ---
const wsStatusLabel = computed(() => {
switch (props.wsStatus) {
case 'connected':
......@@ -204,11 +258,365 @@ const wsReconnectHint = computed(() => {
const sec = Math.max(1, Math.ceil(delayMs / 1000))
return t('admin.ops.realtime.reconnectIn', { seconds: sec })
})
// --- Health Score & Diagnosis (primary) ---
const isSystemIdle = computed(() => {
const ov = overview.value
if (!ov) return true
const qps = props.wsStatus === 'connected' && props.wsHasData ? props.realTimeQps : 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 = 100
const strokeWidth = 8
const radius = (circleSize - strokeWidth) / 2
const circumference = 2 * Math.PI * radius
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 - (score / 100) * circumference
})
interface DiagnosisItem {
type: 'critical' | 'warning' | 'info'
message: string
impact: 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
}
const upstreamRatePct = (ov.upstream_error_rate ?? 0) * 100
if (upstreamRatePct > 10) {
report.push({
type: 'critical',
message: t('admin.ops.diagnosis.upstreamCritical', { rate: upstreamRatePct.toFixed(2) }),
impact: t('admin.ops.diagnosis.upstreamCriticalImpact')
})
} else if (upstreamRatePct > 3) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.upstreamHigh', { rate: upstreamRatePct.toFixed(2) }),
impact: t('admin.ops.diagnosis.upstreamHighImpact')
})
}
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')
})
} else if (slaPct < 98) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.slaLow', { sla: slaPct.toFixed(2) }),
impact: t('admin.ops.diagnosis.slaLowImpact')
})
}
const errorPct = (ov.error_rate ?? 0) * 100
if (errorPct > 5) {
report.push({
type: 'critical',
message: t('admin.ops.diagnosis.errorHigh', { rate: errorPct.toFixed(2) }),
impact: t('admin.ops.diagnosis.errorHighImpact')
})
} else if (errorPct > 1) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.errorElevated', { rate: errorPct.toFixed(2) }),
impact: t('admin.ops.diagnosis.errorElevatedImpact')
})
}
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')
})
} else if (healthScoreValue.value < 90) {
report.push({
type: 'warning',
message: t('admin.ops.diagnosis.healthLow', { score: healthScoreValue.value }),
impact: t('admin.ops.diagnosis.healthLowImpact')
})
}
}
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
}
</script>
<template>
<div class="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
<!-- Top Toolbar (style aligned with docs/sub2api baseline) -->
<!-- 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">
......@@ -222,21 +630,25 @@ const wsReconnectHint = computed(() => {
</svg>
{{ t('admin.ops.title') }}
</h1>
<div 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 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') }}: {{ updatedAtLabel }}</span>
<span>·</span>
<span class="flex items-center gap-1.5">
<span class="relative flex h-2 w-2">
<span
v-if="wsStatus === 'connected'"
class="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"
></span>
<span class="relative inline-flex h-2 w-2 rounded-full" :class="wsStatusDotClass"></span>
</span>
<span>{{ wsStatusLabel }}</span>
......@@ -256,7 +668,7 @@ const wsReconnectHint = computed(() => {
<Select
:model-value="groupId"
:options="groupOptions"
class="w-full sm:w-[140px]"
class="w-full sm:w-[160px]"
@update:model-value="handleGroupChange"
/>
......@@ -295,80 +707,549 @@ const wsReconnectHint = computed(() => {
</div>
</div>
<!-- Placeholder section to keep header height close to baseline.
Will be progressively filled as Milestone 6 modules land. -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900/30">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.requests') }}</div>
<div class="mt-2 text-xl font-black text-gray-900 dark:text-white">
{{ totalRequestsLabel }}
<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 p-4 dark:bg-dark-900 lg:col-span-5">
<div class="grid 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">
🧠 {{ 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>
<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>
</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="text-3xl font-black" :class="healthScoreClass">
{{ isSystemIdle ? t('admin.ops.idleStatus') : (overview.health_score ?? '--') }}
</span>
<span class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.health') }}</span>
</div>
</div>
<div class="mt-4 text-center">
<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 flex-col justify-center py-2">
<div class="mb-3 flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<div class="relative flex h-3 w-3">
<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 :content="t('admin.ops.tooltips.qps')" />
</div>
<!-- Time Window Selector -->
<div class="flex gap-1">
<button
v-for="window in (['1min', '5min', '30min', '1h'] as RealtimeWindow[])"
:key="window"
type="button"
class="rounded px-2 py-0.5 text-[10px] font-bold transition-colors"
: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="grid grid-cols-2 gap-3">
<!-- Current QPS/TPS -->
<div>
<div class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.current') }}</div>
<div class="mt-1 flex items-baseline gap-2">
<span class="text-2xl font-black text-gray-900 dark:text-white">{{ displayRealTimeQps.toFixed(1) }}</span>
<span class="text-xs font-bold text-gray-500">QPS</span>
</div>
<div class="mt-0.5 text-xs font-medium text-gray-500">
TPS: {{ displayRealTimeTps.toFixed(1) }}
</div>
</div>
<!-- Peak QPS/TPS -->
<div>
<div class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.peak') }}</div>
<div class="mt-1 flex items-baseline gap-2">
<span class="text-2xl font-black text-gray-900 dark:text-white">{{ qpsPeakLabel }}</span>
<span class="text-xs font-bold text-gray-500">QPS</span>
</div>
<div class="mt-0.5 text-xs font-medium text-gray-500">
TPS: {{ tpsPeakLabel }}
</div>
</div>
<!-- Average QPS/TPS -->
<div class="col-span-2">
<div class="text-[10px] font-bold uppercase text-gray-400">{{ t('admin.ops.average') }}</div>
<div class="mt-1 flex items-center gap-4 text-sm font-medium text-gray-600 dark:text-gray-400">
<span>QPS: <span class="font-bold">{{ qpsAvgLabel }}</span></span>
<span class="h-1 w-1 rounded-full bg-gray-300 dark:bg-gray-600"></span>
<span>TPS: <span class="font-bold">{{ tpsAvgLabel }}</span></span>
</div>
</div>
</div>
<div class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.ops.tokens') }}: {{ totalTokensLabel }}
</div>
</div>
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900/30">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">QPS / TPS</div>
<div class="mt-2 flex items-end justify-between gap-3">
<div class="text-xl font-black text-gray-900 dark:text-white">
{{ qpsLabel }} <span class="text-xs font-semibold text-gray-400">/</span> {{ tpsLabel }}
</div>
<!-- Right: 6 cards (3 cols x 2 rows) -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:col-span-7 lg:grid-cols-3">
<!-- Card 1: Total Requests -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900">
<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.totalRequests') }}</span>
<HelpTooltip :content="t('admin.ops.tooltips.totalRequests')" />
</div>
<button
class="text-[10px] font-bold text-blue-500 hover:underline"
type="button"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-300 dark:hover:bg-dark-700"
:disabled="props.loading"
:title="t('admin.ops.requestDetails.title')"
@click="emit('openRequestDetails')"
@click="openDetails({ title: t('admin.ops.requestDetails.title') })"
>
{{ t('admin.ops.requestDetails.details') }}
</button>
</div>
<div class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.ops.peak') }}: {{ qpsPeakLabel }} / {{ tpsPeakLabel }}
<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="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900/30">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">SLA</div>
<div class="mt-2 text-xl font-black text-gray-900 dark:text-white">
{{ slaLabel }}
<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 class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('admin.ops.businessLimited') }}: {{ formatNumber(props.overview?.business_limited_count ?? 0) }}
<div class="flex justify-between">
<span class="text-gray-500">{{ t('admin.ops.avgLatency') }}:</span>
<span class="font-bold" :class="getLatencyColor(durationAvgMs)">{{ durationAvgMs ?? '-' }}ms</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">{{ t('admin.ops.avgTtft') }}:</span>
<span class="font-bold" :class="getLatencyColor(ttftAvgMs)">{{ ttftAvgMs ?? '-' }}ms</span>
</div>
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900/30">
<div class="text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errors') }}</div>
<div class="mt-2 flex items-center justify-between gap-3">
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
{{ t('admin.ops.errorRate') }}: <span class="font-mono font-bold text-gray-900 dark:text-white">{{ errorRateLabel }}</span>
</div>
</div>
<!-- Card 2: SLA -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-[10px] font-bold uppercase text-gray-400">SLA</span>
<HelpTooltip :content="t('admin.ops.tooltips.sla')" />
<span class="h-1.5 w-1.5 rounded-full" :class="(slaPercent ?? 0) >= 99.5 ? 'bg-green-500' : 'bg-yellow-500'"></span>
</div>
<button
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 text-3xl font-black text-gray-900 dark:text-white">
{{ 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 bg-green-500 transition-all" :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 3: Latency (Duration) -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900">
<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 :content="t('admin.ops.tooltips.latency')" />
</div>
<button
class="text-[10px] font-bold text-blue-500 hover:underline"
type="button"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-300 dark:hover:bg-dark-700"
:disabled="props.loading"
@click="emit('openErrorDetails', 'request')"
@click="openDetails({ title: t('admin.ops.latencyDuration'), sort: 'duration_desc', min_duration_ms: Math.max(Number(durationP99Ms ?? 0), 0) })"
>
{{ t('admin.ops.errorDetails.requestErrors') }}
{{ t('admin.ops.requestDetails.details') }}
</button>
</div>
<div class="mt-2 flex items-baseline gap-2">
<div class="text-3xl font-black" :class="getLatencyColor(durationP99Ms)">
{{ durationP99Ms ?? '-' }}
</div>
<span class="text-xs font-bold text-gray-400">ms (P99)</span>
</div>
<div class="mt-3 space-y-1 text-xs">
<div class="flex justify-between">
<span class="text-gray-500">P95:</span>
<span class="font-bold" :class="getLatencyColor(durationP95Ms)">{{ durationP95Ms ?? '-' }}ms</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">P90:</span>
<span class="font-bold" :class="getLatencyColor(durationP90Ms)">{{ durationP90Ms ?? '-' }}ms</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">P50:</span>
<span class="font-bold" :class="getLatencyColor(durationP50Ms)">{{ durationP50Ms ?? '-' }}ms</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Avg:</span>
<span class="font-bold" :class="getLatencyColor(durationAvgMs)">{{ durationAvgMs ?? '-' }}ms</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Max:</span>
<span class="font-bold" :class="getLatencyColor(durationMaxMs)">{{ durationMaxMs ?? '-' }}ms</span>
</div>
</div>
</div>
<!-- Card 4: TTFT -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900">
<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 :content="t('admin.ops.tooltips.ttft')" />
</div>
<button
class="text-[10px] font-bold text-blue-500 hover:underline"
type="button"
class="inline-flex items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-300 dark:hover:bg-dark-700"
:disabled="props.loading"
@click="emit('openErrorDetails', 'upstream')"
@click="openDetails({ title: 'TTFT' })"
>
{{ t('admin.ops.errorDetails.upstreamErrors') }}
{{ t('admin.ops.requestDetails.details') }}
</button>
</div>
<div class="mt-2 flex items-baseline gap-2">
<div class="text-3xl font-black" :class="getLatencyColor(ttftP99Ms)">
{{ ttftP99Ms ?? '-' }}
</div>
<span class="text-xs font-bold text-gray-400">ms (P99)</span>
</div>
<div class="mt-3 space-y-1 text-xs">
<div class="flex justify-between">
<span class="text-gray-500">P95:</span>
<span class="font-bold" :class="getLatencyColor(ttftP95Ms)">{{ ttftP95Ms ?? '-' }}ms</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">P90:</span>
<span class="font-bold" :class="getLatencyColor(ttftP90Ms)">{{ ttftP90Ms ?? '-' }}ms</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">P50:</span>
<span class="font-bold" :class="getLatencyColor(ttftP50Ms)">{{ ttftP50Ms ?? '-' }}ms</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Avg:</span>
<span class="font-bold" :class="getLatencyColor(ttftAvgMs)">{{ ttftAvgMs ?? '-' }}ms</span>
</div>
<div class="flex justify-between">
<span class="text-gray-500">Max:</span>
<span class="font-bold" :class="getLatencyColor(ttftMaxMs)">{{ ttftMaxMs ?? '-' }}ms</span>
</div>
</div>
</div>
<!-- Card 5: Request Errors -->
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900">
<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 :content="t('admin.ops.tooltips.errors')" />
</div>
<button 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="(errorRatePercent ?? 0) > 5 ? 'text-red-500' : 'text-gray-900 dark:text-white'">
{{ 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">
<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 :content="t('admin.ops.tooltips.upstreamErrors')" />
</div>
<button 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="(upstreamErrorRatePercent ?? 0) > 5 ? 'text-red-500' : 'text-gray-900 dark:text-white'">
{{ 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="mt-1 text-xs font-semibold text-gray-700 dark:text-gray-200">
{{ t('admin.ops.upstreamRate') }}: <span class="font-mono font-bold text-gray-900 dark:text-white">{{ upstreamErrorRateLabel }}</span>
<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="mb-3 flex flex-wrap items-center justify-between gap-2 text-xs text-gray-500 dark:text-gray-400">
<span v-if="systemMetrics">
{{ t('admin.ops.collectedAt') }} {{ formatTimeShort(systemMetrics.created_at) }}
({{ t('admin.ops.window') }} {{ systemMetrics.window_minutes }}m)
</span>
<span v-else>{{ t('admin.ops.noSystemMetrics') }}</span>
</div>
<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 :content="t('admin.ops.tooltips.cpu')" />
</div>
<div class="mt-1 text-lg font-black" :class="cpuPercentClass">
{{ cpuPercentValue == null ? '-' : `${cpuPercentValue.toFixed(1)}%` }}
</div>
<div 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">MEM</div>
<HelpTooltip :content="t('admin.ops.tooltips.memory')" />
</div>
<div class="mt-1 text-lg font-black" :class="memPercentClass">
{{ memPercentValue == null ? '-' : `${memPercentValue.toFixed(1)}%` }}
</div>
<div 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">DB</div>
<HelpTooltip :content="t('admin.ops.tooltips.db')" />
</div>
<div class="mt-1 text-xs font-medium text-gray-500 dark:text-gray-400">
429: {{ formatNumber(props.overview?.upstream_429_count ?? 0) }} · 529:
{{ formatNumber(props.overview?.upstream_529_count ?? 0) }}
<div class="mt-1 text-lg font-black" :class="dbMiddleClass">
{{ dbMiddleLabel }}
</div>
<div 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 :content="t('admin.ops.tooltips.redis')" />
</div>
<div class="mt-1 text-lg font-black" :class="redisMiddleClass">
{{ redisMiddleLabel }}
</div>
<div 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 :content="t('admin.ops.tooltips.goroutines')" />
</div>
<div class="mt-1 text-lg font-black" :class="goroutineStatusClass">
{{ goroutineStatusLabel }}
</div>
<div 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 :content="t('admin.ops.tooltips.jobs')" />
</div>
<button 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 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="text-xs text-gray-500 dark:text-gray-400">{{ formatTimeShort(hb.updated_at) }}</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>
<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>
</div>
</template>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment