File size: 6,885 Bytes
68722e4 5fe2e71 68722e4 5fe2e71 68722e4 5fe2e71 68722e4 5fe2e71 68722e4 5fe2e71 68722e4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | import { useMemo, useState } from 'react';
import type { JobTimings, JobTokenUsageSummary } from '../../../shared/types/api';
import { useI18n } from '../../../shared/i18n/index';
function formatDuration(ms: number): string {
if (ms >= 1000) {
return `${(ms / 1000).toFixed(1)}s`;
}
return `${Math.round(ms)}ms`;
}
function formatTokens(value: number): string {
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`;
if (value >= 1_000) return `${(value / 1_000).toFixed(value >= 10_000 ? 1 : 2)}K`;
return String(Math.round(value));
}
interface TimingPanelProps {
timings?: JobTimings;
submittedAt?: string | null;
finishedAt?: string | null;
tokenUsage?: JobTokenUsageSummary;
}
export function TimingPanel({ timings, submittedAt, finishedAt, tokenUsage }: TimingPanelProps) {
const { t } = useI18n();
const [isOpen, setIsOpen] = useState(false);
const { total, items } = useMemo(() => {
const timingLabels: Array<{ key: keyof JobTimings; label: string }> = [
{ key: 'queue', label: t('timing.queue') },
{ key: 'analyze', label: t('timing.analyze') },
{ key: 'generate', label: t('timing.generate') },
{ key: 'edit', label: t('timing.edit') },
{ key: 'retry', label: t('timing.retry') },
{ key: 'render', label: t('timing.render') },
{ key: 'mux', label: t('timing.mux') },
{ key: 'store', label: t('timing.store') },
];
const items = timingLabels
.map(({ key, label }) => ({ key, label, value: timings?.[key] }))
.filter((item): item is { key: keyof JobTimings; label: string; value: number } => typeof item.value === 'number');
const submittedMs = submittedAt ? Date.parse(submittedAt) : Number.NaN;
const finishedMs = finishedAt ? Date.parse(finishedAt) : Number.NaN;
const endToEndTotal = Number.isFinite(submittedMs) && Number.isFinite(finishedMs)
? Math.max(0, finishedMs - submittedMs)
: undefined;
const measuredTotal = timings?.total;
const totalIsMeasured = typeof measuredTotal === 'number';
const total = totalIsMeasured
? measuredTotal
: typeof endToEndTotal === 'number'
? endToEndTotal
: items.reduce((sum, item) => sum + item.value, 0);
const rows: Array<{ key: keyof JobTimings | 'overhead'; label: string; value: number }> = [...items];
if (totalIsMeasured) {
const measured = items.reduce((sum, item) => sum + item.value, 0);
const overhead = Math.max(0, total - measured);
if (overhead >= 1) {
rows.push({ key: 'overhead', label: t('timing.overhead'), value: overhead });
}
}
return { total, items: rows };
}, [finishedAt, submittedAt, t, timings]);
const tokenTotals = tokenUsage?.totals;
const hasMeasuredTokens = Boolean(tokenTotals && tokenTotals.measuredCalls > 0);
const measuredTotalTokens = tokenTotals
? tokenTotals.totalTokens || tokenTotals.promptTokens + tokenTotals.completionTokens
: 0;
const hasTokenCalls = Boolean(tokenUsage?.calls.length);
if (!items.length && !Number.isFinite(total) && !hasTokenCalls) {
return null;
}
return (
<div className="fixed left-4 bottom-4 z-40">
<div className="relative">
<div
className={`absolute left-0 bottom-full mb-2 w-64 rounded-2xl bg-bg-secondary/90 text-xs text-text-secondary shadow-lg shadow-black/10 border border-bg-secondary/60 backdrop-blur px-4 py-3 space-y-2 origin-bottom-left transition-all duration-200 ease-out ${
isOpen
? 'opacity-100 translate-y-0 scale-100 pointer-events-auto'
: 'opacity-0 translate-y-2 scale-95 pointer-events-none'
}`}
aria-hidden={!isOpen}
>
{items.map((item) => (
<div key={item.key} className="flex items-center justify-between">
<span>{item.label}</span>
<span className="text-text-primary font-medium">{formatDuration(item.value!)}</span>
</div>
))}
{hasTokenCalls && (
<>
<div className="border-t border-text-secondary/10 pt-2" />
<div className="flex items-center justify-between">
<span>{t('timing.tokensTotal')}</span>
<span className="text-text-primary font-medium">
{hasMeasuredTokens ? formatTokens(measuredTotalTokens) : t('timing.tokensUnmeasured')}
</span>
</div>
{hasMeasuredTokens && (
<>
<div className="flex items-center justify-between">
<span>{t('timing.tokensInput')}</span>
<span className="text-text-primary font-medium">{formatTokens(tokenTotals!.promptTokens)}</span>
</div>
<div className="flex items-center justify-between">
<span>{t('timing.tokensOutput')}</span>
<span className="text-text-primary font-medium">{formatTokens(tokenTotals!.completionTokens)}</span>
</div>
</>
)}
<div className="flex items-center justify-between">
<span>{t('timing.tokensCalls')}</span>
<span className="text-text-primary font-medium">
{tokenTotals?.measuredCalls || 0}/{tokenUsage!.calls.length}
</span>
</div>
{!!tokenTotals?.unmeasuredCalls && (
<div className="text-[10px] leading-relaxed text-text-secondary/70">
{t('timing.tokensPartial', { count: tokenTotals.unmeasuredCalls })}
</div>
)}
</>
)}
</div>
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
aria-expanded={isOpen}
className="flex items-center gap-2 px-3 py-2 rounded-full bg-bg-secondary/80 text-xs text-text-secondary/90 shadow-lg shadow-black/10 backdrop-blur border border-bg-secondary/60 hover:text-text-primary hover:bg-bg-secondary transition-colors"
>
<span className="text-[11px] tracking-wide">{t('timing.title')}</span>
<span className="text-text-primary font-medium">{formatDuration(total)}</span>
{hasTokenCalls && (
<span className="text-text-primary/80 font-medium">
· {hasMeasuredTokens ? `${formatTokens(measuredTotalTokens)} tok` : t('timing.tokensUnmeasured')}
</span>
)}
<svg
className={`w-3 h-3 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
</div>
);
}
|