perf: make admin navigation responsive

This commit is contained in:
ImBenji
2026-08-04 21:36:39 +01:00
parent 5680ee8a44
commit bd162606b6
15 changed files with 204 additions and 80 deletions
+34
View File
@@ -68,6 +68,40 @@ tbody tr:hover { background: rgba(255,255,255,.022); }
.empty-state { padding: 42px 24px; color: var(--muted-dark); text-align: center; }
.skeleton-row:hover { background: transparent; }
.skeleton-row td { height: 205px; padding: 22px 18px; vertical-align: top; }
.skeleton-row td span {
width: min(680px, 82%);
height: 11px;
margin-bottom: 20px;
display: block;
border-radius: 2px;
background: linear-gradient(90deg, #171b17 20%, #252b24 42%, #171b17 64%);
background-size: 300% 100%;
animation: skeleton-wave 1.6s ease infinite;
}
.skeleton-row td span:nth-child(2) { width: 58%; }
.skeleton-row td span:nth-child(3) { width: 72%; }
.skeleton-row td span:nth-child(4) { width: 45%; }
.content-loading { min-height: 160px; position: relative; }
.content-loading::after { content: "Loading"; position: absolute; inset: 0; display: grid; place-items: center; color: var(--muted-dark); font-family: var(--mono); font-size: 9px; letter-spacing: .1em; text-transform: uppercase; }
@keyframes skeleton-wave { from { background-position: 100% 0; } to { background-position: 0 0; } }
html::after {
content: "";
position: fixed;
z-index: 500;
top: 0;
left: var(--rail);
width: 0;
height: 2px;
opacity: 0;
background: var(--accent);
box-shadow: 0 0 12px rgba(223,255,79,.45);
transition: width 220ms ease, opacity 120ms;
}
html.navigating::after { width: calc(100% - var(--rail)); opacity: 1; }
@media (max-width: 640px) {
.table-wrap { border-radius: 0; margin-inline: -16px; border-left: 0; border-right: 0; }
th, td { padding-inline: 11px; }
+49 -2
View File
@@ -148,7 +148,7 @@ async function loadGlobalStats() {
if (!bar) return;
try {
const data = await api("/admin/api/stats");
const data = await api("/admin/api/stats/summary");
const t = document.getElementById("s-total");
if (t) t.textContent = data.total.toLocaleString();
const c = document.getElementById("s-content");
@@ -161,6 +161,49 @@ async function loadGlobalStats() {
}
function installLoadingStates() {
document.querySelectorAll("tbody:empty").forEach(tbody => {
const columns = Math.max(1, tbody.closest("table")?.querySelectorAll("thead th").length || 5);
tbody.setAttribute("aria-busy", "true");
tbody.innerHTML = `<tr class="skeleton-row"><td colspan="${columns}"><span></span><span></span><span></span><span></span></td></tr>`;
const observer = new MutationObserver(() => {
if (!tbody.querySelector(".skeleton-row")) {
tbody.removeAttribute("aria-busy");
observer.disconnect();
}
});
observer.observe(tbody, { childList: true });
});
["intel-signals-grid", "sourceTable", "statusTable"].forEach(id => {
const node = document.getElementById(id);
if (node && !node.children.length) node.classList.add("content-loading");
});
}
function installNavigationPrefetch() {
const seen = new Set();
const prefetch = link => {
const href = link?.href;
if (!href || seen.has(href) || link.origin !== location.origin) return;
seen.add(href);
const hint = document.createElement("link");
hint.rel = "prefetch";
hint.href = href;
hint.as = "document";
document.head.appendChild(hint);
};
document.querySelectorAll(".tabs a, .subnav a").forEach(link => {
link.addEventListener("pointerenter", () => prefetch(link), { once: true });
link.addEventListener("focus", () => prefetch(link), { once: true });
link.addEventListener("click", () => document.documentElement.classList.add("navigating"));
});
const warm = () => document.querySelectorAll(".tabs a, .subnav a").forEach(prefetch);
if ("requestIdleCallback" in window) requestIdleCallback(warm, { timeout: 2500 });
else setTimeout(warm, 1200);
}
// common overlay close-on-backdrop wiring
function wireOverlays() {
document.querySelectorAll(".overlay").forEach(ov => {
@@ -173,6 +216,10 @@ function wireOverlays() {
document.addEventListener("DOMContentLoaded", () => {
installChrome();
installLoadingStates();
installNavigationPrefetch();
wireOverlays();
loadGlobalStats();
const loadStats = () => loadGlobalStats();
if ("requestIdleCallback" in window) requestIdleCallback(loadStats, { timeout: 1800 });
else setTimeout(loadStats, 500);
});
+1
View File
@@ -13,6 +13,7 @@ const signalsById = new Map();
function renderSignals(data) {
const grid = document.getElementById("intel-signals-grid");
const empty = document.getElementById("intel-signals-empty");
grid.classList.remove("content-loading");
if (!data || data.length === 0) {
grid.innerHTML = "";
+3
View File
@@ -4,6 +4,9 @@
async function loadStatsPage() {
const data = await api("/admin/api/stats");
document.getElementById("sourceTable").classList.remove("content-loading");
document.getElementById("statusTable").classList.remove("content-loading");
document.getElementById("sourceTable").innerHTML = data.bySource
.map(r => `<tr><td>${escapeHtml(r.source)}</td><td style="text-align:right; padding-left:24px">${r.n.toLocaleString()}</td></tr>`).join("");
+6 -6
View File
@@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#090b0a">
<title>Duriin — Autonomy</title>
<link rel="stylesheet" href="/admin/assets/css/base.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/layout.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/components.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/autonomy.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/base.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/layout.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/components.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/autonomy.css?v=20260804-2">
</head>
<body class="page-autonomy">
@@ -131,7 +131,7 @@
</main>
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
<script src="/admin/assets/js/app.js?v=20260804-1"></script>
<script src="/admin/assets/js/autonomy.js?v=20260804-1"></script>
<script src="/admin/assets/js/app.js?v=20260804-2"></script>
<script src="/admin/assets/js/autonomy.js?v=20260804-2"></script>
</body>
</html>
+5 -5
View File
@@ -4,9 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Duriin Admin — Articles</title>
<link rel="stylesheet" href="/admin/assets/css/base.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/layout.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/components.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/base.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/layout.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/components.css?v=20260804-2">
</head>
<body>
@@ -134,7 +134,7 @@
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
<script src="/admin/assets/js/app.js?v=20260804-1"></script>
<script src="/admin/assets/js/articles.js?v=20260804-1"></script>
<script src="/admin/assets/js/app.js?v=20260804-2"></script>
<script src="/admin/assets/js/articles.js?v=20260804-2"></script>
</body>
</html>
+5 -5
View File
@@ -4,9 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Duriin Admin — Events</title>
<link rel="stylesheet" href="/admin/assets/css/base.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/layout.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/components.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/base.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/layout.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/components.css?v=20260804-2">
</head>
<body>
@@ -94,7 +94,7 @@
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
<script src="/admin/assets/js/app.js?v=20260804-1"></script>
<script src="/admin/assets/js/events.js?v=20260804-1"></script>
<script src="/admin/assets/js/app.js?v=20260804-2"></script>
<script src="/admin/assets/js/events.js?v=20260804-2"></script>
</body>
</html>
+8 -8
View File
@@ -4,10 +4,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Duriin Admin — Intelligence / Graph</title>
<link rel="stylesheet" href="/admin/assets/css/base.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/layout.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/components.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/intel.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/base.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/layout.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/components.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/intel.css?v=20260804-2">
</head>
<body class="page-graph">
@@ -72,9 +72,9 @@
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
<script src="/admin/assets/js/d3.min.js?v=20260804-1"></script>
<script src="/admin/assets/js/app.js?v=20260804-1"></script>
<script src="/admin/assets/js/intel-shared.js?v=20260804-1"></script>
<script src="/admin/assets/js/intel-graph.js?v=20260804-1"></script>
<script src="/admin/assets/js/d3.min.js?v=20260804-2"></script>
<script src="/admin/assets/js/app.js?v=20260804-2"></script>
<script src="/admin/assets/js/intel-shared.js?v=20260804-2"></script>
<script src="/admin/assets/js/intel-graph.js?v=20260804-2"></script>
</body>
</html>
@@ -4,10 +4,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Duriin Admin — Intelligence / Knowledge</title>
<link rel="stylesheet" href="/admin/assets/css/base.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/layout.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/components.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/intel.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/base.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/layout.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/components.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/intel.css?v=20260804-2">
</head>
<body>
@@ -90,8 +90,8 @@
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
<script src="/admin/assets/js/app.js?v=20260804-1"></script>
<script src="/admin/assets/js/intel-shared.js?v=20260804-1"></script>
<script src="/admin/assets/js/intel-knowledge.js?v=20260804-1"></script>
<script src="/admin/assets/js/app.js?v=20260804-2"></script>
<script src="/admin/assets/js/intel-shared.js?v=20260804-2"></script>
<script src="/admin/assets/js/intel-knowledge.js?v=20260804-2"></script>
</body>
</html>
@@ -4,10 +4,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Duriin Admin — Intelligence / Predictions</title>
<link rel="stylesheet" href="/admin/assets/css/base.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/layout.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/components.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/intel.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/base.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/layout.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/components.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/intel.css?v=20260804-2">
</head>
<body>
@@ -81,8 +81,8 @@
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
<script src="/admin/assets/js/app.js?v=20260804-1"></script>
<script src="/admin/assets/js/intel-shared.js?v=20260804-1"></script>
<script src="/admin/assets/js/intel-predictions.js?v=20260804-1"></script>
<script src="/admin/assets/js/app.js?v=20260804-2"></script>
<script src="/admin/assets/js/intel-shared.js?v=20260804-2"></script>
<script src="/admin/assets/js/intel-predictions.js?v=20260804-2"></script>
</body>
</html>
+7 -7
View File
@@ -4,10 +4,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Duriin Admin — Intelligence / Signals</title>
<link rel="stylesheet" href="/admin/assets/css/base.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/layout.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/components.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/intel.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/base.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/layout.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/components.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/intel.css?v=20260804-2">
</head>
<body>
@@ -137,8 +137,8 @@
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
<script src="/admin/assets/js/app.js?v=20260804-1"></script>
<script src="/admin/assets/js/intel-shared.js?v=20260804-1"></script>
<script src="/admin/assets/js/intel-signals.js?v=20260804-1"></script>
<script src="/admin/assets/js/app.js?v=20260804-2"></script>
<script src="/admin/assets/js/intel-shared.js?v=20260804-2"></script>
<script src="/admin/assets/js/intel-signals.js?v=20260804-2"></script>
</body>
</html>
+5 -5
View File
@@ -4,9 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Duriin Admin — SQL</title>
<link rel="stylesheet" href="/admin/assets/css/base.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/layout.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/components.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/base.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/layout.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/components.css?v=20260804-2">
</head>
<body>
@@ -45,7 +45,7 @@
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
<script src="/admin/assets/js/app.js?v=20260804-1"></script>
<script src="/admin/assets/js/sql.js?v=20260804-1"></script>
<script src="/admin/assets/js/app.js?v=20260804-2"></script>
<script src="/admin/assets/js/sql.js?v=20260804-2"></script>
</body>
</html>
+6 -6
View File
@@ -4,10 +4,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Duriin Admin — Stats</title>
<link rel="stylesheet" href="/admin/assets/css/base.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/layout.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/components.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/intel.css?v=20260804-1">
<link rel="stylesheet" href="/admin/assets/css/base.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/layout.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/components.css?v=20260804-2">
<link rel="stylesheet" href="/admin/assets/css/intel.css?v=20260804-2">
</head>
<body>
@@ -74,7 +74,7 @@
<div id="toast"><span class="toast-dot"></span><span id="toast-msg"></span></div>
<script src="/admin/assets/js/app.js?v=20260804-1"></script>
<script src="/admin/assets/js/stats.js?v=20260804-1"></script>
<script src="/admin/assets/js/app.js?v=20260804-2"></script>
<script src="/admin/assets/js/stats.js?v=20260804-2"></script>
</body>
</html>
+10
View File
@@ -47,7 +47,12 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_articles_normalized_title ON articles(normalized_title);
CREATE INDEX IF NOT EXISTS idx_articles_event_id ON articles(event_id);
CREATE INDEX IF NOT EXISTS idx_articles_has_embedding ON articles(has_embedding);
CREATE INDEX IF NOT EXISTS idx_articles_content_status ON articles(content_status);
CREATE INDEX IF NOT EXISTS idx_articles_content_attempted_at ON articles(content_attempted_at);
CREATE INDEX IF NOT EXISTS idx_articles_pub_date_effective ON articles(pub_date_effective DESC);
CREATE INDEX IF NOT EXISTS idx_articles_has_content
ON articles(id)
WHERE content IS NOT NULL AND content != '';
CREATE INDEX IF NOT EXISTS idx_articles_usable_pub_date
ON articles(pub_date_effective DESC, id DESC)
WHERE content IS NOT NULL
@@ -74,6 +79,11 @@ db.exec(`
);
`);
db.exec(`
CREATE INDEX IF NOT EXISTS idx_article_embedding_meta_embedded_at
ON article_embedding_meta(embedded_at);
`);
db.exec(`
CREATE VIRTUAL TABLE IF NOT EXISTS article_embeddings USING vec0(
article_id INTEGER PRIMARY KEY,
+51 -22
View File
@@ -6,6 +6,8 @@ const config = require('../config');
const Database = require('better-sqlite3');
let idb = null;
let statsSummaryCache = null;
let statsDetailCache = null;
function getIntelligenceDb() {
if (idb) return idb;
@@ -372,28 +374,22 @@ async function adminRoutes(fastify) {
const orderBy = sortMap[q.sort] || sortMap.created_desc;
const whereClause = where.length ? `WHERE ${where.join(' AND ')}` : '';
const havingClause = having.length ? `HAVING ${having.join(' AND ')}` : '';
// total count has to respect the HAVING clause too, so wrap the grouped query
const totalRow = db.prepare(`
SELECT COUNT(*) as n FROM (
SELECT e.id, COUNT(a.id) as article_count
FROM events e
LEFT JOIN articles a ON a.event_id = e.id
${whereClause}
GROUP BY e.id
${havingClause}
)
`).get(...whereParams, ...havingParams);
const countWhereClause = having.length ? `WHERE ${having.join(' AND ')}` : '';
// Avoid grouping the entire article archive for every page visit. The
// correlated count uses idx_articles_event_id and touches only the events
// that survive filtering/pagination.
const eventProjection = `
SELECT e.id, e.title, e.created_at,
(SELECT COUNT(*) FROM articles a WHERE a.event_id = e.id) AS article_count
FROM events e ${whereClause}
`;
const filteredEvents = `SELECT * FROM (${eventProjection}) ${countWhereClause}`;
const totalRow = db.prepare(`SELECT COUNT(*) AS n FROM (${filteredEvents})`)
.get(...whereParams, ...havingParams);
const rows = db.prepare(`
SELECT e.id, e.title, e.created_at, COUNT(a.id) as article_count
FROM events e
LEFT JOIN articles a ON a.event_id = e.id
${whereClause}
GROUP BY e.id
${havingClause}
ORDER BY ${orderBy}
${filteredEvents}
ORDER BY ${orderBy.replaceAll('e.', '')}
LIMIT ? OFFSET ?
`).all(...whereParams, ...havingParams, limit, offset);
@@ -838,9 +834,31 @@ async function adminRoutes(fastify) {
return { results, elapsed: Date.now() - start };
});
// stats for dashboard header
// Lightweight header summary, cached independently from the detailed stats
// page so navigation never waits for source/status aggregation.
fastify.get('/admin/api/stats/summary', async (request, reply) => {
if (!checkAuth(request, reply)) return;
if (statsSummaryCache && Date.now() - statsSummaryCache.at < 60_000) {
return statsSummaryCache.value;
}
const counts = db.prepare(`
SELECT
(SELECT COUNT(*) FROM articles) AS total,
(SELECT COUNT(*) FROM articles WHERE content IS NOT NULL AND content != '') AS withContent,
(SELECT COUNT(*) FROM articles WHERE has_embedding = 1) AS withEmbedding,
(SELECT COUNT(*) FROM events) AS eventCount
`).get();
statsSummaryCache = { at: Date.now(), value: counts };
return counts;
});
// Detailed statistics are cached because they summarize the full archive and
// do not need second-by-second precision.
fastify.get('/admin/api/stats', async (request, reply) => {
if (!checkAuth(request, reply)) return;
if (statsDetailCache && Date.now() - statsDetailCache.at < 5 * 60_000) {
return statsDetailCache.value;
}
const counts = db.prepare(`
SELECT
@@ -868,7 +886,18 @@ async function adminRoutes(fastify) {
`).get().n;
} catch (_) {}
return { ...counts, bySource, byStatus, embeddingsPerHour };
const value = { ...counts, bySource, byStatus, embeddingsPerHour };
statsDetailCache = { at: Date.now(), value };
statsSummaryCache = {
at: Date.now(),
value: {
total: counts.total,
withContent: counts.withContent,
withEmbedding: counts.withEmbedding,
eventCount: counts.eventCount,
},
};
return value;
});
}