Duriin-API/admin.html

1565 lines
49 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 {
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>
</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:#3b82f6; border:2px solid #1d4ed8"></span>Tracked company</span>
<span><span class="graph-legend-dot" style="background:#1e293b; border:2px solid #475569"></span>Untracked entity</span>
<span style="margin-left:8px; color:var(--muted-dark)">Scroll to zoom · drag nodes · click to see 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="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('');
}
// ── 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 ─────────────────────────────────────────────────────
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' : '';
}
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';
// build keyed nodes
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 });
}
const nodes = Array.from(nodeMap.values());
const links = [];
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)) {
links.push({
source: src,
target: tgt,
type: e.relationship_type,
count: e.confirmation_count || 1,
confidence: e.confidence,
});
}
}
renderIntelGraph(nodes, links);
}
function renderIntelGraph(nodes, links) {
const svgEl = document.getElementById('intel-graph-svg');
svgEl.innerHTML = '';
const width = svgEl.clientWidth || 800;
const height = svgEl.clientHeight || 600;
const svg = d3.select(svgEl);
const g = svg.append('g');
svg.call(
d3.zoom().scaleExtent([0.15, 5]).on('zoom', ev => g.attr('transform', ev.transform))
);
// arrow marker
svg.append('defs').append('marker')
.attr('id', 'ig-arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 22)
.attr('refY', 0)
.attr('markerWidth', 5)
.attr('markerHeight', 5)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', '#334155');
const maxCount = Math.max(...links.map(l => l.count), 1);
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.key).distance(110))
.force('charge', d3.forceManyBody().strength(-320))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collide', d3.forceCollide(32));
const linkLines = g.append('g')
.selectAll('line')
.data(links)
.join('line')
.attr('stroke', '#1e293b')
.attr('stroke-width', d => 1 + (d.count / maxCount) * 3.5)
.attr('stroke-opacity', d => 0.35 + (d.count / maxCount) * 0.55)
.attr('marker-end', 'url(#ig-arrow)');
const linkLabels = g.append('g')
.selectAll('text')
.data(links)
.join('text')
.text(d => d.type)
.attr('font-size', 9)
.attr('fill', '#334155')
.attr('text-anchor', 'middle')
.attr('pointer-events', 'none');
const nodeGroups = g.append('g')
.selectAll('g')
.data(nodes)
.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);
});
nodeGroups.append('circle')
.attr('r', d => d.tracked ? 14 : 9)
.attr('fill', d => d.tracked ? '#1e3a5f' : '#0f172a')
.attr('stroke', d => d.tracked ? '#3b82f6' : '#475569')
.attr('stroke-width', d => d.tracked ? 2 : 1.5);
nodeGroups.append('text')
.text(d => d.ticker || d.name.slice(0, 7))
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('font-size', d => d.tracked ? 9 : 8)
.attr('font-weight', d => d.tracked ? '600' : '400')
.attr('fill', d => d.tracked ? '#93c5fd' : '#64748b')
.attr('pointer-events', 'none');
nodeGroups.append('title').text(d => d.name);
simulation.on('tick', () => {
linkLines
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
linkLabels
.attr('x', d => (d.source.x + d.target.x) / 2)
.attr('y', d => (d.source.y + d.target.y) / 2 - 4);
nodeGroups.attr('transform', d => `translate(${d.x},${d.y})`);
});
}
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`;
if (data.rows && data.rows.length > 0) {
const cols = Object.keys(data.rows[0]);
resultsEl.innerHTML = `
<div class="table-wrap" style="margin-top:4px">
<table>
<thead><tr>${cols.map(c => `<th>${c}</th>`).join('')}</tr></thead>
<tbody>${data.rows.map(r =>
`<tr>${cols.map(c => `<td><span class="truncate" style="max-width:300px" title="${String(r[c] ?? '').replace(/"/g,'&quot;')}">${r[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:8px">${data.rows.length} row${data.rows.length !== 1 ? 's' : ''}</div>
`;
} else if (data.rows) {
resultsEl.innerHTML = '<div style="color:var(--muted); font-size:13px; padding-top:8px">No rows returned.</div>';
} else {
resultsEl.innerHTML = `<div style="color:#86efac; font-size:13px; padding-top:8px">${data.changes} row${data.changes !== 1 ? 's' : ''} affected.</div>`;
}
} 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>