refactor(admin): simplify diagnostics and refresh shared UI integration

Adopt UI v0.3.0 and its shared Vite adapter, preserve request-volume ordering in diagnostics, stabilize sidebar animation, and track workbench v0.1.15.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-26 17:14:54 +08:00
co-authored by Codex
parent c12c600b42
commit 2d20f03bcb
17 changed files with 67 additions and 111 deletions
+1 -1
View File
@@ -148,7 +148,7 @@ AI 进行前端开发时,加载并使用 `$frontend-design` 技能。设计方
| 依赖安全 | Cargo Audit、pnpm Audit | 本次变更的权限、数据与信任边界 |
| 工作流与容器 | Actionlint、相关容器检查 | 改动涉及的运行行为 |
UI 与示例源码分别在独立的 `codex-proxy-ui`、`codex-proxy-plugins` 仓库维护。前端的依赖、锁文件和 ESLint 配置均在 `frontend/` 管理,仓库根目录不维护 Node 包。UI 使用固定 GitHub 标签或提交依赖,锁文件记录实际提交、源码归档地址及完整性摘要,常规开发和 CI 不需要检出同级仓库。安装时 pnpm 按 UI 自身锁文件准备构建依赖,再执行 `prepack` 生成 JS、CSS 和类型声明。开发服务通过 UI 包的 `development` 入口使用 Vue SFC,生产构建使用 `dist`。
UI 与示例源码分别在独立的 `codex-proxy-ui`、`codex-proxy-plugins` 仓库维护。前端的依赖、锁文件和 ESLint 配置均在 `frontend/` 管理,仓库根目录不维护 Node 包。UI 使用固定 GitHub 标签或提交依赖,锁文件记录实际提交、源码归档地址及完整性摘要,常规开发和 CI 不需要检出同级仓库。安装时 pnpm 按 UI 自身锁文件准备构建依赖,再执行 `prepack` 生成 JS、CSS 和类型声明。普通开发与生产构建都使用 UI 编译产物,源码联调通过 `@codex-proxy/ui/vite` 显式开启。
pnpm 版本由 `frontend/package.json` 的 `packageManager` 固定,CI 和 Docker 从该字段读取。`frontend/pnpm-workspace.yaml` 的 `allowBuilds` 按仓库放行自有 UI 包的构建脚本;升级依赖时核对源码并更新锁文件,无需逐次修改构建许可,不开放全部依赖脚本。
@@ -1154,7 +1154,6 @@ pub struct DiagnosticsItem {
pub non_completion_rate: f64,
pub retry_count: u64,
pub retry_rate: f64,
pub impact_score: f64,
pub estimated_cost: Option<DecimalAmount>,
pub attempt_count: u64,
pub total_tokens: u64,
@@ -342,21 +342,13 @@ impl ObservabilityService for DefaultObservabilityService {
let total_requests = items.iter().fold(0_u64, |total, item| {
total.saturating_add(item.request_count)
});
let mut items = items
let items = items
.into_iter()
.map(|item| {
let error_rate = rate_or_zero(item.failure_count, item.request_count);
let non_completion_rate =
rate_or_zero(item.non_completion_count, item.request_count);
let retry_rate = rate_or_zero(item.retry_count, item.request_count);
let impact_score = diagnostic_impact_score(
item.request_count,
total_requests,
error_rate,
non_completion_rate,
retry_rate,
item.first_token_p95_ms,
);
DiagnosticsItem {
key: item.key,
name: item.name,
@@ -372,19 +364,12 @@ impl ObservabilityService for DefaultObservabilityService {
non_completion_rate,
retry_count: item.retry_count,
retry_rate,
impact_score,
estimated_cost: usd_cost(&item.costs),
attempt_count: item.attempt_count,
total_tokens: item.total_tokens,
}
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.impact_score
.total_cmp(&left.impact_score)
.then_with(|| right.request_count.cmp(&left.request_count))
});
Ok(DiagnosticsResult { dimension, items })
}
@@ -724,25 +709,6 @@ fn decimal(value: &DecimalAmount) -> Option<gateway_core::metering::Decimal> {
value.as_str().parse().ok()
}
fn diagnostic_impact_score(
request_count: u64,
total_requests: u64,
error_rate: f64,
non_completion_rate: f64,
retry_rate: f64,
first_token_p95_ms: Option<u64>,
) -> f64 {
let request_share = rate_or_zero(request_count, total_requests);
let slow_score = first_token_p95_ms
.map_or(0.0, |value| value as f64 / 30_000.0)
.min(1.0);
error_rate * 0.35
+ non_completion_rate * 0.25
+ retry_rate.min(1.0) * 0.20
+ request_share * 0.10
+ slow_score * 0.10
}
fn usd_cost(costs: &[CurrencyCost]) -> Option<DecimalAmount> {
costs
.iter()
@@ -502,7 +502,31 @@ async fn observability_services_should_calculate_usage_insights_and_diagnostic_s
);
assert_eq!(diagnostics.items[0].request_share, 0.75);
assert_eq!(diagnostics.items[1].request_share, 0.25);
assert!((diagnostics.items[0].impact_score - 0.075).abs() < 1e-9);
}
#[tokio::test]
async fn diagnostics_should_preserve_request_order_when_a_small_sample_fails() {
let now = Utc::now();
let range = observation_range(now);
let store = Arc::new(FixtureObservabilityStore::new(range));
let success = diagnostic("popular", 100);
let mut failure = diagnostic("single_failure", 1);
failure.success_count = 0;
failure.failure_count = 1;
store.replace_diagnostics(vec![success, failure]);
let services = observability_services_with_calculated_billing(store).await;
let result = services
.observability()
.diagnostics(range, UsageFilter::default(), DiagnosticDimension::Model)
.await
.expect("diagnostics");
assert_eq!(result.items[0].key, "popular");
assert_eq!(result.items[1].key, "single_failure");
assert_eq!(result.items[0].error_count, 0);
assert_eq!(result.items[1].error_count, 1);
assert_eq!(result.items[1].error_rate, 1.0);
}
#[tokio::test]
@@ -1294,7 +1294,6 @@ pub(crate) fn diagnostics_view(
non_completion_rate: item.non_completion_rate,
retry_count: item.retry_count,
retry_rate: item.retry_rate,
impact_score: item.impact_score,
estimated_cost: item.estimated_cost.as_ref().map(ToString::to_string),
attempt_count: item.attempt_count,
total_tokens: item.total_tokens,
@@ -758,7 +758,6 @@ pub struct DiagnosticItemView {
pub non_completion_rate: f64,
pub retry_count: u64,
pub retry_rate: f64,
pub impact_score: f64,
pub estimated_cost: Option<String>,
pub attempt_count: u64,
pub total_tokens: u64,
@@ -595,6 +595,7 @@ async fn diagnostics_should_keep_stable_key_and_display_name_contract() {
.await
.expect("diagnostics body");
let value: serde_json::Value = serde_json::from_slice(&body).expect("diagnostics JSON");
assert!(value["data"]["items"][0].get("impactScore").is_none());
assert_eq!(
(
&value["data"]["items"][0]["key"],
+2 -1
View File
@@ -1473,7 +1473,8 @@ Dashboard 的 `capacityInfo.maxConcurrentPerAccount` 为默认账号并发上限
用量查询可组合页码/游标、时间范围、Provider、Client Key、账号、模型、route、transport、状态码、
request/response/upstream ID、outcome 与搜索文本。诊断 `dimension` 可取 `model`、`account`、
`apiKey`、`provider`、`transport`、`failureClass`、`status`。
`apiKey`、`provider`、`transport`、`failureClass`、`status`。诊断按请求量降序返回最多 100 项,
同请求量按维度标识稳定排列。
管理端请求列表及 Dashboard 最近请求中的 `accountNotes` 为账号当前备注,按内部账号 ID 关联。
备注不写入请求历史快照;无备注或账号已删除时返回 `null`,修改备注不改变历史请求的账号归属。
+1 -1
View File
@@ -41,7 +41,7 @@ pnpm --dir modules/ui dev # 组件库交互示
管理端沿用 `frontend/vite.config.ts` 的后端代理,需按原有方式启动网关。插件独立预览的宿主桥使用模拟数据;已安装插件读取包内静态资源,修改后仍需重新构建、打包和安装。
`dev:source` 使用 Vite 的 `source` 模式,在各自配置中将 UI 的公开入口解析到 `modules/ui`,由 Vite 热更新。插件单独检出时仍用普通 `dev`;`source` 模式需要上述子模块目录结构。
`dev:source` 使用 Vite 的 `source` 模式,由 `@codex-proxy/ui/vite` 的 `CodexProxyUI` 适配器将公开入口解析到 `modules/ui`,并统一处理共享依赖、源码预构建排除与开发服务的文件访问范围。入口映射以 UI 包 `exports` 中的 `codex-proxy-source` 条件为准,由 Vite 热更新。插件单独检出时仍用普通 `dev`;`source` 模式需要上述子模块目录结构。
```bash
pnpm --dir frontend build:source
+1 -1
View File
@@ -17,7 +17,7 @@
},
"dependencies": {
"@boxicons/vue": "1.0.1",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.2.0",
"@codex-proxy/ui": "github:zyycn/codex-proxy-ui#v0.3.0",
"@fontsource-variable/inter": "^5.3.0",
"@fontsource-variable/jetbrains-mono": "^5.3.0",
"@lucide/vue": "^1.47.0",
+12 -6
View File
@@ -170,8 +170,8 @@ importers:
specifier: 1.0.1
version: 1.0.1(vue@3.5.43(typescript@6.0.3))
'@codex-proxy/ui':
specifier: github:zyycn/codex-proxy-ui#v0.2.0
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/3e4af442ecf44ce19d456ebbdae589f82a449f07(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vue@3.5.43(typescript@6.0.3))
specifier: github:zyycn/codex-proxy-ui#v0.3.0
version: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/e1d4a164e7cfe72272301ebcb1b1d9c54b786522(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))
'@fontsource-variable/inter':
specifier: ^5.3.0
version: 5.3.0
@@ -378,12 +378,16 @@ packages:
resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==}
engines: {node: '>= 20.12.0'}
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/3e4af442ecf44ce19d456ebbdae589f82a449f07':
resolution: {gitHosted: true, integrity: sha512-QTdvAYEK3hDybYnbctSYZGVBNRblKIj/KZoeorqplTaHVD1UZG4c27LO8+RSa3BWumExoXcJlta1HSgM7Fzd8A==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/3e4af442ecf44ce19d456ebbdae589f82a449f07}
version: 0.2.0
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/e1d4a164e7cfe72272301ebcb1b1d9c54b786522':
resolution: {gitHosted: true, integrity: sha512-I2h13qjmqABk4IviUXdiZXPU9o//qO6WGbBBvav9rXKXyEjfFXvwvz0ofa/4UWOmTRBhmcRFmEIE0qUTzHZThQ==, tarball: https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/e1d4a164e7cfe72272301ebcb1b1d9c54b786522}
version: 0.3.0
engines: {node: '>=24'}
peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
vue: ^3.5.0
peerDependenciesMeta:
vite:
optional: true
'@dprint/formatter@0.5.1':
resolution: {integrity: sha512-cdZUrm0iv/FnnY3CKE2dEcVhNEzrC551aE2h2mTFwQCRBrqyARLDnb7D+3PlXTUVp3s34ftlnGOVCmhLT9DeKA==}
@@ -2770,7 +2774,7 @@ snapshots:
fast-wrap-ansi: 0.2.2
sisteransi: 1.0.5
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/3e4af442ecf44ce19d456ebbdae589f82a449f07(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vue@3.5.43(typescript@6.0.3))':
'@codex-proxy/ui@https://codeload.github.com/zyycn/codex-proxy-ui/tar.gz/e1d4a164e7cfe72272301ebcb1b1d9c54b786522(axios@1.20.0(debug@4.4.3))(change-case@5.4.4)(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3))':
dependencies:
'@ant-design/colors': 8.0.1
'@ant-design/fast-color': 3.0.1
@@ -2783,6 +2787,8 @@ snapshots:
marked: 18.0.14
sortablejs: 1.15.7
vue: 3.5.43(typescript@6.0.3)
optionalDependencies:
vite: 8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0)
transitivePeerDependencies:
- async-validator
- axios
-1
View File
@@ -448,7 +448,6 @@ export interface UsageDiagnosticItem {
nonCompletionRate: number
retryCount: number
retryRate: number
impactScore: number
estimatedCost: string | null
attemptCount: number
totalTokens: number
@@ -460,11 +460,16 @@ onBeforeUnmount(() => {
>
{{ item.label }}
</span>
<ChevronDown
<span
v-if="!isCollapsed"
class="sidebar-label size-4 shrink-0 transition-transform duration-200 motion-reduce:transition-none"
:class="pluginGroupExpanded ? undefined : '-rotate-90'"
/>
class="sidebar-label inline-flex size-4 shrink-0"
aria-hidden="true"
>
<ChevronDown
class="size-4 transition-transform duration-200 motion-reduce:transition-none"
:class="pluginGroupExpanded ? undefined : '-rotate-90'"
/>
</span>
</button>
<div
@@ -44,15 +44,9 @@ const diagnosticColumns = defineTableColumns<DiagnosticDisplayItem>([
kind: 'numeric',
size: 'sm',
},
{
key: 'impactScore',
label: '风险分',
kind: 'numeric',
size: 'sm',
},
{
key: 'errorCount',
label: '错误 / 未完',
label: '失败',
kind: 'numeric',
size: 'sm',
},
@@ -65,27 +59,17 @@ const diagnosticColumns = defineTableColumns<DiagnosticDisplayItem>([
},
])
const selectedDimensionLabel = computed(
() => dimensionOptions.find(option => option.value === dimension.value)?.label ?? '维度',
)
const resultDimension = computed(() => props.diagnostics.dimension || dimension.value)
const resultDimensionLabel = computed(
() => dimensionOptions.find(option => option.value === resultDimension.value)?.label ?? '维度',
)
const sortedItems = computed(() =>
[...props.diagnostics.items].sort(
(left, right) => right.impactScore - left.impactScore || right.requestCount - left.requestCount,
),
)
type DiagnosticDisplayItem = Diagnostics['items'][number] & {
nameDisplay: ReturnType<typeof diagnosticNameDisplay>
}
const displayItems = computed<DiagnosticDisplayItem[]>(() =>
sortedItems.value.map(item => ({
props.diagnostics.items.map(item => ({
...item,
nameDisplay: diagnosticNameDisplay(item.name),
})),
@@ -113,7 +97,7 @@ function diagnosticNameDisplay(name: string) {
<BaseCard
as="article"
title="热点诊断"
:description="`按${selectedDimensionLabel}定位高影响请求`"
:description="`按${resultDimensionLabel}聚合`"
class="h-105 min-h-105 max-h-105 min-w-0 w-full lg:h-full lg:min-h-90 lg:max-h-105"
>
<template #actions>
@@ -178,32 +162,17 @@ function diagnosticNameDisplay(name: string) {
</span>
</template>
<template #impactScore="{ row }">
<template #errorCount="{ row }">
<strong
class="font-mono font-bold tabular-nums"
:class="row.impactScore > 0.1 ? 'text-cp-error-text' : 'text-cp-text-secondary'"
title="综合错误、未完成、重试、请求占比与 TTFT 的风险分(0–100)"
:class="row.errorCount > 0 ? 'text-cp-error-text' : 'text-cp-text-quaternary'"
:aria-label="`失败 ${formatCompactNumber(row.errorCount)} 次`"
:title="`失败率 ${formatPercent(row.errorRate)}`"
>
{{ formatCompactNumber(row.impactScore * 100) }}
{{ formatCompactNumber(row.errorCount) }}
</strong>
</template>
<template #errorCount="{ row }">
<div
class="flex items-center justify-end gap-1.5 whitespace-nowrap text-right font-mono leading-none tabular-nums"
:aria-label="`错误 ${formatCompactNumber(row.errorCount)},未完成 ${formatCompactNumber(row.nonCompletionCount)}`"
:title="`错误率 ${formatPercent(row.errorRate)},未完成率 ${formatPercent(row.nonCompletionRate)}`"
>
<strong :class="row.errorCount > 0 ? 'text-cp-error-text' : 'text-cp-text-quaternary'">
{{ formatCompactNumber(row.errorCount) }}
</strong>
<span class="text-[9px] text-cp-text-quaternary">/</span>
<strong :class="row.nonCompletionCount > 0 ? 'text-cp-orange-text' : 'text-cp-text-quaternary'">
{{ formatCompactNumber(row.nonCompletionCount) }}
</strong>
</div>
</template>
<template #firstTokenP95Ms="{ row }">
<div
class="grid justify-items-end gap-1 font-mono leading-none tabular-nums"
+4 -16
View File
@@ -1,4 +1,5 @@
import { fileURLToPath, URL } from 'node:url'
import CodexProxyUI from '@codex-proxy/ui/vite'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
@@ -6,28 +7,15 @@ import { defineConfig } from 'vite'
export default defineConfig(({ mode }) => {
const sourceUi = mode === 'source'
const uiRoot = new URL('../modules/ui/', import.meta.url)
return {
base: '/',
plugins: [vue(), tailwindcss()],
plugins: [vue(), tailwindcss(), CodexProxyUI({ source: sourceUi ? uiRoot : undefined })],
resolve: {
dedupe: ['vue', '@lucide/vue'],
// 源码联调显式启用;普通开发与发行构建继续使用锁定的包。
alias: [
{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) },
...(sourceUi
? [
{ find: /^@codex-proxy\/ui$/, replacement: fileURLToPath(new URL('src/index.ts', uiRoot)) },
{ find: '@codex-proxy/ui/theme', replacement: fileURLToPath(new URL('src/theme/index.ts', uiRoot)) },
{ find: '@codex-proxy/ui/styles.css', replacement: fileURLToPath(new URL('src/styles/index.css', uiRoot)) },
{ find: '@codex-proxy/ui/tailwind.css', replacement: fileURLToPath(new URL('src/styles/tailwind.css', uiRoot)) },
{ find: /^@codex-proxy\/ui\/(.+)$/, replacement: fileURLToPath(new URL('src/components/$1/index.ts', uiRoot)) },
]
: []),
],
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
},
server: {
port: 5173,
fs: sourceUi ? { allow: [fileURLToPath(new URL('.', import.meta.url)), fileURLToPath(uiRoot)] } : undefined,
proxy: {
'/dev': {
target: 'http://127.0.0.1:8080',