39246-vm/dashboard.html
abbashkyt-creator 7d8ce0e322 V0.1
2026-03-14 04:02:22 +03:00

3516 lines
176 KiB
HTML
Raw Permalink 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>Ghost Node — Auction Sniper</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=Share+Tech+Mono&family=Rajdhani:wght@300;400;500;600;700&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet" />
<style>
/* ─── Reset ────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ─── Design Tokens ─────────────────────────────────────────── */
:root {
--bg-void: #030509;
--bg-panel: #080d14;
--bg-card: #0b1220;
--bg-input: #0d1525;
--border-dim: #1a2a3a;
--border-glow: #0ff;
--accent: #00f5ff;
--accent-hot: #ff3e6c;
--accent-gold: #f0c040;
--accent-green: #00ff88;
--text-bright: #e8f4ff;
--text-mid: #7a9bb5;
--text-dim: #354a5e;
--font-mono: 'Share Tech Mono', monospace;
--font-ui: 'Rajdhani', sans-serif;
--font-display: 'Orbitron', monospace;
--glow-cyan: 0 0 8px #00f5ff66, 0 0 24px #00f5ff22;
--glow-hot: 0 0 8px #ff3e6c66, 0 0 24px #ff3e6c22;
--glow-green: 0 0 8px #00ff8866, 0 0 24px #00ff8822;
--r: 4px;
}
/* ─── Scrollbar ─────────────────────────────────────────────── */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: var(--bg-void); }
::-webkit-scrollbar-thumb { background: var(--border-dim); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--accent); }
/* ─── Body ──────────────────────────────────────────────────── */
body {
background: var(--bg-void);
color: var(--text-bright);
font-family: var(--font-ui);
font-size: 14px;
min-height: 100vh;
overflow-x: hidden;
}
/* CRT scan-line overlay */
body::after {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0,0,0,.08) 2px,
rgba(0,0,0,.08) 4px
);
pointer-events: none;
z-index: 9999;
}
/* ─── Header ────────────────────────────────────────────────── */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 28px;
border-bottom: 1px solid var(--border-dim);
background: linear-gradient(90deg, #030509 0%, #060e18 50%, #030509 100%);
position: sticky;
top: 0;
z-index: 100;
}
.logo {
display: flex;
align-items: center;
gap: 12px;
}
.logo-icon {
width: 38px; height: 38px;
border: 2px solid var(--accent);
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
box-shadow: var(--glow-cyan);
animation: pulse 3s ease-in-out infinite;
font-size: 18px;
}
@keyframes pulse {
0%,100% { box-shadow: var(--glow-cyan); }
50% { box-shadow: 0 0 18px #00f5ffaa, 0 0 40px #00f5ff44; }
}
.logo-text {
font-family: var(--font-display);
font-size: 18px;
font-weight: 900;
letter-spacing: 3px;
color: var(--accent);
text-shadow: var(--glow-cyan);
}
.logo-sub {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
letter-spacing: 2px;
margin-top: 1px;
}
.header-status {
display: flex;
align-items: center;
gap: 20px;
}
.status-pill {
display: flex; align-items: center; gap: 6px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-mid);
letter-spacing: 1px;
}
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: var(--glow-green);
animation: blink 1.2s step-start infinite;
}
@keyframes blink { 50% { opacity: .2; } }
.btn-control {
padding: 6px 16px;
font-family: var(--font-display);
font-size: 10px;
font-weight: 700;
letter-spacing: 2px;
border: 1px solid;
border-radius: var(--r);
cursor: pointer;
transition: all .2s;
background: transparent;
}
.btn-pause { border-color: var(--accent-hot); color: var(--accent-hot); }
.btn-pause:hover { background: var(--accent-hot); color: #fff; box-shadow: var(--glow-hot); }
.btn-resume { border-color: var(--accent-green); color: var(--accent-green); }
.btn-resume:hover { background: var(--accent-green); color: #000; box-shadow: var(--glow-green); }
.btn-restart { border-color: var(--accent-gold); color: var(--accent-gold); }
.btn-restart:hover { background: var(--accent-gold); color: #000; box-shadow: 0 0 12px rgba(255,200,0,.4); }
.btn-restart.restarting { opacity:.5; pointer-events:none; }
.btn-kill { border-color: #ff2222; color: #ff2222; }
.btn-kill:hover { background: #ff2222; color: #fff; box-shadow: 0 0 14px rgba(255,34,34,.55); }
.btn-kill:disabled { opacity:.45; pointer-events:none; }
/* ─── Layout ─────────────────────────────────────────────────── */
.shell {
display: flex;
min-height: calc(100vh - 65px);
}
/* ─── Sidebar Nav ────────────────────────────────────────────── */
nav {
width: 200px;
flex-shrink: 0;
border-right: 1px solid var(--border-dim);
padding: 24px 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
cursor: pointer;
font-family: var(--font-ui);
font-weight: 600;
font-size: 13px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-dim);
border-left: 3px solid transparent;
transition: all .15s;
user-select: none;
}
.nav-item:hover { color: var(--text-mid); background: rgba(0,245,255,.04); }
.nav-item.active {
color: var(--accent);
border-left-color: var(--accent);
background: rgba(0,245,255,.07);
text-shadow: var(--glow-cyan);
}
.nav-item .nav-icon { font-size: 16px; width: 18px; text-align: center; }
.nav-divider {
height: 1px;
background: var(--border-dim);
margin: 10px 20px;
}
/* ─── Main Content ───────────────────────────────────────────── */
main {
flex: 1;
padding: 28px;
overflow-y: auto;
animation: fadeIn .25s ease;
}
@keyframes fadeIn { from { opacity:0; transform: translateY(6px); } }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* ─── Page Heading ───────────────────────────────────────────── */
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 24px;
}
.page-title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
letter-spacing: 3px;
color: var(--accent);
text-shadow: var(--glow-cyan);
}
.page-sub {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
margin-top: 4px;
letter-spacing: 1px;
}
/* ─── Stat Cards ─────────────────────────────────────────────── */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
margin-bottom: 28px;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--r);
padding: 18px 20px;
position: relative;
overflow: hidden;
transition: border-color .2s;
}
.stat-card:hover { border-color: var(--accent); box-shadow: var(--glow-cyan); }
.stat-card::before {
content: '';
position: absolute;
top: 0; left: 0;
width: 100%; height: 2px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: .6;
}
.stat-label {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 2px;
color: var(--text-dim);
text-transform: uppercase;
margin-bottom: 8px;
}
.stat-value {
font-family: var(--font-display);
font-size: 28px;
font-weight: 700;
color: var(--accent);
text-shadow: var(--glow-cyan);
line-height: 1;
}
.stat-unit {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-mid);
margin-top: 4px;
}
/* ─── Activity Log ───────────────────────────────────────────── */
.log-box {
background: var(--bg-panel);
border: 1px solid var(--border-dim);
border-radius: var(--r);
padding: 16px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-mid);
height: 200px;
overflow-y: auto;
line-height: 1.7;
}
.log-line { display: flex; gap: 10px; }
.log-time { color: var(--text-dim); flex-shrink: 0; }
.log-msg-ok { color: var(--accent-green); }
.log-msg-warn { color: var(--accent-gold); }
.log-msg-hit { color: var(--accent); }
/* ─── Table ──────────────────────────────────────────────────── */
.table-wrap {
background: var(--bg-panel);
border: 1px solid var(--border-dim);
border-radius: var(--r);
overflow: hidden;
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border-dim);
background: var(--bg-card);
gap: 12px;
flex-wrap: wrap;
}
.toolbar-title {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-mid);
letter-spacing: 1px;
}
.toolbar-actions { display: flex; gap: 8px; align-items: center; }
.search-input {
background: var(--bg-input);
border: 1px solid var(--border-dim);
border-radius: var(--r);
padding: 6px 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-bright);
width: 200px;
outline: none;
transition: border-color .2s;
}
.search-input:focus { border-color: var(--accent); box-shadow: var(--glow-cyan); }
.search-input::placeholder { color: var(--text-dim); }
table { width: 100%; border-collapse: collapse; }
thead tr { background: var(--bg-card); }
th {
padding: 10px 14px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 1.5px;
color: var(--text-dim);
text-transform: uppercase;
text-align: left;
border-bottom: 1px solid var(--border-dim);
}
th.sortable {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
th.sortable:hover { color: var(--accent); }
th.sort-asc { color: var(--accent) !important; }
th.sort-asc::after { content: " ▲"; font-size: 9px; }
th.sort-desc { color: var(--accent-gold) !important; }
th.sort-desc::after { content: " ▼"; font-size: 9px; }
td {
padding: 10px 14px;
font-family: var(--font-ui);
font-size: 13px;
border-bottom: 1px solid #0d1825;
vertical-align: middle;
max-width: 320px;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: rgba(0,245,255,.03); }
.score-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-family: var(--font-mono);
font-size: 11px;
font-weight: bold;
}
.score-pos { background: rgba(0,255,136,.12); color: var(--accent-green); border: 1px solid #00ff8840; }
.score-zero { background: rgba(255,200,60,.1); color: var(--accent-gold); border: 1px solid #f0c04040; }
.listing-title {
color: var(--text-bright);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
display: block;
}
.listing-link {
color: var(--accent);
text-decoration: none;
font-family: var(--font-mono);
font-size: 10px;
opacity: .7;
transition: opacity .15s;
}
.listing-link:hover { opacity: 1; text-shadow: var(--glow-cyan); }
.price-tag {
font-family: var(--font-mono);
color: var(--accent-gold);
font-size: 12px;
text-shadow: 0 0 6px #f0c04066;
}
/* Pulsing glow for lots closing within 5 minutes */
@keyframes tlPulse {
0%,100% { text-shadow: 0 0 4px #ff444466; }
50% { text-shadow: 0 0 14px #ff4444cc, 0 0 4px #ff4444; }
}
.tl-pulse {
animation: tlPulse 1s ease-in-out infinite;
}
.humanize-btn {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 1px;
padding: 8px 4px;
border: 1px solid var(--border-dim);
background: var(--bg-card);
color: var(--text-mid);
transition: all .15s;
}
.humanize-btn:hover { border-color: var(--accent); color: var(--accent); }
.humanize-btn.active-raw { border-color: #ff4444; color: #ff4444; background: rgba(255,68,68,.08); }
.humanize-btn.active-low { border-color: var(--accent-gold); color: var(--accent-gold); background: rgba(240,192,64,.08); }
.humanize-btn.active-medium { border-color: var(--accent); color: var(--accent); background: rgba(0,245,255,.08); }
.humanize-btn.active-heavy { border-color: var(--accent-green); color: var(--accent-green); background: rgba(0,255,136,.08); }
.channel-btn {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 1px;
padding: 8px 4px;
border: 1px solid var(--border-dim);
background: var(--bg-card);
color: var(--text-mid);
transition: all .15s;
}
.channel-btn:hover { border-color: var(--accent); color: var(--accent); }
.channel-btn.active { border-color: var(--accent-green); color: var(--accent-green); background: rgba(0,255,136,.12); }
.site-health-ok { color: var(--accent-green); font-family: var(--font-mono); font-size: 10px; }
.site-health-warn { color: #ff9900; font-family: var(--font-mono); font-size: 10px; }
.site-health-err { color: #ff4444; font-family: var(--font-mono); font-size: 10px; }
.site-cooldown { color: #ff4444; font-family: var(--font-mono); font-size: 10px; font-weight: bold; }
/* ─── AI Debug Log ────────────────────────────────────────────── */
.aidl-card { border-radius: 6px; border: 1px solid; padding: 0; overflow: hidden; font-family: var(--font-mono); font-size: 11px; }
.aidl-card.request { border-color: rgba(0,245,255,.25); background: rgba(0,245,255,.04); }
.aidl-card.response { border-color: rgba(0,255,136,.25); background: rgba(0,255,136,.04); }
.aidl-card.response.verdict-yes { border-color: rgba(0,255,136,.5); background: rgba(0,255,136,.07); }
.aidl-card.response.verdict-no { border-color: rgba(255,68,68,.35); background: rgba(255,68,68,.05); }
.aidl-card.error { border-color: rgba(255,153,0,.35); background: rgba(255,153,0,.05); }
.aidl-header { display:flex; align-items:center; gap:8px; padding: 7px 12px; border-bottom: 1px solid rgba(255,255,255,.06); flex-wrap:wrap; }
.aidl-badge { padding: 2px 7px; border-radius: 3px; font-size: 9px; font-weight: 700; letter-spacing: 1px; }
.aidl-badge.req { background: rgba(0,245,255,.18); color: var(--accent); }
.aidl-badge.resp { background: rgba(0,255,136,.18); color: var(--accent-green); }
.aidl-badge.err { background: rgba(255,153,0,.2); color: #ff9900; }
.aidl-badge.filter { background: rgba(180,0,255,.18); color: #cc88ff; }
.aidl-badge.adapt { background: rgba(255,200,0,.15); color: var(--accent-gold); }
.aidl-body { padding: 10px 12px; white-space: pre-wrap; word-break: break-word; color: var(--text-mid); font-size: 11px; line-height: 1.65; max-height: 320px; overflow-y: auto; }
.aidl-body.collapsed { max-height: 60px; overflow: hidden; position: relative; }
.aidl-body.collapsed::after { content:''; position:absolute; bottom:0; left:0; right:0; height:24px; background:linear-gradient(transparent, var(--bg-card)); }
.aidl-expand { background:none; border:none; color:var(--accent); cursor:pointer; font-family:var(--font-mono); font-size:10px; padding:4px 12px 6px; text-align:left; width:100%; }
.aidebug-filter.active { border-color: var(--accent); color: var(--accent); background: rgba(0,245,255,.1); }
/* ─── Buttons ────────────────────────────────────────────────── */
.btn {
padding: 7px 16px;
font-family: var(--font-ui);
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
border-radius: var(--r);
cursor: pointer;
border: 1px solid;
transition: all .15s;
text-transform: uppercase;
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #000;
}
.btn-primary:hover { box-shadow: var(--glow-cyan); transform: translateY(-1px); }
.btn-danger {
background: transparent;
border-color: var(--accent-hot);
color: var(--accent-hot);
}
.btn-danger:hover { background: var(--accent-hot); color: #fff; box-shadow: var(--glow-hot); }
.btn-ghost {
background: transparent;
border-color: var(--border-dim);
color: var(--text-mid);
}
.btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
.btn-sm { padding: 4px 10px; font-size: 10px; }
/* ─── Forms ──────────────────────────────────────────────────── */
.form-section {
background: var(--bg-panel);
border: 1px solid var(--border-dim);
border-radius: var(--r);
padding: 20px 24px;
margin-bottom: 20px;
}
.form-section-title {
font-family: var(--font-display);
font-size: 12px;
letter-spacing: 2.5px;
color: var(--accent);
text-transform: uppercase;
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-dim);
}
.form-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: flex-end;
margin-bottom: 14px;
}
.form-group { display: flex; flex-direction: column; gap: 5px; flex: 1; min-width: 160px; }
.form-group.fixed { flex: 0 0 auto; }
label {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
letter-spacing: 1.5px;
text-transform: uppercase;
}
input, textarea, select {
background: var(--bg-input);
border: 1px solid var(--border-dim);
border-radius: var(--r);
padding: 9px 12px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-bright);
outline: none;
transition: border-color .2s;
width: 100%;
}
input:focus, textarea:focus { border-color: var(--accent); box-shadow: var(--glow-cyan); }
input::placeholder { color: var(--text-dim); }
textarea { resize: vertical; min-height: 60px; }
/* ─── Alert Banner ───────────────────────────────────────────── */
.alert {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border-radius: var(--r);
margin-bottom: 16px;
font-family: var(--font-mono);
font-size: 11px;
animation: slideIn .2s ease;
}
@keyframes slideIn { from { opacity:0; transform: translateX(-10px); } }
.alert-success { background: rgba(0,255,136,.1); border: 1px solid var(--accent-green); color: var(--accent-green); }
.alert-error { background: rgba(255,62,108,.1); border: 1px solid var(--accent-hot); color: var(--accent-hot); }
#alert-zone { min-height: 0; }
/* ─── Tag chips ──────────────────────────────────────────────── */
.chip {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 9px;
background: rgba(0,245,255,.08);
border: 1px solid rgba(0,245,255,.2);
border-radius: 20px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--accent);
}
.drag-handle { cursor: grab; color: var(--text-dim); font-size: 14px; padding: 0 6px; user-select: none; }
.drag-handle:active { cursor: grabbing; }
tr.drag-over { outline: 2px solid var(--accent); }
.currency-opt { padding: 6px 12px; font-family: var(--font-mono); font-size: 12px; color: var(--text-hi); cursor: pointer; }
.currency-opt:hover { background: rgba(0,245,255,.1); color: var(--accent); }
/* ─── Empty state ────────────────────────────────────────────── */
.empty-state {
text-align: center;
padding: 48px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 1px;
}
.empty-state .big-icon { font-size: 40px; margin-bottom: 12px; }
/* ─── Grid 2-col ─────────────────────────────────────────────── */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
@media(max-width:900px){ .grid-2 { grid-template-columns: 1fr; } }
/* ─── Tooltip ────────────────────────────────────────────────── */
[data-tip] { position: relative; cursor: help; }
[data-tip]:hover::after {
content: attr(data-tip);
position: absolute;
bottom: 120%; left: 50%;
transform: translateX(-50%);
white-space: nowrap;
background: #0d1525;
border: 1px solid var(--border-dim);
color: var(--text-mid);
font-family: var(--font-mono);
font-size: 10px;
padding: 4px 8px;
border-radius: 3px;
pointer-events: none;
z-index: 200;
}
/* ─── Loader spinner ─────────────────────────────────────────── */
.spinner {
width: 16px; height: 16px;
border: 2px solid var(--border-dim);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .7s linear infinite;
display: inline-block;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Browser selector cards ─────────────────────────── */
.browser-card {
cursor: pointer;
border-radius: 6px;
border: 1px solid rgba(255,255,255,.08);
background: rgba(255,255,255,.03);
transition: border-color .15s, background .15s;
}
.browser-card:hover { border-color: rgba(0,245,255,.35); background: rgba(0,245,255,.05); }
.browser-card.selected {
border-color: var(--accent);
background: rgba(0,245,255,.1);
box-shadow: 0 0 10px rgba(0,245,255,.15);
}
.browser-card-inner {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 8px;
gap: 4px;
}
.browser-icon { font-size: 22px; line-height: 1; }
.browser-name { font-family: var(--font-mono); font-size: 11px; font-weight: 700;
color: var(--text-hi); letter-spacing: .5px; }
.browser-desc { font-family: var(--font-mono); font-size: 9px;
color: var(--text-dim); text-align: center; }
/* ── Incognito toggle switch ─────────────────────────── */
.toggle-wrap { display:inline-flex; align-items:center; cursor:pointer; }
.toggle-input { display:none; }
.toggle-track {
width:48px; height:26px; border-radius:13px;
background:rgba(255,255,255,.08);
border:1px solid rgba(255,255,255,.12);
position:relative; transition:background .2s, border-color .2s;
}
.toggle-input:checked ~ .toggle-track {
background:rgba(0,245,255,.25);
border-color:var(--accent);
box-shadow:0 0 8px rgba(0,245,255,.3);
}
.toggle-thumb {
width:20px; height:20px; border-radius:50%;
background:var(--text-mid);
position:absolute; top:2px; left:3px;
transition:left .2s, background .2s;
}
.toggle-input:checked ~ .toggle-track .toggle-thumb {
left:23px; background:var(--accent);
}
/* ── Delay config cards ─────────────────────────────── */
.delay-hint {
font-family: var(--font-mono); font-size: 10px;
color: var(--text-dim); margin-top: 4px; line-height: 1.6;
}
.delay-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-top: 14px;
}
.delay-card-wide { grid-column: 1 / -1; }
.delay-card {
display: flex; gap: 10px; align-items: flex-start;
background: rgba(255,255,255,.03);
border: 1px solid rgba(255,255,255,.07);
border-radius: 6px; padding: 12px;
transition: border-color .15s;
}
.delay-card:hover { border-color: rgba(0,245,255,.25); }
.delay-card-num {
font-family: var(--font-mono); font-size: 18px;
color: var(--accent); min-width: 24px; padding-top: 2px;
text-shadow: var(--glow-cyan);
}
.delay-card-body { flex: 1; }
.delay-label {
display: flex; align-items: baseline; gap: 6px;
font-family: var(--font-mono); font-size: 11px;
font-weight: 700; color: var(--text-hi);
letter-spacing: .5px; margin-bottom: 6px;
}
.delay-unit {
font-size: 9px; color: var(--accent);
border: 1px solid var(--accent); border-radius: 3px;
padding: 1px 4px; letter-spacing: 1px;
}
.delay-card input[type=number] {
width: 100%; margin-bottom: 8px;
}
/* hide number spinners — only digits via oninput */
.delay-card input[type=number]::-webkit-inner-spin-button,
.delay-card input[type=number]::-webkit-outer-spin-button { -webkit-appearance: none; }
.delay-card input[type=number] { -moz-appearance: textfield; }
.delay-desc {
font-family: var(--font-mono); font-size: 9px;
color: var(--text-dim); line-height: 1.65;
}
.delay-desc b { color: var(--text-mid); }
</style>
</head>
<body>
<!-- ─── Header ─────────────────────────────────────────────────────────────── -->
<header>
<div class="logo">
<div class="logo-icon">👻</div>
<div>
<div class="logo-text">GHOST NODE</div>
<div class="logo-sub">AUCTION SNIPER v1.0 — RTX 3050 NODE</div>
</div>
</div>
<div class="header-status">
<div class="status-pill">
<div class="status-dot" id="engine-dot"></div>
<span id="engine-status-label">INITIALIZING…</span>
</div>
<button class="btn-control btn-pause" onclick="enginePause()">⏸ PAUSE</button>
<button class="btn-control btn-resume" onclick="engineResume()">▶ RESUME</button>
<button class="btn-control btn-restart" id="btn-restart" onclick="engineRestart()">🔄 RESTART</button>
<button class="btn-control btn-kill" id="btn-kill" onclick="engineKill()">☠ KILL</button>
</div>
</header>
<!-- ─── Shell ──────────────────────────────────────────────────────────────── -->
<div class="shell">
<!-- Sidebar -->
<nav>
<div class="nav-item active" data-tab="dashboard">
<span class="nav-icon">📡</span> Dashboard
</div>
<div class="nav-item" data-tab="listings">
<span class="nav-icon">🎯</span> Listings
</div>
<div class="nav-divider"></div>
<div class="nav-item" data-tab="keywords">
<span class="nav-icon">🔍</span> Keywords
</div>
<div class="nav-item" data-tab="sites">
<span class="nav-icon">🌐</span> Target Sites
</div>
<div class="nav-divider"></div>
<div class="nav-item" data-tab="settings">
<span class="nav-icon">⚙️</span> Settings
</div>
<div class="nav-divider"></div>
<div class="nav-item" data-tab="aidebug">
<span class="nav-icon">🧠</span> AI Log
</div>
</nav>
<!-- Main -->
<main>
<div id="alert-zone"></div>
<!-- ═══ TAB: Dashboard ═══════════════════════════════════════════════════ -->
<div class="tab-panel active" id="tab-dashboard">
<div class="page-header">
<div>
<div class="page-title">SYSTEM DASHBOARD</div>
<div class="page-sub">REAL-TIME NODE TELEMETRY</div>
</div>
<div class="status-pill">
<span id="last-cycle-label" style="font-family:var(--font-mono);font-size:11px;color:var(--text-dim)">
Last cycle: —
</span>
</div>
</div>
<div class="stat-grid">
<div class="stat-card">
<div class="stat-label">// TOTAL SCANNED</div>
<div class="stat-value" id="stat-scanned">0</div>
<div class="stat-unit">listings processed</div>
</div>
<div class="stat-card">
<div class="stat-label">// ALERTS FIRED</div>
<div class="stat-value" id="stat-alerts">0</div>
<div class="stat-unit">qualifying hits</div>
</div>
<div class="stat-card">
<div class="stat-label">// UPTIME</div>
<div class="stat-value" id="stat-uptime">0h</div>
<div class="stat-unit">continuous operation</div>
</div>
<div class="stat-card">
<div class="stat-label">// ENGINE</div>
<div class="stat-value" style="font-size:14px;padding-top:6px;" id="stat-engine"></div>
<div class="stat-unit">current state</div>
</div>
</div>
<!-- Activity Log -->
<div class="form-section">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
<div class="form-section-title" style="margin-bottom:0">// ACTIVITY LOG</div>
<div style="display:flex;gap:6px;align-items:center">
<input id="log-search" type="text" placeholder="🔍 filter log…" oninput="filterLog()" style="background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:4px 8px;font-family:var(--font-mono);font-size:11px;width:160px"/>
<button class="btn btn-ghost btn-sm" onclick="log_scrollBottom()" title="Scroll to bottom"></button>
<button class="btn btn-danger btn-sm" onclick="clearLog()" title="Clear log">🗑</button>
</div>
</div>
<div class="log-box" id="log-box">
<div class="log-line">
<span class="log-time">[BOOT]</span>
<span class="log-msg-ok">Ghost Node dashboard initialized.</span>
</div>
</div>
</div>
<!-- Recent hits mini table -->
<div class="form-section">
<div class="form-section-title">// RECENT HITS (top 5)</div>
<div id="recent-hits-container">
<div class="empty-state"><div class="big-icon">🔍</div>No hits yet.</div>
</div>
</div>
</div>
<!-- ═══ TAB: Listings ════════════════════════════════════════════════════ -->
<div class="tab-panel" id="tab-listings">
<div class="page-header">
<div>
<div class="page-title">CAPTURED LISTINGS</div>
<div class="page-sub">ALL QUALIFYING AUCTION HITS</div>
</div>
<div style="display:flex;gap:8px">
<button class="btn btn-ghost" onclick="loadListings()">↺ Refresh</button>
<button class="btn btn-danger btn-sm" onclick="clearListings()">🗑 Clear All</button>
</div>
</div>
<div class="table-wrap">
<div class="table-toolbar">
<span class="toolbar-title">LISTINGS TABLE</span>
<div class="toolbar-actions">
<input class="search-input" type="text" placeholder="filter titles…" id="listing-filter" oninput="filterListings()" />
</div>
</div>
<div id="listings-table-container">
<div class="empty-state"><div class="big-icon">👻</div>Loading…</div>
</div>
</div>
</div>
<!-- ═══ TAB: Keywords ════════════════════════════════════════════════════ -->
<div class="tab-panel" id="tab-keywords">
<div class="page-header">
<div>
<div class="page-title">KEYWORD MATRIX</div>
<div class="page-sub">SEARCH TERMS & SCORING WEIGHTS</div>
</div>
</div>
<div class="form-section">
<div class="form-section-title">// ADD KEYWORD</div>
<div class="form-row">
<div class="form-group">
<label>Search Term</label>
<input type="text" id="kw-term" placeholder="e.g. RTX 4090" />
</div>
<div class="form-group fixed" style="max-width:100px">
<label>Weight <span data-tip="Score multiplier"></span></label>
<input type="number" id="kw-weight" value="1" min="1" max="10" />
</div>
<div class="form-group fixed" style="align-self:flex-end">
<button class="btn btn-primary" onclick="addKeyword()">+ ADD</button>
</div>
</div>
</div>
<!-- Batch import -->
<div class="form-section">
<div class="form-section-title">// BATCH IMPORT KEYWORDS</div>
<div style="font-family:var(--font-mono);font-size:11px;color:var(--text-dim);margin-bottom:8px">
One keyword per line. Optional weight: <b style="color:var(--accent)">laptop:3</b> or just <b style="color:var(--accent)">laptop</b> (defaults to weight 1)
</div>
<textarea id="kw-batch" rows="5" placeholder="RTX 4090:5&#10;PS5:3&#10;MacBook Pro&#10;Steam Deck:4" style="width:100%;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box;resize:vertical;margin-bottom:8px"></textarea>
<button class="btn btn-primary" onclick="batchImportKeywords()">⬇ Import All</button>
<span id="batch-result" style="font-family:var(--font-mono);font-size:11px;color:var(--accent-green);margin-left:12px"></span>
</div>
<div class="table-wrap">
<div class="table-toolbar">
<span class="toolbar-title">ACTIVE KEYWORDS</span>
</div>
<div id="keywords-table-container">
<div class="empty-state"><div class="big-icon">🔍</div>Loading…</div>
</div>
</div>
</div>
<!-- ═══ TAB: Target Sites ════════════════════════════════════════════════ -->
<div class="tab-panel" id="tab-sites">
<div class="page-header">
<div>
<div class="page-title">TARGET SITES</div>
<div class="page-sub">SITE URL TEMPLATES & SELECTORS</div>
</div>
</div>
<div class="form-section">
<div class="form-section-title">// REGISTER NEW SITE</div>
<!-- Mode explanation banner -->
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap">
<div style="flex:1;min-width:220px;background:rgba(0,245,255,.05);border:1px solid rgba(0,245,255,.15);border-radius:4px;padding:10px 14px">
<div style="font-family:var(--font-mono);font-size:10px;color:var(--accent);letter-spacing:1.5px;margin-bottom:4px">MODE A — DIRECT URL</div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-mid);line-height:1.6">
Include <code style="color:var(--accent-gold)">{keyword}</code> in the URL.<br/>
e.g. <code>ebay.co.uk/sch/?_nkw={keyword}</code><br/>
Scraper substitutes the term and navigates directly.
</div>
</div>
<div style="flex:1;min-width:220px;background:rgba(0,255,136,.05);border:1px solid rgba(0,255,136,.15);border-radius:4px;padding:10px 14px">
<div style="font-family:var(--font-mono);font-size:10px;color:var(--accent-green);letter-spacing:1.5px;margin-bottom:4px">MODE B — HOMEPAGE SEARCH</div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-mid);line-height:1.6">
Paste the homepage URL as-is.<br/>
e.g. <code>shopgoodwill.com/home</code><br/>
Scraper auto-discovers the search box and types.
</div>
</div>
</div>
<div class="form-row">
<div class="form-group fixed" style="max-width:180px">
<label>Site Name</label>
<input type="text" id="site-name" placeholder="e.g. ShopGoodwill" />
</div>
<div class="form-group">
<label>
URL
<span id="mode-badge" style="margin-left:8px;font-size:9px;padding:2px 7px;border-radius:3px;font-weight:700;letter-spacing:1px;display:none"></span>
</label>
<input type="text" id="site-url"
placeholder="homepage URL or https://site.com/search?q={keyword}"
oninput="updateModeBadge(this.value)" />
</div>
<div class="form-group fixed" style="max-width:180px">
<label>Search Selector <span data-tip="Optional CSS selector, e.g. input#st — leave blank to let Ghost Node auto-discover the search box"></span></label>
<input type="text" id="site-selector" placeholder="optional — e.g. input#st" />
</div>
<div class="form-group fixed" style="max-width:80px">
<label>Pages <span data-tip="How many result pages to scrape per keyword (1 = first page only)"></span></label>
<input type="number" id="site-max-pages" value="1" min="1" max="20" style="text-align:center" />
</div>
<div class="form-group fixed" style="align-self:flex-end">
<button class="btn btn-primary" onclick="addSite()">+ ADD SITE</button>
</div>
</div>
<!-- Login / session options (collapsed by default) -->
<details style="margin-top:12px;font-family:var(--font-mono)">
<summary style="cursor:pointer;font-size:10px;color:var(--text-dim);letter-spacing:1px;list-style:none">
▶ LOGIN / SESSION OPTIONS (for sites requiring an account)
</summary>
<div style="margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:12px;background:rgba(0,0,0,.3);border-radius:4px;border:1px solid var(--border-dim)">
<div style="grid-column:1/-1;display:flex;align-items:center;gap:10px">
<label class="toggle-wrap" for="site-requires-login">
<input type="checkbox" id="site-requires-login" class="toggle-input"/>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
<span style="font-size:11px;color:var(--text-hi)">This site requires login before scraping</span>
</div>
<div>
<label style="font-size:10px;color:var(--text-dim)">LOGIN PAGE URL</label>
<input type="text" id="site-login-url" placeholder="https://site.com/signin" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:7px;font-family:var(--font-mono);font-size:11px;box-sizing:border-box"/>
</div>
<div>
<label style="font-size:10px;color:var(--text-dim)">LOGGED-IN SELECTOR <span data-tip="CSS selector of an element that only exists when logged in, e.g. #user-menu"></span></label>
<input type="text" id="site-login-check" placeholder="e.g. #user-menu or .logout-btn" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:7px;font-family:var(--font-mono);font-size:11px;box-sizing:border-box"/>
</div>
<div style="grid-column:1/-1;font-size:10px;color:var(--text-dim);line-height:1.6">
After adding the site, use the <span style="color:var(--accent-gold)">🔑 Login</span> button in the table below.
Ghost Node will open a visible browser window — log in manually, close it, and your session is saved automatically for all future scrapes.
</div>
</div>
</details>
</div>
<div class="table-wrap">
<div class="table-toolbar">
<span class="toolbar-title">REGISTERED SITES</span>
</div>
<div id="sites-table-container">
<div class="empty-state"><div class="big-icon">🌐</div>Loading…</div>
</div>
</div>
</div>
<!-- ═══ TAB: Settings ════════════════════════════════════════════════════ -->
<div class="tab-panel" id="tab-settings">
<div class="page-header">
<div>
<div class="page-title">SYSTEM SETTINGS</div>
<div class="page-sub">TELEGRAM C2 & SCRAPER CONFIGURATION</div>
</div>
</div>
<div class="grid-2">
<div class="form-section">
<div class="form-section-title">// TELEGRAM C2 CONFIG</div>
<div class="form-group" style="margin-bottom:14px">
<label>Bot Token</label>
<input type="password" id="cfg-token" placeholder="123456:ABC-DEF1234…" />
</div>
<div class="form-group" style="margin-bottom:14px">
<label>Chat ID</label>
<input type="text" id="cfg-chatid" placeholder="-100123456789" />
</div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-bottom:14px;line-height:1.7">
Commands: /status · /pause · /resume · /listings
</div>
</div>
<div class="form-section">
<div class="form-section-title">// SCRAPER TIMING</div>
<div class="form-group" style="margin-bottom:14px">
<label>Cycle Timer (seconds) <span data-tip="Delay between full scrape cycles"></span></label>
<input type="number" id="cfg-timer" placeholder="120" min="30" max="3600"
oninput="this.value=this.value.replace(/[^0-9]/g,'')" />
<div class="delay-hint">Minimum: 30s · Recommended: 120300s · Jitter ±515s between keywords</div>
</div>
<div class="delay-grid">
<div class="delay-card">
<div class="delay-card-num"></div>
<div class="delay-card-body">
<label class="delay-label">
Post-Launch Delay <span class="delay-unit">sec</span>
</label>
<input type="number" id="cfg-delay-launch" placeholder="0" min="0" max="300"
oninput="this.value=this.value.replace(/[^0-9]/g,'')" />
<div class="delay-desc">
Wait <b>after the browser opens</b> but before the first page navigation.<br/>
Lets the browser fully initialise. Set 0 to skip.
</div>
</div>
</div>
<div class="delay-card">
<div class="delay-card-num"></div>
<div class="delay-card-body">
<label class="delay-label">
Website-Launch Delay <span class="delay-unit">sec</span>
</label>
<input type="number" id="cfg-delay-site-open" placeholder="0" min="0" max="300"
oninput="this.value=this.value.replace(/[^0-9]/g,'')" />
<div class="delay-desc">
Applied <b>once per site launch</b> — fires when the browser first opens a
target website, before any keyword is processed.<br/>
When the engine moves to the next site, it fires once again for that site.<br/>
<b>Does not repeat</b> between keywords on the same site. Set 0 to skip.
</div>
</div>
</div>
<div class="delay-card">
<div class="delay-card-num"></div>
<div class="delay-card-body">
<label class="delay-label">
Post-Search Delay <span class="delay-unit">sec</span>
</label>
<input type="number" id="cfg-delay-search" placeholder="0" min="0" max="300"
oninput="this.value=this.value.replace(/[^0-9]/g,'')" />
<div class="delay-desc">
Wait <b>after the results page loads</b> but before extracting listings.<br/>
Useful for lazy-loaded pages. Set 0 to scrape immediately.
</div>
</div>
</div>
<div class="delay-card">
<div class="delay-card-num"></div>
<div class="delay-card-body">
<label class="delay-label">
Page-Hold Timer <span class="delay-unit">sec</span>
</label>
<input type="number" id="cfg-delay-hold" placeholder="0" min="0" max="3600"
oninput="this.value=this.value.replace(/[^0-9]/g,'')" />
<div class="delay-desc">
<b>Hold the results page open</b> for this many seconds, re-scraping each time
a pass completes. New dynamic listings are captured. Duplicates suppressed.
Set 0 to scrape once and move on.
</div>
</div>
</div>
<div class="delay-card delay-card-wide" style="border-left:2px solid rgba(0,245,255,.15);background:rgba(0,245,255,.02)">
<div class="delay-card-num" style="color:var(--text-dim)"></div>
<div class="delay-card-body">
<div class="delay-desc" style="color:var(--text-mid);font-size:10px;line-height:1.8">
<b style="color:var(--accent)">Page-Hold re-scrape behaviour:</b>
When ④ is active the scraper finishes each pass then <b>immediately</b> starts the
next — no idle gap. Passes repeat until the timer expires. Each listing link is
saved exactly once — clones across all passes are automatically suppressed.
</div>
</div>
</div>
</div>
</div>
<div class="form-section" style="margin-top:20px">
<div class="form-section-title">// BROWSER ENGINE</div>
<div class="form-group" style="margin-bottom:10px">
<label>Scraper Browser <span data-tip="Ghost Node will search for this browser on your machine. Falls back to Playwright Chromium if not found."></span></label>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:6px" id="browser-selector">
<label class="browser-card" data-value="auto">
<input type="radio" name="browser_choice" value="auto" style="display:none" />
<div class="browser-card-inner">
<span class="browser-icon">🔍</span>
<span class="browser-name">Auto-Detect</span>
<span class="browser-desc">Edge → Yandex → Chromium</span>
</div>
</label>
<label class="browser-card" data-value="chrome">
<input type="radio" name="browser_choice" value="chrome" style="display:none" />
<div class="browser-card-inner">
<span class="browser-icon">🟡</span>
<span class="browser-name">Google Chrome</span>
<span class="browser-desc">chrome / google-chrome</span>
</div>
</label>
<label class="browser-card" data-value="edge">
<input type="radio" name="browser_choice" value="edge" style="display:none" />
<div class="browser-card-inner">
<span class="browser-icon">🔵</span>
<span class="browser-name">Microsoft Edge</span>
<span class="browser-desc">msedge.exe</span>
</div>
</label>
<label class="browser-card" data-value="yandex">
<input type="radio" name="browser_choice" value="yandex" style="display:none" />
<div class="browser-card-inner">
<span class="browser-icon">🔴</span>
<span class="browser-name">Yandex Browser</span>
<span class="browser-desc">browser.exe / yandex-browser</span>
</div>
</label>
<label class="browser-card" data-value="brave">
<input type="radio" name="browser_choice" value="brave" style="display:none" />
<div class="browser-card-inner">
<span class="browser-icon">🦁</span>
<span class="browser-name">Brave</span>
<span class="browser-desc">brave.exe / brave-browser</span>
</div>
</label>
</div>
</div>
<div id="browser-status" style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:8px;line-height:1.7">
Restart Ghost Node after changing the browser for it to take effect.
</div>
</div>
</div>
<div class="form-section" style="margin-top:20px">
<div class="form-section-title">// INCOGNITO MODE</div>
<div style="display:flex;align-items:center;gap:16px;margin-top:8px">
<label class="toggle-wrap" for="cfg-incognito">
<input type="checkbox" id="cfg-incognito" class="toggle-input" />
<span class="toggle-track">
<span class="toggle-thumb"></span>
</span>
</label>
<div>
<div style="font-family:var(--font-mono);font-size:12px;color:var(--text-hi);font-weight:700" id="incognito-label">OFF</div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:3px">
When ON: each scrape session launches with <code>--incognito</code> — no cookies,<br/>
no cached sessions, no login state. Harder to track, slower to warm up.
</div>
</div>
</div>
</div>
<div class="form-section" style="margin-top:20px">
<div class="form-section-title">// BROWSER VISIBILITY</div>
<div style="display:flex;align-items:center;gap:16px;margin-top:8px">
<label class="toggle-wrap" for="cfg-headless">
<input type="checkbox" id="cfg-headless" class="toggle-input" />
<span class="toggle-track">
<span class="toggle-thumb"></span>
</span>
</label>
<div>
<div style="font-family:var(--font-mono);font-size:12px;color:var(--text-hi);font-weight:700" id="headless-label">HEADLESS — Browser hidden</div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:3px">
Toggle ON to open a <b style="color:var(--accent-gold)">visible browser window</b> so you can watch<br/>
every page load, search and result in real time. Useful for debugging.<br/>
Takes effect on the next scrape cycle — no restart needed.
</div>
</div>
</div>
</div>
<div class="form-section" style="margin-top:20px">
<div class="form-section-title">// HUMANIZE LEVEL</div>
<div style="margin-top:10px">
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px" id="humanize-btns">
<button class="btn humanize-btn" data-val="raw" onclick="selectHumanize('raw')" title="No simulation — fastest, zero protection">
⚡ RAW
</button>
<button class="btn humanize-btn" data-val="low" onclick="selectHumanize('low')" title="One quick mouse move + scroll">
🐇 LOW
</button>
<button class="btn humanize-btn" data-val="medium" onclick="selectHumanize('medium')" title="Mouse + scroll + idle pauses">
🧍 MEDIUM
</button>
<button class="btn humanize-btn" data-val="heavy" onclick="selectHumanize('heavy')" title="Full simulation — bezier mouse, typos, read pauses, homepage pre-visit">
🐢 HEAVY
</button>
</div>
<div style="margin-top:8px;font-family:var(--font-mono);font-size:10px;color:var(--text-dim)" id="humanize-desc">
Select a level above.
</div>
</div>
</div>
<!-- N2: CAPTCHA Solver -->
<div class="form-section" style="margin-top:20px">
<div class="form-section-title">// CAPTCHA SOLVER</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:10px">
<div>
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">SERVICE</label>
<select id="cfg-captcha-solver" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px">
<option value="none">None (disabled)</option>
<option value="2captcha">2captcha.com</option>
<option value="capsolver">CapSolver.com</option>
</select>
</div>
<div>
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">API KEY</label>
<input id="cfg-captcha-key" type="password" placeholder="Paste your API key…" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box"/>
</div>
</div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:6px">When enabled, Ghost Node will auto-solve CAPTCHAs via the chosen service. Get an API key at 2captcha.com or capsolver.com.</div>
</div>
<!-- N16: AI Filter -->
<div class="form-section" style="margin-top:20px;border-color:var(--accent-green)">
<div class="form-section-title" style="color:var(--accent-green)">// AI SMART FILTER <span style="color:var(--text-dim);font-size:10px;font-weight:400">— powered by Groq (free) or Ollama (local)</span></div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:6px;margin-bottom:12px;line-height:1.7">
When enabled, the AI reads each lot title and decides if it matches your natural-language target description per keyword.<br>
Eliminates accessories, cases, chargers, wrong generations, etc. that keywords alone miss.<br>
Set an <b style="color:var(--accent-green)">AI Target</b> on each keyword in the Keywords tab.
</div>
<!-- Master toggle -->
<div style="display:flex;align-items:center;gap:16px;margin-bottom:14px">
<label class="toggle-wrap" for="cfg-ai-enabled">
<input type="checkbox" id="cfg-ai-enabled" class="toggle-input" onchange="document.getElementById('ai-enabled-label').textContent = this.checked ? 'ON' : 'OFF'"/>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
<div style="font-family:var(--font-mono);font-size:12px;color:var(--accent-green);font-weight:700" id="ai-enabled-label">OFF</div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">Only affects keywords that have an AI Target set</div>
</div>
<!-- Debug mode toggle -->
<div style="display:flex;align-items:center;gap:16px;margin-bottom:14px;padding:8px 10px;background:rgba(255,165,0,.05);border:1px solid rgba(255,165,0,.15);border-radius:4px">
<label class="toggle-wrap" for="cfg-ai-debug">
<input type="checkbox" id="cfg-ai-debug" class="toggle-input" onchange="document.getElementById('ai-debug-label').textContent = this.checked ? 'ON' : 'OFF'"/>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
<div style="font-family:var(--font-mono);font-size:12px;color:#ff9900;font-weight:700" id="ai-debug-label">OFF</div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">
<b style="color:#ff9900">DEBUG MODE</b> — prints every prompt &amp; raw response to the console. Turn on to inspect AI behaviour, turn off in production.
</div>
</div>
<!-- Provider + Model -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
<div>
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">PROVIDER</label>
<select id="cfg-ai-provider" onchange="onAiProviderChange()" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px">
<option value="groq">Groq (free cloud API — best quality)</option>
<option value="ollama">Ollama (local — unlimited, offline)</option>
<option value="none">None (disabled)</option>
</select>
</div>
<div>
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">MODEL</label>
<input id="cfg-ai-model" type="text" placeholder="llama-3.3-70b-versatile" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box"/>
</div>
</div>
<!-- Groq section -->
<div id="ai-groq-section">
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">GROQ API KEY <span style="color:var(--accent-gold)">(free — get one at console.groq.com → API Keys)</span></label>
<input id="cfg-ai-api-key" type="password" placeholder="gsk_…" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box"/>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:4px">Free tier: ~1,300 requests/day. Ghost Node only calls AI on NEW, unique listings — so this is more than enough for normal use.</div>
</div>
<!-- Ollama section -->
<div id="ai-ollama-section" style="display:none">
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">OLLAMA BASE URL</label>
<input id="cfg-ai-base-url" type="text" placeholder="http://localhost:11434" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box"/>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:4px">Install Ollama from <b style="color:var(--accent)">ollama.com/download/windows</b> then run: <code style="color:var(--accent-green)">ollama pull llama3.2:3b</code></div>
</div>
<!-- N17: Auto-Adapter toggle -->
<div style="margin-top:18px;padding-top:14px;border-top:1px solid var(--border-dim)">
<div style="font-family:var(--font-mono);font-size:11px;color:var(--accent-gold);font-weight:700;margin-bottom:6px;letter-spacing:1px">AUTO-ADAPTER <span style="font-size:9px;color:var(--text-dim);font-weight:400">— N17</span></div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-bottom:10px;line-height:1.7">
When enabled, Ghost Node uses the AI to auto-generate CSS selectors for each target site.<br>
It reads the page HTML, asks the AI to identify the listing container, title, price, time and link selectors,<br>
validates them live, then stores them for reuse. Works with any site — Angular, React, Vue, or plain HTML.<br>
Use the <b style="color:var(--accent-gold)">🤖 Adapt</b> button in the Sites tab to run immediately on any site.
</div>
<div style="display:flex;align-items:center;gap:16px">
<label class="toggle-wrap" for="cfg-auto-adapt-enabled">
<input type="checkbox" id="cfg-auto-adapt-enabled" class="toggle-input" onchange="document.getElementById('auto-adapt-label').textContent = this.checked ? 'ON' : 'OFF'"/>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
<div style="font-family:var(--font-mono);font-size:12px;color:var(--accent-gold);font-weight:700" id="auto-adapt-label">OFF</div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">Automatically re-adapts stale sites during scrape cycles</div>
</div>
</div>
</div>
<!-- N9+: Closing Soon Alerts (multi-interval) -->
<div class="form-section" style="margin-top:20px">
<div class="form-section-title">// CLOSING SOON ALERTS</div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:6px;margin-bottom:10px;line-height:1.7">
Only fires for lots that have a captured countdown timer. Each threshold fires once per lot.<br>
Set to <b style="color:var(--accent-gold)">0</b> or leave schedule empty to get <b>no</b> closing alerts (capture alert only).
</div>
<div style="display:flex;align-items:center;gap:16px;margin-bottom:12px">
<label class="toggle-wrap" for="cfg-closing-enabled">
<input type="checkbox" id="cfg-closing-enabled" class="toggle-input" onchange="document.getElementById('closing-enabled-label').textContent = this.checked ? 'ON' : 'OFF'"/>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
<div style="font-family:var(--font-mono);font-size:12px;color:var(--text-hi);font-weight:700" id="closing-enabled-label">OFF</div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">Enable closing-soon alerts</div>
</div>
<!-- Alert schedule presets -->
<div style="margin-bottom:8px">
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">ALERT SCHEDULE — minutes before closing (comma-separated)</label>
<div style="display:flex;gap:6px;margin:6px 0;flex-wrap:wrap">
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('cfg-closing-schedule').value='60,30,10,5'" style="font-size:9px">60,30,10,5</button>
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('cfg-closing-schedule').value='30,10,5'" style="font-size:9px">30,10,5</button>
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('cfg-closing-schedule').value='30'" style="font-size:9px">30 only</button>
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('cfg-closing-schedule').value='10'" style="font-size:9px">10 only</button>
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('cfg-closing-schedule').value='0'" style="font-size:9px;color:#888;border-color:#444">None (0)</button>
</div>
<input id="cfg-closing-schedule" type="text" placeholder="e.g. 60,30,10,5 or 0 for no closing alerts" style="width:100%;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box"/>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:4px">Example: <b style="color:var(--accent)">60,30,10,5</b> fires 4 alerts at 60 min, 30 min, 10 min, and 5 min left. <b style="color:var(--accent)">0</b> = capture alert only, no countdown alerts.</div>
</div>
</div>
<!-- N10: Alert Channels -->
<div class="form-section" style="margin-top:20px">
<div class="form-section-title">// ALERT CHANNELS</div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:6px;margin-bottom:10px">Choose one or multiple channels. All enabled channels receive every alert simultaneously.</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px" id="channel-btns">
<button class="btn channel-btn" data-val="telegram" onclick="toggleChannel('telegram')" id="ch-telegram">📱 TELEGRAM</button>
<button class="btn channel-btn" data-val="discord" onclick="toggleChannel('discord')" id="ch-discord">🎮 DISCORD</button>
<button class="btn channel-btn" data-val="email" onclick="toggleChannel('email')" id="ch-email">📧 EMAIL</button>
</div>
<div style="margin-top:12px">
<div id="discord-section" style="display:none">
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">DISCORD WEBHOOK URL</label>
<input id="cfg-discord-webhook" type="text" placeholder="https://discord.com/api/webhooks/…" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:11px;box-sizing:border-box"/>
</div>
<div id="email-section" style="display:none;margin-top:10px">
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-bottom:10px;line-height:1.7">
Uses Gmail via an <b style="color:var(--accent-gold)">App Password</b> (not your main Gmail password).<br>
1. Enable 2-Step Verification on your Google account.<br>
2. Go to <b style="color:var(--accent)">myaccount.google.com/apppasswords</b> → create an app password → paste it below.
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px">
<div>
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">GMAIL ADDRESS <span style="color:var(--accent-gold)">(sender)</span></label>
<input id="cfg-gmail-address" type="text" placeholder="yourname@gmail.com" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:11px;box-sizing:border-box"/>
</div>
<div>
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">APP PASSWORD <span style="color:var(--accent-gold)">(16-char code)</span></label>
<input id="cfg-gmail-app-password" type="password" placeholder="xxxx xxxx xxxx xxxx" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:11px;box-sizing:border-box"/>
</div>
</div>
<div>
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">SEND ALERTS TO <span style="color:var(--accent-gold)">(destination email)</span></label>
<input id="cfg-email-to" type="text" placeholder="alerts@youremail.com" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:11px;box-sizing:border-box"/>
</div>
</div>
</div>
</div>
<!-- N4: Currency Display -->
<div class="form-section" style="margin-top:20px">
<div class="form-section-title">// CURRENCY DISPLAY <span style="color:var(--text-dim);font-size:10px;font-weight:400">— N4</span></div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:6px;margin-bottom:10px;line-height:1.7">
Prices are shown in their <b>raw scraped currency</b> by default.<br>
Optionally choose a target currency — all prices will also show a converted value using daily rates from <b style="color:var(--accent)">frankfurter.app</b> (free, no key needed).<br>
Leave blank to keep raw currency display.
</div>
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);white-space:nowrap">DISPLAY CURRENCY</label>
<div style="position:relative">
<input id="cfg-display-currency" type="text" maxlength="3" placeholder="blank = raw or type / click ▾" autocomplete="off"
oninput="this.value=this.value.toUpperCase();showCurrencyDropdown(this.value)"
onfocus="showCurrencyDropdown(this.value)"
onblur="setTimeout(()=>hideCurrencyDropdown(),200)"
style="width:220px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box"/>
<div id="currency-dropdown" style="display:none;position:absolute;top:100%;left:0;width:280px;background:var(--bg-panel);border:1px solid var(--accent);border-radius:4px;max-height:200px;overflow-y:auto;z-index:9999;box-shadow:0 4px 20px rgba(0,0,0,.6)"></div>
</div>
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('cfg-display-currency').value='';hideCurrencyDropdown();" title="Clear — show raw prices">✕ Raw</button>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">Rates refresh every 6 hours via frankfurter.app</div>
</div>
</div>
<!-- N1: Proxy -->
<div class="form-section" style="margin-top:20px">
<div class="form-section-title">// PROXY ROTATION <span style="color:var(--text-dim);font-size:10px;font-weight:400">— N1 optional</span></div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:6px;margin-bottom:10px;line-height:1.7">
Optional — provide your own proxy list. Ghost Node rotates through them round-robin per scrape.<br>
Format: one proxy per line — <b style="color:var(--accent)">http://host:port</b> or <b style="color:var(--accent)">http://user:pass@host:port</b><br>
Leave blank to scrape without proxy. Free proxy lists can be found at free-proxy-list.net (quality varies).
</div>
<div style="display:flex;align-items:center;gap:16px;margin-bottom:10px">
<label class="toggle-wrap" for="cfg-proxy-enabled">
<input type="checkbox" id="cfg-proxy-enabled" class="toggle-input" onchange="document.getElementById('proxy-enabled-label').textContent = this.checked ? 'ON' : 'OFF'"/>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
<div style="font-family:var(--font-mono);font-size:12px;color:var(--text-hi);font-weight:700" id="proxy-enabled-label">OFF</div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">Enable proxy rotation</div>
</div>
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">PROXY LIST (one per line)</label>
<textarea id="cfg-proxy-list" rows="4" placeholder="http://1.2.3.4:8080&#10;http://user:pass@5.6.7.8:3128&#10;socks5://9.10.11.12:1080" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:11px;box-sizing:border-box;resize:vertical"></textarea>
</div>
<!-- N13: Site Auto-Disable -->
<div class="form-section" style="margin-top:20px">
<div class="form-section-title">// SITE HEALTH — AUTO COOLDOWN</div>
<div style="display:flex;align-items:center;gap:12px;margin-top:10px">
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);white-space:nowrap">COOLDOWN AFTER</label>
<input id="cfg-auto-disable" type="number" min="0" max="20" value="5" style="width:60px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:13px;text-align:center"/>
<span style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">consecutive failures (0 = never). Site enters 30-min cooldown then retries.</span>
</div>
</div>
<!-- N15: Export -->
<div class="form-section" style="margin-top:20px">
<div class="form-section-title">// EXPORT LISTINGS</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:10px">
<a href="/api/export/csv" class="btn btn-ghost" style="text-align:center;text-decoration:none">⬇ CSV</a>
<a href="/api/export/json" class="btn btn-ghost" style="text-align:center;text-decoration:none">⬇ JSON</a>
<a href="/api/export/html" class="btn btn-ghost" style="text-align:center;text-decoration:none">⬇ HTML Report</a>
</div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:6px">Downloads all captured listings. HTML is a self-contained report you can share.</div>
</div>
<!-- DATABASE BACKUP & RESTORE -->
<div class="form-section" style="margin-top:20px">
<div class="form-section-title">// DATABASE BACKUP &amp; RESTORE</div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:6px;margin-bottom:12px;line-height:1.7">
Your <span style="color:var(--accent-gold)">sniper.db</span> contains all listings, sites, keywords, settings and your Telegram token.
Back it up regularly. Restore to move Ghost Node to a new machine with all data intact.
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<!-- Backup -->
<div style="background:rgba(0,255,136,.04);border:1px solid rgba(0,255,136,.2);border-radius:4px;padding:14px">
<div style="font-family:var(--font-mono);font-size:10px;color:var(--accent-green);letter-spacing:1.5px;margin-bottom:8px">BACKUP</div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-bottom:10px;line-height:1.6">
Downloads a clean timestamped copy of your database file. Store it somewhere safe.
</div>
<a href="/api/backup/download" class="btn btn-ghost" style="display:block;text-align:center;text-decoration:none;color:var(--accent-green);border-color:var(--accent-green);width:100%;box-sizing:border-box">
📦 DOWNLOAD BACKUP
</a>
</div>
<!-- Restore -->
<div style="background:rgba(255,153,0,.04);border:1px solid rgba(255,153,0,.2);border-radius:4px;padding:14px">
<div style="font-family:var(--font-mono);font-size:10px;color:var(--accent-gold);letter-spacing:1.5px;margin-bottom:8px">RESTORE</div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-bottom:10px;line-height:1.6">
Upload a backup .db file. Current database is auto-saved before overwriting.
</div>
<label style="display:block;cursor:pointer">
<input type="file" id="restore-file-input" accept=".db" style="display:none" onchange="restoreDatabase(this)"/>
<span class="btn btn-ghost" style="display:block;text-align:center;color:var(--accent-gold);border-color:var(--accent-gold);width:100%;box-sizing:border-box">
🔁 UPLOAD &amp; RESTORE
</span>
</label>
</div>
</div>
<div id="backup-status" style="font-family:var(--font-mono);font-size:11px;margin-top:10px;display:none"></div>
</div>
<div class="form-section" style="margin-top:20px">
<div class="form-section-title">// DASHBOARD PREFERENCES</div>
<div style="display:flex;align-items:center;gap:16px">
<label class="toggle-wrap" for="cfg-listing-detail">
<input type="checkbox" id="cfg-listing-detail" class="toggle-input" checked/>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
<div>
<div style="font-family:var(--font-mono);font-size:12px;color:var(--text-hi);font-weight:700">Listing Detail View</div>
<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">Click listing title to open full detail panel</div>
</div>
</div>
</div>
<div style="margin-top:20px">
<button class="btn btn-primary" onclick="saveSettings()" style="padding:10px 28px;font-size:13px;letter-spacing:2px">
💾 SAVE CONFIGURATION
</button>
</div>
</div>
<!-- ═══ TAB: AI Debug Log ════════════════════════════════════════════════ -->
<div class="tab-panel" id="tab-aidebug">
<div class="page-header">
<div>
<div class="page-title">AI DEBUG LOG</div>
<div class="page-sub">LIVE PROMPT / RESPONSE INSPECTOR</div>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;flex-direction:column;align-items:flex-start">
<!-- Live indicator -->
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;width:100%">
<div id="aidebug-live-dot" style="width:8px;height:8px;border-radius:50%;background:#444;display:inline-block;margin-right:2px"></div>
<span id="aidebug-live-label" style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">POLLING OFF</span>
<!-- Filter buttons -->
<button class="btn btn-ghost btn-sm aidebug-filter active" data-filter="all" onclick="setAiFilter('all')">ALL</button>
<button class="btn btn-ghost btn-sm aidebug-filter" data-filter="filter" onclick="setAiFilter('filter')">🔍 FILTER</button>
<button class="btn btn-ghost btn-sm aidebug-filter" data-filter="adapt" onclick="setAiFilter('adapt')">🤖 ADAPT</button>
<button class="btn btn-ghost btn-sm aidebug-filter" data-filter="error" onclick="setAiFilter('error')">⚠ ERRORS</button>
<button class="btn btn-ghost btn-sm" onclick="clearAiDebugLog()" style="color:#888;border-color:#444">🗑 Clear</button>
</div>
<!-- Search and refresh -->
<div style="display:flex;gap:8px;align-items:center;width:100%">
<input id="aidl-search" type="text" placeholder="🔍 search AI log…" oninput="filterAiLog()" style="background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:5px 10px;font-family:var(--font-mono);font-size:11px;flex:1;max-width:300px"/>
<button class="btn btn-ghost btn-sm" onclick="_fetchAiDebugLog(true)" title="Refresh without clearing">↺ Refresh</button>
</div>
</div>
</div>
<!-- Warning if debug is off -->
<div id="aidebug-off-warn" style="display:none;font-family:var(--font-mono);font-size:11px;color:#ff9900;background:rgba(255,153,0,.08);border:1px solid rgba(255,153,0,.25);border-radius:4px;padding:10px 14px;margin-bottom:14px">
<b>AI Debug Mode is OFF.</b> No new entries will be captured. Go to <b>Settings → AI Smart Filter → DEBUG MODE</b> and turn it ON, then save.
</div>
<!-- Token counter bar -->
<div style="display:flex;gap:20px;margin-bottom:14px;flex-wrap:wrap">
<div class="stat-card" style="padding:10px 16px;min-width:130px">
<div class="stat-value" id="aidebug-stat-calls" style="font-size:22px">0</div>
<div class="stat-unit">total calls</div>
</div>
<div class="stat-card" style="padding:10px 16px;min-width:130px">
<div class="stat-value" id="aidebug-stat-tokens-p" style="font-size:22px">0</div>
<div class="stat-unit">prompt tokens</div>
</div>
<div class="stat-card" style="padding:10px 16px;min-width:130px">
<div class="stat-value" id="aidebug-stat-tokens-c" style="font-size:22px">0</div>
<div class="stat-unit">completion tokens</div>
</div>
<div class="stat-card" style="padding:10px 16px;min-width:130px">
<div class="stat-value" id="aidebug-stat-yes" style="font-size:22px;color:var(--accent-green)">0</div>
<div class="stat-unit">YES verdicts</div>
</div>
<div class="stat-card" style="padding:10px 16px;min-width:130px">
<div class="stat-value" id="aidebug-stat-no" style="font-size:22px;color:#ff4444">0</div>
<div class="stat-unit">NO verdicts</div>
</div>
<div class="stat-card" style="padding:10px 16px;min-width:130px">
<div class="stat-value" id="aidebug-stat-errors" style="font-size:22px;color:#ff9900">0</div>
<div class="stat-unit">errors</div>
</div>
</div>
<!-- Log entries -->
<div id="aidebug-log" style="display:flex;flex-direction:column;gap:8px;max-height:calc(100vh - 320px);overflow-y:auto;padding-right:4px">
<div class="empty-state" id="aidebug-empty">
<div class="big-icon">🧠</div>
<div>No AI calls captured yet.</div>
<div style="font-size:11px;color:var(--text-dim);margin-top:6px">Enable Debug Mode in Settings and trigger an AI Filter or Auto-Adapt call.</div>
</div>
</div>
</div>
</main>
</div>
<!-- ─── JavaScript ─────────────────────────────────────────────────────────── -->
<script>
const API = ''; // same-origin
/* ─── Tabs ──────────────────────────────────────────────────────── */
let currentTab = 'dashboard';
document.querySelectorAll('.nav-item').forEach(el => {
el.addEventListener('click', () => {
document.querySelectorAll('.nav-item').forEach(x => x.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(x => x.classList.remove('active'));
el.classList.add('active');
const id = el.dataset.tab;
document.getElementById('tab-' + id).classList.add('active');
currentTab = id;
if (id === 'listings') loadListings();
if (id === 'keywords') loadKeywords();
if (id === 'sites') loadSites();
if (id === 'settings') loadSettings();
if (id === 'aidebug') startAiDebugPolling();
else stopAiDebugPolling();
});
});
/* ─── Alert banner ──────────────────────────────────────────────── */
let alertTimer;
function showAlert(msg, type='success') {
clearTimeout(alertTimer);
const z = document.getElementById('alert-zone');
z.innerHTML = `<div class="alert alert-${type}">${type === 'success' ? '✅' : '❌'} ${msg}</div>`;
alertTimer = setTimeout(() => { z.innerHTML = ''; }, 3500);
}
/* ─── Log ───────────────────────────────────────────────────────── */
function addLog(msg, cls='log-msg-ok') {
const box = document.getElementById('log-box');
const q = (document.getElementById('log-search')?.value || '').toLowerCase();
const t = new Date().toTimeString().slice(0,8);
const line = document.createElement('div');
line.className = 'log-line';
line.dataset.msg = msg.toLowerCase();
line.innerHTML = `<span class="log-time">[${t}]</span><span class="${cls}">${msg}</span>`;
if (q && !msg.toLowerCase().includes(q)) line.style.display = 'none';
box.appendChild(line);
box.scrollTop = box.scrollHeight;
while (box.children.length > 200) box.removeChild(box.firstChild);
}
function clearLog() {
const box = document.getElementById('log-box');
box.innerHTML = '';
addLog('Log cleared.', 'log-msg-warn');
}
function log_scrollBottom() {
const box = document.getElementById('log-box');
box.scrollTop = box.scrollHeight;
}
function filterLog() {
const q = (document.getElementById('log-search')?.value || '').toLowerCase();
document.querySelectorAll('#log-box .log-line').forEach(line => {
line.style.display = (!q || (line.dataset.msg || '').includes(q)) ? '' : 'none';
});
}
/* ─── Stats poller ──────────────────────────────────────────────── */
let prevScanned = 0;
async function pollStats() {
try {
const r = await fetch(API + '/api/stats');
const d = await r.json();
document.getElementById('stat-scanned').textContent = d.total_scanned;
document.getElementById('stat-alerts').textContent = d.total_alerts;
const secs = d.uptime_seconds || 0;
const h = Math.floor(secs/3600), m = Math.floor((secs%3600)/60);
document.getElementById('stat-uptime').textContent = `${h}h ${m}m`;
document.getElementById('stat-engine').textContent = d.engine_status || '—';
document.getElementById('last-cycle-label').textContent = 'Last cycle: ' + (d.last_cycle || '—');
const lbl = document.getElementById('engine-status-label');
const dot = document.getElementById('engine-dot');
const st = (d.engine_status || '').toLowerCase();
lbl.textContent = (d.engine_status || 'UNKNOWN').toUpperCase();
if (st.includes('running')) {
dot.style.background = 'var(--accent-green)';
} else if (st.includes('paused')) {
dot.style.background = 'var(--accent-hot)';
dot.style.animation = 'none';
} else {
dot.style.background = 'var(--accent-gold)';
}
if (d.total_scanned > prevScanned) {
addLog(`Scanned ${d.total_scanned - prevScanned} new listings (total: ${d.total_scanned})`, 'log-msg-hit');
prevScanned = d.total_scanned;
}
} catch(e) {
document.getElementById('engine-status-label').textContent = 'OFFLINE';
addLog('Dashboard cannot reach backend.', 'log-msg-warn');
}
}
setInterval(pollStats, 5000);
pollStats();
/* Recent hits on dashboard */
async function loadRecentHits() {
try {
const r = await fetch(API + '/api/listings?limit=5');
const data = await r.json();
const c = document.getElementById('recent-hits-container');
if (!data.length) { c.innerHTML = '<div class="empty-state"><div class="big-icon">🔍</div>No hits yet.</div>'; return; }
c.innerHTML = `<table><thead><tr><th>Title</th><th>Price</th><th>Score</th><th>Keyword</th></tr></thead><tbody>
${data.map(l => `
<tr>
<td><a class="listing-link" href="${l.link}" target="_blank">${escHtml(l.title.slice(0,55))}…</a></td>
<td class="price-tag">${fmtPrice(l)}</td>
<td><span class="score-badge ${l.score > 0 ? 'score-pos' : 'score-zero'}">${l.score}</span></td>
<td><span class="chip">${escHtml(l.keyword || '—')}</span></td>
</tr>`).join('')}
</tbody></table>`;
} catch(e) {}
}
setInterval(loadRecentHits, 8000);
loadRecentHits();
/* ─── Listings ──────────────────────────────────────────────────── */
let _allListings = [];
async function loadListings() {
document.getElementById('listings-table-container').innerHTML = '<div class="empty-state"><span class="spinner"></span></div>';
const r = await fetch(API + '/api/listings?limit=200');
_allListings = await r.json();
renderListings(_allListings);
}
// ── Live countdown — 1-second in-place cell ticker ───────────────────────────
// Instead of re-rendering the whole table every 30s (causes flicker + loses
// scroll position), we stamp data-tlmins and data-captured onto each TD at
// render time and then just rewrite the text/colour of those cells every second.
//
// Format rules:
// > 1 day : "2d 4h" (days + hours, no mins/secs needed)
// 1h 24h : "4h 22m" (hours + minutes)
// 5m 1h : "44m 32s" (minutes + seconds — clearly urgent)
// 0 5m : "4m 12s" 🔴 bold (pulse class added)
// expired : "CLOSED" dim
function _tickCountdowns() {
const now = Date.now();
document.querySelectorAll('.tl-cell').forEach(td => {
const tlMins = parseFloat(td.dataset.tlmins);
const captured = td.dataset.captured ? new Date(td.dataset.captured).getTime() : null;
// No data — leave whatever static text was rendered at load time
if (!captured || isNaN(tlMins)) return;
const elapsedMins = (now - captured) / 60000;
const remainMins = Math.max(0, tlMins - elapsedMins);
const remainSecs = Math.round(remainMins * 60);
let text, color, weight, pulse;
if (remainSecs <= 0) {
text = 'CLOSED';
color = 'var(--text-dim)';
weight = 'normal';
pulse = false;
} else if (remainMins >= 1440) {
// > 1 day — coarse display
const d = Math.floor(remainMins / 1440);
const h = Math.floor((remainMins % 1440) / 60);
text = d + 'd' + (h ? ' ' + h + 'h' : '');
color = 'var(--accent-gold)';
weight = 'normal';
pulse = false;
} else if (remainMins >= 60) {
// 1h 24h
const h = Math.floor(remainMins / 60);
const m = Math.floor(remainMins % 60);
text = h + 'h' + (m ? ' ' + m + 'm' : '');
color = remainMins < 360 ? '#ff9900' : 'var(--accent-gold)';
weight = 'normal';
pulse = false;
} else if (remainMins >= 5) {
// 5m 1h: show live seconds
const m = Math.floor(remainSecs / 60);
const s = remainSecs % 60;
text = m + 'm ' + String(s).padStart(2,'0') + 's';
color = '#ff9900';
weight = 'bold';
pulse = false;
} else {
// 0 5m: critical — red pulse
const m = Math.floor(remainSecs / 60);
const s = remainSecs % 60;
text = m + 'm ' + String(s).padStart(2,'0') + 's';
color = '#ff4444';
weight = 'bold';
pulse = true;
}
// Only touch the DOM if something actually changed (avoids layout thrash)
if (td.textContent !== text) td.textContent = text;
if (td.style.color !== color) td.style.color = color;
if (td.style.fontWeight !== weight) td.style.fontWeight = weight;
td.classList.toggle('tl-pulse', pulse);
});
}
setInterval(_tickCountdowns, 1000);
_tickCountdowns(); // run immediately so cells are right on load
// ── Countdown sync — patches data-tlmins+data-captured every 60s ─────────────
// When Thread D (price refresh) updates time_left_mins in the DB, the cells
// still have the old values stamped in data-* attributes from the last render.
// This poll silently patches those attributes in-place so the ticker always
// uses the freshest measurement from the backend — no re-render needed.
async function _syncCountdownData() {
try {
const r = await fetch(API + '/api/listings/countdown-sync');
if (!r.ok) return;
const rows = await r.json();
rows.forEach(row => {
const td = document.querySelector(`.tl-cell[data-id="${row.id}"]`);
if (!td) return;
// Only patch if the backend has data for this listing
if (row.time_left_mins != null) {
td.dataset.tlmins = row.time_left_mins;
td.dataset.captured = row.price_updated_at || row.timestamp || '';
}
});
// Re-tick immediately after patching so cells show the updated value at once
_tickCountdowns();
} catch(e) { /* silent — never break the page */ }
}
setInterval(_syncCountdownData, 60_000); // patch every 60s
_syncCountdownData(); // run immediately on page load
// ── Price refresh poll (every 5 min checks if backend updated prices) ─────────
let _lastPriceUpdate = null;
setInterval(async () => {
try {
const r = await fetch(API + '/api/listings/refresh-status');
const d = await r.json();
if (d.last_price_update && d.last_price_update !== _lastPriceUpdate) {
_lastPriceUpdate = d.last_price_update;
// Prices changed — reload listing data and re-render with flash
const lr = await fetch(API + '/api/listings?limit=200');
_allListings = await lr.json();
renderListings(_allListings.filter(l => l.title.toLowerCase().includes(
(document.getElementById('listing-filter').value || '').toLowerCase()
)));
// Re-apply live countdown immediately after re-render
// (renderListings stamps fresh data-tlmins / data-captured onto each TD,
// so the ticker now has the latest scraped time_left_mins to work from)
_tickCountdowns();
// Brief green flash on the price column header to signal refresh
const priceTh = document.querySelector('th.sortable');
if (priceTh) {
priceTh.style.transition = 'color .3s';
priceTh.style.color = 'var(--accent-green)';
setTimeout(() => priceTh.style.color = '', 1500);
}
}
} catch(e) {}
}, 300000); // 5 minutes
/* ── Sort state ─────────────────────────────────────────────────── */
let _sortCol = null; // 'price' | 'time'
let _sortDir = 'asc'; // 'asc' | 'desc'
/* ── N4 Currency display ─────────────────────────────────────────── */
let _displayCurrency = ''; // e.g. 'USD', 'EUR', 'IQD' — blank = raw only
let _fxRates = {}; // USD-based rates cache, e.g. { EUR: 0.92, GBP: 0.79 }
let _listingDetailEnabled = true;
async function _fetchFxRates() {
try {
const r = await fetch('https://api.frankfurter.app/latest?from=USD');
if (r.ok) { const d = await r.json(); _fxRates = d.rates || {}; _fxRates['USD'] = 1.0; }
} catch(e) {}
}
function _convertToDisplay(priceUsd) {
if (priceUsd == null || priceUsd === '') return null;
const n = Number(priceUsd);
if (!_displayCurrency) return null; // display_currency not set — show nothing extra
if (_displayCurrency === 'USD') return `${n.toFixed(2)} USD`;
const rate = _fxRates[_displayCurrency];
if (!rate) return `${n.toFixed(2)} USD`; // rate not loaded yet
return `${(n * rate).toFixed(2)} ${_displayCurrency}`;
}
function sortListings(col) {
if (_sortCol === col) {
_sortDir = _sortDir === 'asc' ? 'desc' : 'asc';
} else {
_sortCol = col;
_sortDir = col === 'time' ? 'asc' : 'desc'; // time: soonest first; price: highest first
}
const q = document.getElementById('listing-filter').value.toLowerCase();
const filtered = _allListings.filter(l => l.title.toLowerCase().includes(q));
renderListings(filtered);
}
/* Convert "2d 4h 30m" → total minutes for numeric sort */
function timeLeftToMins(tl) {
if (!tl || !tl.trim()) return Infinity; // no time = treat as furthest away
let mins = 0;
const d = tl.match(/(\d+)d/); if (d) mins += parseInt(d[1]) * 1440;
const h = tl.match(/(\d+)h/); if (h) mins += parseInt(h[1]) * 60;
const m = tl.match(/(\d+)m/); if (m) mins += parseInt(m[1]);
return mins || Infinity;
}
/* ─── Drag & Drop Reorder ───────────────────────────────────────── */
let _dragSrcId = null, _dragType = null;
function dragStart(e) {
_dragSrcId = parseInt(e.currentTarget.dataset.id);
_dragType = e.currentTarget.dataset.type;
e.dataTransfer.effectAllowed = 'move';
e.currentTarget.closest('tr').style.opacity = '0.4';
}
function dragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const tr = e.currentTarget.closest('tr');
document.querySelectorAll('tr.drag-over').forEach(r => r.classList.remove('drag-over'));
tr.classList.add('drag-over');
}
function dragEnd(e) {
e.currentTarget.closest('tr').style.opacity = '';
document.querySelectorAll('tr.drag-over').forEach(r => r.classList.remove('drag-over'));
}
async function dragDrop(e) {
e.preventDefault();
const targetId = parseInt(e.currentTarget.dataset.id);
const targetType = e.currentTarget.dataset.type;
if (_dragSrcId === null || _dragSrcId === targetId || _dragType !== targetType) return;
// Collect all row IDs in current DOM order
const handles = Array.from(document.querySelectorAll(`.drag-handle[data-type="${_dragType}"]`));
const ids = handles.map(h => parseInt(h.dataset.id));
const fromIdx = ids.indexOf(_dragSrcId);
const toIdx = ids.indexOf(targetId);
if (fromIdx < 0 || toIdx < 0) return;
ids.splice(fromIdx, 1);
ids.splice(toIdx, 0, _dragSrcId);
const endpoint = _dragType === 'keyword' ? '/api/keywords/reorder' : '/api/sites/reorder';
await fetch(API + endpoint, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ order: ids })
});
if (_dragType === 'keyword') loadKeywords(); else loadSites();
addLog(`Reordered ${_dragType}s`, 'log-msg-ok');
_dragSrcId = null;
}
function renderListings(data) {
const c = document.getElementById('listings-table-container');
if (!data.length) { c.innerHTML = '<div class="empty-state"><div class="big-icon">👻</div>No listings captured yet.</div>'; return; }
/* Apply sort */
let sorted = [...data];
if (_sortCol === 'price') {
sorted.sort((a, b) => {
const pa = a.price ?? -Infinity;
const pb = b.price ?? -Infinity;
return _sortDir === 'asc' ? pa - pb : pb - pa;
});
} else if (_sortCol === 'time') {
sorted.sort((a, b) => {
const ta = timeLeftToMins(a.time_left);
const tb = timeLeftToMins(b.time_left);
return _sortDir === 'asc' ? ta - tb : tb - ta;
});
}
const priceClass = _sortCol === 'price' ? (_sortDir === 'asc' ? 'sort-asc' : 'sort-desc') : '';
const timeClass = _sortCol === 'time' ? (_sortDir === 'asc' ? 'sort-asc' : 'sort-desc') : '';
c.innerHTML = `<table><thead><tr>
<th>Title</th>
<th class="sortable ${priceClass}" onclick="sortListings('price')" title="Sort by price">Price</th>
<th class="sortable ${timeClass}" onclick="sortListings('time')" title="Sort by time left">Time Left</th>
<th>Score</th><th>AI</th><th>Keyword</th><th>Site</th><th>Captured</th><th></th>
</tr></thead><tbody>
${sorted.map(l => {
const locHtml = l.location ? `<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:2px">📍 ${escHtml(l.location)}</div>` : '';
const convPrice = _convertToDisplay(l.price_usd);
const convHtml = convPrice ? `<div style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);margin-top:1px">${escHtml(convPrice)}</div>` : '';
let thumbHtml = '';
try {
const imgs = JSON.parse(l.images || '[]');
if (imgs.length > 0) {
thumbHtml = `<img src="${escHtml(imgs[0])}" alt="" loading="lazy" onerror="this.style.display='none'" style="width:48px;height:48px;object-fit:cover;border-radius:4px;flex-shrink:0;background:var(--bg-card)">`;
}
} catch(e) {}
return `
<tr style="${l.ai_match === 0 ? 'opacity:0.45' : ''}">
<td onclick="${_listingDetailEnabled ? `openListingDetail(${l.id})` : ''}" style="${_listingDetailEnabled ? 'cursor:pointer' : ''}">
<div style="display:flex;align-items:flex-start;gap:8px">
${thumbHtml}
<div>
<span class="listing-title">${escHtml(l.title)}</span>
<a class="listing-link" href="${escHtml(l.link)}" target="_blank" rel="noopener noreferrer" onclick="event.stopPropagation()"> Open</a>
${locHtml}
</div>
</div>
</td>
<td class="price-tag">${fmtPrice(l)}${convHtml}</td>
<td class="tl-cell"
data-id="${l.id}"
data-tlmins="${l.time_left_mins != null ? l.time_left_mins : ''}"
data-captured="${l.price_updated_at || l.timestamp || ''}"
style="font-family:var(--font-mono);font-size:11px"
>${fmtTimeLeft(l)}</td>
<td><span class="score-badge ${l.score > 0 ? 'score-pos' : 'score-zero'}">${l.score}</span></td>
<td title="${escHtml(l.ai_reason || '')}" style="font-size:15px;cursor:${l.ai_match != null ? 'help' : 'default'}">
${l.ai_match === 1 ? '🤖✅' : l.ai_match === 0 ? '🤖❌' : '<span style="color:var(--text-dim);font-size:10px">—</span>'}
</td>
<td><span class="chip">${escHtml(l.keyword || '—')}</span></td>
<td style="color:var(--text-mid);font-size:11px">${escHtml(l.site_name || '—')}</td>
<td style="color:var(--text-dim);font-family:var(--font-mono);font-size:10px">${fmtDate(l.timestamp)}</td>
<td><button class="btn btn-danger btn-sm" onclick="deleteListing(${l.id})">×</button></td>
</tr>`;
}).join('')}
</tbody></table>`;
}
function filterListings() {
const q = document.getElementById('listing-filter').value.toLowerCase();
const filtered = _allListings.filter(l => l.title.toLowerCase().includes(q));
renderListings(filtered);
}
function openListingDetail(id) {
const l = _allListings.find(x => x.id === id);
if (!l) return;
const convPrice = _convertToDisplay(l.price_usd);
document.getElementById('ld-title').textContent = l.title;
document.getElementById('ld-link').href = l.link;
document.getElementById('ld-price').textContent = (l.price_raw || '—') + (convPrice ? ' ' + convPrice : '');
document.getElementById('ld-location').textContent= l.location || '—';
document.getElementById('ld-site').textContent = l.site_name || '—';
document.getElementById('ld-keyword').textContent = l.keyword || '—';
document.getElementById('ld-score').textContent = l.score;
document.getElementById('ld-ai').textContent = l.ai_match === 1 ? '✅ Match' : l.ai_match === 0 ? '❌ Rejected' : '— Not analysed';
document.getElementById('ld-ai-reason').textContent = l.ai_reason || '';
document.getElementById('ld-time-left').textContent = l.time_left || '—';
document.getElementById('ld-captured').textContent = fmtDate(l.timestamp);
document.getElementById('ld-refreshed').textContent = l.price_updated_at ? fmtDate(l.price_updated_at) : '—';
// Image gallery
const ldImgs = document.getElementById('ld-images');
const ldImgsWrap = document.getElementById('ld-images-wrap');
let imgList = [];
try { imgList = JSON.parse(l.images || '[]'); } catch(e) {}
if (imgList.length > 0) {
ldImgs.innerHTML = imgList.map(src =>
`<a href="${escHtml(src)}" target="_blank" rel="noopener noreferrer" style="display:block;flex-shrink:0">` +
`<img src="${escHtml(src)}" alt="" loading="lazy" onerror="this.parentElement.style.display='none'" ` +
`style="width:140px;height:110px;object-fit:cover;border-radius:6px;border:1px solid var(--border-dim);background:var(--bg-card);display:block"></a>`
).join('');
ldImgsWrap.style.display = '';
} else {
ldImgs.innerHTML = '';
ldImgsWrap.style.display = 'none';
}
document.getElementById('listing-detail-modal').style.display = 'flex';
}
function closeListingDetail() {
document.getElementById('listing-detail-modal').style.display = 'none';
}
async function deleteListing(id) {
await fetch(API + `/api/listings/${id}`, { method: 'DELETE' });
loadListings();
}
async function clearListings() {
if (!confirm('Clear ALL listings? This is irreversible.')) return;
await fetch(API + '/api/listings', { method: 'DELETE' });
loadListings();
showAlert('All listings cleared.');
}
/* ─── Keywords ──────────────────────────────────────────────────── */
async function loadKeywords() {
document.getElementById('keywords-table-container').innerHTML = '<div class="empty-state"><span class="spinner"></span></div>';
const r = await fetch(API + '/api/keywords');
const data = await r.json();
const c = document.getElementById('keywords-table-container');
if (!data.length) { c.innerHTML = '<div class="empty-state"><div class="big-icon">🔍</div>No keywords yet.</div>'; return; }
c.innerHTML = `<table><thead><tr><th style="width:24px"></th><th>#</th><th>Keyword</th><th>Weight</th><th>Price Filter</th><th>AI Target</th><th></th></tr></thead><tbody>
${data.map(k => {
const minTxt = (k.min_price != null && k.min_price !== '') ? k.min_price : null;
const maxTxt = (k.max_price != null && k.max_price !== '') ? k.max_price : null;
const filterBadge = (minTxt !== null || maxTxt !== null)
? `<span style="font-family:var(--font-mono);font-size:11px;color:var(--accent-green)">` +
(minTxt !== null ? `${minTxt}` : '') +
(minTxt !== null && maxTxt !== null ? ' / ' : '') +
(maxTxt !== null ? `${maxTxt}` : '') +
`</span>`
: `<span style="color:var(--text-dim);font-size:11px">none</span>`;
return `
<tr>
<td class="drag-handle" draggable="true" data-id="${k.id}" data-type="keyword" ondragstart="dragStart(event)" ondragover="dragOver(event)" ondrop="dragDrop(event)" ondragend="dragEnd(event)">⋮⋮</td>
<td style="color:var(--text-dim);font-family:var(--font-mono);font-size:11px">${k.id}</td>
<td><span class="chip kw-inline-edit" title="Click to rename" onclick="inlineEditKw(${k.id}, 'term', this, ${JSON.stringify(escHtml(k.term))})">${escHtml(k.term)}</span></td>
<td style="font-family:var(--font-mono);color:var(--accent-gold);cursor:pointer" title="Click to change weight" onclick="inlineEditKw(${k.id}, 'weight', this, '${k.weight}')">${k.weight}×</td>
<td>${filterBadge}</td>
<td style="max-width:240px">
${k.ai_target
? `<span style="color:var(--accent-green);font-size:11px;font-family:var(--font-mono)">🤖 ${escHtml(k.ai_target.length > 50 ? k.ai_target.slice(0,50)+'…' : k.ai_target)}</span>`
: `<span style="color:var(--text-dim);font-size:11px">not set</span>`}
</td>
<td style="white-space:nowrap">
<button class="btn btn-secondary btn-sm" onclick="editPriceFilter(${k.id}, ${JSON.stringify(escHtml(k.term))}, ${minTxt !== null ? minTxt : 'null'}, ${maxTxt !== null ? maxTxt : 'null'})" style="margin-right:4px">💰 Price</button>
<button class="btn btn-secondary btn-sm" onclick="editAiTarget(${k.id}, ${JSON.stringify(escHtml(k.term))}, ${JSON.stringify(escHtml(k.ai_target||''))})" style="margin-right:4px">🤖 AI</button>
<button class="btn btn-danger btn-sm" onclick="deleteKeyword(${k.id})">× Remove</button>
</td>
</tr>`;
}).join('')}
</tbody></table>`;
}
async function addKeyword() {
const term = document.getElementById('kw-term').value.trim();
const weight = parseInt(document.getElementById('kw-weight').value) || 1;
if (!term) { showAlert('Please enter a search term.', 'error'); return; }
const r = await fetch(API + '/api/keywords', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ term, weight })
});
if (r.ok) {
document.getElementById('kw-term').value = '';
document.getElementById('kw-weight').value = '1';
loadKeywords();
showAlert(`Keyword "${term}" added.`);
addLog(`Keyword added: ${term} (weight ${weight})`, 'log-msg-hit');
} else {
const e = await r.json();
showAlert(e.error || 'Failed to add keyword.', 'error');
}
}
async function batchImportKeywords() {
const raw = document.getElementById('kw-batch').value.trim();
if (!raw) { showAlert('Paste at least one keyword.', 'error'); return; }
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
let added = 0, skipped = 0;
for (const line of lines) {
const parts = line.split(':');
const term = parts[0].trim();
const weight = parts[1] ? (parseInt(parts[1]) || 1) : 1;
if (!term) continue;
const r = await fetch(API + '/api/keywords', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ term, weight })
});
if (r.ok) added++; else skipped++;
}
document.getElementById('kw-batch').value = '';
document.getElementById('batch-result').textContent = `${added} added${skipped ? ', ' + skipped + ' skipped (duplicate)' : ''}`;
setTimeout(() => { const el = document.getElementById('batch-result'); if(el) el.textContent = ''; }, 5000);
loadKeywords();
addLog(`Batch import: ${added} keywords added, ${skipped} skipped.`, 'log-msg-ok');
}
async function deleteKeyword(id) {
await fetch(API + `/api/keywords/${id}`, { method: 'DELETE' });
loadKeywords();
showAlert('Keyword removed.');
}
async function inlineEditKw(id, field, cell, currentVal) {
if (cell.querySelector('input')) return; // already editing
const isWeight = field === 'weight';
const inp = document.createElement('input');
inp.type = isWeight ? 'number' : 'text';
if (isWeight) { inp.min = '1'; inp.max = '10'; inp.style.width = '50px'; }
else { inp.style.width = '120px'; }
inp.value = currentVal;
inp.style.cssText += ';background:var(--bg-card);border:1px solid var(--accent);color:var(--text-hi);padding:2px 6px;font-family:var(--font-mono);font-size:12px;border-radius:3px';
const orig = cell.innerHTML;
cell.innerHTML = '';
cell.appendChild(inp);
inp.focus(); inp.select();
const save = async () => {
const val = inp.value.trim();
if (!val || val === String(currentVal)) { cell.innerHTML = orig; return; }
const body = {};
body[field] = isWeight ? parseInt(val) : val;
const r = await fetch(API + `/api/keywords/${id}`, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify(body)
});
if (r.ok) {
loadKeywords();
addLog(`Keyword #${id} ${field} updated to "${val}"`, 'log-msg-ok');
} else {
const e = await r.json();
showAlert(e.error || 'Failed to update.', 'error');
cell.innerHTML = orig;
}
};
inp.addEventListener('keydown', e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') cell.innerHTML = orig; });
inp.addEventListener('blur', save);
}
function editPriceFilter(id, term, currentMin, currentMax) {
document.getElementById('pf-kw-label').textContent = term;
document.getElementById('pf-min-input').value = (currentMin != null) ? currentMin : '';
document.getElementById('pf-max-input').value = (currentMax != null) ? currentMax : '';
document.getElementById('pf-kw-id').value = id;
document.getElementById('price-filter-modal').style.display = 'flex';
}
function closePriceFilterModal() {
document.getElementById('price-filter-modal').style.display = 'none';
}
async function savePriceFilter() {
const id = parseInt(document.getElementById('pf-kw-id').value);
const min = document.getElementById('pf-min-input').value.trim();
const max = document.getElementById('pf-max-input').value.trim();
const r = await fetch(API + `/api/keywords/${id}`, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({
min_price: min !== '' ? parseFloat(min) : null,
max_price: max !== '' ? parseFloat(max) : null
})
});
if (r.ok) {
closePriceFilterModal();
loadKeywords();
showAlert('Price filter saved.');
addLog(`Price filter updated for keyword ID ${id} — min:${min||'none'} max:${max||'none'}`, 'log-msg-ok');
} else {
showAlert('Failed to save price filter.', 'error');
}
}
function editAiTarget(id, term, currentTarget) {
const modal = document.getElementById('ai-target-modal');
document.getElementById('ai-target-kw-label').textContent = term;
document.getElementById('ai-target-input').value = currentTarget || '';
document.getElementById('ai-target-kw-id').value = id;
document.getElementById('ai-test-result').textContent = '';
document.getElementById('ai-test-title-input').value = '';
modal.style.display = 'flex';
}
function closeAiTargetModal() {
document.getElementById('ai-target-modal').style.display = 'none';
}
async function saveAiTarget() {
const id = parseInt(document.getElementById('ai-target-kw-id').value);
const aiTarget = document.getElementById('ai-target-input').value.trim();
const r = await fetch(API + `/api/keywords/${id}`, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ ai_target: aiTarget })
});
if (r.ok) {
closeAiTargetModal();
loadKeywords();
showAlert(aiTarget ? `AI target saved for keyword.` : `AI target cleared.`);
addLog(`AI target updated for keyword ID ${id}`, 'log-msg-ok');
} else {
showAlert('Failed to save AI target.', 'error');
}
}
async function testAiTarget() {
const aiTarget = document.getElementById('ai-target-input').value.trim();
const testTitle = document.getElementById('ai-test-title-input').value.trim();
const resultEl = document.getElementById('ai-test-result');
if (!aiTarget || !testTitle) {
resultEl.textContent = '⚠️ Fill in both AI Target and a test title first.';
resultEl.style.color = 'var(--accent-gold)';
return;
}
resultEl.textContent = '⏳ Testing…';
resultEl.style.color = 'var(--text-mid)';
try {
const r = await fetch(API + '/api/ai/test', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ title: testTitle, ai_target: aiTarget })
});
const d = await r.json();
if (d.error) {
resultEl.textContent = `❌ Error: ${d.error}`;
resultEl.style.color = 'var(--danger)';
} else {
const icon = d.match ? '✅ MATCH' : '❌ NO MATCH';
resultEl.textContent = `${icon}${d.reason} (via ${d.provider || 'ai'})`;
resultEl.style.color = d.match ? 'var(--accent-green)' : 'var(--danger)';
}
} catch(e) {
resultEl.textContent = `❌ Request failed: ${e.message}`;
resultEl.style.color = 'var(--danger)';
}
}
/* ─── Target Sites ──────────────────────────────────────────────── */
let _lastSitesData = [];
async function loadSites() {
document.getElementById('sites-table-container').innerHTML = '<div class="empty-state"><span class="spinner"></span></div>';
const r = await fetch(API + '/api/sites');
const data = await r.json();
_lastSitesData = data;
const c = document.getElementById('sites-table-container');
if (!data.length) { c.innerHTML = '<div class="empty-state"><div class="big-icon">🌐</div>No sites configured.</div>'; return; }
// Load selectors for all sites in one batch, then render
const siteIds = data.map(s => s.id);
const selectorMap = {};
await Promise.all(siteIds.map(async id => {
try {
const r = await fetch(`/api/sites/${id}/selectors`);
const j = await r.json();
selectorMap[id] = j.selectors;
} catch(e) { selectorMap[id] = null; }
}));
c.innerHTML = `<table><thead><tr><th style="width:24px"></th><th>Name</th><th>Mode</th><th>URL</th><th>Selector</th><th>Status</th><th>Health</th><th>AI Adapt</th><th></th></tr></thead><tbody>
${data.map(s => {
const isDirect = s.url_template.includes('{keyword}');
const modeLabel = isDirect
? '<span style="font-family:var(--font-mono);font-size:9px;color:var(--accent);background:rgba(0,245,255,.1);border:1px solid rgba(0,245,255,.2);border-radius:3px;padding:2px 6px;letter-spacing:1px">DIRECT</span>'
: '<span style="font-family:var(--font-mono);font-size:9px;color:var(--accent-green);background:rgba(0,255,136,.1);border:1px solid rgba(0,255,136,.2);border-radius:3px;padding:2px 6px;letter-spacing:1px">HOMEPAGE</span>';
const selDisplay = s.search_selector
? `<span style="font-family:var(--font-mono);font-size:11px;color:var(--accent-gold)">${escHtml(s.search_selector)}</span>`
: `<span style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim);font-style:italic">auto-discover</span>`;
// N13: Health column
let healthHtml = '<span class="site-health-ok">✓ OK</span>';
if (s.cooldown_until && new Date(s.cooldown_until) > new Date()) {
const cd = new Date(s.cooldown_until);
healthHtml = `<span class="site-cooldown">⏳ COOLDOWN<br><small>${cd.toLocaleTimeString()}</small></span>`;
} else if (s.consecutive_failures >= 3) {
healthHtml = `<span class="site-health-err"> ${s.consecutive_failures} fails<br><small style="color:#888">${escHtml((s.last_error||'').slice(0,40))}</small></span>`;
} else if (s.consecutive_failures > 0) {
healthHtml = `<span class="site-health-warn">⚠ ${s.consecutive_failures} fail(s)</span>`;
} else if (s.error_count > 0 && s.last_success_at) {
healthHtml = `<span class="site-health-ok">✓ OK (${s.error_count} total errors)</span>`;
}
// N14: Login button
const loginBtn = s.requires_login && s.login_enabled
? `<button class="btn btn-ghost btn-sm" onclick="triggerLogin(${s.id})" title="Open browser to log in to this site" style="color:var(--accent-gold);border-color:var(--accent-gold)">🔑 Login</button>`
: '';
const pgsBadge = (s.max_pages || 1) > 1
? `<span style="font-family:var(--font-mono);font-size:9px;color:var(--accent-gold);margin-left:4px">${s.max_pages}p</span>`
: '';
const loginStatus = s.requires_login
? (s.login_enabled
? `<span style="font-family:var(--font-mono);font-size:9px;color:var(--accent-gold)">🔑 login</span>`
: `<span style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim)">🔑 off</span>`)
: '';
// N17: AI Adapt column
const ss = selectorMap[s.id];
let aiAdaptHtml = '';
if (ss) {
const conf = ss.confidence || 0;
const stale = ss.stale;
const confColor = stale ? '#888' : (conf >= 70 ? 'var(--accent-green)' : conf >= 40 ? '#ff9900' : '#ff4444');
const confLabel = stale ? 'STALE' : `${Math.round(conf)}%`;
const genAt = ss.generated_at ? new Date(ss.generated_at).toLocaleDateString() : '';
const tooltip = `Container: ${ss.container_sel||'—'} | Items: ${ss.container_count} | Title: ${ss.title_rate}% | Price: ${ss.price_rate}%${genAt?' | '+genAt:''}`;
aiAdaptHtml = `<div style="display:flex;flex-direction:column;gap:3px;align-items:flex-start">
<span style="font-family:var(--font-mono);font-size:10px;color:${confColor};font-weight:700;cursor:help" title="${escHtml(tooltip)}">🤖 ${confLabel}</span>
<button class="btn btn-ghost btn-sm" onclick="triggerAdapt(${s.id})" style="font-size:9px;padding:2px 6px;color:var(--accent-gold);border-color:var(--accent-gold)">↺ Re-adapt</button>
<button class="btn btn-ghost btn-sm" onclick="deleteSelectors(${s.id})" style="font-size:9px;padding:2px 6px;color:#888;border-color:#444">× Clear</button>
</div>`;
} else {
aiAdaptHtml = `<button class="btn btn-ghost btn-sm" onclick="triggerAdapt(${s.id})" style="color:var(--accent-gold);border-color:var(--accent-gold)">🤖 Adapt</button>`;
}
return `
<tr>
<td class="drag-handle" draggable="true" data-id="${s.id}" data-type="site" ondragstart="dragStart(event)" ondragover="dragOver(event)" ondrop="dragDrop(event)" ondragend="dragEnd(event)">⋮⋮</td>
<td>
<strong style="color:var(--accent);cursor:pointer" title="Click to rename" onclick="inlineEditSite(${s.id}, 'name', this, ${JSON.stringify(escHtml(s.name))})">${escHtml(s.name)}</strong>
${pgsBadge} ${loginStatus}
</td>
<td>${modeLabel}</td>
<td style="font-family:var(--font-mono);font-size:10px;color:var(--text-mid);max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer" title="Click to edit URL" onclick="inlineEditSite(${s.id}, 'url_template', this, ${JSON.stringify(escHtml(s.url_template))})">
${escHtml(s.url_template)}
</td>
<td>${selDisplay}</td>
<td>
<span class="score-badge ${s.enabled ? 'score-pos' : 'score-zero'}">${s.enabled ? '● ACTIVE' : '○ OFF'}</span>
</td>
<td>${healthHtml}</td>
<td>${aiAdaptHtml}</td>
<td style="display:flex;gap:6px;flex-wrap:wrap">
${loginBtn}
<button class="btn btn-secondary btn-sm" onclick="openEditSite(${s.id})" style="margin-right:2px">✏ Edit</button>
<button class="btn btn-ghost btn-sm" onclick="toggleSite(${s.id}, ${s.enabled})">${s.enabled ? 'Disable' : 'Enable'}</button>
<button class="btn btn-danger btn-sm" onclick="deleteSite(${s.id})">× Del</button>
</td>
</tr>`;
}).join('')}
</tbody></table>`;
}
/* Live mode badge while typing the URL */
function updateModeBadge(val) {
const badge = document.getElementById('mode-badge');
if (!val.trim()) { badge.style.display = 'none'; return; }
const isDirect = val.includes('{keyword}');
badge.style.display = 'inline';
badge.textContent = isDirect ? 'DIRECT' : 'HOMEPAGE';
badge.style.background = isDirect ? 'rgba(0,245,255,.15)' : 'rgba(0,255,136,.15)';
badge.style.color = isDirect ? 'var(--accent)' : 'var(--accent-green)';
badge.style.border = isDirect ? '1px solid rgba(0,245,255,.3)' : '1px solid rgba(0,255,136,.3)';
}
async function addSite() {
const name = document.getElementById('site-name').value.trim();
const template = document.getElementById('site-url').value.trim();
const selector = document.getElementById('site-selector').value.trim();
const maxPages = parseInt(document.getElementById('site-max-pages').value) || 1;
const requiresLogin = document.getElementById('site-requires-login').checked;
const loginUrl = document.getElementById('site-login-url').value.trim();
const loginCheck = document.getElementById('site-login-check').value.trim();
if (!name) { showAlert('Site name is required.', 'error'); return; }
if (!template) { showAlert('URL is required.', 'error'); return; }
const isDirect = template.includes('{keyword}');
const mode = isDirect ? 'DIRECT' : 'HOMEPAGE';
const r = await fetch(API + '/api/sites', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({
name, url_template: template, search_selector: selector,
max_pages: maxPages,
requires_login: requiresLogin, login_url: loginUrl,
login_check_selector: loginCheck, login_enabled: requiresLogin,
})
});
if (r.ok) {
document.getElementById('site-name').value = '';
document.getElementById('site-url').value = '';
document.getElementById('site-selector').value = '';
document.getElementById('site-max-pages').value = '1';
document.getElementById('site-requires-login').checked = false;
document.getElementById('site-login-url').value = '';
document.getElementById('site-login-check').value = '';
document.getElementById('mode-badge').style.display = 'none';
loadSites();
showAlert(`Site "${name}" registered in ${mode} mode${maxPages > 1 ? ` (${maxPages} pages)` : ''}.`);
addLog(`New target site: ${name} [${mode}]`, 'log-msg-hit');
} else {
const e = await r.json();
showAlert(e.error || 'Failed to add site.', 'error');
}
}
async function toggleSite(id, currentlyEnabled) {
await fetch(API + `/api/sites/${id}`, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ enabled: currentlyEnabled ? 0 : 1 })
});
loadSites();
}
async function deleteSite(id) {
if (!confirm('Remove this target site?')) return;
await fetch(API + `/api/sites/${id}`, { method: 'DELETE' });
loadSites();
showAlert('Site removed.');
}
function openEditSite(id) {
const data = _lastSitesData && _lastSitesData.find(s => s.id === id);
if (!data) { showAlert('Reload sites first.', 'error'); return; }
document.getElementById('es-id').value = id;
document.getElementById('es-name').value = data.name;
document.getElementById('es-url').value = data.url_template;
document.getElementById('es-selector').value = data.search_selector || '';
document.getElementById('es-pages').value = data.max_pages || 1;
document.getElementById('es-login-req').checked = data.requires_login;
document.getElementById('es-login-en').checked = data.login_enabled;
document.getElementById('es-login-url').value = data.login_url || '';
document.getElementById('es-login-check').value= data.login_check_selector || '';
document.getElementById('edit-site-modal').style.display = 'flex';
}
function closeEditSite() {
document.getElementById('edit-site-modal').style.display = 'none';
}
async function saveEditSite() {
const id = parseInt(document.getElementById('es-id').value);
const payload = {
name: document.getElementById('es-name').value.trim(),
url_template: document.getElementById('es-url').value.trim(),
search_selector: document.getElementById('es-selector').value.trim(),
max_pages: parseInt(document.getElementById('es-pages').value) || 1,
requires_login: document.getElementById('es-login-req').checked,
login_enabled: document.getElementById('es-login-en').checked,
login_url: document.getElementById('es-login-url').value.trim(),
login_check_selector: document.getElementById('es-login-check').value.trim(),
};
if (!payload.name || !payload.url_template) { showAlert('Name and URL are required.', 'error'); return; }
const r = await fetch(API + `/api/sites/${id}`, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
if (r.ok) {
closeEditSite();
loadSites();
showAlert('Site updated.');
addLog(`Site #${id} updated: ${payload.name}`, 'log-msg-ok');
} else {
showAlert('Failed to update site.', 'error');
}
}
async function inlineEditSite(id, field, cell, currentVal) {
if (cell.querySelector('input')) return;
const inp = document.createElement('input');
inp.type = 'text';
inp.value = currentVal;
const isUrl = field === 'url_template';
inp.style.cssText = `width:${isUrl?'280px':'140px'};background:var(--bg-card);border:1px solid var(--accent);color:var(--text-hi);padding:2px 6px;font-family:var(--font-mono);font-size:11px;border-radius:3px`;
const orig = cell.innerHTML;
cell.innerHTML = '';
cell.appendChild(inp);
inp.focus(); inp.select();
const save = async () => {
const val = inp.value.trim();
if (!val || val === currentVal) { cell.innerHTML = orig; return; }
const r = await fetch(API + `/api/sites/${id}`, {
method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ [field]: val })
});
if (r.ok) {
loadSites();
addLog(`Site #${id} ${field} updated`, 'log-msg-ok');
} else {
showAlert('Failed to update site.', 'error');
cell.innerHTML = orig;
}
};
inp.addEventListener('keydown', e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') cell.innerHTML = orig; });
inp.addEventListener('blur', save);
}
/* ─── Currency Picker ────────────────────────────────────────────── */
const _ALL_CURRENCIES = [
{code:'', label:'— None (show raw prices)'},
{code:'USD', label:'USD — US Dollar'},
{code:'EUR', label:'EUR — Euro'},
{code:'GBP', label:'GBP — British Pound'},
{code:'CAD', label:'CAD — Canadian Dollar'},
{code:'AUD', label:'AUD — Australian Dollar'},
{code:'JPY', label:'JPY — Japanese Yen'},
{code:'CHF', label:'CHF — Swiss Franc'},
{code:'SEK', label:'SEK — Swedish Krona'},
{code:'NOK', label:'NOK — Norwegian Krone'},
{code:'DKK', label:'DKK — Danish Krone'},
{code:'NZD', label:'NZD — New Zealand Dollar'},
{code:'HKD', label:'HKD — Hong Kong Dollar'},
{code:'SGD', label:'SGD — Singapore Dollar'},
{code:'MXN', label:'MXN — Mexican Peso'},
{code:'BRL', label:'BRL — Brazilian Real'},
{code:'INR', label:'INR — Indian Rupee'},
{code:'KRW', label:'KRW — South Korean Won'},
{code:'CNY', label:'CNY — Chinese Yuan'},
{code:'ZAR', label:'ZAR — South African Rand'},
{code:'AED', label:'AED — UAE Dirham'},
{code:'IQD', label:'IQD — Iraqi Dinar'},
];
function showCurrencyDropdown(query) {
const dd = document.getElementById('currency-dropdown');
const q = (query || '').toUpperCase();
const matches = _ALL_CURRENCIES.filter(c => !q || c.code.includes(q) || c.label.toUpperCase().includes(q));
if (!matches.length) { dd.style.display = 'none'; return; }
dd.innerHTML = matches.map(c =>
`<div class="currency-opt" onmousedown="selectCurrency('${c.code}')">${c.code ? '<b>' + c.code + '</b> — ' + c.label.split('— ')[1] : c.label}</div>`
).join('');
dd.style.display = 'block';
}
function hideCurrencyDropdown() {
const dd = document.getElementById('currency-dropdown');
if (dd) dd.style.display = 'none';
}
function selectCurrency(code) {
document.getElementById('cfg-display-currency').value = code;
hideCurrencyDropdown();
}
/* ─── Settings ──────────────────────────────────────────────────── */
async function loadSettings() {
try {
const r = await fetch(API + '/api/config');
const d = await r.json();
if (d.telegram_token) document.getElementById('cfg-token').value = d.telegram_token;
if (d.telegram_chat_id) document.getElementById('cfg-chatid').value = d.telegram_chat_id;
if (d.timer) document.getElementById('cfg-timer').value = d.timer;
if (d.delay_launch !== undefined) document.getElementById('cfg-delay-launch').value = d.delay_launch || '0';
if (d.delay_post_search !== undefined) document.getElementById('cfg-delay-search').value = d.delay_post_search || '0';
if (d.delay_page_hold !== undefined) document.getElementById('cfg-delay-hold').value = d.delay_page_hold || '0';
if (d.delay_site_open !== undefined) document.getElementById('cfg-delay-site-open').value = d.delay_site_open || '0';
selectBrowser(d.browser_choice || 'auto');
selectHumanize(d.humanize_level || 'heavy');
setChannelsFromString(d.alert_channels || 'telegram');
// CAPTCHA
if (d.captcha_solver) document.getElementById('cfg-captcha-solver').value = d.captcha_solver;
if (d.captcha_api_key) document.getElementById('cfg-captcha-key').value = d.captcha_api_key;
// Discord
if (d.discord_webhook) document.getElementById('cfg-discord-webhook').value = d.discord_webhook;
// Email — Gmail only (old SMTP keys ignored in UI)
// Closing alerts
const closingEnabled = (d.closing_alert_enabled === 'true' || d.closing_alert_enabled === '1');
document.getElementById('cfg-closing-enabled').checked = closingEnabled;
document.getElementById('closing-enabled-label').textContent = closingEnabled ? 'ON' : 'OFF';
// Multi-interval schedule (replaces single threshold)
if (d.closing_alert_schedule !== undefined)
document.getElementById('cfg-closing-schedule').value = d.closing_alert_schedule || '30';
// N4: currency display
if (d.display_currency !== undefined) {
const dc = (d.display_currency || '').toUpperCase();
document.getElementById('cfg-display-currency').value = dc;
_displayCurrency = dc;
if (dc) _fetchFxRates(); // pre-load rates for the listings table
}
// Listing detail view preference
if (d.listing_detail_enabled !== undefined)
_listingDetailEnabled = (d.listing_detail_enabled !== 'false');
if (d.listing_detail_enabled !== undefined)
document.getElementById('cfg-listing-detail').checked = (d.listing_detail_enabled !== 'false');
// N1: proxy
const proxyEnabled = (d.proxy_enabled === 'true' || d.proxy_enabled === '1');
document.getElementById('cfg-proxy-enabled').checked = proxyEnabled;
document.getElementById('proxy-enabled-label').textContent = proxyEnabled ? 'ON' : 'OFF';
if (d.proxy_list !== undefined) document.getElementById('cfg-proxy-list').value = d.proxy_list || '';
// Gmail
if (d.gmail_address) document.getElementById('cfg-gmail-address').value = d.gmail_address;
if (d.gmail_app_password) document.getElementById('cfg-gmail-app-password').value = d.gmail_app_password;
if (d.email_to) document.getElementById('cfg-email-to').value = d.email_to;
// Auto-disable
if (d.site_auto_disable_after !== undefined) document.getElementById('cfg-auto-disable').value = d.site_auto_disable_after || '5';
// N16: AI filter
const aiEnabled = (d.ai_filter_enabled === 'true' || d.ai_filter_enabled === '1');
document.getElementById('cfg-ai-enabled').checked = aiEnabled;
document.getElementById('ai-enabled-label').textContent = aiEnabled ? 'ON' : 'OFF';
if (d.ai_provider) { document.getElementById('cfg-ai-provider').value = d.ai_provider; onAiProviderChange(); }
if (d.ai_model) document.getElementById('cfg-ai-model').value = d.ai_model;
if (d.ai_api_key) document.getElementById('cfg-ai-api-key').value = d.ai_api_key;
if (d.ai_base_url) document.getElementById('cfg-ai-base-url').value = d.ai_base_url;
// AI Debug mode
const aiDebug = (d.ai_debug === 'true' || d.ai_debug === '1');
document.getElementById('cfg-ai-debug').checked = aiDebug;
document.getElementById('ai-debug-label').textContent = aiDebug ? 'ON' : 'OFF';
// N17: Auto-Adapter toggle
const autoAdapt = (d.auto_adapt_enabled === 'true' || d.auto_adapt_enabled === '1');
document.getElementById('cfg-auto-adapt-enabled').checked = autoAdapt;
document.getElementById('auto-adapt-label').textContent = autoAdapt ? 'ON' : 'OFF';
const incog = (d.incognito_mode === 'true' || d.incognito_mode === '1');
document.getElementById('cfg-incognito').checked = incog;
document.getElementById('incognito-label').textContent = incog ? 'ON — Incognito Active' : 'OFF';
const visible = (d.show_browser === 'true' || d.show_browser === '1');
document.getElementById('cfg-headless').checked = visible;
document.getElementById('headless-label').textContent = visible
? 'VISIBLE — Browser window open (debug mode)'
: 'HEADLESS — Browser hidden';
} catch(e) {}
}
function onAiProviderChange() {
const val = document.getElementById('cfg-ai-provider').value;
document.getElementById('ai-groq-section').style.display = (val === 'groq') ? '' : 'none';
document.getElementById('ai-ollama-section').style.display = (val === 'ollama') ? '' : 'none';
// Set sensible default models when switching provider
const modelEl = document.getElementById('cfg-ai-model');
if (!modelEl.value || modelEl.value === 'llama-3.3-70b-versatile' || modelEl.value === 'llama3.2:3b') {
if (val === 'groq') modelEl.value = 'llama-3.3-70b-versatile';
if (val === 'ollama') modelEl.value = 'llama3.2:3b';
}
}
function selectBrowser(val) {
document.querySelectorAll('.browser-card').forEach(card => {
const isMatch = card.dataset.value === val;
card.classList.toggle('selected', isMatch);
card.querySelector('input[type=radio]').checked = isMatch;
});
}
const _humanizeDescs = {
raw: `⚡ RAW — No simulation. Fastest scraping, zero bot protection. Use only if sites don't block you.`,
low: `🐇 LOW — Quick mouse move + one scroll pass. Minimal overhead, basic believability.`,
medium: `🧍 MEDIUM — Mouse + multi-step scroll + idle pauses. Good balance of speed and stealth.`,
heavy: `🐢 HEAVY — Full simulation: bezier mouse paths, read-rhythm scrolling, char-by-char typing with typos, homepage pre-visit. Hardest to detect.`,
};
async function restoreDatabase(input) {
const file = input.files[0];
if (!file) return;
const status = document.getElementById('backup-status');
status.style.display = 'block';
if (!file.name.endsWith('.db')) {
status.style.color = '#ff4444';
status.textContent = '✗ File must be a .db file (from a previous Ghost Node backup).';
input.value = '';
return;
}
const confirmed = confirm(
`Restore database from "${file.name}"?\n\n` +
`Your current database will be auto-saved as a backup before being replaced.\n` +
`Ghost Node will restart automatically after restore.\n\n` +
`Proceed?`
);
if (!confirmed) { input.value = ''; return; }
status.style.color = 'var(--accent-gold)';
status.textContent = `⏳ Uploading ${file.name} (${(file.size/1024).toFixed(1)} KB)…`;
try {
const arrayBuffer = await file.arrayBuffer();
const r = await fetch(API + '/api/backup/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: arrayBuffer,
});
const d = await r.json();
if (r.ok) {
status.style.color = 'var(--accent-green)';
status.textContent = `${d.message}`;
// Auto-refresh after 5s when server restarts
setTimeout(() => { window.location.reload(); }, 5500);
} else {
status.style.color = '#ff4444';
status.textContent = `${d.error || 'Restore failed.'}`;
}
} catch (exc) {
status.style.color = '#ff4444';
status.textContent = `✗ Network error: ${exc.message}`;
}
input.value = '';
}
async function triggerLogin(siteId) {
const r = await fetch(API + `/api/sites/${siteId}/login`, { method: 'POST' });
const d = await r.json();
if (r.ok) {
showAlert(d.message || 'Browser opening — log in and close it when done.', 'success');
} else {
showAlert(d.error || 'Login trigger failed.', 'error');
}
}
/* ─── N17: Auto-Adapter ──────────────────────────────────────────── */
async function triggerAdapt(siteId) {
const btn = event && event.target;
if (btn) { btn.disabled = true; btn.textContent = '⏳…'; }
const r = await fetch(API + `/api/sites/${siteId}/adapt`, { method: 'POST' });
const d = await r.json();
if (btn) { btn.disabled = false; btn.textContent = btn.dataset.label || '🤖 Adapt'; }
if (r.ok) {
showAlert(d.message || 'AI adaptation started — reload Sites in ~30s to see the result.', 'success');
addLog(`🤖 Auto-Adapt started for site #${siteId}`, 'log-msg-info');
} else {
showAlert(d.error || 'Adapt failed.', 'error');
}
}
async function deleteSelectors(siteId) {
if (!confirm('Clear AI selectors for this site? It will fall back to the universal extractor until re-adapted.')) return;
const r = await fetch(API + `/api/sites/${siteId}/selectors`, { method: 'DELETE' });
const d = await r.json();
if (r.ok) {
showAlert(d.message || 'Selectors cleared.', 'success');
loadSites();
} else {
showAlert(d.error || 'Delete failed.', 'error');
}
}
/* ─── Alert Channel multi-select ────────────────────────────────── */
let _activeChannels = new Set(['telegram']);
function toggleChannel(ch) {
if (_activeChannels.has(ch)) {
if (_activeChannels.size === 1) { showAlert('At least one channel must be active.', 'error'); return; }
_activeChannels.delete(ch);
} else {
_activeChannels.add(ch);
}
_refreshChannelUI();
}
function _refreshChannelUI() {
['telegram','discord','email'].forEach(ch => {
const btn = document.getElementById('ch-' + ch);
if (btn) btn.classList.toggle('active', _activeChannels.has(ch));
});
document.getElementById('discord-section').style.display = _activeChannels.has('discord') ? 'block' : 'none';
document.getElementById('email-section').style.display = _activeChannels.has('email') ? 'block' : 'none';
}
function setChannelsFromString(csvStr) {
_activeChannels = new Set((csvStr || 'telegram').split(',').map(s => s.trim()).filter(Boolean));
_refreshChannelUI();
}
function selectHumanize(val) {
document.querySelectorAll('.humanize-btn').forEach(b => {
b.className = b.className.replace(/\bactive-\S+/g, '').trim();
if (b.dataset.val === val) b.classList.add('active-' + val);
});
const desc = document.getElementById('humanize-desc');
if (desc) desc.textContent = _humanizeDescs[val] || '';
let inp = document.getElementById('cfg-humanize');
if (!inp) {
inp = document.createElement('input');
inp.type = 'hidden'; inp.id = 'cfg-humanize';
document.body.appendChild(inp);
}
inp.value = val;
}
// Wire click events on the browser cards
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.browser-card').forEach(card => {
card.addEventListener('click', () => selectBrowser(card.dataset.value));
});
const tog = document.getElementById('cfg-incognito');
if (tog) {
tog.addEventListener('change', () => {
document.getElementById('incognito-label').textContent =
tog.checked ? 'ON — Incognito Active' : 'OFF';
});
}
const togH = document.getElementById('cfg-headless');
if (togH) {
togH.addEventListener('change', () => {
document.getElementById('headless-label').textContent = togH.checked
? 'VISIBLE — Browser window open (debug mode)'
: 'HEADLESS — Browser hidden';
});
}
});
async function saveSettings() {
const token = document.getElementById('cfg-token').value.trim();
const chatid = document.getElementById('cfg-chatid').value.trim();
const timer = document.getElementById('cfg-timer').value.trim() || '120';
const delayLaunch = document.getElementById('cfg-delay-launch').value.trim() || '0';
const delaySearch = document.getElementById('cfg-delay-search').value.trim() || '0';
const delayHold = document.getElementById('cfg-delay-hold').value.trim() || '0';
const siteOpenDelay = document.getElementById('cfg-delay-site-open').value.trim() || '0';
const selectedCard = document.querySelector('.browser-card.selected');
const incognito = document.getElementById('cfg-incognito').checked ? 'true' : 'false';
const showBrowser = document.getElementById('cfg-headless').checked ? 'true' : 'false';
const browser = selectedCard ? selectedCard.dataset.value : 'auto';
const payload = {
telegram_token: token,
telegram_chat_id: chatid,
timer: timer,
delay_launch: delayLaunch,
delay_post_search: delaySearch,
delay_page_hold: delayHold,
delay_site_open: siteOpenDelay,
browser_choice: browser,
humanize_level: (document.getElementById('cfg-humanize') || {}).value || 'heavy',
incognito_mode: incognito,
show_browser: showBrowser,
// N2 CAPTCHA
captcha_solver: document.getElementById('cfg-captcha-solver').value,
captcha_api_key: document.getElementById('cfg-captcha-key').value,
// N9 closing alerts
closing_alert_enabled: document.getElementById('cfg-closing-enabled').checked ? 'true' : 'false',
closing_alert_schedule: document.getElementById('cfg-closing-schedule').value || '30',
// N10 alert channels
alert_channels: Array.from(_activeChannels).join(','),
discord_webhook: document.getElementById('cfg-discord-webhook').value,
// Gmail (simplified email)
gmail_address: document.getElementById('cfg-gmail-address').value.trim(),
gmail_app_password:document.getElementById('cfg-gmail-app-password').value.trim(),
email_to: document.getElementById('cfg-email-to').value.trim(),
// N4 currency display
display_currency: (document.getElementById('cfg-display-currency').value || '').trim().toUpperCase(),
// N1 proxy rotation
proxy_enabled: document.getElementById('cfg-proxy-enabled').checked ? 'true' : 'false',
proxy_list: document.getElementById('cfg-proxy-list').value,
// N13 auto-disable
site_auto_disable_after: document.getElementById('cfg-auto-disable').value || '5',
// N16 AI filter
ai_filter_enabled: document.getElementById('cfg-ai-enabled').checked ? 'true' : 'false',
ai_provider: document.getElementById('cfg-ai-provider').value,
ai_model: document.getElementById('cfg-ai-model').value.trim() || '',
ai_api_key: document.getElementById('cfg-ai-api-key').value.trim(),
ai_base_url: document.getElementById('cfg-ai-base-url').value.trim() || 'http://localhost:11434',
ai_debug: document.getElementById('cfg-ai-debug').checked ? 'true' : 'false',
// N17 Auto-Adapter
auto_adapt_enabled: document.getElementById('cfg-auto-adapt-enabled').checked ? 'true' : 'false',
// Dashboard preferences
listing_detail_enabled: document.getElementById('cfg-listing-detail').checked ? 'true' : 'false',
};
const r = await fetch(API + '/api/config', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
if (r.ok) {
showAlert('Configuration saved to SSD.');
addLog(`Settings updated — Telegram C2, timer & browser (${browser}) saved.`, 'log-msg-ok');
// Refresh display currency cache
_displayCurrency = payload.display_currency || '';
if (_displayCurrency) _fetchFxRates();
// Update listing detail preference
_listingDetailEnabled = (payload.listing_detail_enabled === 'true');
} else {
showAlert('Failed to save configuration.', 'error');
}
}
/* ─── Engine Controls ───────────────────────────────────────────── */
async function enginePause() {
await fetch(API + '/api/engine/pause', { method: 'POST' });
showAlert('Engine paused.');
addLog('Engine paused by operator.', 'log-msg-warn');
}
async function engineResume() {
await fetch(API + '/api/engine/resume', { method: 'POST' });
showAlert('Engine resumed.');
addLog('Engine resumed.', 'log-msg-ok');
}
async function engineRestart() {
if (!confirm('Restart Ghost Node?\nThe current process will exit and a new one will start.\nThe dashboard will reconnect automatically.')) return;
const btn = document.getElementById('btn-restart');
const origText = btn ? btn.textContent : '';
if (btn) { btn.disabled = true; btn.textContent = '⏳ RESTARTING…'; }
addLog('Restart requested — sending signal to server…', 'log-msg-warn');
// Step 1 — fire the restart request
// We expect the fetch to either:
// (a) resolve with 200 before the process exits, or
// (b) reject because the server closed the connection mid-response.
// Either outcome is fine — we move straight to polling.
try {
await fetch(API + '/api/engine/restart', { method: 'POST' });
} catch (_) {
// Connection closed before response arrived — process already restarting
}
addLog('Process restarting — polling for reconnection…', 'log-msg-warn');
// Step 2 — wait 2 seconds for the new process to bind port 8000
await new Promise(r => setTimeout(r, 2000));
// Step 3 — poll /api/stats every 500 ms until the server responds
let attempts = 0;
const MAX_ATTEMPTS = 30; // 15 seconds total
const poll = setInterval(async () => {
attempts++;
try {
const r = await fetch(API + '/api/stats', { cache: 'no-store' });
if (r.ok) {
clearInterval(poll);
if (btn) { btn.disabled = false; btn.textContent = origText; }
showAlert('Ghost Node restarted successfully — back online!');
addLog('Ghost Node back online after restart.', 'log-msg-hit');
loadStats(); // refresh the status panel immediately
}
} catch (_) {
// Server not up yet — keep polling
}
if (attempts >= MAX_ATTEMPTS) {
clearInterval(poll);
if (btn) { btn.disabled = false; btn.textContent = origText; }
showAlert('Restart is taking longer than expected — check the terminal.', 'error');
addLog('Restart timeout — server did not respond in 15s.', 'log-msg-warn');
}
}, 500);
}
async function engineKill() {
if (!confirm(
'KILL Ghost Node?\n\n' +
'This will immediately terminate the entire process.\n' +
'The dashboard will go offline and will NOT reconnect.\n' +
'You will need to restart manually from the terminal.\n\n' +
'Are you sure?'
)) return;
const btn = document.getElementById('btn-kill');
if (btn) { btn.disabled = true; btn.textContent = '💀 KILLING…'; }
addLog('KILL signal sent — process will terminate immediately.', 'log-msg-warn');
try {
await fetch(API + '/api/engine/kill', { method: 'POST' });
} catch (_) {
// Process died before responding — expected
}
// Dashboard goes dark — process is gone
addLog('Ghost Node process killed. Refresh this page will show offline.', 'log-msg-warn');
showAlert('Ghost Node has been killed. Restart from the terminal to bring it back online.', 'error');
// Grey out all control buttons — nothing to talk to anymore
document.querySelectorAll('.btn-control').forEach(b => {
b.disabled = true;
b.style.opacity = '0.35';
});
if (btn) { btn.textContent = '☠ KILLED'; }
}
/* ─── Helpers ───────────────────────────────────────────────────── */
// Show price as "50.00 USD" format.
// price_raw is already stored as "50.00 USD" from the scraper.
// Falls back to raw float if price_raw is missing (legacy rows).
function fmtPrice(l) {
if (l.price_raw && l.price_raw.trim()) return escHtml(l.price_raw.trim());
if (l.price != null) {
const cur = (l.currency && l.currency.trim()) ? l.currency.trim() : 'USD';
return Number(l.price).toFixed(2) + ' ' + cur;
}
return '—';
}
// Live countdown — computes remaining time from time_left_mins + timestamp
// Falls back to static time_left string if time_left_mins not available.
function calcLiveTimeLeft(l) {
if (l.time_left_mins == null) return l.time_left || '';
const capturedAt = l.timestamp ? new Date(l.timestamp).getTime() : null;
if (!capturedAt) return l.time_left || '';
const elapsedMins = (Date.now() - capturedAt) / 60000;
const remainMins = Math.max(0, l.time_left_mins - elapsedMins);
if (remainMins <= 0) return 'CLOSED';
const d = Math.floor(remainMins / 1440);
const h = Math.floor((remainMins % 1440) / 60);
const m = Math.floor(remainMins % 60);
const parts = [];
if (d) parts.push(d + 'd');
if (h) parts.push(h + 'h');
if (m || (!d && !h)) parts.push(m + 'm');
return parts.join(' ');
}
function fmtTimeLeft(l) {
const t = calcLiveTimeLeft(l);
if (!t || t === '—') return '—';
return escHtml(t);
}
// Urgency colour: red < 1h, orange < 6h, gold otherwise
function timeLeftColor(l) {
if (l.time_left_mins == null) return 'var(--accent-gold)';
const capturedAt = l.timestamp ? new Date(l.timestamp).getTime() : null;
if (!capturedAt) return 'var(--accent-gold)';
const remainMins = Math.max(0, l.time_left_mins - (Date.now() - capturedAt) / 60000);
if (remainMins <= 0) return 'var(--text-dim)';
if (remainMins <= 60) return '#ff4444';
if (remainMins <= 360) return '#ff9900';
return 'var(--accent-gold)';
}
function escHtml(str) {
return String(str).replace(/[&<>"']/g, c => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[c]));
}
function fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleDateString('en-GB', { day:'2-digit', month:'short' }) + ' '
+ d.toTimeString().slice(0,5);
}
/* ═══════════════════════════════════════════════════════════════════
AI DEBUG LOG — live panel for watching AI prompts & responses
═══════════════════════════════════════════════════════════════════ */
let _aiDebugPollTimer = null;
let _aiDebugLastId = 0;
let _aiDebugFilter = 'all'; // 'all' | 'filter' | 'adapt' | 'error'
let _aiDebugAllEntries = []; // full local cache (newest last)
// ── Counters ─────────────────────────────────────────────────────────
let _aiDbgTotalCalls = 0;
let _aiDbgTokP = 0;
let _aiDbgTokC = 0;
let _aiDbgYes = 0;
let _aiDbgNo = 0;
let _aiDbgErrors = 0;
function startAiDebugPolling() {
stopAiDebugPolling();
_fetchAiDebugLog(true); // immediate first fetch
_aiDebugPollTimer = setInterval(() => _fetchAiDebugLog(false), 2000);
document.getElementById('aidebug-live-dot').style.background = 'var(--accent-green)';
document.getElementById('aidebug-live-dot').style.boxShadow = '0 0 6px var(--accent-green)';
document.getElementById('aidebug-live-label').textContent = 'LIVE';
document.getElementById('aidebug-live-label').style.color = 'var(--accent-green)';
}
function stopAiDebugPolling() {
if (_aiDebugPollTimer) { clearInterval(_aiDebugPollTimer); _aiDebugPollTimer = null; }
const dot = document.getElementById('aidebug-live-dot');
if (dot) {
dot.style.background = '#444';
dot.style.boxShadow = 'none';
document.getElementById('aidebug-live-label').textContent = 'POLLING OFF';
document.getElementById('aidebug-live-label').style.color = 'var(--text-dim)';
}
}
async function _fetchAiDebugLog(force) {
if (force) { _aiDebugLastId = 0; document.getElementById('aidebug-log').innerHTML = ''; }
try {
const since = force ? 0 : _aiDebugLastId;
const r = await fetch(API + `/api/ai/debug/log?limit=300&since_id=${since}`);
if (!r.ok) return;
const d = await r.json();
// Warn if debug is off
document.getElementById('aidebug-off-warn').style.display = d.debug_enabled ? 'none' : 'block';
if (!d.entries || d.entries.length === 0) {
if (force) _renderAiDebugLog([]);
return;
}
if (force) {
_aiDebugAllEntries = d.entries;
// Recalculate counters from scratch
_aiDbgTotalCalls = _aiDbgTokP = _aiDbgTokC = _aiDbgYes = _aiDbgNo = _aiDbgErrors = 0;
} else {
_aiDebugAllEntries = _aiDebugAllEntries.concat(d.entries);
}
// Update counters from new entries
for (const e of d.entries) {
if (e.direction === 'request') _aiDbgTotalCalls++;
if (e.direction === 'response') {
_aiDbgTokP += (e.tokens_prompt || 0);
_aiDbgTokC += (e.tokens_completion || 0);
if (e.verdict === 'YES') _aiDbgYes++;
if (e.verdict === 'NO') _aiDbgNo++;
}
if (e.direction === 'error') _aiDbgErrors++;
}
// Track highest id for incremental polling
const maxId = Math.max(...d.entries.map(e => e.id || 0));
if (maxId > 0) _aiDebugLastId = maxId;
_updateAiDebugStats();
_renderAiDebugLog(force ? _aiDebugAllEntries : null);
} catch(e) { /* network error — silently skip */ }
}
function _updateAiDebugStats() {
document.getElementById('aidebug-stat-calls').textContent = _aiDbgTotalCalls.toLocaleString();
document.getElementById('aidebug-stat-tokens-p').textContent = _aiDbgTokP.toLocaleString();
document.getElementById('aidebug-stat-tokens-c').textContent = _aiDbgTokC.toLocaleString();
document.getElementById('aidebug-stat-yes').textContent = _aiDbgYes.toLocaleString();
document.getElementById('aidebug-stat-no').textContent = _aiDbgNo.toLocaleString();
document.getElementById('aidebug-stat-errors').textContent = _aiDbgErrors.toLocaleString();
}
function _renderAiDebugLog(entries) {
const container = document.getElementById('aidebug-log');
const empty = document.getElementById('aidebug-empty');
// If entries is null, we're in incremental mode — append only new cards
const toRender = entries !== null ? entries : _aiDebugAllEntries.slice(-(_aiDebugAllEntries.length));
const filtered = _applyAiFilter(toRender);
if (filtered.length === 0) {
empty.style.display = 'flex';
if (entries !== null) container.innerHTML = '';
container.appendChild(empty);
return;
}
empty.style.display = 'none';
if (entries !== null) {
// Full re-render
container.innerHTML = '';
for (const e of [...filtered].reverse()) { // newest first
container.appendChild(_buildAiDebugCard(e));
}
} else {
// Incremental: prepend new cards (new entries come at end of array, newest first on screen)
const latest = _applyAiFilter(_aiDebugAllEntries).slice(-10); // last 10 new
for (const e of [...latest].reverse()) {
const existing = document.getElementById(`aidl-${e.id}`);
if (!existing) container.insertBefore(_buildAiDebugCard(e), container.firstChild);
}
}
}
function _applyAiFilter(entries) {
if (_aiDebugFilter === 'all') return entries;
if (_aiDebugFilter === 'error') return entries.filter(e => e.direction === 'error');
return entries.filter(e => e.call_type === _aiDebugFilter);
}
function _buildAiDebugCard(e) {
const div = document.createElement('div');
div.id = `aidl-${e.id}`;
div.className = `aidl-card ${e.direction}` +
(e.verdict === 'YES' ? ' verdict-yes' : e.verdict === 'NO' ? ' verdict-no' : '');
const dirLabel = { request: '→ SENT', response: '← RECEIVED', error: '⚠ ERROR' }[e.direction] || e.direction;
const dirClass = { request: 'req', response: 'resp', error: 'err' }[e.direction] || 'req';
const typeClass = e.call_type === 'adapt' ? 'adapt' : 'filter';
const typeLabel = e.call_type === 'adapt' ? '🤖 AUTO-ADAPT' : '🔍 AI FILTER';
const ts = e.ts ? new Date(e.ts).toLocaleTimeString() : '';
const model = e.model ? `<span style="color:var(--text-dim)">${escHtml(e.model)}</span>` : '';
// Meta line
let meta = '';
if (e.direction === 'response') {
const tokStr = (e.tokens_prompt != null && e.tokens_completion != null)
? `<span style="color:#888;margin-left:8px">tokens: <b style="color:var(--accent)">${e.tokens_prompt}</b> prompt + <b style="color:var(--accent-green)">${e.tokens_completion}</b> completion</span>`
: '';
const verdict = e.verdict === 'YES'
? `<span style="color:var(--accent-green);font-weight:700;margin-left:8px">✅ YES</span>`
: e.verdict === 'NO'
? `<span style="color:#ff4444;font-weight:700;margin-left:8px">❌ NO</span>`
: '';
meta = tokStr + verdict;
}
// Context info (title for filter, site for adapt)
let ctx = '';
if (e.title) ctx = `<span style="color:var(--accent-gold);margin-left:8px" title="Lot title">📦 ${escHtml(e.title.slice(0, 60))}${e.title.length > 60 ? '…' : ''}</span>`;
if (e.site) ctx += `<span style="color:#88aaff;margin-left:8px" title="Site">🌐 ${escHtml(e.site)}</span>`;
const isLong = (e.content || '').length > 300;
const bodyId = `aidl-body-${e.id}`;
div.innerHTML = `
<div class="aidl-header">
<span class="aidl-badge ${dirClass}">${dirLabel}</span>
<span class="aidl-badge ${typeClass}">${typeLabel}</span>
${model}
<span style="color:var(--text-dim);font-size:10px;margin-left:auto">${ts}</span>
${meta}
${ctx}
</div>
<div class="aidl-body ${isLong ? 'collapsed' : ''}" id="${bodyId}">${escHtml(e.content || '')}</div>
${isLong ? `<button class="aidl-expand" onclick="toggleAiCard('${bodyId}', this)">▼ Show full (${(e.content||'').length} chars)</button>` : ''}
`;
return div;
}
function toggleAiCard(bodyId, btn) {
const body = document.getElementById(bodyId);
if (!body) return;
const collapsed = body.classList.toggle('collapsed');
btn.textContent = collapsed ? `▼ Show full` : `▲ Collapse`;
}
function setAiFilter(f) {
_aiDebugFilter = f;
document.querySelectorAll('.aidebug-filter').forEach(b => {
b.classList.toggle('active', b.dataset.filter === f);
});
_renderAiDebugLog(_aiDebugAllEntries);
}
function filterAiLog() {
const q = (document.getElementById('aidl-search')?.value || '').toLowerCase();
document.querySelectorAll('#aidebug-log .aidl-card').forEach(card => {
const text = card.textContent.toLowerCase();
card.style.display = (!q || text.includes(q)) ? '' : 'none';
});
}
async function clearAiDebugLog() {
if (!confirm('Clear the AI debug log? This only clears the in-memory buffer — it does not affect your listings or settings.')) return;
await fetch(API + '/api/ai/debug/log', { method: 'DELETE' });
_aiDebugAllEntries = [];
_aiDebugLastId = 0;
_aiDbgTotalCalls = _aiDbgTokP = _aiDbgTokC = _aiDbgYes = _aiDbgNo = _aiDbgErrors = 0;
_updateAiDebugStats();
_renderAiDebugLog([]);
showAlert('AI debug log cleared.', 'success');
}
</script>
<!-- ═══ Listing Detail Modal ════════════════════════════════════════════════ -->
<div id="listing-detail-modal" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.8);align-items:center;justify-content:center" onclick="if(event.target===this)closeListingDetail()">
<div style="background:var(--bg-panel);border:1px solid var(--accent);border-radius:8px;padding:28px 32px;min-width:540px;max-width:760px;width:92vw;max-height:88vh;overflow-y:auto">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:18px">
<div style="font-family:var(--font-mono);color:var(--accent);font-size:13px;letter-spacing:2px">📦 LISTING DETAIL</div>
<button class="btn btn-sm" onclick="closeListingDetail()" style="font-size:16px;padding:2px 10px">×</button>
</div>
<div style="font-size:15px;color:var(--text-hi);font-weight:600;margin-bottom:16px;line-height:1.4" id="ld-title"></div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:14px">
<div class="bg-card" style="background:var(--bg-card);border-radius:6px;padding:10px">
<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:1px;margin-bottom:4px">PRICE</div>
<div style="font-family:var(--font-mono);font-size:14px;color:var(--accent-gold);font-weight:700" id="ld-price"></div>
</div>
<div style="background:var(--bg-card);border-radius:6px;padding:10px">
<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:1px;margin-bottom:4px">TIME LEFT</div>
<div style="font-family:var(--font-mono);font-size:13px;color:var(--accent-gold)" id="ld-time-left"></div>
</div>
<div style="background:var(--bg-card);border-radius:6px;padding:10px">
<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:1px;margin-bottom:4px">LOCATION</div>
<div style="font-family:var(--font-mono);font-size:12px;color:var(--text-hi)" id="ld-location"></div>
</div>
<div style="background:var(--bg-card);border-radius:6px;padding:10px">
<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:1px;margin-bottom:4px">SITE</div>
<div style="font-family:var(--font-mono);font-size:12px;color:var(--text-hi)" id="ld-site"></div>
</div>
<div style="background:var(--bg-card);border-radius:6px;padding:10px">
<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:1px;margin-bottom:4px">KEYWORD</div>
<div style="font-family:var(--font-mono);font-size:12px;color:var(--accent)" id="ld-keyword"></div>
</div>
<div style="background:var(--bg-card);border-radius:6px;padding:10px">
<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:1px;margin-bottom:4px">SCORE</div>
<div style="font-family:var(--font-mono);font-size:13px;color:var(--accent-gold);font-weight:700" id="ld-score"></div>
</div>
</div>
<div style="background:var(--bg-card);border-radius:6px;padding:10px;margin-bottom:14px">
<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:1px;margin-bottom:4px">AI VERDICT</div>
<div style="font-family:var(--font-mono);font-size:12px;color:var(--accent-green)" id="ld-ai"></div>
<div style="font-family:var(--font-mono);font-size:11px;color:var(--text-mid);margin-top:4px;font-style:italic" id="ld-ai-reason"></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:18px">
<div style="background:var(--bg-card);border-radius:6px;padding:10px">
<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:1px;margin-bottom:4px">CAPTURED</div>
<div style="font-family:var(--font-mono);font-size:11px;color:var(--text-mid)" id="ld-captured"></div>
</div>
<div style="background:var(--bg-card);border-radius:6px;padding:10px">
<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:1px;margin-bottom:4px">LAST REFRESHED</div>
<div style="font-family:var(--font-mono);font-size:11px;color:var(--text-mid)" id="ld-refreshed"></div>
</div>
</div>
<!-- Image gallery — hidden when no images captured -->
<div id="ld-images-wrap" style="margin-bottom:18px">
<div style="font-family:var(--font-mono);font-size:9px;color:var(--text-dim);letter-spacing:1px;margin-bottom:8px">LOT IMAGES</div>
<div id="ld-images" style="display:flex;flex-wrap:wrap;gap:8px"></div>
</div>
<a id="ld-link" href="#" target="_blank" rel="noopener noreferrer" class="btn btn-primary" style="display:inline-block;text-decoration:none">↗ Open Listing</a>
</div>
</div>
<!-- ═══ Edit Site Modal ═════════════════════════════════════════════════════ -->
<div id="edit-site-modal" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.8);align-items:center;justify-content:center">
<div style="background:var(--bg-panel);border:1px solid var(--accent);border-radius:8px;padding:28px 32px;min-width:560px;max-width:720px;width:92vw;max-height:90vh;overflow-y:auto">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
<div style="font-family:var(--font-mono);color:var(--accent);font-size:13px;letter-spacing:2px">✏ EDIT SITE</div>
<button class="btn btn-sm" onclick="closeEditSite()" style="font-size:16px;padding:2px 10px">×</button>
</div>
<input type="hidden" id="es-id">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
<div>
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">SITE NAME</label>
<input id="es-name" type="text" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box"/>
</div>
<div>
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">SEARCH SELECTOR <span style="color:var(--text-dim)">(optional)</span></label>
<input id="es-selector" type="text" placeholder="e.g. input#st or blank for auto-discover" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box"/>
</div>
</div>
<div style="margin-bottom:12px">
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">URL TEMPLATE <span style="color:var(--accent-gold)">(use {keyword} for direct mode)</span></label>
<input id="es-url" type="text" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box"/>
</div>
<div style="display:grid;grid-template-columns:80px 1fr 1fr;gap:12px;margin-bottom:16px;align-items:end">
<div>
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">MAX PAGES</label>
<input id="es-pages" type="number" min="1" max="20" value="1" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:12px;box-sizing:border-box"/>
</div>
<div style="display:flex;align-items:center;gap:10px;padding-bottom:2px">
<label class="toggle-wrap" for="es-login-req">
<input type="checkbox" id="es-login-req" class="toggle-input"/>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
<span style="font-family:var(--font-mono);font-size:11px;color:var(--text-hi)">Requires Login</span>
</div>
<div style="display:flex;align-items:center;gap:10px;padding-bottom:2px">
<label class="toggle-wrap" for="es-login-en">
<input type="checkbox" id="es-login-en" class="toggle-input"/>
<span class="toggle-track"><span class="toggle-thumb"></span></span>
</label>
<span style="font-family:var(--font-mono);font-size:11px;color:var(--text-hi)">Login Enabled</span>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px">
<div>
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">LOGIN URL</label>
<input id="es-login-url" type="text" placeholder="https://site.com/login" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:11px;box-sizing:border-box"/>
</div>
<div>
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">LOGIN CHECK SELECTOR</label>
<input id="es-login-check" type="text" placeholder=".user-avatar or #logout-btn" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:11px;box-sizing:border-box"/>
</div>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end">
<button class="btn" onclick="closeEditSite()">Cancel</button>
<button class="btn btn-primary" onclick="saveEditSite()">💾 Save Changes</button>
</div>
</div>
</div>
<!-- ═══ Price Filter Modal ═══════════════════════════════════════════════════ -->
<div id="price-filter-modal" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.75);align-items:center;justify-content:center">
<div style="background:var(--bg-panel);border:1px solid var(--accent-gold);border-radius:8px;padding:28px 32px;min-width:400px;max-width:520px;width:90vw">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px">
<div style="font-family:var(--font-mono);color:var(--accent-gold);font-size:13px;letter-spacing:2px">
💰 PRICE FILTER — <span id="pf-kw-label"></span>
</div>
<button class="btn btn-sm" onclick="closePriceFilterModal()" style="font-size:16px;padding:2px 10px">×</button>
</div>
<input type="hidden" id="pf-kw-id">
<div style="font-family:var(--font-mono);font-size:11px;color:var(--text-dim);margin-bottom:14px">
Filter listings for this keyword by price. Leave blank for no limit. Values are in the <b style="color:var(--text-hi)">raw listing currency</b> — comparison is done after converting both sides to USD.
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:18px">
<div>
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">MIN PRICE (USD equivalent)</label>
<input id="pf-min-input" type="number" step="0.01" min="0" placeholder="e.g. 50" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:13px;box-sizing:border-box"/>
</div>
<div>
<label style="font-family:var(--font-mono);font-size:10px;color:var(--text-dim)">MAX PRICE (USD equivalent)</label>
<input id="pf-max-input" type="number" step="0.01" min="0" placeholder="e.g. 500" style="width:100%;margin-top:4px;background:var(--bg-card);border:1px solid var(--border-dim);color:var(--text-hi);padding:8px;font-family:var(--font-mono);font-size:13px;box-sizing:border-box"/>
</div>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end">
<button class="btn btn-ghost" onclick="document.getElementById('pf-min-input').value='';document.getElementById('pf-max-input').value=''">Clear</button>
<button class="btn" onclick="closePriceFilterModal()">Cancel</button>
<button class="btn btn-primary" onclick="savePriceFilter()">💾 Save Filter</button>
</div>
</div>
</div>
<!-- ═══ AI Target Modal ═════════════════════════════════════════════════════ -->
<div id="ai-target-modal" style="display:none;position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,.75);align-items:center;justify-content:center">
<div style="background:var(--bg-panel);border:1px solid var(--accent-green);border-radius:8px;padding:28px 32px;min-width:520px;max-width:680px;width:90vw">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:18px">
<div style="font-family:var(--font-mono);color:var(--accent-green);font-size:13px;letter-spacing:2px">
🤖 AI TARGET — <span id="ai-target-kw-label"></span>
</div>
<button class="btn btn-sm" onclick="closeAiTargetModal()" style="font-size:16px;padding:2px 10px">×</button>
</div>
<input type="hidden" id="ai-target-kw-id">
<div style="margin-bottom:8px;color:var(--text-mid);font-size:12px;line-height:1.6">
Describe in plain English exactly what you want to find. The AI reads each lot title and decides YES or NO.<br>
Be specific: mention generation/model range, condition, and what to exclude.
</div>
<textarea id="ai-target-input"
placeholder="e.g. iPad Pro 4th generation (2020) or newer, actual device only — not cases, covers, chargers, screen protectors, keyboards, or any accessory"
style="width:100%;height:90px;background:var(--bg-input);color:var(--text-bright);border:1px solid var(--border);border-radius:4px;padding:10px;font-family:var(--font-mono);font-size:12px;resize:vertical;box-sizing:border-box"
></textarea>
<div style="margin-top:14px;padding:12px;background:var(--bg-card);border:1px solid var(--border);border-radius:4px">
<div style="color:var(--text-mid);font-size:11px;margin-bottom:8px;font-family:var(--font-mono)">// TEST BEFORE SAVING — paste a real lot title below</div>
<div style="display:flex;gap:8px">
<input id="ai-test-title-input" type="text" placeholder="e.g. Apple iPad Pro 12.9-inch 6th Gen 256GB Wi-Fi Space Gray"
style="flex:1;background:var(--bg-input);color:var(--text-bright);border:1px solid var(--border);border-radius:4px;padding:8px;font-family:var(--font-mono);font-size:12px">
<button class="btn btn-secondary" onclick="testAiTarget()" style="white-space:nowrap;font-size:12px">▶ Test</button>
</div>
<div id="ai-test-result" style="margin-top:8px;font-family:var(--font-mono);font-size:12px;min-height:18px"></div>
</div>
<div style="display:flex;gap:10px;justify-content:flex-end;margin-top:18px">
<button class="btn" onclick="closeAiTargetModal()">Cancel</button>
<button class="btn btn-primary" onclick="saveAiTarget()">💾 Save AI Target</button>
</div>
</div>
</div>
</body>
</html>