diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 210c39e..17d5aa7 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -837,6 +837,46 @@ button { display: inline-block; } +/* Upload progress (Operaciones ingest) */ +.upload-progress { + display: flex; + flex-direction: column; + gap: 6px; + padding: 4px 0 8px; +} +.progress-track { + position: relative; + overflow: hidden; + height: 8px; + border-radius: 999px; + background: var(--paper-2); +} +.progress-fill { + height: 100%; + border-radius: 999px; + background: var(--brand-500); + transition: width 0.2s linear; +} +.progress-indeterminate .progress-fill { + width: 40% !important; + animation: progress-slide 1.2s var(--ease-out-quart) infinite; +} +@keyframes progress-slide { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(250%); + } +} +.upload-progress-stats { + display: flex; + flex-wrap: wrap; + gap: 12px; + font-size: 12px; + color: var(--muted); +} + @keyframes shimmer { 0% { background-position: -420px 0; diff --git a/apps/web/src/app/operaciones/page.tsx b/apps/web/src/app/operaciones/page.tsx index ede68e1..abeece9 100644 --- a/apps/web/src/app/operaciones/page.tsx +++ b/apps/web/src/app/operaciones/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { Fragment, useCallback, useEffect, useRef, useState } from "react"; import { AppShell } from "@/components/AppShell"; import { useCan } from "@/lib/abilities"; import { @@ -20,6 +20,7 @@ import { startOpsJob, uploadIngest, } from "@/lib/api"; +import type { UploadProgress } from "@/lib/api"; import type { BackupFile, IngestFile, @@ -56,6 +57,7 @@ function Operaciones() { const [confirm, setConfirm] = useState(null); const [confirmText, setConfirmText] = useState(""); const [uploading, setUploading] = useState(null); + const [progress, setProgress] = useState(null); const [starting, setStarting] = useState(false); const fileInputs = useRef>({}); @@ -119,14 +121,16 @@ function Operaciones() { setError(null); setNotice(null); setUploading(name); + setProgress(null); try { - await uploadIngest(name, file); + await uploadIngest(name, file, setProgress); setNotice(`${name} cargado.`); refreshLists(); } catch (e) { setError((e as Error)?.message ?? "No se pudo cargar el archivo."); } finally { setUploading(null); + setProgress(null); const input = fileInputs.current[name]; if (input) input.value = ""; } @@ -243,45 +247,55 @@ function Operaciones() { {(ingest ?? []).map((f) => ( - - {f.name} - - - {f.present ? "Presente" : "Falta"} - - - {formatBytes(f.size)} - {formatDateTime(f.modifiedAt)} - -
- { - fileInputs.current[f.name] = el; - }} - type="file" - style={{ display: "none" }} - onChange={(e) => handleUpload(f.name, e.target.files?.[0])} - /> - - {f.present && ( + + + {f.name} + + + {f.present ? "Presente" : "Falta"} + + + {formatBytes(f.size)} + {formatDateTime(f.modifiedAt)} + +
+ { + fileInputs.current[f.name] = el; + }} + type="file" + style={{ display: "none" }} + onChange={(e) => handleUpload(f.name, e.target.files?.[0])} + /> - )} -
- - + {f.present && ( + + )} +
+ + + {uploading === f.name && ( + + + + + + )} + ))} @@ -496,6 +510,61 @@ function Operaciones() { ); } +/** "1:05" / "0:09" — remaining time, coarse on purpose. */ +function formatEta(seconds: number): string { + const s = Math.max(0, Math.round(seconds)); + if (s >= 3600) { + const h = Math.floor(s / 3600); + const m = Math.round((s % 3600) / 60); + return `${h} h ${m} min`; + } + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; +} + +/** + * Live upload readout. The bar tracks bytes handed to the network; once those + * are all sent the server still has to write the file, so the tail of the + * upload reads "Procesando…" instead of sitting at 100%. + */ +function UploadProgressBar({ progress }: { progress: UploadProgress | null }) { + const pct = progress?.fraction != null ? Math.round(progress.fraction * 100) : null; + return ( +
+
+
+
+
+ {progress === null ? ( + "Preparando…" + ) : progress.finishing ? ( + `Procesando en el servidor… (${formatBytes(progress.total)} enviados)` + ) : ( + <> + {pct !== null && {pct}%} + {progress.total > 0 && ( + + {formatBytes(progress.loaded)} / {formatBytes(progress.total)} + + )} + {progress.bytesPerSecond > 0 && ( + {formatBytes(progress.bytesPerSecond)}/s + )} + {progress.secondsRemaining !== null && progress.bytesPerSecond > 0 && ( + faltan {formatEta(progress.secondsRemaining)} + )} + + )} +
+
+ ); +} + function OpTile({ title, desc, diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index dc9bb2f..07e5171 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -803,38 +803,117 @@ export function listIngest(): Promise { return apiFetch("/ops/ingest"); } +/** Live upload stats reported to `uploadFile`'s `onProgress` callback. */ +export type UploadProgress = { + loaded: number; + /** 0 when the browser can't compute the request size. */ + total: number; + /** 0..1, or null when `total` is unknown. */ + fraction: number | null; + /** Smoothed transfer rate. */ + bytesPerSecond: number; + /** null until a rate and a total are both known. */ + secondsRemaining: number | null; + /** True once the bytes are sent and we're waiting on the server's reply. */ + finishing: boolean; +}; + /** * Multipart upload — not JSON, so it bypasses apiFetch's Content-Type. `path` * is API-relative (may include a query string); `filename` overrides the part - * name sent to the server. + * name sent to the server. Uses XHR rather than fetch because fetch has no way + * to report request-body progress. */ -export async function uploadFile( +export function uploadFile( path: string, file: File, filename?: string, + onProgress?: (p: UploadProgress) => void, ): Promise { const body = new FormData(); body.append("file", file, filename ?? file.name); - const res = await fetch(`${API_ORIGIN}${path}`, { - method: "POST", - credentials: "include", - body, - }); - if (!res.ok) { - let message = `Error ${res.status}`; - try { - const b = await res.json(); - if (b?.message) message = b.message; - } catch { - /* ignore */ + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("POST", `${API_ORIGIN}${path}`); + xhr.withCredentials = true; + + if (onProgress) { + // Exponentially smoothed rate — raw per-chunk deltas jump around too + // much to read. + let lastAt = performance.now(); + let lastLoaded = 0; + let rate = 0; + + xhr.upload.onprogress = (e) => { + const now = performance.now(); + const dt = (now - lastAt) / 1000; + if (dt >= 0.15) { + const sample = (e.loaded - lastLoaded) / dt; + rate = rate === 0 ? sample : rate * 0.7 + sample * 0.3; + lastAt = now; + lastLoaded = e.loaded; + } + const total = e.lengthComputable ? e.total : 0; + onProgress({ + loaded: e.loaded, + total, + fraction: total ? e.loaded / total : null, + bytesPerSecond: rate, + secondsRemaining: + total && rate > 0 ? (total - e.loaded) / rate : null, + finishing: false, + }); + }; + // Bytes are out the door; the server still has to write the file. + xhr.upload.onload = () => { + onProgress({ + loaded: file.size, + total: file.size, + fraction: 1, + bytesPerSecond: rate, + secondsRemaining: 0, + finishing: true, + }); + }; } - throw new ApiError(res.status, message); - } - return res.status === 204 ? undefined : res.json().catch(() => undefined); + + xhr.onload = () => { + let parsed: unknown; + try { + parsed = xhr.responseText ? JSON.parse(xhr.responseText) : undefined; + } catch { + parsed = undefined; + } + if (xhr.status >= 200 && xhr.status < 300) { + resolve(parsed); + return; + } + const message = + (parsed as { message?: string } | undefined)?.message ?? + `Error ${xhr.status}`; + reject(new ApiError(xhr.status, message)); + }; + xhr.onerror = () => + reject(new ApiError(0, "Fallo de red durante la carga.")); + xhr.onabort = () => reject(new ApiError(0, "Carga cancelada.")); + xhr.ontimeout = () => reject(new ApiError(0, "Tiempo de carga agotado.")); + + xhr.send(body); + }); } -export function uploadIngest(name: string, file: File): Promise { - return uploadFile(`/ops/ingest/${encodeURIComponent(name)}`, file, name); +export function uploadIngest( + name: string, + file: File, + onProgress?: (p: UploadProgress) => void, +): Promise { + return uploadFile( + `/ops/ingest/${encodeURIComponent(name)}`, + file, + name, + onProgress, + ); } export function deleteIngest(name: string): Promise {