refactor: Rename IPSExtractor to Flujo IPS across the project. Update titles, descriptions, and labels in the API, desktop application, and documentation to reflect the new branding. Introduce a new .gitignore for CodeGraph data files and adjust existing .gitignore to include specific directories. Enhance the desktop application with new components for displaying bot progress and results.
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
# CodeGraph data files — local to each machine, not for committing.
|
||||||
|
# Ignore everything in .codegraph/ except this file itself, so transient
|
||||||
|
# files (the database, daemon.pid, sockets, logs) never show up in git.
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -32,6 +32,8 @@ build/
|
|||||||
develop-eggs/
|
develop-eggs/
|
||||||
dist/
|
dist/
|
||||||
downloads/
|
downloads/
|
||||||
|
!services/desktop_app/src/renderer/modules/downloads/
|
||||||
|
!services/desktop_app/src/renderer/modules/downloads/**
|
||||||
eggs/
|
eggs/
|
||||||
.eggs/
|
.eggs/
|
||||||
lib/
|
lib/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# IPSExtractor
|
# Flujo IPS
|
||||||
|
|
||||||
Sistema de extracción y análisis de datos desde facturas médicas PDF.
|
Sistema de extracción y análisis de datos desde facturas médicas PDF.
|
||||||
Arquitectura de microservicios: cada servicio tiene su propia API, dependencias, Dockerfile y código de dominio. No hay paquetes compartidos entre servicios.
|
Arquitectura de microservicios: cada servicio tiene su propia API, dependencias, Dockerfile y código de dominio. No hay paquetes compartidos entre servicios.
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class BotDashboard(ctk.CTk):
|
|||||||
|
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
config,
|
config,
|
||||||
text="API remota (IPSExtractor)",
|
text="API remota (Flujo IPS)",
|
||||||
font=ctk.CTkFont(size=16, weight="bold"),
|
font=ctk.CTkFont(size=16, weight="bold"),
|
||||||
).pack(anchor="w", padx=18, pady=(14, 8))
|
).pack(anchor="w", padx=18, pady=(14, 8))
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class BotDashboard(ctk.CTk):
|
|||||||
|
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
config,
|
config,
|
||||||
text="API remota (IPSExtractor)",
|
text="API remota (Flujo IPS)",
|
||||||
font=ctk.CTkFont(size=16, weight="bold"),
|
font=ctk.CTkFont(size=16, weight="bold"),
|
||||||
).pack(anchor="w", padx=18, pady=(14, 8))
|
).pack(anchor="w", padx=18, pady=(14, 8))
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ async def lifespan(app: FastAPI):
|
|||||||
_http_client = None
|
_http_client = None
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="IPSExtractor API Gateway", version="1.0.0", lifespan=lifespan)
|
app = FastAPI(title="Flujo IPS API Gateway", version="1.0.0", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -567,17 +567,18 @@ class JobStore:
|
|||||||
exitosos += int(r.get("exitosos") or 0)
|
exitosos += int(r.get("exitosos") or 0)
|
||||||
fallidos += int(r.get("fallidos") or 0)
|
fallidos += int(r.get("fallidos") or 0)
|
||||||
uploaded += int(r.get("uploaded_count") or 0)
|
uploaded += int(r.get("uploaded_count") or 0)
|
||||||
# Progreso parcial mientras corre
|
|
||||||
if c.status == JobStatus.RUNNING.value:
|
|
||||||
total = max(total, int(r.get("total") or 0))
|
|
||||||
exitosos = max(exitosos, int(r.get("processed") or r.get("exitosos") or 0))
|
|
||||||
|
|
||||||
# Agregar progreso en vuelo (processed) de shards running
|
# Agregar progreso en vuelo (processed) de shards running
|
||||||
processed = 0
|
processed = 0
|
||||||
for c in children:
|
for c in children:
|
||||||
r = dict(c.result or {})
|
r = dict(c.result or {})
|
||||||
if c.status in terminal:
|
if c.status in terminal:
|
||||||
processed += int(r.get("exitosos") or 0) + int(r.get("fallidos") or 0)
|
# El agent reemplaza el resultado parcial al finalizar, por eso
|
||||||
|
# el fallback se calcula con los contadores finales.
|
||||||
|
processed += int(
|
||||||
|
r.get("processed")
|
||||||
|
or (int(r.get("exitosos") or 0) + int(r.get("fallidos") or 0))
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
processed += int(r.get("processed") or 0)
|
processed += int(r.get("processed") or 0)
|
||||||
|
|
||||||
|
|||||||
@@ -175,3 +175,48 @@ def test_list_jobs_by_parent(store):
|
|||||||
listed = job_store.list_jobs(limit=50, job_id="parent-1")
|
listed = job_store.list_jobs(limit=50, job_id="parent-1")
|
||||||
assert len(listed) == 1
|
assert len(listed) == 1
|
||||||
assert listed[0].id == a.id
|
assert listed[0].id == a.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_distributed_parent_reports_live_processed_total(store):
|
||||||
|
job_store, JobStatus, _WorkerStatus = store
|
||||||
|
parent = job_store.create_job(
|
||||||
|
None,
|
||||||
|
"uploads/parent/input.xlsx",
|
||||||
|
"input.xlsx",
|
||||||
|
shard_count=2,
|
||||||
|
result={"total_rows": 10, "processed": 0},
|
||||||
|
status=JobStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
first = job_store.create_job(
|
||||||
|
"PC-01",
|
||||||
|
"uploads/first/input.xlsx",
|
||||||
|
parent_download_job_id=parent.id,
|
||||||
|
shard_index=0,
|
||||||
|
shard_count=2,
|
||||||
|
result={"total": 5, "processed": 2},
|
||||||
|
status=JobStatus.RUNNING.value,
|
||||||
|
)
|
||||||
|
second = job_store.create_job(
|
||||||
|
"PC-02",
|
||||||
|
"uploads/second/input.xlsx",
|
||||||
|
parent_download_job_id=parent.id,
|
||||||
|
shard_index=1,
|
||||||
|
shard_count=2,
|
||||||
|
result={"total": 5, "processed": 0},
|
||||||
|
status=JobStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
live = job_store.refresh_parent_from_shards(parent.id)
|
||||||
|
assert live is not None
|
||||||
|
assert live.result["processed"] == 2
|
||||||
|
assert live.result["total"] == 10
|
||||||
|
|
||||||
|
job_store.patch_job(
|
||||||
|
first.id,
|
||||||
|
status=JobStatus.COMPLETED.value,
|
||||||
|
result={"total": 5, "exitosos": 3, "fallidos": 2},
|
||||||
|
)
|
||||||
|
job_store.patch_job(second.id, result={"total": 5, "processed": 1})
|
||||||
|
live = job_store.refresh_parent_from_shards(parent.id)
|
||||||
|
assert live is not None
|
||||||
|
assert live.result["processed"] == 6
|
||||||
|
|||||||
@@ -555,27 +555,37 @@ class JobStore:
|
|||||||
all_terminal = all(s in terminal for s in statuses)
|
all_terminal = all(s in terminal for s in statuses)
|
||||||
|
|
||||||
total = 0
|
total = 0
|
||||||
ok = 0
|
exitosos = 0
|
||||||
error_count = 0
|
fallidos = 0
|
||||||
timeout = 0
|
omitidos = 0
|
||||||
|
timeouts = 0
|
||||||
existentes = 0
|
existentes = 0
|
||||||
|
uploaded = 0
|
||||||
for c in children:
|
for c in children:
|
||||||
r = dict(c.result or {})
|
r = dict(c.result or {})
|
||||||
total += int(r.get("total") or 0)
|
total += int(r.get("total") or 0)
|
||||||
ok += int(r.get("ok") or 0)
|
# Los agents actuales publican los nombres en español. Se
|
||||||
error_count += int(r.get("error") or 0)
|
# conservan los aliases anteriores para jobs históricos.
|
||||||
timeout += int(r.get("timeout") or 0)
|
exitosos += int(r.get("exitosos") or r.get("ok") or 0)
|
||||||
|
fallidos += int(r.get("fallidos") or r.get("error") or 0)
|
||||||
|
omitidos += int(r.get("omitidos") or 0)
|
||||||
|
timeouts += int(r.get("timeouts") or r.get("timeout") or 0)
|
||||||
existentes += int(r.get("existentes") or 0)
|
existentes += int(r.get("existentes") or 0)
|
||||||
|
uploaded += int(r.get("uploaded_count") or 0)
|
||||||
|
|
||||||
processed = 0
|
processed = 0
|
||||||
for c in children:
|
for c in children:
|
||||||
r = dict(c.result or {})
|
r = dict(c.result or {})
|
||||||
if c.status in terminal:
|
if c.status in terminal:
|
||||||
processed += (
|
# fallidos ya incluye omitidos + timeouts; no se suman de
|
||||||
int(r.get("ok") or 0)
|
# nuevo para evitar que el progreso supere el total.
|
||||||
+ int(r.get("error") or 0)
|
processed += int(
|
||||||
+ int(r.get("timeout") or 0)
|
r.get("processed")
|
||||||
+ int(r.get("existentes") or 0)
|
or (
|
||||||
|
int(r.get("exitosos") or r.get("ok") or 0)
|
||||||
|
+ int(r.get("fallidos") or r.get("error") or 0)
|
||||||
|
+ int(r.get("existentes") or 0)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
processed += int(r.get("processed") or 0)
|
processed += int(r.get("processed") or 0)
|
||||||
@@ -588,11 +598,13 @@ class JobStore:
|
|||||||
"shard_ids": shard_ids,
|
"shard_ids": shard_ids,
|
||||||
"total": total or int(parent_result.get("total_rows") or 0),
|
"total": total or int(parent_result.get("total_rows") or 0),
|
||||||
"total_rows": int(parent_result.get("total_rows") or total or 0),
|
"total_rows": int(parent_result.get("total_rows") or total or 0),
|
||||||
"ok": ok,
|
"exitosos": exitosos,
|
||||||
"error": error_count,
|
"fallidos": fallidos,
|
||||||
"timeout": timeout,
|
"omitidos": omitidos,
|
||||||
|
"timeouts": timeouts,
|
||||||
"existentes": existentes,
|
"existentes": existentes,
|
||||||
"processed": processed,
|
"processed": processed,
|
||||||
|
"uploaded_count": uploaded,
|
||||||
"reports_prefix": parent.reports_prefix,
|
"reports_prefix": parent.reports_prefix,
|
||||||
"shards": [
|
"shards": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def store(tmp_path, monkeypatch):
|
||||||
|
db_url = f"sqlite:///{(tmp_path / 'digitador.db').as_posix()}"
|
||||||
|
monkeypatch.setenv("DATABASE_URL", db_url)
|
||||||
|
for name in ["infrastructure.database", "infrastructure.store"]:
|
||||||
|
if name in sys.modules:
|
||||||
|
importlib.reload(sys.modules[name])
|
||||||
|
else:
|
||||||
|
importlib.import_module(name)
|
||||||
|
|
||||||
|
from infrastructure import database as db
|
||||||
|
|
||||||
|
importlib.reload(db)
|
||||||
|
import infrastructure.store as store_mod
|
||||||
|
|
||||||
|
importlib.reload(store_mod)
|
||||||
|
from domain.models import JobStatus
|
||||||
|
|
||||||
|
return store_mod.JobStore(), JobStatus
|
||||||
|
|
||||||
|
|
||||||
|
def test_distributed_parent_uses_agent_counters_and_live_progress(store):
|
||||||
|
job_store, JobStatus = store
|
||||||
|
parent = job_store.create_job(
|
||||||
|
None,
|
||||||
|
"uploads/parent/input.csv",
|
||||||
|
"input.csv",
|
||||||
|
shard_count=2,
|
||||||
|
result={"total_rows": 8, "processed": 0},
|
||||||
|
status=JobStatus.PENDING.value,
|
||||||
|
)
|
||||||
|
first = job_store.create_job(
|
||||||
|
"PC-01",
|
||||||
|
"uploads/first/input.csv",
|
||||||
|
parent_digitador_job_id=parent.id,
|
||||||
|
shard_index=0,
|
||||||
|
shard_count=2,
|
||||||
|
result={"total": 4, "processed": 2},
|
||||||
|
status=JobStatus.RUNNING.value,
|
||||||
|
)
|
||||||
|
second = job_store.create_job(
|
||||||
|
"PC-02",
|
||||||
|
"uploads/second/input.csv",
|
||||||
|
parent_digitador_job_id=parent.id,
|
||||||
|
shard_index=1,
|
||||||
|
shard_count=2,
|
||||||
|
result={"total": 4, "processed": 1},
|
||||||
|
status=JobStatus.RUNNING.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
live = job_store.refresh_parent_from_shards(parent.id)
|
||||||
|
assert live is not None
|
||||||
|
assert live.result["processed"] == 3
|
||||||
|
assert live.result["total"] == 8
|
||||||
|
|
||||||
|
job_store.patch_job(
|
||||||
|
first.id,
|
||||||
|
status=JobStatus.COMPLETED.value,
|
||||||
|
result={
|
||||||
|
"total": 4,
|
||||||
|
"exitosos": 1,
|
||||||
|
"fallidos": 2,
|
||||||
|
"omitidos": 1,
|
||||||
|
"timeouts": 1,
|
||||||
|
"existentes": 1,
|
||||||
|
"uploaded_count": 3,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
live = job_store.refresh_parent_from_shards(parent.id)
|
||||||
|
assert live is not None
|
||||||
|
assert live.result["processed"] == 5
|
||||||
|
assert live.result["exitosos"] == 1
|
||||||
|
assert live.result["fallidos"] == 2
|
||||||
|
assert live.result["existentes"] == 1
|
||||||
|
assert live.result["timeouts"] == 1
|
||||||
|
assert live.result["uploaded_count"] == 3
|
||||||
@@ -40,7 +40,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="IPSExtractor Cleaning Service",
|
title="Flujo IPS Cleaning Service",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# desktop_app
|
# desktop_app
|
||||||
|
|
||||||
Aplicación de escritorio Electron + React para interactuar con el stack IPSExtractor.
|
Aplicación de escritorio Electron + React de **Flujo IPS** para interactuar con el stack de servicios.
|
||||||
Permite seleccionar carpetas locales, contar servicios en facturas médicas, revisar resultados y lanzar extracciones LLM.
|
Permite seleccionar carpetas locales, contar servicios en facturas médicas, revisar resultados y lanzar extracciones LLM.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -147,7 +147,7 @@ Layout en el bucket `apps-versions`:
|
|||||||
IPSExtractor/
|
IPSExtractor/
|
||||||
latest.json
|
latest.json
|
||||||
versions.json ← catálogo de todas las versiones publicadas
|
versions.json ← catálogo de todas las versiones publicadas
|
||||||
1.0.1/IPSExtractor-Desktop-1.0.1-Setup.exe
|
1.0.1/Flujo-IPS-1.0.1-Setup.exe
|
||||||
1.0.1/DATA.txt ← fecha DD/MM/YYYY, ej. 06/07/2026
|
1.0.1/DATA.txt ← fecha DD/MM/YYYY, ej. 06/07/2026
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ function download(urlStr, opts = {}) {
|
|||||||
url,
|
url,
|
||||||
{
|
{
|
||||||
timeout: FETCH_TIMEOUT_MS,
|
timeout: FETCH_TIMEOUT_MS,
|
||||||
headers: { "User-Agent": `IPSExtractor-Desktop/${CURRENT_VERSION}` },
|
headers: { "User-Agent": `Flujo-IPS/${CURRENT_VERSION}` },
|
||||||
},
|
},
|
||||||
(res) => {
|
(res) => {
|
||||||
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function createWindow() {
|
|||||||
height: 860,
|
height: 860,
|
||||||
minWidth: 1080,
|
minWidth: 1080,
|
||||||
minHeight: 720,
|
minHeight: 720,
|
||||||
title: `IPSExtractor Desktop v${appVersion}`,
|
title: `Flujo IPS v${appVersion}`,
|
||||||
icon: APP_ICON,
|
icon: APP_ICON,
|
||||||
backgroundColor: "#f6f7f9",
|
backgroundColor: "#f6f7f9",
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/png" href="/app-icon.png" />
|
<link rel="icon" type="image/png" href="/app-icon.png" />
|
||||||
<title>IPSExtractor Desktop</title>
|
<title>Flujo IPS</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "ipsextractor-desktop",
|
"name": "ipsextractor-desktop",
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Desktop extraction client for IPSExtractor microservices.",
|
"description": "Aplicación de escritorio Flujo IPS para automatizar procesos de cuentas médicas.",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "concurrently -k \"vite --host 127.0.0.1\" \"wait-on tcp:5173 && electron .\"",
|
"dev": "concurrently -k \"vite --host 127.0.0.1\" \"wait-on tcp:5173 && electron .\"",
|
||||||
@@ -41,8 +41,8 @@
|
|||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"appId": "com.ipsextractor.desktop",
|
"appId": "com.ipsextractor.desktop",
|
||||||
"productName": "IPSExtractor Desktop",
|
"productName": "Flujo IPS",
|
||||||
"artifactName": "IPSExtractor-Desktop-${version}-Setup.${ext}",
|
"artifactName": "Flujo-IPS-${version}-Setup.${ext}",
|
||||||
"directories": {
|
"directories": {
|
||||||
"output": "release",
|
"output": "release",
|
||||||
"buildResources": "resources"
|
"buildResources": "resources"
|
||||||
@@ -75,8 +75,8 @@
|
|||||||
"en-US"
|
"en-US"
|
||||||
],
|
],
|
||||||
"language": "1042",
|
"language": "1042",
|
||||||
"shortcutName": "IPSExtractor Desktop",
|
"shortcutName": "Flujo IPS",
|
||||||
"uninstallDisplayName": "IPSExtractor Desktop ${version}"
|
"uninstallDisplayName": "Flujo IPS ${version}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ if (!accessKey || !secretKey) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const setupName = `IPSExtractor-Desktop-${version}-Setup.exe`;
|
const setupName = `Flujo-IPS-${version}-Setup.exe`;
|
||||||
const releaseDir = getReleaseDir();
|
const releaseDir = getReleaseDir();
|
||||||
const setupPath = join(releaseDir, setupName);
|
const setupPath = join(releaseDir, setupName);
|
||||||
if (!existsSync(setupPath)) {
|
if (!existsSync(setupPath)) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ describe("App", () => {
|
|||||||
text: async () => "[]",
|
text: async () => "[]",
|
||||||
} as Response);
|
} as Response);
|
||||||
render(<App />);
|
render(<App />);
|
||||||
expect(screen.getByText("IPSExtractor")).toBeInTheDocument();
|
expect(screen.getByText("Flujo IPS")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: /^trabajos$/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /^trabajos$/i })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: /configuraci[oó]n/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /configuraci[oó]n/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -277,9 +277,9 @@ function App() {
|
|||||||
<main className="app-frame">
|
<main className="app-frame">
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="brand">
|
<div className="brand">
|
||||||
<img className="brand-mark" src={appIcon} alt="IPSExtractor" />
|
<img className="brand-mark" src={appIcon} alt="Flujo IPS" />
|
||||||
<div className="brand-text">
|
<div className="brand-text">
|
||||||
<strong>IPSExtractor</strong>
|
<strong>Flujo IPS</strong>
|
||||||
<span>Desktop</span>
|
<span>Desktop</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
type ParallelShard = {
|
||||||
|
id?: string;
|
||||||
|
worker_id?: string | null;
|
||||||
|
status?: string;
|
||||||
|
shard_index?: number | null;
|
||||||
|
result?: Record<string, unknown>;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
shards: ParallelShard[];
|
||||||
|
unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asCount(value: unknown): number {
|
||||||
|
const parsed = Number(value ?? 0);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shardProgress(shard: ParallelShard): { processed: number; total: number; percent: number } {
|
||||||
|
const result = shard.result ?? {};
|
||||||
|
const total = asCount(result.total);
|
||||||
|
const outcomes =
|
||||||
|
asCount(result.exitosos ?? result.ok) +
|
||||||
|
asCount(result.fallidos ?? result.error) +
|
||||||
|
asCount(result.existentes);
|
||||||
|
const processed = Math.min(total || Number.MAX_SAFE_INTEGER, asCount(result.processed) || outcomes);
|
||||||
|
const percent = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 0;
|
||||||
|
return { processed, total, percent };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ParallelBotProgress({ shards, unit }: Props) {
|
||||||
|
if (shards.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wiz-section" style={{ marginTop: 16, width: "100%", maxWidth: 640 }}>
|
||||||
|
<h3 className="wiz-section-title">Avance por bot</h3>
|
||||||
|
<div className="wiz-review-grid" style={{ marginTop: 8 }}>
|
||||||
|
{shards.map((shard, index) => {
|
||||||
|
const progress = shardProgress(shard);
|
||||||
|
return (
|
||||||
|
<div key={shard.id || String(index)} className="wiz-review-item">
|
||||||
|
<div style={{ width: "100%" }}>
|
||||||
|
<span className="wiz-review-label">
|
||||||
|
Bot {shard.worker_id || `#${(shard.shard_index ?? index) + 1}`}
|
||||||
|
</span>
|
||||||
|
<span className="wiz-review-value">
|
||||||
|
{progress.processed}/{progress.total || "?"} {unit} · {progress.percent}% · {shard.status || "—"}
|
||||||
|
</span>
|
||||||
|
<div className="wiz-progress-track" style={{ marginTop: 8 }}>
|
||||||
|
<div className="wiz-progress-fill" style={{ width: `${progress.percent}%` }} />
|
||||||
|
</div>
|
||||||
|
{shard.error ? (
|
||||||
|
<span className="wiz-hint" style={{ color: "var(--danger, #dc2626)", marginTop: 6 }}>
|
||||||
|
{shard.error}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -183,8 +183,12 @@ export class ApiClient {
|
|||||||
return this.postForm<Record<string, unknown>>(`/tasks/${encodeURIComponent(taskId)}/ingest-many`, form);
|
return this.postForm<Record<string, unknown>>(`/tasks/${encodeURIComponent(taskId)}/ingest-many`, form);
|
||||||
}
|
}
|
||||||
|
|
||||||
async tasks(type = "extraction"): Promise<Task[]> {
|
async tasks(type?: string, status?: string): Promise<Task[]> {
|
||||||
return this.getJson<Task[]>(`/tasks?type=${encodeURIComponent(type)}`);
|
const qs = new URLSearchParams();
|
||||||
|
if (type) qs.set("type", type);
|
||||||
|
if (status) qs.set("status", status);
|
||||||
|
const suffix = qs.size > 0 ? `?${qs.toString()}` : "";
|
||||||
|
return this.getJson<Task[]>(`/tasks${suffix}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async task(taskId: string): Promise<Task> {
|
async task(taskId: string): Promise<Task> {
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { DigitadorJob, DownloadJob, Job, Task } from "../../shared/types";
|
||||||
|
import { buildOperationsMetrics, formatDuration } from "./operationsMetrics";
|
||||||
|
|
||||||
|
const now = new Date(2026, 7, 16, 12, 0, 0);
|
||||||
|
const today = (hour: number) => new Date(2026, 7, 16, hour, 0, 0).toISOString();
|
||||||
|
|
||||||
|
function task(overrides: Partial<Task>): Task {
|
||||||
|
return {
|
||||||
|
id: "task",
|
||||||
|
type: "counting",
|
||||||
|
status: "completed",
|
||||||
|
title: null,
|
||||||
|
request: {},
|
||||||
|
result: {},
|
||||||
|
error: null,
|
||||||
|
owner_id: null,
|
||||||
|
job_id: "job-1",
|
||||||
|
created_at: today(8),
|
||||||
|
updated_at: today(9),
|
||||||
|
started_at: today(8),
|
||||||
|
finished_at: today(9),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadRun(overrides: Partial<DownloadJob>): DownloadJob {
|
||||||
|
return {
|
||||||
|
id: "download",
|
||||||
|
worker_id: "download-worker",
|
||||||
|
status: "completed",
|
||||||
|
title: "Descarga",
|
||||||
|
excel_key: "input.xlsx",
|
||||||
|
downloads_prefix: "downloads/download/",
|
||||||
|
original_filename: "input.xlsx",
|
||||||
|
cancel_requested: false,
|
||||||
|
result: {},
|
||||||
|
error: null,
|
||||||
|
created_at: today(8),
|
||||||
|
started_at: today(8),
|
||||||
|
finished_at: today(9),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function digitadorRun(overrides: Partial<DigitadorJob>): DigitadorJob {
|
||||||
|
return {
|
||||||
|
id: "digitador",
|
||||||
|
worker_id: "digitador-worker",
|
||||||
|
status: "completed",
|
||||||
|
title: "Digitación",
|
||||||
|
csv_key: "input.csv",
|
||||||
|
reports_prefix: "digitados/digitador/",
|
||||||
|
original_filename: "input.csv",
|
||||||
|
cancel_requested: false,
|
||||||
|
result: {},
|
||||||
|
error: null,
|
||||||
|
created_at: today(9),
|
||||||
|
started_at: today(9),
|
||||||
|
finished_at: today(10),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("operations metrics", () => {
|
||||||
|
it("aggregates today's activity, workers, errors and flow progress", () => {
|
||||||
|
const jobs: Job[] = [
|
||||||
|
{
|
||||||
|
id: "job-1",
|
||||||
|
status: "running",
|
||||||
|
title: "Lote activo",
|
||||||
|
metadata: {},
|
||||||
|
created_at: today(7),
|
||||||
|
updated_at: today(10),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const tasks = [
|
||||||
|
task({ id: "download-task", type: "downloads", status: "completed", result: { total: 10, processed: 10 } }),
|
||||||
|
task({
|
||||||
|
id: "count-task",
|
||||||
|
type: "counting",
|
||||||
|
status: "running",
|
||||||
|
result: { total: 10, processed: 5 },
|
||||||
|
finished_at: null,
|
||||||
|
}),
|
||||||
|
task({ id: "failed-task", job_id: null, type: "extraction", status: "failed" }),
|
||||||
|
];
|
||||||
|
const metrics = buildOperationsMetrics(
|
||||||
|
{
|
||||||
|
jobs,
|
||||||
|
tasks,
|
||||||
|
downloadWorkers: [
|
||||||
|
{ id: "d1", hostname: "a", max_concurrent: 1, status: "online", last_heartbeat: today(12), current_job_id: null },
|
||||||
|
{ id: "d2", hostname: "b", max_concurrent: 1, status: "busy", last_heartbeat: today(12), current_job_id: "x" },
|
||||||
|
],
|
||||||
|
digitadorWorkers: [
|
||||||
|
{ id: "g1", hostname: "c", max_concurrent: 1, status: "offline", last_heartbeat: today(7), current_job_id: null },
|
||||||
|
],
|
||||||
|
downloadJobs: [
|
||||||
|
downloadRun({ result: { exitosos: 7 }, task_id: "download-task" }),
|
||||||
|
downloadRun({ id: "child", parent_download_job_id: "download", result: { exitosos: 3 } }),
|
||||||
|
downloadRun({ id: "failed-download", status: "failed", task_id: "failed-task" }),
|
||||||
|
],
|
||||||
|
digitadorJobs: [
|
||||||
|
digitadorRun({ result: { exitosos: 2, fallidos: 1, existentes: 1 } }),
|
||||||
|
digitadorRun({ id: "failed-digitador", status: "failed" }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(metrics.workers).toEqual({ connected: 1, busy: 1, offline: 1, total: 3 });
|
||||||
|
expect(metrics.downloadedToday).toBe(7);
|
||||||
|
expect(metrics.processedToday).toBe(5);
|
||||||
|
expect(metrics.digitatedToday).toBe(4);
|
||||||
|
expect(metrics.pendingErrors).toBe(2);
|
||||||
|
expect(metrics.activeJobs).toHaveLength(1);
|
||||||
|
expect(metrics.activeJobs[0].percent).toBe(25);
|
||||||
|
expect(metrics.flowPercent).toBe(25);
|
||||||
|
expect(metrics.averageStageMs.find((stage) => stage.type === "downloads")?.durationMs).toBe(3_600_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats stage durations for the dashboard", () => {
|
||||||
|
expect(formatDuration(null)).toBe("Sin datos");
|
||||||
|
expect(formatDuration(45_000)).toBe("45 s");
|
||||||
|
expect(formatDuration(125_000)).toBe("2 min 5 s");
|
||||||
|
expect(formatDuration(7_500_000)).toBe("2 h 5 min");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import type {
|
||||||
|
DigitadorJob,
|
||||||
|
DigitadorWorker,
|
||||||
|
DownloadJob,
|
||||||
|
DownloadWorker,
|
||||||
|
Job,
|
||||||
|
Task,
|
||||||
|
} from "../../shared/types";
|
||||||
|
|
||||||
|
const STAGES = ["downloads", "counting", "extraction", "cleaning", "poblacion_apta", "digitador"] as const;
|
||||||
|
|
||||||
|
const STAGE_LABELS: Record<string, string> = {
|
||||||
|
downloads: "Descarga",
|
||||||
|
counting: "Conteo",
|
||||||
|
extraction: "Extracción",
|
||||||
|
cleaning: "Limpieza",
|
||||||
|
poblacion_apta: "Población apta",
|
||||||
|
digitador: "Digitación",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface OperationsSource {
|
||||||
|
jobs: Job[];
|
||||||
|
tasks: Task[];
|
||||||
|
downloadWorkers: DownloadWorker[];
|
||||||
|
digitadorWorkers: DigitadorWorker[];
|
||||||
|
downloadJobs: DownloadJob[];
|
||||||
|
digitadorJobs: DigitadorJob[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveJobMetric {
|
||||||
|
job: Job;
|
||||||
|
percent: number;
|
||||||
|
currentStage: string;
|
||||||
|
completedStages: number;
|
||||||
|
totalStages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperationsMetrics {
|
||||||
|
activeJobs: ActiveJobMetric[];
|
||||||
|
workers: { connected: number; busy: number; offline: number; total: number };
|
||||||
|
downloadedToday: number;
|
||||||
|
processedToday: number;
|
||||||
|
digitatedToday: number;
|
||||||
|
pendingErrors: number;
|
||||||
|
averageStageMs: Array<{ type: string; label: string; durationMs: number | null; samples: number }>;
|
||||||
|
flowPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function count(value: unknown): number {
|
||||||
|
const parsed = Number(value ?? 0);
|
||||||
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDayKey(value: string | null | undefined): string | null {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return null;
|
||||||
|
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function happenedToday(
|
||||||
|
item: { created_at?: string; started_at?: string | null; finished_at?: string | null; updated_at?: string },
|
||||||
|
todayKey: string,
|
||||||
|
): boolean {
|
||||||
|
return localDayKey(item.finished_at || item.started_at || item.updated_at || item.created_at) === todayKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskProgress(task: Task): number {
|
||||||
|
if (task.status === "completed") return 1;
|
||||||
|
const total = count(task.result?.total ?? task.request?.total);
|
||||||
|
const processed = count(task.result?.processed);
|
||||||
|
return total > 0 ? Math.min(1, processed / total) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestTasksByStage(tasks: Task[]): Map<string, Task> {
|
||||||
|
const latest = new Map<string, Task>();
|
||||||
|
for (const task of [...tasks].sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())) {
|
||||||
|
latest.set(task.type, task);
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeJobMetric(job: Job, tasks: Task[]): ActiveJobMetric | null {
|
||||||
|
const active = tasks.some((task) => task.status === "pending" || task.status === "running");
|
||||||
|
if (!active && !(tasks.length === 0 && job.status === "running")) return null;
|
||||||
|
|
||||||
|
const latest = latestTasksByStage(tasks);
|
||||||
|
let progress = 0;
|
||||||
|
let completedStages = 0;
|
||||||
|
let currentStage = "Preparando";
|
||||||
|
for (const stage of STAGES) {
|
||||||
|
const task = latest.get(stage);
|
||||||
|
if (!task) continue;
|
||||||
|
const fraction = taskProgress(task);
|
||||||
|
progress += fraction;
|
||||||
|
if (task.status === "completed") completedStages += 1;
|
||||||
|
if (task.status === "running" || task.status === "pending") currentStage = STAGE_LABELS[stage];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
job,
|
||||||
|
percent: Math.min(100, Math.round((progress / STAGES.length) * 100)),
|
||||||
|
currentStage,
|
||||||
|
completedStages,
|
||||||
|
totalStages: STAGES.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(durationMs: number | null): string {
|
||||||
|
if (durationMs == null) return "Sin datos";
|
||||||
|
const seconds = Math.max(0, Math.round(durationMs / 1000));
|
||||||
|
if (seconds < 60) return `${seconds} s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = seconds % 60;
|
||||||
|
if (minutes < 60) return remainingSeconds ? `${minutes} min ${remainingSeconds} s` : `${minutes} min`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainingMinutes = minutes % 60;
|
||||||
|
return remainingMinutes ? `${hours} h ${remainingMinutes} min` : `${hours} h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOperationsMetrics(source: OperationsSource, now = new Date()): OperationsMetrics {
|
||||||
|
const todayKey = localDayKey(now.toISOString())!;
|
||||||
|
const tasksByJob = new Map<string, Task[]>();
|
||||||
|
for (const task of source.tasks) {
|
||||||
|
if (!task.job_id) continue;
|
||||||
|
const list = tasksByJob.get(task.job_id) ?? [];
|
||||||
|
list.push(task);
|
||||||
|
tasksByJob.set(task.job_id, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeJobs = source.jobs
|
||||||
|
.map((job) => activeJobMetric(job, tasksByJob.get(job.id) ?? []))
|
||||||
|
.filter((item): item is ActiveJobMetric => item !== null)
|
||||||
|
.sort((a, b) => new Date(b.job.updated_at).getTime() - new Date(a.job.updated_at).getTime());
|
||||||
|
|
||||||
|
const workers = [...source.downloadWorkers, ...source.digitadorWorkers].reduce(
|
||||||
|
(summary, worker) => {
|
||||||
|
summary.total += 1;
|
||||||
|
if (worker.status === "busy") summary.busy += 1;
|
||||||
|
else if (worker.status === "online") summary.connected += 1;
|
||||||
|
else summary.offline += 1;
|
||||||
|
return summary;
|
||||||
|
},
|
||||||
|
{ connected: 0, busy: 0, offline: 0, total: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadedToday = source.downloadJobs
|
||||||
|
.filter((run) => !run.parent_download_job_id && happenedToday(run, todayKey))
|
||||||
|
.reduce((total, run) => total + count(run.result?.exitosos ?? run.result?.processed), 0);
|
||||||
|
|
||||||
|
const processedToday = source.tasks
|
||||||
|
.filter((task) => task.type === "counting" && happenedToday(task, todayKey))
|
||||||
|
.reduce((total, task) => total + count(task.result?.processed ?? task.result?.total), 0);
|
||||||
|
|
||||||
|
const digitatedToday = source.digitadorJobs
|
||||||
|
.filter((run) => !run.parent_digitador_job_id && happenedToday(run, todayKey))
|
||||||
|
.reduce(
|
||||||
|
(total, run) =>
|
||||||
|
total +
|
||||||
|
count(
|
||||||
|
run.result?.processed ??
|
||||||
|
count(run.result?.exitosos) + count(run.result?.fallidos) + count(run.result?.existentes),
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const failedTaskIds = new Set(source.tasks.filter((task) => task.status === "failed").map((task) => task.id));
|
||||||
|
let pendingErrors = failedTaskIds.size;
|
||||||
|
for (const run of [...source.downloadJobs, ...source.digitadorJobs]) {
|
||||||
|
if (run.status === "failed" && (!run.task_id || !failedTaskIds.has(run.task_id))) pendingErrors += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const averageStageMs = STAGES.map((type) => {
|
||||||
|
const durations = source.tasks
|
||||||
|
.filter((task) => task.type === type && task.started_at && task.finished_at)
|
||||||
|
.map((task) => new Date(task.finished_at!).getTime() - new Date(task.started_at!).getTime())
|
||||||
|
.filter((duration) => Number.isFinite(duration) && duration >= 0);
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
label: STAGE_LABELS[type],
|
||||||
|
durationMs: durations.length > 0 ? durations.reduce((sum, duration) => sum + duration, 0) / durations.length : null,
|
||||||
|
samples: durations.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const flowPercent =
|
||||||
|
activeJobs.length > 0
|
||||||
|
? Math.round(activeJobs.reduce((sum, job) => sum + job.percent, 0) / activeJobs.length)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeJobs,
|
||||||
|
workers,
|
||||||
|
downloadedToday,
|
||||||
|
processedToday,
|
||||||
|
digitatedToday,
|
||||||
|
pendingErrors,
|
||||||
|
averageStageMs,
|
||||||
|
flowPercent,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
Task,
|
Task,
|
||||||
} from "../../../shared/types";
|
} from "../../../shared/types";
|
||||||
import type { ApiClient } from "../../lib/apiClient";
|
import type { ApiClient } from "../../lib/apiClient";
|
||||||
|
import { ParallelBotProgress } from "../../components/ParallelBotProgress";
|
||||||
|
|
||||||
declare const desktopApi: import("../../../shared/types").DesktopBridge | undefined;
|
declare const desktopApi: import("../../../shared/types").DesktopBridge | undefined;
|
||||||
|
|
||||||
@@ -149,6 +150,8 @@ export function DigitadorResultsPanel({ client, job, tasks, onStartDigitador }:
|
|||||||
const existentes = Number(result.existentes ?? 0);
|
const existentes = Number(result.existentes ?? 0);
|
||||||
const timeouts = Number(result.timeouts ?? 0);
|
const timeouts = Number(result.timeouts ?? 0);
|
||||||
const total = Number(result.total ?? 0);
|
const total = Number(result.total ?? 0);
|
||||||
|
const processed = Number(result.processed ?? exitosos + fallidos + existentes);
|
||||||
|
const progressPercent = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 0;
|
||||||
const bulkBusy = Boolean(dlBusyKey);
|
const bulkBusy = Boolean(dlBusyKey);
|
||||||
|
|
||||||
async function downloadOne(file: DigitadorJobFile) {
|
async function downloadOne(file: DigitadorJobFile) {
|
||||||
@@ -246,6 +249,18 @@ export function DigitadorResultsPanel({ client, job, tasks, onStartDigitador }:
|
|||||||
|
|
||||||
{selected && (
|
{selected && (
|
||||||
<>
|
<>
|
||||||
|
{(running || processed > 0) && (
|
||||||
|
<div className="wiz-progress" style={{ marginBottom: 16, flexShrink: 0 }}>
|
||||||
|
<div className="wiz-progress-header">
|
||||||
|
<span>{processed}/{total || "?"} filas procesadas</span>
|
||||||
|
<span>{progressPercent}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="wiz-progress-track">
|
||||||
|
<div className="wiz-progress-fill" style={{ width: `${progressPercent}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="wiz-stats-row" style={{ marginBottom: 16, flexShrink: 0 }}>
|
<div className="wiz-stats-row" style={{ marginBottom: 16, flexShrink: 0 }}>
|
||||||
<div className="wiz-stat">
|
<div className="wiz-stat">
|
||||||
<span className="wiz-stat-value">{exitosos}</span>
|
<span className="wiz-stat-value">{exitosos}</span>
|
||||||
@@ -298,27 +313,7 @@ export function DigitadorResultsPanel({ client, job, tasks, onStartDigitador }:
|
|||||||
}>)
|
}>)
|
||||||
: [];
|
: [];
|
||||||
if (shardList.length === 0) return null;
|
if (shardList.length === 0) return null;
|
||||||
return (
|
return <ParallelBotProgress shards={shardList} unit="filas" />;
|
||||||
<div className="wiz-section" style={{ marginBottom: 12, flexShrink: 0 }}>
|
|
||||||
<h3 className="wiz-section-title">Estado por bot</h3>
|
|
||||||
<div className="wiz-review-grid" style={{ marginTop: 8 }}>
|
|
||||||
{shardList.map((s, i) => (
|
|
||||||
<div key={s.id || String(i)} className="wiz-review-item">
|
|
||||||
<div>
|
|
||||||
<span className="wiz-review-label">
|
|
||||||
Shard {(s.shard_index ?? i) + 1}
|
|
||||||
</span>
|
|
||||||
<span className="wiz-review-value">{s.worker_id || "—"}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="wiz-review-label">Estado</span>
|
|
||||||
<span className="wiz-review-value">{s.status || "—"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
<div className="wiz-section dl-files-section">
|
<div className="wiz-section dl-files-section">
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { ApiClient } from "../../lib/apiClient";
|
import type { ApiClient } from "../../lib/apiClient";
|
||||||
import { formatDateTime } from "../../lib/time";
|
import { formatDateTime } from "../../lib/time";
|
||||||
|
import { ParallelBotProgress } from "../../components/ParallelBotProgress";
|
||||||
import type {
|
import type {
|
||||||
DigitadorJob,
|
DigitadorJob,
|
||||||
DigitadorJobEvent,
|
DigitadorJobEvent,
|
||||||
@@ -598,7 +599,7 @@ export function DigitadorWizardModule({ client, job, initialSource = null }: Pro
|
|||||||
<div className="wiz-progress">
|
<div className="wiz-progress">
|
||||||
<div className="wiz-progress-header">
|
<div className="wiz-progress-header">
|
||||||
<span>
|
<span>
|
||||||
{Number(result.exitosos ?? processed)}/{total || "?"} filas
|
{processed}/{total || "?"} filas procesadas
|
||||||
</span>
|
</span>
|
||||||
<span>{pct}%</span>
|
<span>{pct}%</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -626,27 +627,7 @@ export function DigitadorWizardModule({ client, job, initialSource = null }: Pro
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shards.length > 0 && (
|
<ParallelBotProgress shards={shards} unit="filas" />
|
||||||
<div className="wiz-section" style={{ marginTop: 16, width: "100%", maxWidth: 640 }}>
|
|
||||||
<h3 className="wiz-section-title">Shards por bot</h3>
|
|
||||||
<div className="wiz-review-grid" style={{ marginTop: 8 }}>
|
|
||||||
{shards.map((s, i) => (
|
|
||||||
<div key={s.id || String(i)} className="wiz-review-item">
|
|
||||||
<div>
|
|
||||||
<span className="wiz-review-label">
|
|
||||||
Shard {(s.shard_index ?? i) + 1}
|
|
||||||
</span>
|
|
||||||
<span className="wiz-review-value">{s.worker_id || "—"}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="wiz-review-label">Estado</span>
|
|
||||||
<span className="wiz-review-value">{s.status || "—"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="wiz-section" style={{ marginTop: 16, width: "100%", maxWidth: 640 }}>
|
<div className="wiz-section" style={{ marginTop: 16, width: "100%", maxWidth: 640 }}>
|
||||||
<h3 className="wiz-section-title">Log del agent</h3>
|
<h3 className="wiz-section-title">Log del agent</h3>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { ModuleDescriptor } from "../registry";
|
||||||
|
import { registerModule } from "../registry";
|
||||||
|
import type { Task } from "../../../shared/types";
|
||||||
|
import { DownloadsWizardModule } from "./DownloadsWizardModule";
|
||||||
|
import { DownloadsResultsPanel } from "./DownloadsResultsPanel";
|
||||||
|
|
||||||
|
const DownloadsModule: ModuleDescriptor = {
|
||||||
|
type: "downloads",
|
||||||
|
label: "Bot de descarga",
|
||||||
|
iconName: "Download",
|
||||||
|
hasTask: (tasks: Task[]) => tasks.some((t) => t.type === "downloads"),
|
||||||
|
Wizard: DownloadsWizardModule as unknown as ModuleDescriptor["Wizard"],
|
||||||
|
Results: DownloadsResultsPanel as unknown as ModuleDescriptor["Results"],
|
||||||
|
};
|
||||||
|
|
||||||
|
registerModule(DownloadsModule);
|
||||||
|
|
||||||
|
export { DownloadsModule };
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Download,
|
||||||
|
FolderDown,
|
||||||
|
FolderOpen,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import type {
|
||||||
|
DownloadJob,
|
||||||
|
DownloadJobEvent,
|
||||||
|
DownloadJobFile,
|
||||||
|
Job,
|
||||||
|
Task,
|
||||||
|
} from "../../../shared/types";
|
||||||
|
import type { ApiClient } from "../../lib/apiClient";
|
||||||
|
import { pdfBasename, savePdfAs, savePdfsToDirectory } from "../../lib/saveMinioPdf";
|
||||||
|
import { ParallelBotProgress } from "../../components/ParallelBotProgress";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
client: ApiClient;
|
||||||
|
job: Job;
|
||||||
|
tasks: Task[];
|
||||||
|
onStartDownload: () => void;
|
||||||
|
onContinueToCounting?: (ctx: {
|
||||||
|
downloadJobId: string;
|
||||||
|
downloadsPrefix: string;
|
||||||
|
files: DownloadJobFile[];
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupFilesByNit(files: DownloadJobFile[]): Array<{ nit: string; files: DownloadJobFile[] }> {
|
||||||
|
const map = new Map<string, DownloadJobFile[]>();
|
||||||
|
for (const f of files) {
|
||||||
|
const nit = (f.nit || "").trim() || "Sin NIT";
|
||||||
|
const list = map.get(nit) ?? [];
|
||||||
|
list.push(f);
|
||||||
|
map.set(nit, list);
|
||||||
|
}
|
||||||
|
return [...map.entries()]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true }))
|
||||||
|
.map(([nit, group]) => ({
|
||||||
|
nit,
|
||||||
|
files: [...group].sort((x, y) =>
|
||||||
|
String(x.radicado ?? "").localeCompare(String(y.radicado ?? ""), undefined, { numeric: true }),
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
type DlProgress = { done: number; total: number; label: string } | null;
|
||||||
|
|
||||||
|
export function DownloadsResultsPanel({
|
||||||
|
client,
|
||||||
|
job,
|
||||||
|
tasks,
|
||||||
|
onStartDownload,
|
||||||
|
onContinueToCounting,
|
||||||
|
}: Props) {
|
||||||
|
const [runs, setRuns] = useState<DownloadJob[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<string>("");
|
||||||
|
const [events, setEvents] = useState<DownloadJobEvent[]>([]);
|
||||||
|
const [files, setFiles] = useState<DownloadJobFile[]>([]);
|
||||||
|
const [filesError, setFilesError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [expandedNits, setExpandedNits] = useState<Set<string>>(new Set());
|
||||||
|
const [dlBusyKey, setDlBusyKey] = useState<string | null>(null);
|
||||||
|
const [dlProgress, setDlProgress] = useState<DlProgress>(null);
|
||||||
|
const [dlStatus, setDlStatus] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const downloadTasks = tasks
|
||||||
|
.filter((t) => t.type === "downloads")
|
||||||
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
|
||||||
|
|
||||||
|
const loadRuns = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const list = await client.listDownloadJobs(100, job.id);
|
||||||
|
setRuns(list);
|
||||||
|
setSelectedId((prev) => {
|
||||||
|
if (prev && list.some((r) => r.id === prev)) return prev;
|
||||||
|
return list[0]?.id ?? "";
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [client, job.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadRuns();
|
||||||
|
}, [loadRuns, downloadTasks.length]);
|
||||||
|
|
||||||
|
const selected = runs.find((r) => r.id === selectedId) ?? null;
|
||||||
|
const running = Boolean(
|
||||||
|
selected && !["completed", "failed", "cancelled"].includes(selected.status),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId) {
|
||||||
|
setEvents([]);
|
||||||
|
setFiles([]);
|
||||||
|
setFilesError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
async function loadDetail() {
|
||||||
|
try {
|
||||||
|
const [{ events: ev }, fresh] = await Promise.all([
|
||||||
|
client.getDownloadEvents(selectedId, 0),
|
||||||
|
client.getDownloadJob(selectedId),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setEvents(ev);
|
||||||
|
setRuns((prev) => prev.map((r) => (r.id === fresh.id ? fresh : r)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileResp = await client.listDownloadJobFiles(selectedId);
|
||||||
|
if (cancelled) return;
|
||||||
|
setFiles(fileResp.files);
|
||||||
|
setFilesError(null);
|
||||||
|
setExpandedNits((prev) => {
|
||||||
|
if (prev.size > 0) return prev;
|
||||||
|
const nits = [
|
||||||
|
...new Set(fileResp.files.map((f) => (f.nit || "").trim() || "Sin NIT")),
|
||||||
|
].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
||||||
|
// Pocas carpetas: abrir todas; muchas: solo la primera.
|
||||||
|
if (nits.length <= 3) return new Set(nits);
|
||||||
|
return nits[0] ? new Set([nits[0]]) : new Set();
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (cancelled) return;
|
||||||
|
setFiles([]);
|
||||||
|
setFilesError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadDetail();
|
||||||
|
if (!running) return;
|
||||||
|
|
||||||
|
const t = setInterval(() => void loadDetail(), 3000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(t);
|
||||||
|
};
|
||||||
|
}, [client, selectedId, running]);
|
||||||
|
|
||||||
|
const result = selected?.result ?? {};
|
||||||
|
const exitosos = Number(result.exitosos ?? 0);
|
||||||
|
const fallidos = Number(result.fallidos ?? 0);
|
||||||
|
const total = Number(result.total ?? 0);
|
||||||
|
const processed = Number(result.processed ?? exitosos + fallidos);
|
||||||
|
const progressPercent = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 0;
|
||||||
|
const uploaded = result.uploaded_count;
|
||||||
|
|
||||||
|
const grouped = useMemo(() => groupFilesByNit(files), [files]);
|
||||||
|
const folderCount = grouped.length;
|
||||||
|
const bulkBusy = Boolean(dlBusyKey);
|
||||||
|
|
||||||
|
function toggleNit(nit: string) {
|
||||||
|
setExpandedNits((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(nit)) next.delete(nit);
|
||||||
|
else next.add(nit);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandAll() {
|
||||||
|
setExpandedNits(new Set(grouped.map((g) => g.nit)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseAll() {
|
||||||
|
setExpandedNits(new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadOne(file: DownloadJobFile) {
|
||||||
|
if (!selectedId || bulkBusy) return;
|
||||||
|
const busyId = `file:${file.key}`;
|
||||||
|
setDlBusyKey(busyId);
|
||||||
|
setDlStatus(null);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await client.fetchDownloadObject(selectedId, file.key);
|
||||||
|
const name = pdfBasename(file.key, file.radicado);
|
||||||
|
const ok = await savePdfAs(name, data);
|
||||||
|
if (ok) setDlStatus(`Guardado: ${name}`);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "No se pudo descargar el archivo.");
|
||||||
|
} finally {
|
||||||
|
setDlBusyKey(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadMany(items: DownloadJobFile[], scopeKey: string) {
|
||||||
|
if (!selectedId || bulkBusy || items.length === 0) return;
|
||||||
|
setDlBusyKey(scopeKey);
|
||||||
|
setDlStatus(null);
|
||||||
|
setDlProgress({ done: 0, total: items.length, label: "Preparando…" });
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const outcome = await savePdfsToDirectory(
|
||||||
|
items,
|
||||||
|
(key) => client.fetchDownloadObject(selectedId, key),
|
||||||
|
(done, tot, label) => setDlProgress({ done, total: tot, label }),
|
||||||
|
);
|
||||||
|
if (!outcome) {
|
||||||
|
setDlStatus(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDlStatus(
|
||||||
|
outcome.failed > 0
|
||||||
|
? `Guardados ${outcome.saved}/${items.length} (${outcome.failed} con error)`
|
||||||
|
: `Guardados ${outcome.saved} PDF(s) en carpetas por NIT`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "No se pudo completar la descarga.");
|
||||||
|
} finally {
|
||||||
|
setDlBusyKey(null);
|
||||||
|
setDlProgress(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canContinue =
|
||||||
|
Boolean(onContinueToCounting) &&
|
||||||
|
selected?.status === "completed" &&
|
||||||
|
files.length > 0;
|
||||||
|
|
||||||
|
if (runs.length === 0 && !loading) {
|
||||||
|
return (
|
||||||
|
<div className="results-empty">
|
||||||
|
<Download size={28} />
|
||||||
|
<p>Aún no hay corridas de bot de descarga en este trabajo.</p>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={onStartDownload}>
|
||||||
|
<Plus size={15} /> Nueva descarga
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="results-section">
|
||||||
|
<div className="results-section-header">
|
||||||
|
<div className="results-section-header-left">
|
||||||
|
<label className="wiz-field-label">Corrida</label>
|
||||||
|
<select
|
||||||
|
className="form-input"
|
||||||
|
value={selectedId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedId(e.target.value);
|
||||||
|
setExpandedNits(new Set());
|
||||||
|
setDlStatus(null);
|
||||||
|
}}
|
||||||
|
style={{ minWidth: 280 }}
|
||||||
|
>
|
||||||
|
{runs.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{r.title || r.original_filename || r.id.slice(0, 8)} · {r.status}
|
||||||
|
{r.shard_count && Number(r.shard_count) > 1
|
||||||
|
? ` · ${r.shard_count} bots`
|
||||||
|
: r.worker_id
|
||||||
|
? ` · ${r.worker_id}`
|
||||||
|
: ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selected && (
|
||||||
|
<span className={`status-badge status-badge--${selected.status}`}>
|
||||||
|
{selected.status}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="results-section-header-right">
|
||||||
|
<button type="button" className="btn btn-sm btn-ghost" onClick={() => void loadRuns()} disabled={loading}>
|
||||||
|
{loading ? <Loader2 size={13} className="spin" /> : <RefreshCw size={13} />}
|
||||||
|
Actualizar
|
||||||
|
</button>
|
||||||
|
{canContinue && selected && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
onClick={() =>
|
||||||
|
onContinueToCounting?.({
|
||||||
|
downloadJobId: selected.id,
|
||||||
|
downloadsPrefix: selected.downloads_prefix,
|
||||||
|
files,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Continuar a conteo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-sm btn-primary" onClick={onStartDownload}>
|
||||||
|
<Plus size={13} /> Nueva descarga
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="wiz-toast wiz-toast--error" style={{ position: "static", marginBottom: 12 }}>
|
||||||
|
<AlertCircle size={15} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected?.error && (
|
||||||
|
<div className="wiz-toast wiz-toast--error" style={{ position: "static", marginBottom: 12 }}>
|
||||||
|
<AlertCircle size={15} />
|
||||||
|
<span>{selected.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dlStatus && (
|
||||||
|
<div className="wiz-toast" style={{ position: "static", marginBottom: 12 }}>
|
||||||
|
<CheckCircle2 size={15} />
|
||||||
|
<span>{dlStatus}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dlProgress && (
|
||||||
|
<div className="dl-bulk-progress">
|
||||||
|
<div className="dl-bulk-progress-meta">
|
||||||
|
<Loader2 size={14} className="spin" />
|
||||||
|
<span>
|
||||||
|
Descargando {dlProgress.done}/{dlProgress.total}
|
||||||
|
{dlProgress.label ? ` · ${dlProgress.label}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="wiz-progress-track">
|
||||||
|
<div
|
||||||
|
className="wiz-progress-fill"
|
||||||
|
style={{
|
||||||
|
width: `${dlProgress.total ? Math.round((dlProgress.done / dlProgress.total) * 100) : 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selected && (
|
||||||
|
<>
|
||||||
|
{(running || processed > 0) && (
|
||||||
|
<div className="wiz-progress" style={{ marginBottom: 16, flexShrink: 0 }}>
|
||||||
|
<div className="wiz-progress-header">
|
||||||
|
<span>{processed}/{total || "?"} radicados procesados</span>
|
||||||
|
<span>{progressPercent}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="wiz-progress-track">
|
||||||
|
<div className="wiz-progress-fill" style={{ width: `${progressPercent}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="wiz-stats-row" style={{ marginBottom: 16, flexShrink: 0 }}>
|
||||||
|
<div className="wiz-stat">
|
||||||
|
<span className="wiz-stat-value">{exitosos}</span>
|
||||||
|
<span className="wiz-stat-label">OK</span>
|
||||||
|
</div>
|
||||||
|
<div className="wiz-stat">
|
||||||
|
<span className="wiz-stat-value">{fallidos}</span>
|
||||||
|
<span className="wiz-stat-label">Fallidos</span>
|
||||||
|
</div>
|
||||||
|
<div className="wiz-stat">
|
||||||
|
<span className="wiz-stat-value">{total || "—"}</span>
|
||||||
|
<span className="wiz-stat-label">Total</span>
|
||||||
|
</div>
|
||||||
|
<div className="wiz-stat">
|
||||||
|
<span className="wiz-stat-value">{uploaded == null ? "—" : String(uploaded)}</span>
|
||||||
|
<span className="wiz-stat-label">En MinIO</span>
|
||||||
|
</div>
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="wiz-stat">
|
||||||
|
<span className="wiz-stat-value">{folderCount}</span>
|
||||||
|
<span className="wiz-stat-label">Carpetas</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="wiz-hint" style={{ marginBottom: 12, flexShrink: 0 }}>
|
||||||
|
{selected.shard_count && Number(selected.shard_count) > 1
|
||||||
|
? `${selected.shard_count} bots (distribuido)`
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
Worker <strong>{selected.worker_id ?? "—"}</strong>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selected.original_filename ? ` · ${selected.original_filename}` : ""}
|
||||||
|
{selected.downloads_prefix ? (
|
||||||
|
<>
|
||||||
|
{" "}
|
||||||
|
· Prefijo <code className="dl-prefix-code">{selected.downloads_prefix}</code>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const shardList = Array.isArray(selected.shards)
|
||||||
|
? selected.shards
|
||||||
|
: Array.isArray(result.shards)
|
||||||
|
? (result.shards as Array<{
|
||||||
|
id?: string;
|
||||||
|
worker_id?: string | null;
|
||||||
|
status?: string;
|
||||||
|
shard_index?: number | null;
|
||||||
|
}>)
|
||||||
|
: [];
|
||||||
|
if (shardList.length === 0) return null;
|
||||||
|
return <ParallelBotProgress shards={shardList} unit="radicados" />;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className="wiz-section dl-files-section">
|
||||||
|
<div className="dl-files-header">
|
||||||
|
<h3 className="wiz-section-title" style={{ margin: 0 }}>
|
||||||
|
Archivos en MinIO
|
||||||
|
{files.length > 0
|
||||||
|
? ` · ${folderCount} carpeta${folderCount === 1 ? "" : "s"} · ${files.length} PDF${files.length === 1 ? "" : "s"}`
|
||||||
|
: ""}
|
||||||
|
</h3>
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="dl-files-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={expandAll}
|
||||||
|
disabled={bulkBusy}
|
||||||
|
>
|
||||||
|
Expandir
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={collapseAll}
|
||||||
|
disabled={bulkBusy}
|
||||||
|
>
|
||||||
|
Colapsar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-primary"
|
||||||
|
disabled={bulkBusy}
|
||||||
|
onClick={() => void downloadMany(files, "all")}
|
||||||
|
title="Elige una carpeta local; se creará una subcarpeta por NIT"
|
||||||
|
>
|
||||||
|
{dlBusyKey === "all" ? (
|
||||||
|
<Loader2 size={13} className="spin" />
|
||||||
|
) : (
|
||||||
|
<FolderDown size={13} />
|
||||||
|
)}
|
||||||
|
Descargar todos
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filesError && (
|
||||||
|
<div className="wiz-toast wiz-toast--error" style={{ position: "static", marginBottom: 12 }}>
|
||||||
|
<AlertCircle size={15} />
|
||||||
|
<span>No se pudo listar MinIO: {filesError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!filesError && files.length === 0 ? (
|
||||||
|
<p className="wiz-hint">Sin PDFs listados aún.</p>
|
||||||
|
) : (
|
||||||
|
<div className="dl-nit-list">
|
||||||
|
{grouped.map(({ nit, files: nitFiles }) => {
|
||||||
|
const open = expandedNits.has(nit);
|
||||||
|
const nitBusy = dlBusyKey === `nit:${nit}`;
|
||||||
|
return (
|
||||||
|
<div key={nit} className={`dl-nit-card${open ? " dl-nit-card--open" : ""}`}>
|
||||||
|
<div className="dl-nit-card-header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dl-nit-card-toggle"
|
||||||
|
onClick={() => toggleNit(nit)}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
|
<FolderOpen size={15} className="dl-nit-card-icon" />
|
||||||
|
<span className="dl-nit-card-title">NIT {nit}</span>
|
||||||
|
<span className="dl-nit-card-count">
|
||||||
|
{nitFiles.length} PDF{nitFiles.length === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
disabled={bulkBusy}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void downloadMany(nitFiles, `nit:${nit}`);
|
||||||
|
}}
|
||||||
|
title={`Descargar los ${nitFiles.length} PDF de este NIT`}
|
||||||
|
>
|
||||||
|
{nitBusy ? <Loader2 size={13} className="spin" /> : <Download size={13} />}
|
||||||
|
Descargar carpeta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="dl-nit-card-body">
|
||||||
|
<table className="dl-files-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Radicado</th>
|
||||||
|
<th>Archivo</th>
|
||||||
|
<th className="dl-files-table-actions"> </th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{nitFiles.map((f) => {
|
||||||
|
const fileBusy = dlBusyKey === `file:${f.key}`;
|
||||||
|
const name = pdfBasename(f.key, f.radicado);
|
||||||
|
return (
|
||||||
|
<tr key={f.key}>
|
||||||
|
<td className="dl-col-radicado">{f.radicado ?? "—"}</td>
|
||||||
|
<td className="dl-col-file" title={f.key}>
|
||||||
|
{name}
|
||||||
|
</td>
|
||||||
|
<td className="dl-files-table-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
disabled={bulkBusy}
|
||||||
|
onClick={() => void downloadOne(f)}
|
||||||
|
title="Descargar este PDF"
|
||||||
|
>
|
||||||
|
{fileBusy ? (
|
||||||
|
<Loader2 size={13} className="spin" />
|
||||||
|
) : (
|
||||||
|
<Download size={13} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wiz-section">
|
||||||
|
<h3 className="wiz-section-title">Log</h3>
|
||||||
|
<div className="wiz-estimate-box dl-log-box">
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<span className="wiz-hint">Sin eventos.</span>
|
||||||
|
) : (
|
||||||
|
events.map((ev) => (
|
||||||
|
<div key={ev.id}>
|
||||||
|
[{ev.timestamp}] {ev.message}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,533 @@
|
|||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
Bot,
|
||||||
|
Download,
|
||||||
|
FileSpreadsheet,
|
||||||
|
Loader2,
|
||||||
|
Play,
|
||||||
|
RefreshCw,
|
||||||
|
Square,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { ApiClient } from "../../lib/apiClient";
|
||||||
|
import type { DownloadJob, DownloadJobEvent, DownloadWorker, Job } from "../../../shared/types";
|
||||||
|
import { ParallelBotProgress } from "../../components/ParallelBotProgress";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
client: ApiClient;
|
||||||
|
job: Job;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadStep = 1 | 2;
|
||||||
|
|
||||||
|
type ShardInfo = {
|
||||||
|
id?: string;
|
||||||
|
worker_id?: string | null;
|
||||||
|
status?: string;
|
||||||
|
shard_index?: number | null;
|
||||||
|
result?: Record<string, unknown>;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DownloadsWizardModule({ client, job }: Props) {
|
||||||
|
const [step, setStep] = useState<DownloadStep>(1);
|
||||||
|
const [workers, setWorkers] = useState<DownloadWorker[]>([]);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
const [useAllOnline, setUseAllOnline] = useState(true);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [activeJob, setActiveJob] = useState<DownloadJob | null>(null);
|
||||||
|
const [events, setEvents] = useState<DownloadJobEvent[]>([]);
|
||||||
|
const sinceRef = useRef(0);
|
||||||
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const initializedSelectRef = useRef(false);
|
||||||
|
|
||||||
|
const onlineWorkers = useMemo(
|
||||||
|
() => workers.filter((w) => w.status === "online" || w.status === "busy"),
|
||||||
|
[workers],
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshWorkers = useCallback(async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
const list = await client.listDownloadWorkers();
|
||||||
|
setWorkers(list);
|
||||||
|
setError(null);
|
||||||
|
const online = list.filter((w) => w.status === "online" || w.status === "busy");
|
||||||
|
if (!initializedSelectRef.current && online.length > 0) {
|
||||||
|
initializedSelectRef.current = true;
|
||||||
|
setUseAllOnline(online.length >= 2);
|
||||||
|
setSelectedIds(online.map((w) => w.id));
|
||||||
|
} else if (useAllOnline) {
|
||||||
|
setSelectedIds(online.map((w) => w.id));
|
||||||
|
} else {
|
||||||
|
setSelectedIds((prev) => prev.filter((id) => online.some((w) => w.id === id)));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [client, useAllOnline]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refreshWorkers();
|
||||||
|
const t = setInterval(() => void refreshWorkers(), 5000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [refreshWorkers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeJob || ["completed", "failed", "cancelled"].includes(activeJob.status)) {
|
||||||
|
if (pollRef.current) {
|
||||||
|
clearInterval(pollRef.current);
|
||||||
|
pollRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function poll() {
|
||||||
|
if (!activeJob) return;
|
||||||
|
try {
|
||||||
|
const nextJob = await client.getDownloadJob(activeJob.id);
|
||||||
|
setActiveJob(nextJob);
|
||||||
|
const { events: next } = await client.getDownloadEvents(activeJob.id, sinceRef.current);
|
||||||
|
if (next.length) {
|
||||||
|
setEvents((prev) => [...prev, ...next]);
|
||||||
|
sinceRef.current = next[next.length - 1].id;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void poll();
|
||||||
|
pollRef.current = setInterval(() => void poll(), 3000);
|
||||||
|
return () => {
|
||||||
|
if (pollRef.current) clearInterval(pollRef.current);
|
||||||
|
};
|
||||||
|
}, [activeJob?.id, activeJob?.status, client]);
|
||||||
|
|
||||||
|
function toggleWorker(id: string) {
|
||||||
|
if (useAllOnline) return;
|
||||||
|
setSelectedIds((prev) =>
|
||||||
|
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startJob() {
|
||||||
|
const targets = useAllOnline ? onlineWorkers.map((w) => w.id) : selectedIds;
|
||||||
|
if (targets.length === 0) {
|
||||||
|
setError("Selecciona al menos un bot online.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!file) {
|
||||||
|
setError("Selecciona un Excel o CSV.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("job_id", job.id);
|
||||||
|
form.append("file", file);
|
||||||
|
if (title.trim()) form.append("title", title.trim());
|
||||||
|
if (targets.length === 1) {
|
||||||
|
form.append("worker_id", targets[0]);
|
||||||
|
} else {
|
||||||
|
form.append("worker_ids", JSON.stringify(targets));
|
||||||
|
}
|
||||||
|
const created = await client.createDownloadJob(form);
|
||||||
|
sinceRef.current = 0;
|
||||||
|
setEvents([]);
|
||||||
|
setActiveJob(created);
|
||||||
|
setStep(2);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelJob() {
|
||||||
|
if (!activeJob) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const updated = await client.cancelDownloadJob(activeJob.id);
|
||||||
|
setActiveJob(updated);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetWizard() {
|
||||||
|
setStep(1);
|
||||||
|
setActiveJob(null);
|
||||||
|
setEvents([]);
|
||||||
|
setFile(null);
|
||||||
|
setTitle("");
|
||||||
|
setError(null);
|
||||||
|
sinceRef.current = 0;
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = activeJob?.result ?? {};
|
||||||
|
const processed = Number(result.processed ?? 0);
|
||||||
|
const total = Number(result.total ?? result.total_rows ?? 0);
|
||||||
|
const pct = total > 0 ? Math.min(100, Math.round((processed / total) * 100)) : 0;
|
||||||
|
const running = Boolean(
|
||||||
|
activeJob && !["completed", "failed", "cancelled"].includes(activeJob.status),
|
||||||
|
);
|
||||||
|
const isDistributed =
|
||||||
|
Boolean(activeJob?.shard_count && Number(activeJob.shard_count) > 1) ||
|
||||||
|
result.mode === "distributed";
|
||||||
|
const shards: ShardInfo[] = Array.isArray(activeJob?.shards)
|
||||||
|
? (activeJob!.shards as ShardInfo[])
|
||||||
|
: Array.isArray(result.shards)
|
||||||
|
? (result.shards as ShardInfo[])
|
||||||
|
: [];
|
||||||
|
const effectiveTargets = useAllOnline ? onlineWorkers.map((w) => w.id) : selectedIds;
|
||||||
|
const canStart = effectiveTargets.length > 0 && Boolean(file) && !busy;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="wiz">
|
||||||
|
<nav className="stepper" aria-label="Pasos del bot de descarga">
|
||||||
|
<div className="stepper-item">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`stepper-dot ${step === 1 ? "active" : step > 1 ? "done" : "todo"}`}
|
||||||
|
onClick={() => !running && setStep(1)}
|
||||||
|
disabled={running}
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</button>
|
||||||
|
<span className={`stepper-label ${step === 1 ? "active" : step > 1 ? "done" : "todo"}`}>
|
||||||
|
Configurar
|
||||||
|
</span>
|
||||||
|
<div className={`stepper-connector ${step > 1 ? "done" : ""}`} />
|
||||||
|
</div>
|
||||||
|
<div className="stepper-item">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`stepper-dot ${step === 2 ? "active" : "todo"}`}
|
||||||
|
onClick={() => activeJob && setStep(2)}
|
||||||
|
disabled={!activeJob}
|
||||||
|
>
|
||||||
|
2
|
||||||
|
</button>
|
||||||
|
<span className={`stepper-label ${step === 2 ? "active" : "todo"}`}>Ejecución</span>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="wiz-body">
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="wiz-step">
|
||||||
|
<div className="wiz-section">
|
||||||
|
<div className="wiz-section-header">
|
||||||
|
<h3 className="wiz-section-title">
|
||||||
|
Bots disponibles
|
||||||
|
{onlineWorkers.length > 0 && (
|
||||||
|
<span className="wiz-count-badge">{onlineWorkers.length} online</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => void refreshWorkers()}
|
||||||
|
disabled={refreshing}
|
||||||
|
>
|
||||||
|
{refreshing ? <Loader2 size={13} className="spin" /> : <RefreshCw size={13} />}
|
||||||
|
Actualizar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="wiz-desc">
|
||||||
|
Con varios bots online el Excel se reparte automáticamente; los PDFs quedan en un
|
||||||
|
solo resultado.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{onlineWorkers.length >= 2 && (
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useAllOnline}
|
||||||
|
onChange={(e) => {
|
||||||
|
const on = e.target.checked;
|
||||||
|
setUseAllOnline(on);
|
||||||
|
if (on) setSelectedIds(onlineWorkers.map((w) => w.id));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>Usar todos los bots online ({onlineWorkers.length})</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{workers.length === 0 ? (
|
||||||
|
<p className="wiz-hint">Ningún worker registrado. Enciende el agent en el PC del bot.</p>
|
||||||
|
) : (
|
||||||
|
<div className="wiz-review-grid" style={{ marginTop: 12 }}>
|
||||||
|
{workers.map((w) => {
|
||||||
|
const online = w.status === "online" || w.status === "busy";
|
||||||
|
const selected = effectiveTargets.includes(w.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={w.id}
|
||||||
|
type="button"
|
||||||
|
className={`wiz-review-item${selected ? " wiz-review-item--selected" : ""}`}
|
||||||
|
disabled={!online || useAllOnline}
|
||||||
|
onClick={() => online && toggleWorker(w.id)}
|
||||||
|
style={{
|
||||||
|
textAlign: "left",
|
||||||
|
cursor: online && !useAllOnline ? "pointer" : "not-allowed",
|
||||||
|
opacity: online ? 1 : 0.5,
|
||||||
|
borderColor: selected ? "var(--accent, #3b8ed0)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="wiz-review-label">Worker</span>
|
||||||
|
<span className="wiz-review-value">{w.id}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="wiz-review-label">Estado</span>
|
||||||
|
<span className="wiz-review-value">
|
||||||
|
{w.status}
|
||||||
|
{w.hostname ? ` · ${w.hostname}` : ""}
|
||||||
|
{selected ? " · seleccionado" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{onlineWorkers.length === 0 && workers.length > 0 && (
|
||||||
|
<p className="wiz-hint">No hay bots online. Inicia el agent en la PC correspondiente.</p>
|
||||||
|
)}
|
||||||
|
{effectiveTargets.length > 1 && (
|
||||||
|
<p className="wiz-hint" style={{ marginTop: 8 }}>
|
||||||
|
Se repartirá entre {effectiveTargets.length} bots.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wiz-section">
|
||||||
|
<h3 className="wiz-section-title">Archivo de radicados</h3>
|
||||||
|
<p className="wiz-desc">Excel o CSV con radicado (columna A) y NIT (columna B).</p>
|
||||||
|
|
||||||
|
<label className="wiz-field-label">Título (opcional)</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Lote mayo 2026"
|
||||||
|
style={{ width: "100%", marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="wiz-field-label">Excel / CSV</label>
|
||||||
|
<div className="folder-picker">
|
||||||
|
<div className="folder-picker-display">
|
||||||
|
{file ? (
|
||||||
|
<>
|
||||||
|
<FileSpreadsheet size={16} className="folder-picker-icon" />
|
||||||
|
<span className="folder-picker-path">{file.name}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="folder-picker-placeholder">Ningún archivo seleccionado</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<FileSpreadsheet size={15} />
|
||||||
|
Seleccionar archivo
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls,.csv"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 2 && activeJob && (
|
||||||
|
<div className="wiz-step wiz-step-centered">
|
||||||
|
<div className="wiz-hero">
|
||||||
|
<div className={`wiz-hero-icon ${running ? "wiz-hero-icon--go" : ""}`}>
|
||||||
|
{running ? (
|
||||||
|
<Loader2 size={28} className="spin" />
|
||||||
|
) : activeJob.status === "completed" ? (
|
||||||
|
<Download size={28} />
|
||||||
|
) : activeJob.status === "failed" ? (
|
||||||
|
<AlertCircle size={28} />
|
||||||
|
) : (
|
||||||
|
<Bot size={28} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="wiz-hero-title">
|
||||||
|
{running
|
||||||
|
? isDistributed
|
||||||
|
? "Descarga distribuida en curso"
|
||||||
|
: "Descarga en curso"
|
||||||
|
: activeJob.status === "completed"
|
||||||
|
? "Descarga completada"
|
||||||
|
: activeJob.status === "cancelled"
|
||||||
|
? "Descarga cancelada"
|
||||||
|
: activeJob.status === "failed"
|
||||||
|
? "Descarga fallida"
|
||||||
|
: "Descarga finalizada"}
|
||||||
|
</h3>
|
||||||
|
<p className="wiz-desc">
|
||||||
|
{isDistributed
|
||||||
|
? `${Number(activeJob.shard_count) || shards.length || "?"} bots`
|
||||||
|
: `Bot ${activeJob.worker_id ?? "—"}`}
|
||||||
|
{activeJob.original_filename ? ` · ${activeJob.original_filename}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeJob.error && (
|
||||||
|
<div className="wiz-toast wiz-toast--error" style={{ position: "static", marginBottom: 12 }}>
|
||||||
|
<AlertCircle size={15} />
|
||||||
|
<span>{activeJob.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="wiz-progress">
|
||||||
|
<div className="wiz-progress-header">
|
||||||
|
<span>
|
||||||
|
{processed}/{total || "?"} radicados procesados
|
||||||
|
</span>
|
||||||
|
<span>{pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="wiz-progress-track">
|
||||||
|
<div className="wiz-progress-fill" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wiz-stats-row">
|
||||||
|
<div className="wiz-stat">
|
||||||
|
<span className="wiz-stat-value">{String(result.exitosos ?? processed)}</span>
|
||||||
|
<span className="wiz-stat-label">OK</span>
|
||||||
|
</div>
|
||||||
|
<div className="wiz-stat">
|
||||||
|
<span className="wiz-stat-value">{String(result.fallidos ?? 0)}</span>
|
||||||
|
<span className="wiz-stat-label">Fallidos</span>
|
||||||
|
</div>
|
||||||
|
<div className="wiz-stat">
|
||||||
|
<span className="wiz-stat-value">{String(result.uploaded_count ?? "—")}</span>
|
||||||
|
<span className="wiz-stat-label">En MinIO</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ParallelBotProgress shards={shards} unit="radicados" />
|
||||||
|
|
||||||
|
{activeJob.downloads_prefix && (
|
||||||
|
<p className="wiz-hint" style={{ marginTop: 8 }}>
|
||||||
|
Prefijo MinIO: <code>{activeJob.downloads_prefix}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="wiz-section" style={{ marginTop: 16, width: "100%", maxWidth: 640 }}>
|
||||||
|
<h3 className="wiz-section-title">Log del proceso</h3>
|
||||||
|
<div
|
||||||
|
className="wiz-estimate-box"
|
||||||
|
style={{
|
||||||
|
maxHeight: 220,
|
||||||
|
overflow: "auto",
|
||||||
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||||
|
fontSize: 12,
|
||||||
|
textAlign: "left",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<span className="wiz-hint">Esperando eventos…</span>
|
||||||
|
) : (
|
||||||
|
events.map((ev) => (
|
||||||
|
<div key={ev.id}>
|
||||||
|
[{ev.timestamp}] {ev.message}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="wiz-toast wiz-toast--error">
|
||||||
|
<AlertCircle size={15} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<footer className="wiz-nav">
|
||||||
|
{step === 2 ? (
|
||||||
|
<button type="button" className="btn" onClick={resetWizard} disabled={running}>
|
||||||
|
Nueva descarga
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="wiz-nav-dots">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`wiz-nav-dot ${step === 1 ? "active" : ""}`}
|
||||||
|
onClick={() => !running && setStep(1)}
|
||||||
|
disabled={running}
|
||||||
|
aria-label="Paso 1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`wiz-nav-dot ${step === 2 ? "active" : ""}`}
|
||||||
|
onClick={() => activeJob && setStep(2)}
|
||||||
|
disabled={!activeJob}
|
||||||
|
aria-label="Paso 2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!canStart}
|
||||||
|
onClick={() => void startJob()}
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 size={15} className="spin" /> : <Play size={15} />}
|
||||||
|
{effectiveTargets.length > 1
|
||||||
|
? `Iniciar con ${effectiveTargets.length} bots`
|
||||||
|
: "Iniciar descarga"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{step === 2 && running && (
|
||||||
|
<button type="button" className="btn" disabled={busy} onClick={() => void cancelJob()}>
|
||||||
|
<Square size={14} /> Cancelar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{step === 2 && !running && (
|
||||||
|
<button type="button" className="btn btn-primary" onClick={resetWizard}>
|
||||||
|
<Play size={15} /> Otra descarga
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3488,6 +3488,12 @@ th {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-header-actions {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.home-body {
|
.home-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -3519,6 +3525,283 @@ th {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-empty--compact {
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Panel general de operaciones ── */
|
||||||
|
.ops-dashboard {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-dashboard-meta {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
font-size: 11.5px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-dashboard-meta span {
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-warning {
|
||||||
|
align-items: center;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fde68a;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #92400e;
|
||||||
|
display: flex;
|
||||||
|
font-size: 12px;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-kpi-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px 11px;
|
||||||
|
grid-template-columns: 38px 1fr;
|
||||||
|
min-height: 102px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-kpi-card--danger {
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-kpi-icon {
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: flex;
|
||||||
|
grid-row: 1 / span 2;
|
||||||
|
height: 38px;
|
||||||
|
justify-content: center;
|
||||||
|
width: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-kpi-icon--blue { background: #eff6ff; color: #2563eb; }
|
||||||
|
.ops-kpi-icon--purple { background: #f5f3ff; color: #7c3aed; }
|
||||||
|
.ops-kpi-icon--cyan { background: #ecfeff; color: #0891b2; }
|
||||||
|
.ops-kpi-icon--green { background: #ecfdf5; color: #059669; }
|
||||||
|
.ops-kpi-icon--amber { background: #fffbeb; color: #d97706; }
|
||||||
|
.ops-kpi-icon--red { background: #fef2f2; color: #dc2626; }
|
||||||
|
|
||||||
|
.ops-kpi-card > div:nth-child(2) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-kpi-label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-kpi-value {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 25px;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-kpi-note {
|
||||||
|
align-self: end;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 10.5px;
|
||||||
|
grid-column: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-worker-statuses {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 9px;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-worker-dot {
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: 10.5px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-worker-dot::before {
|
||||||
|
background: #94a3b8;
|
||||||
|
border-radius: 50%;
|
||||||
|
content: "";
|
||||||
|
height: 6px;
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-worker-dot--online::before { background: #22c55e; }
|
||||||
|
.ops-worker-dot--busy::before { background: #f59e0b; }
|
||||||
|
.ops-worker-dot--offline::before { background: #94a3b8; }
|
||||||
|
|
||||||
|
.ops-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: minmax(0, 1.25fr) minmax(300px, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-panel-header {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-panel-header > div {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-panel-header h2 {
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-panel-header > span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 10.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-stage-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-stage-time {
|
||||||
|
background: var(--surface-2, #f8fafc);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-stage-time span,
|
||||||
|
.ops-stage-time small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-stage-time strong {
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-flow-percent {
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-flow-track,
|
||||||
|
.ops-active-progress {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
height: 7px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-flow-track > div,
|
||||||
|
.ops-active-progress > div {
|
||||||
|
background: linear-gradient(90deg, var(--primary), #22c55e);
|
||||||
|
border-radius: inherit;
|
||||||
|
height: 100%;
|
||||||
|
transition: width 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-flow-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11.5px;
|
||||||
|
margin: 17px 0 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-active-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-active-job {
|
||||||
|
align-items: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
grid-template-columns: minmax(100px, 1fr) minmax(60px, 0.65fr) 35px;
|
||||||
|
padding: 6px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ops-active-job:hover { background: var(--surface-2, #f8fafc); }
|
||||||
|
.ops-active-job > div:first-child { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.ops-active-job > div:first-child strong { font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.ops-active-job > div:first-child span { color: var(--text-muted); font-size: 9.5px; }
|
||||||
|
.ops-active-job > strong { font-size: 11px; text-align: right; }
|
||||||
|
|
||||||
|
.home-list-header {
|
||||||
|
align-items: center;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-list-header > div { align-items: baseline; display: flex; gap: 8px; }
|
||||||
|
.home-list-header h2 { font-size: 15px; margin: 0; }
|
||||||
|
.home-list-header span { color: var(--text-muted); font-size: 11px; }
|
||||||
|
|
||||||
|
@media (max-width: 1050px) {
|
||||||
|
.ops-kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.ops-detail-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.home-header { align-items: flex-start; }
|
||||||
|
.home-header-actions { align-items: stretch; flex-direction: column-reverse; }
|
||||||
|
.ops-kpi-grid { grid-template-columns: 1fr; }
|
||||||
|
.ops-stage-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.ops-dashboard-meta { align-items: flex-start; flex-direction: column; gap: 3px; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Job list ── */
|
/* ── Job list ── */
|
||||||
.job-list {
|
.job-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
|
Activity,
|
||||||
|
AlertTriangle,
|
||||||
|
Bot,
|
||||||
Briefcase,
|
Briefcase,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Clock3,
|
||||||
|
Download,
|
||||||
|
FileCheck2,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
Gauge,
|
||||||
|
Keyboard,
|
||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
@@ -14,6 +22,11 @@ import { createPortal } from "react-dom";
|
|||||||
import type { ApiClient } from "../lib/apiClient";
|
import type { ApiClient } from "../lib/apiClient";
|
||||||
import { DEFAULT_API_BASE_URL } from "../lib/apiDefaults";
|
import { DEFAULT_API_BASE_URL } from "../lib/apiDefaults";
|
||||||
import type { Job } from "../../shared/types";
|
import type { Job } from "../../shared/types";
|
||||||
|
import {
|
||||||
|
buildOperationsMetrics,
|
||||||
|
formatDuration,
|
||||||
|
type OperationsMetrics,
|
||||||
|
} from "../lib/operationsMetrics";
|
||||||
|
|
||||||
declare const desktopApi: import("../../shared/types").DesktopBridge;
|
declare const desktopApi: import("../../shared/types").DesktopBridge;
|
||||||
|
|
||||||
@@ -45,6 +58,9 @@ function statusLabel(s: string) {
|
|||||||
|
|
||||||
export function HomeView({ client, onOpenJob, onJobDeleted }: Props) {
|
export function HomeView({ client, onOpenJob, onJobDeleted }: Props) {
|
||||||
const [jobs, setJobs] = useState<Job[]>([]);
|
const [jobs, setJobs] = useState<Job[]>([]);
|
||||||
|
const [metrics, setMetrics] = useState<OperationsMetrics | null>(null);
|
||||||
|
const [metricsWarning, setMetricsWarning] = useState<string | null>(null);
|
||||||
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
@@ -56,12 +72,47 @@ export function HomeView({ client, onOpenJob, onJobDeleted }: Props) {
|
|||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadJobs = useCallback(async () => {
|
const loadJobs = useCallback(async (showSpinner = true) => {
|
||||||
setLoading(true);
|
if (showSpinner) setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await client.jobs();
|
const data = await client.jobs();
|
||||||
setJobs([...data].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()));
|
const sortedJobs = [...data].sort(
|
||||||
|
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||||
|
);
|
||||||
|
setJobs(sortedJobs);
|
||||||
|
|
||||||
|
const [tasksResult, downloadWorkersResult, digitadorWorkersResult, downloadJobsResult, digitadorJobsResult] =
|
||||||
|
await Promise.allSettled([
|
||||||
|
client.tasks(),
|
||||||
|
client.listDownloadWorkers(),
|
||||||
|
client.listDigitadorWorkers(),
|
||||||
|
client.listDownloadJobs(500),
|
||||||
|
client.listDigitadorJobs(500),
|
||||||
|
]);
|
||||||
|
const failures = [
|
||||||
|
tasksResult,
|
||||||
|
downloadWorkersResult,
|
||||||
|
digitadorWorkersResult,
|
||||||
|
downloadJobsResult,
|
||||||
|
digitadorJobsResult,
|
||||||
|
].filter((result) => result.status === "rejected").length;
|
||||||
|
setMetricsWarning(
|
||||||
|
failures > 0
|
||||||
|
? `${failures} fuente${failures === 1 ? "" : "s"} no respondieron; se muestran métricas parciales.`
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
setMetrics(
|
||||||
|
buildOperationsMetrics({
|
||||||
|
jobs: sortedJobs,
|
||||||
|
tasks: tasksResult.status === "fulfilled" ? tasksResult.value : [],
|
||||||
|
downloadWorkers: downloadWorkersResult.status === "fulfilled" ? downloadWorkersResult.value : [],
|
||||||
|
digitadorWorkers: digitadorWorkersResult.status === "fulfilled" ? digitadorWorkersResult.value : [],
|
||||||
|
downloadJobs: downloadJobsResult.status === "fulfilled" ? downloadJobsResult.value : [],
|
||||||
|
digitadorJobs: digitadorJobsResult.status === "fulfilled" ? digitadorJobsResult.value : [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setLastUpdated(new Date());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
setError(
|
setError(
|
||||||
@@ -75,7 +126,11 @@ export function HomeView({ client, onOpenJob, onJobDeleted }: Props) {
|
|||||||
}
|
}
|
||||||
}, [client]);
|
}, [client]);
|
||||||
|
|
||||||
useEffect(() => { loadJobs(); }, [loadJobs]);
|
useEffect(() => {
|
||||||
|
void loadJobs();
|
||||||
|
const timer = window.setInterval(() => void loadJobs(false), 15_000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [loadJobs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showForm && !jobToDelete) return;
|
if (!showForm && !jobToDelete) return;
|
||||||
@@ -142,12 +197,23 @@ export function HomeView({ client, onOpenJob, onJobDeleted }: Props) {
|
|||||||
<div className="home-view">
|
<div className="home-view">
|
||||||
<header className="home-header">
|
<header className="home-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="home-title">Trabajos</h1>
|
<h1 className="home-title">Panel de operaciones</h1>
|
||||||
<p className="home-subtitle">Cada trabajo agrupa conteo y extracción de un lote de documentos.</p>
|
<p className="home-subtitle">Estado de Flujo IPS y actividad del día en tiempo real.</p>
|
||||||
|
</div>
|
||||||
|
<div className="home-header-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost"
|
||||||
|
onClick={() => void loadJobs(false)}
|
||||||
|
disabled={loading}
|
||||||
|
title="Actualizar panel"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} className={loading ? "spin" : ""} /> Actualizar
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => setShowForm(true)}>
|
||||||
|
<Plus size={15} /> Nuevo trabajo
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-primary" onClick={() => setShowForm(true)}>
|
|
||||||
<Plus size={15} /> Nuevo trabajo
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{showForm && createPortal(
|
{showForm && createPortal(
|
||||||
@@ -293,58 +359,146 @@ export function HomeView({ client, onOpenJob, onJobDeleted }: Props) {
|
|||||||
<RefreshCw size={14} /> Reintentar
|
<RefreshCw size={14} /> Reintentar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : jobs.length === 0 ? (
|
|
||||||
<div className="home-empty">
|
|
||||||
<Briefcase size={40} style={{ opacity: 0.3 }} />
|
|
||||||
<p>No hay trabajos todavía.</p>
|
|
||||||
<button type="button" className="btn btn-primary" onClick={() => setShowForm(true)}>
|
|
||||||
<Plus size={14} /> Crear primer trabajo
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<ul className="job-list">
|
<>
|
||||||
{jobs.map((job) => (
|
{metrics && (
|
||||||
<li key={job.id} className="job-list-item">
|
<section className="ops-dashboard" aria-label="Resumen operativo">
|
||||||
<button
|
<div className="ops-dashboard-meta">
|
||||||
type="button"
|
<span>
|
||||||
className="job-card"
|
<Activity size={13} /> Actualización automática cada 15 segundos
|
||||||
onClick={() => onOpenJob(job)}
|
</span>
|
||||||
>
|
{lastUpdated && <span>Última: {lastUpdated.toLocaleTimeString("es-CO", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}</span>}
|
||||||
<div className="job-card-icon">
|
</div>
|
||||||
<Briefcase size={20} />
|
|
||||||
</div>
|
{metricsWarning && (
|
||||||
<div className="job-card-body">
|
<div className="ops-warning"><AlertTriangle size={14} /> {metricsWarning}</div>
|
||||||
<div className="job-card-title">{job.title ?? "Sin título"}</div>
|
)}
|
||||||
{job.metadata?.folder_path != null && (
|
|
||||||
<div className="job-card-path">
|
<div className="ops-kpi-grid">
|
||||||
<FolderOpen size={12} />
|
<article className="ops-kpi-card">
|
||||||
{String(job.metadata.folder_path)}
|
<div className="ops-kpi-icon ops-kpi-icon--blue"><Briefcase size={19} /></div>
|
||||||
|
<div><span className="ops-kpi-label">Trabajos activos</span><strong className="ops-kpi-value">{metrics.activeJobs.length}</strong></div>
|
||||||
|
<span className="ops-kpi-note">En ejecución ahora</span>
|
||||||
|
</article>
|
||||||
|
<article className="ops-kpi-card">
|
||||||
|
<div className="ops-kpi-icon ops-kpi-icon--purple"><Bot size={19} /></div>
|
||||||
|
<div><span className="ops-kpi-label">Bots registrados</span><strong className="ops-kpi-value">{metrics.workers.total}</strong></div>
|
||||||
|
<div className="ops-worker-statuses">
|
||||||
|
<span className="ops-worker-dot ops-worker-dot--online">{metrics.workers.connected} conectados</span>
|
||||||
|
<span className="ops-worker-dot ops-worker-dot--busy">{metrics.workers.busy} ocupados</span>
|
||||||
|
<span className="ops-worker-dot ops-worker-dot--offline">{metrics.workers.offline} desconectados</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article className="ops-kpi-card">
|
||||||
|
<div className="ops-kpi-icon ops-kpi-icon--cyan"><Download size={19} /></div>
|
||||||
|
<div><span className="ops-kpi-label">Descargadas hoy</span><strong className="ops-kpi-value">{metrics.downloadedToday.toLocaleString("es-CO")}</strong></div>
|
||||||
|
<span className="ops-kpi-note">Facturas obtenidas correctamente</span>
|
||||||
|
</article>
|
||||||
|
<article className="ops-kpi-card">
|
||||||
|
<div className="ops-kpi-icon ops-kpi-icon--green"><FileCheck2 size={19} /></div>
|
||||||
|
<div><span className="ops-kpi-label">Procesadas hoy</span><strong className="ops-kpi-value">{metrics.processedToday.toLocaleString("es-CO")}</strong></div>
|
||||||
|
<span className="ops-kpi-note">Facturas pasadas por conteo</span>
|
||||||
|
</article>
|
||||||
|
<article className="ops-kpi-card">
|
||||||
|
<div className="ops-kpi-icon ops-kpi-icon--amber"><Keyboard size={19} /></div>
|
||||||
|
<div><span className="ops-kpi-label">Digitadas hoy</span><strong className="ops-kpi-value">{metrics.digitatedToday.toLocaleString("es-CO")}</strong></div>
|
||||||
|
<span className="ops-kpi-note">Registros atendidos por los bots</span>
|
||||||
|
</article>
|
||||||
|
<article className={`ops-kpi-card${metrics.pendingErrors > 0 ? " ops-kpi-card--danger" : ""}`}>
|
||||||
|
<div className="ops-kpi-icon ops-kpi-icon--red"><AlertTriangle size={19} /></div>
|
||||||
|
<div><span className="ops-kpi-label">Errores pendientes</span><strong className="ops-kpi-value">{metrics.pendingErrors}</strong></div>
|
||||||
|
<span className="ops-kpi-note">Tareas o corridas fallidas</span>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ops-detail-grid">
|
||||||
|
<article className="ops-panel">
|
||||||
|
<div className="ops-panel-header">
|
||||||
|
<div><Clock3 size={17} /><h2>Tiempo promedio por etapa</h2></div>
|
||||||
|
<span>Histórico completado</span>
|
||||||
|
</div>
|
||||||
|
<div className="ops-stage-grid">
|
||||||
|
{metrics.averageStageMs.map((stage) => (
|
||||||
|
<div key={stage.type} className="ops-stage-time">
|
||||||
|
<span>{stage.label}</span>
|
||||||
|
<strong>{formatDuration(stage.durationMs)}</strong>
|
||||||
|
<small>{stage.samples > 0 ? `${stage.samples} ejecución${stage.samples === 1 ? "" : "es"}` : "Sin ejecuciones"}</small>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="ops-panel">
|
||||||
|
<div className="ops-panel-header">
|
||||||
|
<div><Gauge size={17} /><h2>Avance del flujo completo</h2></div>
|
||||||
|
<strong className="ops-flow-percent">{metrics.flowPercent}%</strong>
|
||||||
|
</div>
|
||||||
|
<div className="ops-flow-track"><div style={{ width: `${metrics.flowPercent}%` }} /></div>
|
||||||
|
{metrics.activeJobs.length === 0 ? (
|
||||||
|
<p className="ops-flow-empty">No hay trabajos ejecutándose en este momento.</p>
|
||||||
|
) : (
|
||||||
|
<div className="ops-active-list">
|
||||||
|
{metrics.activeJobs.slice(0, 5).map((item) => (
|
||||||
|
<button key={item.job.id} type="button" onClick={() => onOpenJob(item.job)} className="ops-active-job">
|
||||||
|
<div>
|
||||||
|
<strong>{item.job.title || "Sin título"}</strong>
|
||||||
|
<span>{item.currentStage} · {item.completedStages}/{item.totalStages} etapas</span>
|
||||||
|
</div>
|
||||||
|
<div className="ops-active-progress"><div style={{ width: `${item.percent}%` }} /></div>
|
||||||
|
<strong>{item.percent}%</strong>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="job-card-meta">
|
</article>
|
||||||
<span className={`task-badge task-badge--${job.status}`}>
|
</div>
|
||||||
{statusLabel(job.status)}
|
</section>
|
||||||
</span>
|
)}
|
||||||
<span className="job-card-date">
|
|
||||||
<CalendarDays size={12} />
|
<div className="home-list-header">
|
||||||
{formatRelative(job.updated_at)}
|
<div><h2>Trabajos recientes</h2><span>{jobs.length} en total</span></div>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
{jobs.length === 0 ? (
|
||||||
<ChevronRight size={18} className="job-card-arrow" />
|
<div className="home-empty home-empty--compact">
|
||||||
|
<Briefcase size={40} style={{ opacity: 0.3 }} />
|
||||||
|
<p>No hay trabajos todavía.</p>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => setShowForm(true)}>
|
||||||
|
<Plus size={14} /> Crear primer trabajo
|
||||||
</button>
|
</button>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
) : (
|
||||||
className="btn btn-sm btn-ghost job-card-delete"
|
<ul className="job-list">
|
||||||
aria-label={`Eliminar ${job.title ?? "trabajo"}`}
|
{jobs.map((job) => (
|
||||||
title="Eliminar trabajo"
|
<li key={job.id} className="job-list-item">
|
||||||
onClick={(e) => openDeleteConfirm(job, e)}
|
<button type="button" className="job-card" onClick={() => onOpenJob(job)}>
|
||||||
>
|
<div className="job-card-icon"><Briefcase size={20} /></div>
|
||||||
<Trash2 size={15} />
|
<div className="job-card-body">
|
||||||
</button>
|
<div className="job-card-title">{job.title ?? "Sin título"}</div>
|
||||||
</li>
|
{job.metadata?.folder_path != null && (
|
||||||
))}
|
<div className="job-card-path"><FolderOpen size={12} />{String(job.metadata.folder_path)}</div>
|
||||||
</ul>
|
)}
|
||||||
|
<div className="job-card-meta">
|
||||||
|
<span className={`task-badge task-badge--${job.status}`}>{statusLabel(job.status)}</span>
|
||||||
|
<span className="job-card-date"><CalendarDays size={12} />{formatRelative(job.updated_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={18} className="job-card-arrow" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-sm btn-ghost job-card-delete"
|
||||||
|
aria-label={`Eliminar ${job.title ?? "trabajo"}`}
|
||||||
|
title="Eliminar trabajo"
|
||||||
|
onClick={(e) => openDeleteConfirm(job, e)}
|
||||||
|
>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="IPSExtractor Extraction Service",
|
title="Flujo IPS Extraction Service",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="IPSExtractor Población Apta Service",
|
title="Flujo IPS Población Apta Service",
|
||||||
version="1.0.0",
|
version="1.0.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user