1947 lines
60 KiB
HTML
1947 lines
60 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Duriin Admin</title>
|
||
<style>
|
||
:root {
|
||
--bg: #020817;
|
||
--bg-card: #0f172a;
|
||
--bg-subtle: #0b1120;
|
||
--border: #1e293b;
|
||
--border-light: #162032;
|
||
--foreground: #f8fafc;
|
||
--muted: #94a3b8;
|
||
--muted-dark: #475569;
|
||
--primary: #f8fafc;
|
||
--primary-bg: #1e293b;
|
||
--accent: #3b82f6;
|
||
--accent-hover: #2563eb;
|
||
--destructive: #7f1d1d;
|
||
--destructive-fg: #fca5a5;
|
||
--radius: 6px;
|
||
--radius-lg: 10px;
|
||
}
|
||
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif;
|
||
background: var(--bg);
|
||
color: var(--foreground);
|
||
font-size: 14px;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
/* ── header ── */
|
||
|
||
header {
|
||
background: var(--bg-card);
|
||
border-bottom: 1px solid var(--border);
|
||
padding: 0 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 24px;
|
||
height: 52px;
|
||
}
|
||
|
||
header h1 {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--foreground);
|
||
letter-spacing: -.01em;
|
||
}
|
||
|
||
header h1 span {
|
||
color: var(--muted);
|
||
font-weight: 400;
|
||
}
|
||
|
||
|
||
/* ── tabs (underline style) ── */
|
||
|
||
.tabs {
|
||
display: flex;
|
||
gap: 0;
|
||
margin-left: auto;
|
||
height: 100%;
|
||
align-items: stretch;
|
||
}
|
||
|
||
.tab-btn {
|
||
background: none;
|
||
border: none;
|
||
border-bottom: 2px solid transparent;
|
||
color: var(--muted);
|
||
padding: 0 14px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
transition: color .15s, border-color .15s;
|
||
height: 100%;
|
||
}
|
||
|
||
.tab-btn:hover { color: var(--foreground); background: none; }
|
||
|
||
.tab-btn.active {
|
||
color: var(--foreground);
|
||
border-bottom-color: var(--foreground);
|
||
background: none;
|
||
}
|
||
|
||
/* ── stats bar ── */
|
||
|
||
.stats-bar {
|
||
display: flex;
|
||
gap: 0;
|
||
background: var(--bg-card);
|
||
border-bottom: 1px solid var(--border);
|
||
padding: 0 24px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.stat {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
padding: 14px 28px 14px 0;
|
||
margin-right: 28px;
|
||
border-right: 1px solid var(--border);
|
||
padding-right: 28px;
|
||
}
|
||
|
||
.stat:last-child { border-right: none; }
|
||
|
||
.stat .label {
|
||
color: var(--muted-dark);
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: .06em;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.stat .value {
|
||
font-size: 22px;
|
||
font-weight: 700;
|
||
color: var(--foreground);
|
||
letter-spacing: -.02em;
|
||
line-height: 1;
|
||
}
|
||
|
||
/* ── content ── */
|
||
|
||
.content { padding: 24px; }
|
||
|
||
/* ── filters ── */
|
||
|
||
.filters {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-bottom: 18px;
|
||
flex-wrap: wrap;
|
||
align-items: flex-end;
|
||
padding: 14px;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-lg);
|
||
}
|
||
|
||
.filters label {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5px;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
color: var(--muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: .05em;
|
||
}
|
||
|
||
/* ── inputs / selects ── */
|
||
|
||
input[type="text"], input[type="date"], select {
|
||
background: var(--bg-subtle);
|
||
border: 1px solid var(--border);
|
||
color: var(--foreground);
|
||
padding: 7px 10px;
|
||
border-radius: var(--radius);
|
||
font-size: 13px;
|
||
outline: none;
|
||
min-width: 140px;
|
||
transition: border-color .15s, box-shadow .15s;
|
||
}
|
||
|
||
input[type="text"]:focus, input[type="date"]:focus, select:focus {
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 3px rgba(59, 130, 246, .15);
|
||
}
|
||
|
||
select option { background: #0f172a; }
|
||
|
||
|
||
/* ── buttons ── */
|
||
|
||
button {
|
||
background: var(--primary-bg);
|
||
border: 1px solid var(--border);
|
||
color: var(--foreground);
|
||
padding: 7px 14px;
|
||
border-radius: var(--radius);
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
transition: background .15s, opacity .1s;
|
||
line-height: 1;
|
||
}
|
||
|
||
button:hover { background: #263347; }
|
||
|
||
button.primary {
|
||
background: var(--foreground);
|
||
color: #0f172a;
|
||
border-color: transparent;
|
||
font-weight: 600;
|
||
}
|
||
|
||
button.primary:hover { background: #e2e8f0; }
|
||
|
||
button.danger {
|
||
background: transparent;
|
||
border-color: var(--destructive);
|
||
color: var(--destructive-fg);
|
||
}
|
||
|
||
button.danger:hover { background: rgba(127, 29, 29, .3); }
|
||
|
||
button:disabled { opacity: .4; cursor: not-allowed; }
|
||
|
||
|
||
/* ── table ── */
|
||
|
||
.table-wrap {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-lg);
|
||
overflow: hidden;
|
||
}
|
||
|
||
table { width: 100%; border-collapse: collapse; }
|
||
|
||
th {
|
||
text-align: left;
|
||
padding: 10px 14px;
|
||
border-bottom: 1px solid var(--border);
|
||
color: var(--muted-dark);
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: .06em;
|
||
font-weight: 600;
|
||
background: var(--bg-subtle);
|
||
}
|
||
|
||
td {
|
||
padding: 10px 14px;
|
||
border-bottom: 1px solid var(--border-light);
|
||
vertical-align: middle;
|
||
}
|
||
|
||
tr:last-child td { border-bottom: none; }
|
||
|
||
tr:hover td { background: rgba(255,255,255,.02); }
|
||
|
||
|
||
/* ── truncate ── */
|
||
|
||
.truncate {
|
||
max-width: 280px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
display: block;
|
||
}
|
||
|
||
|
||
/* ── badges ── */
|
||
|
||
.badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 2px 8px;
|
||
border-radius: 4px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
letter-spacing: .03em;
|
||
}
|
||
|
||
.badge.ok { background: rgba(20, 83, 45, .5); color: #86efac; border: 1px solid rgba(134,239,172,.15); }
|
||
.badge.err { background: rgba(127, 29, 29, .5); color: #fca5a5; border: 1px solid rgba(252,165,165,.15); }
|
||
.badge.pending { background: rgba(30, 58, 95, .5); color: #93c5fd; border: 1px solid rgba(147,197,253,.15); }
|
||
.badge.null { background: rgba(30, 41, 59, .7); color: #64748b; border: 1px solid var(--border); }
|
||
|
||
|
||
/* ── pagination ── */
|
||
|
||
.pagination {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin-top: 14px;
|
||
color: var(--muted-dark);
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.pagination button { font-size: 12px; padding: 5px 12px; }
|
||
|
||
|
||
/* ── overlay / dialog ── */
|
||
|
||
.overlay {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(2, 8, 23, .75);
|
||
backdrop-filter: blur(4px);
|
||
z-index: 100;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.overlay.open { display: flex; }
|
||
|
||
.modal {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-lg);
|
||
padding: 28px;
|
||
width: 680px;
|
||
max-width: 95vw;
|
||
max-height: 90vh;
|
||
overflow-y: auto;
|
||
box-shadow: 0 25px 50px -12px rgba(0,0,0,.5);
|
||
}
|
||
|
||
.modal h2 {
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
margin-bottom: 6px;
|
||
letter-spacing: -.01em;
|
||
}
|
||
|
||
.modal-divider {
|
||
height: 1px;
|
||
background: var(--border);
|
||
margin: 16px -28px;
|
||
}
|
||
|
||
.field { margin-bottom: 14px; display: flex; flex-direction: column; gap: 5px; }
|
||
|
||
.field label {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: .05em;
|
||
}
|
||
|
||
.field input[type="text"],
|
||
.field textarea,
|
||
.field select { width: 100%; min-width: unset; }
|
||
|
||
textarea {
|
||
background: var(--bg-subtle);
|
||
border: 1px solid var(--border);
|
||
color: var(--foreground);
|
||
padding: 8px 10px;
|
||
border-radius: var(--radius);
|
||
font-size: 13px;
|
||
resize: vertical;
|
||
font-family: inherit;
|
||
outline: none;
|
||
min-height: 120px;
|
||
transition: border-color .15s, box-shadow .15s;
|
||
}
|
||
|
||
textarea:focus {
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 3px rgba(59, 130, 246, .15);
|
||
}
|
||
|
||
.modal-footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
margin-top: 20px;
|
||
padding-top: 16px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
|
||
.url-link { color: #60a5fa; text-decoration: none; }
|
||
.url-link:hover { text-decoration: underline; }
|
||
|
||
|
||
/* ── toast ── */
|
||
|
||
#toast {
|
||
position: fixed;
|
||
bottom: 24px;
|
||
right: 24px;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
color: var(--foreground);
|
||
padding: 10px 16px;
|
||
border-radius: var(--radius);
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
display: none;
|
||
z-index: 200;
|
||
box-shadow: 0 8px 24px rgba(0,0,0,.4);
|
||
gap: 8px;
|
||
align-items: center;
|
||
}
|
||
|
||
#toast.show { display: flex; }
|
||
#toast .toast-dot { width: 7px; height: 7px; border-radius: 50%; background: #22c55e; flex-shrink: 0; }
|
||
#toast.error .toast-dot { background: #ef4444; }
|
||
|
||
|
||
/* ── intel stats ── */
|
||
|
||
.intel-stat-card {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-lg);
|
||
padding: 14px 18px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
min-width: 130px;
|
||
}
|
||
|
||
.intel-stat-card .label {
|
||
font-size: 11px;
|
||
color: var(--muted-dark);
|
||
font-weight: 500;
|
||
text-transform: uppercase;
|
||
letter-spacing: .06em;
|
||
}
|
||
|
||
.intel-stat-card .value {
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
color: var(--foreground);
|
||
letter-spacing: -.02em;
|
||
line-height: 1;
|
||
}
|
||
|
||
|
||
/* ── intel detail body ── */
|
||
|
||
.intel-body {
|
||
font-size: 13px;
|
||
line-height: 1.7;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
background: var(--bg-subtle);
|
||
padding: 14px;
|
||
border-radius: var(--radius);
|
||
border: 1px solid var(--border);
|
||
font-family: "SF Mono", "Fira Code", monospace;
|
||
}
|
||
|
||
|
||
/* ── section heading ── */
|
||
|
||
.section-heading {
|
||
font-size: 12px;
|
||
color: var(--muted-dark);
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: .06em;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
/* ── graph view ── */
|
||
|
||
#intel-graph-wrap {
|
||
display: none;
|
||
}
|
||
|
||
#intel-graph-layout {
|
||
display: flex;
|
||
gap: 14px;
|
||
}
|
||
|
||
#intel-graph-svg-wrap {
|
||
flex: 1;
|
||
position: relative;
|
||
background: var(--bg-subtle);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-lg);
|
||
overflow: hidden;
|
||
height: 600px;
|
||
}
|
||
|
||
#intel-graph-svg {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: block;
|
||
cursor: grab;
|
||
}
|
||
#intel-graph-svg:active { cursor: grabbing; }
|
||
|
||
|
||
#graph-controls {
|
||
position: absolute;
|
||
top: 10px;
|
||
left: 10px;
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
z-index: 10;
|
||
}
|
||
|
||
#graph-search {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
color: var(--foreground);
|
||
padding: 5px 9px;
|
||
border-radius: var(--radius);
|
||
font-size: 12px;
|
||
min-width: 170px;
|
||
outline: none;
|
||
font-family: inherit;
|
||
}
|
||
#graph-search:focus { border-color: var(--accent); }
|
||
#graph-search::placeholder { color: var(--muted-dark); }
|
||
|
||
#graph-chips {
|
||
display: flex;
|
||
gap: 4px;
|
||
}
|
||
|
||
.graph-chip {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
color: var(--muted);
|
||
padding: 4px 10px;
|
||
border-radius: var(--radius);
|
||
font-size: 11px;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
transition: color 120ms, background 120ms, border-color 120ms;
|
||
}
|
||
.graph-chip:hover { color: var(--foreground); }
|
||
.graph-chip.active {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
border-color: var(--accent);
|
||
}
|
||
|
||
#graph-legend {
|
||
position: absolute;
|
||
bottom: 10px;
|
||
left: 10px;
|
||
display: flex;
|
||
gap: 14px;
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 6px 10px;
|
||
z-index: 10;
|
||
}
|
||
|
||
.graph-legend-dot {
|
||
display: inline-block;
|
||
width: 12px;
|
||
height: 3px;
|
||
border-radius: 2px;
|
||
margin-right: 5px;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
#graph-info {
|
||
width: 270px;
|
||
flex-shrink: 0;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-lg);
|
||
padding: 16px;
|
||
overflow-y: auto;
|
||
height: 600px;
|
||
}
|
||
|
||
#graph-info-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
#graph-info-sector {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
border-radius: 11px;
|
||
font-size: 11px;
|
||
background: var(--border);
|
||
color: var(--foreground);
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.graph-group-title {
|
||
margin-top: 14px;
|
||
font-size: 10.5px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.6px;
|
||
color: var(--muted);
|
||
padding-bottom: 3px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.graph-conn-row {
|
||
padding: 5px 0;
|
||
font-size: 12px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
}
|
||
|
||
.graph-conn-sector {
|
||
color: var(--muted-dark);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.graph-empty-msg {
|
||
color: var(--muted-dark);
|
||
font-size: 13px;
|
||
margin: 0;
|
||
}
|
||
|
||
.graph-node-label {
|
||
font-size: 11px;
|
||
fill: var(--foreground);
|
||
pointer-events: none;
|
||
user-select: none;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.ig-label-bg {
|
||
fill: var(--bg-subtle);
|
||
fill-opacity: 0.8;
|
||
}
|
||
|
||
</style>
|
||
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<h1>Duriin <span>Admin</span></h1>
|
||
<div class="tabs">
|
||
<button class="tab-btn active" data-tab="articles">Articles</button>
|
||
<button class="tab-btn" data-tab="events">Events</button>
|
||
<button class="tab-btn" data-tab="stats">Stats</button>
|
||
<button class="tab-btn" data-tab="intelligence">Intelligence</button>
|
||
<button class="tab-btn" data-tab="sql">SQL</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="stats-bar" id="statsBar">
|
||
<div class="stat"><span class="label">Total articles</span><span class="value" id="s-total">—</span></div>
|
||
<div class="stat"><span class="label">With content</span><span class="value" id="s-content">—</span></div>
|
||
<div class="stat"><span class="label">With embedding</span><span class="value" id="s-embed">—</span></div>
|
||
<div class="stat"><span class="label">Events</span><span class="value" id="s-events">—</span></div>
|
||
</div>
|
||
|
||
<div class="content">
|
||
|
||
<!-- Articles tab -->
|
||
<div id="tab-articles">
|
||
<div class="filters">
|
||
<label>Keyword <input type="text" id="f-keyword" placeholder="search..." /></label>
|
||
<label>Source <select id="f-source"><option value="">All sources</option></select></label>
|
||
<label>Status
|
||
<select id="f-status">
|
||
<option value="">All</option>
|
||
<option value="ok">ok</option>
|
||
<option value="error">error</option>
|
||
<option value="pending">pending</option>
|
||
<option value="null">no status</option>
|
||
</select>
|
||
</label>
|
||
<label>From <input type="date" id="f-from" /></label>
|
||
<label>To <input type="date" id="f-to" /></label>
|
||
<button class="primary" id="searchBtn" style="align-self:flex-end">Search</button>
|
||
</div>
|
||
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th style="width:44px">ID</th>
|
||
<th>Title</th>
|
||
<th>Source</th>
|
||
<th>Status</th>
|
||
<th>Ingested</th>
|
||
<th style="width:80px"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="articleTable"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="pagination">
|
||
<button id="prevBtn">← Prev</button>
|
||
<span id="pageInfo"></span>
|
||
<button id="nextBtn">Next →</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Events tab -->
|
||
<div id="tab-events" style="display:none">
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th style="width:44px">ID</th>
|
||
<th>Title</th>
|
||
<th style="width:100px">Articles</th>
|
||
<th>Created</th>
|
||
<th style="width:80px"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="eventTable"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="pagination">
|
||
<button id="ePrevBtn">← Prev</button>
|
||
<span id="ePageInfo"></span>
|
||
<button id="eNextBtn">Next →</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Intelligence tab -->
|
||
<div id="tab-intelligence" style="display:none">
|
||
|
||
<div id="intel-unavailable" style="display:none; color:var(--muted); padding: 24px 0">intelligence.sqlite not found — is the intelligence worker running?</div>
|
||
|
||
<div id="intel-content">
|
||
<div style="display:flex; gap:12px; flex-wrap:wrap; margin-bottom:24px" id="intel-stats-row"></div>
|
||
|
||
<div class="filters" style="margin-bottom:18px">
|
||
<label>Company
|
||
<select id="i-company"><option value="">All companies</option></select>
|
||
</label>
|
||
<label>View
|
||
<select id="i-view">
|
||
<option value="knowledge">Knowledge</option>
|
||
<option value="predictions">Predictions</option>
|
||
<option value="graph">Graph</option>
|
||
</select>
|
||
</label>
|
||
<label>Type
|
||
<select id="i-type">
|
||
<option value="">All types</option>
|
||
<option value="relationship">Relationship</option>
|
||
<option value="theme">Theme</option>
|
||
<option value="factor">Factor</option>
|
||
</select>
|
||
</label>
|
||
<label id="i-sort-wrap">Sort
|
||
<select id="i-sort">
|
||
<option value="id">Recent first</option>
|
||
<option value="event_date">By event date</option>
|
||
</select>
|
||
</label>
|
||
<button class="primary" onclick="loadIntelligence()" style="align-self:flex-end">Filter</button>
|
||
</div>
|
||
|
||
<div class="table-wrap" id="intel-table-wrap">
|
||
<table>
|
||
<thead id="intel-thead"></thead>
|
||
<tbody id="intel-tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="pagination" id="intel-pagination">
|
||
<button id="iPrevBtn">← Prev</button>
|
||
<span id="iPageInfo"></span>
|
||
<button id="iNextBtn">Next →</button>
|
||
</div>
|
||
|
||
<!-- graph view -->
|
||
<div id="intel-graph-wrap">
|
||
<div id="intel-graph-layout">
|
||
<div id="intel-graph-svg-wrap">
|
||
<svg id="intel-graph-svg"></svg>
|
||
|
||
<div id="graph-empty" style="display:none; position:absolute; inset:0; color:var(--muted); font-size:13px; text-align:center; padding-top:120px">No relationship data yet</div>
|
||
|
||
<div id="graph-controls">
|
||
<input id="graph-search" placeholder="Search companies..." autocomplete="off" spellcheck="false" />
|
||
<div id="graph-chips">
|
||
<button class="graph-chip active" data-type="all">All</button>
|
||
<button class="graph-chip" data-type="competitor">Competitor</button>
|
||
<button class="graph-chip" data-type="customer">Customer</button>
|
||
<button class="graph-chip" data-type="supplier">Supplier</button>
|
||
<button class="graph-chip" data-type="investor">Investor</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="graph-legend">
|
||
<span><span class="graph-legend-dot" style="background:#E24B4A"></span>Competitor</span>
|
||
<span><span class="graph-legend-dot" style="background:#639922"></span>Customer</span>
|
||
<span><span class="graph-legend-dot" style="background:#BA7517"></span>Supplier</span>
|
||
<span><span class="graph-legend-dot" style="background:#378ADD"></span>Investor</span>
|
||
</div>
|
||
</div>
|
||
|
||
<aside id="graph-info">
|
||
<p class="graph-empty-msg">Click a node to see its connections.</p>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SQL tab -->
|
||
<div id="tab-sql" style="display:none">
|
||
<div style="display:flex; gap:10px; margin-bottom:12px; align-items:center">
|
||
<select id="sql-db" style="min-width:160px">
|
||
<option value="archive">archive.sqlite</option>
|
||
<option value="intelligence">intelligence.sqlite</option>
|
||
</select>
|
||
<button class="primary" id="sql-run-btn">Run</button>
|
||
<span id="sql-elapsed" style="color:var(--muted-dark); font-size:12px"></span>
|
||
</div>
|
||
<textarea id="sql-input" style="width:100%; min-height:120px; font-family:'SF Mono','Fira Code',monospace; font-size:13px; margin-bottom:12px" placeholder="SELECT ..."></textarea>
|
||
<div id="sql-error" style="color:#fca5a5; font-size:13px; margin-bottom:10px; display:none"></div>
|
||
<div id="sql-results" style="overflow-x:auto"></div>
|
||
</div>
|
||
|
||
<!-- Stats tab -->
|
||
<div id="tab-stats" style="display:none">
|
||
|
||
<div style="margin-bottom:28px">
|
||
<div class="section-heading">Pipeline throughput — last 1 hour</div>
|
||
<div style="display:flex; gap:12px; flex-wrap:wrap; margin-top:10px">
|
||
<div class="intel-stat-card" style="min-width:180px">
|
||
<span class="label">Articles ingested</span>
|
||
<span class="value" id="rate-ingested">—</span>
|
||
</div>
|
||
<div class="intel-stat-card" style="min-width:180px">
|
||
<span class="label">Content fetched</span>
|
||
<span class="value" id="rate-content">—</span>
|
||
</div>
|
||
<div class="intel-stat-card" style="min-width:180px">
|
||
<span class="label">Embeddings generated</span>
|
||
<span class="value" id="rate-embeddings">—</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display:flex; gap:32px; flex-wrap:wrap; padding-top:4px">
|
||
<div>
|
||
<div class="section-heading">By source</div>
|
||
<div class="table-wrap" style="width:auto; min-width:220px">
|
||
<table style="width:auto">
|
||
<thead><tr><th>Source</th><th style="text-align:right">Count</th></tr></thead>
|
||
<tbody id="sourceTable"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<div class="section-heading">By content status</div>
|
||
<div class="table-wrap" style="width:auto; min-width:180px">
|
||
<table style="width:auto">
|
||
<thead><tr><th>Status</th><th style="text-align:right">Count</th></tr></thead>
|
||
<tbody id="statusTable"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- Intelligence detail modal -->
|
||
<div class="overlay" id="intelOverlay">
|
||
<div class="modal" style="width:740px">
|
||
<h2 id="intel-modal-title">Detail</h2>
|
||
<div id="intel-modal-meta" style="font-size:12px; color:var(--muted-dark); margin-top:4px; display:flex; gap:12px; flex-wrap:wrap"></div>
|
||
<div class="modal-divider"></div>
|
||
<div id="intel-modal-body" class="intel-body"></div>
|
||
<div class="modal-footer">
|
||
<button id="intelCloseBtn">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Article modal -->
|
||
<div class="overlay" id="articleOverlay">
|
||
<div class="modal">
|
||
<h2 id="modalTitle">Article</h2>
|
||
<div id="modalMeta" style="font-size:12px; color:var(--muted-dark); margin-top:4px; display:flex; gap:14px; flex-wrap:wrap"></div>
|
||
<div class="modal-divider"></div>
|
||
|
||
<div class="field">
|
||
<label>Title</label>
|
||
<input type="text" id="m-title" />
|
||
</div>
|
||
<div class="field">
|
||
<label>Description</label>
|
||
<textarea id="m-desc" style="min-height:70px"></textarea>
|
||
</div>
|
||
<div class="field">
|
||
<label>Content</label>
|
||
<textarea id="m-content" style="min-height:200px"></textarea>
|
||
</div>
|
||
|
||
<div style="display:flex; gap:12px; flex-wrap:wrap">
|
||
<div class="field" style="flex:1; min-width:140px">
|
||
<label>Content status</label>
|
||
<select id="m-status">
|
||
<option value="">— none —</option>
|
||
<option value="ok">ok</option>
|
||
<option value="error">error</option>
|
||
<option value="pending">pending</option>
|
||
</select>
|
||
</div>
|
||
<div class="field" style="flex:1; min-width:140px">
|
||
<label>Language</label>
|
||
<input type="text" id="m-lang" placeholder="en" />
|
||
</div>
|
||
<div class="field" style="flex:1; min-width:140px">
|
||
<label>Pub date</label>
|
||
<input type="text" id="m-pubdate" />
|
||
</div>
|
||
<div class="field" style="flex:1; min-width:140px">
|
||
<label>Is index page</label>
|
||
<select id="m-indexpage">
|
||
<option value="0">No</option>
|
||
<option value="1">Yes</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button class="danger" id="deleteBtn">Delete</button>
|
||
<div style="flex:1"></div>
|
||
<button id="cancelBtn">Cancel</button>
|
||
<button class="primary" id="saveBtn">Save</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Event edit modal -->
|
||
<div class="overlay" id="eventOverlay">
|
||
<div class="modal" style="width:480px">
|
||
<h2>Edit Event</h2>
|
||
<div class="modal-divider"></div>
|
||
<div class="field">
|
||
<label>Title</label>
|
||
<input type="text" id="em-title" />
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="danger" id="eDeleteBtn">Delete event</button>
|
||
<div style="flex:1"></div>
|
||
<button id="eCancelBtn">Cancel</button>
|
||
<button class="primary" id="eSaveBtn">Save</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
|
||
|
||
<script>
|
||
const api = (path, opts) => fetch(path, opts).then(r => {
|
||
if (!r.ok) throw new Error(r.status);
|
||
return r.json();
|
||
});
|
||
|
||
function toast(msg, err) {
|
||
const el = document.getElementById('toast');
|
||
document.getElementById('toast-msg').textContent = msg;
|
||
el.className = 'show' + (err ? ' error' : '');
|
||
clearTimeout(toast._t);
|
||
toast._t = setTimeout(() => el.className = '', 2500);
|
||
}
|
||
|
||
function badgeHtml(status) {
|
||
const s = status || 'null';
|
||
const cls = s === 'ok' ? 'ok' : s === 'error' ? 'err' : s === 'pending' ? 'pending' : 'null';
|
||
return `<span class="badge ${cls}">${s}</span>`;
|
||
}
|
||
|
||
// ── stats ──────────────────────────────────────────────────────────────────
|
||
|
||
async function loadStats() {
|
||
const data = await api('/admin/api/stats');
|
||
document.getElementById('s-total').textContent = data.total.toLocaleString();
|
||
document.getElementById('s-content').textContent = data.withContent.toLocaleString();
|
||
document.getElementById('s-embed').textContent = data.withEmbedding.toLocaleString();
|
||
document.getElementById('s-events').textContent = data.eventCount.toLocaleString();
|
||
|
||
document.getElementById('sourceTable').innerHTML = data.bySource
|
||
.map(r => `<tr><td>${r.source}</td><td style="text-align:right; padding-left:24px">${r.n.toLocaleString()}</td></tr>`).join('');
|
||
|
||
document.getElementById('statusTable').innerHTML = data.byStatus
|
||
.map(r => `<tr><td>${badgeHtml(r.status === 'null' ? null : r.status)}</td><td style="text-align:right; padding-left:24px">${r.n.toLocaleString()}</td></tr>`).join('');
|
||
|
||
document.getElementById('rate-ingested').textContent = (data.ingestedPerHour || 0).toLocaleString();
|
||
document.getElementById('rate-content').textContent = (data.contentPerHour || 0).toLocaleString();
|
||
document.getElementById('rate-embeddings').textContent = (data.embeddingsPerHour || 0).toLocaleString();
|
||
}
|
||
|
||
// ── source dropdown ────────────────────────────────────────────────────────
|
||
|
||
async function loadSources() {
|
||
const sources = await api('/admin/api/sources');
|
||
const sel = document.getElementById('f-source');
|
||
sources.forEach(s => {
|
||
const opt = document.createElement('option');
|
||
opt.value = s;
|
||
opt.textContent = s;
|
||
sel.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
// ── articles ───────────────────────────────────────────────────────────────
|
||
|
||
let articleOffset = 0;
|
||
const PAGE = 50;
|
||
let currentArticle = null;
|
||
|
||
function getFilters() {
|
||
return {
|
||
keyword: document.getElementById('f-keyword').value.trim(),
|
||
source: document.getElementById('f-source').value,
|
||
content_status: document.getElementById('f-status').value,
|
||
from: document.getElementById('f-from').value,
|
||
to: document.getElementById('f-to').value,
|
||
};
|
||
}
|
||
|
||
async function loadArticles() {
|
||
const f = getFilters();
|
||
const params = new URLSearchParams({ limit: PAGE, offset: articleOffset });
|
||
if (f.keyword) params.set('keyword', f.keyword);
|
||
if (f.source) params.set('source', f.source);
|
||
if (f.content_status) params.set('content_status', f.content_status);
|
||
if (f.from) params.set('from', f.from + 'T00:00:00');
|
||
if (f.to) params.set('to', f.to + 'T23:59:59');
|
||
|
||
const data = await api(`/admin/api/articles?${params}`);
|
||
const tbody = document.getElementById('articleTable');
|
||
|
||
tbody.innerHTML = data.rows.map(r => `
|
||
<tr>
|
||
<td style="color:var(--muted-dark); font-size:12px">${r.id}</td>
|
||
<td>
|
||
<span class="truncate" title="${r.title}">${r.title}</span>
|
||
<a class="url-link" href="${r.url}" target="_blank" style="font-size:11px; display:block; margin-top:3px; color:var(--muted-dark)">↗ ${new URL(r.url).hostname}</a>
|
||
</td>
|
||
<td style="color:var(--muted)">${r.source}</td>
|
||
<td>${badgeHtml(r.content_status)}</td>
|
||
<td style="color:var(--muted-dark); white-space:nowrap; font-size:12px">${r.ingested_at ? r.ingested_at.slice(0, 16) : '—'}</td>
|
||
<td><button onclick="openArticle(${r.id})">Edit</button></td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
const total = data.total;
|
||
document.getElementById('pageInfo').textContent =
|
||
`${articleOffset + 1}–${Math.min(articleOffset + PAGE, total)} of ${total.toLocaleString()}`;
|
||
|
||
document.getElementById('prevBtn').disabled = articleOffset === 0;
|
||
document.getElementById('nextBtn').disabled = articleOffset + PAGE >= total;
|
||
}
|
||
|
||
document.getElementById('searchBtn').onclick = () => { articleOffset = 0; loadArticles(); };
|
||
document.getElementById('prevBtn').onclick = () => { articleOffset = Math.max(0, articleOffset - PAGE); loadArticles(); };
|
||
document.getElementById('nextBtn').onclick = () => { articleOffset += PAGE; loadArticles(); };
|
||
|
||
document.querySelector('.filters').addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') { articleOffset = 0; loadArticles(); }
|
||
});
|
||
|
||
// ── article modal ──────────────────────────────────────────────────────────
|
||
|
||
async function openArticle(id) {
|
||
currentArticle = await api(`/admin/api/articles/${id}`);
|
||
const a = currentArticle;
|
||
|
||
document.getElementById('modalTitle').textContent = `Article #${a.id}`;
|
||
|
||
document.getElementById('modalMeta').innerHTML = [
|
||
a.source && `<span>source: <b>${a.source}</b></span>`,
|
||
a.pub_date && `<span>pub: ${a.pub_date.slice(0, 16)}</span>`,
|
||
`<span>has_embedding: ${a.has_embedding ? 'yes' : 'no'}</span>`,
|
||
a.content_error && `<span style="color:#fca5a5">error: ${a.content_error.slice(0, 80)}</span>`,
|
||
].filter(Boolean).join('');
|
||
|
||
document.getElementById('m-title').value = a.title || '';
|
||
document.getElementById('m-desc').value = a.description || '';
|
||
document.getElementById('m-content').value = a.content || '';
|
||
document.getElementById('m-status').value = a.content_status || '';
|
||
document.getElementById('m-lang').value = a.language || '';
|
||
document.getElementById('m-pubdate').value = a.pub_date || '';
|
||
document.getElementById('m-indexpage').value = String(a.is_index_page || 0);
|
||
|
||
document.getElementById('articleOverlay').classList.add('open');
|
||
}
|
||
|
||
document.getElementById('cancelBtn').onclick = () =>
|
||
document.getElementById('articleOverlay').classList.remove('open');
|
||
|
||
document.getElementById('saveBtn').onclick = async () => {
|
||
if (!currentArticle) return;
|
||
try {
|
||
await api(`/admin/api/articles/${currentArticle.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
title: document.getElementById('m-title').value,
|
||
description: document.getElementById('m-desc').value,
|
||
content: document.getElementById('m-content').value,
|
||
content_status: document.getElementById('m-status').value || null,
|
||
language: document.getElementById('m-lang').value || null,
|
||
pub_date: document.getElementById('m-pubdate').value || null,
|
||
is_index_page: parseInt(document.getElementById('m-indexpage').value, 10),
|
||
}),
|
||
});
|
||
document.getElementById('articleOverlay').classList.remove('open');
|
||
toast('Saved');
|
||
loadArticles();
|
||
} catch (e) {
|
||
toast('Save failed', true);
|
||
}
|
||
};
|
||
|
||
document.getElementById('deleteBtn').onclick = async () => {
|
||
if (!currentArticle) return;
|
||
if (!confirm(`Delete article #${currentArticle.id}? This cannot be undone.`)) return;
|
||
try {
|
||
await api(`/admin/api/articles/${currentArticle.id}`, { method: 'DELETE' });
|
||
document.getElementById('articleOverlay').classList.remove('open');
|
||
toast('Deleted');
|
||
loadArticles();
|
||
loadStats();
|
||
} catch (e) {
|
||
toast('Delete failed', true);
|
||
}
|
||
};
|
||
|
||
// ── events ─────────────────────────────────────────────────────────────────
|
||
|
||
let eventOffset = 0;
|
||
let currentEvent = null;
|
||
|
||
async function loadEvents() {
|
||
const data = await api(`/admin/api/events?limit=${PAGE}&offset=${eventOffset}`);
|
||
const tbody = document.getElementById('eventTable');
|
||
|
||
tbody.innerHTML = data.rows.map(r => `
|
||
<tr>
|
||
<td style="color:var(--muted-dark); font-size:12px">${r.id}</td>
|
||
<td><span class="truncate" title="${r.title}">${r.title}</span></td>
|
||
<td>${r.article_count}</td>
|
||
<td style="color:var(--muted-dark); white-space:nowrap; font-size:12px">${r.created_at ? r.created_at.slice(0, 16) : '—'}</td>
|
||
<td><button onclick="openEvent(${r.id}, ${JSON.stringify(r.title).replace(/"/g, '"')})">Edit</button></td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
const total = data.total;
|
||
document.getElementById('ePageInfo').textContent =
|
||
`${eventOffset + 1}–${Math.min(eventOffset + PAGE, total)} of ${total.toLocaleString()}`;
|
||
document.getElementById('ePrevBtn').disabled = eventOffset === 0;
|
||
document.getElementById('eNextBtn').disabled = eventOffset + PAGE >= total;
|
||
}
|
||
|
||
document.getElementById('ePrevBtn').onclick = () => { eventOffset = Math.max(0, eventOffset - PAGE); loadEvents(); };
|
||
document.getElementById('eNextBtn').onclick = () => { eventOffset += PAGE; loadEvents(); };
|
||
|
||
function openEvent(id, title) {
|
||
currentEvent = { id, title };
|
||
document.getElementById('em-title').value = title;
|
||
document.getElementById('eventOverlay').classList.add('open');
|
||
}
|
||
|
||
document.getElementById('eCancelBtn').onclick = () =>
|
||
document.getElementById('eventOverlay').classList.remove('open');
|
||
|
||
document.getElementById('eSaveBtn').onclick = async () => {
|
||
if (!currentEvent) return;
|
||
try {
|
||
await api(`/admin/api/events/${currentEvent.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ title: document.getElementById('em-title').value }),
|
||
});
|
||
document.getElementById('eventOverlay').classList.remove('open');
|
||
toast('Saved');
|
||
loadEvents();
|
||
} catch (e) { toast('Save failed', true); }
|
||
};
|
||
|
||
document.getElementById('eDeleteBtn').onclick = async () => {
|
||
if (!currentEvent) return;
|
||
if (!confirm(`Delete event #${currentEvent.id}? Articles will be detached but not deleted.`)) return;
|
||
try {
|
||
await api(`/admin/api/events/${currentEvent.id}`, { method: 'DELETE' });
|
||
document.getElementById('eventOverlay').classList.remove('open');
|
||
toast('Event deleted');
|
||
loadEvents();
|
||
loadStats();
|
||
} catch (e) { toast('Delete failed', true); }
|
||
};
|
||
|
||
// ── intelligence ───────────────────────────────────────────────────────────
|
||
|
||
let intelOffset = 0;
|
||
|
||
async function loadIntelligenceStats() {
|
||
const data = await api('/admin/api/intelligence/stats');
|
||
if (!data.available) {
|
||
document.getElementById('intel-unavailable').style.display = '';
|
||
document.getElementById('intel-content').style.display = 'none';
|
||
return false;
|
||
}
|
||
|
||
document.getElementById('intel-unavailable').style.display = 'none';
|
||
document.getElementById('intel-content').style.display = '';
|
||
|
||
const queueMap = {};
|
||
(data.queue || []).forEach(r => queueMap[r.status] = r.n);
|
||
|
||
document.getElementById('intel-stats-row').innerHTML = [
|
||
['Queue pending', (queueMap.pending || 0).toLocaleString()],
|
||
['Processed', (queueMap.processed || 0).toLocaleString()],
|
||
['Skipped', (queueMap.skipped || 0).toLocaleString()],
|
||
['Knowledge rows', data.knowledge.toLocaleString()],
|
||
['Predictions', data.predictions.toLocaleString()],
|
||
['Companies', `${data.embeddings}/${data.companies} embedded`],
|
||
].map(([label, value]) => `
|
||
<div class="intel-stat-card">
|
||
<span class="label">${label}</span>
|
||
<span class="value">${value}</span>
|
||
</div>
|
||
`).join('');
|
||
|
||
|
||
return true;
|
||
}
|
||
|
||
async function loadIntelligenceCompanies() {
|
||
const companies = await api('/admin/api/intelligence/companies');
|
||
const sel = document.getElementById('i-company');
|
||
sel.innerHTML = '<option value="">All companies</option>';
|
||
companies.forEach(c => {
|
||
const opt = document.createElement('option');
|
||
opt.value = c.id;
|
||
opt.textContent = `${c.name} (${c.ticker})`;
|
||
sel.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
async function loadIntelligence() {
|
||
const view = document.getElementById('i-view').value;
|
||
|
||
if (view === 'graph') {
|
||
await loadIntelGraph();
|
||
return;
|
||
}
|
||
|
||
showGraphView(false);
|
||
|
||
const companyId = document.getElementById('i-company').value;
|
||
const type = document.getElementById('i-type').value;
|
||
|
||
const params = new URLSearchParams({ limit: PAGE, offset: intelOffset });
|
||
if (companyId) params.set('company_id', companyId);
|
||
|
||
if (view === 'knowledge') {
|
||
document.getElementById('i-type').parentElement.style.display = '';
|
||
document.getElementById('i-sort-wrap').style.display = '';
|
||
|
||
const sort = document.getElementById('i-sort').value;
|
||
if (sort) params.set('sort', sort);
|
||
if (type) params.set('type', type);
|
||
|
||
const data = await api(`/admin/api/intelligence/knowledge?${params}`);
|
||
intelRows = data.rows;
|
||
|
||
document.getElementById('intel-thead').innerHTML = `
|
||
<tr><th>ID</th><th>Company</th><th>Event</th><th>Type</th><th>Data</th><th>Event date</th></tr>`;
|
||
|
||
document.getElementById('intel-tbody').innerHTML = data.rows.map(r => {
|
||
let parsed = {};
|
||
try { parsed = JSON.parse(r.data); } catch (_) {}
|
||
const summary = Object.values(parsed).filter(v => typeof v === 'string').join(' · ').slice(0, 120);
|
||
return `<tr style="cursor:pointer" onclick="openIntelDetail(${r.id}, 'knowledge')">
|
||
<td style="color:var(--muted-dark); font-size:12px">${r.id}</td>
|
||
<td style="white-space:nowrap">${r.company_name}</td>
|
||
<td style="color:var(--muted-dark); font-size:12px">${r.event_id}</td>
|
||
<td><span class="badge null">${r.type}</span></td>
|
||
<td><span class="truncate" style="max-width:360px">${summary}</span></td>
|
||
<td style="color:var(--muted-dark); white-space:nowrap; font-size:12px">${r.event_date ? r.event_date.slice(0,10) : '—'}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
const total = data.total;
|
||
document.getElementById('iPageInfo').textContent =
|
||
`${intelOffset + 1}–${Math.min(intelOffset + PAGE, total)} of ${total.toLocaleString()}`;
|
||
document.getElementById('iPrevBtn').disabled = intelOffset === 0;
|
||
document.getElementById('iNextBtn').disabled = intelOffset + PAGE >= total;
|
||
|
||
} else {
|
||
document.getElementById('i-type').parentElement.style.display = 'none';
|
||
document.getElementById('i-sort-wrap').style.display = '';
|
||
|
||
const sort = document.getElementById('i-sort').value;
|
||
if (sort) params.set('sort', sort);
|
||
|
||
const data = await api(`/admin/api/intelligence/predictions?${params}`);
|
||
intelRows = data.rows;
|
||
|
||
document.getElementById('intel-thead').innerHTML = `
|
||
<tr><th>ID</th><th>Company</th><th>Event</th><th>Type</th><th>Direction</th><th>Magnitude</th><th>Timeframe</th><th>Rationale</th><th>Event date</th></tr>`;
|
||
|
||
document.getElementById('intel-tbody').innerHTML = data.rows.map(r => `
|
||
<tr style="cursor:pointer" onclick="openIntelDetail(${r.id}, 'predictions')">
|
||
<td style="color:var(--muted-dark); font-size:12px">${r.id}</td>
|
||
<td style="white-space:nowrap">${r.company_name}</td>
|
||
<td style="color:var(--muted-dark); font-size:12px">${r.event_id}</td>
|
||
<td><span class="badge null">${r.type}</span></td>
|
||
<td>${r.direction || '—'}</td>
|
||
<td>${r.magnitude || '—'}</td>
|
||
<td>${r.timeframe || '—'}</td>
|
||
<td><span class="truncate" style="max-width:300px">${r.rationale || '—'}</span></td>
|
||
<td style="color:var(--muted-dark); white-space:nowrap; font-size:12px">${r.event_date ? r.event_date.slice(0,10) : '—'}</td>
|
||
</tr>
|
||
`).join('');
|
||
|
||
const total = data.total;
|
||
document.getElementById('iPageInfo').textContent =
|
||
`${intelOffset + 1}–${Math.min(intelOffset + PAGE, total)} of ${total.toLocaleString()}`;
|
||
document.getElementById('iPrevBtn').disabled = intelOffset === 0;
|
||
document.getElementById('iNextBtn').disabled = intelOffset + PAGE >= total;
|
||
}
|
||
}
|
||
|
||
document.getElementById('iPrevBtn').onclick = () => { intelOffset = Math.max(0, intelOffset - PAGE); loadIntelligence(); };
|
||
document.getElementById('iNextBtn').onclick = () => { intelOffset += PAGE; loadIntelligence(); };
|
||
|
||
document.getElementById('i-view').onchange = () => { intelOffset = 0; loadIntelligence(); };
|
||
|
||
|
||
// ── intelligence graph ─────────────────────────────────────────────────────
|
||
|
||
const SECTOR_COLOR = {
|
||
AI: "#378ADD",
|
||
Tech: "#534AB7",
|
||
Semiconductor: "#BA7517",
|
||
Storage: "#888780",
|
||
Media: "#D4537E",
|
||
Auto: "#1D9E75",
|
||
Finance: "#639922",
|
||
Defense: "#6B7280",
|
||
Space: "#534AB7",
|
||
Telecom: "#1D9E75",
|
||
Cloud: "#D85A30",
|
||
};
|
||
|
||
const EDGE_COLOR = {
|
||
competitor: "#E24B4A",
|
||
customer: "#639922",
|
||
supplier: "#BA7517",
|
||
investor: "#378ADD",
|
||
};
|
||
|
||
|
||
// sector lookup for tickers we track — new tickers fall through to inferSector
|
||
const SECTOR_BY_TICKER = {
|
||
NVDA: "Semiconductor", AMD: "Semiconductor", INTC: "Semiconductor",
|
||
TSM: "Semiconductor", "2330.TW": "Semiconductor", ASML: "Semiconductor",
|
||
QCOM: "Semiconductor", AVGO: "Semiconductor", MRVL: "Semiconductor",
|
||
"005930.KS": "Semiconductor", "000660.KS": "Semiconductor",
|
||
KLAC: "Semiconductor", AMAT: "Semiconductor", LRCX: "Semiconductor",
|
||
TXN: "Semiconductor", ADI: "Semiconductor", ON: "Semiconductor",
|
||
|
||
AAPL: "Tech", GOOGL: "Tech", GOOG: "Tech", META: "Tech",
|
||
MSFT: "Tech", AMZN: "Tech", NFLX: "Tech",
|
||
|
||
OPENAI: "AI", ANTHROPIC: "AI", XAI: "AI",
|
||
|
||
MU: "Storage", WDC: "Storage", STX: "Storage",
|
||
|
||
DIS: "Media", CMCSA: "Media", WBD: "Media", SPOT: "Media", PARA: "Media",
|
||
|
||
TSLA: "Auto", F: "Auto", GM: "Auto", TM: "Auto", HMC: "Auto",
|
||
STLA: "Auto", RIVN: "Auto", LCID: "Auto",
|
||
|
||
JPM: "Finance", GS: "Finance", MS: "Finance", BAC: "Finance",
|
||
C: "Finance", BLK: "Finance", "BRK.B": "Finance", V: "Finance",
|
||
MA: "Finance", AXP: "Finance",
|
||
|
||
LMT: "Defense", RTX: "Defense", NOC: "Defense", GD: "Defense", HII: "Defense",
|
||
|
||
SPCX: "Space", RKLB: "Space",
|
||
|
||
VZ: "Telecom", T: "Telecom", TMUS: "Telecom",
|
||
|
||
ORCL: "Cloud", CRM: "Cloud", NOW: "Cloud", SNOW: "Cloud",
|
||
};
|
||
|
||
function inferSector(ticker, name) {
|
||
if (ticker && SECTOR_BY_TICKER[ticker.toUpperCase()]) {
|
||
return SECTOR_BY_TICKER[ticker.toUpperCase()];
|
||
}
|
||
if (name) {
|
||
const upper = name.toUpperCase();
|
||
if (SECTOR_BY_TICKER[upper]) return SECTOR_BY_TICKER[upper];
|
||
const low = name.toLowerCase();
|
||
if (/bank|capital|asset|fund|invest/.test(low)) return "Finance";
|
||
if (/semi|chip/.test(low)) return "Semiconductor";
|
||
if (/media|studio|entertain/.test(low)) return "Media";
|
||
if (/telecom|wireless|mobile/.test(low)) return "Telecom";
|
||
if (/cloud/.test(low)) return "Cloud";
|
||
if (/motor|automot/.test(low)) return "Auto";
|
||
}
|
||
return "Tech"; // sensible default
|
||
}
|
||
|
||
|
||
function showGraphView(visible) {
|
||
document.getElementById('intel-graph-wrap').style.display = visible ? 'block' : 'none';
|
||
document.getElementById('intel-table-wrap').style.display = visible ? 'none' : '';
|
||
document.getElementById('intel-pagination').style.display = visible ? 'none' : '';
|
||
}
|
||
|
||
|
||
let graphNodes = [];
|
||
let graphEdges = [];
|
||
let graphDegree = new Map();
|
||
let graphFilterType = 'all';
|
||
let graphSearchTerm = '';
|
||
let graphSelectedId = null;
|
||
|
||
|
||
async function loadIntelGraph() {
|
||
showGraphView(true);
|
||
|
||
document.getElementById('i-type').parentElement.style.display = 'none';
|
||
document.getElementById('i-sort-wrap').style.display = 'none';
|
||
|
||
const data = await api('/admin/api/intelligence/graph');
|
||
|
||
if (!data.nodes || data.nodes.length === 0) {
|
||
document.getElementById('graph-empty').style.display = 'block';
|
||
graphNodes = []; graphEdges = [];
|
||
const svgEl = document.getElementById('intel-graph-svg');
|
||
svgEl.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
document.getElementById('graph-empty').style.display = 'none';
|
||
|
||
// map company_id → node id (we use ticker; fall back to synthetic id)
|
||
const idByCompanyId = new Map();
|
||
graphNodes = [];
|
||
|
||
for (const n of data.nodes) {
|
||
if (!n.tracked) continue;
|
||
const nodeId = n.ticker || `C${n.id}`;
|
||
idByCompanyId.set(n.id, nodeId);
|
||
graphNodes.push({
|
||
id: nodeId,
|
||
label: n.name,
|
||
sector: inferSector(n.ticker, n.name),
|
||
});
|
||
}
|
||
|
||
// normalize edges: only tracked↔tracked, strict spec types, dedupe
|
||
const seen = new Set();
|
||
graphEdges = [];
|
||
|
||
for (const e of data.edges) {
|
||
let type = (e.relationship_type || '').toLowerCase();
|
||
let src = idByCompanyId.get(e.from_company_id);
|
||
let tgt = e.to_company_id ? idByCompanyId.get(e.to_company_id) : null;
|
||
if (!src || !tgt) continue;
|
||
|
||
// dependency is the reciprocal of investor — flip direction and normalize
|
||
if (type === 'dependency') {
|
||
type = 'investor';
|
||
[src, tgt] = [tgt, src];
|
||
}
|
||
|
||
if (!EDGE_COLOR[type]) continue; // drops partner and other non-spec types
|
||
|
||
const key = `${src}|${tgt}|${type}`;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
|
||
graphEdges.push({ source: src, target: tgt, type });
|
||
}
|
||
|
||
// degree counts (used for node radius)
|
||
graphDegree = new Map();
|
||
for (const n of graphNodes) graphDegree.set(n.id, 0);
|
||
for (const e of graphEdges) {
|
||
graphDegree.set(e.source, (graphDegree.get(e.source) || 0) + 1);
|
||
graphDegree.set(e.target, (graphDegree.get(e.target) || 0) + 1);
|
||
}
|
||
|
||
graphSelectedId = null;
|
||
clearGraphInfo();
|
||
|
||
renderIntelGraph();
|
||
}
|
||
|
||
|
||
function graphNodeRadius(d) {
|
||
return 5 + Math.min((graphDegree.get(d.id) || 0) * 0.8, 12);
|
||
}
|
||
|
||
|
||
function renderIntelGraph() {
|
||
const svgEl = document.getElementById('intel-graph-svg');
|
||
svgEl.innerHTML = '';
|
||
if (graphNodes.length === 0) return;
|
||
|
||
const width = svgEl.clientWidth || 900;
|
||
const height = svgEl.clientHeight || 600;
|
||
|
||
const svg = d3.select(svgEl).attr('viewBox', [0, 0, width, height]);
|
||
const root = svg.append('g');
|
||
|
||
const zoom = d3.zoom()
|
||
.scaleExtent([0.25, 4])
|
||
.on('zoom', ev => root.attr('transform', ev.transform));
|
||
svg.call(zoom);
|
||
|
||
svg.on('click', ev => {
|
||
if (ev.target === svg.node()) clearGraphSelection();
|
||
});
|
||
|
||
const linkLayer = root.append('g').attr('class', 'ig-links');
|
||
const nodeLayer = root.append('g').attr('class', 'ig-nodes');
|
||
|
||
// deep-copy so d3 doesnt mutate our stored arrays
|
||
const nodesCopy = graphNodes.map(n => ({ ...n }));
|
||
const edgesCopy = graphEdges.map(e => ({ ...e }));
|
||
|
||
|
||
const linkSel = linkLayer.selectAll('line')
|
||
.data(edgesCopy)
|
||
.join('line')
|
||
.attr('stroke', d => EDGE_COLOR[d.type] || '#888')
|
||
.attr('stroke-width', 1.5)
|
||
.attr('stroke-opacity', 0.55);
|
||
|
||
const nodeSel = nodeLayer.selectAll('g.ig-node')
|
||
.data(nodesCopy, d => d.id)
|
||
.join('g')
|
||
.attr('class', 'ig-node')
|
||
.style('cursor', 'pointer')
|
||
.call(d3.drag()
|
||
.on('start', dragStart)
|
||
.on('drag', dragMove)
|
||
.on('end', dragEnd));
|
||
|
||
nodeSel.append('circle')
|
||
.attr('r', graphNodeRadius)
|
||
.attr('fill', d => SECTOR_COLOR[d.sector] || '#888')
|
||
.attr('stroke', 'transparent')
|
||
.attr('stroke-width', 2)
|
||
.on('mouseenter', function (ev, d) {
|
||
if (d.id !== graphSelectedId) {
|
||
d3.select(this).attr('stroke', 'rgba(255,255,255,0.6)');
|
||
}
|
||
})
|
||
.on('mouseleave', function (ev, d) {
|
||
if (d.id !== graphSelectedId) {
|
||
d3.select(this).attr('stroke', 'transparent');
|
||
}
|
||
})
|
||
.on('click', (ev, d) => {
|
||
ev.stopPropagation();
|
||
selectGraphNode(d);
|
||
});
|
||
|
||
// label text first (to measure), then bg rect inserted before it
|
||
nodeSel.append('text')
|
||
.attr('class', 'graph-node-label')
|
||
.attr('text-anchor', 'middle')
|
||
.attr('y', d => graphNodeRadius(d) + 13)
|
||
.text(d => d.label);
|
||
|
||
nodeSel.insert('rect', 'text.graph-node-label')
|
||
.attr('class', 'ig-label-bg')
|
||
.attr('rx', 2).attr('ry', 2);
|
||
|
||
nodeSel.each(function () {
|
||
const g = d3.select(this);
|
||
const t = g.select('text.graph-node-label').node();
|
||
if (!t) return;
|
||
const bb = t.getBBox();
|
||
g.select('rect.ig-label-bg')
|
||
.attr('x', bb.x - 3)
|
||
.attr('y', bb.y - 1)
|
||
.attr('width', bb.width + 6)
|
||
.attr('height', bb.height + 2);
|
||
});
|
||
|
||
|
||
const sim = d3.forceSimulation(nodesCopy)
|
||
.force('link', d3.forceLink(edgesCopy).id(d => d.id).distance(80))
|
||
.force('charge', d3.forceManyBody().strength(-300))
|
||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||
.force('x', d3.forceX(width / 2).strength(0.07))
|
||
.force('y', d3.forceY(height / 2).strength(0.07))
|
||
.force('collide', d3.forceCollide(d => graphNodeRadius(d) + 4));
|
||
|
||
sim.on('tick', () => {
|
||
linkSel
|
||
.attr('x1', d => d.source.x)
|
||
.attr('y1', d => d.source.y)
|
||
.attr('x2', d => d.target.x)
|
||
.attr('y2', d => d.target.y);
|
||
|
||
nodeSel.attr('transform', d => `translate(${d.x},${d.y})`);
|
||
});
|
||
|
||
|
||
// stash refs so filter/search handlers can update visibility without re-rendering
|
||
window._igSim = sim;
|
||
window._igLinkSel = linkSel;
|
||
window._igNodeSel = nodeSel;
|
||
|
||
applyGraphFilters();
|
||
|
||
|
||
function dragStart(ev, d) {
|
||
if (!ev.active) sim.alphaTarget(0.3).restart();
|
||
d.fx = d.x; d.fy = d.y;
|
||
}
|
||
function dragMove(ev, d) {
|
||
d.fx = ev.x; d.fy = ev.y;
|
||
}
|
||
function dragEnd(ev, d) {
|
||
if (!ev.active) sim.alphaTarget(0);
|
||
d.fx = null; d.fy = null;
|
||
}
|
||
}
|
||
|
||
|
||
function applyGraphFilters() {
|
||
const linkSel = window._igLinkSel;
|
||
const nodeSel = window._igNodeSel;
|
||
if (!linkSel || !nodeSel) return;
|
||
|
||
const term = graphSearchTerm;
|
||
const filter = graphFilterType;
|
||
|
||
// search: keep matching nodes + their neighbors so subgraph stays readable
|
||
let visibleIds;
|
||
if (term) {
|
||
const direct = graphNodes.filter(n =>
|
||
n.label.toLowerCase().includes(term) ||
|
||
n.id.toLowerCase().includes(term)
|
||
).map(n => n.id);
|
||
visibleIds = new Set(direct);
|
||
|
||
for (const e of graphEdges) {
|
||
if (visibleIds.has(e.source)) visibleIds.add(e.target);
|
||
if (visibleIds.has(e.target)) visibleIds.add(e.source);
|
||
}
|
||
} else {
|
||
visibleIds = new Set(graphNodes.map(n => n.id));
|
||
}
|
||
|
||
const lit = graphSelectedId ? neighborsOfNode(graphSelectedId) : null;
|
||
|
||
nodeSel
|
||
.style('display', d => visibleIds.has(d.id) ? null : 'none')
|
||
.style('opacity', d => (lit && !lit.has(d.id)) ? 0.15 : 1);
|
||
|
||
nodeSel.select('circle')
|
||
.attr('stroke', d => d.id === graphSelectedId ? '#ffffff' : 'transparent')
|
||
.attr('stroke-width', d => d.id === graphSelectedId ? 2.5 : 2);
|
||
|
||
linkSel
|
||
.style('display', e => {
|
||
if (filter !== 'all' && e.type !== filter) return 'none';
|
||
const s = typeof e.source === 'object' ? e.source.id : e.source;
|
||
const t = typeof e.target === 'object' ? e.target.id : e.target;
|
||
if (!visibleIds.has(s) || !visibleIds.has(t)) return 'none';
|
||
return null;
|
||
})
|
||
.attr('stroke-opacity', e => {
|
||
if (!lit) return 0.55;
|
||
const s = typeof e.source === 'object' ? e.source.id : e.source;
|
||
const t = typeof e.target === 'object' ? e.target.id : e.target;
|
||
return (s !== graphSelectedId && t !== graphSelectedId) ? 0.08 : 0.55;
|
||
});
|
||
}
|
||
|
||
|
||
function neighborsOfNode(id) {
|
||
const set = new Set([id]);
|
||
for (const e of graphEdges) {
|
||
const s = typeof e.source === 'object' ? e.source.id : e.source;
|
||
const t = typeof e.target === 'object' ? e.target.id : e.target;
|
||
if (s === id) set.add(t);
|
||
if (t === id) set.add(s);
|
||
}
|
||
return set;
|
||
}
|
||
|
||
|
||
function selectGraphNode(d) {
|
||
graphSelectedId = d.id;
|
||
applyGraphFilters();
|
||
renderGraphInfo(d);
|
||
}
|
||
|
||
function clearGraphSelection() {
|
||
graphSelectedId = null;
|
||
applyGraphFilters();
|
||
clearGraphInfo();
|
||
}
|
||
|
||
function clearGraphInfo() {
|
||
document.getElementById('graph-info').innerHTML =
|
||
'<p class="graph-empty-msg">Click a node to see its connections.</p>';
|
||
}
|
||
|
||
|
||
function renderGraphInfo(node) {
|
||
const groups = { competitor: [], customer: [], supplier: [], investor: [] };
|
||
const seenPer = { competitor: new Set(), customer: new Set(), supplier: new Set(), investor: new Set() };
|
||
|
||
for (const e of graphEdges) {
|
||
let otherId = null;
|
||
if (e.source === node.id) otherId = e.target;
|
||
else if (e.target === node.id) otherId = e.source;
|
||
if (!otherId) continue;
|
||
if (seenPer[e.type].has(otherId)) continue;
|
||
seenPer[e.type].add(otherId);
|
||
const other = graphNodes.find(n => n.id === otherId);
|
||
if (other) groups[e.type].push(other);
|
||
}
|
||
|
||
let html = `<div id="graph-info-title">${escapeHtmlG(node.label)}</div>`;
|
||
html += `<span id="graph-info-sector">${escapeHtmlG(node.sector)}</span>`;
|
||
|
||
let any = false;
|
||
for (const type of ['competitor', 'customer', 'supplier', 'investor']) {
|
||
const list = groups[type];
|
||
if (!list.length) continue;
|
||
any = true;
|
||
html += `<div class="graph-group-title">${type} (${list.length})</div>`;
|
||
for (const o of list) {
|
||
html += `<div class="graph-conn-row"><span>${escapeHtmlG(o.label)}</span><span class="graph-conn-sector">${escapeHtmlG(o.sector)}</span></div>`;
|
||
}
|
||
}
|
||
|
||
if (!any) {
|
||
html += `<p class="graph-empty-msg" style="margin-top:12px">No relationships recorded.</p>`;
|
||
}
|
||
|
||
document.getElementById('graph-info').innerHTML = html;
|
||
}
|
||
|
||
|
||
function escapeHtmlG(s) {
|
||
return String(s).replace(/[&<>"']/g, c => ({
|
||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||
}[c]));
|
||
}
|
||
|
||
|
||
// wire controls (once)
|
||
|
||
document.getElementById('graph-search').addEventListener('input', ev => {
|
||
graphSearchTerm = ev.target.value.trim().toLowerCase();
|
||
applyGraphFilters();
|
||
});
|
||
|
||
document.getElementById('graph-chips').addEventListener('click', ev => {
|
||
const btn = ev.target.closest('.graph-chip');
|
||
if (!btn) return;
|
||
document.querySelectorAll('.graph-chip').forEach(c => c.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
graphFilterType = btn.dataset.type;
|
||
applyGraphFilters();
|
||
});
|
||
|
||
let intelRows = [];
|
||
|
||
function openIntelDetail(id, view) {
|
||
const row = intelRows.find(r => r.id === id);
|
||
if (!row) return;
|
||
|
||
document.getElementById('intel-modal-title').textContent =
|
||
`${row.company_name} — Event ${row.event_id}`;
|
||
|
||
const meta = [`type: ${row.type || view}`, `created: ${row.created_at ? row.created_at.slice(0,16) : '—'}`];
|
||
document.getElementById('intel-modal-meta').innerHTML = meta.map(m => `<span>${m}</span>`).join('');
|
||
|
||
let body = '';
|
||
if (view === 'knowledge') {
|
||
try {
|
||
const parsed = JSON.parse(row.data);
|
||
body = Object.entries(parsed).map(([k, v]) => `${k}: ${v}`).join('\n');
|
||
} catch (_) { body = row.data; }
|
||
} else {
|
||
body = [
|
||
row.rationale,
|
||
'',
|
||
`direction: ${row.direction || '—'}`,
|
||
`magnitude: ${row.magnitude || '—'}`,
|
||
`timeframe: ${row.timeframe || '—'}`,
|
||
].join('\n');
|
||
}
|
||
|
||
document.getElementById('intel-modal-body').textContent = body;
|
||
document.getElementById('intelOverlay').classList.add('open');
|
||
}
|
||
|
||
document.getElementById('intelCloseBtn').onclick = () =>
|
||
document.getElementById('intelOverlay').classList.remove('open');
|
||
|
||
document.getElementById('intelOverlay').onclick = function(e) {
|
||
if (e.target === this) this.classList.remove('open');
|
||
};
|
||
|
||
// ── sql console ────────────────────────────────────────────────────────────
|
||
|
||
async function runSql() {
|
||
const sql = document.getElementById('sql-input').value.trim();
|
||
if (!sql) return;
|
||
|
||
const database = document.getElementById('sql-db').value;
|
||
const errEl = document.getElementById('sql-error');
|
||
const resultsEl = document.getElementById('sql-results');
|
||
const elapsedEl = document.getElementById('sql-elapsed');
|
||
|
||
errEl.style.display = 'none';
|
||
resultsEl.innerHTML = '';
|
||
elapsedEl.textContent = '';
|
||
|
||
try {
|
||
const data = await fetch('/admin/api/sql', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ sql, database }),
|
||
}).then(r => r.json());
|
||
|
||
if (data.error) {
|
||
errEl.textContent = data.error;
|
||
errEl.style.display = '';
|
||
return;
|
||
}
|
||
|
||
elapsedEl.textContent = `${data.elapsed}ms`;
|
||
|
||
const blocks = [];
|
||
for (const r of (data.results || [])) {
|
||
if (r.error) {
|
||
blocks.push(`<div style="color:#fca5a5; font-size:13px; margin-bottom:12px"><span style="color:var(--muted-dark); font-size:11px; display:block; margin-bottom:4px; font-family:'SF Mono','Fira Code',monospace">${r.sql.slice(0, 120)}</span>${r.error}</div>`);
|
||
} else if (r.rows && r.rows.length > 0) {
|
||
const cols = Object.keys(r.rows[0]);
|
||
blocks.push(`
|
||
<div style="margin-bottom:16px">
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr>${cols.map(c => `<th>${c}</th>`).join('')}</tr></thead>
|
||
<tbody>${r.rows.map(row =>
|
||
`<tr>${cols.map(c => `<td><span class="truncate" style="max-width:300px" title="${String(row[c] ?? '').replace(/"/g,'"')}">${row[c] ?? '<span style="color:var(--muted-dark)">NULL</span>'}</span></td>`).join('')}</tr>`
|
||
).join('')}</tbody>
|
||
</table>
|
||
</div>
|
||
<div style="color:var(--muted-dark); font-size:12px; margin-top:6px">${r.rows.length} row${r.rows.length !== 1 ? 's' : ''}</div>
|
||
</div>
|
||
`);
|
||
} else if (r.rows) {
|
||
blocks.push(`<div style="color:var(--muted); font-size:13px; margin-bottom:12px">No rows returned.</div>`);
|
||
} else {
|
||
blocks.push(`<div style="color:#86efac; font-size:13px; margin-bottom:12px">${r.changes} row${r.changes !== 1 ? 's' : ''} affected.</div>`);
|
||
}
|
||
}
|
||
resultsEl.innerHTML = blocks.join('');
|
||
} catch (e) {
|
||
errEl.textContent = e.message;
|
||
errEl.style.display = '';
|
||
}
|
||
}
|
||
|
||
document.getElementById('sql-run-btn').onclick = runSql;
|
||
|
||
document.getElementById('sql-input').addEventListener('keydown', e => {
|
||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) runSql();
|
||
});
|
||
|
||
// ── tabs ───────────────────────────────────────────────────────────────────
|
||
|
||
const tabContents = {
|
||
articles: document.getElementById('tab-articles'),
|
||
events: document.getElementById('tab-events'),
|
||
stats: document.getElementById('tab-stats'),
|
||
intelligence: document.getElementById('tab-intelligence'),
|
||
sql: document.getElementById('tab-sql'),
|
||
};
|
||
|
||
function switchTab(tab) {
|
||
if (!tabContents[tab]) tab = 'articles';
|
||
|
||
document.querySelectorAll('.tab-btn').forEach(b => {
|
||
b.classList.toggle('active', b.dataset.tab === tab);
|
||
});
|
||
|
||
Object.entries(tabContents).forEach(([k, el]) => {
|
||
el.style.display = k === tab ? '' : 'none';
|
||
});
|
||
|
||
location.hash = tab;
|
||
|
||
if (tab === 'events') loadEvents();
|
||
if (tab === 'stats') loadStats();
|
||
if (tab === 'intelligence') { intelOffset = 0; loadIntelligenceStats().then(ok => { if (ok) { loadIntelligenceCompanies(); loadIntelligence(); } }); }
|
||
}
|
||
|
||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||
btn.onclick = () => switchTab(btn.dataset.tab);
|
||
});
|
||
|
||
window.addEventListener('hashchange', () => {
|
||
const tab = location.hash.replace('#', '');
|
||
switchTab(tab);
|
||
});
|
||
|
||
// close overlays on backdrop click
|
||
document.getElementById('articleOverlay').onclick = function(e) {
|
||
if (e.target === this) this.classList.remove('open');
|
||
};
|
||
document.getElementById('eventOverlay').onclick = function(e) {
|
||
if (e.target === this) this.classList.remove('open');
|
||
};
|
||
|
||
// ── init ───────────────────────────────────────────────────────────────────
|
||
|
||
const initialTab = location.hash.replace('#', '') || 'articles';
|
||
switchTab(initialTab);
|
||
|
||
loadSources();
|
||
if (initialTab === 'articles') loadArticles();
|
||
loadStats();
|
||
</script>
|
||
</body>
|
||
</html>
|