feat(前端UI): 实现运维监控前端界面

- 新增帮助提示组件(HelpTooltip.vue)
- 更新侧边栏添加 ops 监控菜单项
- 扩展设置视图集成 ops 配置面板
- 新增 ops 监控视图目录(dashboard, alerts, realtime, settings 等)
This commit is contained in:
IanShaw027
2026-01-09 21:00:04 +08:00
parent fc32b57798
commit 8ae75e7f6e
21 changed files with 5362 additions and 6 deletions

View 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>

View File

@@ -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>

View File

@@ -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'))

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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'

View 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`
}