mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
fix(frontend): improve observability chart continuity
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import type { LineSeriesOption } from 'echarts'
|
||||
|
||||
type TemporalValue = number | null | undefined
|
||||
|
||||
interface GapRun {
|
||||
startIndex: number
|
||||
endIndex: number
|
||||
}
|
||||
|
||||
interface GapBridgeOptions {
|
||||
name: string
|
||||
color: string
|
||||
xAxisIndex?: number
|
||||
yAxisIndex?: number
|
||||
width?: number
|
||||
z?: number
|
||||
maxGapBuckets?: number
|
||||
}
|
||||
|
||||
const defaultShortGapBuckets = 2
|
||||
|
||||
export function sampleGapBridgeSeries(
|
||||
values: TemporalValue[],
|
||||
options: GapBridgeOptions,
|
||||
): LineSeriesOption[] {
|
||||
const data = values.map(normalizeValue)
|
||||
const maxGapBuckets = options.maxGapBuckets ?? defaultShortGapBuckets
|
||||
const series: LineSeriesOption[] = []
|
||||
|
||||
for (const [runIndex, run] of boundedGapRuns(data)
|
||||
.filter(run => gapLength(run) <= maxGapBuckets)
|
||||
.entries()) {
|
||||
const beforeIndex = run.startIndex - 1
|
||||
const afterIndex = run.endIndex + 1
|
||||
const beforeValue = data[beforeIndex]
|
||||
const afterValue = data[afterIndex]
|
||||
const bridge = Array.from<number | null>({ length: data.length }).fill(null)
|
||||
|
||||
if (beforeValue == null || afterValue == null)
|
||||
continue
|
||||
|
||||
const span = afterIndex - beforeIndex
|
||||
for (let index = beforeIndex; index <= afterIndex; index += 1) {
|
||||
const progress = (index - beforeIndex) / span
|
||||
bridge[index] = beforeValue + (afterValue - beforeValue) * progress
|
||||
}
|
||||
|
||||
series.push({
|
||||
name: `${options.name}-gap-${runIndex}`,
|
||||
type: 'line',
|
||||
data: bridge,
|
||||
connectNulls: false,
|
||||
smooth: false,
|
||||
showSymbol: false,
|
||||
silent: true,
|
||||
xAxisIndex: options.xAxisIndex ?? 0,
|
||||
yAxisIndex: options.yAxisIndex ?? 0,
|
||||
z: Math.max(0, (options.z ?? 2) - 1),
|
||||
lineStyle: {
|
||||
color: options.color,
|
||||
type: 'dashed',
|
||||
width: Math.max(1, (options.width ?? 2.2) - 0.7),
|
||||
opacity: 0.32,
|
||||
},
|
||||
itemStyle: { color: options.color, opacity: 0 },
|
||||
emphasis: { disabled: true },
|
||||
tooltip: { show: false },
|
||||
})
|
||||
}
|
||||
|
||||
return series
|
||||
}
|
||||
|
||||
export function requestActivityByBucket(
|
||||
buckets: string[],
|
||||
activity: Array<{ bucket: string, totalRequests: number }>,
|
||||
) {
|
||||
const requestsByBucket = new Map(
|
||||
activity.map(point => [point.bucket, Math.max(0, point.totalRequests)]),
|
||||
)
|
||||
return buckets.map(bucket => (requestsByBucket.get(bucket) ?? 0) > 0)
|
||||
}
|
||||
|
||||
export function zeroInactiveValues(
|
||||
values: TemporalValue[],
|
||||
active: boolean[],
|
||||
) {
|
||||
return values.map((value, index) => normalizeValue(value) ?? (active[index] === false ? 0 : null))
|
||||
}
|
||||
|
||||
function normalizeValue(value: TemporalValue) {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
function boundedGapRuns(values: Array<number | null>) {
|
||||
return gapRuns(values.map(value => value == null)).filter(
|
||||
run => run.startIndex > 0 && run.endIndex < values.length - 1,
|
||||
)
|
||||
}
|
||||
|
||||
function gapRuns(isGap: boolean[]) {
|
||||
const runs: GapRun[] = []
|
||||
let startIndex = -1
|
||||
|
||||
for (let index = 0; index <= isGap.length; index += 1) {
|
||||
if (isGap[index] && startIndex < 0) {
|
||||
startIndex = index
|
||||
continue
|
||||
}
|
||||
if (isGap[index] || startIndex < 0)
|
||||
continue
|
||||
runs.push({ startIndex, endIndex: index - 1 })
|
||||
startIndex = -1
|
||||
}
|
||||
|
||||
return runs
|
||||
}
|
||||
|
||||
function gapLength(run: GapRun) {
|
||||
return run.endIndex - run.startIndex + 1
|
||||
}
|
||||
@@ -373,10 +373,10 @@ function aggregateUsageTrend(points: DashboardTrendPoint[]) {
|
||||
)
|
||||
const uncachedInputTokensValue = inputTokensValue - cachedTokensValue
|
||||
const effectiveTokensValue = uncachedInputTokensValue + outputTokensValue
|
||||
const cacheHitRateValue = inputTokensValue ? cachedTokensValue / inputTokensValue : null
|
||||
const cacheHitRateValue = inputTokensValue ? cachedTokensValue / inputTokensValue : 0
|
||||
const successRateValue = requestsValue
|
||||
? ((requestsValue - errorsValue) / requestsValue) * 100
|
||||
: null
|
||||
: 0
|
||||
|
||||
return {
|
||||
...first,
|
||||
@@ -392,12 +392,12 @@ function aggregateUsageTrend(points: DashboardTrendPoint[]) {
|
||||
uncachedInputTokensValue,
|
||||
effectiveTokens: formatCompactNumber(effectiveTokensValue),
|
||||
effectiveTokensValue: effectiveTokensValue > 0 ? effectiveTokensValue : null,
|
||||
cacheHitRate: cacheHitRateValue === null ? '—' : formatDashboardRate(cacheHitRateValue),
|
||||
cacheHitRate: formatDashboardRate(cacheHitRateValue),
|
||||
cacheHitRateValue,
|
||||
tokensValue: inputTokensValue + outputTokensValue,
|
||||
errors: formatCompactNumber(errorsValue),
|
||||
errorsValue,
|
||||
successRate: successRateValue === null ? '—' : `${successRateValue.toFixed(1)}%`,
|
||||
successRate: `${successRateValue.toFixed(1)}%`,
|
||||
successRateValue,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { dashboardTrendView, normalizeDashboardTrendKind } from './useDashb
|
||||
import { usePreferredReducedMotion } from '@vueuse/core'
|
||||
import { computed, shallowRef, watch } from 'vue'
|
||||
|
||||
import { sampleGapBridgeSeries } from '@/components/charts/timeSeriesGap'
|
||||
import { chartTooltipStyle, tooltipIndex } from '@/components/charts/tooltip'
|
||||
import { useChartPalette } from '@/composables/useChartPalette'
|
||||
|
||||
@@ -197,7 +198,7 @@ export function useRequestTrendChart(options: {
|
||||
),
|
||||
tooltipItem(
|
||||
'缓存命中',
|
||||
trendPointText(point, 'cacheHitRate'),
|
||||
zeroAwareRateText(point, 'cacheHitRate', 'inputTokensValue'),
|
||||
trendColor('缓存', '--cp-color-text-tertiary', '#94A3B8'),
|
||||
),
|
||||
tooltipItem('请求', point?.requests, palette.value.textSecondary),
|
||||
@@ -235,19 +236,19 @@ export function useRequestTrendChart(options: {
|
||||
if (name === '错误数')
|
||||
return point?.errors ?? value
|
||||
if (name === '成功率')
|
||||
return point?.successRate ?? value
|
||||
return zeroAwareRateText(point, 'successRate', 'requestsValue') ?? value
|
||||
if (name === '总请求')
|
||||
return point?.requests ?? value
|
||||
return value
|
||||
}
|
||||
|
||||
function getSeries() {
|
||||
function getSeries(): Array<BarSeriesOption | LineSeriesOption> {
|
||||
if (activeKind.value === 'usage') {
|
||||
const cacheColor = trendColor('缓存', '--cp-color-text-tertiary', '#94A3B8')
|
||||
const inputColor = trendColor('输入', '--cp-color-blue-solid', '#5983F4')
|
||||
const outputColor = trendColor('输出', '--cp-color-green-solid', '#12B981')
|
||||
return [
|
||||
lineSeries('缓存', activeSeriesValues('cachedTokensValue'), cacheColor, {
|
||||
lineSeries('缓存', seriesValues('cachedTokensValue'), cacheColor, {
|
||||
area: true,
|
||||
areaStartAlpha: '30',
|
||||
areaEndAlpha: '08',
|
||||
@@ -257,7 +258,7 @@ export function useRequestTrendChart(options: {
|
||||
width: 1.25,
|
||||
z: 1,
|
||||
}),
|
||||
lineSeries('输入', activeSeriesValues('uncachedInputTokensValue'), inputColor, {
|
||||
lineSeries('输入', seriesValues('uncachedInputTokensValue'), inputColor, {
|
||||
area: true,
|
||||
areaStartAlpha: '2A',
|
||||
areaEndAlpha: '05',
|
||||
@@ -266,7 +267,7 @@ export function useRequestTrendChart(options: {
|
||||
width: 2.3,
|
||||
z: 3,
|
||||
}),
|
||||
lineSeries('输出', activeSeriesValues('outputTokensValue'), outputColor, {
|
||||
lineSeries('输出', seriesValues('outputTokensValue'), outputColor, {
|
||||
smooth: 0.24,
|
||||
width: 2.1,
|
||||
yAxisIndex: 1,
|
||||
@@ -277,19 +278,19 @@ export function useRequestTrendChart(options: {
|
||||
|
||||
if (activeKind.value === 'latency') {
|
||||
return [
|
||||
lineSeries(
|
||||
...gapLineSeries(
|
||||
'总耗时 P95',
|
||||
activeSeriesValues('latencyP95Ms'),
|
||||
trendColor('总耗时', '--cp-color-orange-solid', '#F59E0B'),
|
||||
{ lineType: 'dashed', smooth: 0.2, width: 1.8, z: 2 },
|
||||
),
|
||||
lineSeries(
|
||||
...gapLineSeries(
|
||||
'首字 P95',
|
||||
activeSeriesValues('firstTokenP95Ms'),
|
||||
trendColor('首字', '--cp-color-cyan-solid', '#13C2C2'),
|
||||
{ area: true, smooth: 0.24, width: 2.5, z: 4 },
|
||||
),
|
||||
lineSeries(
|
||||
...gapLineSeries(
|
||||
'吞吐 P50',
|
||||
activeSeriesValues('outputThroughputP50'),
|
||||
trendColor('吞吐', '--cp-color-green-solid', '#12B981'),
|
||||
@@ -304,9 +305,9 @@ export function useRequestTrendChart(options: {
|
||||
seriesValues('errorsValue'),
|
||||
trendColor('错误', '--cp-color-red-solid', '#EF4444'),
|
||||
),
|
||||
lineSeries(
|
||||
...gapLineSeries(
|
||||
'成功率',
|
||||
seriesValues('successRateValue'),
|
||||
activeSeriesValues('successRateValue'),
|
||||
trendColor('成功', '--cp-color-green-solid', '#12B981'),
|
||||
{ width: 2.4, yAxisIndex: 1 },
|
||||
),
|
||||
@@ -325,7 +326,7 @@ export function useRequestTrendChart(options: {
|
||||
|
||||
function activeSeriesValues(key: string) {
|
||||
return points.value.map(point =>
|
||||
Number(point.requestsValue) > 0 ? trendPointNumber(point, key) : null,
|
||||
Number(point.requestsValue) > 0 ? trendPointNumber(point, key) : 0,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -377,6 +378,26 @@ export function useRequestTrendChart(options: {
|
||||
}
|
||||
}
|
||||
|
||||
function gapLineSeries(
|
||||
name: string,
|
||||
data: (number | null)[],
|
||||
color: string,
|
||||
lineOptions: LineSeriesOptions = {},
|
||||
) {
|
||||
const primary = lineSeries(name, data, color, lineOptions)
|
||||
return [
|
||||
primary,
|
||||
...sampleGapBridgeSeries(data, {
|
||||
name,
|
||||
color,
|
||||
width: lineOptions.width,
|
||||
xAxisIndex: lineOptions.xAxisIndex,
|
||||
yAxisIndex: lineOptions.yAxisIndex,
|
||||
z: lineOptions.z,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function barSeries(
|
||||
name: string,
|
||||
data: (number | null)[],
|
||||
@@ -490,7 +511,7 @@ function tooltipValue(source: unknown, key: string) {
|
||||
function trendPointProperty(point: TrendPoint | undefined, key: string) {
|
||||
if (!point || !(key in point))
|
||||
return undefined
|
||||
return (point as Record<string, unknown>)[key]
|
||||
return (point as unknown as Record<string, unknown>)[key]
|
||||
}
|
||||
|
||||
function trendPointText(point: TrendPoint | undefined, key: string) {
|
||||
@@ -498,6 +519,17 @@ function trendPointText(point: TrendPoint | undefined, key: string) {
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
function zeroAwareRateText(
|
||||
point: TrendPoint | undefined,
|
||||
valueKey: string,
|
||||
activityKey: string,
|
||||
) {
|
||||
const activity = trendPointProperty(point, activityKey)
|
||||
if (typeof activity === 'number' && activity <= 0)
|
||||
return '0.0%'
|
||||
return trendPointText(point, valueKey)
|
||||
}
|
||||
|
||||
function trendPointNumber(point: TrendPoint, key: string) {
|
||||
const value = trendPointProperty(point, key)
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
|
||||
@@ -7,6 +7,10 @@ import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseEmpty from '@/components/base/BaseEmpty.vue'
|
||||
import BaseSegmented from '@/components/base/BaseSegmented.vue'
|
||||
import BaseChart from '@/components/charts/BaseChart.vue'
|
||||
import {
|
||||
requestActivityByBucket,
|
||||
zeroInactiveValues,
|
||||
} from '@/components/charts/timeSeriesGap'
|
||||
import { useChartPalette } from '@/composables/useChartPalette'
|
||||
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/number'
|
||||
|
||||
@@ -14,10 +18,11 @@ import {
|
||||
tooltipIndex,
|
||||
tooltipRows,
|
||||
usageCategoryAxis,
|
||||
usageGapAwareLineSeries,
|
||||
usageLegend,
|
||||
usageLineSeries,
|
||||
usageTooltip,
|
||||
usageTooltipContent,
|
||||
usageTooltipItem,
|
||||
usageValueAxis,
|
||||
} from '../utils/chart'
|
||||
import {
|
||||
@@ -28,11 +33,13 @@ import {
|
||||
} from '../utils/format'
|
||||
|
||||
type Cost = Awaited<ReturnType<typeof getUsageRecordInsightsOverview>>['cost']
|
||||
type Activity = Awaited<ReturnType<typeof getUsageRecordInsightsOverview>>['health']['points']
|
||||
type UsageChartPalette = ReturnType<typeof useChartPalette>['palette']['value']
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
cost: Cost
|
||||
activity: Activity
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{
|
||||
@@ -43,6 +50,10 @@ const props = withDefaults(
|
||||
const activeView = shallowRef('cost')
|
||||
const { palette } = useChartPalette()
|
||||
const points = computed(() => props.cost.points)
|
||||
const requestActivity = computed(() => requestActivityByBucket(
|
||||
points.value.map(point => point.bucket),
|
||||
props.activity,
|
||||
))
|
||||
const hasNoCacheCost = computed(() =>
|
||||
points.value.some(point => point.noCacheCost != null),
|
||||
)
|
||||
@@ -74,6 +85,7 @@ const hasData = computed(() => {
|
||||
const chartOption = computed<EChartsOption>(() => {
|
||||
const theme = palette.value
|
||||
const legend = legendNames()
|
||||
const series = chartSeries(theme)
|
||||
|
||||
return {
|
||||
animationDuration: 240,
|
||||
@@ -95,7 +107,7 @@ const chartOption = computed<EChartsOption>(() => {
|
||||
min: activeView.value === 'cache' ? 0 : undefined,
|
||||
max: activeView.value === 'cache' ? 1 : undefined,
|
||||
}),
|
||||
series: chartSeries(theme),
|
||||
series,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -119,9 +131,12 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] {
|
||||
const chartPoints = points.value
|
||||
if (activeView.value === 'savings') {
|
||||
return [
|
||||
usageLineSeries(
|
||||
...usageGapAwareLineSeries(
|
||||
'缓存节省',
|
||||
chartPoints.map(point => decimalDisplayNumber(point.cacheSavings)),
|
||||
zeroInactiveValues(
|
||||
chartPoints.map(point => decimalDisplayNumber(point.cacheSavings)),
|
||||
requestActivity.value,
|
||||
),
|
||||
theme.success,
|
||||
{ area: 'strong' },
|
||||
),
|
||||
@@ -130,12 +145,12 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] {
|
||||
|
||||
if (activeView.value === 'cache') {
|
||||
return [
|
||||
usageLineSeries(
|
||||
...usageGapAwareLineSeries(
|
||||
'缓存 Token 占比',
|
||||
chartPoints.map(point => point.cachedTokenRate),
|
||||
theme.normal,
|
||||
),
|
||||
usageLineSeries(
|
||||
...usageGapAwareLineSeries(
|
||||
'命中请求率',
|
||||
chartPoints.map(point => point.cacheHitRequestRate),
|
||||
theme.success,
|
||||
@@ -144,18 +159,24 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] {
|
||||
}
|
||||
|
||||
const series = [
|
||||
usageLineSeries(
|
||||
...usageGapAwareLineSeries(
|
||||
'实际费用',
|
||||
chartPoints.map(point => decimalDisplayNumber(point.estimatedCost)),
|
||||
zeroInactiveValues(
|
||||
chartPoints.map(point => decimalDisplayNumber(point.estimatedCost)),
|
||||
requestActivity.value,
|
||||
),
|
||||
theme.success,
|
||||
{ area: 'strong' },
|
||||
),
|
||||
]
|
||||
if (hasNoCacheCost.value) {
|
||||
series.push(
|
||||
usageLineSeries(
|
||||
...usageGapAwareLineSeries(
|
||||
'无缓存费用',
|
||||
chartPoints.map(point => decimalDisplayNumber(point.noCacheCost)),
|
||||
zeroInactiveValues(
|
||||
chartPoints.map(point => decimalDisplayNumber(point.noCacheCost)),
|
||||
requestActivity.value,
|
||||
),
|
||||
theme.textMuted,
|
||||
),
|
||||
)
|
||||
@@ -165,29 +186,48 @@ function chartSeries(theme: UsageChartPalette): LineSeriesOption[] {
|
||||
|
||||
function formatTooltip(params: unknown) {
|
||||
const rows = tooltipRows(params)
|
||||
const point = points.value[tooltipIndex(rows[0])]
|
||||
const pointIndex = tooltipIndex(rows[0])
|
||||
const point = points.value[pointIndex]
|
||||
if (!point)
|
||||
return ''
|
||||
const theme = palette.value
|
||||
|
||||
if (activeView.value === 'savings') {
|
||||
return usageTooltipContent(palette.value, point.label, [
|
||||
`缓存节省: ${formatUsd(point.cacheSavings)}`,
|
||||
return usageTooltipContent(theme, point.label, [
|
||||
usageTooltipItem(
|
||||
'缓存节省',
|
||||
formatUsd(costAmount(point.cacheSavings, pointIndex)),
|
||||
theme.success,
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
if (activeView.value === 'cache') {
|
||||
return usageTooltipContent(palette.value, point.label, [
|
||||
`缓存 Token 占比: ${formatPercent(point.cachedTokenRate)}`,
|
||||
`命中请求率: ${formatPercent(point.cacheHitRequestRate)}`,
|
||||
return usageTooltipContent(theme, point.label, [
|
||||
usageTooltipItem('缓存 Token 占比', formatPercent(point.cachedTokenRate), theme.normal),
|
||||
usageTooltipItem('命中请求率', formatPercent(point.cacheHitRequestRate), theme.success),
|
||||
])
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`实际费用: ${formatUsd(point.estimatedCost)}`,
|
||||
usageTooltipItem(
|
||||
'实际费用',
|
||||
formatUsd(costAmount(point.estimatedCost, pointIndex)),
|
||||
theme.success,
|
||||
),
|
||||
]
|
||||
if (point.noCacheCost != null)
|
||||
lines.push(`无缓存费用: ${formatUsd(point.noCacheCost)}`)
|
||||
return usageTooltipContent(palette.value, point.label, lines)
|
||||
if (point.noCacheCost != null) {
|
||||
lines.push(usageTooltipItem(
|
||||
'无缓存费用',
|
||||
formatUsd(costAmount(point.noCacheCost, pointIndex)),
|
||||
theme.textMuted,
|
||||
))
|
||||
}
|
||||
return usageTooltipContent(theme, point.label, lines)
|
||||
}
|
||||
|
||||
function costAmount(value: string | null, pointIndex: number) {
|
||||
return value ?? (requestActivity.value[pointIndex] === false ? '0' : null)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from 'echarts'
|
||||
import type { BarSeriesOption, EChartsOption, LineSeriesOption } from 'echarts'
|
||||
import type { getUsageRecordInsightsOverview } from '@/api'
|
||||
import { BarChart } from 'echarts/charts'
|
||||
import { use } from 'echarts/core'
|
||||
@@ -15,9 +15,11 @@ import {
|
||||
tooltipIndex,
|
||||
tooltipRows,
|
||||
usageCategoryAxis,
|
||||
usageGapAwareLineSeries,
|
||||
usageLegend,
|
||||
usageTooltip,
|
||||
usageTooltipContent,
|
||||
usageTooltipItem,
|
||||
usageValueAxis,
|
||||
} from '../utils/chart'
|
||||
import { formatPercent } from '../utils/format'
|
||||
@@ -60,6 +62,26 @@ const chartOption = computed<EChartsOption>(() => {
|
||||
const theme = palette.value
|
||||
const chartPoints = points.value
|
||||
const activePointCount = chartPoints.filter(point => requestCount(point) > 0).length
|
||||
const successRateSeries = usageGapAwareLineSeries(
|
||||
'服务成功率',
|
||||
chartPoints.map(serviceSuccessRate),
|
||||
theme.success,
|
||||
{ yAxisIndex: 1 },
|
||||
)
|
||||
const series: Array<BarSeriesOption | LineSeriesOption> = [
|
||||
outcomeSeries('成功', chartPoints.map(point => point.successRequests), theme.success),
|
||||
outcomeSeries('服务失败', chartPoints.map(point => point.failedRequests), theme.danger),
|
||||
outcomeSeries('取消', chartPoints.map(point => point.cancelledRequests), theme.info),
|
||||
outcomeSeries('未完成', chartPoints.map(point => point.incompleteRequests), theme.warning),
|
||||
outcomeSeries('调用方错误', chartPoints.map(point => point.callerErrorRequests), theme.normal),
|
||||
...successRateSeries.map((item, index) => index === 0
|
||||
? {
|
||||
...item,
|
||||
symbol: activePointCount <= 16 ? 'circle' : 'none',
|
||||
symbolSize: 4,
|
||||
}
|
||||
: item),
|
||||
]
|
||||
|
||||
return {
|
||||
animationDuration: 240,
|
||||
@@ -85,25 +107,7 @@ const chartOption = computed<EChartsOption>(() => {
|
||||
splitLine: false,
|
||||
}),
|
||||
],
|
||||
series: [
|
||||
outcomeSeries('成功', chartPoints.map(point => point.successRequests), theme.success),
|
||||
outcomeSeries('服务失败', chartPoints.map(point => point.failedRequests), theme.danger),
|
||||
outcomeSeries('取消', chartPoints.map(point => point.cancelledRequests), theme.info),
|
||||
outcomeSeries('未完成', chartPoints.map(point => point.incompleteRequests), theme.warning),
|
||||
outcomeSeries('调用方错误', chartPoints.map(point => point.callerErrorRequests), theme.normal),
|
||||
{
|
||||
name: '服务成功率',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: chartPoints.map(serviceSuccessRate),
|
||||
connectNulls: true,
|
||||
smooth: 0.25,
|
||||
symbol: activePointCount <= 16 ? 'circle' : 'none',
|
||||
symbolSize: 4,
|
||||
lineStyle: { color: theme.success, width: 2.2 },
|
||||
itemStyle: { color: theme.success },
|
||||
},
|
||||
],
|
||||
series,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -113,7 +117,9 @@ function requestCount(point: HealthPoint) {
|
||||
|
||||
function serviceSuccessRate(point: HealthPoint) {
|
||||
const total = Math.max(0, point.successRequests) + Math.max(0, point.failedRequests)
|
||||
return total > 0 ? Math.max(0, point.successRequests ?? 0) / total : null
|
||||
if (total > 0)
|
||||
return Math.max(0, point.successRequests ?? 0) / total
|
||||
return requestCount(point) === 0 ? 0 : null
|
||||
}
|
||||
|
||||
function outcomeSeries(name: string, data: number[], color: string) {
|
||||
@@ -134,15 +140,20 @@ function formatTooltip(params: unknown) {
|
||||
return ''
|
||||
|
||||
const successRate = serviceSuccessRate(point)
|
||||
const theme = palette.value
|
||||
|
||||
return usageTooltipContent(palette.value, point.label, [
|
||||
`请求量: ${formatCompactNumber(requestCount(point))}`,
|
||||
`成功: ${formatCompactNumber(point.successRequests)}`,
|
||||
`服务失败: ${formatCompactNumber(point.failedRequests)}`,
|
||||
`取消: ${formatCompactNumber(point.cancelledRequests)}`,
|
||||
`未完成: ${formatCompactNumber(point.incompleteRequests)}`,
|
||||
`调用方错误: ${formatCompactNumber(point.callerErrorRequests)}`,
|
||||
`服务成功率: ${successRate == null ? '无服务结果' : formatPercent(successRate)}`,
|
||||
return usageTooltipContent(theme, point.label, [
|
||||
usageTooltipItem('请求量', formatCompactNumber(requestCount(point)), theme.textSecondary),
|
||||
usageTooltipItem('成功', formatCompactNumber(point.successRequests), theme.success),
|
||||
usageTooltipItem('服务失败', formatCompactNumber(point.failedRequests), theme.danger),
|
||||
usageTooltipItem('取消', formatCompactNumber(point.cancelledRequests), theme.info),
|
||||
usageTooltipItem('未完成', formatCompactNumber(point.incompleteRequests), theme.warning),
|
||||
usageTooltipItem('调用方错误', formatCompactNumber(point.callerErrorRequests), theme.normal),
|
||||
usageTooltipItem(
|
||||
'服务成功率',
|
||||
successRate == null ? '无服务结果' : formatPercent(successRate),
|
||||
theme.success,
|
||||
),
|
||||
])
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -28,7 +28,7 @@ const diagnosticDimension = defineModel('diagnosticDimension', {
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="mt-5 grid grid-cols-1 gap-3 xl:auto-rows-[24.25rem] xl:grid-cols-2"
|
||||
class="mt-5 grid grid-cols-1 gap-3 xl:auto-rows-97 xl:grid-cols-2"
|
||||
aria-label="使用统计观测"
|
||||
>
|
||||
<UsageHealthCard
|
||||
@@ -43,8 +43,16 @@ const diagnosticDimension = defineModel('diagnosticDimension', {
|
||||
:loading="loading"
|
||||
/>
|
||||
|
||||
<UsagePerformanceCard :performance="overview.performance" :loading="loading" />
|
||||
<UsagePerformanceCard
|
||||
:performance="overview.performance"
|
||||
:activity="overview.health.points"
|
||||
:loading="loading"
|
||||
/>
|
||||
|
||||
<UsageCostCard :cost="overview.cost" :loading="loading" />
|
||||
<UsageCostCard
|
||||
:cost="overview.cost"
|
||||
:activity="overview.health.points"
|
||||
:loading="loading"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -7,6 +7,7 @@ import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseEmpty from '@/components/base/BaseEmpty.vue'
|
||||
import BaseSegmented from '@/components/base/BaseSegmented.vue'
|
||||
import BaseChart from '@/components/charts/BaseChart.vue'
|
||||
import { requestActivityByBucket, zeroInactiveValues } from '@/components/charts/timeSeriesGap'
|
||||
import { useChartPalette } from '@/composables/useChartPalette'
|
||||
import { formatLocalizedCompactNumber as formatCompactNumber } from '@/utils/number'
|
||||
|
||||
@@ -14,20 +15,23 @@ import {
|
||||
tooltipIndex,
|
||||
tooltipRows,
|
||||
usageCategoryAxis,
|
||||
usageGapAwareLineSeries,
|
||||
usageLegend,
|
||||
usageLineSeries,
|
||||
usageTooltip,
|
||||
usageTooltipContent,
|
||||
usageTooltipItem,
|
||||
usageValueAxis,
|
||||
} from '../utils/chart'
|
||||
import { formatDuration, formatDurationAxis, formatPercent } from '../utils/format'
|
||||
|
||||
type Performance = Awaited<ReturnType<typeof getUsageRecordInsightsOverview>>['performance']
|
||||
type PerformancePoint = Performance['points'][number]
|
||||
type Activity = Awaited<ReturnType<typeof getUsageRecordInsightsOverview>>['health']['points']
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
performance: Performance
|
||||
activity: Activity
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{
|
||||
@@ -38,6 +42,10 @@ const props = withDefaults(
|
||||
const activeView = shallowRef('total')
|
||||
const { palette } = useChartPalette()
|
||||
const performancePoints = computed<PerformancePoint[]>(() => props.performance.points)
|
||||
const requestActivity = computed(() => requestActivityByBucket(
|
||||
performancePoints.value.map(point => point.bucket),
|
||||
props.activity,
|
||||
))
|
||||
|
||||
const viewOptions = [
|
||||
{ label: '总耗时', value: 'total' },
|
||||
@@ -54,14 +62,28 @@ const seriesLabels = computed(() => {
|
||||
return ['P50', 'P95', 'P99'] as const
|
||||
})
|
||||
|
||||
const selectedPoints = computed(() =>
|
||||
performancePoints.value.map(point => ({
|
||||
const selectedPoints = computed(() => {
|
||||
const points = performancePoints.value
|
||||
const active = requestActivity.value
|
||||
const firstValues = zeroInactiveValues(
|
||||
points.map(point => seriesValue(point, 'first')),
|
||||
active,
|
||||
)
|
||||
const secondValues = zeroInactiveValues(
|
||||
points.map(point => seriesValue(point, 'second')),
|
||||
active,
|
||||
)
|
||||
const thirdValues = zeroInactiveValues(
|
||||
points.map(point => seriesValue(point, 'third')),
|
||||
active,
|
||||
)
|
||||
return points.map((point, index) => ({
|
||||
point,
|
||||
first: seriesValue(point, 'first'),
|
||||
second: seriesValue(point, 'second'),
|
||||
third: seriesValue(point, 'third'),
|
||||
})),
|
||||
)
|
||||
first: firstValues[index] ?? null,
|
||||
second: secondValues[index] ?? null,
|
||||
third: thirdValues[index] ?? null,
|
||||
}))
|
||||
})
|
||||
|
||||
const summaryMetrics = computed(() => {
|
||||
const performance = props.performance
|
||||
@@ -98,6 +120,25 @@ const hasData = computed(
|
||||
const chartOption = computed<EChartsOption>(() => {
|
||||
const theme = palette.value
|
||||
const points = selectedPoints.value
|
||||
const series = [
|
||||
...usageGapAwareLineSeries(
|
||||
seriesLabels.value[0],
|
||||
points.map(point => point.first),
|
||||
theme.info,
|
||||
{ area: 'subtle' },
|
||||
),
|
||||
...usageGapAwareLineSeries(
|
||||
seriesLabels.value[1],
|
||||
points.map(point => point.second),
|
||||
theme.warning,
|
||||
),
|
||||
...usageGapAwareLineSeries(
|
||||
seriesLabels.value[2],
|
||||
points.map(point => point.third),
|
||||
activeView.value === 'scheduling' ? theme.success : theme.danger,
|
||||
{ yAxisIndex: activeView.value === 'scheduling' ? 1 : 0 },
|
||||
),
|
||||
]
|
||||
|
||||
return {
|
||||
animationDuration: 240,
|
||||
@@ -130,27 +171,7 @@ const chartOption = computed<EChartsOption>(() => {
|
||||
? value => `${formatCompactNumber(value)}/s`
|
||||
: formatDurationAxis,
|
||||
),
|
||||
series: [
|
||||
usageLineSeries(
|
||||
seriesLabels.value[0],
|
||||
points.map(point => point.first),
|
||||
theme.info,
|
||||
{ area: 'subtle' },
|
||||
),
|
||||
usageLineSeries(
|
||||
seriesLabels.value[1],
|
||||
points.map(point => point.second),
|
||||
theme.warning,
|
||||
),
|
||||
{
|
||||
...usageLineSeries(
|
||||
seriesLabels.value[2],
|
||||
points.map(point => point.third),
|
||||
activeView.value === 'scheduling' ? theme.success : theme.danger,
|
||||
),
|
||||
yAxisIndex: activeView.value === 'scheduling' ? 1 : 0,
|
||||
},
|
||||
],
|
||||
series,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -180,10 +201,23 @@ function formatTooltip(params: unknown) {
|
||||
if (!selected)
|
||||
return ''
|
||||
|
||||
return usageTooltipContent(palette.value, selected.point.label, [
|
||||
`${seriesLabels.value[0]}: ${formatSeriesValue(selected.first, 'first')}`,
|
||||
`${seriesLabels.value[1]}: ${formatSeriesValue(selected.second, 'second')}`,
|
||||
`${seriesLabels.value[2]}: ${formatSeriesValue(selected.third, 'third')}`,
|
||||
const theme = palette.value
|
||||
return usageTooltipContent(theme, selected.point.label, [
|
||||
usageTooltipItem(
|
||||
seriesLabels.value[0],
|
||||
formatSeriesValue(selected.first, 'first'),
|
||||
theme.info,
|
||||
),
|
||||
usageTooltipItem(
|
||||
seriesLabels.value[1],
|
||||
formatSeriesValue(selected.second, 'second'),
|
||||
theme.warning,
|
||||
),
|
||||
usageTooltipItem(
|
||||
seriesLabels.value[2],
|
||||
formatSeriesValue(selected.third, 'third'),
|
||||
activeView.value === 'scheduling' ? theme.success : theme.danger,
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LineSeriesOption } from 'echarts'
|
||||
import type { useChartPalette } from '@/composables/useChartPalette'
|
||||
import { sampleGapBridgeSeries } from '@/components/charts/timeSeriesGap'
|
||||
import { chartTooltipStyle } from '@/components/charts/tooltip'
|
||||
import { escapeTooltip } from './format'
|
||||
|
||||
@@ -9,6 +10,8 @@ type UsageAreaStrength = 'strong' | 'subtle'
|
||||
interface UsageLineSeriesOptions {
|
||||
stack?: string
|
||||
area?: UsageAreaStrength | false
|
||||
xAxisIndex?: number
|
||||
yAxisIndex?: number
|
||||
}
|
||||
|
||||
const areaAlpha: Record<UsageAreaStrength, readonly [string, string]> = {
|
||||
@@ -36,6 +39,8 @@ export function usageLineSeries(
|
||||
data: data.map(value => value ?? null),
|
||||
connectNulls: false,
|
||||
stack: options.stack,
|
||||
xAxisIndex: options.xAxisIndex ?? 0,
|
||||
yAxisIndex: options.yAxisIndex ?? 0,
|
||||
smooth: true,
|
||||
showSymbol: showSymbols,
|
||||
showAllSymbol: showSymbols,
|
||||
@@ -61,6 +66,26 @@ export function usageLineSeries(
|
||||
}
|
||||
}
|
||||
|
||||
export function usageGapAwareLineSeries(
|
||||
name: string,
|
||||
data: Array<number | null | undefined>,
|
||||
color: string,
|
||||
options: UsageLineSeriesOptions = {},
|
||||
) {
|
||||
const primary = usageLineSeries(name, data, color, options)
|
||||
return [
|
||||
primary,
|
||||
...sampleGapBridgeSeries(data, {
|
||||
name,
|
||||
color,
|
||||
xAxisIndex: options.xAxisIndex,
|
||||
yAxisIndex: options.yAxisIndex,
|
||||
width: 2.2,
|
||||
z: 2,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
export function usageTooltip(
|
||||
theme: UsageChartPalette,
|
||||
formatter: (params: unknown) => string,
|
||||
@@ -81,6 +106,10 @@ export function usageTooltipContent(
|
||||
return `<div style="margin:0 0 7px;padding:0 0 7px;border-bottom:1px solid ${theme.divider};color:${theme.textPrimary};font-family:'JetBrains Mono Variable','JetBrains Mono',monospace;font-size:11px;font-weight:750;line-height:1.2">${title}</div><div style="line-height:1.55">${lines.join('<br/>')}</div>`
|
||||
}
|
||||
|
||||
export function usageTooltipItem(label: string, value: string, color: string) {
|
||||
return `<span style="display:inline-block;width:7px;height:7px;margin-right:6px;border-radius:999px;background:${escapeTooltip(color)}"></span>${escapeTooltip(label)}: ${escapeTooltip(value)}`
|
||||
}
|
||||
|
||||
export function usageCategoryAxis(labels: string[], theme: UsageChartPalette) {
|
||||
return {
|
||||
type: 'category' as const,
|
||||
|
||||
Reference in New Issue
Block a user