feat: Implement job abandonment logic for reconnected workers in bot services. Introduce a mechanism to cancel pending and running jobs when a worker reconnects, ensuring a fresh start for new jobs. Update tests to verify job cancellation behavior upon worker re-registration. Enhance UI components in desktop application for better user experience and clarity on bot status.
This commit is contained in:
@@ -281,6 +281,7 @@ class BotDashboard(ctk.CTk):
|
|||||||
self.entry_api.delete(0, "end")
|
self.entry_api.delete(0, "end")
|
||||||
self.entry_api.insert(0, api)
|
self.entry_api.insert(0, api)
|
||||||
self._persist_flags()
|
self._persist_flags()
|
||||||
|
self._limpiar_logs()
|
||||||
|
|
||||||
self._agent = WorkerAgent(
|
self._agent = WorkerAgent(
|
||||||
api_url=api,
|
api_url=api,
|
||||||
|
|||||||
@@ -291,6 +291,7 @@ class BotDashboard(ctk.CTk):
|
|||||||
self.entry_api.delete(0, "end")
|
self.entry_api.delete(0, "end")
|
||||||
self.entry_api.insert(0, api)
|
self.entry_api.insert(0, api)
|
||||||
self._persist_flags()
|
self._persist_flags()
|
||||||
|
self._limpiar_logs()
|
||||||
|
|
||||||
self._agent = WorkerAgent(
|
self._agent = WorkerAgent(
|
||||||
api_url=api,
|
api_url=api,
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ from infrastructure.database import (
|
|||||||
from infrastructure.storage_minio import downloads_prefix
|
from infrastructure.storage_minio import downloads_prefix
|
||||||
|
|
||||||
HEARTBEAT_OFFLINE_SECONDS = 45
|
HEARTBEAT_OFFLINE_SECONDS = 45
|
||||||
|
# Un agent recién conectado no recibe jobs reclamados de otros workers.
|
||||||
|
_FRESH_WORKER_GRACE_SECONDS = 45
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso(value: str | None) -> datetime | None:
|
def _parse_iso(value: str | None) -> datetime | None:
|
||||||
@@ -44,6 +46,7 @@ class JobStore:
|
|||||||
def __init__(self, db_path: str | None = None):
|
def __init__(self, db_path: str | None = None):
|
||||||
# db_path ignorado (compat); usa DATABASE_URL.
|
# db_path ignorado (compat); usa DATABASE_URL.
|
||||||
init_db()
|
init_db()
|
||||||
|
self._fresh_until: dict[str, datetime] = {}
|
||||||
|
|
||||||
def _row_worker(self, row: DownloadWorkerRow) -> Worker:
|
def _row_worker(self, row: DownloadWorkerRow) -> Worker:
|
||||||
worker = Worker(
|
worker = Worker(
|
||||||
@@ -120,7 +123,67 @@ class JobStore:
|
|||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
session.flush()
|
session.flush()
|
||||||
return self._row_worker(row)
|
parent_ids = self._abandon_assigned_jobs(
|
||||||
|
session,
|
||||||
|
worker_id,
|
||||||
|
now,
|
||||||
|
"Agent reconectado: job anterior abandonado. Lanza una corrida nueva desde el desktop.",
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
worker = self._row_worker(row)
|
||||||
|
self._fresh_until[worker_id] = now + timedelta(seconds=_FRESH_WORKER_GRACE_SECONDS)
|
||||||
|
for parent_id in parent_ids:
|
||||||
|
try:
|
||||||
|
self.refresh_parent_from_shards(parent_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return worker
|
||||||
|
|
||||||
|
def _abandon_assigned_jobs(
|
||||||
|
self, session, worker_id: str, now: datetime, message: str
|
||||||
|
) -> list[str]:
|
||||||
|
"""Cancela pending/running de este worker para que un agent nuevo arranque idle."""
|
||||||
|
rows = session.scalars(
|
||||||
|
select(DownloadJobRow).where(
|
||||||
|
DownloadJobRow.worker_id == worker_id,
|
||||||
|
DownloadJobRow.status.in_((JobStatus.PENDING.value, JobStatus.RUNNING.value)),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
parent_ids: set[str] = set()
|
||||||
|
for row in rows:
|
||||||
|
is_parent = (
|
||||||
|
row.parent_download_job_id is None
|
||||||
|
and row.shard_count is not None
|
||||||
|
and int(row.shard_count) > 1
|
||||||
|
)
|
||||||
|
if is_parent:
|
||||||
|
continue
|
||||||
|
row.status = JobStatus.CANCELLED.value
|
||||||
|
row.cancel_requested = True
|
||||||
|
row.finished_at = now
|
||||||
|
row.error = message
|
||||||
|
self._sync_task(session, row, status=JobStatus.CANCELLED.value, error=message)
|
||||||
|
session.add(
|
||||||
|
DownloadJobEventRow(
|
||||||
|
download_job_id=row.id,
|
||||||
|
event_type="warning",
|
||||||
|
message=message,
|
||||||
|
timestamp=now,
|
||||||
|
metadata_json={"abandoned_on_register": True, "worker_id": worker_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if row.parent_download_job_id:
|
||||||
|
parent_ids.add(row.parent_download_job_id)
|
||||||
|
session.add(
|
||||||
|
DownloadJobEventRow(
|
||||||
|
download_job_id=row.parent_download_job_id,
|
||||||
|
event_type="warning",
|
||||||
|
message=f"Shard {row.shard_index} abandonado (agent reconectado)",
|
||||||
|
timestamp=now,
|
||||||
|
metadata_json={"shard_id": row.id, "worker_id": worker_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return list(parent_ids)
|
||||||
|
|
||||||
def heartbeat(self, worker_id: str) -> Optional[Worker]:
|
def heartbeat(self, worker_id: str) -> Optional[Worker]:
|
||||||
with session_scope() as session:
|
with session_scope() as session:
|
||||||
@@ -609,6 +672,9 @@ class JobStore:
|
|||||||
w.current_job_id = None
|
w.current_job_id = None
|
||||||
w.status = WorkerStatus.OFFLINE.value
|
w.status = WorkerStatus.OFFLINE.value
|
||||||
elif not w.current_job_id:
|
elif not w.current_job_id:
|
||||||
|
fresh_until = self._fresh_until.get(w.id)
|
||||||
|
if fresh_until is not None and now < fresh_until:
|
||||||
|
continue
|
||||||
online_free.append(w)
|
online_free.append(w)
|
||||||
|
|
||||||
if not offline_ids:
|
if not offline_ids:
|
||||||
|
|||||||
@@ -80,6 +80,32 @@ def test_create_job_requires_worker_and_claim_is_atomic(store):
|
|||||||
assert other is None
|
assert other is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reregister_abandons_leftover_job(store):
|
||||||
|
job_store, JobStatus, _WorkerStatus = store
|
||||||
|
job_store.register_worker("PC-01")
|
||||||
|
job = job_store.create_job(
|
||||||
|
worker_id="PC-01",
|
||||||
|
excel_key="uploads/x/input.xlsx",
|
||||||
|
original_filename="datos.xlsx",
|
||||||
|
)
|
||||||
|
job_store.claim_pending("PC-01")
|
||||||
|
|
||||||
|
job_store.register_worker("PC-01")
|
||||||
|
leftover = job_store.get_job(job.id)
|
||||||
|
assert leftover is not None
|
||||||
|
assert leftover.status == JobStatus.CANCELLED.value
|
||||||
|
assert job_store.claim_pending("PC-01") is None
|
||||||
|
|
||||||
|
fresh = job_store.create_job(
|
||||||
|
worker_id="PC-01",
|
||||||
|
excel_key="uploads/y/input.xlsx",
|
||||||
|
original_filename="nuevo.xlsx",
|
||||||
|
)
|
||||||
|
claimed = job_store.claim_pending("PC-01")
|
||||||
|
assert claimed is not None
|
||||||
|
assert claimed.id == fresh.id
|
||||||
|
|
||||||
|
|
||||||
def test_cancel_pending_marks_cancelled(store):
|
def test_cancel_pending_marks_cancelled(store):
|
||||||
job_store, JobStatus, _WorkerStatus = store
|
job_store, JobStatus, _WorkerStatus = store
|
||||||
job_store.register_worker("PC-01")
|
job_store.register_worker("PC-01")
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from infrastructure.database import (
|
|||||||
from infrastructure.storage_minio import reports_prefix
|
from infrastructure.storage_minio import reports_prefix
|
||||||
|
|
||||||
HEARTBEAT_OFFLINE_SECONDS = 45
|
HEARTBEAT_OFFLINE_SECONDS = 45
|
||||||
|
_FRESH_WORKER_GRACE_SECONDS = 45
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso(value: str | None) -> datetime | None:
|
def _parse_iso(value: str | None) -> datetime | None:
|
||||||
@@ -44,6 +45,7 @@ class JobStore:
|
|||||||
def __init__(self, db_path: str | None = None):
|
def __init__(self, db_path: str | None = None):
|
||||||
# db_path ignorado (compat); usa DATABASE_URL.
|
# db_path ignorado (compat); usa DATABASE_URL.
|
||||||
init_db()
|
init_db()
|
||||||
|
self._fresh_until: dict[str, datetime] = {}
|
||||||
|
|
||||||
def _row_worker(self, row: DigitadorWorkerRow) -> Worker:
|
def _row_worker(self, row: DigitadorWorkerRow) -> Worker:
|
||||||
worker = Worker(
|
worker = Worker(
|
||||||
@@ -120,7 +122,67 @@ class JobStore:
|
|||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
session.flush()
|
session.flush()
|
||||||
return self._row_worker(row)
|
parent_ids = self._abandon_assigned_jobs(
|
||||||
|
session,
|
||||||
|
worker_id,
|
||||||
|
now,
|
||||||
|
"Agent reconectado: job anterior abandonado. Lanza una corrida nueva desde el desktop.",
|
||||||
|
)
|
||||||
|
session.flush()
|
||||||
|
worker = self._row_worker(row)
|
||||||
|
self._fresh_until[worker_id] = now + timedelta(seconds=_FRESH_WORKER_GRACE_SECONDS)
|
||||||
|
for parent_id in parent_ids:
|
||||||
|
try:
|
||||||
|
self.refresh_parent_from_shards(parent_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return worker
|
||||||
|
|
||||||
|
def _abandon_assigned_jobs(
|
||||||
|
self, session, worker_id: str, now: datetime, message: str
|
||||||
|
) -> list[str]:
|
||||||
|
"""Cancela pending/running de este worker para que un agent nuevo arranque idle."""
|
||||||
|
rows = session.scalars(
|
||||||
|
select(DigitadorJobRow).where(
|
||||||
|
DigitadorJobRow.worker_id == worker_id,
|
||||||
|
DigitadorJobRow.status.in_((JobStatus.PENDING.value, JobStatus.RUNNING.value)),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
parent_ids: set[str] = set()
|
||||||
|
for row in rows:
|
||||||
|
is_parent = (
|
||||||
|
row.parent_digitador_job_id is None
|
||||||
|
and row.shard_count is not None
|
||||||
|
and int(row.shard_count) > 1
|
||||||
|
)
|
||||||
|
if is_parent:
|
||||||
|
continue
|
||||||
|
row.status = JobStatus.CANCELLED.value
|
||||||
|
row.cancel_requested = True
|
||||||
|
row.finished_at = now
|
||||||
|
row.error = message
|
||||||
|
self._sync_task(session, row, status=JobStatus.CANCELLED.value, error=message)
|
||||||
|
session.add(
|
||||||
|
DigitadorJobEventRow(
|
||||||
|
digitador_job_id=row.id,
|
||||||
|
event_type="warning",
|
||||||
|
message=message,
|
||||||
|
timestamp=now,
|
||||||
|
metadata_json={"abandoned_on_register": True, "worker_id": worker_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if row.parent_digitador_job_id:
|
||||||
|
parent_ids.add(row.parent_digitador_job_id)
|
||||||
|
session.add(
|
||||||
|
DigitadorJobEventRow(
|
||||||
|
digitador_job_id=row.parent_digitador_job_id,
|
||||||
|
event_type="warning",
|
||||||
|
message=f"Shard {row.shard_index} abandonado (agent reconectado)",
|
||||||
|
timestamp=now,
|
||||||
|
metadata_json={"shard_id": row.id, "worker_id": worker_id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return list(parent_ids)
|
||||||
|
|
||||||
def heartbeat(self, worker_id: str) -> Optional[Worker]:
|
def heartbeat(self, worker_id: str) -> Optional[Worker]:
|
||||||
with session_scope() as session:
|
with session_scope() as session:
|
||||||
@@ -609,6 +671,9 @@ class JobStore:
|
|||||||
w.current_job_id = None
|
w.current_job_id = None
|
||||||
w.status = WorkerStatus.OFFLINE.value
|
w.status = WorkerStatus.OFFLINE.value
|
||||||
elif not w.current_job_id:
|
elif not w.current_job_id:
|
||||||
|
fresh_until = self._fresh_until.get(w.id)
|
||||||
|
if fresh_until is not None and now < fresh_until:
|
||||||
|
continue
|
||||||
online_free.append(w)
|
online_free.append(w)
|
||||||
|
|
||||||
if not offline_ids:
|
if not offline_ids:
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ async function downloadAndInstall(manifest, onProgress) {
|
|||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
app.quit();
|
app.quit();
|
||||||
}, 400);
|
}, 2500);
|
||||||
|
|
||||||
return { ok: true, destPath };
|
return { ok: true, destPath };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ipsextractor-desktop",
|
"name": "ipsextractor-desktop",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Desktop extraction client for IPSExtractor microservices.",
|
"description": "Desktop extraction client for IPSExtractor microservices.",
|
||||||
"main": "electron/main.cjs",
|
"main": "electron/main.cjs",
|
||||||
|
|||||||
@@ -244,7 +244,13 @@ export function useAutoUpdateCheck() {
|
|||||||
try {
|
try {
|
||||||
await window.desktopApi.installUpdate(manifest);
|
await window.desktopApi.installUpdate(manifest);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e));
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
if (msg.toLowerCase().includes("aborted")) {
|
||||||
|
setProgress({ phase: "installing", percent: 100 });
|
||||||
|
setCheckMessage("El instalador se inició. Si la app no se cierra sola, vuelve a abrirla después de unos segundos.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(msg);
|
||||||
setInstalling(false);
|
setInstalling(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
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 type {
|
import type {
|
||||||
DigitadorJob,
|
DigitadorJob,
|
||||||
DigitadorJobEvent,
|
DigitadorJobEvent,
|
||||||
@@ -305,21 +306,107 @@ export function DigitadorWizardModule({ client, job, initialSource = null }: Pro
|
|||||||
<div className="wiz-body">
|
<div className="wiz-body">
|
||||||
{step === 1 && (
|
{step === 1 && (
|
||||||
<div className="wiz-step">
|
<div className="wiz-step">
|
||||||
<div className="wiz-header" style={{ marginBottom: 4 }}>
|
<div className="wiz-section">
|
||||||
<h2 style={{ display: "flex", alignItems: "center", gap: 8, margin: 0 }}>
|
<div className="wiz-section-header">
|
||||||
<Keyboard size={18} />
|
<h3 className="wiz-section-title">
|
||||||
Bot digitador
|
Bots disponibles
|
||||||
</h2>
|
{onlineWorkers.length > 0 && (
|
||||||
<p className="wiz-desc" style={{ marginTop: 6 }}>
|
<span className="wiz-count-badge">{onlineWorkers.length} online</span>
|
||||||
Digita en Integración Total solo los <strong>aptos (PASO_BOT)</strong>, o un CSV
|
)}
|
||||||
propio con el mismo formato.
|
</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 CSV se reparte automáticamente; los reportes RESULTADOS /
|
||||||
|
OMITIDOS quedan en un solo resultado.
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
{/* 1. Origen */}
|
|
||||||
<div className="wiz-section">
|
<div className="wiz-section">
|
||||||
<h3 className="wiz-section-title">1. Origen</h3>
|
<h3 className="wiz-section-title">Origen del CSV</h3>
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginBottom: 14 }}>
|
<p className="wiz-desc">
|
||||||
|
Solo aptos (PASO_BOT) de población apta, o un CSV propio con el mismo formato.
|
||||||
|
</p>
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, marginTop: 8, marginBottom: 14 }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`btn${sourceMode === "aptos" ? " btn-primary" : ""}`}
|
className={`btn${sourceMode === "aptos" ? " btn-primary" : ""}`}
|
||||||
@@ -385,9 +472,10 @@ export function DigitadorWizardModule({ client, job, initialSource = null }: Pro
|
|||||||
>
|
>
|
||||||
{paJobs.map((pa) => {
|
{paJobs.map((pa) => {
|
||||||
const n = Number(pa.summary?.paso_bot ?? 0);
|
const n = Number(pa.summary?.paso_bot ?? 0);
|
||||||
|
const when = formatDateTime(pa.finished_at || pa.created_at);
|
||||||
return (
|
return (
|
||||||
<option key={pa.id} value={pa.id}>
|
<option key={pa.id} value={pa.id}>
|
||||||
{pa.id.slice(0, 8)} · {n} aptos · {pa.summary?.total ?? 0} total
|
{when} · {n} aptos · {pa.summary?.total ?? 0} total · {pa.id.slice(0, 8)}
|
||||||
</option>
|
</option>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -416,7 +504,7 @@ export function DigitadorWizardModule({ client, job, initialSource = null }: Pro
|
|||||||
: "Sin aptos en esta corrida"}
|
: "Sin aptos en esta corrida"}
|
||||||
</div>
|
</div>
|
||||||
<div className="wiz-hint" style={{ margin: 0 }}>
|
<div className="wiz-hint" style={{ margin: 0 }}>
|
||||||
Solo se envían filas PASO_BOT. Los no aptos no se digitan.
|
Solo se envían filas PASO_BOT.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -462,115 +550,6 @@ export function DigitadorWizardModule({ client, job, initialSource = null }: Pro
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2. Bots */}
|
|
||||||
<div className="wiz-section">
|
|
||||||
<div className="wiz-section-header">
|
|
||||||
<h3 className="wiz-section-title">
|
|
||||||
2. Bots
|
|
||||||
{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">
|
|
||||||
El agent debe estar encendido en la PC con Integración Total. Con varios bots, las
|
|
||||||
filas se reparte automáticamente.
|
|
||||||
</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 ? (
|
|
||||||
<div className="wiz-estimate-box" style={{ marginTop: 12, textAlign: "left" }}>
|
|
||||||
<div style={{ display: "flex", alignItems: "flex-start", gap: 10 }}>
|
|
||||||
<Bot size={18} style={{ flexShrink: 0, marginTop: 2 }} />
|
|
||||||
<div>
|
|
||||||
<div style={{ fontWeight: 600 }}>Ningún bot conectado</div>
|
|
||||||
<p className="wiz-hint" style={{ margin: "4px 0 0" }}>
|
|
||||||
En la PC del digitador ejecuta <code>run_gui.py</code> del agent y conéctalo
|
|
||||||
al gateway.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<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.45,
|
|
||||||
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" style={{ marginTop: 8 }}>
|
|
||||||
Hay workers registrados pero ninguno online. Revisa el agent.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{effectiveTargets.length > 1 && (
|
|
||||||
<p className="wiz-hint" style={{ marginTop: 8 }}>
|
|
||||||
Se repartirá entre {effectiveTargets.length} bots.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user