mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-08 02:17:46 +08:00
2224 lines
85 KiB
Vue
2224 lines
85 KiB
Vue
<template>
|
||
<div class="space-y-5 px-4 pb-8 sm:px-6 lg:px-0">
|
||
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||
<div>
|
||
<h1 class="text-lg font-semibold">
|
||
运维总览
|
||
</h1>
|
||
<p class="text-xs text-muted-foreground">
|
||
统一查看流量、吞吐、延迟、错误、上游健康、缓存与审计风险
|
||
</p>
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<span class="text-xs text-muted-foreground">
|
||
更新 {{ lastUpdatedLabel }}
|
||
</span>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
class="h-8 w-8 shrink-0"
|
||
:class="autoRefresh ? 'text-primary' : ''"
|
||
:title="autoRefresh ? '点击关闭自动刷新' : '点击开启自动刷新'"
|
||
:aria-label="autoRefresh ? '关闭自动刷新' : '开启自动刷新'"
|
||
:aria-pressed="autoRefresh"
|
||
@click="toggleAutoRefresh"
|
||
>
|
||
<RefreshCw
|
||
class="h-3.5 w-3.5"
|
||
:class="autoRefresh || refreshing ? 'animate-spin' : ''"
|
||
/>
|
||
</Button>
|
||
<TimeRangePicker
|
||
v-model="timeRange"
|
||
:allow-hourly="true"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="loadWarning"
|
||
class="rounded-lg border border-yellow-300/70 bg-yellow-50/80 px-3 py-2 text-xs text-yellow-900 dark:border-yellow-900/60 dark:bg-yellow-950/30 dark:text-yellow-100"
|
||
>
|
||
{{ loadWarning }}
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-8">
|
||
<Card
|
||
v-for="card in kpiCards"
|
||
:key="card.title"
|
||
class="min-h-[118px] p-4"
|
||
>
|
||
<div class="flex items-start justify-between gap-3">
|
||
<div class="min-w-0">
|
||
<p class="truncate text-xs text-muted-foreground">
|
||
{{ card.title }}
|
||
</p>
|
||
<div
|
||
class="mt-2 truncate text-2xl font-semibold tabular-nums"
|
||
:class="card.valueClass"
|
||
>
|
||
{{ card.value }}
|
||
</div>
|
||
</div>
|
||
<div class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-muted/30">
|
||
<component
|
||
:is="card.icon"
|
||
class="h-4 w-4"
|
||
:class="card.iconClass"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p class="mt-3 line-clamp-2 text-xs text-muted-foreground">
|
||
{{ card.hint }}
|
||
</p>
|
||
</Card>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||
<Card class="overflow-hidden xl:col-span-2">
|
||
<div class="border-b border-border/70 bg-muted/20 px-4 py-3">
|
||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<h2 class="text-sm font-semibold">
|
||
流量与吞吐
|
||
</h2>
|
||
<p class="text-xs text-muted-foreground">
|
||
请求、Token 与费用趋势,按当前时间窗口聚合
|
||
</p>
|
||
</div>
|
||
<Badge variant="outline">
|
||
{{ timeRange.granularity || 'day' }}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
<div class="p-4">
|
||
<div
|
||
v-if="trendLoading"
|
||
class="py-12"
|
||
>
|
||
<LoadingState message="加载流量趋势中" />
|
||
</div>
|
||
<div
|
||
v-else
|
||
class="h-[302px]"
|
||
>
|
||
<LineChart
|
||
:data="trafficChartData"
|
||
:options="trafficChartOptions"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card class="overflow-hidden">
|
||
<div class="border-b border-border/70 bg-muted/20 px-4 py-3">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<div>
|
||
<h2 class="text-sm font-semibold">
|
||
实时并发
|
||
</h2>
|
||
<p class="text-xs text-muted-foreground">
|
||
网关、全局锁与代理通道
|
||
</p>
|
||
</div>
|
||
<Badge :variant="distributedGateVariant">
|
||
{{ distributedGateText }}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
<div class="space-y-4 p-4">
|
||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||
<MetricCell
|
||
label="本机处理中"
|
||
:value="formatMetricNumber(gatewayMetrics?.local.inFlight)"
|
||
/>
|
||
<MetricCell
|
||
label="本机可接入"
|
||
:value="formatMetricNumber(gatewayMetrics?.local.availablePermits)"
|
||
/>
|
||
<MetricCell
|
||
label="全局处理中"
|
||
:value="formatMetricNumber(gatewayMetrics?.distributed.inFlight)"
|
||
/>
|
||
<MetricCell
|
||
label="全局可接入"
|
||
:value="formatMetricNumber(gatewayMetrics?.distributed.availablePermits)"
|
||
/>
|
||
<MetricCell
|
||
label="代理活跃流"
|
||
:value="formatMetricNumber(currentActiveStreams)"
|
||
/>
|
||
<MetricCell
|
||
label="代理连接"
|
||
:value="formatMetricNumber(currentProxyConnections)"
|
||
/>
|
||
</div>
|
||
<div class="rounded-lg border border-border/60 bg-background/45 px-3 py-3">
|
||
<div class="flex items-center justify-between gap-3 text-xs">
|
||
<span class="text-muted-foreground">队列利用率</span>
|
||
<span class="font-medium tabular-nums">{{ tunnelQueueUtilizationText }}</span>
|
||
</div>
|
||
<div class="mt-2 h-2 overflow-hidden rounded-full bg-muted">
|
||
<div
|
||
class="h-full rounded-full bg-primary"
|
||
:style="{ width: tunnelQueueUtilizationWidth }"
|
||
/>
|
||
</div>
|
||
<div class="mt-3 grid grid-cols-2 gap-2 text-xs text-muted-foreground">
|
||
<span>拒绝 {{ formatMetricNumber(tunnelQueueRejectedTotal) }}</span>
|
||
<span>选择压力 {{ formatMetricNumber(tunnelSelectionPressureTotal) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-4 2xl:grid-cols-5">
|
||
<Card class="overflow-hidden">
|
||
<div class="border-b border-border/70 bg-muted/20 px-4 py-3">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<div>
|
||
<h2 class="text-sm font-semibold">
|
||
Admission
|
||
</h2>
|
||
<p class="text-xs text-muted-foreground">
|
||
本机、分布式、候选与上游执行 Gate
|
||
</p>
|
||
</div>
|
||
<Badge :variant="admissionStatusVariant">
|
||
{{ admissionStatusText }}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
<div class="space-y-4 p-4">
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<MetricCell
|
||
label="本机 Gate"
|
||
:value="localGateUtilizationText"
|
||
:value-class="capacityPercentClass(localGateUtilization)"
|
||
/>
|
||
<MetricCell
|
||
label="分布式 Gate"
|
||
:value="distributedGateUtilizationText"
|
||
:value-class="capacityPercentClass(distributedGateUtilization)"
|
||
/>
|
||
<MetricCell
|
||
label="候选规划"
|
||
:value="candidatePlanningGateUtilizationText"
|
||
:value-class="capacityPercentClass(candidatePlanningGateUtilization)"
|
||
/>
|
||
<MetricCell
|
||
label="上游执行"
|
||
:value="upstreamExecutionGateUtilizationText"
|
||
:value-class="capacityPercentClass(upstreamExecutionGateUtilization)"
|
||
/>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-2 text-xs text-muted-foreground">
|
||
<span>本机拒绝 {{ formatMetricNumber(gatewayMetrics?.local.rejectedTotal) }}</span>
|
||
<span>上游拒绝 {{ formatMetricNumber(gatewayMetrics?.upstreamExecution.rejectedTotal) }}</span>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card class="overflow-hidden">
|
||
<div class="border-b border-border/70 bg-muted/20 px-4 py-3">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<div>
|
||
<h2 class="text-sm font-semibold">
|
||
DB Pool
|
||
</h2>
|
||
<p class="text-xs text-muted-foreground">
|
||
前台路径共享连接预算
|
||
</p>
|
||
</div>
|
||
<Badge :variant="databasePoolStatusVariant">
|
||
{{ databasePoolStatusText }}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
<div class="space-y-4 p-4">
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<MetricCell
|
||
label="使用率"
|
||
:value="databasePoolUsageText"
|
||
:value-class="capacityPercentClass(databasePoolUsagePercent)"
|
||
/>
|
||
<MetricCell
|
||
label="占用 / 上限"
|
||
:value="databasePoolCheckedOutText"
|
||
/>
|
||
<MetricCell
|
||
label="空闲连接"
|
||
:value="formatMetricNumber(gatewayMetrics?.databasePool.idle)"
|
||
/>
|
||
<MetricCell
|
||
label="预留空闲"
|
||
:value="formatMetricNumber(gatewayMetrics?.databasePool.idleReserve)"
|
||
/>
|
||
<MetricCell
|
||
label="PG 等待"
|
||
:value="postgresWaitingText"
|
||
:value-class="counterRiskClass(gatewayMetrics?.postgres.waitingConnections)"
|
||
/>
|
||
<MetricCell
|
||
label="PG 锁等待"
|
||
:value="formatMetricNumber(gatewayMetrics?.postgres.lockWaitingConnections)"
|
||
:value-class="counterRiskClass(gatewayMetrics?.postgres.lockWaitingConnections)"
|
||
/>
|
||
<MetricCell
|
||
label="最长查询"
|
||
:value="postgresOldestActiveQueryText"
|
||
:value-class="latencyToneClass(gatewayMetrics?.postgres.oldestActiveQueryAgeMs)"
|
||
/>
|
||
<MetricCell
|
||
label="最长事务"
|
||
:value="postgresOldestTransactionText"
|
||
:value-class="latencyToneClass(gatewayMetrics?.postgres.oldestTransactionAgeMs)"
|
||
/>
|
||
<MetricCell
|
||
label="PG 命中"
|
||
:value="postgresCacheHitText"
|
||
:value-class="postgresCacheHitClass"
|
||
/>
|
||
<MetricCell
|
||
label="临时写"
|
||
:value="formatBytes(gatewayMetrics?.postgres.tempBytesTotal)"
|
||
/>
|
||
<MetricCell
|
||
label="WAL"
|
||
:value="postgresWalText"
|
||
:value-class="postgresWalClass"
|
||
/>
|
||
<MetricCell
|
||
label="Checkpoint"
|
||
:value="postgresCheckpointText"
|
||
:value-class="postgresCheckpointClass"
|
||
/>
|
||
<MetricCell
|
||
label="Top SQL"
|
||
:value="postgresTopStatementText"
|
||
:value-class="postgresStatementClass"
|
||
/>
|
||
<MetricCell
|
||
label="PG 回滚"
|
||
:value="formatMetricNumber(gatewayMetrics?.postgres.xactRollbackTotal)"
|
||
:value-class="counterRiskClass(gatewayMetrics?.postgres.xactRollbackTotal)"
|
||
/>
|
||
</div>
|
||
<div class="text-xs text-muted-foreground">
|
||
Driver {{ gatewayMetrics?.databasePool.driver || '-' }} · Pool size {{ formatMetricNumber(gatewayMetrics?.databasePool.size) }} · PG active {{ formatMetricNumber(gatewayMetrics?.postgres.activeConnections) }} · idle tx {{ formatMetricNumber(gatewayMetrics?.postgres.idleInTransactionConnections) }} · deadlocks {{ formatMetricNumber(gatewayMetrics?.postgres.deadlocksTotal) }} · top calls {{ formatMetricNumber(gatewayMetrics?.postgres.statementTopCallsTotal) }}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card class="overflow-hidden">
|
||
<div class="border-b border-border/70 bg-muted/20 px-4 py-3">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<div>
|
||
<h2 class="text-sm font-semibold">
|
||
Gateway 进程
|
||
</h2>
|
||
<p class="text-xs text-muted-foreground">
|
||
CPU、RSS、FD 与 TCP 连接边界
|
||
</p>
|
||
</div>
|
||
<Badge :variant="gatewayProcessStatusVariant">
|
||
{{ gatewayProcessStatusText }}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
<div class="space-y-4 p-4">
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<MetricCell
|
||
label="进程 CPU"
|
||
:value="formatBasisPointsPercent(gatewayMetrics?.process.processCpuUsageBasisPoints)"
|
||
:value-class="resourceToneClass(gatewayProcessCpuPercent, 70, 90)"
|
||
/>
|
||
<MetricCell
|
||
label="RSS"
|
||
:value="formatBytes(gatewayMetrics?.process.processMemoryBytes)"
|
||
:value-class="resourceToneClass(gatewayProcessMemoryPercent, 70, 90)"
|
||
/>
|
||
<MetricCell
|
||
label="Heap allocated"
|
||
:value="formatBytes(gatewayMetrics?.allocator.allocatedBytes)"
|
||
/>
|
||
<MetricCell
|
||
label="Heap resident"
|
||
:value="formatBytes(gatewayMetrics?.allocator.residentBytes)"
|
||
/>
|
||
<MetricCell
|
||
label="Heap active"
|
||
:value="formatBasisPointsPercent(gatewayAllocatorActivePercentBasisPoints)"
|
||
:value-class="resourceToneClass(gatewayAllocatorActivePercent, 125, 200)"
|
||
/>
|
||
<MetricCell
|
||
label="线程"
|
||
:value="formatMetricNumber(gatewayMetrics?.process.processThreads)"
|
||
/>
|
||
<MetricCell
|
||
label="后台任务"
|
||
:value="gatewayBackgroundTaskText"
|
||
/>
|
||
<MetricCell
|
||
label="Tokio 任务"
|
||
:value="formatMetricNumber(gatewayMetrics?.tokioRuntime.aliveTasks)"
|
||
/>
|
||
<MetricCell
|
||
label="Runtime 队列"
|
||
:value="formatMetricNumber(gatewayMetrics?.tokioRuntime.globalQueueDepth)"
|
||
/>
|
||
<MetricCell
|
||
label="任务退出"
|
||
:value="formatMetricNumber(gatewayBackgroundTaskUnexpectedExits)"
|
||
:value-class="counterRiskClass(gatewayBackgroundTaskUnexpectedExits)"
|
||
/>
|
||
<MetricCell
|
||
label="FD 使用"
|
||
:value="gatewayProcessFdText"
|
||
:value-class="resourceToneClass(gatewayProcessFdPercent, 60, 70)"
|
||
/>
|
||
<MetricCell
|
||
label="Socket FD"
|
||
:value="gatewayProcessSocketFdText"
|
||
/>
|
||
<MetricCell
|
||
label="TCP 连接"
|
||
:value="gatewayProcessTcpText"
|
||
/>
|
||
<MetricCell
|
||
label="CLOSE_WAIT"
|
||
:value="formatMetricNumber(gatewayMetrics?.process.processTcpCloseWaitConnections)"
|
||
:value-class="counterRiskClass(gatewayMetrics?.process.processTcpCloseWaitConnections)"
|
||
/>
|
||
<MetricCell
|
||
label="系统内存"
|
||
:value="formatBasisPointsPercent(gatewayMetrics?.process.systemMemoryUsageBasisPoints)"
|
||
:value-class="resourceToneClass(gatewaySystemMemoryPercent, 75, 90)"
|
||
/>
|
||
<MetricCell
|
||
label="网卡流量"
|
||
:value="gatewayNetworkTrafficText"
|
||
/>
|
||
</div>
|
||
<div class="text-xs text-muted-foreground">
|
||
运行 {{ gatewayProcessUptimeText }} · 虚拟内存 {{ formatBytes(gatewayMetrics?.process.processVirtualMemoryBytes) }} · TCP {{ gatewayTcpStateText }} · allocator {{ gatewayAllocatorStatusText }} · supervisor {{ formatMetricNumber(gatewayMetrics?.backgroundTasks.supervisedTotal) }} · Tokio workers {{ formatMetricNumber(gatewayMetrics?.tokioRuntime.workers) }}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card class="overflow-hidden">
|
||
<div class="border-b border-border/70 bg-muted/20 px-4 py-3">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<div>
|
||
<h2 class="text-sm font-semibold">
|
||
Usage 与队列
|
||
</h2>
|
||
<p class="text-xs text-muted-foreground">
|
||
账务写入和 request candidate 削峰
|
||
</p>
|
||
</div>
|
||
<Badge :variant="queueStatusVariant">
|
||
{{ queueStatusText }}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
<div class="space-y-4 p-4">
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<MetricCell
|
||
label="Usage Worker"
|
||
:value="usageWorkerText"
|
||
/>
|
||
<MetricCell
|
||
label="Usage ACK"
|
||
:value="formatMetricNumber(gatewayMetrics?.usageRuntime.workerAckedEntriesTotal)"
|
||
/>
|
||
<MetricCell
|
||
label="Usage Lag"
|
||
:value="formatMetricNumber(gatewayMetrics?.usageQueue.groupLag)"
|
||
:value-class="counterRiskClass(gatewayMetrics?.usageQueue.groupLag)"
|
||
/>
|
||
<MetricCell
|
||
label="Usage DLQ"
|
||
:value="formatMetricNumber(gatewayMetrics?.usageQueue.dlqLength)"
|
||
:value-class="counterRiskClass(gatewayMetrics?.usageQueue.dlqLength)"
|
||
/>
|
||
<MetricCell
|
||
label="Outbox 待处理"
|
||
:value="formatMetricNumber(gatewayMetrics?.usageCounter.pendingRows)"
|
||
:value-class="counterRiskClass(gatewayMetrics?.usageCounter.pendingRows)"
|
||
/>
|
||
<MetricCell
|
||
label="Outbox 已刷"
|
||
:value="formatMetricNumber(gatewayMetrics?.usageCounter.flushRowsClaimedTotal)"
|
||
/>
|
||
<MetricCell
|
||
label="最老待处理"
|
||
:value="usageCounterOldestPendingAgeText"
|
||
:value-class="usageCounterOldestPendingAgeClass"
|
||
/>
|
||
<MetricCell
|
||
label="Outbox 失败"
|
||
:value="formatMetricNumber(usageCounterOutboxFailures)"
|
||
:value-class="counterRiskClass(usageCounterOutboxFailures)"
|
||
/>
|
||
<MetricCell
|
||
label="Terminal 失败"
|
||
:value="formatMetricNumber(gatewayMetrics?.usageRuntime.terminalEnqueueFailedTotal)"
|
||
:value-class="counterRiskClass(gatewayMetrics?.usageRuntime.terminalEnqueueFailedTotal)"
|
||
/>
|
||
<MetricCell
|
||
label="Worker 异常"
|
||
:value="formatMetricNumber(usageRuntimeWorkerFaults)"
|
||
:value-class="counterRiskClass(usageRuntimeWorkerFaults)"
|
||
/>
|
||
<MetricCell
|
||
label="Candidate 水位"
|
||
:value="candidateQueueUtilizationText"
|
||
:value-class="capacityPercentClass(candidateQueueUtilization)"
|
||
/>
|
||
<MetricCell
|
||
label="Candidate 待刷"
|
||
:value="formatMetricNumber(gatewayMetrics?.requestCandidateQueue.pendingDepth)"
|
||
/>
|
||
<MetricCell
|
||
label="Candidate 已刷"
|
||
:value="formatMetricNumber(gatewayMetrics?.requestCandidateQueue.flushedTotal)"
|
||
/>
|
||
<MetricCell
|
||
label="Candidate 合并"
|
||
:value="formatMetricNumber(gatewayMetrics?.requestCandidateQueue.compactedTotal)"
|
||
/>
|
||
</div>
|
||
<div class="rounded-lg border border-border/60 bg-background/45 px-3 py-3">
|
||
<div class="flex items-center justify-between gap-3 text-xs">
|
||
<span class="text-muted-foreground">Candidate depth</span>
|
||
<span class="font-medium tabular-nums">{{ candidateQueueDepthText }}</span>
|
||
</div>
|
||
<div class="mt-2 h-2 overflow-hidden rounded-full bg-muted">
|
||
<div
|
||
class="h-full rounded-full bg-primary"
|
||
:style="{ width: candidateQueueUtilizationWidth }"
|
||
/>
|
||
</div>
|
||
<div class="mt-3 grid grid-cols-2 gap-2 text-xs text-muted-foreground">
|
||
<span>Outbox processed {{ formatMetricNumber(gatewayMetrics?.usageCounter.processedRows) }}</span>
|
||
<span>Outbox health {{ usageCounterHealthText }}</span>
|
||
<span>Flush batches {{ formatMetricNumber(gatewayMetrics?.usageCounter.flushBatchesTotal) }}</span>
|
||
<span>Flush targets {{ formatMetricNumber(gatewayMetrics?.usageCounter.flushTargetsTotal) }}</span>
|
||
<span>Worker read {{ formatMetricNumber(gatewayMetrics?.usageRuntime.workerReadEntriesTotal) }}</span>
|
||
<span>Worker reclaim {{ formatMetricNumber(gatewayMetrics?.usageRuntime.workerReclaimedEntriesTotal) }}</span>
|
||
<span>Usage pending {{ formatMetricNumber(gatewayMetrics?.usageQueue.groupPending) }}</span>
|
||
<span>Pending idle {{ usageQueueOldestPendingIdleText }}</span>
|
||
<span>Drop {{ formatMetricNumber(gatewayMetrics?.requestCandidateQueue.droppedTotal) }}</span>
|
||
<span>Fallback {{ formatMetricNumber(gatewayMetrics?.requestCandidateQueue.syncFallbackTotal) }}</span>
|
||
<span>Flush batches {{ formatMetricNumber(gatewayMetrics?.requestCandidateQueue.flushBatchesTotal) }}</span>
|
||
<span>SQL ops {{ formatMetricNumber(gatewayMetrics?.requestCandidateQueue.flushSqlOpsTotal) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card class="overflow-hidden">
|
||
<div class="border-b border-border/70 bg-muted/20 px-4 py-3">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<div>
|
||
<h2 class="text-sm font-semibold">
|
||
Upstream Target
|
||
</h2>
|
||
<p class="text-xs text-muted-foreground">
|
||
单 target 饱和隔离和选择压力
|
||
</p>
|
||
</div>
|
||
<Badge :variant="upstreamTargetStatusVariant">
|
||
{{ upstreamTargetStatusText }}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
<div class="space-y-4 p-4">
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<MetricCell
|
||
label="活跃 Target"
|
||
:value="formatMetricNumber(gatewayMetrics?.upstreamTargets.activeTargets)"
|
||
/>
|
||
<MetricCell
|
||
label="单 Target 上限"
|
||
:value="formatMetricNumber(gatewayMetrics?.upstreamTargets.limit)"
|
||
/>
|
||
<MetricCell
|
||
label="饱和累计"
|
||
:value="formatMetricNumber(gatewayMetrics?.upstreamTargets.saturatedTotal)"
|
||
:value-class="counterRiskClass(gatewayMetrics?.upstreamTargets.saturatedTotal)"
|
||
/>
|
||
<MetricCell
|
||
label="拒绝累计"
|
||
:value="formatMetricNumber(gatewayMetrics?.upstreamTargets.rejectedTotal)"
|
||
:value-class="counterRiskClass(gatewayMetrics?.upstreamTargets.rejectedTotal)"
|
||
/>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<div
|
||
v-for="target in upstreamTargetRows"
|
||
:key="target.target"
|
||
class="rounded-lg border border-border/50 bg-background/45 px-3 py-2 text-xs"
|
||
>
|
||
<div class="flex items-center justify-between gap-3">
|
||
<span class="min-w-0 truncate font-medium">{{ target.target }}</span>
|
||
<span class="shrink-0 tabular-nums">{{ formatMetricNumber(target.inFlight) }} in-flight</span>
|
||
</div>
|
||
<div class="mt-1 flex items-center justify-between gap-3 text-muted-foreground">
|
||
<span>available {{ formatMetricNumber(target.availablePermits) }}</span>
|
||
<span>saturated {{ formatMetricNumber(target.saturatedTotal) }}</span>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-if="upstreamTargetRows.length === 0"
|
||
class="rounded-lg border border-dashed border-border/60 px-3 py-6 text-center text-xs text-muted-foreground"
|
||
>
|
||
暂无 target 样本
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card class="overflow-hidden">
|
||
<div class="border-b border-border/70 bg-muted/20 px-4 py-3">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<div>
|
||
<h2 class="text-sm font-semibold">
|
||
关键路径 Stage
|
||
</h2>
|
||
<p class="text-xs text-muted-foreground">
|
||
入口排队、候选规划、上游 Gate 和流式总耗时
|
||
</p>
|
||
</div>
|
||
<Badge variant="outline">
|
||
Gateway latency
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Stage</TableHead>
|
||
<TableHead class="text-right">
|
||
样本
|
||
</TableHead>
|
||
<TableHead class="text-right">
|
||
平均
|
||
</TableHead>
|
||
<TableHead class="text-right">
|
||
最大
|
||
</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
<TableRow
|
||
v-for="stage in stageLatencyRows"
|
||
:key="stage.stage"
|
||
>
|
||
<TableCell>
|
||
<div class="text-sm font-medium">
|
||
{{ stage.label }}
|
||
</div>
|
||
<div class="text-xs text-muted-foreground">
|
||
{{ stage.stage }}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell class="text-right tabular-nums">
|
||
{{ formatMetricNumber(stage.count) }}
|
||
</TableCell>
|
||
<TableCell
|
||
class="text-right tabular-nums"
|
||
:class="latencyToneClass(stage.avgMs)"
|
||
>
|
||
{{ formatMs(stage.avgMs) }}
|
||
</TableCell>
|
||
<TableCell
|
||
class="text-right tabular-nums"
|
||
:class="latencyToneClass(stage.maxMs)"
|
||
>
|
||
{{ formatMs(stage.maxMs) }}
|
||
</TableCell>
|
||
</TableRow>
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
</Card>
|
||
|
||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||
<Card class="overflow-hidden xl:col-span-2">
|
||
<div class="border-b border-border/70 bg-muted/20 px-4 py-3">
|
||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<h2 class="text-sm font-semibold">
|
||
延迟与首字
|
||
</h2>
|
||
<p class="text-xs text-muted-foreground">
|
||
P50/P90/P99 请求耗时与 TTFT 趋势
|
||
</p>
|
||
</div>
|
||
<RouterLink
|
||
to="/admin/performance-analysis"
|
||
class="text-xs font-medium text-primary hover:underline"
|
||
>
|
||
查看性能分析
|
||
</RouterLink>
|
||
</div>
|
||
</div>
|
||
<div class="p-4">
|
||
<div
|
||
v-if="percentileLoading"
|
||
class="py-12"
|
||
>
|
||
<LoadingState message="加载延迟百分位中" />
|
||
</div>
|
||
<div
|
||
v-else
|
||
class="h-[288px]"
|
||
>
|
||
<LineChart
|
||
:data="latencyChartData"
|
||
:options="latencyChartOptions"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card class="overflow-hidden">
|
||
<div class="border-b border-border/70 bg-muted/20 px-4 py-3">
|
||
<h2 class="text-sm font-semibold">
|
||
SLA 与错误
|
||
</h2>
|
||
<p class="text-xs text-muted-foreground">
|
||
请求错误、上游错误与熔断风险
|
||
</p>
|
||
</div>
|
||
<div class="space-y-4 p-4">
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<MetricCell
|
||
label="成功率"
|
||
:value="formatPercent(providerPerformance?.summary.success_rate)"
|
||
:value-class="slaValueClass"
|
||
/>
|
||
<MetricCell
|
||
label="错误率"
|
||
:value="formatErrorRate(providerPerformance?.summary.success_rate)"
|
||
:value-class="errorRateValueClass"
|
||
/>
|
||
<MetricCell
|
||
label="已分类错误"
|
||
:value="formatMetricNumber(classifiedErrorCount)"
|
||
/>
|
||
<MetricCell
|
||
label="熔断打开"
|
||
:value="formatMetricNumber(resilienceStatus?.error_statistics.open_circuit_breakers)"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="mb-2 flex items-center justify-between gap-3">
|
||
<span class="text-xs font-medium text-muted-foreground">错误分类</span>
|
||
<RouterLink
|
||
to="/admin/audit-logs"
|
||
class="text-xs font-medium text-primary hover:underline"
|
||
>
|
||
审计
|
||
</RouterLink>
|
||
</div>
|
||
<div class="h-[154px]">
|
||
<DoughnutChart
|
||
:data="errorDistributionChartData"
|
||
:options="errorDistributionOptions"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||
<Card class="overflow-hidden xl:col-span-2">
|
||
<div class="border-b border-border/70 bg-muted/20 px-4 py-3">
|
||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<h2 class="text-sm font-semibold">
|
||
上游健康与吞吐
|
||
</h2>
|
||
<p class="text-xs text-muted-foreground">
|
||
Provider 维度的 TPS、TTFT、慢请求与错误样本
|
||
</p>
|
||
</div>
|
||
<RouterLink
|
||
to="/admin/health-monitor"
|
||
class="text-xs font-medium text-primary hover:underline"
|
||
>
|
||
健康监控
|
||
</RouterLink>
|
||
</div>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>上游</TableHead>
|
||
<TableHead class="text-right">
|
||
请求
|
||
</TableHead>
|
||
<TableHead class="text-right">
|
||
成功率
|
||
</TableHead>
|
||
<TableHead class="text-right">
|
||
TPS
|
||
</TableHead>
|
||
<TableHead class="text-right">
|
||
TTFT
|
||
</TableHead>
|
||
<TableHead class="text-right">
|
||
P99
|
||
</TableHead>
|
||
<TableHead class="text-right">
|
||
慢请求
|
||
</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
<TableRow
|
||
v-for="provider in providerRows"
|
||
:key="provider.provider_id"
|
||
>
|
||
<TableCell>
|
||
<div class="max-w-[220px] truncate text-sm font-medium">
|
||
{{ provider.provider }}
|
||
</div>
|
||
</TableCell>
|
||
<TableCell class="text-right tabular-nums">
|
||
{{ formatMetricNumber(provider.request_count) }}
|
||
</TableCell>
|
||
<TableCell
|
||
class="text-right tabular-nums"
|
||
:class="successRateClass(provider.success_rate)"
|
||
>
|
||
{{ formatPercent(provider.success_rate) }}
|
||
</TableCell>
|
||
<TableCell class="text-right tabular-nums">
|
||
{{ formatTps(provider.avg_output_tps) }}
|
||
</TableCell>
|
||
<TableCell class="text-right tabular-nums">
|
||
{{ formatMs(provider.avg_first_byte_time_ms) }}
|
||
</TableCell>
|
||
<TableCell class="text-right tabular-nums">
|
||
{{ formatMs(provider.p99_response_time_ms) }}
|
||
</TableCell>
|
||
<TableCell class="text-right tabular-nums">
|
||
{{ formatMetricNumber(provider.slow_request_count) }}
|
||
</TableCell>
|
||
</TableRow>
|
||
<TableRow v-if="providerRows.length === 0">
|
||
<TableCell
|
||
colspan="7"
|
||
class="py-8 text-center text-sm text-muted-foreground"
|
||
>
|
||
当前时间窗口暂无上游样本
|
||
</TableCell>
|
||
</TableRow>
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card class="overflow-hidden">
|
||
<div class="border-b border-border/70 bg-muted/20 px-4 py-3">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<div>
|
||
<h2 class="text-sm font-semibold">
|
||
资源、数据流与缓存
|
||
</h2>
|
||
<p class="text-xs text-muted-foreground">
|
||
代理节点资源、WebSocket 流量、Redis Key 分类与缓存命中
|
||
</p>
|
||
</div>
|
||
<RouterLink
|
||
to="/admin/cache-monitoring"
|
||
class="text-xs font-medium text-primary hover:underline"
|
||
>
|
||
缓存页
|
||
</RouterLink>
|
||
</div>
|
||
</div>
|
||
<div class="space-y-4 p-4">
|
||
<div class="grid grid-cols-2 gap-3">
|
||
<MetricCell
|
||
label="CPU"
|
||
:value="formatPercentResource(resourceSnapshot?.avgCpuPercent)"
|
||
:value-class="resourceToneClass(resourceSnapshot?.avgCpuPercent, 70, 90)"
|
||
/>
|
||
<MetricCell
|
||
label="内存"
|
||
:value="formatPercentResource(resourceSnapshot?.avgMemoryPercent)"
|
||
:value-class="resourceToneClass(resourceSnapshot?.avgMemoryPercent, 75, 90)"
|
||
/>
|
||
<MetricCell
|
||
label="WS 入站"
|
||
:value="formatBytes(resourceSnapshot?.wsInBytes)"
|
||
/>
|
||
<MetricCell
|
||
label="WS 出站"
|
||
:value="formatBytes(resourceSnapshot?.wsOutBytes)"
|
||
/>
|
||
<MetricCell
|
||
label="网关 TCP"
|
||
:value="gatewayProcessTcpText"
|
||
/>
|
||
<MetricCell
|
||
label="网络异常"
|
||
:value="formatMetricNumber(gatewayNetworkFaults)"
|
||
:value-class="counterRiskClass(gatewayNetworkFaults)"
|
||
/>
|
||
<MetricCell
|
||
label="Redis 状态"
|
||
:value="redisStatusText"
|
||
:value-class="redisStatusClass"
|
||
/>
|
||
<MetricCell
|
||
label="Redis 内存"
|
||
:value="redisRuntimeMemoryText"
|
||
:value-class="capacityPercentClass(redisRuntimeMemoryPercent)"
|
||
/>
|
||
<MetricCell
|
||
label="Redis OPS"
|
||
:value="redisRuntimeOpsText"
|
||
/>
|
||
<MetricCell
|
||
label="Redis 延迟"
|
||
:value="redisRuntimeLatencyText"
|
||
:value-class="latencyToneClass(gatewayMetrics?.redisRuntime.nonblockingCommandLatencyMaxMs)"
|
||
/>
|
||
<MetricCell
|
||
label="Redis 客户端"
|
||
:value="redisRuntimeClientsText"
|
||
/>
|
||
<MetricCell
|
||
label="Redis 命令异常"
|
||
:value="formatMetricNumber(redisRuntimeCommandFaults)"
|
||
:value-class="counterRiskClass(redisRuntimeCommandFaults)"
|
||
/>
|
||
<MetricCell
|
||
label="Redis Keys"
|
||
:value="formatMetricNumber(redisCategories?.total_keys)"
|
||
/>
|
||
<MetricCell
|
||
label="亲和缓存"
|
||
:value="formatMetricNumber(cacheStats?.affinity_stats.total_affinities)"
|
||
/>
|
||
<MetricCell
|
||
label="命中率"
|
||
:value="formatPercent(cacheHitRate)"
|
||
/>
|
||
</div>
|
||
|
||
<div class="space-y-2">
|
||
<div
|
||
v-for="item in redisCategoryRows"
|
||
:key="item.key"
|
||
class="flex items-center justify-between gap-3 rounded-lg border border-border/50 bg-background/45 px-3 py-2 text-xs"
|
||
>
|
||
<div class="min-w-0">
|
||
<div class="truncate font-medium">
|
||
{{ item.name }}
|
||
</div>
|
||
<div class="truncate text-muted-foreground">
|
||
{{ item.pattern }}
|
||
</div>
|
||
</div>
|
||
<span class="font-semibold tabular-nums">{{ formatMetricNumber(item.count) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||
<Card class="overflow-hidden xl:col-span-2">
|
||
<div class="border-b border-border/70 bg-muted/20 px-4 py-3">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<div>
|
||
<h2 class="text-sm font-semibold">
|
||
错误审计
|
||
</h2>
|
||
<p class="text-xs text-muted-foreground">
|
||
最近错误、熔断事件和可疑活动入口
|
||
</p>
|
||
</div>
|
||
<RouterLink
|
||
to="/admin/usage?status=error"
|
||
class="text-xs font-medium text-primary hover:underline"
|
||
>
|
||
使用记录
|
||
</RouterLink>
|
||
</div>
|
||
</div>
|
||
<div class="divide-y divide-border/50">
|
||
<div
|
||
v-for="error in recentErrors"
|
||
:key="error.error_id"
|
||
class="grid grid-cols-1 gap-2 px-4 py-3 text-sm lg:grid-cols-[180px_1fr_120px]"
|
||
>
|
||
<div class="font-medium">
|
||
{{ error.error_type }}
|
||
</div>
|
||
<div class="min-w-0 text-xs text-muted-foreground">
|
||
<div class="truncate">
|
||
{{ error.context.error_message || error.operation }}
|
||
</div>
|
||
<div class="mt-1 truncate">
|
||
{{ error.context.provider_name || error.context.provider_id || '-' }} / {{ error.context.model || '-' }}
|
||
</div>
|
||
</div>
|
||
<div class="text-xs text-muted-foreground lg:text-right">
|
||
{{ formatShortDate(error.timestamp) }}
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-if="recentErrors.length === 0"
|
||
class="px-4 py-8 text-center text-sm text-muted-foreground"
|
||
>
|
||
当前没有近期错误
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card class="overflow-hidden">
|
||
<div class="border-b border-border/70 bg-muted/20 px-4 py-3">
|
||
<h2 class="text-sm font-semibold">
|
||
运维入口
|
||
</h2>
|
||
<p class="text-xs text-muted-foreground">
|
||
常用排障视角
|
||
</p>
|
||
</div>
|
||
<div class="grid grid-cols-1 gap-2 p-4">
|
||
<RouterLink
|
||
v-for="link in opsLinks"
|
||
:key="link.to"
|
||
:to="link.to"
|
||
class="flex items-center justify-between rounded-lg border border-border/60 bg-background/45 px-3 py-3 text-sm transition-colors hover:border-primary/50 hover:bg-primary/5"
|
||
>
|
||
<span class="font-medium">{{ link.label }}</span>
|
||
<component
|
||
:is="link.icon"
|
||
class="h-4 w-4 text-muted-foreground"
|
||
/>
|
||
</RouterLink>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, defineComponent, h, onMounted, onUnmounted, ref, watch, type Component } from 'vue'
|
||
import { RouterLink } from 'vue-router'
|
||
import type { ChartData, ChartOptions } from 'chart.js'
|
||
import {
|
||
Activity,
|
||
AlertTriangle,
|
||
BarChart3,
|
||
CircleDollarSign,
|
||
Database,
|
||
Gauge,
|
||
ListChecks,
|
||
RefreshCw,
|
||
ShieldCheck,
|
||
Timer,
|
||
Zap,
|
||
} from 'lucide-vue-next'
|
||
import { Badge, Button, Card, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui'
|
||
import { LoadingState, TimeRangePicker } from '@/components/common'
|
||
import LineChart from '@/components/charts/LineChart.vue'
|
||
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
|
||
import { adminApi, type ErrorDistributionItem, type PercentileItem, type ProviderPerformanceResponse } from '@/api/admin'
|
||
import { cacheApi, redisCacheApi, type CacheStats, type RedisCacheCategoriesResponse } from '@/api/cache'
|
||
import { monitoringApi, type AdminMonitoringRecentError, type AdminMonitoringResilienceStatus, type GatewayMetricsSummary } from '@/api/monitoring'
|
||
import { proxyNodesApi, type ProxyNode, type ProxyNodeMetricsResponse } from '@/api/proxy-nodes'
|
||
import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
||
import type { DateRangeParams } from '@/features/usage/types'
|
||
import { formatByteSize, formatCurrency, formatNumber, formatTokens } from '@/utils/format'
|
||
import { log } from '@/utils/logger'
|
||
|
||
interface MetricCellProps {
|
||
label: string
|
||
value: string
|
||
valueClass?: string
|
||
}
|
||
|
||
const MetricCell = defineComponent<MetricCellProps>({
|
||
name: 'MetricCell',
|
||
props: {
|
||
label: { type: String, required: true },
|
||
value: { type: String, required: true },
|
||
valueClass: { type: String, default: '' },
|
||
},
|
||
setup(props) {
|
||
return () => h('div', { class: 'rounded-lg border border-border/60 bg-background/45 px-3 py-3' }, [
|
||
h('div', { class: 'text-xs text-muted-foreground' }, props.label),
|
||
h('div', { class: ['mt-1 truncate text-lg font-semibold tabular-nums', props.valueClass] }, props.value),
|
||
])
|
||
},
|
||
})
|
||
|
||
const DEFAULT_SLOW_THRESHOLD_MS = 10_000
|
||
|
||
interface ResourceSnapshot {
|
||
totalNodes: number
|
||
onlineNodes: number
|
||
avgCpuPercent: number | null
|
||
avgMemoryPercent: number | null
|
||
wsInBytes: number | null
|
||
wsOutBytes: number | null
|
||
}
|
||
|
||
const timeRange = ref<DateRangeParams>({
|
||
...getDateRangeFromPeriod('today'),
|
||
granularity: 'hour',
|
||
})
|
||
const timeSeries = ref<Array<Record<string, unknown>>>([])
|
||
const percentiles = ref<PercentileItem[]>([])
|
||
const providerPerformance = ref<ProviderPerformanceResponse | null>(null)
|
||
const errorDistribution = ref<ErrorDistributionItem[]>([])
|
||
const errorDistributionLoaded = ref(false)
|
||
const resilienceStatus = ref<AdminMonitoringResilienceStatus | null>(null)
|
||
const gatewayMetrics = ref<GatewayMetricsSummary | null>(null)
|
||
const cacheStats = ref<CacheStats | null>(null)
|
||
const redisCategories = ref<RedisCacheCategoriesResponse | null>(null)
|
||
const resourceSnapshot = ref<ResourceSnapshot | null>(null)
|
||
const lastUpdatedAt = ref<string | null>(null)
|
||
const analyticsWarning = ref<string | null>(null)
|
||
const realtimeWarning = ref<string | null>(null)
|
||
const loadWarning = computed(() =>
|
||
[analyticsWarning.value, realtimeWarning.value].filter(Boolean).join(';') || null
|
||
)
|
||
const refreshing = ref(false)
|
||
const autoRefresh = ref(false)
|
||
const trendLoading = ref(false)
|
||
const percentileLoading = ref(false)
|
||
const AUTO_REFRESH_INTERVAL = 10_000
|
||
let requestId = 0
|
||
let refreshPromise: Promise<void> | null = null
|
||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||
let analyticsGeneration = 0
|
||
|
||
const timeRangeParams = computed(() => ({
|
||
start_date: timeRange.value.start_date,
|
||
end_date: timeRange.value.end_date,
|
||
preset: timeRange.value.preset,
|
||
timezone: timeRange.value.timezone,
|
||
tz_offset_minutes: timeRange.value.tz_offset_minutes,
|
||
granularity: timeRange.value.granularity || 'day',
|
||
}))
|
||
|
||
function numeric(value: unknown): number {
|
||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||
if (typeof value === 'string') {
|
||
const parsed = Number(value)
|
||
return Number.isFinite(parsed) ? parsed : 0
|
||
}
|
||
return 0
|
||
}
|
||
|
||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
|
||
return value as Record<string, unknown>
|
||
}
|
||
|
||
function numberField(record: Record<string, unknown> | null | undefined, key: string): number | null {
|
||
if (!record) return null
|
||
const value = record[key]
|
||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||
if (typeof value === 'string') {
|
||
const parsed = Number(value)
|
||
return Number.isFinite(parsed) ? parsed : null
|
||
}
|
||
return null
|
||
}
|
||
|
||
function averageFinite(values: Array<number | null | undefined>): number | null {
|
||
const numbers = values.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
|
||
if (!numbers.length) return null
|
||
return numbers.reduce((total, value) => total + value, 0) / numbers.length
|
||
}
|
||
|
||
function memoryUsedPercent(node: ProxyNode): number | null {
|
||
const metadata = asRecord(node.proxy_metadata)
|
||
const resource = asRecord(metadata?.resource_usage)
|
||
const hardware = asRecord(node.hardware_info)
|
||
const explicitPercent = numberField(resource, 'memory_used_percent')
|
||
if (explicitPercent != null) return explicitPercent
|
||
const usedBytes = numberField(resource, 'memory_used_bytes')
|
||
const totalBytes = numberField(resource, 'memory_total_bytes')
|
||
?? numberField(hardware, 'memory_total_bytes')
|
||
?? numberField(hardware, 'total_memory_bytes')
|
||
if (usedBytes == null || totalBytes == null || totalBytes <= 0) return null
|
||
return usedBytes / totalBytes * 100
|
||
}
|
||
|
||
function cpuUsedPercent(node: ProxyNode): number | null {
|
||
const metadata = asRecord(node.proxy_metadata)
|
||
const resource = asRecord(metadata?.resource_usage)
|
||
return numberField(resource, 'system_cpu_usage_percent')
|
||
?? numberField(resource, 'process_cpu_usage_percent')
|
||
}
|
||
|
||
async function loadResourceSnapshot(): Promise<ResourceSnapshot | null> {
|
||
const now = Math.floor(Date.now() / 1000)
|
||
const from = now - 3600
|
||
const [nodesResult, fleetResult] = await Promise.allSettled([
|
||
proxyNodesApi.listProxyNodes({ limit: 200 }),
|
||
proxyNodesApi.listFleetMetrics({ from, to: now, step: '1m' }),
|
||
])
|
||
|
||
const nodes = nodesResult.status === 'fulfilled' ? nodesResult.value.items : []
|
||
const fleet: ProxyNodeMetricsResponse | null = fleetResult.status === 'fulfilled'
|
||
? fleetResult.value
|
||
: null
|
||
const onlineNodes = nodes.filter(node => node.status === 'online' || node.tunnel_connected)
|
||
|
||
return {
|
||
totalNodes: nodes.length,
|
||
onlineNodes: onlineNodes.length,
|
||
avgCpuPercent: averageFinite(onlineNodes.map(cpuUsedPercent)),
|
||
avgMemoryPercent: averageFinite(onlineNodes.map(memoryUsedPercent)),
|
||
wsInBytes: fleet?.summary.ws_in_bytes_delta ?? null,
|
||
wsOutBytes: fleet?.summary.ws_out_bytes_delta ?? null,
|
||
}
|
||
}
|
||
|
||
function formatMetricNumber(value: number | null | undefined): string {
|
||
if (value == null || Number.isNaN(value)) return '-'
|
||
if (!Number.isInteger(value)) return value.toFixed(value < 10 ? 2 : 1)
|
||
return formatNumber(value)
|
||
}
|
||
|
||
function formatPercent(value: number | null | undefined): string {
|
||
if (value == null || Number.isNaN(value)) return '-'
|
||
const ratio = value <= 1 ? value * 100 : value
|
||
return `${ratio.toFixed(2)}%`
|
||
}
|
||
|
||
function formatErrorRate(successRate: number | null | undefined): string {
|
||
if (successRate == null || Number.isNaN(successRate)) return '-'
|
||
const rate = successRate <= 1 ? successRate * 100 : successRate
|
||
return `${Math.max(0, 100 - rate).toFixed(2)}%`
|
||
}
|
||
|
||
function formatMs(value: number | null | undefined): string {
|
||
if (value == null || Number.isNaN(value)) return '-'
|
||
if (value >= 1000) return `${(value / 1000).toFixed(value >= 10_000 ? 1 : 2)}s`
|
||
return `${Math.round(value)}ms`
|
||
}
|
||
|
||
function formatTps(value: number | null | undefined): string {
|
||
if (value == null || Number.isNaN(value)) return '-'
|
||
return `${value.toFixed(value < 10 ? 2 : value < 100 ? 1 : 0)} tps`
|
||
}
|
||
|
||
function formatPercentResource(value: number | null | undefined): string {
|
||
if (value == null || Number.isNaN(value)) return '-'
|
||
return `${value.toFixed(value < 10 ? 1 : 0)}%`
|
||
}
|
||
|
||
function basisPointsPercent(value: number | null | undefined): number | null {
|
||
if (value == null || Number.isNaN(value)) return null
|
||
return value / 100
|
||
}
|
||
|
||
function formatBasisPointsPercent(value: number | null | undefined): string {
|
||
return formatPercentResource(basisPointsPercent(value))
|
||
}
|
||
|
||
function formatBytes(value: number | null | undefined): string {
|
||
if (value == null || Number.isNaN(value)) return '-'
|
||
return formatByteSize(value)
|
||
}
|
||
|
||
function formatDurationSeconds(value: number | null | undefined): string {
|
||
if (value == null || Number.isNaN(value)) return '-'
|
||
if (value < 60) return `${Math.round(value)}s`
|
||
if (value < 3600) return `${Math.round(value / 60)}m`
|
||
if (value < 86_400) return `${(value / 3600).toFixed(value < 36_000 ? 1 : 0)}h`
|
||
return `${(value / 86_400).toFixed(1)}d`
|
||
}
|
||
|
||
function resourceToneClass(value: number | null | undefined, warning: number, critical: number): string {
|
||
if (value == null || Number.isNaN(value)) return ''
|
||
if (value >= critical) return 'text-red-600 dark:text-red-400'
|
||
if (value >= warning) return 'text-amber-600 dark:text-amber-400'
|
||
return 'text-green-600 dark:text-green-400'
|
||
}
|
||
|
||
function capacityPercentClass(value: number | null | undefined): string {
|
||
return resourceToneClass(value, 80, 90)
|
||
}
|
||
|
||
function counterRiskClass(value: number | null | undefined): string {
|
||
if (value == null || Number.isNaN(value)) return ''
|
||
return value > 0 ? 'text-amber-600 dark:text-amber-400' : 'text-green-600 dark:text-green-400'
|
||
}
|
||
|
||
function latencyToneClass(value: number | null | undefined): string {
|
||
if (value == null || Number.isNaN(value)) return ''
|
||
if (value >= 5_000) return 'text-red-600 dark:text-red-400'
|
||
if (value >= 1_000) return 'text-amber-600 dark:text-amber-400'
|
||
return ''
|
||
}
|
||
|
||
function formatCapacityPercent(value: number | null | undefined): string {
|
||
if (value == null || Number.isNaN(value)) return '-'
|
||
return `${value.toFixed(value < 10 ? 1 : 0)}%`
|
||
}
|
||
|
||
function formatMetricRatio(current: number | null | undefined, max: number | null | undefined): string {
|
||
return `${formatMetricNumber(current)} / ${formatMetricNumber(max)}`
|
||
}
|
||
|
||
function gateUtilization(gate: GatewayMetricsSummary['local'] | null | undefined): number | null {
|
||
const inFlight = gate?.inFlight
|
||
const available = gate?.availablePermits
|
||
if (inFlight == null || available == null) return null
|
||
const total = inFlight + available
|
||
if (total <= 0) return null
|
||
return Math.max(0, Math.min(100, inFlight / total * 100))
|
||
}
|
||
|
||
function hasCapacitySignal(...values: Array<number | null | undefined>): boolean {
|
||
return values.some(value => typeof value === 'number' && Number.isFinite(value))
|
||
}
|
||
|
||
function formatShortDate(value?: string | null): string {
|
||
if (!value) return '-'
|
||
const date = new Date(value)
|
||
if (Number.isNaN(date.getTime())) return '-'
|
||
return date.toLocaleString('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
})
|
||
}
|
||
|
||
function successRateClass(value: number | null | undefined): string {
|
||
if (value == null || Number.isNaN(value)) return ''
|
||
const rate = value <= 1 ? value * 100 : value
|
||
if (rate >= 95) return 'text-green-600 dark:text-green-400'
|
||
if (rate >= 80) return 'text-amber-600 dark:text-amber-400'
|
||
return 'text-red-600 dark:text-red-400'
|
||
}
|
||
|
||
function average(values: Array<number | null | undefined>): number | null {
|
||
const numbers = values.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
|
||
if (!numbers.length) return null
|
||
return numbers.reduce((total, value) => total + value, 0) / numbers.length
|
||
}
|
||
|
||
function sumSeries(field: string): number {
|
||
return timeSeries.value.reduce((total, item) => total + numeric(item[field]), 0)
|
||
}
|
||
|
||
function seriesTokenTotal(item: Record<string, unknown>): number {
|
||
return numeric(item.input_tokens)
|
||
+ numeric(item.output_tokens)
|
||
+ numeric(item.cache_creation_tokens)
|
||
+ numeric(item.cache_read_tokens)
|
||
}
|
||
|
||
async function performRefresh(refreshAnalytics: boolean) {
|
||
const currentRequestId = ++requestId
|
||
const currentAnalyticsGeneration = analyticsGeneration
|
||
refreshing.value = true
|
||
if (refreshAnalytics) {
|
||
trendLoading.value = timeSeries.value.length === 0
|
||
percentileLoading.value = percentiles.value.length === 0
|
||
analyticsWarning.value = null
|
||
}
|
||
realtimeWarning.value = null
|
||
|
||
const params = timeRangeParams.value
|
||
const canCommitRealtime = () => currentRequestId === requestId
|
||
const canCommitAnalytics = () => (
|
||
refreshAnalytics
|
||
&& canCommitRealtime()
|
||
&& currentAnalyticsGeneration === analyticsGeneration
|
||
)
|
||
|
||
const timeSeriesRequest = (refreshAnalytics
|
||
? adminApi.getTimeSeries(params, { skipCache: true })
|
||
: Promise.resolve(timeSeries.value)
|
||
).then((value) => {
|
||
if (canCommitAnalytics()) timeSeries.value = value
|
||
return value
|
||
}).finally(() => {
|
||
if (canCommitAnalytics()) trendLoading.value = false
|
||
})
|
||
|
||
const percentilesRequest = (refreshAnalytics
|
||
? adminApi.getPercentiles(params, { skipCache: true })
|
||
: Promise.resolve(percentiles.value)
|
||
).then((value) => {
|
||
if (canCommitAnalytics()) percentiles.value = value
|
||
return value
|
||
}).finally(() => {
|
||
if (canCommitAnalytics()) percentileLoading.value = false
|
||
})
|
||
|
||
const providerPerformanceRequest = (refreshAnalytics
|
||
? adminApi.getProviderPerformance({
|
||
...params,
|
||
granularity: params.granularity === 'hour' ? 'hour' : 'day',
|
||
limit: 8,
|
||
slow_threshold_ms: DEFAULT_SLOW_THRESHOLD_MS,
|
||
include_timeline: false,
|
||
}, { skipCache: true })
|
||
: Promise.resolve(providerPerformance.value)
|
||
).then((value) => {
|
||
if (canCommitAnalytics()) providerPerformance.value = value
|
||
return value
|
||
})
|
||
|
||
const errorDistributionRequest = (refreshAnalytics
|
||
? adminApi.getErrorDistribution(params, { skipCache: true })
|
||
: Promise.resolve({ distribution: errorDistribution.value })
|
||
).then((value) => {
|
||
if (canCommitAnalytics()) {
|
||
errorDistribution.value = value.distribution
|
||
errorDistributionLoaded.value = true
|
||
}
|
||
return value
|
||
})
|
||
|
||
const resilienceRequest = monitoringApi.getResilienceStatus().then((value) => {
|
||
if (canCommitRealtime()) resilienceStatus.value = value
|
||
return value
|
||
})
|
||
const gatewayRequest = monitoringApi.getGatewayMetricsSummary().then((value) => {
|
||
if (canCommitRealtime()) gatewayMetrics.value = value
|
||
return value
|
||
})
|
||
const cacheRequest = cacheApi.getStats().then((value) => {
|
||
if (canCommitRealtime()) cacheStats.value = value
|
||
return value
|
||
})
|
||
const redisRequest = redisCacheApi.getCategories().then((value) => {
|
||
if (canCommitRealtime()) redisCategories.value = value
|
||
return value
|
||
})
|
||
const resourceRequest = loadResourceSnapshot().then((value) => {
|
||
if (canCommitRealtime()) resourceSnapshot.value = value
|
||
return value
|
||
})
|
||
|
||
const results = await Promise.allSettled([
|
||
timeSeriesRequest,
|
||
percentilesRequest,
|
||
providerPerformanceRequest,
|
||
errorDistributionRequest,
|
||
resilienceRequest,
|
||
gatewayRequest,
|
||
cacheRequest,
|
||
redisRequest,
|
||
resourceRequest,
|
||
])
|
||
|
||
if (currentRequestId !== requestId) return
|
||
|
||
const analyticsFailed: string[] = []
|
||
const realtimeFailed: string[] = []
|
||
const [
|
||
timeSeriesResult,
|
||
percentilesResult,
|
||
providerPerformanceResult,
|
||
errorDistributionResult,
|
||
resilienceResult,
|
||
gatewayResult,
|
||
cacheResult,
|
||
redisResult,
|
||
] = results
|
||
|
||
if (refreshAnalytics && currentAnalyticsGeneration === analyticsGeneration) {
|
||
if (timeSeriesResult.status === 'rejected') analyticsFailed.push('流量趋势')
|
||
if (percentilesResult.status === 'rejected') analyticsFailed.push('延迟百分位')
|
||
if (providerPerformanceResult.status === 'rejected') analyticsFailed.push('上游性能')
|
||
if (errorDistributionResult.status === 'rejected') analyticsFailed.push('错误分类')
|
||
|
||
analyticsWarning.value = analyticsFailed.length
|
||
? `分析数据加载失败:${analyticsFailed.join('、')}`
|
||
: null
|
||
}
|
||
|
||
if (resilienceResult.status === 'rejected') realtimeFailed.push('韧性状态')
|
||
if (gatewayResult.status === 'rejected') realtimeFailed.push('网关指标')
|
||
if (cacheResult.status === 'rejected') realtimeFailed.push('缓存统计')
|
||
if (redisResult.status === 'rejected') realtimeFailed.push('Redis 分类')
|
||
|
||
results.forEach((result, index) => {
|
||
if (result.status === 'rejected') {
|
||
log.error(`运维总览加载失败 ${index}`, result.reason)
|
||
}
|
||
})
|
||
|
||
realtimeWarning.value = realtimeFailed.length
|
||
? `实时数据加载失败:${realtimeFailed.join('、')}`
|
||
: null
|
||
if (currentAnalyticsGeneration === analyticsGeneration) {
|
||
lastUpdatedAt.value = new Date().toISOString()
|
||
}
|
||
}
|
||
|
||
function refreshAll(): Promise<void> {
|
||
if (refreshPromise) {
|
||
return refreshPromise
|
||
}
|
||
|
||
const request = performRefresh(true).finally(() => {
|
||
if (refreshPromise === request) {
|
||
refreshPromise = null
|
||
}
|
||
refreshing.value = false
|
||
trendLoading.value = false
|
||
percentileLoading.value = false
|
||
})
|
||
refreshPromise = request
|
||
return request
|
||
}
|
||
|
||
function stopAutoRefresh() {
|
||
if (autoRefreshTimer) {
|
||
clearInterval(autoRefreshTimer)
|
||
autoRefreshTimer = null
|
||
}
|
||
}
|
||
|
||
function toggleAutoRefresh() {
|
||
autoRefresh.value = !autoRefresh.value
|
||
if (!autoRefresh.value) {
|
||
stopAutoRefresh()
|
||
return
|
||
}
|
||
|
||
void refreshAll()
|
||
autoRefreshTimer = setInterval(() => {
|
||
void refreshAll()
|
||
}, AUTO_REFRESH_INTERVAL)
|
||
}
|
||
|
||
const lastUpdatedLabel = computed(() => formatShortDate(lastUpdatedAt.value))
|
||
|
||
const totalRequests = computed(() => sumSeries('total_requests'))
|
||
const totalTokens = computed(() => timeSeries.value.reduce(
|
||
(total, item) => total + seriesTokenTotal(item),
|
||
0,
|
||
))
|
||
const totalCost = computed(() => sumSeries('total_cost'))
|
||
const classifiedErrorCount = computed(() => (
|
||
errorDistributionLoaded.value
|
||
? errorDistribution.value.reduce((total, item) => total + numeric(item.count), 0)
|
||
: null
|
||
))
|
||
const avgResponseMs = computed(() => providerPerformance.value?.summary.avg_response_time_ms ?? null)
|
||
const avgFirstByteMs = computed(() => providerPerformance.value?.summary.avg_first_byte_time_ms ?? average(percentiles.value.map(item => item.p50_first_byte_time_ms)))
|
||
const avgOutputTps = computed(() => providerPerformance.value?.summary.avg_output_tps ?? null)
|
||
const windowSeconds = computed(() => {
|
||
const start = timeRange.value.start_date ? new Date(timeRange.value.start_date).getTime() : NaN
|
||
const end = timeRange.value.end_date ? new Date(timeRange.value.end_date).getTime() : NaN
|
||
if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
|
||
return (end - start) / 1000
|
||
}
|
||
switch (timeRange.value.preset) {
|
||
case 'today': return Math.max(1, (Date.now() - new Date().setHours(0, 0, 0, 0)) / 1000)
|
||
case 'yesterday': return 86_400
|
||
case 'last7days': return 7 * 86_400
|
||
case 'last30days': return 30 * 86_400
|
||
case 'last90days': return 90 * 86_400
|
||
default: return Math.max(1, timeSeries.value.length * 86_400)
|
||
}
|
||
})
|
||
const qps = computed(() => totalRequests.value / windowSeconds.value)
|
||
const rpm = computed(() => qps.value * 60)
|
||
const tokensPerMinute = computed(() => totalTokens.value / Math.max(1, windowSeconds.value / 60))
|
||
const slaRate = computed(() => providerPerformance.value?.summary.success_rate ?? null)
|
||
|
||
const kpiCards = computed<Array<{
|
||
title: string
|
||
value: string
|
||
hint: string
|
||
icon: Component
|
||
iconClass: string
|
||
valueClass?: string
|
||
}>>(() => [
|
||
{
|
||
title: 'QPS',
|
||
value: qps.value.toFixed(qps.value < 10 ? 2 : 1),
|
||
hint: `RPM ${rpm.value.toFixed(rpm.value < 100 ? 1 : 0)} · 请求 ${formatMetricNumber(totalRequests.value)}`,
|
||
icon: Activity,
|
||
iconClass: 'text-sky-500',
|
||
},
|
||
{
|
||
title: '趋势 Tokens',
|
||
value: formatTokens(totalTokens.value),
|
||
hint: `趋势聚合 · ${formatMetricNumber(tokensPerMinute.value)} TPM`,
|
||
icon: Zap,
|
||
iconClass: 'text-amber-500',
|
||
},
|
||
{
|
||
title: 'SLA',
|
||
value: formatPercent(slaRate.value),
|
||
hint: `错误率 ${formatErrorRate(slaRate.value)}`,
|
||
icon: ShieldCheck,
|
||
iconClass: 'text-emerald-500',
|
||
valueClass: successRateClass(slaRate.value),
|
||
},
|
||
{
|
||
title: '请求时长',
|
||
value: formatMs(avgResponseMs.value),
|
||
hint: `P99 ${formatMs(providerPerformance.value?.summary.p99_response_time_ms)}`,
|
||
icon: Gauge,
|
||
iconClass: 'text-violet-500',
|
||
},
|
||
{
|
||
title: 'TTFT',
|
||
value: formatMs(avgFirstByteMs.value),
|
||
hint: `P99 首字 ${formatMs(providerPerformance.value?.summary.p99_first_byte_time_ms)}`,
|
||
icon: Timer,
|
||
iconClass: 'text-blue-500',
|
||
},
|
||
{
|
||
title: '输出 TPS',
|
||
value: formatTps(avgOutputTps.value),
|
||
hint: `慢请求 ${formatMetricNumber(providerPerformance.value?.summary.slow_request_count)}`,
|
||
icon: BarChart3,
|
||
iconClass: 'text-cyan-500',
|
||
},
|
||
{
|
||
title: '上游错误',
|
||
value: formatMetricNumber(resilienceStatus.value?.error_statistics.total_errors),
|
||
hint: `打开熔断 ${formatMetricNumber(resilienceStatus.value?.error_statistics.open_circuit_breakers)}`,
|
||
icon: AlertTriangle,
|
||
iconClass: 'text-red-500',
|
||
},
|
||
{
|
||
title: '费用',
|
||
value: formatCurrency(totalCost.value),
|
||
hint: `缓存读 ${formatTokens(cacheStats.value?.affinity_stats.cache_hits ?? 0)} 次`,
|
||
icon: CircleDollarSign,
|
||
iconClass: 'text-green-500',
|
||
},
|
||
])
|
||
|
||
const trafficChartData = computed<ChartData<'line'>>(() => ({
|
||
labels: timeSeries.value.map(item => String(item.date ?? item.bucket ?? item.time ?? '')),
|
||
datasets: [
|
||
{
|
||
label: '请求',
|
||
data: timeSeries.value.map(item => numeric(item.total_requests ?? item.requests)),
|
||
borderColor: 'rgb(14, 165, 233)',
|
||
backgroundColor: 'rgba(14, 165, 233, 0.12)',
|
||
tension: 0.25,
|
||
pointRadius: 2,
|
||
yAxisID: 'y',
|
||
},
|
||
{
|
||
label: 'Tokens',
|
||
data: timeSeries.value.map(seriesTokenTotal),
|
||
borderColor: 'rgb(245, 158, 11)',
|
||
backgroundColor: 'rgba(245, 158, 11, 0.12)',
|
||
tension: 0.25,
|
||
pointRadius: 2,
|
||
yAxisID: 'y1',
|
||
},
|
||
],
|
||
}))
|
||
|
||
const trafficChartOptions: ChartOptions<'line'> = {
|
||
interaction: { mode: 'index', intersect: false },
|
||
scales: {
|
||
y: { position: 'left' },
|
||
y1: {
|
||
position: 'right',
|
||
grid: { drawOnChartArea: false },
|
||
},
|
||
},
|
||
}
|
||
|
||
const latencyChartData = computed<ChartData<'line'>>(() => ({
|
||
labels: percentiles.value.map(item => item.date),
|
||
datasets: [
|
||
{
|
||
label: 'P90 请求',
|
||
data: percentiles.value.map(item => item.p90_response_time_ms ?? null),
|
||
borderColor: 'rgb(124, 58, 237)',
|
||
tension: 0.25,
|
||
pointRadius: 2,
|
||
},
|
||
{
|
||
label: 'P99 请求',
|
||
data: percentiles.value.map(item => item.p99_response_time_ms ?? null),
|
||
borderColor: 'rgb(239, 68, 68)',
|
||
tension: 0.25,
|
||
pointRadius: 2,
|
||
},
|
||
{
|
||
label: 'P90 首字',
|
||
data: percentiles.value.map(item => item.p90_first_byte_time_ms ?? null),
|
||
borderColor: 'rgb(14, 165, 233)',
|
||
tension: 0.25,
|
||
pointRadius: 2,
|
||
},
|
||
],
|
||
}))
|
||
|
||
const latencyChartOptions: ChartOptions<'line'> = {
|
||
scales: {
|
||
y: {
|
||
ticks: {
|
||
callback: value => formatMs(Number(value)),
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
const errorDistributionChartData = computed<ChartData<'doughnut'>>(() => {
|
||
const rows = errorDistribution.value.length
|
||
? errorDistribution.value
|
||
: [{ category: '无错误', count: 1 }]
|
||
return {
|
||
labels: rows.map(item => item.category),
|
||
datasets: [
|
||
{
|
||
data: rows.map(item => item.count),
|
||
backgroundColor: [
|
||
'rgba(239, 68, 68, 0.82)',
|
||
'rgba(245, 158, 11, 0.82)',
|
||
'rgba(14, 165, 233, 0.82)',
|
||
'rgba(99, 102, 241, 0.82)',
|
||
'rgba(34, 197, 94, 0.82)',
|
||
],
|
||
},
|
||
],
|
||
}
|
||
})
|
||
|
||
const errorDistributionOptions: ChartOptions<'doughnut'> = {
|
||
plugins: {
|
||
legend: {
|
||
position: 'bottom',
|
||
},
|
||
tooltip: {
|
||
callbacks: {
|
||
label: context => `${context.label}: ${formatMetricNumber(context.raw as number)}`,
|
||
},
|
||
},
|
||
},
|
||
}
|
||
|
||
const distributedGateVariant = computed<'warning' | 'outline'>(() => (
|
||
gatewayMetrics.value?.distributed.unavailable ? 'warning' : 'outline'
|
||
))
|
||
const distributedGateText = computed(() => (
|
||
gatewayMetrics.value?.distributed.unavailable ? '全局不可用' : '全局在线'
|
||
))
|
||
const currentActiveStreams = computed(() => gatewayMetrics.value?.tunnel.activeStreams ?? null)
|
||
const currentProxyConnections = computed(() => gatewayMetrics.value?.tunnel.proxyConnections ?? null)
|
||
const localGateUtilization = computed(() => gateUtilization(gatewayMetrics.value?.local))
|
||
const distributedGateUtilization = computed(() => gateUtilization(gatewayMetrics.value?.distributed))
|
||
const candidatePlanningGateUtilization = computed(() => gateUtilization(gatewayMetrics.value?.candidatePlanning))
|
||
const upstreamExecutionGateUtilization = computed(() => gateUtilization(gatewayMetrics.value?.upstreamExecution))
|
||
const localGateUtilizationText = computed(() => formatCapacityPercent(localGateUtilization.value))
|
||
const distributedGateUtilizationText = computed(() => formatCapacityPercent(distributedGateUtilization.value))
|
||
const candidatePlanningGateUtilizationText = computed(() => formatCapacityPercent(candidatePlanningGateUtilization.value))
|
||
const upstreamExecutionGateUtilizationText = computed(() => formatCapacityPercent(upstreamExecutionGateUtilization.value))
|
||
const admissionStatusVariant = computed<'success' | 'warning' | 'destructive' | 'outline'>(() => {
|
||
if (!gatewayMetrics.value) return 'outline'
|
||
if (gatewayMetrics.value.distributed.unavailable) return 'destructive'
|
||
if (
|
||
(localGateUtilization.value ?? 0) >= 90
|
||
|| (distributedGateUtilization.value ?? 0) >= 90
|
||
|| (candidatePlanningGateUtilization.value ?? 0) >= 90
|
||
|| (upstreamExecutionGateUtilization.value ?? 0) >= 90
|
||
) return 'warning'
|
||
return 'success'
|
||
})
|
||
const admissionStatusText = computed(() => {
|
||
if (!gatewayMetrics.value) return '未接入'
|
||
if (gatewayMetrics.value.distributed.unavailable) return '分布式异常'
|
||
if (admissionStatusVariant.value === 'warning') return '接近上限'
|
||
return '正常'
|
||
})
|
||
const databasePoolUsagePercent = computed(() => {
|
||
const basisPoints = gatewayMetrics.value?.databasePool.usageBasisPoints
|
||
return basisPoints == null ? null : basisPoints / 100
|
||
})
|
||
const databasePoolUsageText = computed(() => formatCapacityPercent(databasePoolUsagePercent.value))
|
||
const databasePoolCheckedOutText = computed(() => formatMetricRatio(
|
||
gatewayMetrics.value?.databasePool.checkedOut,
|
||
gatewayMetrics.value?.databasePool.max
|
||
))
|
||
const postgresWaitingText = computed(() => formatMetricRatio(
|
||
gatewayMetrics.value?.postgres.waitingConnections,
|
||
gatewayMetrics.value?.postgres.activeConnections
|
||
))
|
||
const postgresOldestActiveQueryText = computed(() => formatMs(
|
||
gatewayMetrics.value?.postgres.oldestActiveQueryAgeMs
|
||
))
|
||
const postgresOldestTransactionText = computed(() => formatMs(
|
||
gatewayMetrics.value?.postgres.oldestTransactionAgeMs
|
||
))
|
||
const postgresCacheHitPercent = computed(() => (
|
||
basisPointsPercent(gatewayMetrics.value?.postgres.blockCacheHitRateBasisPoints)
|
||
))
|
||
const postgresCacheHitText = computed(() => formatBasisPointsPercent(
|
||
gatewayMetrics.value?.postgres.blockCacheHitRateBasisPoints
|
||
))
|
||
const postgresCacheHitClass = computed(() => {
|
||
const value = postgresCacheHitPercent.value
|
||
if (value == null || Number.isNaN(value)) return ''
|
||
if (value < 90) return 'text-red-600 dark:text-red-400'
|
||
if (value < 95) return 'text-amber-600 dark:text-amber-400'
|
||
return 'text-green-600 dark:text-green-400'
|
||
})
|
||
function postgresOptionalText(
|
||
available: boolean | null | undefined,
|
||
unavailable: boolean | null | undefined,
|
||
value: string,
|
||
): string {
|
||
if (unavailable) return '异常'
|
||
if (available === false) return '未接入'
|
||
return value
|
||
}
|
||
const postgresWalText = computed(() => {
|
||
const postgres = gatewayMetrics.value?.postgres
|
||
return postgresOptionalText(
|
||
postgres?.walAvailable,
|
||
postgres?.walUnavailable,
|
||
formatBytes(postgres?.walBytesTotal),
|
||
)
|
||
})
|
||
const postgresWalClass = computed(() => (
|
||
gatewayMetrics.value?.postgres.walUnavailable ? 'text-red-600 dark:text-red-400' : ''
|
||
))
|
||
const postgresCheckpointText = computed(() => {
|
||
const postgres = gatewayMetrics.value?.postgres
|
||
const value = `${formatMs(postgres?.checkpointWriteTimeMsTotal)} / ${formatMs(postgres?.checkpointSyncTimeMsTotal)}`
|
||
return postgresOptionalText(postgres?.checkpointAvailable, postgres?.checkpointUnavailable, value)
|
||
})
|
||
const postgresCheckpointClass = computed(() => (
|
||
gatewayMetrics.value?.postgres.checkpointUnavailable ? 'text-red-600 dark:text-red-400' : ''
|
||
))
|
||
const postgresTopStatementText = computed(() => {
|
||
const postgres = gatewayMetrics.value?.postgres
|
||
return postgresOptionalText(
|
||
postgres?.statementAvailable,
|
||
postgres?.statementUnavailable,
|
||
formatMs(postgres?.statementTopMaxExecTimeMs),
|
||
)
|
||
})
|
||
const postgresStatementClass = computed(() => {
|
||
const postgres = gatewayMetrics.value?.postgres
|
||
if (postgres?.statementUnavailable) return 'text-red-600 dark:text-red-400'
|
||
return latencyToneClass(postgres?.statementTopMaxExecTimeMs)
|
||
})
|
||
const databasePoolStatusVariant = computed<'success' | 'warning' | 'destructive' | 'outline'>(() => {
|
||
const pool = gatewayMetrics.value?.databasePool
|
||
const postgres = gatewayMetrics.value?.postgres
|
||
if (!pool || pool.max == null) return postgres?.unavailable ? 'destructive' : 'outline'
|
||
if (
|
||
pool.underMaintenancePressure
|
||
|| postgres?.unavailable
|
||
|| (postgres?.lockWaitingConnections ?? 0) > 0
|
||
|| (postgres?.idleInTransactionConnections ?? 0) > 0
|
||
|| (postgres?.oldestActiveQueryAgeMs ?? 0) >= 60_000
|
||
|| (postgres?.oldestTransactionAgeMs ?? 0) >= 60_000
|
||
|| (postgres?.walUnavailable ?? false)
|
||
|| (postgres?.checkpointUnavailable ?? false)
|
||
|| (postgres?.statementUnavailable ?? false)
|
||
|| (postgres?.statementTopMaxExecTimeMs ?? 0) >= 60_000
|
||
|| (databasePoolUsagePercent.value ?? 0) >= 90
|
||
) return 'destructive'
|
||
if ((postgres?.waitingConnections ?? 0) > 0 || (databasePoolUsagePercent.value ?? 0) >= 80) return 'warning'
|
||
return 'success'
|
||
})
|
||
const databasePoolStatusText = computed(() => {
|
||
const pool = gatewayMetrics.value?.databasePool
|
||
const postgres = gatewayMetrics.value?.postgres
|
||
if (!pool || pool.max == null) return postgres?.unavailable ? 'PG 异常' : '未接入'
|
||
if (postgres?.unavailable) return 'PG 异常'
|
||
if ((postgres?.lockWaitingConnections ?? 0) > 0) return '锁等待'
|
||
if ((postgres?.idleInTransactionConnections ?? 0) > 0) return '长事务'
|
||
if (postgres?.walUnavailable) return 'WAL 异常'
|
||
if (postgres?.checkpointUnavailable) return 'Checkpoint 异常'
|
||
if (postgres?.statementUnavailable) return 'Top SQL 异常'
|
||
if ((postgres?.statementTopMaxExecTimeMs ?? 0) >= 60_000) return '慢 SQL'
|
||
if (pool.underMaintenancePressure) return '维护压力'
|
||
if (databasePoolStatusVariant.value === 'destructive') return '高压'
|
||
if (databasePoolStatusVariant.value === 'warning') return '偏高'
|
||
return '正常'
|
||
})
|
||
const gatewayProcessCpuPercent = computed(() => basisPointsPercent(gatewayMetrics.value?.process.processCpuUsageBasisPoints))
|
||
const gatewaySystemCpuPercent = computed(() => basisPointsPercent(gatewayMetrics.value?.process.systemCpuUsageBasisPoints))
|
||
const gatewayProcessMemoryPercent = computed(() => basisPointsPercent(gatewayMetrics.value?.process.processMemoryBasisPoints))
|
||
const gatewaySystemMemoryPercent = computed(() => basisPointsPercent(gatewayMetrics.value?.process.systemMemoryUsageBasisPoints))
|
||
const gatewayProcessFdPercent = computed(() => basisPointsPercent(gatewayMetrics.value?.process.fdUsageBasisPoints))
|
||
const gatewayAllocatorActivePercent = computed(() => basisPointsPercent(gatewayMetrics.value?.allocator.activeToAllocatedBasisPoints))
|
||
const gatewayAllocatorActivePercentBasisPoints = computed(() => gatewayMetrics.value?.allocator.activeToAllocatedBasisPoints ?? null)
|
||
const gatewayProcessFdText = computed(() => formatMetricRatio(
|
||
gatewayMetrics.value?.process.openFds,
|
||
gatewayMetrics.value?.process.fdLimit
|
||
))
|
||
const gatewayProcessSocketFdText = computed(() => formatMetricRatio(
|
||
gatewayMetrics.value?.process.socketFds,
|
||
gatewayMetrics.value?.process.openFds
|
||
))
|
||
const gatewayProcessTcpText = computed(() => formatMetricRatio(
|
||
gatewayMetrics.value?.process.processTcpEstablishedConnections,
|
||
gatewayMetrics.value?.process.processTcpConnections
|
||
))
|
||
const gatewayBackgroundTaskText = computed(() => formatMetricRatio(
|
||
gatewayMetrics.value?.backgroundTasks.active,
|
||
gatewayMetrics.value?.backgroundTasks.supervisedTotal
|
||
))
|
||
const gatewayBackgroundTaskUnexpectedExits = computed(() => {
|
||
const tasks = gatewayMetrics.value?.backgroundTasks
|
||
if (!tasks) return null
|
||
return tasks.unexpectedExitsTotal
|
||
})
|
||
const gatewayNetworkTrafficText = computed(() => {
|
||
const process = gatewayMetrics.value?.process
|
||
if (process?.networkReceivedBytesTotal == null && process?.networkTransmittedBytesTotal == null) return '-'
|
||
return `RX ${formatBytes(process?.networkReceivedBytesTotal)} / TX ${formatBytes(process?.networkTransmittedBytesTotal)}`
|
||
})
|
||
const gatewayNetworkFaults = computed(() => {
|
||
const process = gatewayMetrics.value?.process
|
||
if (!process) return null
|
||
return (process.networkReceiveErrorsTotal ?? 0)
|
||
+ (process.networkTransmitErrorsTotal ?? 0)
|
||
+ (process.networkReceiveDroppedTotal ?? 0)
|
||
+ (process.networkTransmitDroppedTotal ?? 0)
|
||
})
|
||
const gatewayTcpStateText = computed(() => {
|
||
const process = gatewayMetrics.value?.process
|
||
if (!process || process.tcpStateAvailable == null) return '-'
|
||
if (!process.tcpStateAvailable) return '未采集'
|
||
return `host ${formatMetricNumber(process.hostTcpEstablishedConnections)} established · ${formatMetricNumber(process.hostTcpTimeWaitConnections)} time-wait`
|
||
})
|
||
const gatewayProcessUptimeText = computed(() => formatDurationSeconds(gatewayMetrics.value?.process.processUptimeSeconds))
|
||
const gatewayAllocatorStatusText = computed(() => {
|
||
const allocator = gatewayMetrics.value?.allocator
|
||
if (!allocator || allocator.available == null) return '-'
|
||
if (!allocator.available) return 'unavailable'
|
||
return `allocated ${formatBytes(allocator.allocatedBytes)}`
|
||
})
|
||
const gatewayProcessStatusVariant = computed<'success' | 'warning' | 'destructive' | 'outline'>(() => {
|
||
const process = gatewayMetrics.value?.process
|
||
const backgroundTasks = gatewayMetrics.value?.backgroundTasks
|
||
const tokioRuntime = gatewayMetrics.value?.tokioRuntime
|
||
if (!process || !hasCapacitySignal(
|
||
process.processCpuUsageBasisPoints,
|
||
process.processMemoryBytes,
|
||
process.fdUsageBasisPoints,
|
||
process.socketFds,
|
||
process.processTcpConnections,
|
||
backgroundTasks?.active,
|
||
backgroundTasks?.supervisedTotal,
|
||
tokioRuntime?.aliveTasks,
|
||
tokioRuntime?.globalQueueDepth,
|
||
)) return 'outline'
|
||
if (
|
||
(process.processTcpCloseWaitConnections ?? 0) > 0
|
||
|| tokioRuntime?.available === false
|
||
|| (backgroundTasks?.unexpectedExitsTotal ?? 0) > 0
|
||
|| (backgroundTasks?.panickedTotal ?? 0) > 0
|
||
|| (backgroundTasks?.abortedTotal ?? 0) > 0
|
||
|| (gatewayProcessFdPercent.value ?? 0) >= 70
|
||
|| (gatewaySystemCpuPercent.value ?? 0) >= 90
|
||
|| (gatewayProcessMemoryPercent.value ?? 0) >= 90
|
||
) return 'destructive'
|
||
if (
|
||
(gatewayProcessFdPercent.value ?? 0) >= 60
|
||
|| (gatewaySystemCpuPercent.value ?? 0) >= 70
|
||
|| (gatewayProcessMemoryPercent.value ?? 0) >= 70
|
||
) return 'warning'
|
||
return 'success'
|
||
})
|
||
const gatewayProcessStatusText = computed(() => {
|
||
if (gatewayProcessStatusVariant.value === 'outline') return '未接入'
|
||
if ((gatewayMetrics.value?.process.processTcpCloseWaitConnections ?? 0) > 0) return '连接泄漏'
|
||
if (gatewayMetrics.value?.tokioRuntime.available === false) return 'Runtime 异常'
|
||
if ((gatewayMetrics.value?.backgroundTasks.unexpectedExitsTotal ?? 0) > 0) return '任务退出'
|
||
if (gatewayProcessStatusVariant.value === 'destructive') return '超阈值'
|
||
if (gatewayProcessStatusVariant.value === 'warning') return '接近上限'
|
||
return '正常'
|
||
})
|
||
const usageWorkerText = computed(() => {
|
||
const usage = gatewayMetrics.value?.usageRuntime
|
||
return formatMetricRatio(
|
||
usage?.workerActiveCount ?? usage?.workerCount,
|
||
usage?.workerMaxCount
|
||
)
|
||
})
|
||
const usageRuntimeWorkerFaults = computed(() => {
|
||
const usage = gatewayMetrics.value?.usageRuntime
|
||
if (!usage) return null
|
||
const values = [
|
||
usage.workerDeadLetteredEntriesTotal,
|
||
usage.workerProcessFailuresTotal,
|
||
usage.workerReadFailuresTotal,
|
||
usage.workerReclaimFailuresTotal,
|
||
]
|
||
if (!values.some(value => value != null)) return null
|
||
return values.reduce((total, value) => total + (value ?? 0), 0)
|
||
})
|
||
const usageQueueOldestPendingIdleText = computed(() => (
|
||
formatDurationSeconds((gatewayMetrics.value?.usageQueue.oldestPendingIdleMs ?? null) == null
|
||
? null
|
||
: (gatewayMetrics.value?.usageQueue.oldestPendingIdleMs ?? 0) / 1000)
|
||
))
|
||
const usageCounterOldestPendingAgeText = computed(() => (
|
||
formatDurationSeconds(gatewayMetrics.value?.usageCounter.oldestPendingAgeSeconds)
|
||
))
|
||
const usageCounterOldestPendingAgeClass = computed(() => {
|
||
const age = gatewayMetrics.value?.usageCounter.oldestPendingAgeSeconds
|
||
if (age == null || Number.isNaN(age)) return ''
|
||
if (age >= 60) return 'text-red-600 dark:text-red-400'
|
||
if (age > 0) return 'text-amber-600 dark:text-amber-400'
|
||
return 'text-green-600 dark:text-green-400'
|
||
})
|
||
const usageCounterHealthText = computed(() => {
|
||
const counter = gatewayMetrics.value?.usageCounter
|
||
if (!counter || counter.unavailable == null) return '-'
|
||
if (counter.unavailable) return 'unavailable'
|
||
if ((counter.oldestPendingAgeSeconds ?? 0) >= 60) return 'backlogged'
|
||
if ((counter.pendingRows ?? 0) > 0) return 'catching-up'
|
||
return 'ok'
|
||
})
|
||
const usageCounterOutboxFailures = computed(() => {
|
||
const counter = gatewayMetrics.value?.usageCounter
|
||
if (!counter) return null
|
||
const values = [
|
||
counter.flushFailedBatchesTotal,
|
||
counter.cleanupFailedBatchesTotal,
|
||
]
|
||
if (!values.some(value => value != null)) return null
|
||
return values.reduce((total, value) => total + (value ?? 0), 0)
|
||
})
|
||
const candidateQueueUtilization = computed(() => {
|
||
const depth = gatewayMetrics.value?.requestCandidateQueue.depth
|
||
const capacity = gatewayMetrics.value?.requestCandidateQueue.capacity
|
||
if (depth == null || capacity == null || capacity <= 0) return null
|
||
return Math.max(0, Math.min(100, depth / capacity * 100))
|
||
})
|
||
const candidateQueueUtilizationText = computed(() => formatCapacityPercent(candidateQueueUtilization.value))
|
||
const candidateQueueUtilizationWidth = computed(() => (
|
||
candidateQueueUtilization.value == null ? '0%' : `${candidateQueueUtilization.value}%`
|
||
))
|
||
const candidateQueueDepthText = computed(() => formatMetricRatio(
|
||
gatewayMetrics.value?.requestCandidateQueue.depth,
|
||
gatewayMetrics.value?.requestCandidateQueue.capacity
|
||
))
|
||
const queueStatusVariant = computed<'success' | 'warning' | 'destructive' | 'outline'>(() => {
|
||
const usage = gatewayMetrics.value?.usageRuntime
|
||
const queue = gatewayMetrics.value?.requestCandidateQueue
|
||
const usageQueue = gatewayMetrics.value?.usageQueue
|
||
const counter = gatewayMetrics.value?.usageCounter
|
||
const hasUsageQueueSignal = usageQueue?.unavailable != null || hasCapacitySignal(
|
||
usageQueue?.groupPending,
|
||
usageQueue?.groupLag,
|
||
usageQueue?.dlqLength,
|
||
)
|
||
const hasUsageCounterSignal = counter?.unavailable != null || hasCapacitySignal(
|
||
counter?.pendingRows,
|
||
counter?.processedRows,
|
||
counter?.oldestPendingAgeSeconds,
|
||
)
|
||
if (!gatewayMetrics.value || (!hasUsageQueueSignal && !hasUsageCounterSignal && !hasCapacitySignal(
|
||
usage?.workerActiveCount,
|
||
queue?.depth,
|
||
queue?.capacity,
|
||
))) return 'outline'
|
||
const hardFailures = (usage?.terminalEnqueueFailedTotal ?? 0)
|
||
+ (usage?.lifecycleEnqueueFailedTotal ?? 0)
|
||
+ (queue?.flushFailedTotal ?? 0)
|
||
+ (queue?.syncFallbackTotal ?? 0)
|
||
+ (usageRuntimeWorkerFaults.value ?? 0)
|
||
if (
|
||
hardFailures > 0
|
||
|| usageQueue?.unavailable
|
||
|| (usageQueue?.dlqLength ?? 0) > 0
|
||
|| (usageQueue?.oldestPendingIdleMs ?? 0) >= 60_000
|
||
|| counter?.unavailable
|
||
|| (usageCounterOutboxFailures.value ?? 0) > 0
|
||
|| (counter?.oldestPendingAgeSeconds ?? 0) >= 60
|
||
) return 'destructive'
|
||
if (
|
||
(candidateQueueUtilization.value ?? 0) >= 80
|
||
|| (usage?.lifecycleEnqueueDeferredDroppedTotal ?? 0) > 0
|
||
|| (usageQueue?.groupPending ?? 0) > 0
|
||
|| (usageQueue?.groupLag ?? 0) > 0
|
||
|| (counter?.pendingRows ?? 0) > 0
|
||
) return 'warning'
|
||
return 'success'
|
||
})
|
||
const queueStatusText = computed(() => {
|
||
if (queueStatusVariant.value === 'outline') return '未接入'
|
||
if (queueStatusVariant.value === 'destructive') return '异常'
|
||
if (queueStatusVariant.value === 'warning') return '积压'
|
||
return '正常'
|
||
})
|
||
const upstreamTargetRows = computed(() => gatewayMetrics.value?.upstreamTargets.rows ?? [])
|
||
const upstreamTargetStatusVariant = computed<'success' | 'warning' | 'destructive' | 'outline'>(() => {
|
||
const targets = gatewayMetrics.value?.upstreamTargets
|
||
if (!targets || targets.activeTargets == null) return 'outline'
|
||
if (targets.rejectedTotal > 0) return 'destructive'
|
||
if (targets.saturatedTotal > 0) return 'warning'
|
||
return 'success'
|
||
})
|
||
const upstreamTargetStatusText = computed(() => {
|
||
if (upstreamTargetStatusVariant.value === 'outline') return '未接入'
|
||
if (upstreamTargetStatusVariant.value === 'destructive') return '有拒绝'
|
||
if (upstreamTargetStatusVariant.value === 'warning') return '有饱和'
|
||
return '正常'
|
||
})
|
||
const stageLatencyRows = computed(() => gatewayMetrics.value?.stageLatency.rows ?? [])
|
||
const tunnelQueueRejectedTotal = computed(() => (
|
||
(gatewayMetrics.value?.tunnel.outboundQueueRejectedFullTotal ?? 0)
|
||
+ (gatewayMetrics.value?.tunnel.outboundQueueRejectedClosedTotal ?? 0)
|
||
))
|
||
const tunnelSelectionPressureTotal = computed(() => (
|
||
(gatewayMetrics.value?.tunnel.proxyConnectionCongestedTotal ?? 0)
|
||
+ (gatewayMetrics.value?.tunnel.selectionRetryTotal ?? 0)
|
||
+ (gatewayMetrics.value?.tunnel.selectionUnavailableTotal ?? 0)
|
||
))
|
||
const tunnelQueueUtilization = computed(() => {
|
||
const depth = gatewayMetrics.value?.tunnel.outboundQueueDepthTotal
|
||
const capacity = gatewayMetrics.value?.tunnel.outboundQueueCapacityTotal
|
||
if (depth == null || capacity == null || capacity <= 0) return null
|
||
return Math.max(0, Math.min(100, depth / capacity * 100))
|
||
})
|
||
const tunnelQueueUtilizationText = computed(() => (
|
||
tunnelQueueUtilization.value == null ? '-' : `${Math.round(tunnelQueueUtilization.value)}%`
|
||
))
|
||
const tunnelQueueUtilizationWidth = computed(() => (
|
||
tunnelQueueUtilization.value == null ? '0%' : `${tunnelQueueUtilization.value}%`
|
||
))
|
||
|
||
const slaValueClass = computed(() => successRateClass(providerPerformance.value?.summary.success_rate))
|
||
const errorRateValueClass = computed(() => {
|
||
const rate = providerPerformance.value?.summary.success_rate
|
||
if (rate == null) return ''
|
||
const successPercent = rate <= 1 ? rate * 100 : rate
|
||
const errorPercent = Math.max(0, 100 - successPercent)
|
||
if (errorPercent <= 5) return 'text-green-600 dark:text-green-400'
|
||
if (errorPercent <= 20) return 'text-amber-600 dark:text-amber-400'
|
||
return 'text-red-600 dark:text-red-400'
|
||
})
|
||
|
||
const providerRows = computed(() => providerPerformance.value?.providers.slice(0, 8) ?? [])
|
||
const cacheHitRate = computed(() => cacheStats.value?.affinity_stats.cache_hit_rate ?? null)
|
||
const redisStatusText = computed(() => {
|
||
const runtime = gatewayMetrics.value?.redisRuntime
|
||
if (runtime?.unavailable) return '异常'
|
||
if (runtime?.enabled) return '在线'
|
||
if (!redisCategories.value) return '-'
|
||
return redisCategories.value.available ? '在线' : '未启用'
|
||
})
|
||
const redisStatusClass = computed(() => {
|
||
const runtime = gatewayMetrics.value?.redisRuntime
|
||
if (runtime?.unavailable) return 'text-red-600 dark:text-red-400'
|
||
if (
|
||
(redisRuntimeCommandFaults.value ?? 0) > 0
|
||
|| (redisRuntimeMemoryPercent.value ?? 0) >= 90
|
||
|| (gatewayMetrics.value?.redisRuntime.nonblockingCommandLatencyMaxMs ?? 0) >= 500
|
||
) {
|
||
return 'text-red-600 dark:text-red-400'
|
||
}
|
||
if (
|
||
(redisRuntimeMemoryPercent.value ?? 0) >= 80
|
||
|| (gatewayMetrics.value?.redisRuntime.nonblockingCommandLatencyMaxMs ?? 0) >= 100
|
||
) return 'text-amber-600 dark:text-amber-400'
|
||
if (runtime?.enabled) return 'text-green-600 dark:text-green-400'
|
||
if (!redisCategories.value) return ''
|
||
return redisCategories.value.available ? 'text-green-600 dark:text-green-400' : 'text-amber-600 dark:text-amber-400'
|
||
})
|
||
const redisRuntimeMemoryPercent = computed(() => basisPointsPercent(gatewayMetrics.value?.redisRuntime.memoryUsageBasisPoints))
|
||
const redisRuntimeMemoryText = computed(() => {
|
||
const runtime = gatewayMetrics.value?.redisRuntime
|
||
if (runtime?.usedMemoryBytes == null) return '-'
|
||
if ((runtime.maxmemoryBytes ?? 0) > 0) {
|
||
return `${formatBytes(runtime.usedMemoryBytes)} / ${formatBytes(runtime.maxmemoryBytes)}`
|
||
}
|
||
return formatBytes(runtime.usedMemoryBytes)
|
||
})
|
||
const redisRuntimeOpsText = computed(() => {
|
||
const ops = gatewayMetrics.value?.redisRuntime.instantaneousOpsPerSec
|
||
if (ops == null || Number.isNaN(ops)) return '-'
|
||
return `${formatMetricNumber(ops)}/s`
|
||
})
|
||
const redisRuntimeLatencyText = computed(() => formatMs(
|
||
gatewayMetrics.value?.redisRuntime.nonblockingCommandLatencyMaxMs
|
||
))
|
||
const redisRuntimeClientsText = computed(() => formatMetricRatio(
|
||
gatewayMetrics.value?.redisRuntime.connectedClients,
|
||
gatewayMetrics.value?.redisRuntime.blockedClients
|
||
))
|
||
const redisRuntimeCommandFaults = computed(() => {
|
||
const runtime = gatewayMetrics.value?.redisRuntime
|
||
if (!runtime) return null
|
||
return (runtime.laneCommandErrorsTotal ?? 0)
|
||
+ (runtime.laneCommandTimeoutsTotal ?? 0)
|
||
+ (runtime.totalErrorReplies ?? 0)
|
||
+ (runtime.rejectedConnectionsTotal ?? 0)
|
||
+ (runtime.evictedKeysTotal ?? 0)
|
||
})
|
||
const redisCategoryRows = computed(() => (
|
||
redisCategories.value?.categories
|
||
.slice()
|
||
.sort((left, right) => right.count - left.count)
|
||
.slice(0, 6) ?? []
|
||
))
|
||
const recentErrors = computed<AdminMonitoringRecentError[]>(() => resilienceStatus.value?.recent_errors.slice(0, 6) ?? [])
|
||
|
||
const opsLinks = [
|
||
{ label: '性能分析', to: '/admin/performance-analysis', icon: Gauge },
|
||
{ label: '健康监控', to: '/admin/health-monitor', icon: ShieldCheck },
|
||
{ label: '使用记录', to: '/admin/usage', icon: ListChecks },
|
||
{ label: '缓存监控', to: '/admin/cache-monitoring', icon: Database },
|
||
{ label: '审计日志', to: '/admin/audit-logs', icon: AlertTriangle },
|
||
{ label: '异步任务', to: '/admin/async-tasks', icon: RefreshCw },
|
||
]
|
||
|
||
watch(timeRange, () => {
|
||
analyticsGeneration += 1
|
||
timeSeries.value = []
|
||
percentiles.value = []
|
||
providerPerformance.value = null
|
||
errorDistribution.value = []
|
||
errorDistributionLoaded.value = false
|
||
analyticsWarning.value = null
|
||
lastUpdatedAt.value = null
|
||
trendLoading.value = false
|
||
percentileLoading.value = false
|
||
if (autoRefresh.value) {
|
||
void refreshAll()
|
||
}
|
||
}, { deep: true })
|
||
|
||
onMounted(() => {
|
||
void refreshAll()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
stopAutoRefresh()
|
||
requestId += 1
|
||
analyticsGeneration += 1
|
||
})
|
||
</script>
|