feat: record task attempt chains

This commit is contained in:
2026-05-11 22:39:45 +08:00
parent 4c2de4b4c9
commit 0049b246c1
9 changed files with 492 additions and 9 deletions
+97
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react';
import { Popover as AntPopover } from 'antd';
import { ChevronLeft, ChevronRight, Copy, CreditCard, Eye, KeyRound, ListChecks, Plus, RotateCcw, Search, ShieldCheck, Trash2, UserRound } from 'lucide-react';
import type { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayTask, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts';
import type { ConsoleData } from '../app-state';
@@ -433,6 +434,7 @@ function TaskPanel(props: {
<TableHead>RequestID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>API Key</TableHead>
<TableHead>Token</TableHead>
@@ -556,6 +558,9 @@ function TaskRecord(props: { task: GatewayTask; onCopyRequestId: (task: GatewayT
{props.task.requestedModel && props.task.requestedModel !== resolvedModel && <small>{props.task.requestedModel}</small>}
</span>
</TableCell>
<TableCell className="taskRecordAttemptCell">
<TaskAttemptChain task={props.task} />
</TableCell>
<TableCell>{props.task.modelType || '-'}</TableCell>
<TableCell>{props.task.apiKeyName || props.task.apiKeyPrefix || props.task.apiKeyId || '-'}</TableCell>
<TableCell className="taskRecordTokenCell">{tokenUsage}</TableCell>
@@ -572,11 +577,103 @@ function TaskRecord(props: { task: GatewayTask; onCopyRequestId: (task: GatewayT
);
}
function TaskAttemptChain(props: { task: GatewayTask }) {
const attempts = props.task.attempts ?? [];
if (!attempts.length) return <span>-</span>;
return (
<AntPopover
align={{ offset: [0, 8] }}
content={<TaskAttemptPopoverContent task={props.task} />}
overlayClassName="taskRecordAttemptAntPopover"
placement="bottomLeft"
trigger={['hover', 'focus']}
>
<button className="taskRecordAttemptCount" type="button" aria-label={attempts.map(taskAttemptTitle).join('\n')}>
{attempts.length}
</button>
</AntPopover>
);
}
function TaskAttemptPopoverContent(props: { task: GatewayTask }) {
const attempts = props.task.attempts ?? [];
return (
<span className="taskRecordAttemptPopover" role="tooltip">
{attempts.map((attempt) => (
<span
key={attempt.id || `${props.task.id}-${attempt.attemptNo}`}
className={`taskRecordAttemptDetail ${attempt.status === 'failed' ? 'failed' : attempt.status === 'succeeded' ? 'succeeded' : ''}`}
>
<span className="taskRecordAttemptDetailHeader">
<strong>#{attempt.attemptNo} {taskAttemptTarget(attempt)}</strong>
<Badge variant={attempt.status === 'succeeded' ? 'success' : attempt.status === 'failed' ? 'destructive' : 'secondary'}>{taskAttemptStatusText(attempt.status)}</Badge>
</span>
<small>{taskAttemptMeta(attempt)}</small>
{attempt.status === 'failed' && <span className="taskRecordAttemptError">{taskAttemptFailureReason(attempt)}</span>}
</span>
))}
</span>
);
}
function taskAttemptTitle(attempt: NonNullable<GatewayTask['attempts']>[number]) {
const parts = [
`#${attempt.attemptNo}`,
attempt.platformName || attempt.provider || attempt.clientId || '',
attempt.status,
attempt.errorMessage || attempt.errorCode || metadataString(attempt.metrics, 'error') || '',
].filter(Boolean);
return parts.join(' · ');
}
function taskAttemptTarget(attempt: NonNullable<GatewayTask['attempts']>[number]) {
return attempt.platformName || attempt.provider || attempt.clientId || `尝试 ${attempt.attemptNo}`;
}
function taskAttemptStatusText(status: string) {
if (status === 'succeeded') return '成功';
if (status === 'failed') return '失败';
if (status === 'running') return '运行中';
return status || '-';
}
function taskAttemptMeta(attempt: NonNullable<GatewayTask['attempts']>[number]) {
const values = [
attempt.providerModelName || attempt.modelName || attempt.modelAlias,
attempt.requestId ? `RequestID ${attempt.requestId}` : '',
attempt.responseDurationMs ? formatDuration(attempt.responseDurationMs) : '',
].filter(Boolean);
return values.join(' · ') || attempt.clientId || '-';
}
function taskAttemptFailureReason(attempt: NonNullable<GatewayTask['attempts']>[number]) {
const detail = firstText(
attempt.errorMessage,
metadataString(attempt.metrics, 'error'),
metadataString(attempt.metrics, 'message'),
);
const code = firstText(attempt.errorCode, metadataString(attempt.metrics, 'errorCode'));
if (detail && code && detail !== code) return `${detail}${code}`;
return detail || code || '失败';
}
function formatCellValue(value: unknown) {
if (value === undefined || value === null || value === '') return '-';
return String(value);
}
function firstText(...values: Array<unknown>) {
for (const value of values) {
if (typeof value === 'string' && value.trim()) return value.trim();
}
return '';
}
function metadataString(metadata: Record<string, unknown> | undefined, key: string) {
const value = metadata?.[key];
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function formatTokenUsage(usage: Record<string, unknown>) {
const input = tokenValue(usage.inputTokens ?? usage.promptTokens ?? usage.input_tokens ?? usage.prompt_tokens);
const output = tokenValue(usage.outputTokens ?? usage.completionTokens ?? usage.output_tokens ?? usage.completion_tokens);
+71 -2
View File
@@ -261,8 +261,8 @@ strong {
}
.taskRecordTable .shTableRow {
grid-template-columns: minmax(190px, 0.95fr) minmax(220px, 1.05fr) minmax(94px, 0.42fr) minmax(280px, 1.55fr) minmax(126px, 0.58fr) minmax(150px, 0.7fr) minmax(154px, 0.66fr) minmax(82px, 0.38fr) minmax(98px, 0.45fr) minmax(150px, 0.7fr) minmax(130px, 0.58fr);
min-width: 1674px;
grid-template-columns: minmax(190px, 0.9fr) minmax(220px, 1fr) minmax(94px, 0.4fr) minmax(280px, 1.45fr) minmax(104px, 0.42fr) minmax(126px, 0.55fr) minmax(150px, 0.66fr) minmax(154px, 0.62fr) minmax(82px, 0.36fr) minmax(98px, 0.42fr) minmax(150px, 0.66fr) minmax(130px, 0.54fr);
min-width: 1778px;
align-items: start;
}
@@ -354,6 +354,75 @@ strong {
word-break: break-word;
}
.taskRecordAttemptCell {
overflow: visible;
white-space: normal;
}
.taskRecordAttemptCount {
display: inline-flex;
align-items: center;
justify-content: flex-start;
min-height: 1.5rem;
padding: 0;
border: 0;
background: transparent;
color: var(--text-strong);
cursor: default;
font: inherit;
font-weight: var(--font-weight-medium);
}
.taskRecordAttemptAntPopover {
z-index: 1200;
}
.taskRecordAttemptPopover {
display: grid;
width: min(34rem, calc(100vw - 2rem));
gap: 0.6rem;
}
.taskRecordAttemptDetail {
display: grid;
min-width: 0;
gap: 0.35rem;
padding-bottom: 0.6rem;
border-bottom: 1px solid var(--border);
}
.taskRecordAttemptDetail:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.taskRecordAttemptDetailHeader {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.taskRecordAttemptDetailHeader strong {
min-width: 0;
color: var(--text-strong);
font-weight: var(--font-weight-semibold);
overflow-wrap: anywhere;
}
.taskRecordAttemptDetail small {
color: var(--text-soft);
font-size: var(--font-size-xs);
line-height: 1.4;
}
.taskRecordAttemptError {
color: var(--destructive);
font-size: var(--font-size-xs);
line-height: 1.45;
overflow-wrap: anywhere;
}
.taskRecordJsonButton {
width: 100%;
justify-content: flex-start;