| <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, |
| () => { |
| |
| realtimeWindow.value = '1min' |
| |
| loadRealtimeTrafficSummary() |
| } |
| ) |
|
|
| |
|
|
| 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') { |
| |
| 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('update:customTimeRange', startTime, endTime) |
| emit('update:timeRange', 'custom') |
| showCustomTimeRangeDialog.value = false |
| } |
|
|
| function handleCustomTimeRangeCancel() { |
| showCustomTimeRangeDialog.value = false |
| |
| |
| } |
|
|
| 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) |
| } |
|
|
| |
| 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' |
|
|
| |
| |
| |
| 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' |
| } |
| } |
|
|
| |
|
|
| 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) { |
| |
| realtimeTrafficSummary.value = makeZeroRealtimeTrafficSummary() |
| } else { |
| loadRealtimeTrafficSummary() |
| } |
| }, |
| { immediate: true } |
| ) |
|
|
| |
| watch( |
| () => [props.autoRefreshEnabled, props.autoRefreshCountdown, props.loading] as const, |
| ([enabled, countdown, loading]) => { |
| if (!enabled) return |
| if (loading) return |
| |
| if (countdown === 0) { |
| loadRealtimeTrafficSummary() |
| } |
| } |
| ) |
|
|
| |
|
|
| 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) |
|
|
| |
|
|
| 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' |
| const score = healthScoreValue.value |
| if (score == null) return '#9ca3af' |
| if (score >= 90) return '#10b981' |
| if (score >= 60) return '#f59e0b' |
| return '#ef4444' |
| }) |
|
|
| 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 |
| } |
|
|
| |
| 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') |
| }) |
| } |
|
|
| |
| 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') |
| }) |
| } |
|
|
| |
| 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') |
| }) |
| } |
|
|
| |
| 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 |
| }) |
|
|
| |
|
|
| 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']"> |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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"> |
| |
| <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"> |
| |
| <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" |
| > |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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'"> |
| |
| <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> |
| |
| |
| <div class="grid grid-cols-2 gap-3"> |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <div class="grid h-full grid-cols-1 content-center gap-4 sm:grid-cols-2 lg:col-span-7 lg:grid-cols-3"> |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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"> |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
| |
| |
| <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> |
|
|