Compare commits

...
3 Commits
Author SHA1 Message Date
ZyyandCodex dd17332c31 chore: bump version to 3.3.25
Co-authored-by: Codex <noreply@openai.com>
2026-09-07 04:48:14 +08:00
ZyyandCodex 47d08786ad fix(scheduling): retain valid quota signals across idle periods
Keep OpenAI quota windows eligible for scheduling until their reset time,
independently of the projection cache TTL. Expire undated observations and
reset windows separately, and clear old signals when a fresh observation
contains no active window.

Record bounded candidate diagnostics with quota, concurrency, smart scores
and affinity results. Cover idle 74%/96% accounts with one concurrent title
request, independent window expiry, cache replacement and trace retention.

Validation: workspace tests (1852 passed), rustfmt, and strict workspace
Clippy with all targets and features. PostgreSQL/Redis integration services
were not configured.

Co-authored-by: Codex <noreply@openai.com>
2026-09-07 04:47:30 +08:00
ZyyandCodex 3434ad5e72 fix(ui): polish diagnostics and account usage displays
Use the shared scrollbar for request diagnostics, improve timeline spacing
and disclosure styling, and show export loading feedback with duplicate
click protection and a minimum visible duration.

Hide reset times in metric account usage windows while retaining them in
the detail variant.

Validation: frontend typecheck and build, targeted ESLint for both files,
and browser checks for diagnostic scrolling, expansion, mobile layout,
loading feedback, and exported content.

