feat(ops): show upload percent, speed and ETA for ingest files
The ingest upload used fetch(), which cannot report request-body progress, so the only feedback was a static "Cargando…" label — no way to tell a stalled 2 GB upload from a working one. Switch uploadFile() to XMLHttpRequest and expose an optional onProgress callback reporting loaded/total bytes, a smoothed transfer rate and a remaining-time estimate. The Operaciones ingest table renders a progress bar row under the file being uploaded. Once the bytes are all sent the server still has to write the file, so that tail reads "Procesando en el servidor…" rather than parking at 100%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<ConfirmState>(null);
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
const [progress, setProgress] = useState<UploadProgress | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
const fileInputs = useRef<Record<string, HTMLInputElement | null>>({});
|
||||
@@ -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() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{(ingest ?? []).map((f) => (
|
||||
<tr key={f.name}>
|
||||
<td className="mono">{f.name}</td>
|
||||
<td>
|
||||
<span className={`badge ${f.present ? "badge-positive" : "badge-negative"}`}>
|
||||
{f.present ? "Presente" : "Falta"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">{formatBytes(f.size)}</td>
|
||||
<td>{formatDateTime(f.modifiedAt)}</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<input
|
||||
ref={(el) => {
|
||||
fileInputs.current[f.name] = el;
|
||||
}}
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => handleUpload(f.name, e.target.files?.[0])}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-outline"
|
||||
type="button"
|
||||
disabled={uploading === f.name}
|
||||
onClick={() => fileInputs.current[f.name]?.click()}
|
||||
>
|
||||
{uploading === f.name ? "Cargando…" : f.present ? "Reemplazar" : "Cargar"}
|
||||
</button>
|
||||
{f.present && (
|
||||
<Fragment key={f.name}>
|
||||
<tr>
|
||||
<td className="mono">{f.name}</td>
|
||||
<td>
|
||||
<span className={`badge ${f.present ? "badge-positive" : "badge-negative"}`}>
|
||||
{f.present ? "Presente" : "Falta"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">{formatBytes(f.size)}</td>
|
||||
<td>{formatDateTime(f.modifiedAt)}</td>
|
||||
<td>
|
||||
<div className="row-actions">
|
||||
<input
|
||||
ref={(el) => {
|
||||
fileInputs.current[f.name] = el;
|
||||
}}
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => handleUpload(f.name, e.target.files?.[0])}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
className="btn btn-outline"
|
||||
type="button"
|
||||
onClick={() => handleDeleteIngest(f.name)}
|
||||
disabled={uploading === f.name}
|
||||
onClick={() => fileInputs.current[f.name]?.click()}
|
||||
>
|
||||
Eliminar
|
||||
{uploading === f.name ? "Cargando…" : f.present ? "Reemplazar" : "Cargar"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{f.present && (
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
type="button"
|
||||
disabled={uploading === f.name}
|
||||
onClick={() => handleDeleteIngest(f.name)}
|
||||
>
|
||||
Eliminar
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{uploading === f.name && (
|
||||
<tr>
|
||||
<td colSpan={5}>
|
||||
<UploadProgressBar progress={progress} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -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 (
|
||||
<div className="upload-progress">
|
||||
<div
|
||||
className={`progress-track${pct === null ? " progress-indeterminate" : ""}`}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={pct ?? undefined}
|
||||
>
|
||||
<div className="progress-fill" style={{ width: `${pct ?? 100}%` }} />
|
||||
</div>
|
||||
<div className="upload-progress-stats mono">
|
||||
{progress === null ? (
|
||||
"Preparando…"
|
||||
) : progress.finishing ? (
|
||||
`Procesando en el servidor… (${formatBytes(progress.total)} enviados)`
|
||||
) : (
|
||||
<>
|
||||
{pct !== null && <strong>{pct}%</strong>}
|
||||
{progress.total > 0 && (
|
||||
<span>
|
||||
{formatBytes(progress.loaded)} / {formatBytes(progress.total)}
|
||||
</span>
|
||||
)}
|
||||
{progress.bytesPerSecond > 0 && (
|
||||
<span>{formatBytes(progress.bytesPerSecond)}/s</span>
|
||||
)}
|
||||
{progress.secondsRemaining !== null && progress.bytesPerSecond > 0 && (
|
||||
<span>faltan {formatEta(progress.secondsRemaining)}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OpTile({
|
||||
title,
|
||||
desc,
|
||||
|
||||
Reference in New Issue
Block a user