mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-07 02:02:27 +08:00
257 lines
8.3 KiB
Vue
257 lines
8.3 KiB
Vue
<template>
|
||
<div class="w-full space-y-1">
|
||
<!-- 时间线 -->
|
||
<div class="flex items-center gap-px h-6 w-full">
|
||
<TooltipProvider v-for="(segment, index) in segments" :key="index" :delay-duration="100">
|
||
<Tooltip>
|
||
<TooltipTrigger as-child>
|
||
<div
|
||
class="flex-1 h-full rounded-sm transition-all duration-150 cursor-pointer hover:scale-y-110 hover:brightness-110"
|
||
:class="segment.color"
|
||
></div>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="top" :side-offset="8" class="max-w-xs">
|
||
<div class="text-xs whitespace-pre-line">{{ segment.tooltip }}</div>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
</div>
|
||
<!-- 时间标签 -->
|
||
<div class="flex items-center justify-between text-[10px] text-muted-foreground">
|
||
<span>{{ earliestTime }}</span>
|
||
<span>{{ latestTime }}</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed } from 'vue'
|
||
import type { EndpointStatusMonitor, EndpointHealthEvent, PublicEndpointStatusMonitor, PublicHealthEvent } from '@/api/endpoints'
|
||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||
|
||
// 组件同时支持管理员端和用户端的监控数据类型
|
||
// - EndpointStatusMonitor: 管理员端,包含 provider_count, key_count 等敏感信息
|
||
// - PublicEndpointStatusMonitor: 用户端,不含敏感信息
|
||
const props = defineProps<{
|
||
monitor?: EndpointStatusMonitor | PublicEndpointStatusMonitor | null
|
||
segmentCount?: number
|
||
lookbackHours?: number
|
||
}>()
|
||
|
||
// 固定格子数量,将实际事件按时间均匀分布到格子中
|
||
const GRID_COUNT = 100
|
||
|
||
const segments = computed(() => {
|
||
const gridCount = props.segmentCount ?? GRID_COUNT
|
||
const lookbackHours = props.lookbackHours ?? 6
|
||
const usageTimeline = Array.isArray(props.monitor?.timeline)
|
||
? props.monitor?.timeline ?? []
|
||
: []
|
||
|
||
if (usageTimeline.length > 0) {
|
||
return buildUsageTimelineSegments(
|
||
usageTimeline,
|
||
props.monitor?.time_range_start ?? null,
|
||
props.monitor?.time_range_end ?? null,
|
||
lookbackHours
|
||
)
|
||
}
|
||
|
||
const events = props.monitor?.events ?? []
|
||
|
||
// 无数据时显示空白格子
|
||
if (events.length === 0) {
|
||
return Array.from({ length: gridCount }, () => ({
|
||
color: 'bg-gray-300 dark:bg-gray-600',
|
||
tooltip: '暂无请求记录'
|
||
}))
|
||
}
|
||
|
||
// 计算时间范围:使用 UTC 时间戳避免时区问题
|
||
const nowUtc = Date.now()
|
||
const startTimeUtc = nowUtc - lookbackHours * 60 * 60 * 1000
|
||
const timeRange = lookbackHours * 60 * 60 * 1000
|
||
const timePerGrid = timeRange / gridCount
|
||
|
||
const gridEvents: Array<Array<EndpointHealthEvent | PublicHealthEvent>> = Array.from({ length: gridCount }, () => [])
|
||
|
||
for (const event of events) {
|
||
const eventTime = new Date(event.timestamp).getTime()
|
||
const gridIndex = Math.floor((eventTime - startTimeUtc) / timePerGrid)
|
||
if (gridIndex >= 0 && gridIndex < gridCount) {
|
||
gridEvents[gridIndex].push(event)
|
||
}
|
||
}
|
||
|
||
const result: Array<{ color: string; tooltip: string }> = []
|
||
|
||
for (let i = 0; i < gridCount; i++) {
|
||
const cellEvents = gridEvents[i]
|
||
const cellStartTime = new Date(startTimeUtc + i * timePerGrid)
|
||
const cellEndTime = new Date(startTimeUtc + (i + 1) * timePerGrid)
|
||
|
||
if (cellEvents.length === 0) {
|
||
result.push({
|
||
color: 'bg-gray-300 dark:bg-gray-600',
|
||
tooltip: `${formatTimestamp(cellStartTime.toISOString())} - ${formatTimestamp(cellEndTime.toISOString())}\n暂无请求记录`
|
||
})
|
||
continue
|
||
}
|
||
|
||
if (cellEvents.length === 1) {
|
||
result.push({
|
||
color: getStatusColor(cellEvents[0].status),
|
||
tooltip: buildTooltip(cellEvents[0])
|
||
})
|
||
continue
|
||
}
|
||
|
||
const successCount = cellEvents.filter(e => e.status === 'success').length
|
||
const failedCount = cellEvents.filter(e => e.status === 'failed').length
|
||
const skippedCount = cellEvents.filter(e => e.status === 'skipped').length
|
||
const total = cellEvents.length
|
||
|
||
let color: string
|
||
if (failedCount > 0) {
|
||
const failRate = failedCount / total
|
||
color = failRate > 0.5 ? 'bg-red-500' : 'bg-red-400/80'
|
||
} else if (successCount > 0) {
|
||
const successRate = successCount / total
|
||
color = successRate > 0.7 ? 'bg-green-500/80' : 'bg-green-400/80'
|
||
} else if (skippedCount > 0) {
|
||
color = 'bg-amber-400/80'
|
||
} else {
|
||
color = 'bg-gray-300 dark:bg-gray-600'
|
||
}
|
||
|
||
const firstTime = formatTimestamp(cellEvents[0]?.timestamp)
|
||
const lastTime = formatTimestamp(cellEvents[cellEvents.length - 1]?.timestamp)
|
||
const tooltip = `${firstTime} - ${lastTime}\n共 ${total} 次请求\n成功: ${successCount}, 失败: ${failedCount}, 跳过: ${skippedCount}`
|
||
|
||
result.push({ color, tooltip })
|
||
}
|
||
|
||
return result
|
||
})
|
||
|
||
function getStatusColor(status: string) {
|
||
switch (status) {
|
||
case 'success':
|
||
return 'bg-green-500/80 dark:bg-green-400/90'
|
||
case 'failed':
|
||
return 'bg-red-500/80 dark:bg-red-400/90'
|
||
case 'skipped':
|
||
return 'bg-amber-400/80 dark:bg-amber-300/80'
|
||
case 'started':
|
||
return 'bg-blue-400/80 dark:bg-blue-300/80'
|
||
default:
|
||
return 'bg-muted/50 dark:bg-muted/20'
|
||
}
|
||
}
|
||
|
||
function buildTooltip(event: EndpointHealthEvent | PublicHealthEvent) {
|
||
const time = formatTimestamp(event.timestamp)
|
||
const statusText = getStatusText(event.status)
|
||
const latency = event.latency_ms ? ` • ${event.latency_ms}ms` : ''
|
||
const code = event.status_code ? ` • ${event.status_code}` : ''
|
||
const error = event.error_type ? ` • ${event.error_type}` : ''
|
||
return `${time} ${statusText}${latency}${code}${error}`
|
||
}
|
||
|
||
function getStatusText(status: string) {
|
||
switch (status) {
|
||
case 'success':
|
||
return '成功'
|
||
case 'failed':
|
||
return '失败'
|
||
case 'skipped':
|
||
return '跳过'
|
||
case 'started':
|
||
return '执行中'
|
||
default:
|
||
return '未知'
|
||
}
|
||
}
|
||
|
||
function formatTimestamp(timestamp?: string | null) {
|
||
if (!timestamp) return '未知时间'
|
||
const date = new Date(timestamp)
|
||
return date.toLocaleString('zh-CN', {
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit'
|
||
})
|
||
}
|
||
|
||
// 计算时间范围显示
|
||
const earliestTime = computed(() => {
|
||
const explicitStart =
|
||
(props.monitor as (EndpointStatusMonitor | PublicEndpointStatusMonitor | null))?.time_range_start
|
||
if (explicitStart) return formatTimestamp(explicitStart)
|
||
const lookbackHours = props.lookbackHours ?? 6
|
||
const startTime = new Date(Date.now() - lookbackHours * 60 * 60 * 1000)
|
||
return formatTimestamp(startTime.toISOString())
|
||
})
|
||
|
||
const latestTime = computed(() => {
|
||
const explicitEnd =
|
||
(props.monitor as (EndpointStatusMonitor | PublicEndpointStatusMonitor | null))?.time_range_end
|
||
if (explicitEnd) return formatTimestamp(explicitEnd)
|
||
return formatTimestamp(new Date().toISOString())
|
||
})
|
||
|
||
function buildUsageTimelineSegments(
|
||
statuses: string[],
|
||
timeRangeStart: string | null,
|
||
timeRangeEnd: string | null,
|
||
lookbackHours: number
|
||
) {
|
||
const gridCount = statuses.length
|
||
const endTime = timeRangeEnd ? new Date(timeRangeEnd).getTime() : Date.now()
|
||
const startTime = timeRangeStart
|
||
? new Date(timeRangeStart).getTime()
|
||
: endTime - lookbackHours * 60 * 60 * 1000
|
||
const safeRange = Math.max(endTime - startTime, 1)
|
||
const interval = safeRange / gridCount
|
||
|
||
return statuses.map((status, index) => {
|
||
const cellStart = new Date(startTime + index * interval)
|
||
const cellEnd = new Date(startTime + (index + 1) * interval)
|
||
return {
|
||
color: getHealthTimelineColor(status),
|
||
tooltip: `${formatTimestamp(cellStart.toISOString())} - ${formatTimestamp(
|
||
cellEnd.toISOString()
|
||
)}\n状态:${getHealthTimelineLabel(status)}`
|
||
}
|
||
})
|
||
}
|
||
|
||
function getHealthTimelineColor(status: string) {
|
||
switch (status) {
|
||
case 'healthy':
|
||
return 'bg-green-500/80 dark:bg-green-400/90'
|
||
case 'warning':
|
||
return 'bg-amber-400/80 dark:bg-amber-300/80'
|
||
case 'unhealthy':
|
||
return 'bg-red-500/80 dark:bg-red-400/90'
|
||
default:
|
||
return 'bg-gray-300 dark:bg-gray-600'
|
||
}
|
||
}
|
||
|
||
function getHealthTimelineLabel(status: string) {
|
||
switch (status) {
|
||
case 'healthy':
|
||
return '健康'
|
||
case 'warning':
|
||
return '警告'
|
||
case 'unhealthy':
|
||
return '异常'
|
||
default:
|
||
return '未知'
|
||
}
|
||
}
|
||
</script>
|