Co-authored-by: Codex <noreply@openai.com>
2026-09-07 04:25:16 +08:00
14 changed files with 533 additions and 51 deletions
@@ -9,4 +9,5 @@ mod store;
pub use error::CredentialError;
pub use model::*;
pub use selection::*;
pub(crate) use selection::{SMART_SCORE_TOLERANCE, smart_score};
pub use store::ProviderAccountStore;
@@ -590,7 +590,7 @@ impl AccountSelector {
})
}
fn scheduling_blocker(
pub(crate) fn scheduling_blocker(
&self,
candidate: &AccountCandidate,
context: &AccountSelectionContext,
@@ -643,7 +643,7 @@ const SMART_QUOTA_WEIGHT: f64 = 0.8;
const SMART_FAILURE_WEIGHT: f64 = 1.0;
const SMART_LATENCY_WEIGHT: f64 = 0.5;
// 容忍 5 个百分点的单项负载/失败率差异,避免微小信号波动独占新会话。
const SMART_SCORE_TOLERANCE: f64 = 0.05;
pub(crate) const SMART_SCORE_TOLERANCE: f64 = 0.05;
// 首输出 10 秒时延迟得分减半;固定尺度不随其他候选账号变化。
const SMART_LATENCY_HALF_SCORE_MS: f64 = 10_000.0;
@@ -677,7 +677,7 @@ fn select_smart_candidate<'a>(
Some(ranked[index].0)
}
fn smart_score(candidate: &AccountCandidate, default_concurrency: NonZeroU32) -> f64 {
pub(crate) fn smart_score(candidate: &AccountCandidate, default_concurrency: NonZeroU32) -> f64 {
let load = 1.0 - capacity_utilization(candidate, default_concurrency).clamp(0.0, 1.0);
let quota = candidate
.signals
@@ -1,6 +1,7 @@
//! 有界、请求局部的诊断证据;不参与路由、重试或客户端协议决策。
mod capture;
mod selection;
mod stream;
mod trace;
@@ -0,0 +1,73 @@
//! 复用选号时的输入,保留有界的候选评分与阻断原因。
use serde_json::json;
use super::TraceContext;
use crate::account::{
AccountCandidate, AccountSelection, AccountSelectionContext, AccountSelector, RotationStrategy,
SMART_SCORE_TOLERANCE, smart_score,
};
// 单个 trace 事件最多 4 KiB;优先保留实际选中的账号。
const MAX_CANDIDATES: usize = 6;
impl TraceContext {
/// 记录实际选号输入与结果;只复用评分规则,不参与调度决策。
pub fn account_selection(
&self,
candidates: &[AccountCandidate],
context: &AccountSelectionContext,
selection: Option<&AccountSelection<'_>>,
) {
if !self.is_enabled() {
return;
}
let selected = selection.as_ref().map(|s| s.candidate());
let selected_id = selected.map(|c| c.account.id());
let smart = context.policy.strategy() == RotationStrategy::Smart;
let observations = selected
.into_iter()
.chain(
candidates
.iter()
.filter(|c| Some(c.account.id()) != selected_id),
)
.take(MAX_CANDIDATES)
.map(|candidate| {
let signals = &candidate.signals;
json!({
"accountId": candidate.account.id().as_str(),
"weight": candidate.account.weight().get(),
"blocker": AccountSelector.scheduling_blocker(candidate, context)
.map(|blocker| format!("{blocker:?}")),
"inFlight": signals.in_flight,
"concurrencyLimit": candidate.account
.effective_concurrency(context.policy.max_concurrent_per_account()).get(),
"quotaRemainingBasisPoints": signals.quota_remaining_rank,
"quotaResetAtUnixMs": signals.quota_reset_at.and_then(|reset|
reset.duration_since(std::time::UNIX_EPOCH).ok()
).map(|duration| duration.as_millis()),
"failureRateBasisPoints": signals.failure_rate_basis_points,
"firstOutputLatencyMs": signals.first_output_latency_ms,
"smartScore": smart.then(|| smart_score(
candidate, context.policy.max_concurrent_per_account()
)),
})
})
.collect::<Vec<_>>();
self.record(
"account.selection",
json!({
"strategy": context.policy.strategy().as_str(),
"selectedAccountId": selected_id.map(|id| id.as_str()),
"preferredAccountId": context.preferred_account.as_ref().map(|id| id.as_str()),
"preferredResult": selection.as_ref().map(|s| format!("{:?}", s.preferred())),
"roundRobinCursor": context.round_robin_cursor,
"smartScoreTolerance": smart.then_some(SMART_SCORE_TOLERANCE),
"candidateCount": candidates.len(),
"omittedCandidates": candidates.len().saturating_sub(observations.len()),
"candidates": observations,
}),
);
}
}
@@ -65,6 +65,12 @@ struct TraceEvent {
}
impl TraceContext {
/// 是否已启用请求诊断,供调用方避免构造不会保存的事实。
#[must_use]
pub const fn is_enabled(&self) -> bool {
self.state.is_some()
}
/// 一个模型请求只创建一次,所有 attempt 共享同一有界时间线。
#[must_use]
pub fn new(request_id: &str) -> Self {
@@ -306,6 +312,7 @@ impl TraceContext {
!matches!(
state.events[*index].stage,
"attempt.started"
| "account.selection"
| "account.selected"
| "attempt.failed"
| "retry.decided"
@@ -1,4 +1,5 @@
mod selection;
mod selection_diagnostics;
use std::collections::{BTreeMap, BTreeSet};
use std::num::NonZeroU32;
@@ -0,0 +1,92 @@
use gateway_core::account::{AccountSelector, RotationStrategy};
use gateway_core::diagnostics::TraceContext;
use serde_json::json;
use super::{candidate, candidate_with_concurrency, context};
#[test]
fn selection_trace_should_distinguish_load_quota_and_affinity() {
let mut candidates = [
candidate_with_concurrency("acct_74", 1, 10),
candidate_with_concurrency("acct_96", 0, 10),
];
candidates[0].signals.quota_remaining_rank = Some(2600);
candidates[1].signals.quota_remaining_rank = Some(400);
let mut context = context(RotationStrategy::Smart);
let trace = TraceContext::new("req_smart_trace");
let selection = AccountSelector.select(&candidates, &context);
trace.account_selection(&candidates, &context, selection.as_ref());
context.preferred_account = Some(candidates[1].account.id().clone());
let selection = AccountSelector.select(&candidates, &context);
trace.account_selection(&candidates, &context, selection.as_ref());
let snapshot = trace.snapshot().expect("trace");
let first = &snapshot["events"][0]["data"];
let second = &snapshot["events"][1]["data"];
assert_eq!(first["selectedAccountId"], "acct_74");
assert_eq!(first["preferredResult"], "NotRequested");
let busy = &first["candidates"][0];
assert_eq!(busy["inFlight"], 1);
assert_eq!(busy["concurrencyLimit"], 10);
assert_eq!(busy["quotaRemainingBasisPoints"], 2600);
assert!(busy["blocker"].is_null());
assert!(
busy["smartScore"].as_f64().expect("score")
> first["candidates"][1]["smartScore"]
.as_f64()
.expect("score")
);
assert_eq!(second["selectedAccountId"], "acct_96");
assert_eq!(second["preferredResult"], "Hit");
}
#[test]
fn selection_trace_should_bound_candidates_and_preserve_the_selected_account() {
let mut candidates = (0..30)
.map(|index| candidate(&format!("acct_{index}"), 0, None))
.collect::<Vec<_>>();
candidates.push(candidate("acct_best", 0, Some(10_000)));
let trace = TraceContext::new("req_many_candidates");
// 选号位于首部保留区之外,也应在长流的常规事件淘汰后保留。
for _ in 0..10 {
trace.record("test.event", json!({}));
}
let context = context(RotationStrategy::Smart);
let selection = AccountSelector.select(&candidates, &context);
trace.account_selection(&candidates, &context, selection.as_ref());
for index in 0..400 {
trace.record("test.event", json!({"index": index}));
}
let snapshot = trace.snapshot().expect("trace");
let event = snapshot["events"]
.as_array()
.expect("events")
.iter()
.find(|event| event["stage"] == "account.selection")
.expect("selection survives event eviction");
let data = &event["data"];
let recorded = data["candidates"].as_array().expect("bounded candidates");
assert!(recorded.len() < candidates.len());
assert_eq!(recorded[0]["accountId"], "acct_best");
assert!(recorded[1]["quotaRemainingBasisPoints"].is_null());
assert_eq!(
recorded.len() as u64 + data["omittedCandidates"].as_u64().expect("omitted"),
candidates.len() as u64
);
assert!(data.to_string().len() < 4096);
}
#[test]
fn selection_trace_should_explain_when_all_accounts_are_busy() {
let candidates = [candidate_with_concurrency("acct_busy", 10, 10)];
let trace = TraceContext::new("req_no_candidate");
let context = context(RotationStrategy::Smart);
let selection = AccountSelector.select(&candidates, &context);
assert!(selection.is_none());
trace.account_selection(&candidates, &context, selection.as_ref());
let snapshot = trace.snapshot().expect("trace");
let data = &snapshot["events"][0]["data"];
assert!(data["selectedAccountId"].is_null());
assert_eq!(data["candidates"][0]["blocker"], "ConcurrencyLimit");
}
@@ -236,25 +236,36 @@ pub(crate) fn quota_snapshot_from_observation(
Some(snapshot)
}
/// 调度投影缓存 TTL:`min(常规 TTL, 最近 active reset - now)`。
/// reset 到期时缓存自然失效,不固定多等一个常规 TTL 周期。
/// 缓存定期重读持久化事实,额度窗口独立按 reset 到期。
/// 正常账号依赖被动同步,空闲超过缓存 TTL 不能把尚未重置的额度抹成未知。
pub(crate) fn quota_projection_ttl(snapshot: &CodexAccountQuotaSnapshot) -> Option<Duration> {
let age = SystemTime::now()
.duration_since(snapshot.observed_at())
.unwrap_or(Duration::ZERO);
let normal_ttl = QUOTA_SCHEDULING_TTL.checked_sub(age)?;
let now = SystemTime::now();
let reset_ttl = snapshot
snapshot
.windows()
.iter()
.filter_map(|window| window.reset_at())
.map(SystemTime::from)
.filter(|reset| *reset > now)
.map(|reset| reset.duration_since(now).unwrap_or(Duration::ZERO))
.min();
let ttl = match reset_ttl {
Some(reset_ttl) if reset_ttl < normal_ttl => reset_ttl,
_ => normal_ttl,
.filter_map(|window| quota_window_ttl(window, snapshot.observed_at(), now))
.min()
// 新观测即使没有有效窗口,也要用“未知”替换此前缓存的排序信号。
.or_else(|| {
QUOTA_SCHEDULING_TTL.checked_sub(
now.duration_since(snapshot.observed_at())
.unwrap_or(Duration::ZERO),
)
})
.filter(|ttl| !ttl.is_zero())
.map(|ttl| ttl.min(QUOTA_SCHEDULING_TTL))
}
fn quota_window_ttl(
window: &CodexQuotaWindow,
observed_at: SystemTime,
now: SystemTime,
) -> Option<Duration> {
let ttl = match window.reset_at() {
Some(reset_at) => SystemTime::from(reset_at).duration_since(now).ok()?,
// 没有明确窗口边界时,不能无限延长旧观测的有效期。
None => QUOTA_SCHEDULING_TTL
.checked_sub(now.duration_since(observed_at).unwrap_or(Duration::ZERO))?,
};
(!ttl.is_zero()).then_some(ttl)
}
@@ -273,12 +284,13 @@ pub(crate) fn quota_scheduling_signals(
let mut remaining_rank: Option<u64> = None;
let mut reset_at: Option<SystemTime> = None;
for window in &snapshot.windows {
if quota_window_ttl(window, snapshot.observed_at(), now).is_none() {
continue;
}
let window_reset_at = window.reset_at().map(SystemTime::from);
let observation_expired = window_reset_at.is_some_and(|reset_at| reset_at <= now);
if !observation_expired
&& let Some(used) = window
.used_percent()
.filter(|used| used.is_finite() && (0.0..=100.0).contains(used))
if let Some(used) = window
.used_percent()
.filter(|used| used.is_finite() && (0.0..=100.0).contains(used))
{
let rank = ((100.0 - used) * 100.0).round() as u64;
remaining_rank = Some(remaining_rank.map_or(rank, |current| current.min(rank)));
@@ -512,7 +512,12 @@ impl CodexCredentialSelector {
account_scope: request.attempt.account_scope().cloned(),
};
let capacity = AccountSelector.capacity_snapshot(&candidates, &context);
let Some(selection) = AccountSelector.select(&candidates, &context) else {
let selection = AccountSelector.select(&candidates, &context);
request
.attempt
.trace()
.account_selection(&candidates, &context, selection.as_ref());
let Some(selection) = selection else {
return match shortest_retry {
Some(retry_after) => Err(CredentialSelectionError::CapacityUnavailable {
retry_after: Some(retry_after),
@@ -1,6 +1,7 @@
//! OpenAI 额度事实边界与展示快照回归。
mod recovery;
mod scheduling;
mod slots;
mod snapshot;
@@ -0,0 +1,277 @@
//! 持久化额度的有效期与短暂并发占用共同参与选号。
use std::collections::BTreeSet;
use std::num::NonZeroU32;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use gateway_core::account::{
AccountCandidate, AccountConcurrencyLimit, AccountEligibilityPolicy, AccountRuntimeSignals,
AccountSelectionContext, AccountSelectionPolicy, AccountSelector, AccountWeight,
OpaqueProviderData, ProviderAccount, ProviderAccountStore as _, QuotaAccessState,
QuotaEvidence, QuotaObservation, QuotaState, RotationStrategy,
};
use serde_json::{Value, json};
use super::{MemoryAccountStore, create_account, quota_service};
async fn persist_snapshot(
store: &Arc<MemoryAccountStore>,
id: &str,
observed_at: SystemTime,
rate_limit: Value,
) -> ProviderAccount {
create_account(store, id).await;
let account = store.account(id).expect("created account");
store
.compare_and_swap_quota(QuotaObservation {
account_id: account.id().clone(),
expected_revision: account.revision(),
quota: OpaqueProviderData::new(
json!({"rate_limit": rate_limit})
.as_object()
.expect("quota object")
.clone(),
),
observed_at,
state: QuotaState::allowed(observed_at),
})
.await
.expect("persist quota");
store.account(id).expect("persisted account")
}
fn timestamp(time: SystemTime) -> u64 {
time.duration_since(UNIX_EPOCH).expect("epoch").as_secs()
}
#[tokio::test]
async fn old_weekly_quota_should_outweigh_one_in_flight_title_request() {
let store = Arc::new(MemoryAccountStore::default());
let now = SystemTime::now();
let reset_at = timestamp(now + Duration::from_secs(6 * 3600));
let mut accounts = Vec::new();
for (id, used_percent, age_minutes) in [("acct_74", 74, 41), ("acct_96", 96, 16)] {
accounts.push(
persist_snapshot(
&store,
id,
now - Duration::from_secs(age_minutes * 60),
json!({
"allowed": true,
"primary_window": {
"used_percent": used_percent,
"reset_at": reset_at,
"limit_window_seconds": 604_800
}
}),
)
.await
.with_scheduling(
Some(AccountConcurrencyLimit::new(10).expect("concurrency")),
AccountWeight::DEFAULT,
),
);
}
let service = quota_service(&store);
service.prepare_scheduling(&accounts).await;
let candidates = accounts
.into_iter()
.map(|account| AccountCandidate {
signals: AccountRuntimeSignals {
in_flight: u32::from(account.id().as_str() == "acct_74"),
last_started_at: None,
quota_reset_at: None,
quota_remaining_rank: None,
rate_limited_until: None,
failure_rate_basis_points: None,
first_output_latency_ms: None,
}
.with_provider_quota(service.scheduling_signals(&account)),
account,
})
.collect::<Vec<_>>();
for cursor in 0..20 {
let context = AccountSelectionContext {
policy: AccountSelectionPolicy::new(
RotationStrategy::Smart,
NonZeroU32::new(3).expect("default concurrency"),
Duration::ZERO,
),
now,
excluded_accounts: BTreeSet::new(),
preferred_account: None,
preferred_account_overrides_weight: true,
round_robin_cursor: cursor,
eligibility: AccountEligibilityPolicy::Enforce,
account_scope: None,
};
let selected = AccountSelector
.select(&candidates, &context)
.expect("available account");
assert_eq!(selected.candidate().account.id().as_str(), "acct_74");
}
}
#[tokio::test]
async fn old_quota_should_drop_reset_short_window_and_keep_weekly_window() {
let store = Arc::new(MemoryAccountStore::default());
let now = SystemTime::now();
let weekly_reset = now + Duration::from_secs(6 * 3600);
let account = persist_snapshot(
&store,
"acct_mixed_resets",
now - Duration::from_secs(41 * 60),
json!({
"primary_window": {
"used_percent": 99,
"reset_at": timestamp(now - Duration::from_secs(60)),
"limit_window_seconds": 18_000
},
"secondary_window": {
"used_percent": 74,
"reset_at": timestamp(weekly_reset),
"limit_window_seconds": 604_800
}
}),
)
.await;
let service = quota_service(&store);
service
.read_account(account.id())
.await
.expect("read quota");
let signals = service.scheduling_signals(&account).expect("weekly quota");
assert_eq!(
(signals.remaining_rank(), signals.reset_at().map(timestamp)),
(Some(2600), Some(timestamp(weekly_reset)))
);
}
#[tokio::test]
async fn undated_quota_should_expire_independently_of_dated_weekly_quota() {
for (age_minutes, expected_remaining) in [(1, 100), (41, 2600)] {
let store = Arc::new(MemoryAccountStore::default());
let now = SystemTime::now();
let account = persist_snapshot(
&store,
"acct_undated_window",
now - Duration::from_secs(age_minutes * 60),
json!({
"primary_window": {"used_percent": 99},
"secondary_window": {
"used_percent": 74,
"reset_at": timestamp(now + Duration::from_secs(6 * 3600)),
"limit_window_seconds": 604_800
}
}),
)
.await;
let service = quota_service(&store);
service
.prepare_scheduling(std::slice::from_ref(&account))
.await;
assert_eq!(
service
.scheduling_signals(&account)
.and_then(|s| s.remaining_rank()),
Some(expected_remaining),
"observation age: {age_minutes} minutes"
);
}
}
#[tokio::test]
async fn old_undated_quota_should_remain_unknown_after_cache_hydration() {
let store = Arc::new(MemoryAccountStore::default());
let account = persist_snapshot(
&store,
"acct_undated_only",
SystemTime::now() - Duration::from_secs(41 * 60),
json!({"primary_window": {"used_percent": 74}}),
)
.await;
let service = quota_service(&store);
for _ in 0..2 {
service
.prepare_scheduling(std::slice::from_ref(&account))
.await;
assert_eq!(service.scheduling_signals(&account), None);
}
assert_eq!(store.quota_reads(), 1);
}
#[tokio::test]
async fn fresh_reset_observation_should_clear_previous_scheduling_signals() {
let store = Arc::new(MemoryAccountStore::default());
let now = SystemTime::now();
let account = persist_snapshot(
&store,
"acct_fresh_reset",
now,
json!({"primary_window": {
"used_percent": 74,
"reset_at": timestamp(now + Duration::from_secs(3600))
}}),
)
.await;
let service = quota_service(&store);
service
.prepare_scheduling(std::slice::from_ref(&account))
.await;
assert_eq!(
service
.scheduling_signals(&account)
.and_then(|s| s.remaining_rank()),
Some(2600)
);
let headers = vec![
("x-codex-active-limit".to_owned(), "codex".to_owned()),
("x-codex-primary-used-percent".to_owned(), "74".to_owned()),
(
"x-codex-primary-reset-at".to_owned(),
timestamp(now - Duration::from_secs(60)).to_string(),
),
];
assert!(
service
.synchronize_passive_headers(&account, &headers)
.await
.expect("reset observation")
);
assert_eq!(service.scheduling_signals(&account), None);
}
#[tokio::test]
async fn reset_window_should_not_clear_confirmed_exhaustion() {
let store = Arc::new(MemoryAccountStore::default());
let now = SystemTime::now();
let reset_at = now - Duration::from_secs(60);
let account = persist_snapshot(
&store,
"acct_exhausted_reset",
now - Duration::from_secs(41 * 60),
json!({"primary_window": {"used_percent": 100, "reset_at": timestamp(reset_at)}}),
)
.await;
super::persist_quota_state(
&store,
&account,
QuotaState::exhausted(QuotaEvidence::ProviderDenied, now, Some(reset_at)),
)
.await;
let service = quota_service(&store);
service
.prepare_scheduling(std::slice::from_ref(&account))
.await;
assert_eq!(service.scheduling_signals(&account), None);
assert_eq!(
store
.account("acct_exhausted_reset")
.expect("account")
.quota()
.access(),
QuotaAccessState::Exhausted
);
}
@@ -63,7 +63,7 @@ const variantDefinitions: Record<
minimumBarWidth: '6px',
compactValues: false,
labelTooltip: true,
showResetTime: true,
showResetTime: false,
},
compact: {
rootClass: 'flex min-w-0 flex-col',
@@ -1,12 +1,15 @@
<script setup lang="ts">
import { computed } from 'vue'
import BaseButton from '@/components/base/BaseButton.vue'
import BaseScrollbar from '@/components/base/BaseScrollbar.vue'
import { useAsyncAction } from '@/composables/useAsyncAction'
import { useDownload } from '@/composables/useDownload'
import { useRequestDiagnostics } from '../composables/useRequestDiagnostics'
import UsageDetailCodePanel from './UsageDetailCodePanel.vue'
const props = defineProps<{ requestId: string }>()
const { downloadJson } = useDownload()
const { loading: exporting, run: runExport } = useAsyncAction()
const { selectedId, detail, loading, error, refresh } = useRequestDiagnostics(() => props.requestId)
const trace = computed(() => detail.value?.trace)
const events = computed(() => (trace.value?.events ?? []).map(event => ({
@@ -15,7 +18,7 @@ const events = computed(() => (trace.value?.events ?? []).map(event => ({
})))
function download() {
if (!detail.value)
if (!detail.value || exporting.value)
return
const record = detail.value
const bundle = {
@@ -40,7 +43,10 @@ function download() {
attemptsComplete: record.attemptsComplete,
relatedRequests: record.relatedRequests,
}
return downloadJson(bundle, `diagnostics-${record.requestId.replace(/[^\w-]/g, '_')}.json`)
return runExport(
() => downloadJson(bundle, `diagnostics-${record.requestId.replace(/[^\w-]/g, '_')}.json`),
{ errorText: '导出诊断包失败', minimumMs: 400 },
)
}
</script>
@@ -57,12 +63,12 @@ function download() {
<BaseButton size="sm" :disabled="loading" @click="refresh">
刷新
</BaseButton>
<BaseButton size="sm" :disabled="!detail" @click="download">
<BaseButton size="sm" :loading="exporting" :disabled="!detail" @click="download">
导出诊断包
</BaseButton>
</div>
</div>
<p class="break-all font-mono text-cp-xs text-cp-text-secondary">
<p class="mt-1 mb-3 break-all font-mono text-cp-xs leading-relaxed text-cp-text-secondary">
{{ selectedId }}
</p>
<p v-if="loading" role="status" class="text-cp-sm text-cp-text-secondary">
@@ -81,27 +87,33 @@ function download() {
这条记录没有保存诊断时间线。旧记录无法补回当时未采集的事件。
</p>
<template v-else>
<p class="text-cp-xs text-cp-text-secondary">
已观测 {{ trace.totalEvents }} 个阶段或事件,展示 {{ events.length }} 条摘要。连续增量合并计数;正文和敏感字段仅保留摘要。
</p>
<p v-if="trace.droppedEvents" role="status" class="text-cp-xs text-cp-warning-text">
达到保存上限,{{ trace.droppedEvents }} 个事件已被淘汰;保留请求开头与最近事件。
</p>
<ol class="m-0 grid max-h-128 list-none gap-2 overflow-auto p-0">
<li v-for="event in events" :key="event.sequence" class="min-w-0 rounded-cp bg-cp-bg-container px-3 py-2">
<details>
<summary class="cursor-pointer break-all text-cp-xs leading-relaxed">
<span class="font-mono text-cp-text-secondary">+{{ event.elapsedMs }} ms · #{{ event.sequence }}</span>
<span class="mx-2 font-mono">{{ event.stage }}</span>
<span v-if="event.attemptIndex">尝试 {{ event.attemptIndex }}</span>
<span v-if="event.exchangeId"> · 交换 {{ event.exchangeId }}</span>
<span v-if="event.count > 1"> · {{ event.count }} 次(至 +{{ event.lastElapsedMs }} ms)</span>
</summary>
<UsageDetailCodePanel class="mt-2" title="诊断事实" :content="event.content" max-height="280px" />
</details>
</li>
</ol>
<p class="mb-0 text-cp-xs text-cp-text-secondary">
<div class="mb-3 grid gap-1 text-cp-xs leading-relaxed">
<p class="m-0 text-cp-text-secondary">
已观测 {{ trace.totalEvents }} 个阶段或事件,展示 {{ events.length }} 条摘要。连续增量合并计数;正文和敏感字段仅保留摘要。
</p>
<p v-if="trace.droppedEvents" role="status" class="m-0 text-cp-warning-text">
达到保存上限,{{ trace.droppedEvents }} 个事件已被淘汰;保留请求开头与最近事件。
</p>
</div>
<BaseScrollbar max-height="32rem">
<ol class="m-0 grid list-none gap-2 p-0 pr-3">
<li v-for="event in events" :key="event.sequence" class="min-w-0 rounded-cp bg-cp-bg-container">
<details>
<summary class="cursor-pointer rounded-cp px-3 py-2.5 break-all text-cp-xs leading-relaxed hover:bg-cp-fill-quaternary focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-cp-primary">
<span class="font-mono text-cp-text-secondary">+{{ event.elapsedMs }} ms · #{{ event.sequence }}</span>
<span class="mx-2 font-mono text-cp-text">{{ event.stage }}</span>
<span v-if="event.attemptIndex" class="text-cp-text-secondary">尝试 {{ event.attemptIndex }}</span>
<span v-if="event.exchangeId" class="text-cp-text-secondary"> · 交换 {{ event.exchangeId }}</span>
<span v-if="event.count > 1" class="text-cp-text-secondary"> · {{ event.count }} 次(至 +{{ event.lastElapsedMs }} ms)</span>
</summary>
<div class="mx-3 border-t border-cp-border-secondary py-3">
<UsageDetailCodePanel title="诊断事实" :content="event.content" max-height="280px" />
</div>
</details>
</li>
</ol>
</BaseScrollbar>
<p class="mt-3 mb-0 text-cp-xs leading-relaxed text-cp-text-secondary">
时间线保存至执行结束。客户端最终写入、进程退出等后续事实,可用此请求 ID 检索服务器日志。
</p>
</template>
+1 -1
View File
@@ -1 +1 @@
version: "3.3.24"
version: "3.3.25"