Duriin-API/admin.html

1835 lines
59 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>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-svg-wrap {
flex: 1;
background: var(--bg-subtle);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
position: relative;
height: 600px;
}
#intel-graph-svg-wrap.graph-expanded {
position: fixed;
inset: 0;
z-index: 500;
border-radius: 0;
border: none;
height: 100dvh;
}
#intel-graph-svg {
width: 100%;
height: 100%;
}
#graph-sidebar {
display: none;
width: 270px;
flex-shrink: 0;
margin-left: 14px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px;
overflow-y: auto;
height: 600px;
}
#graph-sidebar-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.graph-fact-row {
padding: 7px 0;
border-bottom: 1px solid var(--border-light);
}
.graph-fact-row:last-child {
border-bottom: none;
}
.graph-fact-claim {
font-size: 12px;
color: var(--foreground);
line-height: 1.5;
margin-bottom: 3px;
}
.graph-fact-meta {
font-size: 11px;
color: var(--muted-dark);
}
#graph-legend {
margin-top: 10px;
display: flex;
gap: 18px;
font-size: 11px;
color: var(--muted-dark);
align-items: center;
}
.graph-legend-dot {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-right: 5px;
vertical-align: middle;
}
</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 style="display:flex">
<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>
<button onclick="if(window._graphFit) window._graphFit()" style="position:absolute; top:10px; right:120px; padding:5px 10px; font-size:12px; background:var(--bg-card); border:1px solid var(--border); color:var(--muted); border-radius:var(--radius); cursor:pointer; z-index:10" title="Fit all nodes into view">⊡ Fit</button>
<button id="graph-expand-btn" onclick="toggleGraphExpand()" style="position:absolute; top:10px; right:10px; padding:5px 10px; font-size:12px; background:var(--bg-card); border:1px solid var(--border); color:var(--muted); border-radius:var(--radius); cursor:pointer; z-index:10" title="Expand graph">⤢ Expand</button>
</div>
<div id="graph-sidebar">
<div id="graph-sidebar-title"></div>
<div id="graph-sidebar-body"></div>
</div>
</div>
<div id="graph-legend">
<span><span class="graph-legend-dot" style="background:#1e3a5f; border:2px solid #3b82f6"></span>Tracked company</span>
<span><span class="graph-legend-dot" style="background:#0f172a; border:2px solid #475569"></span>Untracked entity</span>
<label style="display:flex; align-items:center; gap:6px; margin-left:16px; cursor:pointer; user-select:none">
<input type="checkbox" id="graph-show-untracked" style="accent-color:var(--accent); width:13px; height:13px" />
<span>Show untracked entities</span>
</label>
<span style="margin-left:auto; color:var(--muted-dark)">Scroll to zoom · drag nodes · click for facts</span>
</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, '&quot;')})">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 ─────────────────────────────────────────────────────
// tickers that dont make a readable label inside a node
const TICKER_DISPLAY = {
'005930.KS': 'SMSNG',
'000660.KS': 'HYNIX',
'OPENAI': 'OAI',
'ANTHROPIC': 'ANTH',
};
// split a name into up to two lines at a natural word boundary
function splitLabel(name, maxLine) {
if (name.length <= maxLine) return [name];
const mid = Math.floor(name.length / 2);
// find a space near the middle to break on
let best = -1;
let bestDist = Infinity;
for (let i = 0; i < name.length; i++) {
if (name[i] === ' ') {
const dist = Math.abs(i - mid);
if (dist < bestDist) { bestDist = dist; best = i; }
}
}
if (best === -1) return [name]; // no spaces, just show as-is
return [name.slice(0, best), name.slice(best + 1)];
}
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 graphAllNodes = [];
let graphAllLinks = [];
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';
return;
}
document.getElementById('graph-empty').style.display = 'none';
const nodeMap = new Map();
for (const n of data.nodes) {
const key = n.tracked ? `c_${n.id}` : `u_${n.name}`;
nodeMap.set(key, { ...n, key });
}
graphAllNodes = Array.from(nodeMap.values());
graphAllLinks = [];
for (const e of data.edges) {
const src = `c_${e.from_company_id}`;
const tgt = e.to_company_id ? `c_${e.to_company_id}` : `u_${e.to_entity}`;
if (nodeMap.has(src) && nodeMap.has(tgt)) {
graphAllLinks.push({
source: src,
target: tgt,
type: e.relationship_type,
count: e.confirmation_count || 1,
confidence: e.confidence,
});
}
}
renderIntelGraph();
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
const wrap = document.getElementById('intel-graph-svg-wrap');
if (wrap.classList.contains('graph-expanded')) toggleGraphExpand();
}
});
document.getElementById('graph-show-untracked').addEventListener('change', () => {
if (graphAllNodes.length) renderIntelGraph();
});
function toggleGraphExpand() {
const wrap = document.getElementById('intel-graph-svg-wrap');
const btn = document.getElementById('graph-expand-btn');
const expanded = wrap.classList.toggle('graph-expanded');
btn.textContent = expanded ? '⤡ Collapse' : '⤢ Expand';
// re-render so the simulation uses the new dimensions
if (graphAllNodes.length) renderIntelGraph();
}
function renderIntelGraph() {
const showUntracked = document.getElementById('graph-show-untracked').checked;
const filteredLinks = graphAllLinks.slice();
// only show nodes that still have at least one edge after the count filter
const activeKeys = new Set();
for (const l of filteredLinks) {
const src = typeof l.source === 'object' ? l.source.key : l.source;
const tgt = typeof l.target === 'object' ? l.target.key : l.target;
activeKeys.add(src);
activeKeys.add(tgt);
}
const nodes = graphAllNodes.filter(n =>
activeKeys.has(n.key) && (showUntracked || n.tracked)
);
const visibleKeys = new Set(nodes.map(n => n.key));
const links = filteredLinks.filter(l => visibleKeys.has(
typeof l.source === 'object' ? l.source.key : l.source
) && visibleKeys.has(
typeof l.target === 'object' ? l.target.key : l.target
));
// deep-copy so d3 doesnt mutate our stored arrays on re-render
const nodesCopy = nodes.map(n => ({ ...n }));
// merge parallel + reciprocal edges into one — sort the pair so A→B and B→A share a key
const edgeMap = new Map();
for (const l of links) {
const src = typeof l.source === 'object' ? l.source.key : l.source;
const tgt = typeof l.target === 'object' ? l.target.key : l.target;
const [a, b] = src < tgt ? [src, tgt] : [tgt, src];
const key = `${a}||${b}`;
if (edgeMap.has(key)) {
const existing = edgeMap.get(key);
if (!existing.types.includes(l.type)) existing.types.push(l.type);
existing.count = Math.max(existing.count, l.count || 1);
} else {
edgeMap.set(key, { source: src, target: tgt, types: [l.type], count: l.count || 1 });
}
}
const linksCopy = Array.from(edgeMap.values()).map(l => ({
...l,
type: l.types.join(' · '),
}));
const svgEl = document.getElementById('intel-graph-svg');
svgEl.innerHTML = '';
const width = svgEl.clientWidth || 900;
const height = svgEl.clientHeight || 600;
const svg = d3.select(svgEl);
const g = svg.append('g');
// svg defs — arrow marker + glow filter
const defs = svg.append('defs');
const glow = defs.append('filter')
.attr('id', 'ig-glow')
.attr('x', '-50%').attr('y', '-50%')
.attr('width', '200%').attr('height', '200%');
glow.append('feGaussianBlur').attr('stdDeviation', '4').attr('result', 'blur');
const merge = glow.append('feMerge');
merge.append('feMergeNode').attr('in', 'blur');
merge.append('feMergeNode').attr('in', 'SourceGraphic');
const maxCount = Math.max(...linksCopy.map(l => l.count), 1);
const zoomBehavior = d3.zoom().scaleExtent([0.08, 6]).on('zoom', ev => g.attr('transform', ev.transform));
svg.call(zoomBehavior);
// degree map — nodes with many connections need stronger repulsion
const degree = {};
for (const l of linksCopy) {
degree[l.source] = (degree[l.source] || 0) + 1;
degree[l.target] = (degree[l.target] || 0) + 1;
}
const maxDegree = Math.max(...Object.values(degree), 1);
// custom force: push nodes away from edges they are not endpoints of
function forceNodeEdgeRepulsion() {
const minDist = 55;
return function() {
for (const node of nodesCopy) {
for (const link of linksCopy) {
const s = link.source;
const t = link.target;
if (s === node || t === node) continue;
const dx = t.x - s.x;
const dy = t.y - s.y;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) continue;
// parameter of closest point on segment
let u = ((node.x - s.x) * dx + (node.y - s.y) * dy) / lenSq;
u = Math.max(0, Math.min(1, u));
const cx = s.x + u * dx;
const cy = s.y + u * dy;
const ex = node.x - cx;
const ey = node.y - cy;
const dist = Math.sqrt(ex * ex + ey * ey);
if (dist < minDist && dist > 0) {
const push = (minDist - dist) / dist * 0.6;
node.vx += ex * push;
node.vy += ey * push;
}
}
}
};
}
const simulation = d3.forceSimulation(nodesCopy)
.force('link', d3.forceLink(linksCopy).id(d => d.key).distance(d => {
return Math.max(130, d.type.length * 5 + 90);
}))
.force('charge', d3.forceManyBody().strength(-220))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('x', d3.forceX(width / 2).strength(0.08))
.force('y', d3.forceY(height / 2).strength(0.08))
.force('collide', d3.forceCollide(d => d.tracked ? 56 : 40).strength(1))
.force('nodeEdgeRepulsion', forceNodeEdgeRepulsion())
.alphaDecay(0.015);
// links — use <path> so textPath can align labels to the line
const linkG = g.append('g');
let linkLabels;
const strokeWidth = d => 1.5 + Math.pow(d.count / maxCount, 0.55) * 3.5;
const linkPaths = linkG.selectAll('path.ig-edge')
.data(linksCopy)
.join('path')
.attr('class', 'ig-edge')
.attr('id', (d, i) => `ig-ep-${i}`)
.attr('fill', 'none')
.attr('stroke', '#1e293b')
.attr('stroke-width', strokeWidth)
.attr('stroke-opacity', d => 0.55 + (d.count / maxCount) * 0.35)
.on('mouseenter', function(ev, d) {
d3.select(this).attr('stroke', '#60a5fa').attr('stroke-opacity', 1);
linkLabels.filter(l => l === d).attr('fill', '#f8fafc');
})
.on('mouseleave', function(ev, d) {
d3.select(this).attr('stroke', '#334155')
.attr('stroke-opacity', 0.2 + (d.count / maxCount) * 0.7);
linkLabels.filter(l => l === d).attr('fill', '#94a3b8');
});
// labels along each edge via textPath — font-size slightly smaller than stroke-width
linkLabels = linkG.selectAll('text.ig-elabel')
.data(linksCopy)
.join('text')
.attr('class', 'ig-elabel')
.attr('font-size', 7)
.attr('fill', '#94a3b8')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('pointer-events', 'none')
.append('textPath')
.attr('href', (d, i) => `#ig-ep-${i}`)
.attr('startOffset', '50%')
.text(d => d.type);
// nodes
const nodeGroups = g.append('g')
.selectAll('g')
.data(nodesCopy)
.join('g')
.attr('cursor', d => d.tracked ? 'pointer' : 'default')
.call(
d3.drag()
.on('start', (ev, d) => {
if (!ev.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x; d.fy = d.y;
})
.on('drag', (ev, d) => { d.fx = ev.x; d.fy = ev.y; })
.on('end', (ev, d) => {
if (!ev.active) simulation.alphaTarget(0);
d.fx = null; d.fy = null;
})
)
.on('click', (ev, d) => {
if (d.tracked) openGraphSidebar(d);
});
// tracked node: glow circle + main circle
nodeGroups.filter(d => d.tracked).append('circle')
.attr('r', 24)
.attr('fill', 'rgba(59,130,246,0.08)')
.attr('stroke', 'rgba(59,130,246,0.25)')
.attr('stroke-width', 1)
.attr('filter', 'url(#ig-glow)')
.attr('pointer-events', 'none');
nodeGroups.append('circle')
.attr('r', d => d.tracked ? 20 : 12)
.attr('fill', d => d.tracked ? '#1a3152' : '#0b1524')
.attr('stroke', d => d.tracked ? '#3b82f6' : '#334155')
.attr('stroke-width', d => d.tracked ? 2 : 1.5);
// short ticker label inside circle for tracked nodes
nodeGroups.filter(d => d.tracked).append('text')
.text(d => TICKER_DISPLAY[d.ticker] || (d.ticker && d.ticker.length <= 5 ? d.ticker : d.name.slice(0, 4).toUpperCase()))
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('font-size', 8)
.attr('font-weight', '700')
.attr('fill', '#93c5fd')
.attr('letter-spacing', '0.04em')
.attr('pointer-events', 'none');
// full company name below tracked nodes — wrapped
nodeGroups.filter(d => d.tracked).each(function(d) {
const lines = splitLabel(d.name, 14);
const el = d3.select(this).append('text')
.attr('text-anchor', 'middle')
.attr('font-size', 10)
.attr('font-weight', '500')
.attr('fill', '#cbd5e1')
.attr('pointer-events', 'none');
lines.forEach((line, i) => {
el.append('tspan')
.attr('x', 0)
.attr('dy', i === 0 ? 32 : 13)
.text(line);
});
});
// full entity name below untracked nodes — wrapped
nodeGroups.filter(d => !d.tracked).each(function(d) {
const lines = splitLabel(d.name, 14);
const el = d3.select(this).append('text')
.attr('text-anchor', 'middle')
.attr('font-size', 9)
.attr('fill', '#475569')
.attr('pointer-events', 'none');
lines.forEach((line, i) => {
el.append('tspan')
.attr('x', 0)
.attr('dy', i === 0 ? 20 : 12)
.text(line);
});
});
nodeGroups.append('title').text(d => d.name);
simulation.on('tick', () => {
linkPaths.attr('d', d => {
// always draw left-to-right so textPath reads correctly
const [sx, sy, tx, ty] = d.source.x < d.target.x
? [d.source.x, d.source.y, d.target.x, d.target.y]
: [d.target.x, d.target.y, d.source.x, d.source.y];
return `M${sx},${sy} L${tx},${ty}`;
});
nodeGroups.attr('transform', d => `translate(${d.x},${d.y})`);
});
// expose fit function for the button
window._graphFit = () => {
const bounds = g.node().getBBox();
if (!bounds.width || !bounds.height) return;
const pad = 60;
const scale = Math.min(width / (bounds.width + pad * 2), height / (bounds.height + pad * 2), 1);
const tx = width / 2 - scale * (bounds.x + bounds.width / 2);
const ty = height / 2 - scale * (bounds.y + bounds.height / 2);
svg.transition().duration(500)
.call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
};
}
async function openGraphSidebar(node) {
const sidebar = document.getElementById('graph-sidebar');
const titleEl = document.getElementById('graph-sidebar-title');
const bodyEl = document.getElementById('graph-sidebar-body');
sidebar.style.display = '';
titleEl.textContent = node.ticker ? `${node.name} (${node.ticker})` : node.name;
bodyEl.innerHTML = '<div style="color:var(--muted-dark); font-size:12px">Loading...</div>';
try {
const facts = await api(`/admin/api/intelligence/facts/${node.id}`);
if (!facts.length) {
bodyEl.innerHTML = '<div style="color:var(--muted-dark); font-size:12px">No facts yet.</div>';
return;
}
bodyEl.innerHTML = facts.map(f => `
<div class="graph-fact-row">
<div class="graph-fact-claim">${f.claim}</div>
<div class="graph-fact-meta">${f.type} &middot; ${f.confidence} &middot; &times;${f.confirmation_count}</div>
</div>
`).join('');
} catch (_) {
bodyEl.innerHTML = '<div style="color:#fca5a5; font-size:12px">Failed to load.</div>';
}
}
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,'&quot;')}">${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>