1528 lines
73 KiB
HTML
1528 lines
73 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Seed — Admin</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
:root {
|
||
--bg: #0f1117; --surface: #1a1d27; --surface2: #222536;
|
||
--border: #2a2d3e; --text: #e2e8f0; --muted: #8892a4;
|
||
--accent: #6366f1; --green: #22c55e; --red: #ef4444; --yellow: #eab308;
|
||
}
|
||
body { background: var(--bg); color: var(--text); font-family: 'Vazirmatn', 'Inter', system-ui, sans-serif; font-size: 14px; }
|
||
|
||
/* ── Header ─────────────────────────────────── */
|
||
header {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding: 14px 24px; border-bottom: 1px solid var(--border); background: var(--surface);
|
||
position: sticky; top: 0; z-index: 50;
|
||
}
|
||
header h1 { font-size: 17px; font-weight: 600; }
|
||
.badge { background: var(--accent); color: #fff; border-radius: 4px; padding: 2px 8px; font-size: 11px; font-weight: 600; }
|
||
#last-updated { color: var(--muted); font-size: 12px; }
|
||
|
||
/* ── Tabs ────────────────────────────────────── */
|
||
.tabs { display: flex; gap: 2px; padding: 0 24px; background: var(--surface); border-bottom: 1px solid var(--border); }
|
||
.tab { padding: 11px 18px; cursor: pointer; font-size: 13px; font-weight: 500; color: var(--muted); border-bottom: 2px solid transparent; transition: color .15s; }
|
||
.tab:hover { color: var(--text); }
|
||
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
|
||
|
||
.tab-panel { display: none; }
|
||
.tab-panel.active { display: block; }
|
||
|
||
main { padding: 24px; max-width: 1400px; margin: 0 auto; }
|
||
|
||
/* ── Cards ───────────────────────────────────── */
|
||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 16px; margin-bottom: 28px; }
|
||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 18px 16px; }
|
||
.card .label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; margin-bottom: 8px; }
|
||
.card .value { font-size: 30px; font-weight: 700; line-height: 1; }
|
||
.card .value.green { color: var(--green); } .card .value.red { color: var(--red); } .card .value.accent { color: var(--accent); }
|
||
|
||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 28px; }
|
||
@media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } }
|
||
.panel { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 20px; }
|
||
.section-title { font-size: 11px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 14px; }
|
||
|
||
/* ── Tables ──────────────────────────────────── */
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th { text-align: left; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; padding: 8px 10px; border-bottom: 1px solid var(--border); }
|
||
td { padding: 9px 10px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
||
tr:last-child td { border-bottom: none; }
|
||
tr:hover td { background: rgba(255,255,255,.02); }
|
||
|
||
.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.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; }
|
||
.reason-text { color: var(--text); font-size: 13px; flex: 1; margin-right: 12px; word-break: break-all; }
|
||
.reason-count { color: var(--red); font-weight: 700; }
|
||
.breakdown-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); }
|
||
.breakdown-row:last-child { border-bottom: none; }
|
||
.bar-bg { flex: 1; background: var(--border); border-radius: 4px; height: 8px; overflow: hidden; }
|
||
.bar-fill { height: 100%; background: var(--accent); border-radius: 4px; }
|
||
.empty { color: var(--muted); text-align: center; padding: 32px 0; font-size: 13px; }
|
||
|
||
/* ── Buttons ─────────────────────────────────── */
|
||
.btn { border: none; border-radius: 7px; padding: 9px 16px; font-size: 13px; font-weight: 600; cursor: pointer; transition: opacity .15s; }
|
||
.btn:hover { opacity: .85; }
|
||
.btn-primary { background: var(--accent); color: #fff; }
|
||
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||
.btn-danger { background: rgba(239,68,68,.15); color: var(--red); border: 1px solid rgba(239,68,68,.3); }
|
||
.btn-sm { padding: 5px 10px; font-size: 12px; border-radius: 5px; }
|
||
#refresh-btn { background: none; border: 1px solid var(--border); color: var(--text); border-radius: 6px; padding: 6px 12px; cursor: pointer; font-size: 13px; }
|
||
|
||
/* ── Flow Config panel ───────────────────────── */
|
||
.config-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 24px; }
|
||
.config-card {
|
||
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
||
padding: 16px 18px; display: flex; align-items: flex-start; gap: 14px;
|
||
}
|
||
.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; }
|
||
.config-actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
|
||
|
||
/* ── Form ────────────────────────────────────── */
|
||
.form-overlay {
|
||
position: fixed; inset: 0; background: rgba(0,0,0,.65); backdrop-filter: blur(4px);
|
||
display: flex; align-items: flex-start; justify-content: center;
|
||
z-index: 200; overflow-y: auto; padding: 40px 16px;
|
||
}
|
||
.form-overlay.hidden { display: none; }
|
||
.form-box {
|
||
background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
|
||
padding: 32px 28px; width: 680px; max-width: 100%;
|
||
}
|
||
.form-box h2 { font-size: 17px; font-weight: 600; margin-bottom: 24px; }
|
||
.form-section { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .6px; color: var(--accent); margin: 22px 0 12px; }
|
||
details.adv { margin-top: 20px; border: 1px solid var(--border); border-radius: 9px; }
|
||
details.adv > summary {
|
||
list-style: none; cursor: pointer; padding: 11px 14px;
|
||
font-size: 12px; font-weight: 600; color: var(--muted);
|
||
display: flex; align-items: center; gap: 7px; user-select: none;
|
||
}
|
||
details.adv > summary::-webkit-details-marker { display: none; }
|
||
details.adv > summary::before { content: '▶'; font-size: 9px; transition: transform .15s; }
|
||
details.adv[open] > summary::before { transform: rotate(90deg); }
|
||
details.adv > summary:hover { color: var(--text); }
|
||
.adv-body { padding: 4px 14px 14px; }
|
||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||
.fg { margin-bottom: 14px; }
|
||
.fg label { display: block; color: var(--muted); font-size: 12px; margin-bottom: 5px; }
|
||
.fg input, .fg select, .fg textarea {
|
||
width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 7px;
|
||
padding: 9px 11px; color: var(--text); font-size: 13px; font-family: inherit;
|
||
}
|
||
.fg textarea { resize: vertical; min-height: 64px; font-family: 'Vazirmatn', 'Inter', system-ui, sans-serif; }
|
||
.fg input:focus, .fg select:focus, .fg textarea:focus { outline: none; border-color: var(--accent); }
|
||
.fg .hint { color: var(--muted); font-size: 11px; margin-top: 4px; }
|
||
.form-footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; }
|
||
|
||
/* ── Auth modal ──────────────────────────────── */
|
||
#auth-modal {
|
||
position: fixed; inset: 0; background: rgba(0,0,0,.75); backdrop-filter: blur(4px);
|
||
display: flex; align-items: center; justify-content: center; z-index: 300;
|
||
}
|
||
#auth-modal.hidden { display: none; }
|
||
.modal-box { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 36px 32px; width: 320px; }
|
||
.modal-box h2 { font-size: 18px; font-weight: 600; margin-bottom: 24px; }
|
||
.error-msg { color: var(--red); font-size: 12px; margin-top: 8px; text-align: center; }
|
||
.error-msg.hidden { display: none; }
|
||
.remember-row { display: flex; align-items: center; gap: 8px; margin-top: 12px; }
|
||
.remember-row input[type=checkbox] { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; }
|
||
.remember-row label { color: var(--muted); font-size: 13px; cursor: pointer; user-select: none; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- Auth Modal -->
|
||
<div id="auth-modal">
|
||
<div class="modal-box">
|
||
<h2>Seed Admin</h2>
|
||
<div class="fg"><label>Username</label><input id="inp-user" type="text" autocomplete="username" /></div>
|
||
<div class="fg"><label>Password</label><input id="inp-pass" type="password" autocomplete="current-password" /></div>
|
||
<div class="remember-row">
|
||
<input type="checkbox" id="inp-remember" />
|
||
<label for="inp-remember">Remember me</label>
|
||
</div>
|
||
<button class="btn btn-primary" style="width:100%;margin-top:14px" id="login-btn">Sign in</button>
|
||
<p class="error-msg hidden" id="auth-error">Invalid credentials</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Flow Config Form Overlay -->
|
||
<div class="form-overlay hidden" id="config-overlay">
|
||
<div class="form-box">
|
||
<h2 id="form-title">New Flow Config</h2>
|
||
<input type="hidden" id="cfg-id" value="" />
|
||
|
||
<div class="fg">
|
||
<label>Config Name</label>
|
||
<input id="cfg-name" type="text" placeholder="e.g. Product Search Flow" />
|
||
</div>
|
||
|
||
<!-- Scenario selector -->
|
||
<div class="fg">
|
||
<label>Scenario</label>
|
||
<select id="cfg-scenario" style="width:100%">
|
||
<option value="dynamic">Generic / DynamicFlow</option>
|
||
<option value="digipay">DigiPay (mydigipay.com)</option>
|
||
<option value="snappmarket">Snapp Market (snappshop.ir)</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="fg">
|
||
<label>Target URL</label>
|
||
<input id="cfg-target-url" type="url" placeholder="https://example.com" />
|
||
</div>
|
||
|
||
<div class="fg" style="display:flex;align-items:center;gap:10px;margin-bottom:14px">
|
||
<input id="cfg-stop-on-first-click" type="checkbox" style="width:16px;height:16px;accent-color:var(--accent);cursor:pointer" />
|
||
<label for="cfg-stop-on-first-click" style="margin:0;cursor:pointer;color:var(--text)">Stop after first successful click</label>
|
||
</div>
|
||
|
||
<div class="fg" style="margin-top:6px">
|
||
<label>Search Texts <span style="color:var(--muted)">(one per line)</span></label>
|
||
<textarea id="cfg-search-texts" placeholder="shoes blue sneakers running shoes"></textarea>
|
||
</div>
|
||
|
||
<div class="fg">
|
||
<label id="cfg-item-ids-label">Target Item IDs <span style="color:var(--muted)">(one per line)</span></label>
|
||
<textarea id="cfg-item-ids" placeholder="prod-1234 prod-5678"></textarea>
|
||
</div>
|
||
|
||
<!-- DigiPay-specific fields -->
|
||
<div id="digipay-fields" style="display:none">
|
||
<div class="fg">
|
||
<label>Item Match Mode</label>
|
||
<select id="cfg-digipay-match" style="width:100%">
|
||
<option value="onclick">URL match — onclick goToProduct URL fragment</option>
|
||
<option value="text">Text match — product title text</option>
|
||
</select>
|
||
<p class="hint" id="digipay-onclick-hint">Paste URL fragment from goToProduct('https://…')— e.g. roshdbook.ir/product/شیمی-دهم/</p>
|
||
<p class="hint" id="digipay-text-hint" style="display:none">Paste the visible Persian product title — e.g. شیمی دهم تک جلدی انتشارات مبتکران</p>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="fg">
|
||
<label>Max Scrolls</label>
|
||
<input id="cfg-dp-max-scrolls" type="number" min="1" max="500" value="20" />
|
||
</div>
|
||
<div class="fg">
|
||
<label>Scroll Pause (ms)</label>
|
||
<input id="cfg-dp-scroll-pause" type="number" min="200" max="10000" value="1500" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Advanced (generic scenario only) -->
|
||
<details class="adv" id="adv-details">
|
||
<summary>Advanced Settings</summary>
|
||
<div class="adv-body">
|
||
|
||
<div class="form-section">Search Step</div>
|
||
<div class="form-row">
|
||
<div class="fg">
|
||
<label>Search Box Selector</label>
|
||
<input id="cfg-search-sel" type="text" placeholder="input[name='q']" />
|
||
</div>
|
||
<div class="fg">
|
||
<label>Type</label>
|
||
<select id="cfg-search-sel-type"><option value="css">CSS</option><option value="xpath">XPath</option></select>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="fg">
|
||
<label>Submit Button Selector <span style="color:var(--muted)">(blank → Enter key)</span></label>
|
||
<input id="cfg-submit-sel" type="text" placeholder="button[type='submit']" />
|
||
</div>
|
||
<div class="fg">
|
||
<label>Type</label>
|
||
<select id="cfg-submit-sel-type"><option value="css">CSS</option><option value="xpath">XPath</option></select>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="fg">
|
||
<label>Results Container Selector <span style="color:var(--muted)">(waited for after search)</span></label>
|
||
<input id="cfg-results-sel" type="text" placeholder=".search-results, #results-list" />
|
||
</div>
|
||
<div class="fg">
|
||
<label>Type</label>
|
||
<select id="cfg-results-sel-type"><option value="css">CSS</option><option value="xpath">XPath</option></select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-section">Scroll</div>
|
||
<div class="fg">
|
||
<label>Scroll Container Selector <span style="color:var(--muted)">(blank = window)</span></label>
|
||
<div style="display:flex;gap:8px;align-items:center">
|
||
<input id="cfg-scroll-container" type="text" placeholder=".product-list" style="flex:1" />
|
||
<select id="cfg-scroll-container-type" style="width:auto"><option value="css">CSS</option><option value="xpath">XPath</option></select>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="fg">
|
||
<label>Max Scrolls</label>
|
||
<input id="cfg-max-scrolls" type="number" min="1" max="500" value="30" />
|
||
</div>
|
||
<div class="fg">
|
||
<label>Scroll Pause (ms)</label>
|
||
<input id="cfg-scroll-pause" type="number" min="200" max="10000" value="1200" />
|
||
</div>
|
||
</div>
|
||
<div class="fg">
|
||
<label>No New Content Timeout (ms)</label>
|
||
<input id="cfg-no-content-timeout" type="number" min="500" max="30000" value="3000" />
|
||
</div>
|
||
|
||
<div class="form-section">Pagination <span style="font-weight:400;color:var(--muted)">(optional)</span></div>
|
||
<div class="fg">
|
||
<label>Next Page Button Selector <span style="color:var(--muted)">(blank = disabled)</span></label>
|
||
<div style="display:flex;gap:8px;align-items:center">
|
||
<input id="cfg-pagination-sel" type="text" placeholder=".pagination .next, a[rel='next']" style="flex:1" />
|
||
<select id="cfg-pagination-sel-type" style="width:auto"><option value="css">CSS</option><option value="xpath">XPath</option></select>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="fg">
|
||
<label>Max Pages</label>
|
||
<input id="cfg-max-pages" type="number" min="1" max="200" value="10" />
|
||
</div>
|
||
<div class="fg">
|
||
<label>Wait After Page Turn (ms)</label>
|
||
<input id="cfg-pagination-wait" type="number" min="200" max="15000" value="1500" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-section">Item Selection</div>
|
||
<div class="fg">
|
||
<label>Item URL Template <span style="color:var(--muted)">(href pattern, highest priority)</span></label>
|
||
<input id="cfg-item-url-tpl" type="text" placeholder="/products/{item_id}" />
|
||
<p class="hint">Finds <code><a href></code> whose href contains this pattern. Use <code>{item_id}</code> as placeholder.</p>
|
||
</div>
|
||
<div class="fg">
|
||
<label>Item Selector Template <span style="color:var(--muted)">(fallback)</span></label>
|
||
<input id="cfg-item-sel" type="text" placeholder="[data-id='{item_id}'] or /products/{item_id}" />
|
||
</div>
|
||
<div class="fg">
|
||
<label>Selector Type</label>
|
||
<select id="cfg-item-sel-type" style="width:auto">
|
||
<option value="css">CSS</option>
|
||
<option value="xpath">XPath</option>
|
||
<option value="a-link">A-Link (href contains)</option>
|
||
</select>
|
||
<p class="hint" id="alink-hint" style="display:none">Template matched against <code><a href></code> — e.g. <code>/products/{item_id}</code>.</p>
|
||
</div>
|
||
|
||
</div>
|
||
</details>
|
||
|
||
<div class="form-footer">
|
||
<button class="btn btn-ghost" id="form-cancel">Cancel</button>
|
||
<button class="btn btn-primary" id="form-save">Save Config</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════ APP ═══════════════════════════════════ -->
|
||
<header>
|
||
<div style="display:flex;align-items:center;gap:10px">
|
||
<h1>Seed</h1><span class="badge">LIVE</span>
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:14px">
|
||
<span id="last-updated">–</span>
|
||
<button id="refresh-btn">↻ Refresh</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="tabs">
|
||
<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 ──────────────────────────────────────────── -->
|
||
<div class="tab-panel active" id="tab-dashboard">
|
||
<main>
|
||
<div class="cards">
|
||
<div class="card"><div class="label">Total Runs</div><div class="value accent" id="c-total">–</div></div>
|
||
<div class="card"><div class="label">Unique Users</div><div class="value" id="c-unique">–</div></div>
|
||
<div class="card"><div class="label">Successes</div><div class="value green" id="c-success">–</div></div>
|
||
<div class="card"><div class="label">Failures</div><div class="value red" id="c-failures">–</div></div>
|
||
<div class="card"><div class="label">Success Rate</div><div class="value" id="c-rate">–</div></div>
|
||
</div>
|
||
|
||
<!-- Speed Test -->
|
||
<div class="panel" style="margin-bottom:20px">
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
|
||
<div class="section-title" style="margin:0">Network Speed</div>
|
||
<button class="btn btn-primary" id="speedtest-btn" style="padding:5px 16px;font-size:12px" onclick="runSpeedTest()">▶ Run Test</button>
|
||
</div>
|
||
<div id="speedtest-status" style="color:var(--muted);font-size:12px;margin-bottom:12px;display:none">
|
||
<span id="speedtest-msg">Testing… this takes ~20 seconds</span>
|
||
</div>
|
||
<div id="speedtest-results" style="display:none">
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:12px">
|
||
<div style="background:var(--surface2);border-radius:8px;padding:14px 16px">
|
||
<div style="color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">Download</div>
|
||
<div id="st-download" style="font-size:24px;font-weight:700;color:var(--green)">–</div>
|
||
<div style="color:var(--muted);font-size:11px">Mbps</div>
|
||
</div>
|
||
<div style="background:var(--surface2);border-radius:8px;padding:14px 16px">
|
||
<div style="color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">Upload</div>
|
||
<div id="st-upload" style="font-size:24px;font-weight:700;color:var(--accent)">–</div>
|
||
<div style="color:var(--muted);font-size:11px">Mbps</div>
|
||
</div>
|
||
<div style="background:var(--surface2);border-radius:8px;padding:14px 16px">
|
||
<div style="color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">Ping</div>
|
||
<div id="st-ping" style="font-size:24px;font-weight:700;color:var(--yellow)">–</div>
|
||
<div style="color:var(--muted);font-size:11px">ms</div>
|
||
</div>
|
||
<div style="background:var(--surface2);border-radius:8px;padding:14px 16px;grid-column:span 2">
|
||
<div style="color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">Server / ISP</div>
|
||
<div id="st-server" style="font-size:13px;font-weight:600">–</div>
|
||
<div id="st-isp" style="color:var(--muted);font-size:12px;margin-top:2px">–</div>
|
||
</div>
|
||
<div style="background:var(--surface2);border-radius:8px;padding:14px 16px">
|
||
<div style="color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px">Tested At</div>
|
||
<div id="st-time" style="font-size:13px;font-weight:600">–</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid-2">
|
||
<div class="panel">
|
||
<div class="section-title">Scenario Breakdown</div>
|
||
<div id="scenario-breakdown"><p class="empty">No data yet.</p></div>
|
||
</div>
|
||
<div class="panel">
|
||
<div class="section-title">Top Failure Reasons</div>
|
||
<div id="failure-reasons"><p class="empty">No failures recorded.</p></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="section-title" style="margin-bottom:14px">Recent Runs</div>
|
||
<div style="overflow-x:auto">
|
||
<table>
|
||
<thead><tr><th>#</th><th>Time</th><th>Scenario</th><th>Identifier</th><th>Status</th><th>Duration</th><th>Failure Reason</th></tr></thead>
|
||
<tbody id="runs-tbody"><tr><td colspan="7" class="empty">No runs yet.</td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<!-- ── Flow Configs Tab ───────────────────────────────────────── -->
|
||
<div class="tab-panel" id="tab-flows">
|
||
<main>
|
||
<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">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>
|
||
<button class="btn btn-primary" id="btn-new-config">+ New Config</button>
|
||
</div>
|
||
</div>
|
||
<input type="file" id="import-file-input" accept=".json" style="display:none">
|
||
|
||
<div class="config-list" id="config-list">
|
||
<p class="empty">No configs yet. Create one to get started.</p>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<!-- ── Batch Run Tab ─────────────────────────────────────────── -->
|
||
<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 a selected flow config.</div>
|
||
|
||
<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">
|
||
<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" />
|
||
</div>
|
||
<div class="fg" style="margin-bottom:14px">
|
||
<label>Total Runs</label>
|
||
<input id="batch-runs" type="number" min="1" max="10000" value="10" />
|
||
</div>
|
||
<div class="fg" style="margin-bottom:14px">
|
||
<label>Stagger Between Starts (ms)</label>
|
||
<input id="batch-stagger" type="number" min="0" max="30000" value="500" />
|
||
</div>
|
||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:20px">
|
||
<input id="batch-headless" type="checkbox" checked style="width:16px;height:16px;cursor:pointer" />
|
||
<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">+ 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" 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>
|
||
<span id="batch-progress-label" style="font-size:12px">0 / 0</span>
|
||
</div>
|
||
<div style="background:var(--surface2);border-radius:4px;height:8px;overflow:hidden">
|
||
<div id="batch-progress-bar" style="height:100%;background:var(--accent);width:0%;transition:width .3s"></div>
|
||
</div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;margin-bottom:16px">
|
||
<div class="card" style="padding:12px">
|
||
<div class="label">Completed</div>
|
||
<div class="value accent" id="bs-completed">0</div>
|
||
</div>
|
||
<div class="card" style="padding:12px">
|
||
<div class="label">Succeeded</div>
|
||
<div class="value green" id="bs-succeeded">0</div>
|
||
</div>
|
||
<div class="card" style="padding:12px">
|
||
<div class="label">Failed</div>
|
||
<div class="value red" id="bs-failed">0</div>
|
||
</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 with scroll -->
|
||
<div class="section-title">Workers</div>
|
||
<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>
|
||
|
||
<script>
|
||
/* ─────────────────────────── Auth ─────────────────────────── */
|
||
let _creds = null;
|
||
function b64(u, p) { return btoa(`${u}:${p}`); }
|
||
function authHeader() { return { 'Authorization': `Basic ${b64(_creds.u, _creds.p)}` }; }
|
||
|
||
async function apiFetch(path, opts = {}) {
|
||
const res = await fetch(path, { ...opts, headers: { ...authHeader(), ...(opts.headers || {}) } });
|
||
if (res.status === 401) { showAuth(); return null; }
|
||
return res;
|
||
}
|
||
|
||
const _STORAGE_KEY = 'seed_admin_creds';
|
||
|
||
function showAuth() {
|
||
document.getElementById('auth-modal').classList.remove('hidden');
|
||
}
|
||
function hideAuth() {
|
||
document.getElementById('auth-modal').classList.add('hidden');
|
||
}
|
||
|
||
function _loadSaved() {
|
||
try {
|
||
const raw = localStorage.getItem(_STORAGE_KEY);
|
||
if (!raw) return null;
|
||
return JSON.parse(raw);
|
||
} catch { return null; }
|
||
}
|
||
|
||
function _saveCreds(u, p) {
|
||
localStorage.setItem(_STORAGE_KEY, JSON.stringify({ u, p }));
|
||
}
|
||
|
||
function _clearCreds() {
|
||
localStorage.removeItem(_STORAGE_KEY);
|
||
}
|
||
|
||
async function _attemptLogin(u, p) {
|
||
const res = await fetch('/api/stats', { headers: { 'Authorization': `Basic ${b64(u, p)}` } });
|
||
return res;
|
||
}
|
||
|
||
document.getElementById('login-btn').addEventListener('click', async () => {
|
||
const u = document.getElementById('inp-user').value.trim();
|
||
const p = document.getElementById('inp-pass').value;
|
||
const remember = document.getElementById('inp-remember').checked;
|
||
const res = await _attemptLogin(u, p);
|
||
if (res.ok) {
|
||
_creds = { u, p };
|
||
remember ? _saveCreds(u, p) : _clearCreds();
|
||
document.getElementById('auth-error').classList.add('hidden');
|
||
hideAuth();
|
||
await Promise.all([loadStats(), loadConfigs()]);
|
||
startAutoRefresh();
|
||
} else {
|
||
_clearCreds();
|
||
document.getElementById('auth-error').classList.remove('hidden');
|
||
}
|
||
});
|
||
document.getElementById('inp-pass').addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') document.getElementById('login-btn').click();
|
||
});
|
||
|
||
// Auto-login from saved credentials on page load
|
||
(async () => {
|
||
const saved = _loadSaved();
|
||
if (!saved) return;
|
||
const res = await _attemptLogin(saved.u, saved.p);
|
||
if (res.ok) {
|
||
_creds = saved;
|
||
document.getElementById('inp-user').value = saved.u;
|
||
document.getElementById('inp-remember').checked = true;
|
||
hideAuth();
|
||
await Promise.all([loadStats(), loadConfigs()]);
|
||
startAutoRefresh();
|
||
} else {
|
||
_clearCreds(); // saved creds are stale (password changed)
|
||
}
|
||
})();
|
||
|
||
/* ─────────────────────────── Tabs ─────────────────────────── */
|
||
document.querySelectorAll('.tab').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||
tab.classList.add('active');
|
||
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');
|
||
if (tab.dataset.tab === 'flows') loadConfigs();
|
||
if (tab.dataset.tab === 'batch') { loadConfigs(); pollBatchStatus(); loadBatchHistory(); }
|
||
if (tab.dataset.tab === 'logs') { loadLogs(); _startLogPoll(); }
|
||
else _stopLogPoll();
|
||
});
|
||
});
|
||
|
||
/* ─────────────────────────── Speed Test ─────────────────────── */
|
||
async function runSpeedTest() {
|
||
const btn = document.getElementById('speedtest-btn');
|
||
btn.disabled = true;
|
||
btn.textContent = '⏳ Testing…';
|
||
document.getElementById('speedtest-status').style.display = 'block';
|
||
document.getElementById('speedtest-results').style.display = 'none';
|
||
|
||
const res = await apiFetch('/api/speedtest', { method: 'POST' });
|
||
btn.disabled = false;
|
||
btn.textContent = '▶ Run Test';
|
||
document.getElementById('speedtest-status').style.display = 'none';
|
||
|
||
if (!res || !res.ok) {
|
||
document.getElementById('speedtest-msg').textContent = 'Speed test failed.';
|
||
document.getElementById('speedtest-status').style.display = 'block';
|
||
return;
|
||
}
|
||
const d = await res.json();
|
||
document.getElementById('st-download').textContent = d.download_mbps;
|
||
document.getElementById('st-upload').textContent = d.upload_mbps;
|
||
document.getElementById('st-ping').textContent = d.ping_ms;
|
||
document.getElementById('st-server').textContent = d.server || '–';
|
||
document.getElementById('st-isp').textContent = d.isp || '–';
|
||
document.getElementById('st-time').textContent = new Date().toLocaleTimeString();
|
||
document.getElementById('speedtest-results').style.display = 'block';
|
||
}
|
||
|
||
/* ─────────────────────────── Dashboard ──────────────────────── */
|
||
async function loadStats() {
|
||
const res = await apiFetch('/api/stats');
|
||
if (!res || !res.ok) return;
|
||
renderStats(await res.json());
|
||
}
|
||
|
||
function renderStats(data) {
|
||
document.getElementById('c-total').textContent = data.total_runs;
|
||
document.getElementById('c-unique').textContent = data.unique_identifiers;
|
||
document.getElementById('c-success').textContent = data.successes;
|
||
document.getElementById('c-failures').textContent = data.failures;
|
||
document.getElementById('c-rate').textContent = `${data.success_rate}%`;
|
||
document.getElementById('last-updated').textContent = `Updated ${new Date().toLocaleTimeString()}`;
|
||
|
||
const sbEl = document.getElementById('scenario-breakdown');
|
||
sbEl.innerHTML = data.scenario_breakdown.length === 0
|
||
? '<p class="empty">No data yet.</p>'
|
||
: data.scenario_breakdown.map(s => {
|
||
const pct = s.total ? Math.round(s.success / s.total * 100) : 0;
|
||
return `<div class="breakdown-row">
|
||
<span style="width:70px;font-weight:600">${s.scenario}</span>
|
||
<div class="bar-bg"><div class="bar-fill" style="width:${pct}%"></div></div>
|
||
<span style="width:38px;text-align:right;color:var(--muted);font-size:12px">${pct}%</span>
|
||
<span style="width:70px;text-align:right;color:var(--muted);font-size:12px">${s.success}/${s.total}</span>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
const frEl = document.getElementById('failure-reasons');
|
||
frEl.innerHTML = data.failure_reasons.length === 0
|
||
? '<p class="empty">No failures recorded.</p>'
|
||
: data.failure_reasons.map(r =>
|
||
`<div class="reason-row"><span class="reason-text">${esc(r.reason)}</span><span class="reason-count">${r.count}</span></div>`
|
||
).join('');
|
||
|
||
const tbody = document.getElementById('runs-tbody');
|
||
tbody.innerHTML = !data.recent_runs.length
|
||
? '<tr><td colspan="7" class="empty">No runs yet.</td></tr>'
|
||
: data.recent_runs.map(r => `<tr>
|
||
<td style="color:var(--muted)">${r.id}</td>
|
||
<td style="color:var(--muted);white-space:nowrap">${fmtTime(r.created_at)}</td>
|
||
<td><span style="text-transform:uppercase;font-size:11px;font-weight:600">${esc(r.scenario)}</span></td>
|
||
<td style="font-family:monospace">${esc(r.identifier)}</td>
|
||
<td><span class="pill ${r.success ? 'ok' : 'err'}">${r.success ? 'OK' : 'FAIL'}</span></td>
|
||
<td style="color:var(--muted)">${r.total_ms ? r.total_ms + ' ms' : '–'}</td>
|
||
<td style="color:var(--red);font-size:12px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${r.failure_reason ? esc(r.failure_reason) : '–'}</td>
|
||
</tr>`).join('');
|
||
}
|
||
|
||
document.getElementById('refresh-btn').addEventListener('click', loadStats);
|
||
let _timer = null;
|
||
function startAutoRefresh() {
|
||
if (_timer) clearInterval(_timer);
|
||
_timer = setInterval(loadStats, 15000);
|
||
}
|
||
|
||
/* ─────────────────────────── Flow Configs ──────────────────── */
|
||
let _configs = [];
|
||
|
||
async function loadConfigs() {
|
||
const res = await apiFetch('/api/flow-configs');
|
||
if (!res || !res.ok) return;
|
||
_configs = await res.json();
|
||
renderConfigs();
|
||
_refreshBatchConfigSelect();
|
||
}
|
||
|
||
function renderConfigs() {
|
||
const el = document.getElementById('config-list');
|
||
if (!_configs.length) {
|
||
el.innerHTML = '<p class="empty">No configs yet. Create one to get started.</p>';
|
||
return;
|
||
}
|
||
el.innerHTML = _configs.map(c => `
|
||
<div class="config-card" id="cc-${c.id}">
|
||
<div class="config-info">
|
||
<div class="config-name">
|
||
${esc(c.name)}
|
||
<span class="pill id-badge">#${c.id}</span>
|
||
</div>
|
||
<div class="config-meta">
|
||
<span class="pill" style="font-size:11px;text-transform:uppercase;letter-spacing:.05em">${esc(c.scenario || 'dynamic')}</span>
|
||
· Searches: <strong>${(c.search_texts_json || []).length}</strong> text(s) ·
|
||
Items: <strong>${(c.target_item_ids_json || []).length}</strong> ID(s) ·
|
||
Max scrolls: <strong>${c.max_scrolls}</strong> ·
|
||
Search box: <code style="font-size:11px">${esc(c.search_box_selector || '—')}</code>
|
||
</div>
|
||
<div class="config-meta" style="margin-top:4px">
|
||
Item selector: <code style="font-size:11px">${esc(c.item_selector_template || '—')}</code>
|
||
${c.item_url_template ? ` · href pattern: <code style="font-size:11px">${esc(c.item_url_template)}</code>` : ''}
|
||
${c.pagination_selector ? ` · Pagination: <code style="font-size:11px">${esc(c.pagination_selector)}</code> (max ${c.max_pages} pages)` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="config-actions">
|
||
<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>
|
||
<button class="btn btn-danger btn-sm" onclick="deleteConfig(${c.id})">Delete</button>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
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}) [${esc(c.scenario || 'dynamic')}]</option>`).join('');
|
||
}
|
||
|
||
async function deleteConfig(id) {
|
||
const cfg = _configs.find(c => c.id === id);
|
||
if (!confirm(`Delete "${cfg?.name}"?`)) return;
|
||
const res = await apiFetch(`/api/flow-configs/${id}`, { method: 'DELETE' });
|
||
if (res && (res.ok || res.status === 204)) await loadConfigs();
|
||
}
|
||
|
||
function exportConfig(id) {
|
||
const c = _configs.find(x => x.id === id);
|
||
if (!c) return;
|
||
const out = {
|
||
name: c.name,
|
||
search_texts: c.search_texts_json || [],
|
||
search_box_selector: c.search_box_selector,
|
||
search_box_selector_type: c.search_box_selector_type,
|
||
search_submit_selector: c.search_submit_selector,
|
||
search_submit_selector_type: c.search_submit_selector_type,
|
||
search_results_selector: c.search_results_selector,
|
||
search_results_selector_type: c.search_results_selector_type,
|
||
scroll_container_selector: c.scroll_container_selector,
|
||
scroll_container_selector_type: c.scroll_container_selector_type,
|
||
max_scrolls: c.max_scrolls,
|
||
scroll_pause_ms: c.scroll_pause_ms,
|
||
no_new_content_timeout_ms: c.no_new_content_timeout_ms,
|
||
pagination_selector: c.pagination_selector,
|
||
pagination_selector_type: c.pagination_selector_type,
|
||
max_pages: c.max_pages,
|
||
pagination_wait_ms: c.pagination_wait_ms,
|
||
item_selector_template: c.item_selector_template,
|
||
item_selector_type: c.item_selector_type,
|
||
item_url_template: c.item_url_template,
|
||
target_item_ids: c.target_item_ids_json || [],
|
||
target_url: c.target_url || '',
|
||
scenario: c.scenario || 'dynamic',
|
||
};
|
||
const blob = new Blob([JSON.stringify(out, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url; a.download = `${c.name.replace(/[^a-z0-9_-]/gi, '_')}.json`;
|
||
a.click(); URL.revokeObjectURL(url);
|
||
}
|
||
|
||
async function duplicateConfig(id) {
|
||
const c = _configs.find(x => x.id === id);
|
||
if (!c) return;
|
||
const body = {
|
||
name: c.name + ' (copy)',
|
||
search_texts: c.search_texts_json || [],
|
||
search_box_selector: c.search_box_selector || '',
|
||
search_box_selector_type: c.search_box_selector_type || 'css',
|
||
search_submit_selector: c.search_submit_selector || '',
|
||
search_submit_selector_type: c.search_submit_selector_type || 'css',
|
||
search_results_selector: c.search_results_selector || '',
|
||
search_results_selector_type: c.search_results_selector_type || 'css',
|
||
scroll_container_selector: c.scroll_container_selector || '',
|
||
scroll_container_selector_type: c.scroll_container_selector_type || 'css',
|
||
max_scrolls: c.max_scrolls ?? 30,
|
||
scroll_pause_ms: c.scroll_pause_ms ?? 1200,
|
||
no_new_content_timeout_ms: c.no_new_content_timeout_ms ?? 3000,
|
||
pagination_selector: c.pagination_selector || '',
|
||
pagination_selector_type: c.pagination_selector_type || 'css',
|
||
max_pages: c.max_pages ?? 10,
|
||
pagination_wait_ms: c.pagination_wait_ms ?? 1500,
|
||
item_selector_template: c.item_selector_template || '',
|
||
item_selector_type: c.item_selector_type || 'css',
|
||
item_url_template: c.item_url_template || '',
|
||
target_item_ids: c.target_item_ids_json || [],
|
||
target_url: c.target_url || '',
|
||
scenario: c.scenario || 'dynamic',
|
||
};
|
||
const res = await apiFetch('/api/flow-configs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||
if (res && res.ok) await loadConfigs();
|
||
}
|
||
|
||
document.getElementById('btn-import-config').addEventListener('click', () => {
|
||
document.getElementById('import-file-input').click();
|
||
});
|
||
document.getElementById('import-file-input').addEventListener('change', async (e) => {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
e.target.value = '';
|
||
let data;
|
||
try { data = JSON.parse(await file.text()); } catch { alert('Invalid JSON file'); return; }
|
||
const body = {
|
||
name: data.name || file.name.replace(/\.json$/, ''),
|
||
search_texts: data.search_texts || [],
|
||
search_box_selector: data.search_box_selector || '',
|
||
search_box_selector_type: data.search_box_selector_type || 'css',
|
||
search_submit_selector: data.search_submit_selector || '',
|
||
search_submit_selector_type: data.search_submit_selector_type || 'css',
|
||
search_results_selector: data.search_results_selector || '',
|
||
search_results_selector_type: data.search_results_selector_type || 'css',
|
||
scroll_container_selector: data.scroll_container_selector || '',
|
||
scroll_container_selector_type: data.scroll_container_selector_type || 'css',
|
||
max_scrolls: data.max_scrolls ?? 30,
|
||
scroll_pause_ms: data.scroll_pause_ms ?? 1200,
|
||
no_new_content_timeout_ms: data.no_new_content_timeout_ms ?? 3000,
|
||
pagination_selector: data.pagination_selector || '',
|
||
pagination_selector_type: data.pagination_selector_type || 'css',
|
||
max_pages: data.max_pages ?? 10,
|
||
pagination_wait_ms: data.pagination_wait_ms ?? 1500,
|
||
item_selector_template: data.item_selector_template || '',
|
||
item_selector_type: data.item_selector_type || 'css',
|
||
item_url_template: data.item_url_template || '',
|
||
target_item_ids: data.target_item_ids || [],
|
||
target_url: data.target_url || '',
|
||
scenario: data.scenario || 'dynamic',
|
||
};
|
||
const res = await apiFetch('/api/flow-configs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||
if (res && res.ok) await loadConfigs();
|
||
});
|
||
|
||
/* ─── Form ─── */
|
||
function _applyScenario(scenario) {
|
||
const isDigi = scenario === 'digipay';
|
||
const isSnapp = scenario === 'snappmarket';
|
||
|
||
document.getElementById('digipay-fields').style.display = isDigi ? '' : 'none';
|
||
document.getElementById('adv-details').style.display = isDigi ? 'none' : '';
|
||
|
||
if (isDigi) {
|
||
if (!document.getElementById('cfg-target-url').value)
|
||
document.getElementById('cfg-target-url').value = 'https://www.mydigipay.com/stores/all-stores/';
|
||
document.getElementById('cfg-target-url').placeholder = 'https://www.mydigipay.com/stores/all-stores/';
|
||
document.getElementById('cfg-item-ids-label').innerHTML =
|
||
'Target Item IDs <span style="color:var(--muted)">(one per line — URL fragments or product titles)</span>';
|
||
} else if (isSnapp) {
|
||
if (!document.getElementById('cfg-target-url').value)
|
||
document.getElementById('cfg-target-url').value = 'https://snappshop.ir/';
|
||
document.getElementById('cfg-target-url').placeholder = 'https://snappshop.ir/';
|
||
document.getElementById('cfg-item-ids-label').innerHTML =
|
||
'Target Item IDs <span style="color:var(--muted)">(one per line — href URL fragments)</span>';
|
||
// Pre-set item selector to a-link for href-based matching
|
||
document.getElementById('cfg-item-sel-type').value = 'a-link';
|
||
document.getElementById('alink-hint').style.display = '';
|
||
} else {
|
||
document.getElementById('cfg-target-url').placeholder = 'https://example.com';
|
||
document.getElementById('cfg-item-ids-label').innerHTML =
|
||
'Target Item IDs <span style="color:var(--muted)">(one per line)</span>';
|
||
}
|
||
}
|
||
|
||
function _resetAdvanced() {
|
||
document.getElementById('cfg-search-sel').value = '';
|
||
document.getElementById('cfg-search-sel-type').value = 'css';
|
||
document.getElementById('cfg-submit-sel').value = '';
|
||
document.getElementById('cfg-submit-sel-type').value = 'css';
|
||
document.getElementById('cfg-results-sel').value = '';
|
||
document.getElementById('cfg-results-sel-type').value = 'css';
|
||
document.getElementById('cfg-scroll-container').value = '';
|
||
document.getElementById('cfg-scroll-container-type').value = 'css';
|
||
document.getElementById('cfg-max-scrolls').value = '30';
|
||
document.getElementById('cfg-scroll-pause').value = '1200';
|
||
document.getElementById('cfg-no-content-timeout').value = '3000';
|
||
document.getElementById('cfg-pagination-sel').value = '';
|
||
document.getElementById('cfg-pagination-sel-type').value = 'css';
|
||
document.getElementById('cfg-max-pages').value = '10';
|
||
document.getElementById('cfg-pagination-wait').value = '1500';
|
||
document.getElementById('cfg-item-sel').value = '';
|
||
document.getElementById('cfg-item-sel-type').value = 'css';
|
||
document.getElementById('alink-hint').style.display = 'none';
|
||
document.getElementById('cfg-item-url-tpl').value = '';
|
||
// DigiPay defaults
|
||
document.getElementById('cfg-digipay-match').value = 'onclick';
|
||
document.getElementById('cfg-dp-max-scrolls').value = '20';
|
||
document.getElementById('cfg-dp-scroll-pause').value = '1500';
|
||
document.getElementById('digipay-onclick-hint').style.display = '';
|
||
document.getElementById('digipay-text-hint').style.display = 'none';
|
||
}
|
||
|
||
function openNewForm() {
|
||
document.getElementById('form-title').textContent = 'New Flow Config';
|
||
document.getElementById('cfg-id').value = '';
|
||
document.getElementById('cfg-name').value = '';
|
||
document.getElementById('cfg-scenario').value = 'dynamic';
|
||
document.getElementById('cfg-target-url').value = '';
|
||
document.getElementById('cfg-search-texts').value = '';
|
||
document.getElementById('cfg-item-ids').value = '';
|
||
document.getElementById('cfg-stop-on-first-click').checked = false;
|
||
_resetAdvanced();
|
||
document.getElementById('adv-details').removeAttribute('open');
|
||
_applyScenario('dynamic');
|
||
document.getElementById('config-overlay').classList.remove('hidden');
|
||
}
|
||
|
||
function openEditForm(id) {
|
||
const c = _configs.find(x => x.id === id);
|
||
if (!c) return;
|
||
const scenario = c.scenario || 'dynamic';
|
||
document.getElementById('form-title').textContent = 'Edit Flow Config';
|
||
document.getElementById('cfg-id').value = id;
|
||
document.getElementById('cfg-name').value = c.name || '';
|
||
document.getElementById('cfg-scenario').value = scenario;
|
||
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');
|
||
// Generic fields
|
||
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 || '';
|
||
document.getElementById('cfg-submit-sel-type').value = c.search_submit_selector_type || 'css';
|
||
document.getElementById('cfg-results-sel').value = c.search_results_selector || '';
|
||
document.getElementById('cfg-results-sel-type').value = c.search_results_selector_type || 'css';
|
||
document.getElementById('cfg-scroll-container').value = c.scroll_container_selector || '';
|
||
document.getElementById('cfg-scroll-container-type').value = c.scroll_container_selector_type || 'css';
|
||
document.getElementById('cfg-max-scrolls').value = c.max_scrolls ?? 30;
|
||
document.getElementById('cfg-scroll-pause').value = c.scroll_pause_ms ?? 1200;
|
||
document.getElementById('cfg-no-content-timeout').value = c.no_new_content_timeout_ms ?? 3000;
|
||
document.getElementById('cfg-pagination-sel').value = c.pagination_selector || '';
|
||
document.getElementById('cfg-pagination-sel-type').value = c.pagination_selector_type || 'css';
|
||
document.getElementById('cfg-max-pages').value = c.max_pages ?? 10;
|
||
document.getElementById('cfg-pagination-wait').value = c.pagination_wait_ms ?? 1500;
|
||
document.getElementById('cfg-item-sel').value = c.item_selector_template || '';
|
||
const _ist = c.item_selector_type || 'css';
|
||
document.getElementById('cfg-item-sel-type').value = _ist;
|
||
document.getElementById('alink-hint').style.display = _ist === 'a-link' ? '' : 'none';
|
||
document.getElementById('cfg-item-url-tpl').value = c.item_url_template || '';
|
||
// DigiPay fields
|
||
const dpMatch = (scenario === 'digipay') ? (_ist || 'onclick') : 'onclick';
|
||
document.getElementById('cfg-digipay-match').value = dpMatch;
|
||
document.getElementById('cfg-stop-on-first-click').checked = c.stop_on_first_click !== 0;
|
||
document.getElementById('cfg-dp-max-scrolls').value = c.max_scrolls ?? 20;
|
||
document.getElementById('cfg-dp-scroll-pause').value = c.scroll_pause_ms ?? 1500;
|
||
document.getElementById('digipay-onclick-hint').style.display = dpMatch === 'onclick' ? '' : 'none';
|
||
document.getElementById('digipay-text-hint').style.display = dpMatch === 'text' ? '' : 'none';
|
||
document.getElementById('adv-details').setAttribute('open', '');
|
||
_applyScenario(scenario);
|
||
document.getElementById('config-overlay').classList.remove('hidden');
|
||
}
|
||
|
||
function closeForm() {
|
||
document.getElementById('config-overlay').classList.add('hidden');
|
||
}
|
||
|
||
function collectForm() {
|
||
const scenario = document.getElementById('cfg-scenario').value;
|
||
const base = {
|
||
name: document.getElementById('cfg-name').value.trim(),
|
||
scenario,
|
||
target_url: document.getElementById('cfg-target-url').value.trim(),
|
||
search_texts: document.getElementById('cfg-search-texts').value.split('\n').map(s => s.trim()).filter(Boolean),
|
||
target_item_ids: document.getElementById('cfg-item-ids').value.split('\n').map(s => s.trim()).filter(Boolean),
|
||
stop_on_first_click: document.getElementById('cfg-stop-on-first-click').checked,
|
||
};
|
||
|
||
if (scenario === 'digipay') {
|
||
const matchMode = document.getElementById('cfg-digipay-match').value;
|
||
return {
|
||
...base,
|
||
// Fixed DigiPay selectors (hardcoded in DigiPayFlow, stored for reference)
|
||
search_box_selector: '/html/body/header/header/div[2]',
|
||
search_box_selector_type: 'xpath',
|
||
search_results_selector: "a[onclick*='goToProduct']",
|
||
search_results_selector_type: 'css',
|
||
search_submit_selector: '',
|
||
search_submit_selector_type: 'css',
|
||
scroll_container_selector: '',
|
||
scroll_container_selector_type: 'css',
|
||
item_selector_type: matchMode,
|
||
item_selector_template: '',
|
||
item_url_template: '',
|
||
max_scrolls: parseInt(document.getElementById('cfg-dp-max-scrolls').value) || 20,
|
||
scroll_pause_ms: parseInt(document.getElementById('cfg-dp-scroll-pause').value) || 1500,
|
||
no_new_content_timeout_ms: 3000,
|
||
pagination_selector: '', pagination_selector_type: 'css',
|
||
max_pages: 10, pagination_wait_ms: 1500,
|
||
};
|
||
}
|
||
|
||
return {
|
||
...base,
|
||
search_box_selector: document.getElementById('cfg-search-sel').value.trim(),
|
||
search_box_selector_type: document.getElementById('cfg-search-sel-type').value,
|
||
search_submit_selector: document.getElementById('cfg-submit-sel').value.trim(),
|
||
search_submit_selector_type: document.getElementById('cfg-submit-sel-type').value,
|
||
search_results_selector: document.getElementById('cfg-results-sel').value.trim(),
|
||
search_results_selector_type: document.getElementById('cfg-results-sel-type').value,
|
||
scroll_container_selector: document.getElementById('cfg-scroll-container').value.trim(),
|
||
scroll_container_selector_type: document.getElementById('cfg-scroll-container-type').value,
|
||
max_scrolls: parseInt(document.getElementById('cfg-max-scrolls').value) || 30,
|
||
scroll_pause_ms: parseInt(document.getElementById('cfg-scroll-pause').value) || 1200,
|
||
no_new_content_timeout_ms: parseInt(document.getElementById('cfg-no-content-timeout').value) || 3000,
|
||
pagination_selector: document.getElementById('cfg-pagination-sel').value.trim(),
|
||
pagination_selector_type: document.getElementById('cfg-pagination-sel-type').value,
|
||
max_pages: parseInt(document.getElementById('cfg-max-pages').value) || 10,
|
||
pagination_wait_ms: parseInt(document.getElementById('cfg-pagination-wait').value) || 1500,
|
||
item_selector_template: document.getElementById('cfg-item-sel').value.trim(),
|
||
item_selector_type: document.getElementById('cfg-item-sel-type').value,
|
||
item_url_template: document.getElementById('cfg-item-url-tpl').value.trim(),
|
||
};
|
||
}
|
||
|
||
document.getElementById('btn-new-config').addEventListener('click', openNewForm);
|
||
document.getElementById('form-cancel').addEventListener('click', closeForm);
|
||
document.getElementById('config-overlay').addEventListener('click', e => { if (e.target === e.currentTarget) closeForm(); });
|
||
document.getElementById('cfg-item-sel-type').addEventListener('change', e => {
|
||
document.getElementById('alink-hint').style.display = e.target.value === 'a-link' ? '' : 'none';
|
||
});
|
||
document.getElementById('cfg-scenario').addEventListener('change', e => {
|
||
_applyScenario(e.target.value);
|
||
});
|
||
document.getElementById('cfg-digipay-match').addEventListener('change', e => {
|
||
document.getElementById('digipay-onclick-hint').style.display = e.target.value === 'onclick' ? '' : 'none';
|
||
document.getElementById('digipay-text-hint').style.display = e.target.value === 'text' ? '' : 'none';
|
||
});
|
||
|
||
document.getElementById('form-save').addEventListener('click', async () => {
|
||
const body = collectForm();
|
||
if (!body.name) { alert('Config name is required.'); return; }
|
||
|
||
const cfgId = document.getElementById('cfg-id').value;
|
||
const isEdit = !!cfgId;
|
||
const url = isEdit ? `/api/flow-configs/${cfgId}` : '/api/flow-configs';
|
||
const method = isEdit ? 'PUT' : 'POST';
|
||
|
||
const res = await apiFetch(url, {
|
||
method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (res && (res.ok || res.status === 201)) {
|
||
closeForm();
|
||
await loadConfigs();
|
||
} else {
|
||
const detail = res ? await res.text().catch(() => `HTTP ${res.status}`) : 'No response (network error)';
|
||
console.error('Save failed:', detail);
|
||
alert(`Save failed (${res?.status ?? '?'}):\n${detail}\n\nIf columns are missing, restart the admin panel to apply DB migrations.`);
|
||
}
|
||
});
|
||
|
||
/* ─────────────────────────── Utils ────────────────────────── */
|
||
function esc(s) {
|
||
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
function fmtTime(iso) {
|
||
if (!iso) return '–';
|
||
return new Date(iso).toLocaleString();
|
||
}
|
||
|
||
/* ─────────────────────────── Batch Run ─────────────────────── */
|
||
let _batchPollTimer = null;
|
||
|
||
const _STATUS_COLOR = {
|
||
running: 'var(--accent)',
|
||
ok: 'var(--green)',
|
||
failed: 'var(--red)',
|
||
error: 'var(--red)',
|
||
skipped: 'var(--muted)',
|
||
pending: 'var(--muted)',
|
||
};
|
||
|
||
function fmtDuration(ms) {
|
||
if (ms == null) return '–';
|
||
if (ms < 1000) return ms + ' ms';
|
||
return (ms / 1000).toFixed(1) + ' s';
|
||
}
|
||
|
||
function fmtTs(ts) {
|
||
if (!ts) return '–';
|
||
return new Date(ts * 1000).toLocaleTimeString();
|
||
}
|
||
|
||
function renderBatchStatus(s) {
|
||
const idle = document.getElementById('batch-idle-msg');
|
||
const body = document.getElementById('batch-status-body');
|
||
const errEl = document.getElementById('batch-error');
|
||
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';
|
||
stopBtn.style.display = 'none';
|
||
badge.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
idle.style.display = 'none';
|
||
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);
|
||
|
||
const finRow = document.getElementById('bs-finished-row');
|
||
if (s.finished_at) {
|
||
finRow.style.display = '';
|
||
document.getElementById('bs-finished').textContent = fmtTs(s.finished_at);
|
||
} else {
|
||
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) {
|
||
badge.textContent = `${active} / ${s.configured_workers} active`;
|
||
badge.style.display = '';
|
||
} else {
|
||
badge.style.display = 'none';
|
||
}
|
||
|
||
// Per-worker table
|
||
const tbody = document.getElementById('bs-worker-tbody');
|
||
const workers = s.workers_detail || [];
|
||
tbody.innerHTML = workers.map(w => {
|
||
const color = _STATUS_COLOR[w.status] || 'var(--muted)';
|
||
const label = w.status === 'running' ? '⟳ running' : w.status;
|
||
const err = w.error && w.status !== 'skipped' ? `<span style="color:var(--red)">${esc(w.error)}</span>` : '–';
|
||
return `<tr>
|
||
<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:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${err}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
if (s.error) {
|
||
errEl.textContent = 'Error: ' + s.error;
|
||
errEl.style.display = '';
|
||
} else {
|
||
errEl.style.display = 'none';
|
||
}
|
||
|
||
if (s.running) {
|
||
stopBtn.style.display = '';
|
||
stopBtn.disabled = s.stopping || false;
|
||
stopBtn.textContent = s.stopping ? '■ Stopping…' : '■ Stop';
|
||
} else {
|
||
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;
|
||
const s = await res.json();
|
||
renderBatchStatus(s);
|
||
if (s.running && !_batchPollTimer) startBatchPoll();
|
||
}
|
||
|
||
function startBatchPoll() {
|
||
if (_batchPollTimer) return;
|
||
_batchPollTimer = setInterval(async () => {
|
||
const r = await apiFetch('/api/batch-status');
|
||
if (!r || !r.ok) return;
|
||
const st = await r.json();
|
||
renderBatchStatus(st);
|
||
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/batch-queue', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ config_id: configId, workers, total_runs: runs, stagger_ms: stagger, headless }),
|
||
});
|
||
|
||
if (!res) 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();
|
||
});
|
||
|
||
document.getElementById('batch-stop-btn').addEventListener('click', async () => {
|
||
const btn = document.getElementById('batch-stop-btn');
|
||
btn.disabled = true;
|
||
btn.textContent = '■ Stopping…';
|
||
const res = await apiFetch('/api/stop-batch', { method: 'POST' });
|
||
if (!res || !res.ok) {
|
||
btn.disabled = false;
|
||
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>
|