Compare commits

...
4 Commits
Author SHA1 Message Date
ZyyandCodex e7c2f025cc chore: bump version to 3.3.13
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 13:29:18 +08:00
ZyyandCodex 4ee294b208 fix(frontend): improve mobile layout sizing
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 13:28:50 +08:00
Zyy 93f1a82d3f fix(frontend): improve observability chart continuity 2026-08-31 13:09:24 +08:00
ZyyandCodex c46f98cd2b fix: align wire profiles and diagnostics
Co-authored-by: Codex <noreply@openai.com>
2026-08-31 12:29:29 +08:00
19 changed files with 445 additions and 139 deletions
+2 -2
View File
@@ -144,7 +144,7 @@ fn config_loader_should_reject_removed_fingerprint_section() {
#[test]
fn config_loader_should_reject_invalid_codex_cli_version() {
assert_rejected(valid_config().replace(
"codex_version: '0.147.0-alpha.6.6'",
"codex_version: '0.151.0-alpha.7.2'",
"codex_version: 'latest'",
));
}
@@ -165,7 +165,7 @@ fn config_loader_should_reject_zero_server_port() {
#[test]
fn config_loader_should_reject_invalid_desktop_profile_fields() {
assert_rejected(valid_config().replace("desktop_build: '6415'", "desktop_build: 'build'"));
assert_rejected(valid_config().replace("desktop_build: '7377'", "desktop_build: 'build'"));
}
fn assert_rejected(config: String) {
@@ -455,10 +455,11 @@ fn passthrough_header_name(name: &str, connection_headers: &[String]) -> bool {
| "x-openai-project"
// installation ID 始终由当前 lease 重建。
| "x-codex-installation-id"
// 上游指纹必须由运行时画像统一生成,客户端 originator/User-Agent
// 不能透传覆盖,避免不同下游客户端暴露不一致的设备指纹。
// 上游指纹必须由运行时画像统一生成,客户端 originator/User-Agent/version
// 不能作为不透明头透传覆盖,避免不同下游客户端暴露不一致的设备指纹。
| "originator"
| "user-agent"
| "version"
// 设备 attestation/integrity 头只能由官方客户端或网关自身生成,
// 客户端注入的 x-oai-attestation / X-OAI-IS 不得透传上游。
| "x-oai-attestation"
@@ -320,8 +320,8 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
vec![b"future-ascii".to_vec(), b"\x80\xff".to_vec()]
);
assert_eq!(
values("version"),
vec![b"future-v1".to_vec(), b"future-\x80".to_vec()]
openai_protocol_context(&decoded).get("version"),
Some(&json!("future-v1"))
);
assert_eq!(
values("openai-beta"),
@@ -349,6 +349,7 @@ fn decoder_should_preserve_ordinary_request_headers_as_opaque_multivalues() {
// 上游指纹由运行时画像统一生成,客户端不得覆盖。
"user-agent",
"originator",
"version",
"x-oai-attestation",
"x-oai-is",
"x-oai-is-update",
@@ -402,7 +402,7 @@ pub struct CodexRequestContext<'a> {
pub beta_features: Option<&'a str>,
/// x-responsesapi-include-timing-metrics 头值。
pub include_timing_metrics: Option<&'a str>,
/// 客户端版本号。
/// 下游 `version` 扩展头;存在时上游值由 Desktop 版本画像统一生成。
pub version: Option<&'a str>,
/// 客户端 window ID。
pub codex_window_id: Option<&'a str>,
@@ -688,15 +688,16 @@ impl CodexBackendClient {
X_OPENAI_MEMGEN_REQUEST_HEADER,
request.memgen_request.as_deref(),
);
// 客户端确实携带的普通协议头优先;没有携带时才使用上面的 Codex profile 默认值。
// 客户端确实携带的普通协议头优先;画像身份头只能由 Codex profile 生成。
for name in request.passthrough_headers.keys() {
// 上游指纹必须由运行时画像统一生成:originator/User-Agent 即使绕过
// API 透传黑名单也不能覆盖画像,避免下游客户端暴露不一致指纹。
// 上游指纹必须由运行时画像统一生成:originator/User-Agent/version 即使
// 绕过 API 透传黑名单也不能覆盖画像,避免下游客户端暴露不一致指纹。
if matches!(
name.as_str(),
"openai-beta"
| "originator"
| "user-agent"
| "version"
| "x-oai-attestation"
| "x-oai-is"
| "x-oai-is-update"
@@ -770,7 +771,17 @@ impl CodexBackendClient {
"x-responsesapi-include-timing-metrics",
context.include_timing_metrics,
);
insert_optional_protocol_header(&mut headers, "version", context.version);
// 官方 Desktop 的版本画像来自 clientInfo.version。保留下游是否携带
// `version` 扩展头的协议形状,但上游值统一使用当前 Desktop 制品版本。
if context
.version
.is_some_and(|version| HeaderValue::from_str(version).is_ok())
{
headers.insert(
HeaderName::from_static("version"),
HeaderValue::from_str(&profile.desktop_version)?,
);
}
insert_optional_protocol_header(
&mut headers,
"x-codex-parent-thread-id",
@@ -72,7 +72,7 @@ pub struct CodexResponsesRequest {
pub turn_metadata: Option<String>,
/// beta features 透传头。
pub beta_features: Option<String>,
/// 客户端版本头。
/// 下游 `version` 扩展头;存在时上游值由 Desktop 版本画像统一生成。
pub version: Option<String>,
/// timing metrics 透传头。
pub include_timing_metrics: Option<String>,
@@ -32,6 +32,8 @@ fn request_with_opaque_headers(use_websocket: bool) -> CodexResponsesRequest {
],
["user-agent", STANDARD.encode(b"Codex future-client")],
["originator", STANDARD.encode(b"future-originator")],
["version", STANDARD.encode(b"26.999.10001")],
["version", STANDARD.encode(b"26.999.10002")],
["openai-beta", STANDARD.encode(b"future_responses=v2")],
["openai-beta", STANDARD.encode(b"future_tools=v3")],
[
@@ -321,7 +323,7 @@ async fn backend_websocket_should_forward_context_headers_and_preserve_payload_f
("x-codex-turn-metadata", "{\"thread_source\":\"subagent\"}"),
("x-codex-beta-features", "feature-a"),
("x-responsesapi-include-timing-metrics", "true"),
("version", "26.318.11754"),
("version", "1.2.3"),
("x-codex-window-id", "cw_derived"),
("x-codex-parent-thread-id", "parent-456"),
("x-openai-subagent", "future_codex_mode"),
@@ -356,14 +358,17 @@ async fn backend_http_should_restore_opaque_multivalue_header_bytes_and_lease_id
request
});
let request = request_with_opaque_headers(false);
let expected_user_agent = test_wire_profile().snapshot().user_agent();
let profile = test_wire_profile();
let profile_snapshot = profile.snapshot();
let expected_user_agent = profile_snapshot.user_agent();
let expected_desktop_version = profile_snapshot.desktop_version;
let client = CodexBackendClient::new(
reqwest::Client::builder()
.no_proxy()
.build()
.expect("HTTP client"),
format!("http://{address}"),
test_wire_profile(),
profile,
);
client
@@ -374,6 +379,7 @@ async fn backend_http_should_restore_opaque_multivalue_header_bytes_and_lease_id
account_id: Some("lease-account"),
installation_id: Some("lease-installation"),
turn_state: request.turn_state.as_deref(),
version: Some("26.825.51511"),
..request_context("req_opaque_http", Some("lease-account"))
},
)
@@ -413,6 +419,10 @@ async fn backend_http_should_restore_opaque_multivalue_header_bytes_and_lease_id
raw_header_values(&raw, "originator"),
vec![b"codex_cli_rs".to_vec()]
);
assert_eq!(
raw_header_values(&raw, "version"),
vec![expected_desktop_version.into_bytes()]
);
for dropped in [
"openai-beta",
"x-oai-attestation",
@@ -490,6 +500,7 @@ async fn backend_websocket_should_drop_only_unrepresentable_opaque_header_values
account_id: Some("lease-account"),
installation_id: Some("lease-installation"),
turn_state: request.turn_state.as_deref(),
version: Some("26.825.51511"),
..request_context("req_opaque_ws", Some("lease-account"))
},
)
@@ -526,6 +537,7 @@ async fn backend_websocket_should_drop_only_unrepresentable_opaque_header_values
vec![test_wire_profile().snapshot().user_agent().into_bytes()]
);
assert_eq!(values("originator"), vec![b"codex_cli_rs".to_vec()]);
assert_eq!(values("version"), vec![b"1.2.3".to_vec()]);
assert_eq!(
values("x-openai-internal-codex-residency"),
vec![b"us".to_vec()]
@@ -569,7 +581,9 @@ async fn backend_http_should_send_codex_context_without_browser_headers() {
request.codex_window_id = Some("cw_1".to_owned());
request.parent_thread_id = Some("parent-1".to_owned());
let profile = test_wire_profile();
let expected_user_agent = profile.snapshot().user_agent();
let profile_snapshot = profile.snapshot();
let expected_user_agent = profile_snapshot.user_agent();
let expected_desktop_version = profile_snapshot.desktop_version;
let client = CodexBackendClient::new(
reqwest::Client::builder()
.no_proxy()
@@ -629,6 +643,10 @@ async fn backend_http_should_send_codex_context_without_browser_headers() {
Some("turn-state"),
)
);
assert_eq!(
read_header_value(&raw_request, "version"),
Some(expected_desktop_version.as_str())
);
for required in [
"authorization",
"chatgpt-account-id",
+7 -7
View File
@@ -63,25 +63,25 @@ openai:
# 版本元组来自同一个官方 macOS Desktop ZIP;Provider 每 24 小时检查 appcast,
# 仅在包内 Core 核验和 Redis 条件覆盖都成功后原子更新。
originator: 'Codex Desktop'
codex_version: '0.147.0-alpha.6.6'
desktop_version: '26.803.81509'
desktop_build: '6415'
codex_version: '0.151.0-alpha.7.2'
desktop_version: '26.825.51511'
desktop_build: '7377'
os_type: 'Mac OS'
os_version: '15.7.1'
arch: 'arm64'
terminal: 'unknown'
verified_at: '2026-08-03T00:00:00+08:00'
verified_at: '2026-08-31T00:00:00+08:00'
xai:
# OAuth 端点、额度与模型目录策略由 xAI Provider 固定管理;这里只声明请求画像启动基线。
wire_profile:
# Provider 每 24 小时读取官方 npm metadata,仅在进程内更新 client_version。
client_identifier: 'grok-shell'
client_version: '0.2.118'
client_identifier: 'GrokShell'
client_version: '1.0.13'
client_mode: 'headless'
target_os: 'linux'
target_arch: 'x86_64'
verified_at: '2026-07-21T00:00:00+08:00'
verified_at: '2026-08-31T00:00:00+08:00'
# Compose 凭据桥接区;密码仍只维护在 store 中。
services:
@@ -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
}
+4 -4
View File
@@ -157,7 +157,7 @@ const {
</script>
<template>
<div class="flex h-full min-h-0 w-full flex-col overflow-hidden">
<div class="flex min-h-0 w-full flex-col xl:h-full xl:overflow-hidden">
<BasePageHeader
class="h-17"
title="账号管理"
@@ -167,7 +167,7 @@ const {
<AccountOverviewCards :summary="accountSummary" />
<BaseCard
class="mt-4 flex h-[calc(100dvh-250px)] min-h-125 flex-col"
class="mt-4 flex flex-col xl:h-[calc(100dvh-250px)] xl:min-h-125"
>
<template #header>
<AccountFilters
@@ -188,9 +188,9 @@ const {
</template>
<template #body>
<div class="flex h-full min-h-0 flex-col">
<div class="flex min-h-0 flex-col xl:h-full">
<BaseTable
class="min-h-0 flex-1 [--cp-table-row-height:72px]"
class="h-100! min-h-100 flex-none [--cp-table-row-height:72px] xl:h-auto! xl:min-h-0 xl:flex-1"
:columns="accountColumns"
:rows="accounts"
:loading="loading"
@@ -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>
@@ -196,7 +236,7 @@ function formatTooltip(params: unknown) {
as="article"
title="成本效率"
description="实际费用、缓存节省、服务层溢价与单位成本"
class="h-full min-h-90"
class="min-h-90 xl:h-full"
>
<template #actions>
<BaseSegmented v-model="activeView" label="成本视图" :options="viewOptions" :disabled="loading" class="w-50" />
@@ -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>
@@ -152,7 +163,7 @@ function formatTooltip(params: unknown) {
as="article"
title="请求健康"
:description="`按${granularityText}区分服务结果、取消、未完成与调用方错误`"
class="h-full min-h-90"
class="min-h-90 xl:h-full"
>
<template #body>
<div class="grid min-h-66" :class="hasData ? 'gap-3' : 'h-full'">
@@ -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,
),
])
}
@@ -205,7 +239,7 @@ function formatThroughput(value: number | null) {
as="article"
title="响应速度"
description="延迟、吞吐与调度分位"
class="h-full min-h-90"
class="min-h-90 xl:h-full"
>
<template #actions>
<BaseSegmented v-model="activeView" label="性能指标" :options="viewOptions" :disabled="loading" class="w-68" />
+6 -6
View File
@@ -36,20 +36,20 @@ export const usageRecordColumns = defineTableColumns<UsageDisplayRecord>([
export const opsErrorColumns = defineTableColumns<OpsErrorRow>([
{ key: 'accountId', label: '账号', kind: 'mono', size: '3xl' },
{ key: 'provider', label: '平台/类型', kind: 'custom', size: 'sm' },
{ key: 'createdAtDisplay', label: '时间', kind: 'datetime' },
{ key: 'message', label: '错误摘要', kind: 'custom', size: '3xl' },
{ key: 'failureClass', label: '失败分类', kind: 'mono' },
{ key: 'upstreamStatusCode', label: '上游状态', kind: 'status', size: 'sm' },
{ key: 'clientStatusCode', label: '客户端状态', kind: 'status', size: 'md' },
{ key: 'failureClass', label: '失败分类', kind: 'mono' },
{ key: 'kind', label: '事件', kind: 'mono' },
{ key: 'route', label: '端点', kind: 'mono' },
{ key: 'model', label: '模型', kind: 'custom', size: 'xl' },
{ key: 'reasoningEffort', label: '推理强度', kind: 'custom' },
{ key: 'route', label: '端点', kind: 'mono' },
{ key: 'transport', label: '上游', kind: 'custom', size: 'md' },
{ key: 'clientTransport', label: '接入', kind: 'custom', size: 'md' },
{ key: 'createdAtDisplay', label: '时间', kind: 'datetime' },
{ key: 'requestId', label: '请求 ID', kind: 'mono', size: '2xl' },
{ key: 'kind', label: '事件', kind: 'mono' },
{ key: 'clientIp', label: 'IP', kind: 'custom', size: '3xl' },
{ key: 'userAgent', label: 'User-Agent', kind: 'custom', size: '4xl' },
{ key: 'requestId', label: '请求 ID', kind: 'mono', size: '2xl' },
{ key: 'message', label: '错误摘要', kind: 'custom', size: '3xl' },
{ key: 'actions', label: '操作', kind: 'actions', size: 'sm' },
])
+29
View File
@@ -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,
+1 -1
View File
@@ -1 +1 @@
version: "3.3.12"
version: "3.3.13"