ManimCat / frontend /src /components /TimingPanel.tsx
Bin29's picture
Sync from main: c1ef036 chore: document docker persistence volumes
94e1b2f
import { useMemo, useState } from 'react';
import type { JobTimings } from '../types/api';
import { useI18n } from '../i18n';
function formatDuration(ms: number): string {
if (ms >= 1000) {
return `${(ms / 1000).toFixed(1)}s`;
}
return `${Math.round(ms)}ms`;
}
interface TimingPanelProps {
timings?: JobTimings;
submittedAt?: string | null;
finishedAt?: string | null;
}
export function TimingPanel({ timings, submittedAt, finishedAt }: TimingPanelProps) {
const { t } = useI18n();
const [isOpen, setIsOpen] = useState(false);
const { total, items } = useMemo(() => {
const timingLabels: Array<{ key: keyof JobTimings; label: string }> = [
{ key: 'analyze', label: t('timing.analyze') },
{ key: 'edit', label: t('timing.edit') },
{ key: 'retry', label: t('timing.retry') },
{ key: 'render', label: t('timing.render') },
{ key: 'store', label: t('timing.store') },
];
const items = timingLabels
.map(({ key, label }) => ({ key, label, value: timings?.[key] }))
.filter((item) => 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 total = typeof endToEndTotal === 'number'
? endToEndTotal
: typeof timings?.total === 'number'
? timings.total
: items.reduce((sum, item) => sum + (item.value || 0), 0);
return { total, items };
}, [finishedAt, submittedAt, t, timings]);
if (!items.length && !Number.isFinite(total)) {
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-56 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>
))}
</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>
<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>
);
}