Files
crawle-snapp/admin/static/dashboard.html
T
2026-06-15 19:42:03 +03:30

559 lines
28 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>
<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: '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.active-badge { background: rgba(99,102,241,.2); color: var(--accent); }
.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.is-active { border-color: var(--accent); }
.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; }
.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: monospace; }
.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; }
</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>
<button class="btn btn-primary" style="width:100%;margin-top:4px" 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>
<div class="fg">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input id="cfg-active" type="checkbox" style="width:auto" /> Set as active config
</label>
</div>
<!-- Search -->
<div class="form-section">Search Step</div>
<div class="fg">
<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="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)">(leave 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="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>
<!-- Scroll -->
<div class="form-section">Scroll Step (Infinite Scroll)</div>
<div class="fg">
<label>Scroll Container Selector <span style="color:var(--muted)">(blank = window)</span></label>
<input id="cfg-scroll-container" type="text" placeholder=".product-list" />
</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) <span style="color:var(--muted)">— bail if DOM height unchanged for this long</span></label>
<input id="cfg-no-content-timeout" type="number" min="500" max="30000" value="3000" />
</div>
<!-- Item -->
<div class="form-section">Item Selection</div>
<div class="fg">
<label>Item Selector Template</label>
<input id="cfg-item-sel" type="text" placeholder="[data-id='{item_id}'] or .result-card" />
<p class="hint">Use <code>{item_id}</code> as a placeholder — it is replaced per item ID below.</p>
</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></select>
</div>
<div class="fg">
<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>
<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>
<!-- ── 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>
<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">Only one config is active at a time. The crawler picks it up on every run.</div>
</div>
<button class="btn btn-primary" id="btn-new-config">+ New Config</button>
</div>
<div class="config-list" id="config-list">
<p class="empty">No configs yet. Create one to get started.</p>
</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;
}
function showAuth() { document.getElementById('auth-modal').classList.remove('hidden'); }
function hideAuth() { document.getElementById('auth-modal').classList.add('hidden'); }
document.getElementById('login-btn').addEventListener('click', async () => {
const u = document.getElementById('inp-user').value.trim();
const p = document.getElementById('inp-pass').value;
const res = await fetch('/api/stats', { headers: { 'Authorization': `Basic ${b64(u, p)}` } });
if (res.ok) {
_creds = { u, p };
document.getElementById('auth-error').classList.add('hidden');
hideAuth();
await Promise.all([loadStats(), loadConfigs()]);
startAutoRefresh();
} else {
document.getElementById('auth-error').classList.remove('hidden');
}
});
document.getElementById('inp-pass').addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('login-btn').click(); });
/* ─────────────────────────── 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();
});
});
/* ─────────────────────────── 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();
}
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 ${c.is_active ? 'is-active' : ''}" id="cc-${c.id}">
<div class="config-info">
<div class="config-name">
${esc(c.name)}
${c.is_active ? '<span class="pill active-badge">ACTIVE</span>' : ''}
</div>
<div class="config-meta">
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>
</div>
</div>
<div class="config-actions">
${!c.is_active ? `<button class="btn btn-ghost btn-sm" onclick="activateConfig(${c.id})">Activate</button>` : ''}
<button class="btn btn-ghost btn-sm" onclick="openEditForm(${c.id})">Edit</button>
<button class="btn btn-danger btn-sm" onclick="deleteConfig(${c.id})">Delete</button>
</div>
</div>
`).join('');
}
async function activateConfig(id) {
const res = await apiFetch(`/api/flow-configs/${id}/activate`, { method: 'POST' });
if (res && res.ok) await loadConfigs();
}
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();
}
/* ─── Form ─── */
function openNewForm() {
document.getElementById('form-title').textContent = 'New Flow Config';
document.getElementById('cfg-id').value = '';
document.getElementById('cfg-name').value = '';
document.getElementById('cfg-active').checked = false;
document.getElementById('cfg-search-texts').value = '';
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-scroll-container').value = '';
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-item-sel').value = '';
document.getElementById('cfg-item-sel-type').value = 'css';
document.getElementById('cfg-item-ids').value = '';
document.getElementById('config-overlay').classList.remove('hidden');
}
function openEditForm(id) {
const c = _configs.find(x => x.id === id);
if (!c) return;
document.getElementById('form-title').textContent = 'Edit Flow Config';
document.getElementById('cfg-id').value = id;
document.getElementById('cfg-name').value = c.name || '';
document.getElementById('cfg-active').checked = !!c.is_active;
document.getElementById('cfg-search-texts').value = (c.search_texts_json || []).join('\n');
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-scroll-container').value = c.scroll_container_selector || '';
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-item-sel').value = c.item_selector_template || '';
document.getElementById('cfg-item-sel-type').value = c.item_selector_type || 'css';
document.getElementById('cfg-item-ids').value = (c.target_item_ids_json || []).join('\n');
document.getElementById('config-overlay').classList.remove('hidden');
}
function closeForm() {
document.getElementById('config-overlay').classList.add('hidden');
}
function collectForm() {
return {
name: document.getElementById('cfg-name').value.trim(),
is_active: document.getElementById('cfg-active').checked,
search_texts: document.getElementById('cfg-search-texts').value.split('\n').map(s => s.trim()).filter(Boolean),
search_box_selector: document.getElementById('cfg-search-sel').value.trim(),
search_box_selector_type: document.getElementById('cfg-search-sel-type').value,
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(),
scroll_container_selector: document.getElementById('cfg-scroll-container').value.trim(),
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,
item_selector_template: document.getElementById('cfg-item-sel').value.trim(),
item_selector_type: document.getElementById('cfg-item-sel-type').value,
target_item_ids: document.getElementById('cfg-item-ids').value.split('\n').map(s => s.trim()).filter(Boolean),
};
}
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('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 {
alert('Failed to save config. Check console.');
console.error(await res?.text());
}
});
/* ─────────────────────────── 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();
}
</script>
</body>
</html>