feat(key-usage): add about dialog with build information

Reuse the shared About dialog from a toolbar icon and expose version and commit identifiers through an authenticated key-only endpoint. Align the About and refresh button backgrounds with the configuration button in dark mode.

Validate the response field allowlist and session boundaries, and document the new endpoint.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Zyy
2026-09-20 16:27:55 +08:00
co-authored by Codex
parent ccb7357914
commit f3236d0410
12 changed files with 149 additions and 20 deletions
+3 -1
View File
@@ -370,10 +370,12 @@ pub async fn initialize(
backup_ports.dump(),
backup_ports.object_store(),
);
let system = Arc::new(DefaultSystemService::new(system));
let key_usage = Arc::new(use_case::key_usage::DefaultKeyUsageService::new(
auth.clone(),
store.client_keys(),
store.observability(),
system.clone(),
));
let openai = Arc::new(DefaultOpenAiService::new(
openai,
@@ -423,7 +425,7 @@ pub async fn initialize(
registry,
pricing_source,
)),
system: Arc::new(DefaultSystemService::new(system)),
system,
openai,
xai,
import_tasks,
@@ -6,7 +6,7 @@ use async_trait::async_trait;
use chrono::Utc;
use crate::{
AuthService,
AuthService, SystemService,
model::{
AdminError, AdminErrorKind,
auth::SessionSubject,
@@ -18,6 +18,7 @@ use crate::{
observability::{
OpsErrorFilter, OpsErrorQuery, TimeRange, UsageFilter, UsageQuery, china_day_start,
},
system::SystemVersion,
},
ports::store::{ClientKeyStore, ObservabilityStore},
};
@@ -27,6 +28,8 @@ use super::{map_store_error, observability::health_timeline_at};
#[async_trait]
pub trait KeyUsageService: Send + Sync {
async fn version(&self, session_id: Option<&str>) -> Result<Option<SystemVersion>, AdminError>;
async fn config(&self, session_id: Option<&str>)
-> Result<Option<ClientKeySecret>, AdminError>;
@@ -47,6 +50,7 @@ pub(crate) struct DefaultKeyUsageService {
auth: Arc<dyn AuthService>,
keys: Arc<dyn ClientKeyStore>,
observations: Arc<dyn ObservabilityStore>,
system: Arc<dyn SystemService>,
}
impl DefaultKeyUsageService {
@@ -54,11 +58,13 @@ impl DefaultKeyUsageService {
auth: Arc<dyn AuthService>,
keys: Arc<dyn ClientKeyStore>,
observations: Arc<dyn ObservabilityStore>,
system: Arc<dyn SystemService>,
) -> Self {
Self {
auth,
keys,
observations,
system,
}
}
@@ -89,6 +95,13 @@ fn usage_filter(id: &ClientApiKeyId, model: Option<String>) -> UsageFilter {
#[async_trait]
impl KeyUsageService for DefaultKeyUsageService {
async fn version(&self, session_id: Option<&str>) -> Result<Option<SystemVersion>, AdminError> {
if self.key_id(session_id).await?.is_none() {
return Ok(None);
}
self.system.version().await.map(Some)
}
async fn config(
&self,
session_id: Option<&str>,
@@ -23,6 +23,7 @@ where
S: SessionState + Clone + Send + Sync + 'static,
{
Router::new()
.route("/api/key-usage/version", get(version::<S>))
.route("/api/key-usage/config", get(config::<S>))
.route("/api/key-usage/overview", get(overview::<S>))
.route("/api/key-usage/records", get(records::<S>))
@@ -32,10 +33,31 @@ where
.layer(middleware::map_response(no_store))
}
async fn version<S>(
State(state): State<S>,
headers: HeaderMap,
AdminQuery(_): AdminQuery<query::EmptyQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
{
let version = state
.admin_services()
.key_usage()
.version(session_cookie::value(&headers).as_deref())
.await
.map_err(map_admin_service_error)?
.ok_or_else(AdminError::session_required)?;
Ok(AdminResponse::new(
StatusCode::OK,
AdminEnvelope::ok(presenter::version(version)),
))
}
async fn config<S>(
State(state): State<S>,
headers: HeaderMap,
AdminQuery(_): AdminQuery<query::ConfigQuery>,
AdminQuery(_): AdminQuery<query::EmptyQuery>,
) -> Result<impl IntoResponse, AdminError>
where
S: SessionState + Send + Sync,
@@ -7,6 +7,7 @@ use gateway_admin::model::{
observability::{
CostCoverage, CurrencyCost, Granularity, OpsError, RequestMetrics, UsageListRecord,
},
system::SystemVersion,
};
use serde::Serialize;
@@ -15,6 +16,21 @@ use crate::admin::observability::{
health_timeline_view, usage_list_token_details,
};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct VersionView {
version: String,
git_sha: String,
}
pub(super) fn version(version: SystemVersion) -> VersionView {
// 密钥用户仅查看构建标识,不暴露部署环境、更新状态或内部诊断。
VersionView {
version: version.version,
git_sha: version.git_sha,
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ConfigView {
@@ -13,7 +13,7 @@ use crate::admin::AdminError;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct ConfigQuery {}
pub(super) struct EmptyQuery {}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
@@ -40,8 +40,8 @@ async fn login(app: &Router, mode: &str) -> String {
}
async fn get(app: &Router, resource: &str, suffix: &str, cookie: &str) -> Response {
let path = if resource == "config" {
format!("/api/key-usage/config{suffix}")
let path = if matches!(resource, "config" | "version") {
format!("/api/key-usage/{resource}{suffix}")
} else {
format!("/api/key-usage/{resource}?{RANGE}{suffix}")
};
@@ -54,6 +54,33 @@ async fn get(app: &Router, resource: &str, suffix: &str, cookie: &str) -> Respon
response
}
#[tokio::test]
async fn version_exposes_only_build_identifiers_for_key_sessions() {
let fixture = fixtures::fixture().await;
let app = crate::openai::api_router_with_admin(fixture.services.clone());
let cookie = login(&app, "key").await;
let response = get(&app, "version", "", &cookie).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response_json(response).await["data"],
json!({"version": "3.7.0", "gitSha": "internal-revision"})
);
assert_eq!(
get(&app, "version", "?keyId=other", &cookie).await.status(),
StatusCode::BAD_REQUEST
);
let response = app
.clone()
.oneshot(cookie_request(
Method::GET,
"/api/admin/system/version",
&cookie,
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn config_reveals_only_the_session_keys_name_and_plaintext() {
let fixture = fixtures::fixture().await;
@@ -288,7 +315,7 @@ async fn missing_admin_and_revoked_sessions_cannot_read_key_usage() {
let app = crate::openai::api_router_with_admin(fixture.services.clone());
let admin = login(&app, "admin").await;
let key = login(&app, "key").await;
for resource in ["overview", "records", "config"] {
for resource in ["overview", "records", "config", "version"] {
assert_eq!(
get(&app, resource, "", "").await.status(),
StatusCode::UNAUTHORIZED
@@ -427,6 +454,11 @@ async fn namespace_errors_remain_json_and_uncacheable() {
"/api/key-usage/config",
StatusCode::METHOD_NOT_ALLOWED,
),
(
Method::POST,
"/api/key-usage/version",
StatusCode::METHOD_NOT_ALLOWED,
),
] {
let response = app
.clone()
+3
View File
@@ -348,6 +348,7 @@ OpenAI 选号阶段确认本次可选账号全部额度耗尽时,HTTP 返回 `
| `GET` | `/api/key-usage/overview` | `startTime`、`endTime`、`model?` | 用量汇总、趋势、当前额度和北京时间今日健康时间线 |
| `GET` | `/api/key-usage/records` | 同上,另含 `kind?`、`currentPage?`、`pageSize?` | 当前 Key 的成功请求或错误记录 |
| `GET` | `/api/key-usage/config` | 无 | 当前 Key 的客户端配置凭据 |
| `GET` | `/api/key-usage/version` | 无 | “关于”弹窗使用的当前版本号和提交号 |
用量查询的起止时间使用 RFC3339,开始必须早于结束,一次最多 31 天。模型按完整名称匹配;
不接受 Key ID、账号、Provider 等范围参数或其他未知字段。页码默认 1,每页默认 20,允许 1–100 条;
@@ -369,6 +370,8 @@ Token 明细、费用明细、用时/首字与状态。Token 和费用复用现
成功记录的 `status` 为 `success`,不伪造未保存的 HTTP 状态;错误记录为 `error`,只返回客户端状态码,
缺失的 Token/费用明细为 null。不返回账号资料、Key ID、上游模型或请求标识、原始错误正文或诊断内容。
version 返回 `{ version, gitSha }`,不接受查询参数,不包含部署模式、更新状态或内部诊断。
config 返回 `{ name, plaintextKey }`,仅读取服务端会话绑定的当前 Key,不接受任何查询参数。
使用统计页在打开“密钥配置”弹窗时读取,用于复制 Codex 配置文件或导入 CCSwitch;
明文不进入用量轮询响应或浏览器持久化存储,关闭弹窗后清除页面中的配置状态。
+13
View File
@@ -8,6 +8,11 @@ export interface KeyUsageConfig {
plaintextKey: string
}
export interface KeyUsageVersion {
version: string
gitSha: string
}
export interface KeyUsageMetrics {
requests: number
inputTokens: number
@@ -91,6 +96,14 @@ export function getKeyUsageOverview(params: KeyUsageQuery, options: RequestOptio
})
}
export function getKeyUsageVersion(options: RequestOptions = {}) {
return request<KeyUsageVersion>({
url: '/api/key-usage/version',
method: 'GET',
...options,
})
}
export function getKeyUsageConfig(options: RequestOptions = {}) {
return request<KeyUsageConfig>({
url: '/api/key-usage/config',
@@ -1,16 +1,16 @@
<script setup lang="ts">
import { Github } from '@boxicons/vue'
import { ExternalLink } from '@lucide/vue'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import BaseModal from '@/components/base/BaseModal/index.vue'
import { useSystemUpdateStore } from '@/stores/modules/system-update'
const props = defineProps<{
version: { version: string, gitSha: string } | null
}>()
const open = defineModel<boolean>({ default: false })
const { version } = storeToRefs(useSystemUpdateStore())
const author = 'Zyy'
const githubUrl = 'https://github.com/zyycn/codex-proxy-rs'
@@ -25,11 +25,11 @@ function normalizeBuildValue(value: string | undefined) {
}
const versionDisplay = computed(() => {
const versionText = normalizeBuildValue(version.value?.version).replace(/^v/i, '')
const versionText = normalizeBuildValue(props.version?.version).replace(/^v/i, '')
return versionText ? `v${versionText}` : ''
})
const gitShaDisplay = computed(() => {
const gitSha = normalizeBuildValue(version.value?.gitSha)
const gitSha = normalizeBuildValue(props.version?.gitSha)
return gitSha ? gitSha.slice(0, 8) : ''
})
+3 -3
View File
@@ -3,11 +3,11 @@ import { storeToRefs } from 'pinia'
import { nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import AppAboutModal from '@/components/AppAboutModal.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import { useSystemUpdateStore } from '@/stores/modules/system-update'
import { useUiStore } from '@/stores/modules/ui'
import AppAboutModal from './components/AppAboutModal.vue'
import AppSidebar from './components/AppSidebar.vue'
import FloatingSidebarToggle from './components/FloatingSidebarToggle.vue'
import SystemUpdateModal from './components/SystemUpdateModal/index.vue'
@@ -15,7 +15,7 @@ import SystemUpdateModal from './components/SystemUpdateModal/index.vue'
const uiStore = useUiStore()
const systemUpdateStore = useSystemUpdateStore()
const { sidebarCollapsed } = storeToRefs(uiStore)
const { loadedOnce } = storeToRefs(systemUpdateStore)
const { loadedOnce, version } = storeToRefs(systemUpdateStore)
const { toggleSidebar } = uiStore
const route = useRoute()
const pageScrollbarRef = ref<InstanceType<typeof BaseScrollbar> | null>(null)
@@ -110,7 +110,7 @@ watch(
</Transition>
</Teleport>
<AppAboutModal v-model="aboutOpen" />
<AppAboutModal v-model="aboutOpen" :version="version" />
<SystemUpdateModal v-model="systemUpdateOpen" />
</div>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { KeyRound, LogOut, Moon, RefreshCw, Sun, Terminal } from '@lucide/vue'
import { Info, KeyRound, LogOut, Moon, RefreshCw, Sun, Terminal } from '@lucide/vue'
import { shallowRef } from 'vue'
import { useRouter } from 'vue-router'
import BaseButton from '@/components/base/BaseButton.vue'
@@ -10,7 +10,7 @@ import { useAuthStore } from '@/stores/modules/auth'
import { useThemeStore } from '@/stores/modules/theme'
defineProps<{ name?: string, prefix?: string, refreshing: boolean, configuring: boolean }>()
defineEmits<{ refresh: [], configure: [] }>()
defineEmits<{ refresh: [], configure: [], openAbout: [] }>()
const period = defineModel<string>('period', { required: true })
const refreshInterval = defineModel<string>('refreshInterval', { required: true })
const auth = useAuthStore()
@@ -45,13 +45,16 @@ async function logout() {
</template>
<template #actions>
<div class="flex max-w-[calc(100vw-32px)] flex-wrap items-center justify-end gap-2">
<BaseIconButton label="关于" variant="filled" @click="$emit('openAbout')">
<Info class="size-4" />
</BaseIconButton>
<BaseButton variant="secondary" :loading="configuring" @click="$emit('configure')">
<Terminal class="size-4" />
密钥配置
</BaseButton>
<BaseSelect v-model="period" aria-label="统计时间范围" class="w-29" :options="[{ label: '今天', value: 'today' }, { label: '近 7 天', value: '7d' }, { label: '近 30 天', value: '30d' }]" />
<BaseSelect v-model="refreshInterval" aria-label="自动刷新频率" class="w-30" :options="[{ label: '30 秒刷新', value: '30' }, { label: '60 秒刷新', value: '60' }, { label: '暂停刷新', value: '0' }]" />
<BaseIconButton label="刷新用量" variant="secondary" :loading="refreshing" @click="$emit('refresh')">
<BaseIconButton label="刷新用量" variant="filled" :loading="refreshing" @click="$emit('refresh')">
<RefreshCw class="size-4" />
</BaseIconButton>
<BaseIconButton label="切换主题" @click="theme.toggleTheme($event)">
+26 -1
View File
@@ -1,6 +1,10 @@
<script setup lang="ts">
import type { KeyUsageVersion } from '@/api/modules/key-usage'
import { Search } from '@lucide/vue'
import { shallowRef } from 'vue'
import { getKeyUsageVersion } from '@/api/modules/key-usage'
import ApiKeyConfigModal from '@/components/ApiKeyConfigModal.vue'
import AppAboutModal from '@/components/AppAboutModal.vue'
import BaseInput from '@/components/base/BaseInput.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import RequestHealthTimelineCard from '@/views/dashboard/components/RequestHealthTimelineCard.vue'
@@ -17,6 +21,26 @@ import { keyUsageTime } from './utils/format'
const { period, model, kind, refreshInterval, overview, overviewLoading, overviewError, refreshing, recordsStale, records, refresh, changePageSize, changePage } = useKeyUsage()
const { items, currentPage, pageSize, total, loading: recordsLoading, error: recordsError } = records
const { showConfig, configKey, configuring, apiBaseUrl, openConfig, copyConfig } = useKeyConfig()
const aboutOpen = shallowRef(false)
const version = shallowRef<KeyUsageVersion | null>(null)
const versionLoading = shallowRef(false)
async function openAbout() {
aboutOpen.value = true
if (version.value || versionLoading.value)
return
versionLoading.value = true
try {
version.value = await getKeyUsageVersion({ silent: true })
}
catch {
// 版本不可用不影响查看项目信息,下次打开时重试。
}
finally {
versionLoading.value = false
}
}
</script>
<template>
@@ -24,7 +48,7 @@ const { showConfig, configKey, configuring, apiBaseUrl, openConfig, copyConfig }
<BaseScrollbar>
<div class="mx-auto flex min-h-full w-full max-w-480 flex-col gap-5 p-4 min-[961px]:p-6">
<div class="flex flex-col gap-2">
<KeyUsageHeader v-model:period="period" v-model:refresh-interval="refreshInterval" :name="overview?.key.name" :prefix="overview?.key.prefix" :refreshing="refreshing || overviewLoading" :configuring="configuring" @refresh="refresh" @configure="openConfig" />
<KeyUsageHeader v-model:period="period" v-model:refresh-interval="refreshInterval" :name="overview?.key.name" :prefix="overview?.key.prefix" :refreshing="refreshing || overviewLoading" :configuring="configuring" @refresh="refresh" @configure="openConfig" @open-about="openAbout" />
<div class="flex flex-wrap items-center justify-between gap-3">
<span v-if="overview" class="text-cp-sm text-cp-text-tertiary">更新于 {{ keyUsageTime(overview.asOf).slice(11) }}</span>
<BaseInput v-model="model" class="ml-auto w-60 max-w-full" placeholder="输入完整模型名称" aria-label="筛选统计和日志的模型" :maxlength="128">
@@ -50,5 +74,6 @@ const { showConfig, configKey, configuring, apiBaseUrl, openConfig, copyConfig }
</div>
</BaseScrollbar>
<ApiKeyConfigModal v-model="showConfig" title="密钥配置" :api-key="configKey" :api-base-url="apiBaseUrl" @copy="copyConfig" />
<AppAboutModal v-model="aboutOpen" :version="version" />
</main>
</template>