diff --git a/frontend/src/components/pages/launch-model/launch-dialog/download-progress-details.tsx b/frontend/src/components/pages/launch-model/launch-dialog/download-progress-details.tsx new file mode 100644 index 0000000000..e7f30f0556 --- /dev/null +++ b/frontend/src/components/pages/launch-model/launch-dialog/download-progress-details.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { CheckCircle2, Download, LoaderCircle } from 'lucide-react'; + +import { CollapsiblePanel } from '@/components/ui/collapsible'; +import { Progress } from '@/components/ui/progress'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { useI18n } from '@/contexts/i18n-context'; +import { formatFileSize } from '@/lib/utils'; + +export interface DownloadProgressFile { + name: string; + downloaded_bytes: number; + total_bytes: number | null; + progress: number | null; + speed_bytes_per_second: number | null; + elapsed_seconds: number; + eta_seconds: number | null; + status: string; + replica_id?: number; + replica_model_uid?: string; +} + +interface DownloadProgressDetailsProps { + files: DownloadProgressFile[]; +} + +function getProgressPercent(file: DownloadProgressFile): number { + if (file.progress !== null && file.progress !== undefined) { + const progress = Number(file.progress); + if (Number.isFinite(progress)) { + const percent = progress <= 1 ? progress * 100 : progress; + return Math.max(0, Math.min(100, percent)); + } + } + + if (file.total_bytes && file.total_bytes > 0) { + return Math.max(0, Math.min(100, (file.downloaded_bytes / file.total_bytes) * 100)); + } + + return 0; +} + +function formatSpeed(bytesPerSecond: number | null, completed: boolean): string { + if (completed || bytesPerSecond === null || !Number.isFinite(bytesPerSecond)) { + return '—'; + } + + return `${formatFileSize(Math.max(0, bytesPerSecond))}/s`; +} + +function formatDuration(seconds: number | null): string { + if (seconds === null || !Number.isFinite(seconds)) { + return '—'; + } + + const totalSeconds = Math.max(0, Math.ceil(seconds)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const remainingSeconds = totalSeconds % 60; + + return [hours, minutes, remainingSeconds] + .map((value) => String(value).padStart(2, '0')) + .join(':'); +} + +export default function DownloadProgressDetails({ files }: DownloadProgressDetailsProps) { + const { t } = useI18n(); + const hasMultipleReplicas = + new Set(files.map((file) => file.replica_model_uid).filter(Boolean)).size > 1; + + return ( + + {t('launchModel.downloadDetails')} + + {files.length} + + + } + icon={} + className="rounded-lg" + contentClassName="p-0" + > + {files.length === 0 ? ( +
+ + {t('launchModel.waitingDownloadDetails')} +
+ ) : ( +
+ + + + {t('launchModel.downloadFileName')} + {t('launchModel.downloadProgress')} + {t('launchModel.downloadStatus')} + {t('launchModel.downloadSpeed')} + {t('launchModel.downloadEta')} + + + + {files.map((file, index) => { + const progress = getProgressPercent(file); + const completed = file.status === 'completed' || progress >= 100; + + return ( + + +
+ {file.name || '-'} +
+ {hasMultipleReplicas && ( +
+ {t('launchModel.replica')} {file.replica_id} +
+ )} +
+ +
+ + + {Math.round(progress)}% + +
+
+ + + {completed ? ( + + ) : ( + + )} + {t( + completed + ? 'launchModel.downloadCompleted' + : 'launchModel.downloadInProgress' + )} + + + + {formatSpeed(file.speed_bytes_per_second, completed)} + + + {formatDuration(completed ? 0 : file.eta_seconds)} + +
+ ); + })} +
+
+
+ )} +
+ ); +} diff --git a/frontend/src/components/pages/launch-model/launch-dialog/launch-dialog.tsx b/frontend/src/components/pages/launch-model/launch-dialog/launch-dialog.tsx index fc7a3cb879..e99a97b46f 100644 --- a/frontend/src/components/pages/launch-model/launch-dialog/launch-dialog.tsx +++ b/frontend/src/components/pages/launch-model/launch-dialog/launch-dialog.tsx @@ -56,6 +56,7 @@ import { GPU_IDX_PATTERN, } from '../utils'; import CommandLine from './command-line'; +import DownloadProgressDetails, { type DownloadProgressFile } from './download-progress-details'; import ReplicaPlacementConfig from './replica-placement-config'; import { FormField } from '@/components/ui/form-field'; @@ -66,6 +67,23 @@ interface LaunchDialogProps { onOpenChange: (open: boolean) => void; } +interface LaunchProgressReplica { + replica_id: number; + replica_model_uid: string; + progress: number; + stage: string; + info: string | null; + updated_at: number | null; + download_files: DownloadProgressFile[]; +} + +interface LaunchProgressResponse { + progress?: number | string; + stage?: string; + download_files?: DownloadProgressFile[]; + replicas?: LaunchProgressReplica[]; +} + export default function LaunchDialog({ model, modelType, @@ -83,6 +101,7 @@ export default function LaunchDialog({ const [canceling, setCanceling] = useState(false); const [saveAutostart, setSaveAutostart] = useState(false); const [progress, setProgress] = useState(0); + const [progressDetails, setProgressDetails] = useState(null); const [replicaStatuses, setReplicaStatuses] = useState([]); const [configCacheRefreshKey, setConfigCacheRefreshKey] = useState(0); const pollingRef = useRef | null>(null); @@ -1315,9 +1334,7 @@ export default function LaunchDialog({ const modelUid = form.getFieldValue('model_uid') || model?.model_name; try { const [progressRes, replicaRes] = await Promise.all([ - request.get( - `/v1/models/${modelUid}/progress` - ), + request.get(`/v1/models/${modelUid}/progress`), request.get(`/v1/models/${modelUid}/replicas`), ]); @@ -1326,6 +1343,7 @@ export default function LaunchDialog({ const nextProgress = normalizeProgress(progressValue); setProgress(nextProgress); + setProgressDetails(progressRes && typeof progressRes === 'object' ? progressRes : null); setReplicaStatuses(normalizeReplicaStatuses(replicaRes)); if (nextProgress >= 100) { @@ -1416,6 +1434,7 @@ export default function LaunchDialog({ stopPolling(); setLoading(false); setProgress(0); + setProgressDetails(null); setReplicaStatuses([]); toast.success(t('launchModel.launchCanceled')); } finally { @@ -1450,6 +1469,7 @@ export default function LaunchDialog({ isCanceledLaunchRef.current = false; setLoading(true); setProgress(0); + setProgressDetails(null); setReplicaStatuses([]); request @@ -1504,6 +1524,7 @@ export default function LaunchDialog({ setLoading(false); setCanceling(false); setProgress(0); + setProgressDetails(null); setReplicaStatuses([]); setSaveAutostart(false); stopPolling(); @@ -1564,7 +1585,10 @@ export default function LaunchDialog({ onOpenChange(open); }} > - +
{model?.model_name} @@ -1590,7 +1614,20 @@ export default function LaunchDialog({ {renderLaunchFields(currentLaunchFields)} - {loading && } + {loading && ( +
+
+ + + {Math.round(progress)}% + +
+ {(progressDetails?.stage === 'downloading' || + Boolean(progressDetails?.download_files?.length)) && ( + + )} +
+ )}