Duriin-API/admin.html

1937 lines
60 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-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, '&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 ─────────────────────────────────────────────────────
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 () {
d3.select(this).attr('stroke', 'rgba(255,255,255,0.85)');
})
.on('mouseleave', function () {
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('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);
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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[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,'&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>