226 lines
8.0 KiB
JavaScript
226 lines
8.0 KiB
JavaScript
// shared helpers + global chrome (stats bar) used by every admin page.
|
|
// each page-specific script depends on this loading first.
|
|
|
|
const PAGE = 50;
|
|
|
|
|
|
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");
|
|
if (!el) return;
|
|
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>`;
|
|
}
|
|
|
|
|
|
function escapeHtml(s) {
|
|
return String(s ?? "").replace(/[&<>"']/g, c => ({
|
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
|
}[c]));
|
|
}
|
|
|
|
|
|
function formatNumber(value, compact = false) {
|
|
const number = Number(value || 0);
|
|
return new Intl.NumberFormat("en-GB", compact ? { notation: "compact", maximumFractionDigits: 1 } : {}).format(number);
|
|
}
|
|
|
|
|
|
function formatPercent(value, digits = 1) {
|
|
if (value == null || !Number.isFinite(Number(value))) return "—";
|
|
return `${(Number(value) * 100).toFixed(digits)}%`;
|
|
}
|
|
|
|
|
|
function formatRelative(value) {
|
|
if (!value) return "—";
|
|
const timestamp = new Date(value.endsWith && value.endsWith("Z") ? value : `${value}Z`).getTime();
|
|
if (!Number.isFinite(timestamp)) return value;
|
|
const seconds = Math.round((timestamp - Date.now()) / 1000);
|
|
const units = [[86400, "day"], [3600, "hour"], [60, "minute"]];
|
|
const formatter = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
|
for (const [size, name] of units) {
|
|
if (Math.abs(seconds) >= size) return formatter.format(Math.round(seconds / size), name);
|
|
}
|
|
return "just now";
|
|
}
|
|
|
|
|
|
function installChrome() {
|
|
const header = document.querySelector("header.app-header");
|
|
if (!header) return;
|
|
const title = header.querySelector("h1");
|
|
if (title) title.innerHTML = `<span class="brand-mark">D</span><span class="brand-copy">Duriin<span>Autonomous intelligence</span></span>`;
|
|
|
|
const nav = header.querySelector(".tabs");
|
|
if (nav && !nav.querySelector('[href="/admin/autonomy"]')) {
|
|
nav.insertAdjacentHTML("afterbegin", '<a href="/admin/autonomy">Autonomy</a>');
|
|
}
|
|
if (nav) {
|
|
const path = location.pathname;
|
|
nav.querySelectorAll("a").forEach(link => {
|
|
const href = link.getAttribute("href");
|
|
const active = href === "/admin/autonomy"
|
|
? path === href
|
|
: href === "/admin/ingest"
|
|
? path.startsWith("/admin/ingest")
|
|
: href === "/admin/intelligence"
|
|
? path.startsWith("/admin/intelligence")
|
|
: path === href;
|
|
link.classList.toggle("active", active);
|
|
});
|
|
}
|
|
|
|
const status = document.createElement("div");
|
|
status.className = "rail-status";
|
|
status.innerHTML = `<div class="rail-status-label"><span class="rail-status-dot"></span><span id="rail-mode">Connecting</span></div><div class="rail-status-meta" id="rail-meta">Runtime status</div>`;
|
|
header.appendChild(status);
|
|
api("/admin/api/autonomy/overview").then(data => {
|
|
const mode = String(data.mode || "offline").toUpperCase();
|
|
document.getElementById("rail-mode").textContent = data.enabled ? `${mode} runtime` : "Runtime unavailable";
|
|
document.getElementById("rail-meta").textContent = data.broker?.configured ? `${data.broker.name} · connected` : "Broker not configured";
|
|
document.querySelector(".rail-status-dot")?.classList.toggle("live", data.enabled);
|
|
}).catch(() => {
|
|
document.getElementById("rail-mode").textContent = "Runtime unavailable";
|
|
});
|
|
}
|
|
|
|
|
|
// ── url query-param helpers ────────────────────────────────────────────────
|
|
//
|
|
// filters and sort state live in the url so reloads and shared links keep
|
|
// their shape. queryGet reads a single param, queryAll returns them all,
|
|
// queryWrite replaces the query string with the cleaned-up params (empty
|
|
// values removed). we use replaceState so each filter change doesnt spam
|
|
// history.
|
|
|
|
function queryGet(key, fallback = "") {
|
|
const v = new URLSearchParams(location.search).get(key);
|
|
return v == null ? fallback : v;
|
|
}
|
|
|
|
|
|
function queryAll() {
|
|
return Object.fromEntries(new URLSearchParams(location.search));
|
|
}
|
|
|
|
|
|
function queryWrite(params) {
|
|
const clean = {};
|
|
for (const [k, v] of Object.entries(params)) {
|
|
if (v === "" || v == null) continue;
|
|
clean[k] = v;
|
|
}
|
|
const qs = new URLSearchParams(clean).toString();
|
|
const next = location.pathname + (qs ? "?" + qs : "");
|
|
history.replaceState(null, "", next);
|
|
}
|
|
|
|
|
|
// apply current query params onto form inputs — call on page init
|
|
function queryApplyToInputs(bindings) {
|
|
for (const [id, key] of Object.entries(bindings)) {
|
|
const el = document.getElementById(id);
|
|
if (!el) continue;
|
|
const v = queryGet(key, "");
|
|
if (v !== "") el.value = v;
|
|
}
|
|
}
|
|
|
|
|
|
// global stats bar — small counters shown on articles/events/stats pages
|
|
async function loadGlobalStats() {
|
|
const bar = document.getElementById("statsBar");
|
|
if (!bar) return;
|
|
|
|
try {
|
|
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");
|
|
if (c) c.textContent = data.withContent.toLocaleString();
|
|
const em = document.getElementById("s-embed");
|
|
if (em) em.textContent = data.withEmbedding.toLocaleString();
|
|
const ev = document.getElementById("s-events");
|
|
if (ev) ev.textContent = data.eventCount.toLocaleString();
|
|
} catch (_) { /* ignore — stats bar is best-effort */ }
|
|
}
|
|
|
|
|
|
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 => {
|
|
ov.addEventListener("click", e => {
|
|
if (e.target === ov) ov.classList.remove("open");
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
installChrome();
|
|
installLoadingStates();
|
|
installNavigationPrefetch();
|
|
wireOverlays();
|
|
const loadStats = () => loadGlobalStats();
|
|
if ("requestIdleCallback" in window) requestIdleCallback(loadStats, { timeout: 1800 });
|
|
else setTimeout(loadStats, 500);
|
|
});
|