diff --git a/backend/migrations/030_ops_monitoring_vnext.sql b/backend/migrations/030_ops_monitoring_vnext.sql index 39b19e5d..a18c061d 100644 --- a/backend/migrations/030_ops_monitoring_vnext.sql +++ b/backend/migrations/030_ops_monitoring_vnext.sql @@ -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).'; diff --git a/frontend/src/api/admin/ops.ts b/frontend/src/api/admin/ops.ts index 851993ca..42b9e70d 100644 --- a/frontend/src/api/admin/ops.ts +++ b/frontend/src/api/admin/ops.ts @@ -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 diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 1caae1d5..a4c631cb 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index d8ce293c..ced386d5 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -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: '基于当前指标的自动诊断建议', diff --git a/frontend/src/views/admin/ops/components/OpsDashboardHeader.vue b/frontend/src/views/admin/ops/components/OpsDashboardHeader.vue index c2c6adb6..04cae822 100644 --- a/frontend/src/views/admin/ops/components/OpsDashboardHeader.vue +++ b/frontend/src/views/admin/ops/components/OpsDashboardHeader.vue @@ -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() const { t } = useI18n() +const realtimeWindow = ref('1min') + +const overview = computed(() => props.overview ?? null) +const systemMetrics = computed(() => overview.value?.system_metrics ?? null) + +// --- Filters --- + const groups = ref>([]) 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 qpsLabel = computed(() => { +const totalRequestsLabel = computed(() => formatNumber(overview.value?.request_count_total ?? 0)) +const totalTokensLabel = computed(() => formatNumber(overview.value?.token_consumed ?? 0)) + +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(() => { + 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(() => { + 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(() => { + 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(() => { + 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(() => { + const v = systemMetrics.value?.db_conn_active + return typeof v === 'number' && Number.isFinite(v) ? v : null +}) + +const dbConnIdleValue = computed(() => { + const v = systemMetrics.value?.db_conn_idle + return typeof v === 'number' && Number.isFinite(v) ? v : null +}) + +const dbConnWaitingValue = computed(() => { + const v = systemMetrics.value?.db_conn_waiting + return typeof v === 'number' && Number.isFinite(v) ? v : null +}) + +const dbConnOpenValue = computed(() => { + if (dbConnActiveValue.value == null || dbConnIdleValue.value == null) return null + return dbConnActiveValue.value + dbConnIdleValue.value +}) + +const dbMaxOpenConnsValue = computed(() => { + const v = systemMetrics.value?.db_max_open_conns + return typeof v === 'number' && Number.isFinite(v) ? v : null +}) + +const dbUsagePercent = computed(() => { + 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(() => { + const v = systemMetrics.value?.redis_conn_total + return typeof v === 'number' && Number.isFinite(v) ? v : null +}) + +const redisConnIdleValue = computed(() => { + const v = systemMetrics.value?.redis_conn_idle + return typeof v === 'number' && Number.isFinite(v) ? v : null +}) + +const redisConnActiveValue = computed(() => { + if (redisConnTotalValue.value == null || redisConnIdleValue.value == null) return null + return Math.max(redisConnTotalValue.value - redisConnIdleValue.value, 0) +}) + +const redisPoolSizeValue = computed(() => { + const v = systemMetrics.value?.redis_pool_size + return typeof v === 'number' && Number.isFinite(v) ? v : null +}) + +const redisUsagePercent = computed(() => { + 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(() => { + 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 +}