updates :)
This commit is contained in:
+20
-13
@@ -1,7 +1,9 @@
|
||||
"""FastAPI admin application."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
@@ -18,24 +20,29 @@ app.include_router(router)
|
||||
_STATIC = Path(__file__).parent / "static"
|
||||
app.mount("/static", StaticFiles(directory=str(_STATIC)), name="static")
|
||||
|
||||
_rq_worker: subprocess.Popen[bytes] | None = None
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup() -> None:
|
||||
global _rq_worker
|
||||
init_db()
|
||||
_start_huey_consumer()
|
||||
redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
|
||||
_rq_worker = subprocess.Popen(
|
||||
[sys.executable, "-m", "rq", "worker", "--url", redis_url, "batch"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
|
||||
def _start_huey_consumer() -> None:
|
||||
from huey.consumer import Consumer
|
||||
from crawler.tasks import huey
|
||||
|
||||
class _ThreadConsumer(Consumer):
|
||||
def _set_signal_handlers(self) -> None:
|
||||
pass # signal.signal() only works on the main thread
|
||||
|
||||
consumer = _ThreadConsumer(huey, workers=1, periodic=False)
|
||||
t = threading.Thread(target=consumer.run, daemon=True, name="huey-consumer")
|
||||
t.start()
|
||||
@app.on_event("shutdown")
|
||||
def on_shutdown() -> None:
|
||||
if _rq_worker is not None:
|
||||
_rq_worker.terminate()
|
||||
try:
|
||||
_rq_worker.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
_rq_worker.kill()
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
+1
-9
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from db.connection import DB_PATH as DB_PATH, init_db as init_db
|
||||
from db.repositories import flow_config_repo, runs_repo
|
||||
from db.repositories import flow_config_repo as flow_config_repo, runs_repo
|
||||
|
||||
FlowConfigRow = dict[str, Any]
|
||||
|
||||
@@ -36,10 +36,6 @@ async def get_flow_config(cfg_id: int) -> FlowConfigRow | None:
|
||||
return await flow_config_repo.get(cfg_id)
|
||||
|
||||
|
||||
async def get_active_flow_config() -> FlowConfigRow | None:
|
||||
return await flow_config_repo.get_active()
|
||||
|
||||
|
||||
async def upsert_flow_config(data: dict[str, Any], cfg_id: int | None = None) -> int:
|
||||
if cfg_id is None:
|
||||
return await flow_config_repo.create(data)
|
||||
@@ -49,7 +45,3 @@ async def upsert_flow_config(data: dict[str, Any], cfg_id: int | None = None) ->
|
||||
|
||||
async def delete_flow_config(cfg_id: int) -> None:
|
||||
await flow_config_repo.delete(cfg_id)
|
||||
|
||||
|
||||
async def activate_flow_config(cfg_id: int) -> None:
|
||||
await flow_config_repo.activate(cfg_id)
|
||||
|
||||
+74
-29
@@ -2,7 +2,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
@@ -10,7 +9,6 @@ from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from pydantic import BaseModel
|
||||
|
||||
from admin.models import (
|
||||
activate_flow_config,
|
||||
delete_flow_config,
|
||||
get_flow_config,
|
||||
get_stats,
|
||||
@@ -56,7 +54,6 @@ async def stats(_: Auth) -> dict[str, object]:
|
||||
|
||||
class FlowConfigIn(BaseModel):
|
||||
name: str
|
||||
is_active: bool = False
|
||||
search_texts: list[str] = []
|
||||
search_box_selector: str = ""
|
||||
search_box_selector_type: str = "css"
|
||||
@@ -83,7 +80,6 @@ class FlowConfigIn(BaseModel):
|
||||
def _to_db_dict(body: FlowConfigIn) -> dict[str, Any]:
|
||||
return {
|
||||
"name": body.name,
|
||||
"is_active": int(body.is_active),
|
||||
"search_texts_json": body.search_texts,
|
||||
"search_box_selector": body.search_box_selector,
|
||||
"search_box_selector_type": body.search_box_selector_type,
|
||||
@@ -138,15 +134,6 @@ async def update_config(cfg_id: int, body: FlowConfigIn, _: Auth) -> dict[str, o
|
||||
return row or {} # type: ignore[return-value]
|
||||
|
||||
|
||||
@router.post("/api/flow-configs/{cfg_id}/activate", status_code=200)
|
||||
async def activate_config(cfg_id: int, _: Auth) -> dict[str, object]:
|
||||
existing = await get_flow_config(cfg_id)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
await activate_flow_config(cfg_id)
|
||||
return {"activated": cfg_id}
|
||||
|
||||
|
||||
@router.delete("/api/flow-configs/{cfg_id}", status_code=204)
|
||||
async def delete_config(cfg_id: int, _: Auth) -> None:
|
||||
existing = await get_flow_config(cfg_id)
|
||||
@@ -159,32 +146,42 @@ async def delete_config(cfg_id: int, _: Auth) -> None:
|
||||
# Batch runs (managed via Huey task queue)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BatchRunIn(BaseModel):
|
||||
class BatchTaskIn(BaseModel):
|
||||
config_id: int
|
||||
workers: int = 3
|
||||
total_runs: int = 10
|
||||
stagger_ms: int = 500
|
||||
headless: bool = True
|
||||
|
||||
|
||||
@router.post("/api/run-batch", status_code=202)
|
||||
async def start_batch(body: BatchRunIn, _: Auth) -> dict[str, object]:
|
||||
from crawler.tasks import batch_status as _status, run_batch_task, set_current
|
||||
@router.post("/api/batch-queue", status_code=202)
|
||||
async def add_to_batch_queue(body: BatchTaskIn, _: Auth) -> dict[str, object]:
|
||||
from crawler.tasks import enqueue
|
||||
|
||||
current = _status()
|
||||
if current.get("running"):
|
||||
raise HTTPException(status_code=409, detail="A batch is already running")
|
||||
cfg = await get_flow_config(body.config_id)
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=404, detail=f"Flow config {body.config_id} not found")
|
||||
|
||||
from admin.models import get_active_flow_config
|
||||
active_cfg = await get_active_flow_config()
|
||||
if not active_cfg:
|
||||
raise HTTPException(status_code=422, detail="No active flow config — activate one first")
|
||||
result = enqueue(cfg, body.workers, body.total_runs, body.stagger_ms, body.headless)
|
||||
log.info("Task enqueued — config=%d runs=%d workers=%d status=%s",
|
||||
body.config_id, body.total_runs, body.workers, result["status"])
|
||||
return result
|
||||
|
||||
batch_id = uuid.uuid4().hex
|
||||
result = run_batch_task(batch_id, active_cfg, body.workers, body.total_runs, body.headless, body.stagger_ms)
|
||||
set_current(batch_id, result)
|
||||
|
||||
log.info("Batch %s enqueued — %d runs, %d workers", batch_id[:8], body.total_runs, body.workers)
|
||||
return {"batch_id": batch_id, "status": "queued"}
|
||||
@router.delete("/api/batch-queue/{queue_id}", status_code=200)
|
||||
async def remove_from_batch_queue(queue_id: str, _: Auth) -> dict[str, object]:
|
||||
from crawler.tasks import dequeue
|
||||
removed = dequeue(queue_id)
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail="Queue item not found")
|
||||
return {"removed": queue_id}
|
||||
|
||||
|
||||
@router.post("/api/batch-queue/clear", status_code=200)
|
||||
async def clear_batch_queue(_: Auth) -> dict[str, object]:
|
||||
from crawler.tasks import clear_queue
|
||||
count = clear_queue()
|
||||
return {"cleared": count}
|
||||
|
||||
|
||||
@router.post("/api/stop-batch", status_code=200)
|
||||
@@ -200,3 +197,51 @@ async def stop_batch(_: Auth) -> dict[str, object]:
|
||||
async def batch_status(_: Auth) -> dict[str, object]:
|
||||
from crawler.tasks import batch_status as _status
|
||||
return _status() # type: ignore[return-value]
|
||||
|
||||
|
||||
@router.get("/api/batch-runs")
|
||||
async def list_batch_runs(_: Auth) -> list[dict[str, object]]:
|
||||
from db.repositories.batch_runs import batch_runs_repo
|
||||
return await batch_runs_repo.list() # type: ignore[return-value]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log viewer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/logs")
|
||||
def get_logs(
|
||||
_: Auth,
|
||||
n: int = 200,
|
||||
level: str = "",
|
||||
q: str = "",
|
||||
) -> dict[str, object]:
|
||||
import os
|
||||
from logger import _LOG_FILE
|
||||
|
||||
path = _LOG_FILE
|
||||
if not os.path.exists(path):
|
||||
return {"lines": [], "file": path}
|
||||
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
all_lines = f.readlines()
|
||||
|
||||
all_lines.reverse() # newest first
|
||||
|
||||
level_upper = level.upper()
|
||||
q_lower = q.lower()
|
||||
|
||||
results: list[str] = []
|
||||
for raw in all_lines:
|
||||
line = raw.rstrip()
|
||||
if not line:
|
||||
continue
|
||||
if level_upper and level_upper not in line:
|
||||
continue
|
||||
if q_lower and q_lower not in line.lower():
|
||||
continue
|
||||
results.append(line)
|
||||
if len(results) >= n:
|
||||
break
|
||||
|
||||
return {"lines": results, "file": path}
|
||||
|
||||
+303
-48
@@ -56,7 +56,7 @@
|
||||
.pill { display: inline-block; border-radius: 999px; padding: 2px 10px; font-size: 11px; font-weight: 600; }
|
||||
.pill.ok { background: rgba(34,197,94,.15); color: var(--green); }
|
||||
.pill.err { background: rgba(239,68,68,.15); color: var(--red); }
|
||||
.pill.active-badge { background: rgba(99,102,241,.2); color: var(--accent); }
|
||||
.pill.id-badge { background: rgba(99,102,241,.12); color: var(--muted); font-family: monospace; }
|
||||
|
||||
.reason-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); }
|
||||
.reason-row:last-child { border-bottom: none; }
|
||||
@@ -83,7 +83,7 @@
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 16px 18px; display: flex; align-items: flex-start; gap: 14px;
|
||||
}
|
||||
.config-card.is-active { border-color: var(--accent); }
|
||||
.config-card { cursor: default; }
|
||||
.config-info { flex: 1; }
|
||||
.config-name { font-weight: 600; font-size: 15px; margin-bottom: 4px; display: flex; align-items: center; gap: 8px; }
|
||||
.config-meta { color: var(--muted); font-size: 12px; }
|
||||
@@ -188,12 +188,6 @@
|
||||
<summary>Advanced Settings</summary>
|
||||
<div class="adv-body">
|
||||
|
||||
<div class="fg">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||
<input id="cfg-active" type="checkbox" style="width:auto" /> Set as active config
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-section">Search Step</div>
|
||||
<div class="form-row">
|
||||
<div class="fg">
|
||||
@@ -313,6 +307,7 @@
|
||||
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||
<div class="tab" data-tab="flows">Flow Configs</div>
|
||||
<div class="tab" data-tab="batch">Batch Run</div>
|
||||
<div class="tab" data-tab="logs">Logs</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Dashboard Tab ──────────────────────────────────────────── -->
|
||||
@@ -355,7 +350,7 @@
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
|
||||
<div>
|
||||
<div style="font-size:16px;font-weight:600;margin-bottom:4px">Flow Configurations</div>
|
||||
<div style="color:var(--muted);font-size:13px">Only one config is active at a time. The crawler picks it up on every run.</div>
|
||||
<div style="color:var(--muted);font-size:13px">Select a config in the Batch Run tab to use it for a run.</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button class="btn btn-ghost" id="btn-import-config">↑ Import</button>
|
||||
@@ -374,13 +369,22 @@
|
||||
<div class="tab-panel" id="tab-batch">
|
||||
<main>
|
||||
<div style="font-size:16px;font-weight:600;margin-bottom:4px">Batch Run</div>
|
||||
<div style="color:var(--muted);font-size:13px;margin-bottom:24px">Launch many parallel crawler sessions using the active flow config.</div>
|
||||
<div style="color:var(--muted);font-size:13px;margin-bottom:24px">Launch many parallel crawler sessions using a selected flow config.</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:320px 1fr;gap:20px;max-width:1100px">
|
||||
<div style="display:grid;grid-template-columns:320px 1fr;gap:20px;max-width:1100px;align-items:start">
|
||||
|
||||
<!-- Left column: controls + queue -->
|
||||
<div style="display:flex;flex-direction:column;gap:16px">
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="panel" style="align-self:start">
|
||||
<div class="panel">
|
||||
<div class="section-title">Configuration</div>
|
||||
<div class="fg" style="margin-bottom:14px">
|
||||
<label>Flow Config</label>
|
||||
<select id="batch-config-select" style="width:100%">
|
||||
<option value="">— select a config —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="fg" style="margin-bottom:14px">
|
||||
<label>Workers <span style="color:var(--muted)">(parallel Chrome instances)</span></label>
|
||||
<input id="batch-workers" type="number" min="1" max="50" value="3" />
|
||||
@@ -398,20 +402,37 @@
|
||||
<label for="batch-headless" style="cursor:pointer;margin:0">Headless</label>
|
||||
</div>
|
||||
<div style="display:flex;gap:10px">
|
||||
<button class="btn btn-primary" id="batch-start-btn" style="flex:1">▶ Start</button>
|
||||
<button class="btn btn-primary" id="batch-start-btn" style="flex:1">+ Add to Queue</button>
|
||||
<button class="btn btn-danger" id="batch-stop-btn" style="flex:1;display:none">■ Stop</button>
|
||||
</div>
|
||||
<div id="batch-error" style="color:var(--red);font-size:12px;margin-top:10px;display:none"></div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Queue -->
|
||||
<div class="panel" id="queue-panel">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
|
||||
<div class="section-title" style="margin:0">Pending Queue <span id="queue-count-badge" style="background:var(--accent);color:#fff;border-radius:10px;padding:1px 7px;font-size:11px;margin-left:6px;display:none"></span></div>
|
||||
<button class="btn btn-ghost btn-sm" id="queue-clear-btn" style="display:none">Clear All</button>
|
||||
</div>
|
||||
<div id="queue-list">
|
||||
<p class="empty" style="font-size:12px">Queue is empty.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div> <!-- end left column -->
|
||||
|
||||
<!-- Status -->
|
||||
<div class="panel">
|
||||
<div class="panel" style="overflow-y:auto;max-height:680px">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:14px">
|
||||
<div class="section-title" style="margin:0">Live Status</div>
|
||||
<span id="bs-active-badge" style="display:none;font-size:12px;color:var(--accent);font-weight:600"></span>
|
||||
</div>
|
||||
<div id="batch-idle-msg" style="color:var(--muted);font-size:13px">No batch running.</div>
|
||||
<div id="batch-status-body" style="display:none">
|
||||
|
||||
<!-- Completion banner -->
|
||||
<div id="bs-completion-banner" style="display:none;border-radius:7px;padding:8px 12px;font-size:12px;font-weight:600;margin-bottom:14px"></div>
|
||||
|
||||
<div style="margin-bottom:16px">
|
||||
<div style="display:flex;justify-content:space-between;margin-bottom:6px">
|
||||
<span style="color:var(--muted);font-size:12px">Progress</span>
|
||||
@@ -436,27 +457,89 @@
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--muted);margin-bottom:14px">
|
||||
Config: <strong id="bs-config-name">–</strong> ·
|
||||
Workers: <strong id="bs-workers">–</strong> ·
|
||||
Started: <strong id="bs-started">–</strong>
|
||||
<span id="bs-finished-row" style="display:none"> · Finished: <strong id="bs-finished">–</strong></span>
|
||||
</div>
|
||||
|
||||
<!-- Per-worker table -->
|
||||
<!-- Per-worker table with scroll -->
|
||||
<div class="section-title">Workers</div>
|
||||
<table id="bs-worker-table" style="font-size:12px">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Status</th>
|
||||
<th>Duration</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bs-worker-tbody"></tbody>
|
||||
</table>
|
||||
<div style="max-height:320px;overflow-y:auto;border:1px solid var(--border);border-radius:7px">
|
||||
<table id="bs-worker-table" style="font-size:12px">
|
||||
<thead style="position:sticky;top:0;background:var(--surface);z-index:1">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Status</th>
|
||||
<th>Duration</th>
|
||||
<th>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bs-worker-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Batch run history -->
|
||||
<div class="panel" style="margin-top:20px;max-width:1100px">
|
||||
<div class="section-title" style="margin-bottom:14px">Run History</div>
|
||||
<div style="overflow-x:auto">
|
||||
<table id="batch-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Started</th>
|
||||
<th>Config</th>
|
||||
<th>Workers</th>
|
||||
<th>Total</th>
|
||||
<th>Succeeded</th>
|
||||
<th>Failed</th>
|
||||
<th>Duration</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="batch-history-tbody">
|
||||
<tr><td colspan="9" class="empty">No batch runs yet.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ── Logs Tab ───────────────────────────────────────────────── -->
|
||||
<div class="tab-panel" id="tab-logs">
|
||||
<main>
|
||||
<div class="panel" style="margin-bottom:20px">
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:14px">
|
||||
<span class="section-title" style="margin:0">Log Viewer</span>
|
||||
<select id="log-level" style="background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px">
|
||||
<option value="">All levels</option>
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
</select>
|
||||
<input id="log-search" type="text" placeholder="Filter…" style="background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 10px;font-size:12px;width:200px" />
|
||||
<select id="log-n" style="background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 8px;font-size:12px">
|
||||
<option value="100">100 lines</option>
|
||||
<option value="200" selected>200 lines</option>
|
||||
<option value="500">500 lines</option>
|
||||
<option value="1000">1000 lines</option>
|
||||
</select>
|
||||
<button class="btn" onclick="loadLogs()" style="padding:4px 14px;font-size:12px">↻ Refresh</button>
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);cursor:pointer">
|
||||
<input type="checkbox" id="log-tail" checked style="accent-color:var(--accent)" /> Auto-refresh (5s)
|
||||
</label>
|
||||
<span id="log-file" style="color:var(--muted);font-size:11px;margin-left:auto"></span>
|
||||
</div>
|
||||
<div id="log-output" style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:14px;height:68vh;overflow-y:auto;font-family:monospace;font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-all">
|
||||
<span style="color:var(--muted)">Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -548,7 +631,9 @@ document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.classList.add('active');
|
||||
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');
|
||||
if (tab.dataset.tab === 'flows') loadConfigs();
|
||||
if (tab.dataset.tab === 'batch') pollBatchStatus();
|
||||
if (tab.dataset.tab === 'batch') { loadConfigs(); pollBatchStatus(); loadBatchHistory(); }
|
||||
if (tab.dataset.tab === 'logs') { loadLogs(); _startLogPoll(); }
|
||||
else _stopLogPoll();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -616,6 +701,7 @@ async function loadConfigs() {
|
||||
if (!res || !res.ok) return;
|
||||
_configs = await res.json();
|
||||
renderConfigs();
|
||||
_refreshBatchConfigSelect();
|
||||
}
|
||||
|
||||
function renderConfigs() {
|
||||
@@ -625,11 +711,11 @@ function renderConfigs() {
|
||||
return;
|
||||
}
|
||||
el.innerHTML = _configs.map(c => `
|
||||
<div class="config-card ${c.is_active ? 'is-active' : ''}" id="cc-${c.id}">
|
||||
<div class="config-card" id="cc-${c.id}">
|
||||
<div class="config-info">
|
||||
<div class="config-name">
|
||||
${esc(c.name)}
|
||||
${c.is_active ? '<span class="pill active-badge">ACTIVE</span>' : ''}
|
||||
<span class="pill id-badge">#${c.id}</span>
|
||||
</div>
|
||||
<div class="config-meta">
|
||||
Searches: <strong>${(c.search_texts_json || []).length}</strong> text(s) ·
|
||||
@@ -644,7 +730,6 @@ function renderConfigs() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-actions">
|
||||
${!c.is_active ? `<button class="btn btn-ghost btn-sm" onclick="activateConfig(${c.id})">Activate</button>` : ''}
|
||||
<button class="btn btn-ghost btn-sm" onclick="openEditForm(${c.id})">Edit</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="duplicateConfig(${c.id})">Duplicate</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="exportConfig(${c.id})">↓ Export</button>
|
||||
@@ -654,9 +739,11 @@ function renderConfigs() {
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function activateConfig(id) {
|
||||
const res = await apiFetch(`/api/flow-configs/${id}/activate`, { method: 'POST' });
|
||||
if (res && res.ok) await loadConfigs();
|
||||
function _refreshBatchConfigSelect() {
|
||||
const sel = document.getElementById('batch-config-select');
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = '<option value="">— select a config —</option>' +
|
||||
_configs.map(c => `<option value="${c.id}" ${String(c.id) === cur ? 'selected' : ''}>${esc(c.name)} (#${c.id})</option>`).join('');
|
||||
}
|
||||
|
||||
async function deleteConfig(id) {
|
||||
@@ -705,7 +792,6 @@ async function duplicateConfig(id) {
|
||||
if (!c) return;
|
||||
const body = {
|
||||
name: c.name + ' (copy)',
|
||||
is_active: false,
|
||||
search_texts: c.search_texts_json || [],
|
||||
search_box_selector: c.search_box_selector || '',
|
||||
search_box_selector_type: c.search_box_selector_type || 'css',
|
||||
@@ -743,7 +829,6 @@ document.getElementById('import-file-input').addEventListener('change', async (e
|
||||
try { data = JSON.parse(await file.text()); } catch { alert('Invalid JSON file'); return; }
|
||||
const body = {
|
||||
name: data.name || file.name.replace(/\.json$/, ''),
|
||||
is_active: false,
|
||||
search_texts: data.search_texts || [],
|
||||
search_box_selector: data.search_box_selector || '',
|
||||
search_box_selector_type: data.search_box_selector_type || 'css',
|
||||
@@ -772,7 +857,6 @@ document.getElementById('import-file-input').addEventListener('change', async (e
|
||||
|
||||
/* ─── Form ─── */
|
||||
function _resetAdvanced() {
|
||||
document.getElementById('cfg-active').checked = false;
|
||||
document.getElementById('cfg-search-sel').value = '';
|
||||
document.getElementById('cfg-search-sel-type').value = 'css';
|
||||
document.getElementById('cfg-submit-sel').value = '';
|
||||
@@ -815,7 +899,6 @@ function openEditForm(id) {
|
||||
document.getElementById('cfg-target-url').value = c.target_url || '';
|
||||
document.getElementById('cfg-search-texts').value = (c.search_texts_json || []).join('\n');
|
||||
document.getElementById('cfg-item-ids').value = (c.target_item_ids_json || []).join('\n');
|
||||
document.getElementById('cfg-active').checked = !!c.is_active;
|
||||
document.getElementById('cfg-search-sel').value = c.search_box_selector || '';
|
||||
document.getElementById('cfg-search-sel-type').value = c.search_box_selector_type || 'css';
|
||||
document.getElementById('cfg-submit-sel').value = c.search_submit_selector || '';
|
||||
@@ -847,7 +930,6 @@ function closeForm() {
|
||||
function collectForm() {
|
||||
return {
|
||||
name: document.getElementById('cfg-name').value.trim(),
|
||||
is_active: document.getElementById('cfg-active').checked,
|
||||
search_texts: document.getElementById('cfg-search-texts').value.split('\n').map(s => s.trim()).filter(Boolean),
|
||||
search_box_selector: document.getElementById('cfg-search-sel').value.trim(),
|
||||
search_box_selector_type: document.getElementById('cfg-search-sel-type').value,
|
||||
@@ -939,14 +1021,16 @@ function renderBatchStatus(s) {
|
||||
const idle = document.getElementById('batch-idle-msg');
|
||||
const body = document.getElementById('batch-status-body');
|
||||
const errEl = document.getElementById('batch-error');
|
||||
const startBtn = document.getElementById('batch-start-btn');
|
||||
const stopBtn = document.getElementById('batch-stop-btn');
|
||||
const badge = document.getElementById('bs-active-badge');
|
||||
const banner = document.getElementById('bs-completion-banner');
|
||||
|
||||
// Always render queue
|
||||
renderQueue(s.queue || []);
|
||||
|
||||
if (!s.batch_id) {
|
||||
idle.style.display = '';
|
||||
body.style.display = 'none';
|
||||
startBtn.style.display = '';
|
||||
stopBtn.style.display = 'none';
|
||||
badge.style.display = 'none';
|
||||
return;
|
||||
@@ -956,11 +1040,16 @@ function renderBatchStatus(s) {
|
||||
body.style.display = '';
|
||||
|
||||
const pct = s.total ? Math.round(s.completed / s.total * 100) : 0;
|
||||
const barColor = !s.running
|
||||
? (s.stopping ? 'var(--yellow)' : s.error ? 'var(--red)' : 'var(--green)')
|
||||
: 'var(--accent)';
|
||||
document.getElementById('batch-progress-bar').style.background = barColor;
|
||||
document.getElementById('batch-progress-bar').style.width = pct + '%';
|
||||
document.getElementById('batch-progress-label').textContent = `${s.completed} / ${s.total}`;
|
||||
document.getElementById('bs-completed').textContent = s.completed ?? 0;
|
||||
document.getElementById('bs-succeeded').textContent = s.succeeded ?? 0;
|
||||
document.getElementById('bs-failed').textContent = s.failed ?? 0;
|
||||
document.getElementById('bs-config-name').textContent = s.config_name || '–';
|
||||
document.getElementById('bs-workers').textContent = s.configured_workers ?? '–';
|
||||
document.getElementById('bs-started').textContent = fmtTs(s.started_at);
|
||||
|
||||
@@ -972,6 +1061,23 @@ function renderBatchStatus(s) {
|
||||
finRow.style.display = 'none';
|
||||
}
|
||||
|
||||
// Completion banner
|
||||
if (!s.running && s.finished_at) {
|
||||
let msg, bg, color;
|
||||
if (s.stopping) {
|
||||
msg = '■ Stopped'; bg = 'rgba(234,179,8,.15)'; color = 'var(--yellow)';
|
||||
} else if (s.error) {
|
||||
msg = '✕ Error: ' + s.error; bg = 'rgba(239,68,68,.12)'; color = 'var(--red)';
|
||||
} else {
|
||||
msg = `✓ Batch complete — ${s.succeeded ?? 0} succeeded, ${s.failed ?? 0} failed`;
|
||||
bg = 'rgba(34,197,94,.12)'; color = 'var(--green)';
|
||||
}
|
||||
banner.textContent = msg;
|
||||
banner.style.cssText = `display:block;border-radius:7px;padding:8px 12px;font-size:12px;font-weight:600;margin-bottom:14px;background:${bg};color:${color}`;
|
||||
} else {
|
||||
banner.style.display = 'none';
|
||||
}
|
||||
|
||||
// Active worker badge
|
||||
const active = s.active_workers ?? 0;
|
||||
if (s.running && active > 0) {
|
||||
@@ -992,7 +1098,7 @@ function renderBatchStatus(s) {
|
||||
<td style="font-family:monospace">${w.slot}</td>
|
||||
<td style="color:${color};font-weight:600">${label}</td>
|
||||
<td>${fmtDuration(w.duration_ms)}</td>
|
||||
<td style="max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${err}</td>
|
||||
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${err}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
@@ -1004,16 +1110,52 @@ function renderBatchStatus(s) {
|
||||
}
|
||||
|
||||
if (s.running) {
|
||||
startBtn.style.display = 'none';
|
||||
stopBtn.style.display = '';
|
||||
stopBtn.disabled = s.stopping || false;
|
||||
stopBtn.textContent = s.stopping ? '■ Stopping…' : '■ Stop';
|
||||
} else {
|
||||
startBtn.style.display = '';
|
||||
stopBtn.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function renderQueue(queue) {
|
||||
const list = document.getElementById('queue-list');
|
||||
const badge = document.getElementById('queue-count-badge');
|
||||
const clearBtn = document.getElementById('queue-clear-btn');
|
||||
|
||||
if (!queue.length) {
|
||||
list.innerHTML = '<p class="empty" style="font-size:12px">Queue is empty.</p>';
|
||||
badge.style.display = 'none';
|
||||
clearBtn.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
badge.textContent = queue.length;
|
||||
badge.style.display = '';
|
||||
clearBtn.style.display = '';
|
||||
|
||||
list.innerHTML = queue.map((t, i) => `
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid var(--border)">
|
||||
<span style="color:var(--muted);font-size:11px;width:18px;text-align:right">${i + 1}</span>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:600;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(t.config_name || '–')}</div>
|
||||
<div style="color:var(--muted);font-size:11px">${t.workers}w · ${t.total_runs} runs · ${t.stagger_ms}ms stagger</div>
|
||||
</div>
|
||||
<button class="btn btn-danger btn-sm" onclick="removeQueueItem('${t.queue_id}')">✕</button>
|
||||
</div>
|
||||
`).join('').replace(/<div[^>]*>.*?<\/div>\s*$/, s => s.replace('border-bottom:1px solid var(--border)', ''));
|
||||
}
|
||||
|
||||
async function removeQueueItem(queueId) {
|
||||
const res = await apiFetch(`/api/batch-queue/${queueId}`, { method: 'DELETE' });
|
||||
if (res && res.ok) await pollBatchStatus();
|
||||
}
|
||||
|
||||
document.getElementById('queue-clear-btn').addEventListener('click', async () => {
|
||||
const res = await apiFetch('/api/batch-queue/clear', { method: 'POST' });
|
||||
if (res && res.ok) await pollBatchStatus();
|
||||
});
|
||||
|
||||
async function pollBatchStatus() {
|
||||
const res = await apiFetch('/api/batch-status');
|
||||
if (!res || !res.ok) return;
|
||||
@@ -1032,27 +1174,31 @@ function startBatchPoll() {
|
||||
if (!st.running) {
|
||||
clearInterval(_batchPollTimer);
|
||||
_batchPollTimer = null;
|
||||
// Delay history reload to allow the DB finish() write to complete
|
||||
setTimeout(loadBatchHistory, 2000);
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
document.getElementById('batch-start-btn').addEventListener('click', async () => {
|
||||
const configId = parseInt(document.getElementById('batch-config-select').value);
|
||||
if (!configId) { alert('Please select a flow config.'); return; }
|
||||
const workers = parseInt(document.getElementById('batch-workers').value) || 3;
|
||||
const runs = parseInt(document.getElementById('batch-runs').value) || 10;
|
||||
const stagger = parseInt(document.getElementById('batch-stagger').value) || 0;
|
||||
const headless = document.getElementById('batch-headless').checked;
|
||||
|
||||
const res = await apiFetch('/api/run-batch', {
|
||||
const res = await apiFetch('/api/batch-queue', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workers, total_runs: runs, stagger_ms: stagger, headless }),
|
||||
body: JSON.stringify({ config_id: configId, workers, total_runs: runs, stagger_ms: stagger, headless }),
|
||||
});
|
||||
|
||||
if (!res) return;
|
||||
if (res.status === 409) { alert('A batch is already running.'); return; }
|
||||
if (res.status === 422) { const d = await res.json(); alert(d.detail || 'No active flow config.'); return; }
|
||||
if (!res.ok) { alert('Failed to start batch.'); return; }
|
||||
if (res.status === 404) { const d = await res.json(); alert(d.detail || 'Flow config not found.'); return; }
|
||||
if (!res.ok) { alert('Failed to add task.'); return; }
|
||||
|
||||
const data = await res.json();
|
||||
await pollBatchStatus();
|
||||
startBatchPoll();
|
||||
});
|
||||
@@ -1067,6 +1213,115 @@ document.getElementById('batch-stop-btn').addEventListener('click', async () =>
|
||||
btn.textContent = '■ Stop';
|
||||
}
|
||||
});
|
||||
|
||||
/* ──────────────────────── Batch Run History ────────────────────── */
|
||||
async function loadBatchHistory() {
|
||||
const res = await apiFetch('/api/batch-runs');
|
||||
if (!res || !res.ok) return;
|
||||
renderBatchHistory(await res.json());
|
||||
}
|
||||
|
||||
/* ─────────────────────────── Logs ──────────────────────────── */
|
||||
let _logPollTimer = null;
|
||||
|
||||
const _LEVEL_COLORS = {
|
||||
DEBUG: '#8892a4',
|
||||
INFO: '#6ee7b7',
|
||||
WARNING: '#eab308',
|
||||
ERROR: '#ef4444',
|
||||
CRITICAL: '#f97316',
|
||||
};
|
||||
|
||||
function _colorLine(line) {
|
||||
for (const [lvl, col] of Object.entries(_LEVEL_COLORS)) {
|
||||
if (line.includes(lvl)) {
|
||||
return `<span style="color:${col}">${esc(line)}</span>`;
|
||||
}
|
||||
}
|
||||
return `<span style="color:var(--muted)">${esc(line)}</span>`;
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
const level = document.getElementById('log-level').value;
|
||||
const q = document.getElementById('log-search').value;
|
||||
const n = document.getElementById('log-n').value;
|
||||
const params = new URLSearchParams({ n, level, q });
|
||||
const res = await apiFetch(`/api/logs?${params}`);
|
||||
if (!res || !res.ok) return;
|
||||
const data = await res.json();
|
||||
document.getElementById('log-file').textContent = data.file || '';
|
||||
const out = document.getElementById('log-output');
|
||||
if (!data.lines.length) {
|
||||
out.innerHTML = '<span style="color:var(--muted)">No log entries.</span>';
|
||||
return;
|
||||
}
|
||||
out.innerHTML = data.lines.map(_colorLine).join('\n');
|
||||
}
|
||||
|
||||
function _startLogPoll() {
|
||||
_stopLogPoll();
|
||||
_logPollTimer = setInterval(() => {
|
||||
if (document.getElementById('log-tail').checked) loadLogs();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function _stopLogPoll() {
|
||||
if (_logPollTimer) { clearInterval(_logPollTimer); _logPollTimer = null; }
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('log-tail').addEventListener('change', e => {
|
||||
if (e.target.checked) _startLogPoll(); else _stopLogPoll();
|
||||
});
|
||||
['log-level', 'log-n'].forEach(id =>
|
||||
document.getElementById(id).addEventListener('change', loadLogs)
|
||||
);
|
||||
let _debounce;
|
||||
document.getElementById('log-search').addEventListener('input', () => {
|
||||
clearTimeout(_debounce);
|
||||
_debounce = setTimeout(loadLogs, 300);
|
||||
});
|
||||
});
|
||||
|
||||
/* ──────────────────────── Batch Run History ────────────────────── */
|
||||
function renderBatchHistory(rows) {
|
||||
const tbody = document.getElementById('batch-history-tbody');
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="9" class="empty">No batch runs yet.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = rows.map(r => {
|
||||
const dur = r.finished_at && r.started_at
|
||||
? fmtDuration((new Date(r.finished_at) - new Date(r.started_at)))
|
||||
: '–';
|
||||
let statusLabel, statusColor;
|
||||
if (!r.finished_at) {
|
||||
statusLabel = '⟳ running'; statusColor = 'var(--accent)';
|
||||
} else if (r.stopped) {
|
||||
statusLabel = '■ stopped'; statusColor = 'var(--yellow)';
|
||||
} else if (r.error) {
|
||||
statusLabel = '✕ error'; statusColor = 'var(--red)';
|
||||
} else if (r.failed > 0 && r.succeeded === 0) {
|
||||
statusLabel = 'all failed'; statusColor = 'var(--red)';
|
||||
} else {
|
||||
statusLabel = '✓ done'; statusColor = 'var(--green)';
|
||||
}
|
||||
const configLabel = r.config_name
|
||||
? `${esc(r.config_name)} <span style="color:var(--muted);font-size:11px">#${r.config_id}</span>`
|
||||
: `<span style="color:var(--muted)">#${r.config_id ?? '–'}</span>`;
|
||||
return `<tr>
|
||||
<td style="color:var(--muted);font-family:monospace;font-size:11px">${r.batch_id.slice(0,8)}</td>
|
||||
<td style="white-space:nowrap;color:var(--muted)">${fmtTime(r.started_at)}</td>
|
||||
<td>${configLabel}</td>
|
||||
<td style="text-align:center">${r.workers}</td>
|
||||
<td style="text-align:center">${r.total_runs}</td>
|
||||
<td style="text-align:center;color:var(--green);font-weight:600">${r.succeeded}</td>
|
||||
<td style="text-align:center;color:var(--red);font-weight:600">${r.failed}</td>
|
||||
<td style="color:var(--muted)">${dur}</td>
|
||||
<td style="color:${statusColor};font-weight:600">${statusLabel}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user