refactor admin navigation; update links to ingest pages and improve loading of data in parallel
This commit is contained in:
parent
16cd61fdf5
commit
fc7ea464b3
17 changed files with 292 additions and 67 deletions
|
|
@ -546,6 +546,58 @@
|
||||||
|
|
||||||
.signal-regen-btn:hover { color: var(--foreground); background: var(--bg-subtle); }
|
.signal-regen-btn:hover { color: var(--foreground); background: var(--bg-subtle); }
|
||||||
|
|
||||||
|
|
||||||
|
.signal-ref-event {
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
padding: 2px 0 6px 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-ref-event-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--foreground);
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-ref-articles {
|
||||||
|
padding-left: 14px;
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-ref-articles li {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-ref-articles a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-ref-articles a:hover { color: var(--foreground); }
|
||||||
|
|
||||||
|
.signal-ref-source {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--muted-dark);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signal-ref-date {
|
||||||
|
font-size: 10.5px;
|
||||||
|
color: var(--muted-dark);
|
||||||
|
margin-left: 6px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
.signal-empty {
|
.signal-empty {
|
||||||
color: var(--muted-dark);
|
color: var(--muted-dark);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,12 @@ async function loadSources() {
|
||||||
|
|
||||||
|
|
||||||
function getFilters() {
|
function getFilters() {
|
||||||
|
const sel = document.getElementById("f-source");
|
||||||
return {
|
return {
|
||||||
keyword: document.getElementById("f-keyword").value.trim(),
|
keyword: document.getElementById("f-keyword").value.trim(),
|
||||||
source: document.getElementById("f-source").value,
|
// fall back to url when the dropdown hasn't populated yet —
|
||||||
|
// lets us fire loadArticles in parallel with loadSources on init
|
||||||
|
source: sel.value || queryGet("source"),
|
||||||
content_status: document.getElementById("f-status").value,
|
content_status: document.getElementById("f-status").value,
|
||||||
from: document.getElementById("f-from").value,
|
from: document.getElementById("f-from").value,
|
||||||
to: document.getElementById("f-to").value,
|
to: document.getElementById("f-to").value,
|
||||||
|
|
@ -184,6 +187,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
await loadSources(); // wait for dropdown to render the saved source selection
|
// both calls can run concurrently — loadArticles falls back to the
|
||||||
loadArticles();
|
// url param for source until the dropdown finishes populating.
|
||||||
|
await Promise.all([loadSources(), loadArticles()]);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
2
public/admin/assets/js/d3.min.js
vendored
Normal file
2
public/admin/assets/js/d3.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -586,7 +586,8 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||||
applyGraphFilters();
|
applyGraphFilters();
|
||||||
});
|
});
|
||||||
|
|
||||||
const ok = await loadIntelStatsRow();
|
// both calls are independent — run them concurrently. the graph
|
||||||
if (!ok) return;
|
// endpoint returns empty nodes/edges when intelligence db is missing
|
||||||
loadIntelGraph();
|
// so we dont need to gate on the stats check.
|
||||||
|
Promise.all([loadIntelStatsRow(), loadIntelGraph()]);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,9 @@ function syncUrl() {
|
||||||
|
|
||||||
|
|
||||||
async function loadKnowledge() {
|
async function loadKnowledge() {
|
||||||
const companyId = document.getElementById("i-company").value;
|
// fall back to url params when the company dropdown hasn't populated
|
||||||
|
// yet — lets init fire all api calls in parallel
|
||||||
|
const companyId = document.getElementById("i-company").value || queryGet("company_id");
|
||||||
const type = document.getElementById("i-type").value;
|
const type = document.getElementById("i-type").value;
|
||||||
const sort = document.getElementById("i-sort").value;
|
const sort = document.getElementById("i-sort").value;
|
||||||
|
|
||||||
|
|
@ -73,18 +75,19 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||||
loadKnowledge();
|
loadKnowledge();
|
||||||
};
|
};
|
||||||
|
|
||||||
const ok = await loadIntelStatsRow();
|
// restore non-async inputs from url up-front (company_id is handled
|
||||||
if (!ok) return;
|
// inside loadIntelCompanies once the dropdown has options)
|
||||||
|
|
||||||
await loadIntelCompanies();
|
|
||||||
|
|
||||||
// restore from url (after dropdown is populated so company_id options exist)
|
|
||||||
queryApplyToInputs({
|
queryApplyToInputs({
|
||||||
"i-company": "company_id",
|
|
||||||
"i-type": "type",
|
"i-type": "type",
|
||||||
"i-sort": "sort",
|
"i-sort": "sort",
|
||||||
});
|
});
|
||||||
knowledgeOffset = parseInt(queryGet("offset"), 10) || 0;
|
knowledgeOffset = parseInt(queryGet("offset"), 10) || 0;
|
||||||
|
|
||||||
loadKnowledge();
|
// fire all three calls concurrently — loadKnowledge reads company_id
|
||||||
|
// from the url when the select isn't populated yet
|
||||||
|
await Promise.all([
|
||||||
|
loadIntelStatsRow(),
|
||||||
|
loadIntelCompanies(),
|
||||||
|
loadKnowledge(),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ function syncUrl() {
|
||||||
|
|
||||||
|
|
||||||
async function loadPredictions() {
|
async function loadPredictions() {
|
||||||
const companyId = document.getElementById("i-company").value;
|
const companyId = document.getElementById("i-company").value || queryGet("company_id");
|
||||||
const sort = document.getElementById("i-sort").value;
|
const sort = document.getElementById("i-sort").value;
|
||||||
|
|
||||||
const params = new URLSearchParams({ limit: PAGE, offset: predictionsOffset });
|
const params = new URLSearchParams({ limit: PAGE, offset: predictionsOffset });
|
||||||
|
|
@ -69,16 +69,12 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||||
loadPredictions();
|
loadPredictions();
|
||||||
};
|
};
|
||||||
|
|
||||||
const ok = await loadIntelStatsRow();
|
queryApplyToInputs({ "i-sort": "sort" });
|
||||||
if (!ok) return;
|
|
||||||
|
|
||||||
await loadIntelCompanies();
|
|
||||||
|
|
||||||
queryApplyToInputs({
|
|
||||||
"i-company": "company_id",
|
|
||||||
"i-sort": "sort",
|
|
||||||
});
|
|
||||||
predictionsOffset = parseInt(queryGet("offset"), 10) || 0;
|
predictionsOffset = parseInt(queryGet("offset"), 10) || 0;
|
||||||
|
|
||||||
loadPredictions();
|
await Promise.all([
|
||||||
|
loadIntelStatsRow(),
|
||||||
|
loadIntelCompanies(),
|
||||||
|
loadPredictions(),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ async function loadIntelCompanies() {
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
|
|
||||||
const companies = await api("/admin/api/intelligence/companies");
|
const companies = await api("/admin/api/intelligence/companies");
|
||||||
|
const current = queryGet("company_id"); // preserve selection if url set it
|
||||||
|
|
||||||
sel.innerHTML = '<option value="">All companies</option>';
|
sel.innerHTML = '<option value="">All companies</option>';
|
||||||
companies.forEach(c => {
|
companies.forEach(c => {
|
||||||
const opt = document.createElement("option");
|
const opt = document.createElement("option");
|
||||||
|
|
@ -54,6 +56,8 @@ async function loadIntelCompanies() {
|
||||||
opt.textContent = `${c.name} (${c.ticker})`;
|
opt.textContent = `${c.name} (${c.ticker})`;
|
||||||
sel.appendChild(opt);
|
sel.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (current) sel.value = current;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ async function loadSignals() {
|
||||||
|
|
||||||
grid.innerHTML = data.map(s => {
|
grid.innerHTML = data.map(s => {
|
||||||
const firstSentence = (s.summary || "").split(/\.\s+/)[0].replace(/\.$/, "") + ".";
|
const firstSentence = (s.summary || "").split(/\.\s+/)[0].replace(/\.$/, "") + ".";
|
||||||
const ts = s.generated_at ? s.generated_at.slice(0, 16).replace("T", " ") : "—";
|
const eventTs = s.latest_event_date ? s.latest_event_date.slice(0, 10) : null;
|
||||||
|
|
||||||
let drivers = [];
|
let drivers = [];
|
||||||
let risks = [];
|
let risks = [];
|
||||||
|
|
@ -50,7 +50,7 @@ async function loadSignals() {
|
||||||
<span class="signal-tag">${escapeHtml(s.timeframe)}</span>
|
<span class="signal-tag">${escapeHtml(s.timeframe)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="signal-summary">${escapeHtml(firstSentence)}</div>
|
<div class="signal-summary">${escapeHtml(firstSentence)}</div>
|
||||||
<div class="signal-ts">Generated ${ts}</div>
|
<div class="signal-ts">${eventTs ? `Latest event ${eventTs}` : "No dated event"}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="signal-card-detail">
|
<div class="signal-card-detail">
|
||||||
<div class="signal-detail-section">
|
<div class="signal-detail-section">
|
||||||
|
|
@ -63,6 +63,10 @@ async function loadSignals() {
|
||||||
<div class="signal-detail-label">Data used</div>
|
<div class="signal-detail-label">Data used</div>
|
||||||
<p style="color:var(--muted-dark)">${predIds.length} predictions · window: 90 days</p>
|
<p style="color:var(--muted-dark)">${predIds.length} predictions · window: 90 days</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="signal-detail-section" id="signal-refs-${s.company_id}" data-loaded="0">
|
||||||
|
<div class="signal-detail-label">References</div>
|
||||||
|
<p style="color:var(--muted-dark)">Loading…</p>
|
||||||
|
</div>
|
||||||
<button class="signal-regen-btn" onclick="regenerateSignal(event, ${s.company_id})">Regenerate</button>
|
<button class="signal-regen-btn" onclick="regenerateSignal(event, ${s.company_id})">Regenerate</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -73,7 +77,73 @@ async function loadSignals() {
|
||||||
|
|
||||||
function toggleSignalCard(companyId) {
|
function toggleSignalCard(companyId) {
|
||||||
const card = document.getElementById(`signal-card-${companyId}`);
|
const card = document.getElementById(`signal-card-${companyId}`);
|
||||||
if (card) card.classList.toggle("expanded");
|
if (!card) return;
|
||||||
|
|
||||||
|
card.classList.toggle("expanded");
|
||||||
|
|
||||||
|
if (card.classList.contains("expanded")) {
|
||||||
|
loadSignalReferences(companyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function loadSignalReferences(companyId) {
|
||||||
|
const container = document.getElementById(`signal-refs-${companyId}`);
|
||||||
|
if (!container || container.dataset.loaded === "1") return;
|
||||||
|
|
||||||
|
container.dataset.loaded = "1";
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await api(`/admin/api/intelligence/signals/${companyId}/references`);
|
||||||
|
} catch (_) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="signal-detail-label">References</div>
|
||||||
|
<p style="color:var(--muted-dark)">Failed to load references</p>
|
||||||
|
`;
|
||||||
|
container.dataset.loaded = "0";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = data?.events || [];
|
||||||
|
|
||||||
|
if (!events.length) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="signal-detail-label">References</div>
|
||||||
|
<p style="color:var(--muted-dark)">No linked events</p>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = events.map(ev => {
|
||||||
|
const dateStr = (ev.event_date || ev.created_at || "").slice(0, 10);
|
||||||
|
|
||||||
|
const articles = (ev.articles || []).map(a => {
|
||||||
|
const src = a.source ? `<span class="signal-ref-source">${escapeHtml(a.source)}</span>` : "";
|
||||||
|
const pd = a.pub_date ? `<span class="signal-ref-date">${escapeHtml(a.pub_date.slice(0, 10))}</span>` : "";
|
||||||
|
return `
|
||||||
|
<li>
|
||||||
|
<a href="${escapeHtml(a.url)}" target="_blank" rel="noopener">${escapeHtml(a.title || a.url)}</a>
|
||||||
|
${src}${pd}
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="signal-ref-event">
|
||||||
|
<div class="signal-ref-event-title">
|
||||||
|
${escapeHtml(ev.title || `Event #${ev.id}`)}
|
||||||
|
${dateStr ? `<span class="signal-ref-date">${dateStr}</span>` : ""}
|
||||||
|
</div>
|
||||||
|
${articles ? `<ul class="signal-ref-articles">${articles}</ul>` : `<p style="color:var(--muted-dark);margin:4px 0 0">No articles linked</p>`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="signal-detail-label">References</div>
|
||||||
|
${blocks}
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -96,8 +166,8 @@ async function regenerateSignal(ev, companyId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", async () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
const ok = await loadIntelStatsRow();
|
// fire both in parallel — signals api returns [] when intelligence
|
||||||
if (!ok) return;
|
// db is unavailable, so no need to gate on the stats call
|
||||||
loadSignals();
|
Promise.all([loadIntelStatsRow(), loadSignals()]);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,18 @@
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<h1>Duriin <span>Admin</span></h1>
|
<h1>Duriin <span>Admin</span></h1>
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<a href="/admin/articles" class="active">Articles</a>
|
<a href="/admin/ingest" class="active">Ingest</a>
|
||||||
<a href="/admin/events">Events</a>
|
|
||||||
<a href="/admin/stats">Stats</a>
|
|
||||||
<a href="/admin/intelligence">Intelligence</a>
|
<a href="/admin/intelligence">Intelligence</a>
|
||||||
|
<a href="/admin/stats">Stats</a>
|
||||||
<a href="/admin/sql">SQL</a>
|
<a href="/admin/sql">SQL</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<nav class="subnav">
|
||||||
|
<a href="/admin/ingest/articles" class="active">Articles</a>
|
||||||
|
<a href="/admin/ingest/events">Events</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="stats-bar" id="statsBar">
|
<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">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 content</span><span class="value" id="s-content">—</span></div>
|
||||||
|
|
@ -13,14 +13,18 @@
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<h1>Duriin <span>Admin</span></h1>
|
<h1>Duriin <span>Admin</span></h1>
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<a href="/admin/articles">Articles</a>
|
<a href="/admin/ingest" class="active">Ingest</a>
|
||||||
<a href="/admin/events" class="active">Events</a>
|
|
||||||
<a href="/admin/stats">Stats</a>
|
|
||||||
<a href="/admin/intelligence">Intelligence</a>
|
<a href="/admin/intelligence">Intelligence</a>
|
||||||
|
<a href="/admin/stats">Stats</a>
|
||||||
<a href="/admin/sql">SQL</a>
|
<a href="/admin/sql">SQL</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<nav class="subnav">
|
||||||
|
<a href="/admin/ingest/articles">Articles</a>
|
||||||
|
<a href="/admin/ingest/events" class="active">Events</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="stats-bar" id="statsBar">
|
<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">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 content</span><span class="value" id="s-content">—</span></div>
|
||||||
|
|
@ -14,10 +14,9 @@
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<h1>Duriin <span>Admin</span></h1>
|
<h1>Duriin <span>Admin</span></h1>
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<a href="/admin/articles">Articles</a>
|
<a href="/admin/ingest">Ingest</a>
|
||||||
<a href="/admin/events">Events</a>
|
|
||||||
<a href="/admin/stats">Stats</a>
|
|
||||||
<a href="/admin/intelligence" class="active">Intelligence</a>
|
<a href="/admin/intelligence" class="active">Intelligence</a>
|
||||||
|
<a href="/admin/stats">Stats</a>
|
||||||
<a href="/admin/sql">SQL</a>
|
<a href="/admin/sql">SQL</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -73,7 +72,7 @@
|
||||||
|
|
||||||
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
|
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
<script src="/admin/assets/js/d3.min.js"></script>
|
||||||
<script src="/admin/assets/js/app.js"></script>
|
<script src="/admin/assets/js/app.js"></script>
|
||||||
<script src="/admin/assets/js/intel-shared.js"></script>
|
<script src="/admin/assets/js/intel-shared.js"></script>
|
||||||
<script src="/admin/assets/js/intel-graph.js"></script>
|
<script src="/admin/assets/js/intel-graph.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,9 @@
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<h1>Duriin <span>Admin</span></h1>
|
<h1>Duriin <span>Admin</span></h1>
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<a href="/admin/articles">Articles</a>
|
<a href="/admin/ingest">Ingest</a>
|
||||||
<a href="/admin/events">Events</a>
|
|
||||||
<a href="/admin/stats">Stats</a>
|
|
||||||
<a href="/admin/intelligence" class="active">Intelligence</a>
|
<a href="/admin/intelligence" class="active">Intelligence</a>
|
||||||
|
<a href="/admin/stats">Stats</a>
|
||||||
<a href="/admin/sql">SQL</a>
|
<a href="/admin/sql">SQL</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,9 @@
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<h1>Duriin <span>Admin</span></h1>
|
<h1>Duriin <span>Admin</span></h1>
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<a href="/admin/articles">Articles</a>
|
<a href="/admin/ingest">Ingest</a>
|
||||||
<a href="/admin/events">Events</a>
|
|
||||||
<a href="/admin/stats">Stats</a>
|
|
||||||
<a href="/admin/intelligence" class="active">Intelligence</a>
|
<a href="/admin/intelligence" class="active">Intelligence</a>
|
||||||
|
<a href="/admin/stats">Stats</a>
|
||||||
<a href="/admin/sql">SQL</a>
|
<a href="/admin/sql">SQL</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,9 @@
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<h1>Duriin <span>Admin</span></h1>
|
<h1>Duriin <span>Admin</span></h1>
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<a href="/admin/articles">Articles</a>
|
<a href="/admin/ingest">Ingest</a>
|
||||||
<a href="/admin/events">Events</a>
|
|
||||||
<a href="/admin/stats">Stats</a>
|
|
||||||
<a href="/admin/intelligence" class="active">Intelligence</a>
|
<a href="/admin/intelligence" class="active">Intelligence</a>
|
||||||
|
<a href="/admin/stats">Stats</a>
|
||||||
<a href="/admin/sql">SQL</a>
|
<a href="/admin/sql">SQL</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,9 @@
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<h1>Duriin <span>Admin</span></h1>
|
<h1>Duriin <span>Admin</span></h1>
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<a href="/admin/articles">Articles</a>
|
<a href="/admin/ingest">Ingest</a>
|
||||||
<a href="/admin/events">Events</a>
|
|
||||||
<a href="/admin/stats">Stats</a>
|
|
||||||
<a href="/admin/intelligence">Intelligence</a>
|
<a href="/admin/intelligence">Intelligence</a>
|
||||||
|
<a href="/admin/stats">Stats</a>
|
||||||
<a href="/admin/sql" class="active">SQL</a>
|
<a href="/admin/sql" class="active">SQL</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,9 @@
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<h1>Duriin <span>Admin</span></h1>
|
<h1>Duriin <span>Admin</span></h1>
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<a href="/admin/articles">Articles</a>
|
<a href="/admin/ingest">Ingest</a>
|
||||||
<a href="/admin/events">Events</a>
|
|
||||||
<a href="/admin/stats" class="active">Stats</a>
|
|
||||||
<a href="/admin/intelligence">Intelligence</a>
|
<a href="/admin/intelligence">Intelligence</a>
|
||||||
|
<a href="/admin/stats" class="active">Stats</a>
|
||||||
<a href="/admin/sql">SQL</a>
|
<a href="/admin/sql">SQL</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,8 @@ const pagesDir = path.join(publicDir, 'pages');
|
||||||
// map pretty url → page html file. keep these close to the routes so its
|
// map pretty url → page html file. keep these close to the routes so its
|
||||||
// obvious when a page gets added or renamed.
|
// obvious when a page gets added or renamed.
|
||||||
const pageMap = {
|
const pageMap = {
|
||||||
'/admin/articles': path.join(pagesDir, 'articles.html'),
|
'/admin/ingest/articles': path.join(pagesDir, 'ingest', 'articles.html'),
|
||||||
'/admin/events': path.join(pagesDir, 'events.html'),
|
'/admin/ingest/events': path.join(pagesDir, 'ingest', 'events.html'),
|
||||||
'/admin/stats': path.join(pagesDir, 'stats.html'),
|
'/admin/stats': path.join(pagesDir, 'stats.html'),
|
||||||
'/admin/sql': path.join(pagesDir, 'sql.html'),
|
'/admin/sql': path.join(pagesDir, 'sql.html'),
|
||||||
'/admin/intelligence/knowledge': path.join(pagesDir, 'intelligence', 'knowledge.html'),
|
'/admin/intelligence/knowledge': path.join(pagesDir, 'intelligence', 'knowledge.html'),
|
||||||
|
|
@ -78,19 +78,25 @@ async function adminRoutes(fastify) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// static assets (css + js) under /admin/assets/*
|
// static assets (css + js) under /admin/assets/*
|
||||||
// use must-revalidate so the browser always checks for a newer copy;
|
// cache for an hour — avoids per-navigation revalidation round-trips
|
||||||
// prevents the admin getting stuck on old js after a deploy.
|
// for the admin panel. during dev use a hard-reload (cmd-shift-r)
|
||||||
|
// or bump the script src query string to bust it.
|
||||||
fastify.register(fastifyStatic, {
|
fastify.register(fastifyStatic, {
|
||||||
root: assetsDir,
|
root: assetsDir,
|
||||||
prefix: '/admin/assets/',
|
prefix: '/admin/assets/',
|
||||||
decorateReply: false,
|
decorateReply: false,
|
||||||
cacheControl: true,
|
cacheControl: true,
|
||||||
maxAge: 0,
|
maxAge: 3600 * 1000, // 1h, in ms (fastify-static forwards to send())
|
||||||
});
|
});
|
||||||
|
|
||||||
// top-level entry — redirect to articles
|
// top-level entry — redirect into ingest/articles
|
||||||
fastify.get('/admin', async (request, reply) => {
|
fastify.get('/admin', async (request, reply) => {
|
||||||
reply.redirect('/admin/articles');
|
reply.redirect('/admin/ingest/articles');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ingest root — redirect to the articles subsection
|
||||||
|
fastify.get('/admin/ingest', async (request, reply) => {
|
||||||
|
reply.redirect('/admin/ingest/articles');
|
||||||
});
|
});
|
||||||
|
|
||||||
// intelligence root — redirect to the knowledge subsection
|
// intelligence root — redirect to the knowledge subsection
|
||||||
|
|
@ -98,6 +104,10 @@ async function adminRoutes(fastify) {
|
||||||
reply.redirect('/admin/intelligence/knowledge');
|
reply.redirect('/admin/intelligence/knowledge');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// backward-compat redirects from the pre-merge urls
|
||||||
|
fastify.get('/admin/articles', async (request, reply) => reply.redirect('/admin/ingest/articles'));
|
||||||
|
fastify.get('/admin/events', async (request, reply) => reply.redirect('/admin/ingest/events'));
|
||||||
|
|
||||||
// wire up each pretty page path
|
// wire up each pretty page path
|
||||||
for (const [route, filePath] of Object.entries(pageMap)) {
|
for (const [route, filePath] of Object.entries(pageMap)) {
|
||||||
fastify.get(route, async (request, reply) => sendPage(reply, filePath));
|
fastify.get(route, async (request, reply) => sendPage(reply, filePath));
|
||||||
|
|
@ -478,6 +488,23 @@ async function adminRoutes(fastify) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pick the most recent event_date out of the predictions that fed each signal
|
||||||
|
const latestEventDateStmt = db.prepare(`
|
||||||
|
SELECT MAX(event_date) as latest
|
||||||
|
FROM event_predictions
|
||||||
|
WHERE id IN (SELECT value FROM json_each(?))
|
||||||
|
AND event_date IS NOT NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
let latest = null;
|
||||||
|
try {
|
||||||
|
const res = latestEventDateStmt.get(row.supporting_prediction_ids || "[]");
|
||||||
|
latest = res?.latest || null;
|
||||||
|
} catch (_) {}
|
||||||
|
row.latest_event_date = latest;
|
||||||
|
}
|
||||||
|
|
||||||
return rows;
|
return rows;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -491,6 +518,70 @@ async function adminRoutes(fastify) {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// references behind a signal — walks predictions → events → articles
|
||||||
|
// so the frontend can show the actual sources that fed the signal
|
||||||
|
fastify.get('/admin/api/intelligence/signals/:company_id/references', async (request, reply) => {
|
||||||
|
if (!checkAuth(request, reply)) return;
|
||||||
|
const idb = getIntelligenceDb();
|
||||||
|
if (!idb) return { events: [] };
|
||||||
|
|
||||||
|
const companyId = parseInt(request.params.company_id, 10);
|
||||||
|
|
||||||
|
const signal = idb.prepare(`
|
||||||
|
SELECT supporting_prediction_ids FROM trade_signals WHERE company_id = ?
|
||||||
|
`).get(companyId);
|
||||||
|
|
||||||
|
if (!signal) return { events: [] };
|
||||||
|
|
||||||
|
let predIds = [];
|
||||||
|
try { predIds = JSON.parse(signal.supporting_prediction_ids || "[]"); } catch (_) {}
|
||||||
|
if (!predIds.length) return { events: [] };
|
||||||
|
|
||||||
|
const placeholders = predIds.map(() => '?').join(',');
|
||||||
|
|
||||||
|
// pull the event_ids (and keep the newest event_date per event) from the
|
||||||
|
// predictions that fed the signal
|
||||||
|
const predRows = idb.prepare(`
|
||||||
|
SELECT event_id, MAX(event_date) as event_date
|
||||||
|
FROM event_predictions
|
||||||
|
WHERE id IN (${placeholders})
|
||||||
|
GROUP BY event_id
|
||||||
|
`).all(...predIds);
|
||||||
|
|
||||||
|
const eventMeta = new Map();
|
||||||
|
for (const p of predRows) {
|
||||||
|
if (p.event_id != null) eventMeta.set(p.event_id, p.event_date || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventMeta.size === 0) return { events: [] };
|
||||||
|
|
||||||
|
const eventIds = [...eventMeta.keys()];
|
||||||
|
const eventPh = eventIds.map(() => '?').join(',');
|
||||||
|
|
||||||
|
const eventRows = db.prepare(`
|
||||||
|
SELECT id, title, created_at FROM events
|
||||||
|
WHERE id IN (${eventPh})
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
`).all(...eventIds);
|
||||||
|
|
||||||
|
const artStmt = db.prepare(`
|
||||||
|
SELECT id, title, source, pub_date, url
|
||||||
|
FROM articles
|
||||||
|
WHERE event_id = ?
|
||||||
|
ORDER BY COALESCE(pub_date, ingested_at) DESC
|
||||||
|
LIMIT 5
|
||||||
|
`);
|
||||||
|
|
||||||
|
const events = eventRows.map(ev => ({
|
||||||
|
...ev,
|
||||||
|
event_date: eventMeta.get(ev.id) || null,
|
||||||
|
articles: artStmt.all(ev.id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { events };
|
||||||
|
});
|
||||||
|
|
||||||
// per-company facts for the graph sidebar
|
// per-company facts for the graph sidebar
|
||||||
fastify.get('/admin/api/intelligence/facts/:company_id', async (request, reply) => {
|
fastify.get('/admin/api/intelligence/facts/:company_id', async (request, reply) => {
|
||||||
if (!checkAuth(request, reply)) return;
|
if (!checkAuth(request, reply)) return;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue