Duriin-API/admin.html

1014 lines
35 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>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0f1117;
color: #e2e8f0;
font-size: 14px;
}
header {
background: #1a1d27;
border-bottom: 1px solid #2d3148;
padding: 12px 24px;
display: flex;
align-items: center;
gap: 24px;
}
header h1 { font-size: 18px; font-weight: 600; color: #fff; }
.tabs {
display: flex;
gap: 4px;
margin-left: auto;
}
.tab-btn {
background: none;
border: none;
color: #94a3b8;
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.tab-btn.active {
background: #2d3148;
color: #fff;
}
.stats-bar {
display: flex;
gap: 16px;
padding: 12px 24px;
background: #141620;
border-bottom: 1px solid #2d3148;
flex-wrap: wrap;
}
.stat {
display: flex;
flex-direction: column;
gap: 2px;
}
.stat .label { color: #64748b; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; }
.stat .value { font-size: 20px; font-weight: 600; color: #fff; }
.content { padding: 20px 24px; }
.filters {
display: flex;
gap: 10px;
margin-bottom: 16px;
flex-wrap: wrap;
align-items: flex-end;
}
.filters label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #94a3b8; }
input[type="text"], input[type="date"], select {
background: #1a1d27;
border: 1px solid #2d3148;
color: #e2e8f0;
padding: 6px 10px;
border-radius: 6px;
font-size: 13px;
outline: none;
min-width: 140px;
}
input[type="text"]:focus, select:focus {
border-color: #4f6ef7;
}
button {
background: #2d3148;
border: none;
color: #e2e8f0;
padding: 7px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
button:hover { background: #3a3f5c; }
button.primary { background: #4f6ef7; color: #fff; }
button.primary:hover { background: #3d5ce0; }
button.danger { background: #7f1d1d; color: #fca5a5; }
button.danger:hover { background: #991b1b; }
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: left;
padding: 8px 12px;
border-bottom: 1px solid #2d3148;
color: #64748b;
font-size: 11px;
text-transform: uppercase;
letter-spacing: .05em;
font-weight: 500;
}
td {
padding: 8px 12px;
border-bottom: 1px solid #1e2130;
vertical-align: top;
}
tr:hover td { background: #161926; }
.truncate {
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
.badge {
display: inline-block;
padding: 2px 7px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
.badge.ok { background: #14532d; color: #86efac; }
.badge.err { background: #7f1d1d; color: #fca5a5; }
.badge.pending { background: #1e3a5f; color: #93c5fd; }
.badge.null { background: #1e293b; color: #94a3b8; }
.pagination {
display: flex;
align-items: center;
gap: 10px;
margin-top: 16px;
color: #64748b;
font-size: 13px;
}
/* modal */
.overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,.6);
z-index: 100;
align-items: center;
justify-content: center;
}
.overlay.open { display: flex; }
.modal {
background: #1a1d27;
border: 1px solid #2d3148;
border-radius: 10px;
padding: 24px;
width: 680px;
max-width: 95vw;
max-height: 90vh;
overflow-y: auto;
}
.modal h2 { font-size: 16px; margin-bottom: 16px; }
.field { margin-bottom: 14px; display: flex; flex-direction: column; gap: 5px; }
.field label { font-size: 12px; color: #94a3b8; }
.field input[type="text"],
.field textarea,
.field select {
width: 100%;
min-width: unset;
}
textarea {
background: #141620;
border: 1px solid #2d3148;
color: #e2e8f0;
padding: 8px 10px;
border-radius: 6px;
font-size: 13px;
resize: vertical;
font-family: inherit;
outline: none;
min-height: 120px;
}
textarea:focus { border-color: #4f6ef7; }
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 18px;
}
.url-link { color: #818cf8; text-decoration: none; }
.url-link:hover { text-decoration: underline; }
#toast {
position: fixed;
bottom: 24px;
right: 24px;
background: #1e293b;
border: 1px solid #334155;
color: #e2e8f0;
padding: 10px 18px;
border-radius: 8px;
font-size: 13px;
display: none;
z-index: 200;
}
#toast.show { display: block; }
</style>
</head>
<body>
<header>
<h1>Duriin Admin</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>
<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 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">
<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 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:#94a3b8; padding: 24px 0">intelligence.sqlite not found — is the intelligence worker running?</div>
<div id="intel-content">
<div style="display:flex; gap:24px; flex-wrap:wrap; margin-bottom:24px" id="intel-stats-row"></div>
<div style="display:flex; gap:16px; margin-bottom:14px; flex-wrap:wrap; align-items:flex-end">
<label style="display:flex; flex-direction:column; gap:4px; font-size:12px; color:#94a3b8">Company
<select id="i-company"><option value="">All companies</option></select>
</label>
<label style="display:flex; flex-direction:column; gap:4px; font-size:12px; color:#94a3b8">View
<select id="i-view">
<option value="knowledge">Knowledge</option>
<option value="predictions">Predictions</option>
</select>
</label>
<label style="display:flex; flex-direction:column; gap:4px; font-size:12px; color:#94a3b8">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 style="display:flex; flex-direction:column; gap:4px; font-size:12px; color:#94a3b8" 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()">Filter</button>
</div>
<table>
<thead id="intel-thead"></thead>
<tbody id="intel-tbody"></tbody>
</table>
<div class="pagination">
<button id="iPrevBtn">← Prev</button>
<span id="iPageInfo"></span>
<button id="iNextBtn">Next →</button>
</div>
</div>
</div>
<!-- SQL tab -->
<div id="tab-sql" style="display:none">
<div style="display:flex; gap:10px; margin-bottom:10px; 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:#64748b; font-size:12px"></span>
</div>
<textarea id="sql-input" style="width:100%; min-height:120px; font-family: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:8px">
<div>
<h3 style="font-size:13px; color:#94a3b8; margin-bottom:10px; text-transform:uppercase; letter-spacing:.05em">By source</h3>
<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>
<h3 style="font-size:13px; color:#94a3b8; margin-bottom:10px; text-transform:uppercase; letter-spacing:.05em">By content status</h3>
<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>
<!-- 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:#64748b; margin-bottom:16px; display:flex; gap:16px; flex-wrap:wrap"></div>
<div id="intel-modal-body" style="font-size:13px; line-height:1.7; white-space:pre-wrap; word-break:break-word; background:#141620; padding:14px; border-radius:6px; border:1px solid #2d3148"></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:#64748b; margin-bottom:16px; display:flex; gap:16px; flex-wrap:wrap"></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>
<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">
<h2>Edit Event</h2>
<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>
<button id="eCancelBtn">Cancel</button>
<button class="primary" id="eSaveBtn">Save</button>
</div>
</div>
</div>
<div id="toast"></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');
el.textContent = msg;
el.className = 'show';
if (err) el.style.borderColor = '#ef4444';
else el.style.borderColor = '#334155';
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:#64748b">${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:2px">↗ ${new URL(r.url).hostname}</a>
</td>
<td><span style="color:#94a3b8">${r.source}</span></td>
<td>${badgeHtml(r.content_status)}</td>
<td style="color:#64748b; white-space:nowrap">${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(); };
// enter key in filters triggers search
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:#64748b">${r.id}</td>
<td><span class="truncate" title="${r.title}">${r.title}</span></td>
<td>${r.article_count}</td>
<td style="color:#64748b; white-space:nowrap">${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="stat">
<span class="label">${label}</span>
<span class="value" style="font-size:16px">${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;
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:#64748b">${r.id}</td>
<td style="white-space:nowrap">${r.company_name}</td>
<td style="color:#64748b">${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:#64748b; white-space:nowrap">${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:#64748b">${r.id}</td>
<td style="white-space:nowrap">${r.company_name}</td>
<td style="color:#64748b">${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:#64748b; white-space:nowrap">${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(); };
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 = `
<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:#64748b">NULL</span>'}</span></td>`).join('')}</tr>`
).join('')}</tbody>
</table>
<div style="color:#64748b; 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:#64748b; font-size:13px">No rows returned.</div>';
} else {
resultsEl.innerHTML = `<div style="color:#86efac; font-size:13px">${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>