feat(前端UI): 实现运维监控前端界面
- 新增帮助提示组件(HelpTooltip.vue) - 更新侧边栏添加 ops 监控菜单项 - 扩展设置视图集成 ops 配置面板 - 新增 ops 监控视图目录(dashboard, alerts, realtime, settings 等)
This commit is contained in:
44
frontend/src/components/common/HelpTooltip.vue
Normal file
44
frontend/src/components/common/HelpTooltip.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
content?: string
|
||||
}>()
|
||||
|
||||
const show = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="group relative ml-1 inline-flex items-center align-middle"
|
||||
@mouseenter="show = true"
|
||||
@mouseleave="show = false"
|
||||
>
|
||||
<!-- Trigger Icon -->
|
||||
<slot name="trigger">
|
||||
<svg
|
||||
class="h-4 w-4 cursor-help text-gray-400 transition-colors hover:text-primary-600 dark:text-gray-500 dark:hover:text-primary-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</slot>
|
||||
|
||||
<!-- Popover Content -->
|
||||
<div
|
||||
v-show="show"
|
||||
class="absolute bottom-full left-1/2 z-50 mb-2 w-64 -translate-x-1/2 rounded-lg bg-gray-900 p-3 text-xs leading-relaxed text-white shadow-xl ring-1 ring-white/10 opacity-0 transition-opacity duration-200 group-hover:opacity-100 dark:bg-gray-800"
|
||||
>
|
||||
<slot>{{ content }}</slot>
|
||||
<div class="absolute -bottom-1 left-1/2 h-2 w-2 -translate-x-1/2 rotate-45 bg-gray-900 dark:bg-gray-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -144,10 +144,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, h, ref } from 'vue'
|
||||
import { computed, h, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
|
||||
import { useAdminSettingsStore, useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
|
||||
import VersionBadge from '@/components/common/VersionBadge.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
@@ -156,6 +156,7 @@ const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
const authStore = useAuthStore()
|
||||
const onboardingStore = useOnboardingStore()
|
||||
const adminSettingsStore = useAdminSettingsStore()
|
||||
|
||||
const sidebarCollapsed = computed(() => appStore.sidebarCollapsed)
|
||||
const mobileOpen = computed(() => appStore.mobileOpen)
|
||||
@@ -442,6 +443,9 @@ const personalNavItems = computed(() => {
|
||||
const adminNavItems = computed(() => {
|
||||
const baseItems = [
|
||||
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
|
||||
...(adminSettingsStore.opsMonitoringEnabled
|
||||
? [{ path: '/admin/ops', label: t('nav.ops'), icon: ChartIcon }]
|
||||
: []),
|
||||
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true },
|
||||
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
|
||||
@@ -510,6 +514,23 @@ if (
|
||||
isDark.value = true
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
|
||||
// Fetch admin settings (for feature-gated nav items like Ops).
|
||||
watch(
|
||||
isAdmin,
|
||||
(v) => {
|
||||
if (v) {
|
||||
adminSettingsStore.fetch()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (isAdmin.value) {
|
||||
adminSettingsStore.fetch()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -665,6 +665,59 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ops Monitoring -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.opsMonitoring.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.opsMonitoring.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.settings.opsMonitoring.enabled')
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.opsMonitoring.enabledHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.ops_monitoring_enabled" />
|
||||
</div>
|
||||
|
||||
<div v-if="form.ops_monitoring_enabled" class="mt-5 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.settings.opsMonitoring.realtimeEnabled')
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.opsMonitoring.realtimeEnabledHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.ops_realtime_monitoring_enabled" />
|
||||
</div>
|
||||
|
||||
<div v-if="form.ops_monitoring_enabled" class="mt-5 flex items-center justify-between">
|
||||
<div>
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{
|
||||
t('admin.settings.opsMonitoring.queryMode')
|
||||
}}</label>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.opsMonitoring.queryModeHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
v-model="form.ops_query_mode_default"
|
||||
:options="opsQueryModeOptions"
|
||||
class="w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" :disabled="saving" class="btn btn-primary">
|
||||
@@ -687,22 +740,34 @@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Ops settings and management (only show when enabled) -->
|
||||
<div v-if="showOpsSettings" class="space-y-6">
|
||||
<OpsRuntimeSettingsCard />
|
||||
<OpsEmailNotificationCard />
|
||||
<OpsAlertRulesCard />
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api'
|
||||
import type { SystemSettings, UpdateSettingsRequest } from '@/api/admin/settings'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import Toggle from '@/components/common/Toggle.vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import { useAdminSettingsStore, useAppStore } from '@/stores'
|
||||
import OpsAlertRulesCard from '@/views/admin/ops/components/OpsAlertRulesCard.vue'
|
||||
import OpsEmailNotificationCard from '@/views/admin/ops/components/OpsEmailNotificationCard.vue'
|
||||
import OpsRuntimeSettingsCard from '@/views/admin/ops/components/OpsRuntimeSettingsCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const adminSettingsStore = useAdminSettingsStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
@@ -747,11 +812,29 @@ const form = reactive<SettingsForm>({
|
||||
turnstile_site_key: '',
|
||||
turnstile_secret_key: '',
|
||||
turnstile_secret_key_configured: false,
|
||||
// Model fallback
|
||||
enable_model_fallback: false,
|
||||
fallback_model_anthropic: 'claude-3-5-sonnet-20241022',
|
||||
fallback_model_openai: 'gpt-4o',
|
||||
fallback_model_gemini: 'gemini-2.5-pro',
|
||||
fallback_model_antigravity: 'gemini-2.5-pro',
|
||||
// Identity patch (Claude -> Gemini)
|
||||
enable_identity_patch: true,
|
||||
identity_patch_prompt: ''
|
||||
identity_patch_prompt: '',
|
||||
// Ops Monitoring (vNext)
|
||||
ops_monitoring_enabled: true,
|
||||
ops_realtime_monitoring_enabled: true,
|
||||
ops_query_mode_default: 'auto'
|
||||
})
|
||||
|
||||
const opsQueryModeOptions = computed(() => [
|
||||
{ value: 'auto', label: t('admin.settings.opsMonitoring.queryModeAuto') },
|
||||
{ value: 'raw', label: t('admin.settings.opsMonitoring.queryModeRaw') },
|
||||
{ value: 'preagg', label: t('admin.settings.opsMonitoring.queryModePreagg') }
|
||||
])
|
||||
|
||||
const showOpsSettings = computed(() => !!form.ops_monitoring_enabled)
|
||||
|
||||
function handleLogoUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
@@ -829,12 +912,25 @@ async function saveSettings() {
|
||||
smtp_use_tls: form.smtp_use_tls,
|
||||
turnstile_enabled: form.turnstile_enabled,
|
||||
turnstile_site_key: form.turnstile_site_key,
|
||||
turnstile_secret_key: form.turnstile_secret_key || undefined
|
||||
turnstile_secret_key: form.turnstile_secret_key || undefined,
|
||||
enable_model_fallback: form.enable_model_fallback,
|
||||
fallback_model_anthropic: form.fallback_model_anthropic,
|
||||
fallback_model_openai: form.fallback_model_openai,
|
||||
fallback_model_gemini: form.fallback_model_gemini,
|
||||
fallback_model_antigravity: form.fallback_model_antigravity,
|
||||
enable_identity_patch: form.enable_identity_patch,
|
||||
identity_patch_prompt: form.identity_patch_prompt,
|
||||
ops_monitoring_enabled: form.ops_monitoring_enabled,
|
||||
ops_realtime_monitoring_enabled: form.ops_realtime_monitoring_enabled,
|
||||
ops_query_mode_default: form.ops_query_mode_default
|
||||
}
|
||||
const updated = await adminAPI.settings.updateSettings(payload)
|
||||
Object.assign(form, updated)
|
||||
form.smtp_password = ''
|
||||
form.turnstile_secret_key = ''
|
||||
adminSettingsStore.setOpsMonitoringEnabledLocal(!!updated.ops_monitoring_enabled)
|
||||
adminSettingsStore.setOpsRealtimeMonitoringEnabledLocal(!!updated.ops_realtime_monitoring_enabled)
|
||||
adminSettingsStore.setOpsQueryModeDefaultLocal(updated.ops_query_mode_default || 'auto')
|
||||
// Refresh cached public settings so sidebar/header update immediately
|
||||
await appStore.fetchPublicSettings(true)
|
||||
appStore.showSuccess(t('admin.settings.settingsSaved'))
|
||||
|
||||
854
frontend/src/views/admin/ops/OpsDashboard.vue
Normal file
854
frontend/src/views/admin/ops/OpsDashboard.vue
Normal file
@@ -0,0 +1,854 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6 pb-12">
|
||||
<div
|
||||
v-if="errorMessage"
|
||||
class="rounded-2xl bg-red-50 p-4 text-sm text-red-600 dark:bg-red-900/20 dark:text-red-400"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<OpsDashboardSkeleton v-if="loading && !hasLoadedOnce" />
|
||||
|
||||
<OpsDashboardHeader
|
||||
v-else-if="opsEnabled"
|
||||
:overview="overview"
|
||||
:ws-status="wsStatus"
|
||||
:ws-reconnect-in-ms="wsReconnectInMs"
|
||||
:ws-has-data="wsHasData"
|
||||
:real-time-qps="realTimeQPS"
|
||||
:real-time-tps="realTimeTPS"
|
||||
:platform="platform"
|
||||
:group-id="groupId"
|
||||
:time-range="timeRange"
|
||||
:query-mode="queryMode"
|
||||
:loading="loading"
|
||||
:last-updated="lastUpdated"
|
||||
@update:time-range="onTimeRangeChange"
|
||||
@update:platform="onPlatformChange"
|
||||
@update:group="onGroupChange"
|
||||
@update:query-mode="onQueryModeChange"
|
||||
@refresh="fetchData"
|
||||
@open-request-details="handleOpenRequestDetails"
|
||||
@open-error-details="openErrorDetails"
|
||||
/>
|
||||
|
||||
<!-- Overview -->
|
||||
<div
|
||||
v-if="opsEnabled && !(loading && !hasLoadedOnce)"
|
||||
class="overflow-hidden rounded-3xl bg-white shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700"
|
||||
>
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.systemHealth') }}</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div v-if="loadingOverview" class="flex items-center justify-center py-10">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!overview?.system_metrics" class="py-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.noSystemMetrics') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.collectedAt') }} {{ formatDateTime(overview.system_metrics.created_at) }} ({{ t('admin.ops.window') }}
|
||||
{{ overview.system_metrics.window_minutes }}m)
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-5">
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800/50">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.cpu') }}</div>
|
||||
<div class="mt-1 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ formatPercent0to100(overview.system_metrics.cpu_usage_percent) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800/50">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.memory') }}</div>
|
||||
<div class="mt-1 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ formatPercent0to100(overview.system_metrics.memory_usage_percent) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ formatMBPair(overview.system_metrics.memory_used_mb, overview.system_metrics.memory_total_mb) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800/50">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.db') }}</div>
|
||||
<div class="mt-1 text-xl font-semibold" :class="boolOkClass(overview.system_metrics.db_ok)">
|
||||
{{ boolOkLabel(overview.system_metrics.db_ok) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.active') }}: {{ overview.system_metrics.db_conn_active ?? '-' }}, {{ t('admin.ops.idle') }}:
|
||||
{{ overview.system_metrics.db_conn_idle ?? '-' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800/50">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.redis') }}</div>
|
||||
<div class="mt-1 text-xl font-semibold" :class="boolOkClass(overview.system_metrics.redis_ok)">
|
||||
{{ boolOkLabel(overview.system_metrics.redis_ok) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800/50">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.goroutines') }}</div>
|
||||
<div class="mt-1 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ overview.system_metrics.goroutine_count ?? '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="overview?.job_heartbeats?.length" class="rounded-xl border border-gray-100 dark:border-dark-700">
|
||||
<div class="border-b border-gray-100 px-4 py-3 text-sm font-semibold text-gray-900 dark:border-dark-700 dark:text-white">
|
||||
{{ t('admin.ops.jobs') }}
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100 dark:divide-dark-700">
|
||||
<div
|
||||
v-for="job in overview.job_heartbeats"
|
||||
:key="job.job_name"
|
||||
class="flex flex-col gap-1 px-4 py-3 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ job.job_name }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.lastRun') }}: {{ job.last_run_at ? formatDateTime(job.last_run_at) : '-' }} · {{ t('admin.ops.lastSuccess') }}:
|
||||
{{ job.last_success_at ? formatDateTime(job.last_success_at) : '-' }} ·
|
||||
<span v-if="job.last_error" class="text-rose-600 dark:text-rose-400">
|
||||
{{ t('admin.ops.lastError') }}: {{ job.last_error }}
|
||||
</span>
|
||||
<span v-else class="text-emerald-600 dark:text-emerald-400">{{ t('admin.ops.ok') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="opsEnabled && !(loading && !hasLoadedOnce)" class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.overview') }}</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div v-if="loadingOverview" class="flex items-center justify-center py-10">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!overview" class="py-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.noData') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800/50">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.requestsTotal') }}</div>
|
||||
<div class="mt-1 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ formatInt(overview.request_count_total) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.slaScope') }} {{ formatInt(overview.request_count_sla) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800/50">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.tokens') }}</div>
|
||||
<div class="mt-1 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ formatInt(overview.token_consumed) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.tps') }} {{ overview.tps.current }} ({{ t('admin.ops.peak') }} {{ overview.tps.peak }})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800/50">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.sla') }}</div>
|
||||
<div class="mt-1 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ formatPercent(overview.sla) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.businessLimited') }}: {{ formatInt(overview.business_limited_count) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-800/50">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.errors') }}</div>
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ t('admin.ops.errorRate') }}: <span class="font-semibold">{{ formatPercent(overview.error_rate) }}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ t('admin.ops.upstreamRate') }}: <span class="font-semibold">{{ formatPercent(overview.upstream_error_rate) }}</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
429: {{ formatInt(overview.upstream_429_count) }} · 529:
|
||||
{{ formatInt(overview.upstream_529_count) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.latencyDuration') }}</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 text-xs text-gray-600 dark:text-gray-300 md:grid-cols-3">
|
||||
<div>{{ t('admin.ops.p50') }}: <span class="font-mono">{{ formatMs(overview.duration.p50_ms) }}</span></div>
|
||||
<div>{{ t('admin.ops.p90') }}: <span class="font-mono">{{ formatMs(overview.duration.p90_ms) }}</span></div>
|
||||
<div>{{ t('admin.ops.p95') }}: <span class="font-mono">{{ formatMs(overview.duration.p95_ms) }}</span></div>
|
||||
<div>{{ t('admin.ops.p99') }}: <span class="font-mono">{{ formatMs(overview.duration.p99_ms) }}</span></div>
|
||||
<div>{{ t('admin.ops.avg') }}: <span class="font-mono">{{ formatMs(overview.duration.avg_ms) }}</span></div>
|
||||
<div>{{ t('admin.ops.max') }}: <span class="font-mono">{{ formatMs(overview.duration.max_ms) }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.ttftLabel') }}</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 text-xs text-gray-600 dark:text-gray-300 md:grid-cols-3">
|
||||
<div>{{ t('admin.ops.p50') }}: <span class="font-mono">{{ formatMs(overview.ttft.p50_ms) }}</span></div>
|
||||
<div>{{ t('admin.ops.p90') }}: <span class="font-mono">{{ formatMs(overview.ttft.p90_ms) }}</span></div>
|
||||
<div>{{ t('admin.ops.p95') }}: <span class="font-mono">{{ formatMs(overview.ttft.p95_ms) }}</span></div>
|
||||
<div>{{ t('admin.ops.p99') }}: <span class="font-mono">{{ formatMs(overview.ttft.p99_ms) }}</span></div>
|
||||
<div>{{ t('admin.ops.avg') }}: <span class="font-mono">{{ formatMs(overview.ttft.avg_ms) }}</span></div>
|
||||
<div>{{ t('admin.ops.max') }}: <span class="font-mono">{{ formatMs(overview.ttft.max_ms) }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row: Concurrency + Throughput -->
|
||||
<div v-if="opsEnabled && !(loading && !hasLoadedOnce)" class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div class="lg:col-span-1 min-h-[360px]">
|
||||
<OpsConcurrencyCard :platform-filter="platform" :group-id-filter="groupId" />
|
||||
</div>
|
||||
<div class="lg:col-span-2 min-h-[360px]">
|
||||
<OpsThroughputTrendChart
|
||||
:points="throughputTrend?.points ?? []"
|
||||
:by-platform="throughputTrend?.by_platform ?? []"
|
||||
:top-groups="throughputTrend?.top_groups ?? []"
|
||||
:loading="loadingTrend"
|
||||
:time-range="timeRange"
|
||||
@select-platform="handleThroughputSelectPlatform"
|
||||
@select-group="handleThroughputSelectGroup"
|
||||
@open-details="handleOpenRequestDetails"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row: Visual Analysis (baseline 3-up grid) -->
|
||||
<div v-if="opsEnabled && !(loading && !hasLoadedOnce)" class="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<OpsLatencyChart :latency-data="latencyHistogram" :loading="loadingLatency" />
|
||||
<OpsErrorDistributionChart
|
||||
:data="errorDistribution"
|
||||
:loading="loadingErrorDistribution"
|
||||
@open-details="openErrorDetails('request')"
|
||||
/>
|
||||
<OpsErrorTrendChart
|
||||
:points="errorTrend?.points ?? []"
|
||||
:loading="loadingErrorTrend"
|
||||
:time-range="timeRange"
|
||||
@open-request-errors="openErrorDetails('request')"
|
||||
@open-upstream-errors="openErrorDetails('upstream')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Alert Events -->
|
||||
<OpsAlertEventsCard v-if="opsEnabled && !(loading && !hasLoadedOnce)" />
|
||||
|
||||
<OpsErrorDetailsModal
|
||||
:show="showErrorDetails"
|
||||
:time-range="timeRange"
|
||||
:platform="platform"
|
||||
:group-id="groupId"
|
||||
:error-type="errorDetailsType"
|
||||
@update:show="showErrorDetails = $event"
|
||||
@openErrorDetail="openError"
|
||||
/>
|
||||
|
||||
<OpsErrorDetailModal v-model:show="showErrorModal" :error-id="selectedErrorId" />
|
||||
|
||||
<OpsRequestDetailsModal
|
||||
v-model="showRequestDetails"
|
||||
:time-range="timeRange"
|
||||
:preset="requestDetailsPreset"
|
||||
:platform="platform"
|
||||
:group-id="groupId"
|
||||
@openErrorDetail="openError"
|
||||
/>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import {
|
||||
opsAPI,
|
||||
OPS_WS_CLOSE_CODES,
|
||||
type OpsWSStatus,
|
||||
type OpsDashboardOverview,
|
||||
type OpsErrorDistributionResponse,
|
||||
type OpsErrorTrendResponse,
|
||||
type OpsLatencyHistogramResponse,
|
||||
type OpsThroughputTrendResponse
|
||||
} from '@/api/admin/ops'
|
||||
import { useAdminSettingsStore, useAppStore } from '@/stores'
|
||||
import OpsDashboardHeader from './components/OpsDashboardHeader.vue'
|
||||
import OpsDashboardSkeleton from './components/OpsDashboardSkeleton.vue'
|
||||
import OpsConcurrencyCard from './components/OpsConcurrencyCard.vue'
|
||||
import OpsErrorDetailModal from './components/OpsErrorDetailModal.vue'
|
||||
import OpsErrorDistributionChart from './components/OpsErrorDistributionChart.vue'
|
||||
import OpsErrorDetailsModal from './components/OpsErrorDetailsModal.vue'
|
||||
import OpsErrorTrendChart from './components/OpsErrorTrendChart.vue'
|
||||
import OpsLatencyChart from './components/OpsLatencyChart.vue'
|
||||
import OpsThroughputTrendChart from './components/OpsThroughputTrendChart.vue'
|
||||
import OpsAlertEventsCard from './components/OpsAlertEventsCard.vue'
|
||||
import OpsRequestDetailsModal, { type OpsRequestDetailsPreset } from './components/OpsRequestDetailsModal.vue'
|
||||
import { formatDateTime, formatNumberLocaleString } from '@/utils/format'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
const adminSettingsStore = useAdminSettingsStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const opsEnabled = computed(() => adminSettingsStore.opsMonitoringEnabled)
|
||||
|
||||
type TimeRange = '5m' | '30m' | '1h' | '6h' | '24h'
|
||||
const allowedTimeRanges = new Set<TimeRange>(['5m', '30m', '1h', '6h', '24h'])
|
||||
|
||||
type QueryMode = 'auto' | 'raw' | 'preagg'
|
||||
const allowedQueryModes = new Set<QueryMode>(['auto', 'raw', 'preagg'])
|
||||
|
||||
const loading = ref(true)
|
||||
const hasLoadedOnce = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const lastUpdated = ref<Date | null>(new Date())
|
||||
|
||||
const timeRange = ref<TimeRange>('1h')
|
||||
const platform = ref<string>('')
|
||||
const groupId = ref<number | null>(null)
|
||||
const queryMode = ref<QueryMode>('auto')
|
||||
|
||||
const QUERY_KEYS = {
|
||||
timeRange: 'tr',
|
||||
platform: 'platform',
|
||||
groupId: 'group_id',
|
||||
queryMode: 'mode'
|
||||
} as const
|
||||
|
||||
const isApplyingRouteQuery = ref(false)
|
||||
const isSyncingRouteQuery = ref(false)
|
||||
|
||||
// WebSocket for realtime QPS/TPS
|
||||
const realTimeQPS = ref(0)
|
||||
const realTimeTPS = ref(0)
|
||||
const wsStatus = ref<OpsWSStatus>('closed')
|
||||
const wsReconnectInMs = ref<number | null>(null)
|
||||
const wsHasData = ref(false)
|
||||
let unsubscribeQPS: (() => void) | null = null
|
||||
|
||||
let dashboardFetchController: AbortController | null = null
|
||||
let dashboardFetchSeq = 0
|
||||
|
||||
function isCanceledRequest(err: unknown): boolean {
|
||||
return (
|
||||
!!err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
(err as Record<string, unknown>).code === 'ERR_CANCELED'
|
||||
)
|
||||
}
|
||||
|
||||
function abortDashboardFetch() {
|
||||
if (dashboardFetchController) {
|
||||
dashboardFetchController.abort()
|
||||
dashboardFetchController = null
|
||||
}
|
||||
}
|
||||
|
||||
function stopQPSSubscription(options?: { resetMetrics?: boolean }) {
|
||||
wsStatus.value = 'closed'
|
||||
wsReconnectInMs.value = null
|
||||
if (unsubscribeQPS) unsubscribeQPS()
|
||||
unsubscribeQPS = null
|
||||
|
||||
if (options?.resetMetrics) {
|
||||
realTimeQPS.value = 0
|
||||
realTimeTPS.value = 0
|
||||
wsHasData.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startQPSSubscription() {
|
||||
stopQPSSubscription()
|
||||
unsubscribeQPS = opsAPI.subscribeQPS(
|
||||
(payload) => {
|
||||
if (payload && typeof payload === 'object' && payload.type === 'qps_update' && payload.data) {
|
||||
realTimeQPS.value = payload.data.qps || 0
|
||||
realTimeTPS.value = payload.data.tps || 0
|
||||
wsHasData.value = true
|
||||
}
|
||||
},
|
||||
{
|
||||
onStatusChange: (status) => {
|
||||
wsStatus.value = status
|
||||
if (status === 'connected') wsReconnectInMs.value = null
|
||||
},
|
||||
onReconnectScheduled: ({ delayMs }) => {
|
||||
wsReconnectInMs.value = delayMs
|
||||
},
|
||||
onFatalClose: (event) => {
|
||||
// Server-side feature flag says realtime is disabled; keep UI consistent and avoid reconnect loops.
|
||||
if (event && event.code === OPS_WS_CLOSE_CODES.REALTIME_DISABLED) {
|
||||
adminSettingsStore.setOpsRealtimeMonitoringEnabledLocal(false)
|
||||
stopQPSSubscription({ resetMetrics: true })
|
||||
}
|
||||
},
|
||||
// QPS updates may be sparse in idle periods; keep the timeout conservative.
|
||||
staleTimeoutMs: 180_000
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const readQueryString = (key: string): string => {
|
||||
const value = route.query[key]
|
||||
if (typeof value === 'string') return value
|
||||
if (Array.isArray(value) && typeof value[0] === 'string') return value[0]
|
||||
return ''
|
||||
}
|
||||
|
||||
const readQueryNumber = (key: string): number | null => {
|
||||
const raw = readQueryString(key)
|
||||
if (!raw) return null
|
||||
const n = Number.parseInt(raw, 10)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
const applyRouteQueryToState = () => {
|
||||
const nextTimeRange = readQueryString(QUERY_KEYS.timeRange)
|
||||
if (nextTimeRange && allowedTimeRanges.has(nextTimeRange as TimeRange)) {
|
||||
timeRange.value = nextTimeRange as TimeRange
|
||||
}
|
||||
|
||||
platform.value = readQueryString(QUERY_KEYS.platform) || ''
|
||||
|
||||
const groupIdRaw = readQueryNumber(QUERY_KEYS.groupId)
|
||||
groupId.value = typeof groupIdRaw === 'number' && groupIdRaw > 0 ? groupIdRaw : null
|
||||
|
||||
const nextMode = readQueryString(QUERY_KEYS.queryMode)
|
||||
if (nextMode && allowedQueryModes.has(nextMode as QueryMode)) {
|
||||
queryMode.value = nextMode as QueryMode
|
||||
} else {
|
||||
const fallback = adminSettingsStore.opsQueryModeDefault || 'auto'
|
||||
queryMode.value = allowedQueryModes.has(fallback as QueryMode) ? (fallback as QueryMode) : 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
applyRouteQueryToState()
|
||||
|
||||
const buildQueryFromState = () => {
|
||||
const next: Record<string, any> = { ...route.query }
|
||||
|
||||
Object.values(QUERY_KEYS).forEach((k) => {
|
||||
delete next[k]
|
||||
})
|
||||
|
||||
if (timeRange.value !== '1h') next[QUERY_KEYS.timeRange] = timeRange.value
|
||||
if (platform.value) next[QUERY_KEYS.platform] = platform.value
|
||||
if (typeof groupId.value === 'number' && groupId.value > 0) next[QUERY_KEYS.groupId] = String(groupId.value)
|
||||
if (queryMode.value !== 'auto') next[QUERY_KEYS.queryMode] = queryMode.value
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
const syncQueryToRoute = useDebounceFn(async () => {
|
||||
if (isApplyingRouteQuery.value) return
|
||||
const nextQuery = buildQueryFromState()
|
||||
|
||||
const curr = route.query as Record<string, any>
|
||||
const nextKeys = Object.keys(nextQuery)
|
||||
const currKeys = Object.keys(curr)
|
||||
const sameLength = nextKeys.length === currKeys.length
|
||||
const sameValues = sameLength && nextKeys.every((k) => String(curr[k] ?? '') === String(nextQuery[k] ?? ''))
|
||||
if (sameValues) return
|
||||
|
||||
try {
|
||||
isSyncingRouteQuery.value = true
|
||||
await router.replace({ query: nextQuery })
|
||||
} finally {
|
||||
isSyncingRouteQuery.value = false
|
||||
}
|
||||
}, 250)
|
||||
|
||||
const overview = ref<OpsDashboardOverview | null>(null)
|
||||
const loadingOverview = ref(false)
|
||||
|
||||
const throughputTrend = ref<OpsThroughputTrendResponse | null>(null)
|
||||
const loadingTrend = ref(false)
|
||||
|
||||
const latencyHistogram = ref<OpsLatencyHistogramResponse | null>(null)
|
||||
const loadingLatency = ref(false)
|
||||
|
||||
const errorTrend = ref<OpsErrorTrendResponse | null>(null)
|
||||
const loadingErrorTrend = ref(false)
|
||||
|
||||
const errorDistribution = ref<OpsErrorDistributionResponse | null>(null)
|
||||
const loadingErrorDistribution = ref(false)
|
||||
|
||||
const selectedErrorId = ref<number | null>(null)
|
||||
const showErrorModal = ref(false)
|
||||
|
||||
const showErrorDetails = ref(false)
|
||||
const errorDetailsType = ref<'request' | 'upstream'>('request')
|
||||
|
||||
const showRequestDetails = ref(false)
|
||||
const requestDetailsPreset = ref<OpsRequestDetailsPreset>({
|
||||
title: '',
|
||||
kind: 'all',
|
||||
sort: 'created_at_desc'
|
||||
})
|
||||
|
||||
function handleThroughputSelectPlatform(nextPlatform: string) {
|
||||
platform.value = nextPlatform || ''
|
||||
groupId.value = null
|
||||
}
|
||||
|
||||
function handleThroughputSelectGroup(nextGroupId: number) {
|
||||
const id = Number.isFinite(nextGroupId) && nextGroupId > 0 ? nextGroupId : null
|
||||
groupId.value = id
|
||||
}
|
||||
|
||||
function handleOpenRequestDetails() {
|
||||
requestDetailsPreset.value = {
|
||||
title: t('admin.ops.requestDetails.title'),
|
||||
kind: 'all',
|
||||
sort: 'created_at_desc'
|
||||
}
|
||||
showRequestDetails.value = true
|
||||
}
|
||||
|
||||
function openErrorDetails(kind: 'request' | 'upstream') {
|
||||
errorDetailsType.value = kind
|
||||
showErrorDetails.value = true
|
||||
}
|
||||
|
||||
function onTimeRangeChange(v: string | number | boolean | null) {
|
||||
if (typeof v !== 'string') return
|
||||
if (!allowedTimeRanges.has(v as TimeRange)) return
|
||||
timeRange.value = v as TimeRange
|
||||
}
|
||||
|
||||
function onPlatformChange(v: string | number | boolean | null) {
|
||||
platform.value = typeof v === 'string' ? v : ''
|
||||
}
|
||||
|
||||
function onGroupChange(v: string | number | boolean | null) {
|
||||
if (v === null) {
|
||||
groupId.value = null
|
||||
return
|
||||
}
|
||||
if (typeof v === 'number') {
|
||||
groupId.value = v > 0 ? v : null
|
||||
return
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
const n = Number.parseInt(v, 10)
|
||||
groupId.value = Number.isFinite(n) && n > 0 ? n : null
|
||||
}
|
||||
}
|
||||
|
||||
function onQueryModeChange(v: string | number | boolean | null) {
|
||||
if (typeof v !== 'string') return
|
||||
if (!allowedQueryModes.has(v as QueryMode)) return
|
||||
queryMode.value = v as QueryMode
|
||||
}
|
||||
|
||||
function openError(id: number) {
|
||||
selectedErrorId.value = id
|
||||
showErrorModal.value = true
|
||||
}
|
||||
|
||||
function formatInt(v: number | null | undefined): string {
|
||||
if (typeof v !== 'number') return '0'
|
||||
return formatNumberLocaleString(v)
|
||||
}
|
||||
|
||||
function formatPercent(v: number | null | undefined): string {
|
||||
if (typeof v !== 'number') return '-'
|
||||
return `${(v * 100).toFixed(2)}%`
|
||||
}
|
||||
|
||||
function formatPercent0to100(v: number | null | undefined): string {
|
||||
if (typeof v !== 'number') return '-'
|
||||
return `${v.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function formatMBPair(used: number | null | undefined, total: number | null | undefined): string {
|
||||
if (typeof used !== 'number' || typeof total !== 'number') return '-'
|
||||
return `${formatNumberLocaleString(used)} / ${formatNumberLocaleString(total)} MB`
|
||||
}
|
||||
|
||||
function boolOkLabel(v: boolean | null | undefined): string {
|
||||
if (v === true) return 'OK'
|
||||
if (v === false) return 'FAIL'
|
||||
return '-'
|
||||
}
|
||||
|
||||
function boolOkClass(v: boolean | null | undefined): string {
|
||||
if (v === true) return 'text-emerald-600 dark:text-emerald-400'
|
||||
if (v === false) return 'text-rose-600 dark:text-rose-400'
|
||||
return 'text-gray-900 dark:text-white'
|
||||
}
|
||||
|
||||
function formatMs(v: number | null | undefined): string {
|
||||
if (v == null) return '-'
|
||||
return `${v}ms`
|
||||
}
|
||||
|
||||
async function refreshOverviewWithCancel(fetchSeq: number, signal: AbortSignal) {
|
||||
if (!opsEnabled.value) return
|
||||
loadingOverview.value = true
|
||||
try {
|
||||
const data = await opsAPI.getDashboardOverview(
|
||||
{
|
||||
time_range: timeRange.value,
|
||||
platform: platform.value || undefined,
|
||||
group_id: groupId.value ?? undefined,
|
||||
mode: queryMode.value
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
if (fetchSeq !== dashboardFetchSeq) return
|
||||
overview.value = data
|
||||
} catch (err: any) {
|
||||
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
|
||||
overview.value = null
|
||||
appStore.showError(err?.message || 'Failed to load overview')
|
||||
} finally {
|
||||
if (fetchSeq === dashboardFetchSeq) {
|
||||
loadingOverview.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshThroughputTrendWithCancel(fetchSeq: number, signal: AbortSignal) {
|
||||
if (!opsEnabled.value) return
|
||||
loadingTrend.value = true
|
||||
try {
|
||||
const data = await opsAPI.getThroughputTrend(
|
||||
{
|
||||
time_range: timeRange.value,
|
||||
platform: platform.value || undefined,
|
||||
group_id: groupId.value ?? undefined,
|
||||
mode: queryMode.value
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
if (fetchSeq !== dashboardFetchSeq) return
|
||||
throughputTrend.value = data
|
||||
} catch (err: any) {
|
||||
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
|
||||
throughputTrend.value = null
|
||||
appStore.showError(err?.message || 'Failed to load throughput trend')
|
||||
} finally {
|
||||
if (fetchSeq === dashboardFetchSeq) {
|
||||
loadingTrend.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshLatencyHistogramWithCancel(fetchSeq: number, signal: AbortSignal) {
|
||||
if (!opsEnabled.value) return
|
||||
loadingLatency.value = true
|
||||
try {
|
||||
const data = await opsAPI.getLatencyHistogram(
|
||||
{
|
||||
time_range: timeRange.value,
|
||||
platform: platform.value || undefined,
|
||||
group_id: groupId.value ?? undefined,
|
||||
mode: queryMode.value
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
if (fetchSeq !== dashboardFetchSeq) return
|
||||
latencyHistogram.value = data
|
||||
} catch (err: any) {
|
||||
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
|
||||
latencyHistogram.value = null
|
||||
appStore.showError(err?.message || 'Failed to load latency histogram')
|
||||
} finally {
|
||||
if (fetchSeq === dashboardFetchSeq) {
|
||||
loadingLatency.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshErrorTrendWithCancel(fetchSeq: number, signal: AbortSignal) {
|
||||
if (!opsEnabled.value) return
|
||||
loadingErrorTrend.value = true
|
||||
try {
|
||||
const data = await opsAPI.getErrorTrend(
|
||||
{
|
||||
time_range: timeRange.value,
|
||||
platform: platform.value || undefined,
|
||||
group_id: groupId.value ?? undefined,
|
||||
mode: queryMode.value
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
if (fetchSeq !== dashboardFetchSeq) return
|
||||
errorTrend.value = data
|
||||
} catch (err: any) {
|
||||
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
|
||||
errorTrend.value = null
|
||||
appStore.showError(err?.message || 'Failed to load error trend')
|
||||
} finally {
|
||||
if (fetchSeq === dashboardFetchSeq) {
|
||||
loadingErrorTrend.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshErrorDistributionWithCancel(fetchSeq: number, signal: AbortSignal) {
|
||||
if (!opsEnabled.value) return
|
||||
loadingErrorDistribution.value = true
|
||||
try {
|
||||
const data = await opsAPI.getErrorDistribution(
|
||||
{
|
||||
time_range: timeRange.value,
|
||||
platform: platform.value || undefined,
|
||||
group_id: groupId.value ?? undefined,
|
||||
mode: queryMode.value
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
if (fetchSeq !== dashboardFetchSeq) return
|
||||
errorDistribution.value = data
|
||||
} catch (err: any) {
|
||||
if (fetchSeq !== dashboardFetchSeq || isCanceledRequest(err)) return
|
||||
errorDistribution.value = null
|
||||
appStore.showError(err?.message || 'Failed to load error distribution')
|
||||
} finally {
|
||||
if (fetchSeq === dashboardFetchSeq) {
|
||||
loadingErrorDistribution.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isOpsDisabledError(err: unknown): boolean {
|
||||
return (
|
||||
!!err &&
|
||||
typeof err === 'object' &&
|
||||
'code' in err &&
|
||||
typeof (err as Record<string, unknown>).code === 'string' &&
|
||||
(err as Record<string, unknown>).code === 'OPS_DISABLED'
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchData() {
|
||||
if (!opsEnabled.value) return
|
||||
|
||||
abortDashboardFetch()
|
||||
dashboardFetchSeq += 1
|
||||
const fetchSeq = dashboardFetchSeq
|
||||
dashboardFetchController = new AbortController()
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await Promise.all([
|
||||
refreshOverviewWithCancel(fetchSeq, dashboardFetchController.signal),
|
||||
refreshThroughputTrendWithCancel(fetchSeq, dashboardFetchController.signal),
|
||||
refreshLatencyHistogramWithCancel(fetchSeq, dashboardFetchController.signal),
|
||||
refreshErrorTrendWithCancel(fetchSeq, dashboardFetchController.signal),
|
||||
refreshErrorDistributionWithCancel(fetchSeq, dashboardFetchController.signal)
|
||||
])
|
||||
if (fetchSeq !== dashboardFetchSeq) return
|
||||
lastUpdated.value = new Date()
|
||||
} catch (err) {
|
||||
if (!isOpsDisabledError(err)) {
|
||||
console.error('[ops] failed to fetch dashboard data', err)
|
||||
errorMessage.value = t('admin.ops.failedToLoadData')
|
||||
}
|
||||
} finally {
|
||||
if (fetchSeq === dashboardFetchSeq) {
|
||||
loading.value = false
|
||||
hasLoadedOnce.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [timeRange.value, platform.value, groupId.value, queryMode.value] as const,
|
||||
() => {
|
||||
if (isApplyingRouteQuery.value) return
|
||||
if (opsEnabled.value) {
|
||||
fetchData()
|
||||
}
|
||||
syncQueryToRoute()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query,
|
||||
() => {
|
||||
if (isSyncingRouteQuery.value) return
|
||||
|
||||
const prevTimeRange = timeRange.value
|
||||
const prevPlatform = platform.value
|
||||
const prevGroupId = groupId.value
|
||||
|
||||
isApplyingRouteQuery.value = true
|
||||
applyRouteQueryToState()
|
||||
isApplyingRouteQuery.value = false
|
||||
|
||||
const changed =
|
||||
prevTimeRange !== timeRange.value || prevPlatform !== platform.value || prevGroupId !== groupId.value
|
||||
if (changed) {
|
||||
if (opsEnabled.value) {
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await adminSettingsStore.fetch()
|
||||
if (!adminSettingsStore.opsMonitoringEnabled) {
|
||||
await router.replace('/admin/settings')
|
||||
return
|
||||
}
|
||||
|
||||
if (adminSettingsStore.opsRealtimeMonitoringEnabled) {
|
||||
startQPSSubscription()
|
||||
} else {
|
||||
stopQPSSubscription({ resetMetrics: true })
|
||||
}
|
||||
|
||||
if (opsEnabled.value) {
|
||||
await fetchData()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopQPSSubscription()
|
||||
abortDashboardFetch()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => adminSettingsStore.opsRealtimeMonitoringEnabled,
|
||||
(enabled) => {
|
||||
if (!opsEnabled.value) return
|
||||
if (enabled) {
|
||||
startQPSSubscription()
|
||||
} else {
|
||||
stopQPSSubscription({ resetMetrics: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
165
frontend/src/views/admin/ops/components/OpsAlertEventsCard.vue
Normal file
165
frontend/src/views/admin/ops/components/OpsAlertEventsCard.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import { opsAPI } from '@/api/admin/ops'
|
||||
import type { AlertEvent } from '../types'
|
||||
import { formatDateTime } from '../utils/opsFormatters'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const events = ref<AlertEvent[]>([])
|
||||
|
||||
const limit = ref(100)
|
||||
const limitOptions = computed(() => [
|
||||
{ value: 50, label: '50' },
|
||||
{ value: 100, label: '100' },
|
||||
{ value: 200, label: '200' }
|
||||
])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
events.value = await opsAPI.listAlertEvents(limit.value)
|
||||
} catch (err: any) {
|
||||
console.error('[OpsAlertEventsCard] Failed to load alert events', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertEvents.loadFailed'))
|
||||
events.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
})
|
||||
|
||||
watch(limit, () => {
|
||||
load()
|
||||
})
|
||||
|
||||
function severityBadgeClass(severity: string | undefined): string {
|
||||
const s = String(severity || '').trim().toLowerCase()
|
||||
if (s === 'p0' || s === 'critical') return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
||||
if (s === 'p1' || s === 'warning') return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
|
||||
if (s === 'p2' || s === 'info') return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
if (s === 'p3') return 'bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-gray-300'
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-gray-300'
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: string | undefined): string {
|
||||
const s = String(status || '').trim().toLowerCase()
|
||||
if (s === 'firing') return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-300 dark:ring-red-500/30'
|
||||
if (s === 'resolved') return 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-300 dark:ring-green-500/30'
|
||||
return 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-300 dark:ring-gray-500/30'
|
||||
}
|
||||
|
||||
const empty = computed(() => events.value.length === 0 && !loading.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white">{{ t('admin.ops.alertEvents.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.alertEvents.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Select :model-value="limit" :options="limitOptions" class="w-[88px]" @change="limit = Number($event || 100)" />
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
||||
:disabled="loading"
|
||||
@click="load"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" :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>
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
{{ t('admin.ops.alertEvents.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="empty" class="rounded-xl border border-dashed border-gray-200 p-8 text-center text-sm text-gray-500 dark:border-dark-700 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.empty') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
|
||||
<div class="max-h-[600px] overflow-y-auto overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="bg-gray-50 dark:bg-dark-900">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.time') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.status') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.severity') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.title') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.metric') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertEvents.table.email') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-800">
|
||||
<tr v-for="row in events" :key="row.id" class="hover:bg-gray-50 dark:hover:bg-dark-700/50">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ formatDateTime(row.fired_at || row.created_at) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<span class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold ring-1 ring-inset" :class="statusBadgeClass(row.status)">
|
||||
{{ String(row.status || '-').toUpperCase() }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<span class="rounded-full px-2 py-1 text-[10px] font-bold" :class="severityBadgeClass(String(row.severity || ''))">
|
||||
{{ row.severity || '-' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="min-w-[280px] px-4 py-3 text-xs text-gray-700 dark:text-gray-200">
|
||||
<div class="font-semibold">{{ row.title || '-' }}</div>
|
||||
<div v-if="row.description" class="mt-0.5 line-clamp-2 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{{ row.description }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span v-if="typeof row.metric_value === 'number' && typeof row.threshold_value === 'number'">
|
||||
{{ row.metric_value.toFixed(2) }} / {{ row.threshold_value.toFixed(2) }}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-xs">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2 py-1 text-[10px] font-bold ring-1 ring-inset"
|
||||
:class="row.email_sent ? 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-300 dark:ring-green-500/30' : 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-300 dark:ring-gray-500/30'"
|
||||
>
|
||||
{{ row.email_sent ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
357
frontend/src/views/admin/ops/components/OpsAlertRulesCard.vue
Normal file
357
frontend/src/views/admin/ops/components/OpsAlertRulesCard.vue
Normal file
@@ -0,0 +1,357 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import { opsAPI } from '@/api/admin/ops'
|
||||
import type { AlertRule, MetricType, Operator } from '../types'
|
||||
import type { OpsSeverity } from '@/api/admin/ops'
|
||||
import { formatDateTime } from '../utils/opsFormatters'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const rules = ref<AlertRule[]>([])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
rules.value = await opsAPI.listAlertRules()
|
||||
} catch (err: any) {
|
||||
console.error('[OpsAlertRulesCard] Failed to load rules', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertRules.loadFailed'))
|
||||
rules.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
})
|
||||
|
||||
const sortedRules = computed(() => {
|
||||
return [...rules.value].sort((a, b) => (b.id || 0) - (a.id || 0))
|
||||
})
|
||||
|
||||
const showEditor = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const draft = ref<AlertRule | null>(null)
|
||||
|
||||
const metricOptions = computed(() => {
|
||||
const items: Array<{ value: MetricType; label: string }> = [
|
||||
{ value: 'success_rate', label: t('admin.ops.alertRules.metrics.successRate') },
|
||||
{ value: 'error_rate', label: t('admin.ops.alertRules.metrics.errorRate') },
|
||||
{ value: 'p95_latency_ms', label: t('admin.ops.alertRules.metrics.p95') },
|
||||
{ value: 'p99_latency_ms', label: t('admin.ops.alertRules.metrics.p99') },
|
||||
{ value: 'cpu_usage_percent', label: t('admin.ops.alertRules.metrics.cpu') },
|
||||
{ value: 'memory_usage_percent', label: t('admin.ops.alertRules.metrics.memory') },
|
||||
{ value: 'concurrency_queue_depth', label: t('admin.ops.alertRules.metrics.queueDepth') }
|
||||
]
|
||||
return items
|
||||
})
|
||||
|
||||
const operatorOptions = computed(() => {
|
||||
const ops: Operator[] = ['>', '>=', '<', '<=', '==', '!=']
|
||||
return ops.map((o) => ({ value: o, label: o }))
|
||||
})
|
||||
|
||||
const severityOptions = computed(() => {
|
||||
const sev: OpsSeverity[] = ['P0', 'P1', 'P2', 'P3']
|
||||
return sev.map((s) => ({ value: s, label: s }))
|
||||
})
|
||||
|
||||
const windowOptions = computed(() => {
|
||||
const windows = [1, 5, 60]
|
||||
return windows.map((m) => ({ value: m, label: `${m}m` }))
|
||||
})
|
||||
|
||||
function newRuleDraft(): AlertRule {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: true,
|
||||
metric_type: 'error_rate',
|
||||
operator: '>',
|
||||
threshold: 1,
|
||||
window_minutes: 1,
|
||||
sustained_minutes: 2,
|
||||
severity: 'P1',
|
||||
cooldown_minutes: 10,
|
||||
notify_email: true
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = null
|
||||
draft.value = newRuleDraft()
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
function openEdit(rule: AlertRule) {
|
||||
editingId.value = rule.id ?? null
|
||||
draft.value = JSON.parse(JSON.stringify(rule))
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
const editorValidation = computed(() => {
|
||||
const errors: string[] = []
|
||||
const r = draft.value
|
||||
if (!r) return { valid: true, errors }
|
||||
if (!r.name || !r.name.trim()) errors.push(t('admin.ops.alertRules.validation.nameRequired'))
|
||||
if (!r.metric_type) errors.push(t('admin.ops.alertRules.validation.metricRequired'))
|
||||
if (!r.operator) errors.push(t('admin.ops.alertRules.validation.operatorRequired'))
|
||||
if (!(typeof r.threshold === 'number' && Number.isFinite(r.threshold)))
|
||||
errors.push(t('admin.ops.alertRules.validation.thresholdRequired'))
|
||||
if (!(typeof r.window_minutes === 'number' && Number.isFinite(r.window_minutes) && [1, 5, 60].includes(r.window_minutes))) {
|
||||
errors.push(t('admin.ops.alertRules.validation.windowRange'))
|
||||
}
|
||||
if (!(typeof r.sustained_minutes === 'number' && Number.isFinite(r.sustained_minutes) && r.sustained_minutes >= 1 && r.sustained_minutes <= 1440)) {
|
||||
errors.push(t('admin.ops.alertRules.validation.sustainedRange'))
|
||||
}
|
||||
if (!(typeof r.cooldown_minutes === 'number' && Number.isFinite(r.cooldown_minutes) && r.cooldown_minutes >= 0 && r.cooldown_minutes <= 1440)) {
|
||||
errors.push(t('admin.ops.alertRules.validation.cooldownRange'))
|
||||
}
|
||||
return { valid: errors.length === 0, errors }
|
||||
})
|
||||
|
||||
async function save() {
|
||||
if (!draft.value) return
|
||||
if (!editorValidation.value.valid) {
|
||||
appStore.showError(editorValidation.value.errors[0] || t('admin.ops.alertRules.validation.invalid'))
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await opsAPI.updateAlertRule(editingId.value, draft.value)
|
||||
} else {
|
||||
await opsAPI.createAlertRule(draft.value)
|
||||
}
|
||||
showEditor.value = false
|
||||
draft.value = null
|
||||
editingId.value = null
|
||||
await load()
|
||||
appStore.showSuccess(t('common.success'))
|
||||
} catch (err: any) {
|
||||
console.error('[OpsAlertRulesCard] Failed to save rule', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertRules.saveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showDeleteConfirm = ref(false)
|
||||
const pendingDelete = ref<AlertRule | null>(null)
|
||||
|
||||
function requestDelete(rule: AlertRule) {
|
||||
pendingDelete.value = rule
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!pendingDelete.value?.id) return
|
||||
try {
|
||||
await opsAPI.deleteAlertRule(pendingDelete.value.id)
|
||||
showDeleteConfirm.value = false
|
||||
pendingDelete.value = null
|
||||
await load()
|
||||
appStore.showSuccess(t('common.success'))
|
||||
} catch (err: any) {
|
||||
console.error('[OpsAlertRulesCard] Failed to delete rule', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.alertRules.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDelete() {
|
||||
showDeleteConfirm.value = false
|
||||
pendingDelete.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white">{{ t('admin.ops.alertRules.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.alertRules.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-sm btn-primary" :disabled="loading" @click="openCreate">
|
||||
{{ t('admin.ops.alertRules.create') }}
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
||||
:disabled="loading"
|
||||
@click="load"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" :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>
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="py-10 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertRules.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="sortedRules.length === 0" class="rounded-xl border border-dashed border-gray-200 p-8 text-center text-sm text-gray-500 dark:border-dark-700 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertRules.empty') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="bg-gray-50 dark:bg-dark-900">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertRules.table.name') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertRules.table.metric') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertRules.table.severity') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertRules.table.enabled') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.alertRules.table.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-800">
|
||||
<tr v-for="row in sortedRules" :key="row.id" class="hover:bg-gray-50 dark:hover:bg-dark-700/50">
|
||||
<td class="px-4 py-3">
|
||||
<div class="text-xs font-bold text-gray-900 dark:text-white">{{ row.name }}</div>
|
||||
<div v-if="row.description" class="mt-0.5 line-clamp-2 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{{ row.description }}
|
||||
</div>
|
||||
<div v-if="row.updated_at" class="mt-1 text-[10px] text-gray-400">
|
||||
{{ formatDateTime(row.updated_at) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-700 dark:text-gray-200">
|
||||
<span class="font-mono">{{ row.metric_type }}</span>
|
||||
<span class="mx-1 text-gray-400">{{ row.operator }}</span>
|
||||
<span class="font-mono">{{ row.threshold }}</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs font-bold text-gray-700 dark:text-gray-200">
|
||||
{{ row.severity }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-700 dark:text-gray-200">
|
||||
{{ row.enabled ? t('common.enabled') : t('common.disabled') }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-xs">
|
||||
<button class="btn btn-sm btn-secondary" @click="openEdit(row)">{{ t('common.edit') }}</button>
|
||||
<button class="ml-2 btn btn-sm btn-danger" @click="requestDelete(row)">{{ t('common.delete') }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseDialog
|
||||
:show="showEditor"
|
||||
:title="editingId ? t('admin.ops.alertRules.editTitle') : t('admin.ops.alertRules.createTitle')"
|
||||
width="wide"
|
||||
@close="showEditor = false"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div v-if="!editorValidation.valid" class="rounded-xl bg-red-50 p-4 text-xs text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
<div class="font-bold">{{ t('admin.ops.alertRules.validation.title') }}</div>
|
||||
<ul class="mt-1 list-disc pl-5">
|
||||
<li v-for="e in editorValidation.errors" :key="e">{{ e }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="md:col-span-2">
|
||||
<label class="input-label">{{ t('admin.ops.alertRules.form.name') }}</label>
|
||||
<input v-model="draft!.name" class="input" type="text" />
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="input-label">{{ t('admin.ops.alertRules.form.description') }}</label>
|
||||
<input v-model="draft!.description" class="input" type="text" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.alertRules.form.metric') }}</label>
|
||||
<Select v-model="draft!.metric_type" :options="metricOptions" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.alertRules.form.operator') }}</label>
|
||||
<Select v-model="draft!.operator" :options="operatorOptions" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.alertRules.form.threshold') }}</label>
|
||||
<input v-model.number="draft!.threshold" class="input" type="number" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.alertRules.form.severity') }}</label>
|
||||
<Select v-model="draft!.severity" :options="severityOptions" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.alertRules.form.window') }}</label>
|
||||
<Select v-model="draft!.window_minutes" :options="windowOptions" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.alertRules.form.sustained') }}</label>
|
||||
<input v-model.number="draft!.sustained_minutes" class="input" type="number" min="1" max="1440" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.alertRules.form.cooldown') }}</label>
|
||||
<input v-model.number="draft!.cooldown_minutes" class="input" type="number" min="0" max="1440" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-xl bg-gray-50 px-4 py-3 dark:bg-dark-800/50 md:col-span-2">
|
||||
<span class="text-xs font-bold text-gray-700 dark:text-gray-200">{{ t('admin.ops.alertRules.form.enabled') }}</span>
|
||||
<input v-model="draft!.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-xl bg-gray-50 px-4 py-3 dark:bg-dark-800/50 md:col-span-2">
|
||||
<span class="text-xs font-bold text-gray-700 dark:text-gray-200">{{ t('admin.ops.alertRules.form.notifyEmail') }}</span>
|
||||
<input v-model="draft!.notify_email" type="checkbox" class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<button class="btn btn-secondary" :disabled="saving" @click="showEditor = false">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button class="btn btn-primary" :disabled="saving" @click="save">
|
||||
{{ saving ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
:show="showDeleteConfirm"
|
||||
:title="t('admin.ops.alertRules.deleteConfirmTitle')"
|
||||
:message="t('admin.ops.alertRules.deleteConfirmMessage')"
|
||||
:confirmText="t('common.delete')"
|
||||
:cancelText="t('common.cancel')"
|
||||
@confirm="confirmDelete"
|
||||
@cancel="cancelDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
525
frontend/src/views/admin/ops/components/OpsConcurrencyCard.vue
Normal file
525
frontend/src/views/admin/ops/components/OpsConcurrencyCard.vue
Normal file
@@ -0,0 +1,525 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { opsAPI, type OpsAccountAvailabilityStatsResponse, type OpsConcurrencyStatsResponse } from '@/api/admin/ops'
|
||||
|
||||
interface Props {
|
||||
platformFilter?: string
|
||||
groupIdFilter?: number | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
platformFilter: '',
|
||||
groupIdFilter: null
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const concurrency = ref<OpsConcurrencyStatsResponse | null>(null)
|
||||
const availability = ref<OpsAccountAvailabilityStatsResponse | null>(null)
|
||||
|
||||
const realtimeEnabled = computed(() => {
|
||||
return (concurrency.value?.enabled ?? true) && (availability.value?.enabled ?? true)
|
||||
})
|
||||
|
||||
function safeNumber(n: unknown): number {
|
||||
return typeof n === 'number' && Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
// 计算显示维度
|
||||
const displayDimension = computed<'platform' | 'group' | 'account'>(() => {
|
||||
if (typeof props.groupIdFilter === 'number' && props.groupIdFilter > 0) {
|
||||
return 'account'
|
||||
}
|
||||
if (props.platformFilter) {
|
||||
return 'group'
|
||||
}
|
||||
return 'platform'
|
||||
})
|
||||
|
||||
// 平台/分组汇总行数据
|
||||
interface SummaryRow {
|
||||
key: string
|
||||
name: string
|
||||
platform?: string
|
||||
// 账号统计
|
||||
total_accounts: number
|
||||
available_accounts: number
|
||||
rate_limited_accounts: number
|
||||
error_accounts: number
|
||||
// 并发统计
|
||||
total_concurrency: number
|
||||
used_concurrency: number
|
||||
waiting_in_queue: number
|
||||
// 计算字段
|
||||
availability_percentage: number
|
||||
concurrency_percentage: number
|
||||
}
|
||||
|
||||
// 账号详细行数据
|
||||
interface AccountRow {
|
||||
key: string
|
||||
name: string
|
||||
platform: string
|
||||
group_name: string
|
||||
// 并发
|
||||
current_in_use: number
|
||||
max_capacity: number
|
||||
waiting_in_queue: number
|
||||
load_percentage: number
|
||||
// 状态
|
||||
is_available: boolean
|
||||
is_rate_limited: boolean
|
||||
rate_limit_remaining_sec?: number
|
||||
is_overloaded: boolean
|
||||
overload_remaining_sec?: number
|
||||
has_error: boolean
|
||||
error_message?: string
|
||||
}
|
||||
|
||||
// 平台维度汇总
|
||||
const platformRows = computed((): SummaryRow[] => {
|
||||
const concStats = concurrency.value?.platform || {}
|
||||
const availStats = availability.value?.platform || {}
|
||||
|
||||
const platforms = new Set([...Object.keys(concStats), ...Object.keys(availStats)])
|
||||
|
||||
return Array.from(platforms).map(platform => {
|
||||
const conc = concStats[platform] || {}
|
||||
const avail = availStats[platform] || {}
|
||||
|
||||
const totalAccounts = safeNumber(avail.total_accounts)
|
||||
const availableAccounts = safeNumber(avail.available_count)
|
||||
const totalConcurrency = safeNumber(conc.max_capacity)
|
||||
const usedConcurrency = safeNumber(conc.current_in_use)
|
||||
|
||||
return {
|
||||
key: platform,
|
||||
name: platform.toUpperCase(),
|
||||
total_accounts: totalAccounts,
|
||||
available_accounts: availableAccounts,
|
||||
rate_limited_accounts: safeNumber(avail.rate_limit_count),
|
||||
error_accounts: safeNumber(avail.error_count),
|
||||
total_concurrency: totalConcurrency,
|
||||
used_concurrency: usedConcurrency,
|
||||
waiting_in_queue: safeNumber(conc.waiting_in_queue),
|
||||
availability_percentage: totalAccounts > 0 ? Math.round((availableAccounts / totalAccounts) * 100) : 0,
|
||||
concurrency_percentage: totalConcurrency > 0 ? Math.round((usedConcurrency / totalConcurrency) * 100) : 0
|
||||
}
|
||||
}).sort((a, b) => b.concurrency_percentage - a.concurrency_percentage)
|
||||
})
|
||||
|
||||
// 分组维度汇总
|
||||
const groupRows = computed((): SummaryRow[] => {
|
||||
const concStats = concurrency.value?.group || {}
|
||||
const availStats = availability.value?.group || {}
|
||||
|
||||
const groupIds = new Set([...Object.keys(concStats), ...Object.keys(availStats)])
|
||||
|
||||
const rows = Array.from(groupIds)
|
||||
.map(gid => {
|
||||
const conc = concStats[gid] || {}
|
||||
const avail = availStats[gid] || {}
|
||||
|
||||
// 只显示匹配的平台
|
||||
if (props.platformFilter && conc.platform !== props.platformFilter && avail.platform !== props.platformFilter) {
|
||||
return null
|
||||
}
|
||||
|
||||
const totalAccounts = safeNumber(avail.total_accounts)
|
||||
const availableAccounts = safeNumber(avail.available_count)
|
||||
const totalConcurrency = safeNumber(conc.max_capacity)
|
||||
const usedConcurrency = safeNumber(conc.current_in_use)
|
||||
|
||||
return {
|
||||
key: gid,
|
||||
name: String(conc.group_name || avail.group_name || `Group ${gid}`),
|
||||
platform: String(conc.platform || avail.platform || ''),
|
||||
total_accounts: totalAccounts,
|
||||
available_accounts: availableAccounts,
|
||||
rate_limited_accounts: safeNumber(avail.rate_limit_count),
|
||||
error_accounts: safeNumber(avail.error_count),
|
||||
total_concurrency: totalConcurrency,
|
||||
used_concurrency: usedConcurrency,
|
||||
waiting_in_queue: safeNumber(conc.waiting_in_queue),
|
||||
availability_percentage: totalAccounts > 0 ? Math.round((availableAccounts / totalAccounts) * 100) : 0,
|
||||
concurrency_percentage: totalConcurrency > 0 ? Math.round((usedConcurrency / totalConcurrency) * 100) : 0
|
||||
}
|
||||
})
|
||||
.filter((row): row is NonNullable<typeof row> => row !== null)
|
||||
|
||||
return rows.sort((a, b) => b.concurrency_percentage - a.concurrency_percentage)
|
||||
})
|
||||
|
||||
// 账号维度详细
|
||||
const accountRows = computed((): AccountRow[] => {
|
||||
const concStats = concurrency.value?.account || {}
|
||||
const availStats = availability.value?.account || {}
|
||||
|
||||
const accountIds = new Set([...Object.keys(concStats), ...Object.keys(availStats)])
|
||||
|
||||
const rows = Array.from(accountIds)
|
||||
.map(aid => {
|
||||
const conc = concStats[aid] || {}
|
||||
const avail = availStats[aid] || {}
|
||||
|
||||
// 只显示匹配的分组
|
||||
if (typeof props.groupIdFilter === 'number' && props.groupIdFilter > 0) {
|
||||
if (conc.group_id !== props.groupIdFilter && avail.group_id !== props.groupIdFilter) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: aid,
|
||||
name: String(conc.account_name || avail.account_name || `Account ${aid}`),
|
||||
platform: String(conc.platform || avail.platform || ''),
|
||||
group_name: String(conc.group_name || avail.group_name || ''),
|
||||
current_in_use: safeNumber(conc.current_in_use),
|
||||
max_capacity: safeNumber(conc.max_capacity),
|
||||
waiting_in_queue: safeNumber(conc.waiting_in_queue),
|
||||
load_percentage: safeNumber(conc.load_percentage),
|
||||
is_available: avail.is_available || false,
|
||||
is_rate_limited: avail.is_rate_limited || false,
|
||||
rate_limit_remaining_sec: avail.rate_limit_remaining_sec,
|
||||
is_overloaded: avail.is_overloaded || false,
|
||||
overload_remaining_sec: avail.overload_remaining_sec,
|
||||
has_error: avail.has_error || false,
|
||||
error_message: avail.error_message || ''
|
||||
}
|
||||
})
|
||||
.filter((row): row is NonNullable<typeof row> => row !== null)
|
||||
|
||||
return rows.sort((a, b) => {
|
||||
// 优先显示异常账号
|
||||
if (a.has_error !== b.has_error) return a.has_error ? -1 : 1
|
||||
if (a.is_rate_limited !== b.is_rate_limited) return a.is_rate_limited ? -1 : 1
|
||||
// 然后按负载排序
|
||||
return b.load_percentage - a.load_percentage
|
||||
})
|
||||
})
|
||||
|
||||
// 根据维度选择数据
|
||||
const displayRows = computed(() => {
|
||||
if (displayDimension.value === 'account') return accountRows.value
|
||||
if (displayDimension.value === 'group') return groupRows.value
|
||||
return platformRows.value
|
||||
})
|
||||
|
||||
const displayTitle = computed(() => {
|
||||
if (displayDimension.value === 'account') return t('admin.ops.concurrency.byAccount')
|
||||
if (displayDimension.value === 'group') return t('admin.ops.concurrency.byGroup')
|
||||
return t('admin.ops.concurrency.byPlatform')
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const [concData, availData] = await Promise.all([
|
||||
opsAPI.getConcurrencyStats(props.platformFilter, props.groupIdFilter),
|
||||
opsAPI.getAccountAvailabilityStats(props.platformFilter, props.groupIdFilter)
|
||||
])
|
||||
concurrency.value = concData
|
||||
availability.value = availData
|
||||
} catch (err: any) {
|
||||
console.error('[OpsConcurrencyCard] Failed to load data', err)
|
||||
errorMessage.value = err?.response?.data?.detail || t('admin.ops.concurrency.loadFailed')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 定期刷新(5秒)
|
||||
const { pause: pauseRefresh, resume: resumeRefresh } = useIntervalFn(
|
||||
() => {
|
||||
if (realtimeEnabled.value) {
|
||||
loadData()
|
||||
}
|
||||
},
|
||||
5000,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
function getLoadBarClass(loadPct: number): string {
|
||||
if (loadPct >= 90) return 'bg-red-500 dark:bg-red-600'
|
||||
if (loadPct >= 70) return 'bg-orange-500 dark:bg-orange-600'
|
||||
if (loadPct >= 50) return 'bg-yellow-500 dark:bg-yellow-600'
|
||||
return 'bg-green-500 dark:bg-green-600'
|
||||
}
|
||||
|
||||
function getLoadBarStyle(loadPct: number): string {
|
||||
return `width: ${Math.min(100, Math.max(0, loadPct))}%`
|
||||
}
|
||||
|
||||
function getLoadTextClass(loadPct: number): string {
|
||||
if (loadPct >= 90) return 'text-red-600 dark:text-red-400'
|
||||
if (loadPct >= 70) return 'text-orange-600 dark:text-orange-400'
|
||||
if (loadPct >= 50) return 'text-yellow-600 dark:text-yellow-400'
|
||||
return 'text-green-600 dark:text-green-400'
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds <= 0) return '0s'
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
return `${hours}h`
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
resumeRefresh()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
pauseRefresh()
|
||||
})
|
||||
|
||||
watch(realtimeEnabled, async (enabled) => {
|
||||
if (!enabled) {
|
||||
pauseRefresh()
|
||||
} else {
|
||||
resumeRefresh()
|
||||
await loadData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<!-- 头部 -->
|
||||
<div class="mb-4 flex shrink-0 items-center justify-between gap-3">
|
||||
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
|
||||
<svg class="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
{{ t('admin.ops.concurrency.title') }}
|
||||
</h3>
|
||||
<button
|
||||
class="flex items-center gap-1 rounded-lg bg-gray-100 px-2 py-1 text-[11px] font-semibold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
||||
:disabled="loading"
|
||||
:title="t('common.refresh')"
|
||||
@click="loadData"
|
||||
>
|
||||
<svg class="h-3 w-3" :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>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div v-if="errorMessage" class="mb-3 shrink-0 rounded-xl bg-red-50 p-2.5 text-xs text-red-600 dark:bg-red-900/20 dark:text-red-400">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- 禁用状态 -->
|
||||
<div
|
||||
v-if="!realtimeEnabled"
|
||||
class="flex flex-1 items-center justify-center rounded-xl border border-dashed border-gray-200 text-sm text-gray-500 dark:border-dark-700 dark:text-gray-400"
|
||||
>
|
||||
{{ t('admin.ops.concurrency.disabledHint') }}
|
||||
</div>
|
||||
|
||||
<!-- 数据展示区域 -->
|
||||
<div v-else class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
|
||||
<!-- 维度标题栏 -->
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-gray-200 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900">
|
||||
<span class="text-[10px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ displayTitle }}
|
||||
</span>
|
||||
<span class="text-[10px] text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.concurrency.totalRows', { count: displayRows.length }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="displayRows.length === 0" class="flex flex-1 items-center justify-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.concurrency.empty') }}
|
||||
</div>
|
||||
|
||||
<!-- 汇总视图(平台/分组) -->
|
||||
<div v-else-if="displayDimension !== 'account'" class="custom-scrollbar flex-1 space-y-2 overflow-y-auto p-3">
|
||||
<div v-for="row in (displayRows as SummaryRow[])" :key="row.key" class="rounded-lg bg-gray-50 p-3 dark:bg-dark-900">
|
||||
<!-- 标题行 -->
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="truncate text-[11px] font-bold text-gray-900 dark:text-white" :title="row.name">
|
||||
{{ row.name }}
|
||||
</div>
|
||||
<span v-if="displayDimension === 'group' && row.platform" class="text-[10px] text-gray-400 dark:text-gray-500">
|
||||
{{ row.platform.toUpperCase() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2 text-[10px]">
|
||||
<span class="font-mono font-bold text-gray-900 dark:text-white"> {{ row.used_concurrency }}/{{ row.total_concurrency }} </span>
|
||||
<span :class="['font-bold', getLoadTextClass(row.concurrency_percentage)]"> {{ row.concurrency_percentage }}% </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="mb-2 h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-700">
|
||||
<div
|
||||
class="h-full rounded-full transition-all duration-300"
|
||||
:class="getLoadBarClass(row.concurrency_percentage)"
|
||||
:style="getLoadBarStyle(row.concurrency_percentage)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px]">
|
||||
<!-- 账号统计 -->
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="h-3 w-3 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-gray-600 dark:text-gray-300">
|
||||
<span class="font-bold text-green-600 dark:text-green-400">{{ row.available_accounts }}</span
|
||||
>/{{ row.total_accounts }}
|
||||
</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">{{ row.availability_percentage }}%</span>
|
||||
</div>
|
||||
|
||||
<!-- 限流账号 -->
|
||||
<span
|
||||
v-if="row.rate_limited_accounts > 0"
|
||||
class="rounded-full bg-amber-100 px-1.5 py-0.5 font-semibold text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
>
|
||||
{{ t('admin.ops.concurrency.rateLimited', { count: row.rate_limited_accounts }) }}
|
||||
</span>
|
||||
|
||||
<!-- 异常账号 -->
|
||||
<span
|
||||
v-if="row.error_accounts > 0"
|
||||
class="rounded-full bg-red-100 px-1.5 py-0.5 font-semibold text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
>
|
||||
{{ t('admin.ops.concurrency.errorAccounts', { count: row.error_accounts }) }}
|
||||
</span>
|
||||
|
||||
<!-- 等待队列 -->
|
||||
<span
|
||||
v-if="row.waiting_in_queue > 0"
|
||||
class="rounded-full bg-purple-100 px-1.5 py-0.5 font-semibold text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
|
||||
>
|
||||
{{ t('admin.ops.concurrency.queued', { count: row.waiting_in_queue }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账号详细视图 -->
|
||||
<div v-else class="custom-scrollbar flex-1 space-y-2 overflow-y-auto p-3">
|
||||
<div v-for="row in (displayRows as AccountRow[])" :key="row.key" class="rounded-lg bg-gray-50 p-2.5 dark:bg-dark-900">
|
||||
<!-- 账号名称和并发 -->
|
||||
<div class="mb-1.5 flex items-center justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-[11px] font-bold text-gray-900 dark:text-white" :title="row.name">
|
||||
{{ row.name }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-[9px] text-gray-400 dark:text-gray-500">
|
||||
{{ row.group_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-2">
|
||||
<!-- 并发使用 -->
|
||||
<span class="font-mono text-[11px] font-bold text-gray-900 dark:text-white"> {{ row.current_in_use }}/{{ row.max_capacity }} </span>
|
||||
<!-- 状态徽章 -->
|
||||
<span
|
||||
v-if="row.is_available"
|
||||
class="inline-flex items-center gap-1 rounded bg-green-100 px-1.5 py-0.5 text-[10px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{{ t('admin.ops.accountAvailability.available') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="row.is_rate_limited"
|
||||
class="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
{{ formatDuration(row.rate_limit_remaining_sec || 0) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="row.is_overloaded"
|
||||
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-[10px] font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
{{ formatDuration(row.overload_remaining_sec || 0) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="row.has_error"
|
||||
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-[10px] font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
{{ t('admin.ops.accountAvailability.accountError') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-flex items-center gap-1 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-400"
|
||||
>
|
||||
{{ t('admin.ops.accountAvailability.unavailable') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="h-1.5 w-full overflow-hidden rounded-full bg-gray-200 dark:bg-dark-700">
|
||||
<div class="h-full rounded-full transition-all duration-300" :class="getLoadBarClass(row.load_percentage)" :style="getLoadBarStyle(row.load_percentage)"></div>
|
||||
</div>
|
||||
|
||||
<!-- 等待队列 -->
|
||||
<div v-if="row.waiting_in_queue > 0" class="mt-1.5 flex justify-end">
|
||||
<span class="rounded-full bg-purple-100 px-1.5 py-0.5 text-[10px] font-semibold text-purple-700 dark:bg-purple-900/30 dark:text-purple-400">
|
||||
{{ t('admin.ops.concurrency.queued', { count: row.waiting_in_queue }) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(156, 163, 175, 0.3) transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
}
|
||||
</style>
|
||||
374
frontend/src/views/admin/ops/components/OpsDashboardHeader.vue
Normal file
374
frontend/src/views/admin/ops/components/OpsDashboardHeader.vue
Normal file
@@ -0,0 +1,374 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import { adminAPI } from '@/api'
|
||||
import type { OpsDashboardOverview, OpsWSStatus } from '@/api/admin/ops'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
interface Props {
|
||||
overview?: OpsDashboardOverview | null
|
||||
wsStatus: OpsWSStatus
|
||||
wsReconnectInMs?: number | null
|
||||
wsHasData?: boolean
|
||||
realTimeQps: number
|
||||
realTimeTps: number
|
||||
platform: string
|
||||
groupId: number | null
|
||||
timeRange: string
|
||||
queryMode: string
|
||||
loading: boolean
|
||||
lastUpdated: Date | 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: 'refresh'): void
|
||||
(e: 'openRequestDetails'): void
|
||||
(e: 'openErrorDetails', kind: 'request' | 'upstream'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
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: '5m' },
|
||||
{ value: '30m', label: '30m' },
|
||||
{ value: '1h', label: '1h' },
|
||||
{ value: '6h', label: '6h' },
|
||||
{ value: '24h', label: '24h' }
|
||||
])
|
||||
|
||||
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) {
|
||||
emit('update:timeRange', String(val || '1h'))
|
||||
}
|
||||
|
||||
function handleQueryModeChange(val: string | number | boolean | null) {
|
||||
emit('update:queryMode', String(val || 'auto'))
|
||||
}
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
const totalTokensLabel = computed(() => {
|
||||
const n = props.overview?.token_consumed ?? 0
|
||||
return formatNumber(n)
|
||||
})
|
||||
|
||||
const qpsLabel = computed(() => {
|
||||
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 tpsLabel = computed(() => {
|
||||
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 qpsPeakLabel = computed(() => {
|
||||
const n = props.overview?.qps?.peak
|
||||
if (typeof n !== 'number') return '-'
|
||||
return n.toFixed(1)
|
||||
})
|
||||
|
||||
const tpsPeakLabel = computed(() => {
|
||||
const n = props.overview?.tps?.peak
|
||||
if (typeof n !== 'number') return '-'
|
||||
return n.toFixed(1)
|
||||
})
|
||||
|
||||
const slaLabel = computed(() => {
|
||||
const v = props.overview?.sla
|
||||
if (typeof v !== 'number') return '-'
|
||||
return `${(v * 100).toFixed(3)}%`
|
||||
})
|
||||
|
||||
const errorRateLabel = computed(() => {
|
||||
const v = props.overview?.error_rate
|
||||
if (typeof v !== 'number') return '-'
|
||||
return `${(v * 100).toFixed(2)}%`
|
||||
})
|
||||
|
||||
const upstreamErrorRateLabel = computed(() => {
|
||||
const v = props.overview?.upstream_error_rate
|
||||
if (typeof v !== 'number') return '-'
|
||||
return `${(v * 100).toFixed(2)}%`
|
||||
})
|
||||
|
||||
const wsStatusLabel = computed(() => {
|
||||
switch (props.wsStatus) {
|
||||
case 'connected':
|
||||
return t('admin.ops.realtime.connected')
|
||||
case 'connecting':
|
||||
return t('admin.ops.realtime.connecting')
|
||||
case 'reconnecting':
|
||||
return t('admin.ops.realtime.reconnecting')
|
||||
case 'offline':
|
||||
return t('admin.ops.realtime.offline')
|
||||
case 'closed':
|
||||
default:
|
||||
return t('admin.ops.realtime.closed')
|
||||
}
|
||||
})
|
||||
|
||||
const wsStatusDotClass = computed(() => {
|
||||
switch (props.wsStatus) {
|
||||
case 'connected':
|
||||
return 'bg-green-500'
|
||||
case 'reconnecting':
|
||||
case 'connecting':
|
||||
return 'bg-yellow-500'
|
||||
case 'offline':
|
||||
return 'bg-orange-500'
|
||||
case 'closed':
|
||||
default:
|
||||
return 'bg-gray-400'
|
||||
}
|
||||
})
|
||||
|
||||
const wsReconnectHint = computed(() => {
|
||||
if (props.wsStatus !== 'reconnecting') return ''
|
||||
const delayMs = props.wsReconnectInMs ?? null
|
||||
if (typeof delayMs !== 'number' || !Number.isFinite(delayMs) || delayMs <= 0) return ''
|
||||
const sec = Math.max(1, Math.ceil(delayMs / 1000))
|
||||
return t('admin.ops.realtime.reconnectIn', { seconds: sec })
|
||||
})
|
||||
</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) -->
|
||||
<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 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') }}: {{ updatedAtLabel }}</span>
|
||||
<span>·</span>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="relative inline-flex h-2 w-2 rounded-full" :class="wsStatusDotClass"></span>
|
||||
</span>
|
||||
<span>{{ wsStatusLabel }}</span>
|
||||
<span v-if="wsReconnectHint" class="text-[11px] text-gray-400">({{ wsReconnectHint }})</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<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-[140px]"
|
||||
@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"
|
||||
/>
|
||||
|
||||
<Select
|
||||
:model-value="queryMode"
|
||||
:options="queryModeOptions"
|
||||
class="relative w-full sm:w-[170px]"
|
||||
@update:model-value="handleQueryModeChange"
|
||||
/>
|
||||
|
||||
<button
|
||||
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="emit('refresh')"
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
<button
|
||||
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')"
|
||||
>
|
||||
{{ 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>
|
||||
</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>
|
||||
<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>
|
||||
</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 class="flex items-center gap-2">
|
||||
<button
|
||||
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')"
|
||||
>
|
||||
{{ t('admin.ops.errorDetails.requestErrors') }}
|
||||
</button>
|
||||
<button
|
||||
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')"
|
||||
>
|
||||
{{ t('admin.ops.errorDetails.upstreamErrors') }}
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-2">
|
||||
<div class="h-5 w-48 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="h-4 w-72 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-9 w-28 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="h-9 w-28 animate-pulse rounded-xl bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
<div v-for="i in 4" :key="i" class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-900/30">
|
||||
<div class="h-3 w-16 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="mt-3 h-6 w-24 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="h-4 w-40 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="mt-6 h-64 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div>
|
||||
</div>
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="h-4 w-40 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="mt-6 h-64 animate-pulse rounded-2xl bg-gray-100 dark:bg-dark-700/70"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cards -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700"
|
||||
>
|
||||
<div class="h-4 w-36 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="h-3 w-2/3 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
|
||||
<div class="h-3 w-1/2 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
|
||||
<div class="h-3 w-3/5 animate-pulse rounded bg-gray-100 dark:bg-dark-700/70"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { opsAPI } from '@/api/admin/ops'
|
||||
import type { EmailNotificationConfig, AlertSeverity } from '../types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const config = ref<EmailNotificationConfig | null>(null)
|
||||
|
||||
const showEditor = ref(false)
|
||||
const saving = ref(false)
|
||||
const draft = ref<EmailNotificationConfig | null>(null)
|
||||
const alertRecipientInput = ref('')
|
||||
const reportRecipientInput = ref('')
|
||||
const alertRecipientError = ref('')
|
||||
const reportRecipientError = ref('')
|
||||
|
||||
const severityOptions: Array<{ value: AlertSeverity | ''; label: string }> = [
|
||||
{ value: '', label: t('admin.ops.email.minSeverityAll') },
|
||||
{ value: 'critical', label: t('common.critical') },
|
||||
{ value: 'warning', label: t('common.warning') },
|
||||
{ value: 'info', label: t('common.info') }
|
||||
]
|
||||
|
||||
async function loadConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await opsAPI.getEmailNotificationConfig()
|
||||
config.value = data
|
||||
} catch (err: any) {
|
||||
console.error('[OpsEmailNotificationCard] Failed to load config', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.email.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
if (!draft.value) return
|
||||
if (!editorValidation.value.valid) {
|
||||
appStore.showError(editorValidation.value.errors[0] || t('admin.ops.email.validation.invalid'))
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
config.value = await opsAPI.updateEmailNotificationConfig(draft.value)
|
||||
showEditor.value = false
|
||||
appStore.showSuccess(t('admin.ops.email.saveSuccess'))
|
||||
} catch (err: any) {
|
||||
console.error('[OpsEmailNotificationCard] Failed to save config', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.email.saveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openEditor() {
|
||||
if (!config.value) return
|
||||
draft.value = JSON.parse(JSON.stringify(config.value))
|
||||
alertRecipientInput.value = ''
|
||||
reportRecipientInput.value = ''
|
||||
alertRecipientError.value = ''
|
||||
reportRecipientError.value = ''
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
function isValidEmailAddress(email: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
||||
}
|
||||
|
||||
function isNonNegativeNumber(value: unknown): boolean {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0
|
||||
}
|
||||
|
||||
function validateCronField(enabled: boolean, cron: string): string | null {
|
||||
if (!enabled) return null
|
||||
if (!cron || !cron.trim()) return t('admin.ops.email.validation.cronRequired')
|
||||
if (cron.trim().split(/\s+/).length < 5) return t('admin.ops.email.validation.cronFormat')
|
||||
return null
|
||||
}
|
||||
|
||||
const editorValidation = computed(() => {
|
||||
const errors: string[] = []
|
||||
if (!draft.value) return { valid: true, errors }
|
||||
|
||||
if (draft.value.alert.enabled && draft.value.alert.recipients.length === 0) {
|
||||
errors.push(t('admin.ops.email.validation.alertRecipientsRequired'))
|
||||
}
|
||||
if (draft.value.report.enabled && draft.value.report.recipients.length === 0) {
|
||||
errors.push(t('admin.ops.email.validation.reportRecipientsRequired'))
|
||||
}
|
||||
|
||||
const invalidAlertRecipients = draft.value.alert.recipients.filter((e) => !isValidEmailAddress(e))
|
||||
if (invalidAlertRecipients.length > 0) errors.push(t('admin.ops.email.validation.invalidRecipients'))
|
||||
|
||||
const invalidReportRecipients = draft.value.report.recipients.filter((e) => !isValidEmailAddress(e))
|
||||
if (invalidReportRecipients.length > 0) errors.push(t('admin.ops.email.validation.invalidRecipients'))
|
||||
|
||||
if (!isNonNegativeNumber(draft.value.alert.rate_limit_per_hour)) {
|
||||
errors.push(t('admin.ops.email.validation.rateLimitRange'))
|
||||
}
|
||||
if (
|
||||
!isNonNegativeNumber(draft.value.alert.batching_window_seconds) ||
|
||||
draft.value.alert.batching_window_seconds > 86400
|
||||
) {
|
||||
errors.push(t('admin.ops.email.validation.batchWindowRange'))
|
||||
}
|
||||
|
||||
const dailyErr = validateCronField(
|
||||
draft.value.report.daily_summary_enabled,
|
||||
draft.value.report.daily_summary_schedule
|
||||
)
|
||||
if (dailyErr) errors.push(dailyErr)
|
||||
const weeklyErr = validateCronField(
|
||||
draft.value.report.weekly_summary_enabled,
|
||||
draft.value.report.weekly_summary_schedule
|
||||
)
|
||||
if (weeklyErr) errors.push(weeklyErr)
|
||||
const digestErr = validateCronField(
|
||||
draft.value.report.error_digest_enabled,
|
||||
draft.value.report.error_digest_schedule
|
||||
)
|
||||
if (digestErr) errors.push(digestErr)
|
||||
const accErr = validateCronField(
|
||||
draft.value.report.account_health_enabled,
|
||||
draft.value.report.account_health_schedule
|
||||
)
|
||||
if (accErr) errors.push(accErr)
|
||||
|
||||
if (!isNonNegativeNumber(draft.value.report.error_digest_min_count)) {
|
||||
errors.push(t('admin.ops.email.validation.digestMinCountRange'))
|
||||
}
|
||||
|
||||
const thr = draft.value.report.account_health_error_rate_threshold
|
||||
if (!(typeof thr === 'number' && Number.isFinite(thr) && thr >= 0 && thr <= 100)) {
|
||||
errors.push(t('admin.ops.email.validation.accountHealthThresholdRange'))
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
})
|
||||
|
||||
function addRecipient(target: 'alert' | 'report') {
|
||||
if (!draft.value) return
|
||||
const raw = (target === 'alert' ? alertRecipientInput.value : reportRecipientInput.value).trim()
|
||||
if (!raw) return
|
||||
|
||||
if (!isValidEmailAddress(raw)) {
|
||||
const msg = t('common.invalidEmail')
|
||||
if (target === 'alert') alertRecipientError.value = msg
|
||||
else reportRecipientError.value = msg
|
||||
return
|
||||
}
|
||||
|
||||
const normalized = raw.toLowerCase()
|
||||
const list = target === 'alert' ? draft.value.alert.recipients : draft.value.report.recipients
|
||||
if (!list.includes(normalized)) {
|
||||
list.push(normalized)
|
||||
}
|
||||
if (target === 'alert') alertRecipientInput.value = ''
|
||||
else reportRecipientInput.value = ''
|
||||
if (target === 'alert') alertRecipientError.value = ''
|
||||
else reportRecipientError.value = ''
|
||||
}
|
||||
|
||||
function removeRecipient(target: 'alert' | 'report', email: string) {
|
||||
if (!draft.value) return
|
||||
const list = target === 'alert' ? draft.value.alert.recipients : draft.value.report.recipients
|
||||
const idx = list.indexOf(email)
|
||||
if (idx >= 0) list.splice(idx, 1)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white">{{ t('admin.ops.email.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.email.description') }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
||||
:disabled="loading"
|
||||
@click="loadConfig"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" :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>
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-secondary" :disabled="!config" @click="openEditor">{{ t('common.edit') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!config" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span v-if="loading">{{ t('admin.ops.email.loading') }}</span>
|
||||
<span v-else>{{ t('admin.ops.email.noData') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||
<h4 class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.email.alertTitle') }}</h4>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ t('common.enabled') }}:
|
||||
<span class="ml-1 font-medium text-gray-900 dark:text-white">
|
||||
{{ config.alert.enabled ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ t('admin.ops.email.recipients') }}:
|
||||
<span class="ml-1 font-medium text-gray-900 dark:text-white">{{ config.alert.recipients.length }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ t('admin.ops.email.minSeverity') }}:
|
||||
<span class="ml-1 font-medium text-gray-900 dark:text-white">{{
|
||||
config.alert.min_severity || t('admin.ops.email.minSeverityAll')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ t('admin.ops.email.rateLimitPerHour') }}:
|
||||
<span class="ml-1 font-medium text-gray-900 dark:text-white">{{ config.alert.rate_limit_per_hour }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||
<h4 class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.email.reportTitle') }}</h4>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ t('common.enabled') }}:
|
||||
<span class="ml-1 font-medium text-gray-900 dark:text-white">
|
||||
{{ config.report.enabled ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ t('admin.ops.email.recipients') }}:
|
||||
<span class="ml-1 font-medium text-gray-900 dark:text-white">{{ config.report.recipients.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseDialog :show="showEditor" :title="t('admin.ops.email.title')" width="extra-wide" @close="showEditor = false">
|
||||
<div v-if="draft" class="space-y-6">
|
||||
<div
|
||||
v-if="!editorValidation.valid"
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-200"
|
||||
>
|
||||
<div class="font-bold">{{ t('admin.ops.email.validation.title') }}</div>
|
||||
<ul class="mt-1 list-disc space-y-1 pl-4">
|
||||
<li v-for="msg in editorValidation.errors" :key="msg">{{ msg }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.email.alertTitle') }}</h4>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('common.enabled') }}</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="draft.alert.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
|
||||
<span>{{ draft.alert.enabled ? t('common.enabled') : t('common.disabled') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.minSeverity') }}</div>
|
||||
<Select v-model="draft.alert.min_severity" :options="severityOptions" />
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.recipients') }}</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="alertRecipientInput"
|
||||
type="email"
|
||||
class="input"
|
||||
:placeholder="t('admin.ops.email.recipients')"
|
||||
@keydown.enter.prevent="addRecipient('alert')"
|
||||
/>
|
||||
<button class="btn btn-secondary whitespace-nowrap" type="button" @click="addRecipient('alert')">
|
||||
{{ t('common.add') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="alertRecipientError" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ alertRecipientError }}</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="email in draft.alert.recipients"
|
||||
:key="email"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
>
|
||||
{{ email }}
|
||||
<button
|
||||
type="button"
|
||||
class="text-blue-700/80 hover:text-blue-900 dark:text-blue-300"
|
||||
@click="removeRecipient('alert', email)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.email.recipientsHint') }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.rateLimitPerHour') }}</div>
|
||||
<input v-model.number="draft.alert.rate_limit_per_hour" type="number" min="0" max="100000" class="input" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.batchWindowSeconds') }}</div>
|
||||
<input v-model.number="draft.alert.batching_window_seconds" type="number" min="0" max="86400" class="input" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.includeResolved') }}</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="draft.alert.include_resolved_alerts" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
|
||||
<span>{{ draft.alert.include_resolved_alerts ? t('common.enabled') : t('common.disabled') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.email.reportTitle') }}</h4>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('common.enabled') }}</div>
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="draft.report.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
|
||||
<span>{{ draft.report.enabled ? t('common.enabled') : t('common.disabled') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.recipients') }}</div>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="reportRecipientInput"
|
||||
type="email"
|
||||
class="input"
|
||||
:placeholder="t('admin.ops.email.recipients')"
|
||||
@keydown.enter.prevent="addRecipient('report')"
|
||||
/>
|
||||
<button class="btn btn-secondary whitespace-nowrap" type="button" @click="addRecipient('report')">
|
||||
{{ t('common.add') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="reportRecipientError" class="mt-1 text-xs text-red-600 dark:text-red-400">{{ reportRecipientError }}</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="email in draft.report.recipients"
|
||||
:key="email"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
>
|
||||
{{ email }}
|
||||
<button
|
||||
type="button"
|
||||
class="text-blue-700/80 hover:text-blue-900 dark:text-blue-300"
|
||||
@click="removeRecipient('report', email)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.dailySummary') }}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="draft.report.daily_summary_enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
|
||||
</label>
|
||||
<input v-model="draft.report.daily_summary_schedule" type="text" class="input" :placeholder="t('admin.ops.email.cronPlaceholder')" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.weeklySummary') }}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="draft.report.weekly_summary_enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
|
||||
</label>
|
||||
<input v-model="draft.report.weekly_summary_schedule" type="text" class="input" :placeholder="t('admin.ops.email.cronPlaceholder')" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.errorDigest') }}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="draft.report.error_digest_enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
|
||||
</label>
|
||||
<input v-model="draft.report.error_digest_schedule" type="text" class="input" :placeholder="t('admin.ops.email.cronPlaceholder')" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.errorDigestMinCount') }}</div>
|
||||
<input v-model.number="draft.report.error_digest_min_count" type="number" min="0" max="1000000" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.accountHealth') }}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="draft.report.account_health_enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
|
||||
</label>
|
||||
<input v-model="draft.report.account_health_schedule" type="text" class="input" :placeholder="t('admin.ops.email.cronPlaceholder')" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.email.accountHealthThreshold') }}</div>
|
||||
<input v-model.number="draft.report.account_health_error_rate_threshold" type="number" min="0" max="100" step="0.1" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.email.reportHint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="btn btn-secondary" @click="showEditor = false">{{ t('common.cancel') }}</button>
|
||||
<button class="btn btn-primary" :disabled="saving || !editorValidation.valid" @click="saveConfig">
|
||||
{{ saving ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
360
frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue
Normal file
360
frontend/src/views/admin/ops/components/OpsErrorDetailModal.vue
Normal file
@@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="title" width="full" :close-on-click-outside="true" @close="close">
|
||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('admin.ops.errorDetail.loading') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!detail" class="py-10 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ emptyText }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6 p-6">
|
||||
<!-- Top Summary -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.requestId') }}</div>
|
||||
<div class="mt-1 break-all font-mono text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ detail.request_id || detail.client_request_id || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.time') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ formatDateTime(detail.created_at) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.phase') }}</div>
|
||||
<div class="mt-1 text-sm font-bold uppercase text-gray-900 dark:text-white">
|
||||
{{ detail.phase || '—' }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ detail.type || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-50 p-4 dark:bg-dark-900">
|
||||
<div class="text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.status') }}</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<span :class="['inline-flex items-center rounded-lg px-2 py-1 text-xs font-black ring-1 ring-inset shadow-sm', statusClass]">
|
||||
{{ detail.status_code }}
|
||||
</span>
|
||||
<span
|
||||
v-if="detail.severity"
|
||||
:class="['rounded-md px-2 py-0.5 text-[10px] font-black shadow-sm', severityClass]"
|
||||
>
|
||||
{{ detail.severity }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message -->
|
||||
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<h3 class="mb-4 text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.message') }}</h3>
|
||||
<div class="text-sm font-medium text-gray-800 dark:text-gray-200 break-words">
|
||||
{{ detail.message || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Basic Info -->
|
||||
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<h3 class="mb-4 text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.basicInfo') }}</h3>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.platform') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">{{ detail.platform || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.model') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">{{ detail.model || '—' }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.latency') }}</div>
|
||||
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
|
||||
{{ detail.latency_ms != null ? `${detail.latency_ms}ms` : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.ttft') }}</div>
|
||||
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
|
||||
{{ detail.time_to_first_token_ms != null ? `${detail.time_to_first_token_ms}ms` : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.businessLimited') }}</div>
|
||||
<div class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ detail.is_business_limited ? 'true' : 'false' }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.requestPath') }}</div>
|
||||
<div class="mt-1 font-mono text-xs text-gray-700 dark:text-gray-200 break-all">
|
||||
{{ detail.request_path || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timings (best-effort fields) -->
|
||||
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<h3 class="mb-4 text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.timings') }}</h3>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-dark-800">
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.auth') }}</div>
|
||||
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
|
||||
{{ detail.auth_latency_ms != null ? `${detail.auth_latency_ms}ms` : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-dark-800">
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.routing') }}</div>
|
||||
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
|
||||
{{ detail.routing_latency_ms != null ? `${detail.routing_latency_ms}ms` : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-dark-800">
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.upstream') }}</div>
|
||||
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
|
||||
{{ detail.upstream_latency_ms != null ? `${detail.upstream_latency_ms}ms` : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-dark-800">
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.response') }}</div>
|
||||
<div class="mt-1 font-mono text-sm font-bold text-gray-900 dark:text-white">
|
||||
{{ detail.response_latency_ms != null ? `${detail.response_latency_ms}ms` : '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Retry -->
|
||||
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<div class="flex flex-col justify-between gap-4 md:flex-row md:items-start">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.retry') }}</h3>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.errorDetail.retryNote1') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" class="btn btn-secondary btn-sm" :disabled="retrying" @click="openRetryConfirm('client')">
|
||||
{{ t('admin.ops.errorDetail.retryClient') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="retrying || !pinnedAccountId"
|
||||
@click="openRetryConfirm('upstream')"
|
||||
:title="pinnedAccountId ? '' : t('admin.ops.errorDetail.retryUpstreamHint')"
|
||||
>
|
||||
{{ t('admin.ops.errorDetail.retryUpstream') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="md:col-span-1">
|
||||
<label class="mb-1 block text-xs font-bold uppercase tracking-wider text-gray-400">{{ t('admin.ops.errorDetail.pinnedAccountId') }}</label>
|
||||
<input v-model="pinnedAccountIdInput" type="text" class="input font-mono text-sm" :placeholder="t('admin.ops.errorDetail.pinnedAccountIdHint')" />
|
||||
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.errorDetail.retryNote2') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<div class="rounded-lg bg-white p-4 shadow-sm dark:bg-dark-800">
|
||||
<div class="text-xs font-bold uppercase text-gray-400">{{ t('admin.ops.errorDetail.retryNotes') }}</div>
|
||||
<ul class="mt-2 list-disc space-y-1 pl-5 text-xs text-gray-600 dark:text-gray-300">
|
||||
<li>{{ t('admin.ops.errorDetail.retryNote3') }}</li>
|
||||
<li>{{ t('admin.ops.errorDetail.retryNote4') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Request body -->
|
||||
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.requestBody') }}</h3>
|
||||
<div
|
||||
v-if="detail.request_body_truncated"
|
||||
class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
|
||||
>
|
||||
{{ t('admin.ops.errorDetail.trimmed') }}
|
||||
</div>
|
||||
</div>
|
||||
<pre
|
||||
class="mt-4 max-h-[420px] overflow-auto rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-100"
|
||||
><code>{{ prettyJSON(detail.request_body) }}</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- Error body -->
|
||||
<div class="rounded-xl bg-gray-50 p-6 dark:bg-dark-900">
|
||||
<h3 class="text-sm font-black uppercase tracking-wider text-gray-900 dark:text-white">{{ t('admin.ops.errorDetail.errorBody') }}</h3>
|
||||
<pre
|
||||
class="mt-4 max-h-[420px] overflow-auto rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-800 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-100"
|
||||
><code>{{ prettyJSON(detail.error_body) }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
:show="showRetryConfirm"
|
||||
:title="t('admin.ops.errorDetail.confirmRetry')"
|
||||
:message="retryConfirmMessage"
|
||||
@confirm="runConfirmedRetry"
|
||||
@cancel="cancelRetry"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { opsAPI, type OpsErrorDetail, type OpsRetryMode } from '@/api/admin/ops'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { getSeverityClass } from '../utils/opsFormatters'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
errorId: number | null
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:show', value: boolean): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const detail = ref<OpsErrorDetail | null>(null)
|
||||
|
||||
const retrying = ref(false)
|
||||
const showRetryConfirm = ref(false)
|
||||
const pendingRetryMode = ref<OpsRetryMode>('client')
|
||||
|
||||
const pinnedAccountIdInput = ref('')
|
||||
const pinnedAccountId = computed<number | null>(() => {
|
||||
const raw = String(pinnedAccountIdInput.value || '').trim()
|
||||
if (!raw) return null
|
||||
const n = Number.parseInt(raw, 10)
|
||||
return Number.isFinite(n) && n > 0 ? n : null
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
if (!props.errorId) return 'Error Detail'
|
||||
return `Error #${props.errorId}`
|
||||
})
|
||||
|
||||
const emptyText = computed(() => 'No error selected.')
|
||||
|
||||
function close() {
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
function prettyJSON(raw?: string): string {
|
||||
if (!raw) return t('admin.ops.errorDetail.na')
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchDetail(id: number) {
|
||||
loading.value = true
|
||||
try {
|
||||
const d = await opsAPI.getErrorLogDetail(id)
|
||||
detail.value = d
|
||||
|
||||
// Default pinned account from error log if present.
|
||||
if (d.account_id && d.account_id > 0) {
|
||||
pinnedAccountIdInput.value = String(d.account_id)
|
||||
} else {
|
||||
pinnedAccountIdInput.value = ''
|
||||
}
|
||||
} catch (err: any) {
|
||||
detail.value = null
|
||||
appStore.showError(err?.message || 'Failed to load error detail')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.show, props.errorId] as const,
|
||||
([show, id]) => {
|
||||
if (!show) {
|
||||
detail.value = null
|
||||
return
|
||||
}
|
||||
if (typeof id === 'number' && id > 0) {
|
||||
fetchDetail(id)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function openRetryConfirm(mode: OpsRetryMode) {
|
||||
pendingRetryMode.value = mode
|
||||
showRetryConfirm.value = true
|
||||
}
|
||||
|
||||
const retryConfirmMessage = computed(() => {
|
||||
const mode = pendingRetryMode.value
|
||||
if (mode === 'upstream') {
|
||||
return t('admin.ops.errorDetail.confirmRetryMessage')
|
||||
}
|
||||
return t('admin.ops.errorDetail.confirmRetryHint')
|
||||
})
|
||||
|
||||
const severityClass = computed(() => {
|
||||
if (!detail.value?.severity) return 'bg-gray-100 text-gray-700 dark:bg-dark-700 dark:text-gray-300'
|
||||
return getSeverityClass(detail.value.severity)
|
||||
})
|
||||
|
||||
const statusClass = computed(() => {
|
||||
const code = detail.value?.status_code ?? 0
|
||||
if (code >= 500) return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30'
|
||||
if (code === 429) return 'bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-900/30 dark:text-purple-400 dark:ring-purple-500/30'
|
||||
if (code >= 400) return 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-400 dark:ring-amber-500/30'
|
||||
return 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-400 dark:ring-gray-500/30'
|
||||
})
|
||||
|
||||
async function runConfirmedRetry() {
|
||||
if (!props.errorId) return
|
||||
const mode = pendingRetryMode.value
|
||||
showRetryConfirm.value = false
|
||||
|
||||
retrying.value = true
|
||||
try {
|
||||
const req =
|
||||
mode === 'upstream'
|
||||
? { mode, pinned_account_id: pinnedAccountId.value ?? undefined }
|
||||
: { mode }
|
||||
|
||||
const res = await opsAPI.retryErrorRequest(props.errorId, req)
|
||||
const summary = res.status === 'succeeded' ? t('admin.ops.errorDetail.retrySuccess') : t('admin.ops.errorDetail.retryFailed')
|
||||
appStore.showSuccess(summary)
|
||||
} catch (err: any) {
|
||||
appStore.showError(err?.message || 'Retry failed')
|
||||
} finally {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRetry() {
|
||||
showRetryConfirm.value = false
|
||||
}
|
||||
</script>
|
||||
293
frontend/src/views/admin/ops/components/OpsErrorDetailsModal.vue
Normal file
293
frontend/src/views/admin/ops/components/OpsErrorDetailsModal.vue
Normal file
@@ -0,0 +1,293 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { onKeyStroke } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import OpsErrorLogTable from './OpsErrorLogTable.vue'
|
||||
import { opsAPI, type OpsErrorLog } from '@/api/admin/ops'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
timeRange: string
|
||||
platform?: string
|
||||
groupId?: number | null
|
||||
errorType: 'request' | 'upstream'
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:show', value: boolean): void
|
||||
(e: 'openErrorDetail', errorId: number): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const loading = ref(false)
|
||||
const rows = ref<OpsErrorLog[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(50)
|
||||
|
||||
const q = ref('')
|
||||
const statusCode = ref<number | null>(null)
|
||||
const phase = ref<string>('')
|
||||
const accountIdInput = ref<string>('')
|
||||
|
||||
const accountId = computed<number | null>(() => {
|
||||
const raw = String(accountIdInput.value || '').trim()
|
||||
if (!raw) return null
|
||||
const n = Number.parseInt(raw, 10)
|
||||
return Number.isFinite(n) && n > 0 ? n : null
|
||||
})
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
return props.errorType === 'upstream' ? t('admin.ops.errorDetails.upstreamErrors') : t('admin.ops.errorDetails.requestErrors')
|
||||
})
|
||||
|
||||
const statusCodeSelectOptions = computed(() => {
|
||||
const codes = [400, 401, 403, 404, 409, 422, 429, 500, 502, 503, 504, 529]
|
||||
return [
|
||||
{ value: null, label: t('common.all') },
|
||||
...codes.map((c) => ({ value: c, label: String(c) }))
|
||||
]
|
||||
})
|
||||
|
||||
const phaseSelectOptions = computed(() => {
|
||||
const options = [
|
||||
{ value: '', label: t('common.all') },
|
||||
{ value: 'upstream', label: 'upstream' },
|
||||
{ value: 'network', label: 'network' },
|
||||
{ value: 'routing', label: 'routing' },
|
||||
{ value: 'auth', label: 'auth' },
|
||||
{ value: 'billing', label: 'billing' },
|
||||
{ value: 'concurrency', label: 'concurrency' },
|
||||
{ value: 'internal', label: 'internal' }
|
||||
]
|
||||
return options
|
||||
})
|
||||
|
||||
function close() {
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
onKeyStroke('Escape', () => {
|
||||
if (props.show) close()
|
||||
})
|
||||
|
||||
async function fetchErrorLogs() {
|
||||
if (!props.show) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
time_range: props.timeRange
|
||||
}
|
||||
|
||||
const platform = String(props.platform || '').trim()
|
||||
if (platform) params.platform = platform
|
||||
if (typeof props.groupId === 'number' && props.groupId > 0) params.group_id = props.groupId
|
||||
|
||||
if (q.value.trim()) params.q = q.value.trim()
|
||||
if (typeof statusCode.value === 'number') params.status_codes = String(statusCode.value)
|
||||
if (typeof accountId.value === 'number') params.account_id = accountId.value
|
||||
|
||||
const phaseVal = String(phase.value || '').trim()
|
||||
if (phaseVal) params.phase = phaseVal
|
||||
|
||||
const res = await opsAPI.listErrorLogs(params)
|
||||
rows.value = res.items || []
|
||||
total.value = res.total || 0
|
||||
} catch (err) {
|
||||
console.error('[OpsErrorDetailsModal] Failed to fetch error logs', err)
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
q.value = ''
|
||||
statusCode.value = null
|
||||
phase.value = props.errorType === 'upstream' ? 'upstream' : ''
|
||||
accountIdInput.value = ''
|
||||
page.value = 1
|
||||
fetchErrorLogs()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
page.value = 1
|
||||
pageSize.value = 50
|
||||
resetFilters()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.timeRange, props.platform, props.groupId] as const,
|
||||
() => {
|
||||
if (!props.show) return
|
||||
page.value = 1
|
||||
fetchErrorLogs()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [page.value, pageSize.value] as const,
|
||||
() => {
|
||||
if (!props.show) return
|
||||
fetchErrorLogs()
|
||||
}
|
||||
)
|
||||
|
||||
let searchTimeout: number | null = null
|
||||
watch(
|
||||
() => q.value,
|
||||
() => {
|
||||
if (!props.show) return
|
||||
if (searchTimeout) window.clearTimeout(searchTimeout)
|
||||
searchTimeout = window.setTimeout(() => {
|
||||
page.value = 1
|
||||
fetchErrorLogs()
|
||||
}, 350)
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [statusCode.value, phase.value] as const,
|
||||
() => {
|
||||
if (!props.show) return
|
||||
page.value = 1
|
||||
fetchErrorLogs()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => accountId.value,
|
||||
() => {
|
||||
if (!props.show) return
|
||||
page.value = 1
|
||||
fetchErrorLogs()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="show" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm px-4" @click.self="close">
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div v-if="show" class="relative flex max-h-[90vh] w-full max-w-7xl flex-col overflow-hidden rounded-3xl bg-white shadow-2xl dark:bg-dark-800">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-5 dark:border-dark-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-2xl bg-orange-50 dark:bg-orange-900/20">
|
||||
<svg class="h-6 w-6 text-orange-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-black text-gray-900 dark:text-white">{{ modalTitle }}</h3>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.errorDetails.total') }} {{ total }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-lg text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-dark-700"
|
||||
@click="close"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="border-b border-gray-200 px-6 py-4 dark:border-dark-700">
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-12">
|
||||
<div class="lg:col-span-5">
|
||||
<div class="relative group">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5">
|
||||
<svg
|
||||
class="h-4 w-4 text-gray-400 transition-colors group-focus-within:text-blue-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
v-model="q"
|
||||
type="text"
|
||||
class="w-full rounded-2xl border-gray-200 bg-gray-50/50 py-2 pl-10 pr-4 text-sm font-medium text-gray-700 transition-all focus:border-blue-500 focus:bg-white focus:ring-4 focus:ring-blue-500/10 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:focus:bg-dark-800"
|
||||
:placeholder="t('admin.ops.errorDetails.searchPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2">
|
||||
<Select :model-value="statusCode" :options="statusCodeSelectOptions" class="w-full" @update:model-value="statusCode = $event as any" />
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2">
|
||||
<Select :model-value="phase" :options="phaseSelectOptions" class="w-full" @update:model-value="phase = String($event ?? '')" />
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2">
|
||||
<input
|
||||
v-model="accountIdInput"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
class="input w-full text-sm"
|
||||
:placeholder="t('admin.ops.errorDetails.accountIdPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-1 flex items-center justify-end">
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="resetFilters">
|
||||
{{ t('common.reset') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-6">
|
||||
<OpsErrorLogTable
|
||||
:rows="rows"
|
||||
:total="total"
|
||||
:loading="loading"
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
@openErrorDetail="emit('openErrorDetail', $event)"
|
||||
@update:page="page = $event"
|
||||
@update:pageSize="pageSize = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Chart as ChartJS, ArcElement, Legend, Tooltip } from 'chart.js'
|
||||
import { Doughnut } from 'vue-chartjs'
|
||||
import type { OpsErrorDistributionResponse } from '@/api/admin/ops'
|
||||
import type { ChartState } from '../types'
|
||||
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend)
|
||||
|
||||
interface Props {
|
||||
data: OpsErrorDistributionResponse | null
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'openDetails'): void
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isDarkMode = computed(() => document.documentElement.classList.contains('dark'))
|
||||
const colors = computed(() => ({
|
||||
blue: '#3b82f6',
|
||||
red: '#ef4444',
|
||||
orange: '#f59e0b',
|
||||
gray: '#9ca3af',
|
||||
text: isDarkMode.value ? '#9ca3af' : '#6b7280'
|
||||
}))
|
||||
|
||||
const hasData = computed(() => (props.data?.total ?? 0) > 0)
|
||||
|
||||
const state = computed<ChartState>(() => {
|
||||
if (hasData.value) return 'ready'
|
||||
if (props.loading) return 'loading'
|
||||
return 'empty'
|
||||
})
|
||||
|
||||
interface ErrorCategory {
|
||||
label: string
|
||||
count: number
|
||||
color: string
|
||||
}
|
||||
|
||||
const categories = computed<ErrorCategory[]>(() => {
|
||||
if (!props.data) return []
|
||||
|
||||
let upstream = 0 // 502, 503, 504
|
||||
let client = 0 // 4xx
|
||||
let system = 0 // 500
|
||||
let other = 0
|
||||
|
||||
for (const item of props.data.items || []) {
|
||||
const code = Number(item.status_code || 0)
|
||||
const count = Number(item.total || 0)
|
||||
if (!Number.isFinite(code) || !Number.isFinite(count)) continue
|
||||
|
||||
if ([502, 503, 504].includes(code)) upstream += count
|
||||
else if (code >= 400 && code < 500) client += count
|
||||
else if (code === 500) system += count
|
||||
else other += count
|
||||
}
|
||||
|
||||
const out: ErrorCategory[] = []
|
||||
if (upstream > 0) out.push({ label: t('admin.ops.upstream'), count: upstream, color: colors.value.orange })
|
||||
if (client > 0) out.push({ label: t('admin.ops.client'), count: client, color: colors.value.blue })
|
||||
if (system > 0) out.push({ label: t('admin.ops.system'), count: system, color: colors.value.red })
|
||||
if (other > 0) out.push({ label: t('admin.ops.other'), count: other, color: colors.value.gray })
|
||||
return out
|
||||
})
|
||||
|
||||
const topReason = computed(() => {
|
||||
if (categories.value.length === 0) return null
|
||||
return categories.value.reduce((prev, cur) => (cur.count > prev.count ? cur : prev))
|
||||
})
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!hasData.value || categories.value.length === 0) return null
|
||||
return {
|
||||
labels: categories.value.map((c) => c.label),
|
||||
datasets: [
|
||||
{
|
||||
data: categories.value.map((c) => c.count),
|
||||
backgroundColor: categories.value.map((c) => c.color),
|
||||
borderWidth: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const options = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: isDarkMode.value ? '#1f2937' : '#ffffff',
|
||||
titleColor: isDarkMode.value ? '#f3f4f6' : '#111827',
|
||||
bodyColor: isDarkMode.value ? '#d1d5db' : '#4b5563'
|
||||
}
|
||||
}
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
|
||||
<svg class="h-4 w-4 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.ops.errorDistribution') }}
|
||||
<HelpTooltip :content="t('admin.ops.tooltips.errorDistribution')" />
|
||||
</h3>
|
||||
<button
|
||||
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 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
||||
:disabled="state !== 'ready'"
|
||||
:title="t('admin.ops.errorTrend')"
|
||||
@click="emit('openDetails')"
|
||||
>
|
||||
{{ t('admin.ops.requestDetails.details') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative min-h-0 flex-1">
|
||||
<div v-if="state === 'ready' && chartData" class="flex h-full flex-col">
|
||||
<div class="flex-1">
|
||||
<Doughnut :data="chartData" :options="{ ...options, cutout: '65%' }" />
|
||||
</div>
|
||||
<div class="mt-4 flex flex-col items-center gap-2">
|
||||
<div v-if="topReason" class="text-xs font-bold text-gray-900 dark:text-white">
|
||||
{{ t('admin.ops.top') }}: <span :style="{ color: topReason.color }">{{ topReason.label }}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<div v-for="item in categories" :key="item.label" class="flex items-center gap-1.5 text-xs">
|
||||
<span class="h-2 w-2 rounded-full" :style="{ backgroundColor: item.color }"></span>
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ item.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex h-full items-center justify-center">
|
||||
<div v-if="state === 'loading'" class="animate-pulse text-sm text-gray-400">{{ t('common.loading') }}</div>
|
||||
<EmptyState v-else :title="t('common.noData')" :description="t('admin.ops.charts.emptyError')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
238
frontend/src/views/admin/ops/components/OpsErrorLogTable.vue
Normal file
238
frontend/src/views/admin/ops/components/OpsErrorLogTable.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="loading" class="flex items-center justify-center py-10">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="sticky top-0 z-10 bg-gray-50/50 dark:bg-dark-800/50">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
class="whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
||||
>
|
||||
{{ t('admin.ops.errorLog.timeId') }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
||||
>
|
||||
{{ t('admin.ops.errorLog.context') }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="whitespace-nowrap px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
||||
>
|
||||
{{ t('admin.ops.errorLog.status') }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-6 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
||||
>
|
||||
{{ t('admin.ops.errorLog.message') }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="whitespace-nowrap px-6 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
||||
>
|
||||
{{ t('admin.ops.errorLog.latency') }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="whitespace-nowrap px-6 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-dark-400"
|
||||
>
|
||||
{{ t('admin.ops.errorLog.action') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-dark-700">
|
||||
<tr v-if="rows.length === 0" class="bg-white dark:bg-dark-900">
|
||||
<td colspan="6" class="py-16 text-center text-sm text-gray-400 dark:text-dark-500">
|
||||
{{ t('admin.ops.errorLog.noErrors') }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
v-for="log in rows"
|
||||
:key="log.id"
|
||||
class="group cursor-pointer transition-all duration-200 hover:bg-gray-50/80 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:hover:bg-dark-800/50 dark:focus:ring-offset-dark-900"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
@click="emit('openErrorDetail', log.id)"
|
||||
@keydown.enter.prevent="emit('openErrorDetail', log.id)"
|
||||
@keydown.space.prevent="emit('openErrorDetail', log.id)"
|
||||
>
|
||||
<!-- Time & ID -->
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="font-mono text-xs font-bold text-gray-900 dark:text-gray-200">
|
||||
{{ formatDateTime(log.created_at).split(' ')[1] }}
|
||||
</span>
|
||||
<span
|
||||
class="font-mono text-[10px] text-gray-400 transition-colors group-hover:text-primary-600 dark:group-hover:text-primary-400"
|
||||
:title="log.request_id || log.client_request_id"
|
||||
>
|
||||
{{ (log.request_id || log.client_request_id || '').substring(0, 12) }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Context (Platform/Model) -->
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex flex-col items-start gap-1.5">
|
||||
<span
|
||||
class="inline-flex items-center rounded-md bg-gray-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-tight text-gray-600 dark:bg-dark-700 dark:text-gray-300"
|
||||
>
|
||||
{{ log.platform || '-' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="log.model"
|
||||
class="max-w-[160px] truncate font-mono text-[10px] text-gray-500 dark:text-dark-400"
|
||||
:title="log.model"
|
||||
>
|
||||
{{ log.model }}
|
||||
</span>
|
||||
<div
|
||||
v-if="log.group_id || log.account_id"
|
||||
class="flex flex-wrap items-center gap-2 font-mono text-[10px] font-semibold text-gray-400 dark:text-dark-500"
|
||||
>
|
||||
<span v-if="log.group_id">{{ t('admin.ops.errorLog.grp') }} {{ log.group_id }}</span>
|
||||
<span v-if="log.account_id">{{ t('admin.ops.errorLog.acc') }} {{ log.account_id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Status & Severity -->
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center rounded-lg px-2 py-1 text-xs font-black ring-1 ring-inset shadow-sm',
|
||||
getStatusClass(log.status_code)
|
||||
]"
|
||||
>
|
||||
{{ log.status_code }}
|
||||
</span>
|
||||
<span
|
||||
v-if="log.severity"
|
||||
:class="['rounded-md px-2 py-0.5 text-[10px] font-black shadow-sm', getSeverityClass(log.severity)]"
|
||||
>
|
||||
{{ log.severity }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Message -->
|
||||
<td class="px-6 py-4">
|
||||
<div class="max-w-md lg:max-w-2xl">
|
||||
<p class="truncate text-xs font-semibold text-gray-700 dark:text-gray-300" :title="log.message">
|
||||
{{ formatSmartMessage(log.message) || '-' }}
|
||||
</p>
|
||||
<div class="mt-1.5 flex flex-wrap gap-x-3 gap-y-1">
|
||||
<div v-if="log.phase" class="flex items-center gap-1">
|
||||
<span class="h-1 w-1 rounded-full bg-gray-300"></span>
|
||||
<span class="text-[9px] font-black uppercase tracking-tighter text-gray-400">{{ log.phase }}</span>
|
||||
</div>
|
||||
<div v-if="log.client_ip" class="flex items-center gap-1">
|
||||
<span class="h-1 w-1 rounded-full bg-gray-300"></span>
|
||||
<span class="text-[9px] font-mono font-bold text-gray-400">{{ log.client_ip }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Latency -->
|
||||
<td class="px-6 py-4 text-right">
|
||||
<div class="flex flex-col items-end">
|
||||
<span class="font-mono text-xs font-black" :class="getLatencyClass(log.latency_ms ?? null)">
|
||||
{{ log.latency_ms != null ? Math.round(log.latency_ms) + 'ms' : '--' }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- Actions -->
|
||||
<td class="px-6 py-4 text-right" @click.stop>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="emit('openErrorDetail', log.id)">
|
||||
{{ t('admin.ops.errorLog.details') }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
v-if="total > 0"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
:page-size-options="[10, 20, 50, 100, 200, 500]"
|
||||
@update:page="emit('update:page', $event)"
|
||||
@update:pageSize="emit('update:pageSize', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import type { OpsErrorLog } from '@/api/admin/ops'
|
||||
import { getSeverityClass, formatDateTime } from '../utils/opsFormatters'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Props {
|
||||
rows: OpsErrorLog[]
|
||||
total: number
|
||||
loading: boolean
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'openErrorDetail', id: number): void
|
||||
(e: 'update:page', value: number): void
|
||||
(e: 'update:pageSize', value: number): void
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
function getStatusClass(code: number): string {
|
||||
if (code >= 500) return 'bg-red-50 text-red-700 ring-red-600/20 dark:bg-red-900/30 dark:text-red-400 dark:ring-red-500/30'
|
||||
if (code === 429) return 'bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-900/30 dark:text-purple-400 dark:ring-purple-500/30'
|
||||
if (code >= 400) return 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-900/30 dark:text-amber-400 dark:ring-amber-500/30'
|
||||
return 'bg-gray-50 text-gray-700 ring-gray-600/20 dark:bg-gray-900/30 dark:text-gray-400 dark:ring-gray-500/30'
|
||||
}
|
||||
|
||||
function getLatencyClass(latency: number | null): string {
|
||||
if (!latency) return 'text-gray-400'
|
||||
if (latency > 10000) return 'text-red-600 font-black'
|
||||
if (latency > 5000) return 'text-red-500 font-bold'
|
||||
if (latency > 2000) return 'text-orange-500 font-medium'
|
||||
return 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
|
||||
function formatSmartMessage(msg: string): string {
|
||||
if (!msg) return ''
|
||||
|
||||
if (msg.startsWith('{') || msg.startsWith('[')) {
|
||||
try {
|
||||
const obj = JSON.parse(msg)
|
||||
if (obj?.error?.message) return String(obj.error.message)
|
||||
if (obj?.message) return String(obj.message)
|
||||
if (obj?.detail) return String(obj.detail)
|
||||
if (typeof obj === 'object') return JSON.stringify(obj).substring(0, 150)
|
||||
} catch {
|
||||
// ignore parse error
|
||||
}
|
||||
}
|
||||
|
||||
if (msg.includes('context deadline exceeded')) return 'context deadline exceeded'
|
||||
if (msg.includes('connection refused')) return 'connection refused'
|
||||
if (msg.toLowerCase().includes('rate limit')) return 'rate limit'
|
||||
|
||||
return msg.length > 200 ? msg.substring(0, 200) + '...' : msg
|
||||
}
|
||||
</script>
|
||||
185
frontend/src/views/admin/ops/components/OpsErrorTrendChart.vue
Normal file
185
frontend/src/views/admin/ops/components/OpsErrorTrendChart.vue
Normal file
@@ -0,0 +1,185 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
Filler,
|
||||
Legend,
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip
|
||||
} from 'chart.js'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import type { OpsErrorTrendPoint } from '@/api/admin/ops'
|
||||
import type { ChartState } from '../types'
|
||||
import { formatHistoryLabel, sumNumbers } from '../utils/opsFormatters'
|
||||
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
|
||||
ChartJS.register(Title, Tooltip, Legend, LineElement, LinearScale, PointElement, CategoryScale, Filler)
|
||||
|
||||
interface Props {
|
||||
points: OpsErrorTrendPoint[]
|
||||
loading: boolean
|
||||
timeRange: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'openRequestErrors'): void
|
||||
(e: 'openUpstreamErrors'): void
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isDarkMode = computed(() => document.documentElement.classList.contains('dark'))
|
||||
const colors = computed(() => ({
|
||||
red: '#ef4444',
|
||||
redAlpha: '#ef444420',
|
||||
purple: '#8b5cf6',
|
||||
purpleAlpha: '#8b5cf620',
|
||||
gray: '#9ca3af',
|
||||
grid: isDarkMode.value ? '#374151' : '#f3f4f6',
|
||||
text: isDarkMode.value ? '#9ca3af' : '#6b7280'
|
||||
}))
|
||||
|
||||
const totalErrors = computed(() => sumNumbers(props.points.map((p) => p.error_count_sla ?? 0)))
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!props.points.length || totalErrors.value <= 0) return null
|
||||
return {
|
||||
labels: props.points.map((p) => formatHistoryLabel(p.bucket_start, props.timeRange)),
|
||||
datasets: [
|
||||
{
|
||||
label: t('admin.ops.errorsSla'),
|
||||
data: props.points.map((p) => p.error_count_sla ?? 0),
|
||||
borderColor: colors.value.red,
|
||||
backgroundColor: colors.value.redAlpha,
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 10
|
||||
},
|
||||
{
|
||||
label: t('admin.ops.upstreamExcl429529'),
|
||||
data: props.points.map((p) => p.upstream_error_count_excl_429_529 ?? 0),
|
||||
borderColor: colors.value.purple,
|
||||
backgroundColor: colors.value.purpleAlpha,
|
||||
fill: true,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 10
|
||||
},
|
||||
{
|
||||
label: t('admin.ops.businessLimited'),
|
||||
data: props.points.map((p) => p.business_limited_count ?? 0),
|
||||
borderColor: colors.value.gray,
|
||||
backgroundColor: 'transparent',
|
||||
borderDash: [6, 6],
|
||||
fill: false,
|
||||
tension: 0.35,
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 10
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const state = computed<ChartState>(() => {
|
||||
if (chartData.value) return 'ready'
|
||||
if (props.loading) return 'loading'
|
||||
return 'empty'
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
const c = colors.value
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { intersect: false, mode: 'index' as const },
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
align: 'end' as const,
|
||||
labels: { color: c.text, usePointStyle: true, boxWidth: 6, font: { size: 10 } }
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDarkMode.value ? '#1f2937' : '#ffffff',
|
||||
titleColor: isDarkMode.value ? '#f3f4f6' : '#111827',
|
||||
bodyColor: isDarkMode.value ? '#d1d5db' : '#4b5563',
|
||||
borderColor: c.grid,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
displayColors: true
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category' as const,
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
color: c.text,
|
||||
font: { size: 10 },
|
||||
maxTicksLimit: 8,
|
||||
autoSkip: true,
|
||||
autoSkipPadding: 10
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
position: 'left' as const,
|
||||
grid: { color: c.grid, borderDash: [4, 4] },
|
||||
ticks: { color: c.text, font: { size: 10 }, precision: 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="mb-4 flex shrink-0 items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
|
||||
<svg class="h-4 w-4 text-rose-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 17h8m0 0V9m0 8l-8-8-4 4-6-6"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.ops.errorTrend') }}
|
||||
<HelpTooltip :content="t('admin.ops.tooltips.errorTrend')" />
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
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 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
||||
:disabled="state !== 'ready'"
|
||||
@click="emit('openRequestErrors')"
|
||||
>
|
||||
{{ t('admin.ops.errorDetails.requestErrors') }}
|
||||
</button>
|
||||
<button
|
||||
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 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
||||
:disabled="state !== 'ready'"
|
||||
@click="emit('openUpstreamErrors')"
|
||||
>
|
||||
{{ t('admin.ops.errorDetails.upstreamErrors') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1">
|
||||
<Line v-if="state === 'ready' && chartData" :data="chartData" :options="options" />
|
||||
<div v-else class="flex h-full items-center justify-center">
|
||||
<div v-if="state === 'loading'" class="animate-pulse text-sm text-gray-400">{{ t('common.loading') }}</div>
|
||||
<EmptyState v-else :title="t('common.noData')" :description="t('admin.ops.charts.emptyError')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
101
frontend/src/views/admin/ops/components/OpsLatencyChart.vue
Normal file
101
frontend/src/views/admin/ops/components/OpsLatencyChart.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Chart as ChartJS, BarElement, CategoryScale, Legend, LinearScale, Tooltip } from 'chart.js'
|
||||
import { Bar } from 'vue-chartjs'
|
||||
import type { OpsLatencyHistogramResponse } from '@/api/admin/ops'
|
||||
import type { ChartState } from '../types'
|
||||
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
|
||||
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend)
|
||||
|
||||
interface Props {
|
||||
latencyData: OpsLatencyHistogramResponse | null
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const isDarkMode = computed(() => document.documentElement.classList.contains('dark'))
|
||||
const colors = computed(() => ({
|
||||
blue: '#3b82f6',
|
||||
grid: isDarkMode.value ? '#374151' : '#f3f4f6',
|
||||
text: isDarkMode.value ? '#9ca3af' : '#6b7280'
|
||||
}))
|
||||
|
||||
const hasData = computed(() => (props.latencyData?.total_requests ?? 0) > 0)
|
||||
|
||||
const state = computed<ChartState>(() => {
|
||||
if (hasData.value) return 'ready'
|
||||
if (props.loading) return 'loading'
|
||||
return 'empty'
|
||||
})
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!props.latencyData || !hasData.value) return null
|
||||
const c = colors.value
|
||||
return {
|
||||
labels: props.latencyData.buckets.map((b) => b.range),
|
||||
datasets: [
|
||||
{
|
||||
label: t('admin.ops.requests'),
|
||||
data: props.latencyData.buckets.map((b) => b.count),
|
||||
backgroundColor: c.blue,
|
||||
borderRadius: 4,
|
||||
barPercentage: 0.6
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
const c = colors.value
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false }
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: c.text, font: { size: 10 } }
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: c.grid, borderDash: [4, 4] },
|
||||
ticks: { color: c.text, font: { size: 10 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
|
||||
<svg class="h-4 w-4 text-purple-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.ops.latencyHistogram') }}
|
||||
<HelpTooltip :content="t('admin.ops.tooltips.latencyHistogram')" />
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1">
|
||||
<Bar v-if="state === 'ready' && chartData" :data="chartData" :options="options" />
|
||||
<div v-else class="flex h-full items-center justify-center">
|
||||
<div v-if="state === 'loading'" class="animate-pulse text-sm text-gray-400">{{ t('common.loading') }}</div>
|
||||
<EmptyState v-else :title="t('common.noData')" :description="t('admin.ops.charts.emptyRequest')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,309 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { onKeyStroke } from '@vueuse/core'
|
||||
import Pagination from '@/components/common/Pagination.vue'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useAppStore } from '@/stores'
|
||||
import { opsAPI, type OpsRequestDetailsParams, type OpsRequestDetail } from '@/api/admin/ops'
|
||||
import { parseTimeRangeMinutes, formatDateTime } from '../utils/opsFormatters'
|
||||
|
||||
export interface OpsRequestDetailsPreset {
|
||||
title: string
|
||||
kind?: OpsRequestDetailsParams['kind']
|
||||
sort?: OpsRequestDetailsParams['sort']
|
||||
min_duration_ms?: number
|
||||
max_duration_ms?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
timeRange: string
|
||||
preset: OpsRequestDetailsPreset
|
||||
platform?: string
|
||||
groupId?: number | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'openErrorDetail', errorId: number): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
const loading = ref(false)
|
||||
const items = ref<OpsRequestDetail[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(50)
|
||||
|
||||
const close = () => emit('update:modelValue', false)
|
||||
|
||||
onKeyStroke('Escape', () => {
|
||||
if (props.modelValue) close()
|
||||
})
|
||||
|
||||
const handleOverlayClick = (e: MouseEvent) => {
|
||||
if (e.target === e.currentTarget) close()
|
||||
}
|
||||
|
||||
const rangeLabel = computed(() => {
|
||||
const minutes = parseTimeRangeMinutes(props.timeRange)
|
||||
if (minutes >= 60) return t('admin.ops.requestDetails.rangeHours', { n: Math.round(minutes / 60) })
|
||||
return t('admin.ops.requestDetails.rangeMinutes', { n: minutes })
|
||||
})
|
||||
|
||||
function buildTimeParams(): Pick<OpsRequestDetailsParams, 'start_time' | 'end_time'> {
|
||||
const minutes = parseTimeRangeMinutes(props.timeRange)
|
||||
const endTime = new Date()
|
||||
const startTime = new Date(endTime.getTime() - minutes * 60 * 1000)
|
||||
return {
|
||||
start_time: startTime.toISOString(),
|
||||
end_time: endTime.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
const fetchData = async () => {
|
||||
if (!props.modelValue) return
|
||||
loading.value = true
|
||||
try {
|
||||
const params: OpsRequestDetailsParams = {
|
||||
...buildTimeParams(),
|
||||
page: page.value,
|
||||
page_size: pageSize.value,
|
||||
kind: props.preset.kind ?? 'all',
|
||||
sort: props.preset.sort ?? 'created_at_desc'
|
||||
}
|
||||
|
||||
const platform = (props.platform || '').trim()
|
||||
if (platform) params.platform = platform
|
||||
if (typeof props.groupId === 'number' && props.groupId > 0) params.group_id = props.groupId
|
||||
|
||||
if (typeof props.preset.min_duration_ms === 'number') params.min_duration_ms = props.preset.min_duration_ms
|
||||
if (typeof props.preset.max_duration_ms === 'number') params.max_duration_ms = props.preset.max_duration_ms
|
||||
|
||||
const res = await opsAPI.listRequestDetails(params)
|
||||
items.value = res.items || []
|
||||
total.value = res.total || 0
|
||||
} catch (e: any) {
|
||||
console.error('[OpsRequestDetailsModal] Failed to fetch request details', e)
|
||||
appStore.showError(e?.message || t('admin.ops.requestDetails.failedToLoad'))
|
||||
items.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.timeRange,
|
||||
props.platform,
|
||||
props.groupId,
|
||||
props.preset.kind,
|
||||
props.preset.sort,
|
||||
props.preset.min_duration_ms,
|
||||
props.preset.max_duration_ms
|
||||
],
|
||||
() => {
|
||||
if (!props.modelValue) return
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
)
|
||||
|
||||
function handlePageChange(next: number) {
|
||||
page.value = next
|
||||
fetchData()
|
||||
}
|
||||
|
||||
function handlePageSizeChange(next: number) {
|
||||
pageSize.value = next
|
||||
page.value = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
async function handleCopyRequestId(requestId: string) {
|
||||
const ok = await copyToClipboard(requestId, t('admin.ops.requestDetails.requestIdCopied'))
|
||||
if (ok) return
|
||||
// `useClipboard` already shows toast on failure; this keeps UX consistent with older ops modal.
|
||||
appStore.showWarning(t('admin.ops.requestDetails.copyFailed'))
|
||||
}
|
||||
|
||||
function openErrorDetail(errorId: number | null | undefined) {
|
||||
if (!errorId) return
|
||||
close()
|
||||
emit('openErrorDetail', errorId)
|
||||
}
|
||||
|
||||
const kindBadgeClass = (kind: string) => {
|
||||
if (kind === 'error') return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300'
|
||||
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="modelValue" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" @click="handleOverlayClick">
|
||||
<div class="relative max-h-[90vh] w-full max-w-5xl overflow-y-auto rounded-2xl bg-white shadow-2xl dark:bg-dark-800" @click.stop>
|
||||
<!-- Header -->
|
||||
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-gray-200 bg-white px-6 py-4 dark:border-dark-700 dark:bg-dark-800">
|
||||
<div>
|
||||
<div class="text-sm font-bold text-gray-900 dark:text-white">
|
||||
{{ props.preset.title || t('admin.ops.requestDetails.title') }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.requestDetails.rangeLabel', { range: rangeLabel }) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-bold text-gray-600 hover:bg-gray-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
||||
@click="fetchData"
|
||||
>
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-gray-100 p-2 text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-200 dark:hover:bg-dark-600"
|
||||
@click="close"
|
||||
:title="t('common.close')"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<svg class="h-8 w-8 animate-spin text-blue-500" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t('common.loading') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div v-else class="p-6">
|
||||
<div v-if="items.length === 0" class="rounded-xl border border-dashed border-gray-200 p-10 text-center dark:border-dark-700">
|
||||
<div class="text-sm font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.requestDetails.empty') }}</div>
|
||||
<div class="mt-1 text-xs text-gray-400">{{ t('admin.ops.requestDetails.emptyHint') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-hidden rounded-xl border border-gray-200 dark:border-dark-700">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dark-700">
|
||||
<thead class="bg-gray-50 dark:bg-dark-900">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.requestDetails.table.time') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.requestDetails.table.kind') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.requestDetails.table.platform') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.requestDetails.table.model') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.requestDetails.table.duration') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.requestDetails.table.status') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.requestDetails.table.requestId') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right text-[11px] font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.requestDetails.table.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-800">
|
||||
<tr v-for="(row, idx) in items" :key="idx" class="hover:bg-gray-50 dark:hover:bg-dark-700/50">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ formatDateTime(row.created_at) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<span class="rounded-full px-2 py-1 text-[10px] font-bold" :class="kindBadgeClass(row.kind)">
|
||||
{{ row.kind === 'error' ? t('admin.ops.requestDetails.kind.error') : t('admin.ops.requestDetails.kind.success') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-200">
|
||||
{{ (row.platform || 'unknown').toUpperCase() }}
|
||||
</td>
|
||||
<td class="max-w-[240px] truncate px-4 py-3 text-xs text-gray-600 dark:text-gray-300" :title="row.model || ''">
|
||||
{{ row.model || '-' }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ typeof row.duration_ms === 'number' ? `${row.duration_ms} ms` : '-' }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ row.status_code ?? '-' }}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div v-if="row.request_id" class="flex items-center gap-2">
|
||||
<span class="max-w-[220px] truncate font-mono text-[11px] text-gray-700 dark:text-gray-200" :title="row.request_id">
|
||||
{{ row.request_id }}
|
||||
</span>
|
||||
<button
|
||||
class="rounded-md bg-gray-100 px-2 py-1 text-[10px] font-bold text-gray-600 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
||||
@click="handleCopyRequestId(row.request_id)"
|
||||
>
|
||||
{{ t('admin.ops.requestDetails.copy') }}
|
||||
</button>
|
||||
</div>
|
||||
<span v-else class="text-xs text-gray-400">-</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right">
|
||||
<button
|
||||
v-if="row.kind === 'error' && row.error_id"
|
||||
class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-bold text-red-600 hover:bg-red-100 dark:bg-red-900/20 dark:text-red-300 dark:hover:bg-red-900/30"
|
||||
@click="openErrorDetail(row.error_id)"
|
||||
>
|
||||
{{ t('admin.ops.requestDetails.viewError') }}
|
||||
</button>
|
||||
<span v-else class="text-xs text-gray-400">-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:total="total"
|
||||
:page="page"
|
||||
:page-size="pageSize"
|
||||
@update:page="handlePageChange"
|
||||
@update:pageSize="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,439 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { opsAPI } from '@/api/admin/ops'
|
||||
import type { OpsAlertRuntimeSettings } from '../types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const alertSettings = ref<OpsAlertRuntimeSettings | null>(null)
|
||||
|
||||
const showAlertEditor = ref(false)
|
||||
const draftAlert = ref<OpsAlertRuntimeSettings | null>(null)
|
||||
|
||||
type ValidationResult = { valid: boolean; errors: string[] }
|
||||
|
||||
function normalizeSeverities(input: Array<string | null | undefined> | null | undefined): string[] {
|
||||
if (!input || input.length === 0) return []
|
||||
const allowed = new Set(['P0', 'P1', 'P2', 'P3'])
|
||||
const out: string[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const raw of input) {
|
||||
const s = String(raw || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
if (!s) continue
|
||||
if (!allowed.has(s)) continue
|
||||
if (seen.has(s)) continue
|
||||
seen.add(s)
|
||||
out.push(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function validateRuntimeSettings(settings: OpsAlertRuntimeSettings): ValidationResult {
|
||||
const errors: string[] = []
|
||||
|
||||
const evalSeconds = settings.evaluation_interval_seconds
|
||||
if (!Number.isFinite(evalSeconds) || evalSeconds < 1 || evalSeconds > 86400) {
|
||||
errors.push(t('admin.ops.runtime.validation.evalIntervalRange'))
|
||||
}
|
||||
|
||||
const lock = settings.distributed_lock
|
||||
if (lock?.enabled) {
|
||||
if (!lock.key || lock.key.trim().length < 3) {
|
||||
errors.push(t('admin.ops.runtime.validation.lockKeyRequired'))
|
||||
} else if (!lock.key.startsWith('ops:')) {
|
||||
errors.push(t('admin.ops.runtime.validation.lockKeyPrefix', { prefix: 'ops:' }))
|
||||
}
|
||||
if (!Number.isFinite(lock.ttl_seconds) || lock.ttl_seconds < 1 || lock.ttl_seconds > 86400) {
|
||||
errors.push(t('admin.ops.runtime.validation.lockTtlRange'))
|
||||
}
|
||||
}
|
||||
|
||||
// Silencing validation (alert-only)
|
||||
const silencing = settings.silencing
|
||||
if (silencing?.enabled) {
|
||||
const until = (silencing.global_until_rfc3339 || '').trim()
|
||||
if (until) {
|
||||
const parsed = Date.parse(until)
|
||||
if (!Number.isFinite(parsed)) errors.push(t('admin.ops.runtime.silencing.validation.timeFormat'))
|
||||
}
|
||||
|
||||
const entries = Array.isArray(silencing.entries) ? silencing.entries : []
|
||||
for (let idx = 0; idx < entries.length; idx++) {
|
||||
const entry = entries[idx]
|
||||
const untilEntry = (entry?.until_rfc3339 || '').trim()
|
||||
if (!untilEntry) {
|
||||
errors.push(t('admin.ops.runtime.silencing.entries.validation.untilRequired'))
|
||||
break
|
||||
}
|
||||
const parsedEntry = Date.parse(untilEntry)
|
||||
if (!Number.isFinite(parsedEntry)) {
|
||||
errors.push(t('admin.ops.runtime.silencing.entries.validation.untilFormat'))
|
||||
break
|
||||
}
|
||||
const ruleId = (entry as any)?.rule_id
|
||||
if (typeof ruleId === 'number' && (!Number.isFinite(ruleId) || ruleId <= 0)) {
|
||||
errors.push(t('admin.ops.runtime.silencing.entries.validation.ruleIdPositive'))
|
||||
break
|
||||
}
|
||||
if ((entry as any)?.severities) {
|
||||
const raw = (entry as any).severities
|
||||
const normalized = normalizeSeverities(Array.isArray(raw) ? raw : [raw])
|
||||
if (Array.isArray(raw) && raw.length > 0 && normalized.length === 0) {
|
||||
errors.push(t('admin.ops.runtime.silencing.entries.validation.severitiesFormat'))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
|
||||
const alertValidation = computed(() => {
|
||||
if (!draftAlert.value) return { valid: true, errors: [] as string[] }
|
||||
return validateRuntimeSettings(draftAlert.value)
|
||||
})
|
||||
|
||||
async function loadSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
alertSettings.value = await opsAPI.getAlertRuntimeSettings()
|
||||
} catch (err: any) {
|
||||
console.error('[OpsRuntimeSettingsCard] Failed to load runtime settings', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.runtime.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAlertEditor() {
|
||||
if (!alertSettings.value) return
|
||||
draftAlert.value = JSON.parse(JSON.stringify(alertSettings.value))
|
||||
|
||||
// Backwards-compat: ensure nested settings exist even if API payload is older.
|
||||
if (draftAlert.value) {
|
||||
if (!draftAlert.value.distributed_lock) {
|
||||
draftAlert.value.distributed_lock = { enabled: true, key: 'ops:alert:evaluator:leader', ttl_seconds: 30 }
|
||||
}
|
||||
if (!draftAlert.value.silencing) {
|
||||
draftAlert.value.silencing = { enabled: false, global_until_rfc3339: '', global_reason: '', entries: [] }
|
||||
}
|
||||
if (!Array.isArray(draftAlert.value.silencing.entries)) {
|
||||
draftAlert.value.silencing.entries = []
|
||||
}
|
||||
}
|
||||
|
||||
showAlertEditor.value = true
|
||||
}
|
||||
|
||||
function addSilenceEntry() {
|
||||
if (!draftAlert.value) return
|
||||
if (!draftAlert.value.silencing) {
|
||||
draftAlert.value.silencing = { enabled: true, global_until_rfc3339: '', global_reason: '', entries: [] }
|
||||
}
|
||||
if (!Array.isArray(draftAlert.value.silencing.entries)) {
|
||||
draftAlert.value.silencing.entries = []
|
||||
}
|
||||
draftAlert.value.silencing.entries.push({
|
||||
rule_id: undefined,
|
||||
severities: [],
|
||||
until_rfc3339: '',
|
||||
reason: ''
|
||||
})
|
||||
}
|
||||
|
||||
function removeSilenceEntry(index: number) {
|
||||
if (!draftAlert.value?.silencing?.entries) return
|
||||
draftAlert.value.silencing.entries.splice(index, 1)
|
||||
}
|
||||
|
||||
function updateSilenceEntryRuleId(index: number, raw: string) {
|
||||
const entries = draftAlert.value?.silencing?.entries
|
||||
if (!entries || !entries[index]) return
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) {
|
||||
delete (entries[index] as any).rule_id
|
||||
return
|
||||
}
|
||||
const n = Number.parseInt(trimmed, 10)
|
||||
;(entries[index] as any).rule_id = Number.isFinite(n) ? n : undefined
|
||||
}
|
||||
|
||||
function updateSilenceEntrySeverities(index: number, raw: string) {
|
||||
const entries = draftAlert.value?.silencing?.entries
|
||||
if (!entries || !entries[index]) return
|
||||
const parts = raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
;(entries[index] as any).severities = normalizeSeverities(parts)
|
||||
}
|
||||
|
||||
async function saveAlertSettings() {
|
||||
if (!draftAlert.value) return
|
||||
if (!alertValidation.value.valid) {
|
||||
appStore.showError(alertValidation.value.errors[0] || t('admin.ops.runtime.validation.invalid'))
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
alertSettings.value = await opsAPI.updateAlertRuntimeSettings(draftAlert.value)
|
||||
showAlertEditor.value = false
|
||||
appStore.showSuccess(t('admin.ops.runtime.saveSuccess'))
|
||||
} catch (err: any) {
|
||||
console.error('[OpsRuntimeSettingsCard] Failed to save alert runtime settings', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.runtime.saveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.description') }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-bold text-gray-700 transition-colors hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600"
|
||||
:disabled="loading"
|
||||
@click="loadSettings"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" :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>
|
||||
{{ t('common.refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!alertSettings" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
<span v-if="loading">{{ t('admin.ops.runtime.loading') }}</span>
|
||||
<span v-else>{{ t('admin.ops.runtime.noData') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.alertTitle') }}</h4>
|
||||
<button class="btn btn-sm btn-secondary" @click="openAlertEditor">{{ t('common.edit') }}</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
{{ t('admin.ops.runtime.evalIntervalSeconds') }}:
|
||||
<span class="ml-1 font-medium text-gray-900 dark:text-white">{{ alertSettings.evaluation_interval_seconds }}s</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="alertSettings.silencing?.enabled && alertSettings.silencing.global_until_rfc3339"
|
||||
class="text-xs text-gray-600 dark:text-gray-300 md:col-span-2"
|
||||
>
|
||||
{{ t('admin.ops.runtime.silencing.globalUntil') }}:
|
||||
<span class="ml-1 font-mono text-gray-900 dark:text-white">{{ alertSettings.silencing.global_until_rfc3339 }}</span>
|
||||
</div>
|
||||
|
||||
<details class="col-span-1 md:col-span-2">
|
||||
<summary class="cursor-pointer text-xs font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400">
|
||||
{{ t('admin.ops.runtime.showAdvancedDeveloperSettings') }}
|
||||
</summary>
|
||||
<div class="mt-2 grid grid-cols-1 gap-3 rounded-lg bg-gray-100 p-3 dark:bg-dark-800 md:grid-cols-2">
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.runtime.lockEnabled') }}:
|
||||
<span class="ml-1 font-mono text-gray-700 dark:text-gray-300">{{ alertSettings.distributed_lock.enabled }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.runtime.lockKey') }}:
|
||||
<span class="ml-1 font-mono text-gray-700 dark:text-gray-300">{{ alertSettings.distributed_lock.key }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.runtime.lockTTLSeconds') }}:
|
||||
<span class="ml-1 font-mono text-gray-700 dark:text-gray-300">{{ alertSettings.distributed_lock.ttl_seconds }}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseDialog :show="showAlertEditor" :title="t('admin.ops.runtime.alertTitle')" width="extra-wide" @close="showAlertEditor = false">
|
||||
<div v-if="draftAlert" class="space-y-4">
|
||||
<div
|
||||
v-if="!alertValidation.valid"
|
||||
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-200"
|
||||
>
|
||||
<div class="font-bold">{{ t('admin.ops.runtime.validation.title') }}</div>
|
||||
<ul class="mt-1 list-disc space-y-1 pl-4">
|
||||
<li v-for="msg in alertValidation.errors" :key="msg">{{ msg }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.evalIntervalSeconds') }}</div>
|
||||
<input
|
||||
v-model.number="draftAlert.evaluation_interval_seconds"
|
||||
type="number"
|
||||
min="1"
|
||||
max="86400"
|
||||
class="input"
|
||||
:aria-invalid="!alertValidation.valid"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ t('admin.ops.runtime.evalIntervalHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||
<div class="mb-2 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.silencing.title') }}</div>
|
||||
|
||||
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input v-model="draftAlert.silencing.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
|
||||
<span>{{ t('admin.ops.runtime.silencing.enabled') }}</span>
|
||||
</label>
|
||||
|
||||
<div v-if="draftAlert.silencing.enabled" class="mt-4 space-y-4">
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.globalUntil') }}</div>
|
||||
<input
|
||||
v-model="draftAlert.silencing.global_until_rfc3339"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.ops.runtime.silencing.untilPlaceholder')"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.silencing.untilHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.reason') }}</div>
|
||||
<input
|
||||
v-model="draftAlert.silencing.global_reason"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.ops.runtime.silencing.reasonPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-800">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div class="text-xs font-bold text-gray-900 dark:text-white">{{ t('admin.ops.runtime.silencing.entries.title') }}</div>
|
||||
<p class="text-[11px] text-gray-500 dark:text-gray-400">{{ t('admin.ops.runtime.silencing.entries.hint') }}</p>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-secondary" type="button" @click="addSilenceEntry">
|
||||
{{ t('admin.ops.runtime.silencing.entries.add') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!draftAlert.silencing.entries?.length" class="mt-3 rounded-lg bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-900 dark:text-gray-400">
|
||||
{{ t('admin.ops.runtime.silencing.entries.empty') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="mt-4 space-y-4">
|
||||
<div
|
||||
v-for="(entry, idx) in draftAlert.silencing.entries"
|
||||
:key="idx"
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-700 dark:bg-dark-900"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="text-xs font-bold text-gray-900 dark:text-white">
|
||||
{{ t('admin.ops.runtime.silencing.entries.entryTitle', { n: idx + 1 }) }}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-danger" type="button" @click="removeSilenceEntry(idx)">{{ t('common.delete') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.entries.ruleId') }}</div>
|
||||
<input
|
||||
:value="typeof (entry as any).rule_id === 'number' ? String((entry as any).rule_id) : ''"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.ops.runtime.silencing.entries.ruleIdPlaceholder')"
|
||||
@input="updateSilenceEntryRuleId(idx, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.entries.severities') }}</div>
|
||||
<input
|
||||
:value="Array.isArray((entry as any).severities) ? (entry as any).severities.join(', ') : ''"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.ops.runtime.silencing.entries.severitiesPlaceholder')"
|
||||
@input="updateSilenceEntrySeverities(idx, ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.entries.until') }}</div>
|
||||
<input
|
||||
v-model="(entry as any).until_rfc3339"
|
||||
type="text"
|
||||
class="input font-mono text-sm"
|
||||
:placeholder="t('admin.ops.runtime.silencing.untilPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-600 dark:text-gray-300">{{ t('admin.ops.runtime.silencing.entries.reason') }}</div>
|
||||
<input
|
||||
v-model="(entry as any).reason"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('admin.ops.runtime.silencing.reasonPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-dark-600 dark:bg-dark-800">
|
||||
<summary class="cursor-pointer text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.ops.runtime.advancedSettingsSummary') }}</summary>
|
||||
<div class="mt-3 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="inline-flex items-center gap-2 text-xs text-gray-700 dark:text-gray-300">
|
||||
<input v-model="draftAlert.distributed_lock.enabled" type="checkbox" class="h-4 w-4 rounded border-gray-300" />
|
||||
<span>{{ t('admin.ops.runtime.lockEnabled') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<div class="mb-1 text-xs font-medium text-gray-500">{{ t('admin.ops.runtime.lockKey') }}</div>
|
||||
<input v-model="draftAlert.distributed_lock.key" type="text" class="input text-xs font-mono" />
|
||||
<p v-if="draftAlert.distributed_lock.enabled" class="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.runtime.validation.lockKeyHint', { prefix: 'ops:' }) }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 text-xs font-medium text-gray-500">{{ t('admin.ops.runtime.lockTTLSeconds') }}</div>
|
||||
<input v-model.number="draftAlert.distributed_lock.ttl_seconds" type="number" min="1" max="86400" class="input text-xs font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button class="btn btn-secondary" @click="showAlertEditor = false">{{ t('common.cancel') }}</button>
|
||||
<button class="btn btn-primary" :disabled="saving || !alertValidation.valid" @click="saveAlertSettings">
|
||||
{{ saving ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Chart as ChartJS, CategoryScale, Filler, Legend, LineElement, LinearScale, PointElement, Title, Tooltip } from 'chart.js'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import type { ChartComponentRef } from 'vue-chartjs'
|
||||
import type { OpsThroughputGroupBreakdownItem, OpsThroughputPlatformBreakdownItem, OpsThroughputTrendPoint } from '@/api/admin/ops'
|
||||
import type { ChartState } from '../types'
|
||||
import { formatHistoryLabel, sumNumbers } from '../utils/opsFormatters'
|
||||
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
ChartJS.register(Title, Tooltip, Legend, LineElement, LinearScale, PointElement, CategoryScale, Filler)
|
||||
|
||||
interface Props {
|
||||
points: OpsThroughputTrendPoint[]
|
||||
loading: boolean
|
||||
timeRange: string
|
||||
byPlatform?: OpsThroughputPlatformBreakdownItem[]
|
||||
topGroups?: OpsThroughputGroupBreakdownItem[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { t } = useI18n()
|
||||
const emit = defineEmits<{
|
||||
(e: 'selectPlatform', platform: string): void
|
||||
(e: 'selectGroup', groupId: number): void
|
||||
(e: 'openDetails'): void
|
||||
}>()
|
||||
|
||||
const throughputChartRef = ref<ChartComponentRef | null>(null)
|
||||
watch(
|
||||
() => props.timeRange,
|
||||
() => {
|
||||
setTimeout(() => {
|
||||
const chart: any = throughputChartRef.value?.chart
|
||||
if (chart && typeof chart.resetZoom === 'function') {
|
||||
chart.resetZoom()
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
)
|
||||
|
||||
const isDarkMode = computed(() => document.documentElement.classList.contains('dark'))
|
||||
const colors = computed(() => ({
|
||||
blue: '#3b82f6',
|
||||
blueAlpha: '#3b82f620',
|
||||
green: '#10b981',
|
||||
greenAlpha: '#10b98120',
|
||||
grid: isDarkMode.value ? '#374151' : '#f3f4f6',
|
||||
text: isDarkMode.value ? '#9ca3af' : '#6b7280'
|
||||
}))
|
||||
|
||||
const totalRequests = computed(() => sumNumbers(props.points.map((p) => p.request_count)))
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!props.points.length || totalRequests.value <= 0) return null
|
||||
return {
|
||||
labels: props.points.map((p) => formatHistoryLabel(p.bucket_start, props.timeRange)),
|
||||
datasets: [
|
||||
{
|
||||
label: t('admin.ops.qps'),
|
||||
data: props.points.map((p) => p.qps ?? 0),
|
||||
borderColor: colors.value.blue,
|
||||
backgroundColor: colors.value.blueAlpha,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 10
|
||||
},
|
||||
{
|
||||
label: t('admin.ops.tpsK'),
|
||||
data: props.points.map((p) => (p.tps ?? 0) / 1000),
|
||||
borderColor: colors.value.green,
|
||||
backgroundColor: colors.value.greenAlpha,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 10,
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const state = computed<ChartState>(() => {
|
||||
if (chartData.value) return 'ready'
|
||||
if (props.loading) return 'loading'
|
||||
return 'empty'
|
||||
})
|
||||
|
||||
const options = computed(() => {
|
||||
const c = colors.value
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { intersect: false, mode: 'index' as const },
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
align: 'end' as const,
|
||||
labels: { color: c.text, usePointStyle: true, boxWidth: 6, font: { size: 10 } }
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: isDarkMode.value ? '#1f2937' : '#ffffff',
|
||||
titleColor: isDarkMode.value ? '#f3f4f6' : '#111827',
|
||||
bodyColor: isDarkMode.value ? '#d1d5db' : '#4b5563',
|
||||
borderColor: c.grid,
|
||||
borderWidth: 1,
|
||||
padding: 10,
|
||||
displayColors: true,
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
let label = context.dataset.label || ''
|
||||
if (label) label += ': '
|
||||
if (context.raw !== null) label += context.parsed.y.toFixed(1)
|
||||
return label
|
||||
}
|
||||
}
|
||||
},
|
||||
// Optional: if chartjs-plugin-zoom is installed, these options will enable zoom/pan.
|
||||
zoom: {
|
||||
pan: { enabled: true, mode: 'x' as const, modifierKey: 'ctrl' as const },
|
||||
zoom: { wheel: { enabled: true }, pinch: { enabled: true }, mode: 'x' as const }
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category' as const,
|
||||
grid: { display: false },
|
||||
ticks: {
|
||||
color: c.text,
|
||||
font: { size: 10 },
|
||||
maxTicksLimit: 8,
|
||||
autoSkip: true,
|
||||
autoSkipPadding: 10
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
position: 'left' as const,
|
||||
grid: { color: c.grid, borderDash: [4, 4] },
|
||||
ticks: { color: c.text, font: { size: 10 } }
|
||||
},
|
||||
y1: {
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
position: 'right' as const,
|
||||
grid: { display: false },
|
||||
ticks: { color: c.green, font: { size: 10 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function resetZoom() {
|
||||
const chart: any = throughputChartRef.value?.chart
|
||||
if (chart && typeof chart.resetZoom === 'function') chart.resetZoom()
|
||||
}
|
||||
|
||||
function downloadChart() {
|
||||
const chart: any = throughputChartRef.value?.chart
|
||||
if (!chart || typeof chart.toBase64Image !== 'function') return
|
||||
const url = chart.toBase64Image('image/png', 1)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `ops-throughput-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`
|
||||
a.click()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full flex-col rounded-3xl bg-white p-6 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700">
|
||||
<div class="mb-4 flex shrink-0 items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
|
||||
<svg class="h-4 w-4 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
{{ t('admin.ops.throughputTrend') }}
|
||||
<HelpTooltip :content="t('admin.ops.tooltips.throughputTrend')" />
|
||||
</h3>
|
||||
<div class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-blue-500"></span>{{ t('admin.ops.qps') }}</span>
|
||||
<span class="flex items-center gap-1"><span class="h-2 w-2 rounded-full bg-green-500"></span>{{ t('admin.ops.tpsK') }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 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 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
||||
:disabled="state !== 'ready'"
|
||||
:title="t('admin.ops.requestDetails.title')"
|
||||
@click="emit('openDetails')"
|
||||
>
|
||||
{{ t('admin.ops.requestDetails.details') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-2 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 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
||||
:disabled="state !== 'ready'"
|
||||
:title="t('admin.ops.charts.resetZoomHint')"
|
||||
@click="resetZoom"
|
||||
>
|
||||
{{ t('admin.ops.charts.resetZoom') }}
|
||||
</button>
|
||||
<button
|
||||
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 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
|
||||
:disabled="state !== 'ready'"
|
||||
:title="t('admin.ops.charts.downloadChartHint')"
|
||||
@click="downloadChart"
|
||||
>
|
||||
{{ t('admin.ops.charts.downloadChart') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drilldown chips (baseline interaction: click to set global filter) -->
|
||||
<div v-if="(props.topGroups?.length ?? 0) > 0" class="mb-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="g in props.topGroups"
|
||||
:key="g.group_id"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-[11px] font-semibold text-gray-700 hover:bg-gray-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-200 dark:hover:bg-dark-800"
|
||||
@click="emit('selectGroup', g.group_id)"
|
||||
>
|
||||
<span class="max-w-[180px] truncate">{{ g.group_name || `#${g.group_id}` }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">{{ formatNumber(g.request_count) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="(props.byPlatform?.length ?? 0) > 0" class="mb-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="p in props.byPlatform"
|
||||
:key="p.platform"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-[11px] font-semibold text-gray-700 hover:bg-gray-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-200 dark:hover:bg-dark-800"
|
||||
@click="emit('selectPlatform', p.platform)"
|
||||
>
|
||||
<span class="uppercase">{{ p.platform }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500">{{ formatNumber(p.request_count) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1">
|
||||
<Line v-if="state === 'ready' && chartData" ref="throughputChartRef" :data="chartData" :options="options" />
|
||||
<div v-else class="flex h-full items-center justify-center">
|
||||
<div v-if="state === 'loading'" class="animate-pulse text-sm text-gray-400">{{ t('common.loading') }}</div>
|
||||
<EmptyState v-else :title="t('common.noData')" :description="t('admin.ops.charts.emptyRequest')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/views/admin/ops/types.ts
Normal file
17
frontend/src/views/admin/ops/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Ops 前端视图层的共享类型(与后端 DTO 解耦)。
|
||||
|
||||
export type ChartState = 'loading' | 'empty' | 'ready'
|
||||
|
||||
// Re-export ops alert/settings types so view components can import from a single place
|
||||
// while keeping the API contract centralized in `@/api/admin/ops`.
|
||||
export type {
|
||||
AlertRule,
|
||||
AlertEvent,
|
||||
AlertSeverity,
|
||||
ThresholdMode,
|
||||
MetricType,
|
||||
Operator,
|
||||
EmailNotificationConfig,
|
||||
OpsDistributedLockSettings,
|
||||
OpsAlertRuntimeSettings
|
||||
} from '@/api/admin/ops'
|
||||
75
frontend/src/views/admin/ops/utils/opsFormatters.ts
Normal file
75
frontend/src/views/admin/ops/utils/opsFormatters.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Ops 页面共享的格式化/样式工具。
|
||||
*
|
||||
* 目标:尽量对齐 `docs/sub2api` 备份版本的视觉表现(需求一致部分保持一致),
|
||||
* 同时避免引入额外 UI 依赖。
|
||||
*/
|
||||
|
||||
import type { OpsSeverity } from '@/api/admin/ops'
|
||||
import { formatBytes } from '@/utils/format'
|
||||
|
||||
export function getSeverityClass(severity: OpsSeverity): string {
|
||||
const classes: Record<string, string> = {
|
||||
P0: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
|
||||
P1: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
P2: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
P3: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
}
|
||||
return classes[String(severity || '')] || classes.P3
|
||||
}
|
||||
|
||||
export function truncateMessage(msg: string, maxLength = 80): string {
|
||||
if (!msg) return ''
|
||||
return msg.length > maxLength ? msg.substring(0, maxLength) + '...' : msg
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间(短格式,和旧 Ops 页面一致)。
|
||||
* 输出: `MM-DD HH:mm:ss`
|
||||
*/
|
||||
export function formatDateTime(dateStr: string): string {
|
||||
const d = new Date(dateStr)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function sumNumbers(values: Array<number | null | undefined>): number {
|
||||
return values.reduce<number>((acc, v) => {
|
||||
const n = typeof v === 'number' && Number.isFinite(v) ? v : 0
|
||||
return acc + n
|
||||
}, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 time_range 为分钟数。
|
||||
* 支持:`5m/30m/1h/6h/24h`
|
||||
*/
|
||||
export function parseTimeRangeMinutes(range: string): number {
|
||||
const trimmed = (range || '').trim()
|
||||
if (!trimmed) return 60
|
||||
if (trimmed.endsWith('m')) {
|
||||
const v = Number.parseInt(trimmed.slice(0, -1), 10)
|
||||
return Number.isFinite(v) && v > 0 ? v : 60
|
||||
}
|
||||
if (trimmed.endsWith('h')) {
|
||||
const v = Number.parseInt(trimmed.slice(0, -1), 10)
|
||||
return Number.isFinite(v) && v > 0 ? v * 60 : 60
|
||||
}
|
||||
return 60
|
||||
}
|
||||
|
||||
export function formatHistoryLabel(date: string | undefined, timeRange: string): string {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
const minutes = parseTimeRangeMinutes(timeRange)
|
||||
if (minutes >= 24 * 60) {
|
||||
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function formatByteRate(bytes: number, windowMinutes: number): string {
|
||||
const seconds = Math.max(1, (windowMinutes || 1) * 60)
|
||||
return `${formatBytes(bytes / seconds, 1)}/s`
|
||||
}
|
||||
Reference in New Issue
Block a user