Files
crawle-snapp/admin/static/dashboard.html
T

1528 lines
73 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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&#10;blue sneakers&#10;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&#10;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>&lt;a href&gt;</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>&lt;a href&gt;</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> &nbsp;·&nbsp;
Workers: <strong id="bs-workers"></strong> &nbsp;·&nbsp;
Started: <strong id="bs-started"></strong>
<span id="bs-finished-row" style="display:none"> &nbsp;·&nbsp; 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>
&nbsp;·&nbsp; Searches: <strong>${(c.search_texts_json || []).length}</strong> text(s) &nbsp;·&nbsp;
Items: <strong>${(c.target_item_ids_json || []).length}</strong> ID(s) &nbsp;·&nbsp;
Max scrolls: <strong>${c.max_scrolls}</strong> &nbsp;·&nbsp;
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 ? `&nbsp;·&nbsp; href pattern: <code style="font-size:11px">${esc(c.item_url_template)}</code>` : ''}
${c.pagination_selector ? `&nbsp;·&nbsp; 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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>