Add `ingester.api.root_path` so the HTTP control plane (dashboard + API)
can be reverse-proxied behind a sub-path (e.g. /ingester/) on a shared
origin, instead of needing nginx sub_filter URL-rewriting.
- APIConfig.root_path: normalized ('', or single leading slash, no trailing
slash) via a field_validator; validate_assignment so CLI overrides
normalize the same way as config-file values.
- Forwarded to FastAPI(root_path=) and uvicorn.Config(root_path=) so
OpenAPI/docs links are prefix-aware.
- Dashboard route injects a <base href> matching root_path; all dashboard
fetches are now base-relative, so they resolve under the prefix while
staying identical at the root.
- `serve --root-path` CLI flag.
- Docs: "Behind a reverse proxy" section with an nginx example.
Closes #431
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
842 lines
31 KiB
HTML
842 lines
31 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>haiku-ingester · status</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0f1419;
|
|
--panel: #1a2027;
|
|
--border: #2a323d;
|
|
--text: #d9e1ea;
|
|
--muted: #7e8a99;
|
|
--accent: #6db4ff;
|
|
--queued: #4f86c6;
|
|
--claimed: #d4a247;
|
|
--succeeded: #4caf7c;
|
|
--dead: #cc5454;
|
|
--warn: #d4a247;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI",
|
|
Roboto, Helvetica, Arial, sans-serif;
|
|
font-size: 14px;
|
|
line-height: 1.45;
|
|
}
|
|
a { color: var(--accent); text-decoration: none; }
|
|
a:hover { text-decoration: underline; }
|
|
.container { max-width: 1400px; margin: 0 auto; padding: 16px; }
|
|
|
|
.topbar {
|
|
display: flex; align-items: center; gap: 16px;
|
|
padding: 12px 16px; background: var(--panel);
|
|
border: 1px solid var(--border); border-radius: 8px; margin-bottom: 16px;
|
|
}
|
|
.topbar h1 {
|
|
margin: 0; font-size: 16px; font-weight: 600; letter-spacing: 0.02em;
|
|
}
|
|
.topbar .dot {
|
|
width: 8px; height: 8px; border-radius: 50%; background: var(--succeeded);
|
|
}
|
|
.topbar .dot.stale { background: var(--dead); }
|
|
.topbar .spacer { flex: 1; }
|
|
.topbar .meta { color: var(--muted); font-size: 12px; }
|
|
.topbar button {
|
|
background: var(--bg); color: var(--text); border: 1px solid var(--border);
|
|
padding: 6px 12px; border-radius: 4px; font: inherit; cursor: pointer;
|
|
}
|
|
.topbar button:hover { background: var(--border); }
|
|
|
|
.chips { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px; }
|
|
.chip {
|
|
background: var(--panel); border: 1px solid var(--border);
|
|
border-radius: 8px; padding: 12px 16px;
|
|
}
|
|
.chip .label {
|
|
text-transform: uppercase; font-size: 11px; letter-spacing: 0.08em;
|
|
color: var(--muted);
|
|
}
|
|
.chip .value { font-size: 28px; font-weight: 600; margin-top: 4px; }
|
|
.chip.queued .value { color: var(--queued); }
|
|
.chip.claimed .value { color: var(--claimed); }
|
|
.chip.succeeded .value { color: var(--succeeded); }
|
|
.chip.dead .value { color: var(--dead); }
|
|
|
|
.panels { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
.panel {
|
|
background: var(--panel); border: 1px solid var(--border);
|
|
border-radius: 8px; padding: 16px;
|
|
}
|
|
.panel.full { grid-column: 1 / -1; }
|
|
.panel h2 {
|
|
margin: 0 0 12px 0; font-size: 13px; font-weight: 600;
|
|
letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted);
|
|
}
|
|
.panel h2 .count { color: var(--text); margin-left: 8px; }
|
|
.empty { color: var(--muted); font-style: italic; padding: 12px 4px; }
|
|
|
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
th, td {
|
|
text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border);
|
|
}
|
|
th { font-weight: 600; color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
|
|
tr:last-child td { border-bottom: none; }
|
|
tr:hover td { background: rgba(255, 255, 255, 0.02); }
|
|
td.id, td.worker { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--muted); }
|
|
td.retried { color: var(--warn); font-weight: 600; cursor: help; }
|
|
td.uri {
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
max-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
font-size: 12px;
|
|
}
|
|
td.err {
|
|
color: var(--dead); font-size: 12px; max-width: 0;
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
td.ok {
|
|
color: var(--succeeded); font-size: 12px; max-width: 0;
|
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
td.actions { width: 1%; white-space: nowrap; }
|
|
td.actions button {
|
|
background: var(--bg); color: var(--text); border: 1px solid var(--border);
|
|
padding: 3px 8px; border-radius: 3px; cursor: pointer; font: inherit;
|
|
font-size: 11px; margin-left: 4px;
|
|
}
|
|
td.actions button:hover { background: var(--border); }
|
|
|
|
.badge {
|
|
display: inline-block; padding: 2px 6px; border-radius: 3px;
|
|
font-size: 11px; font-weight: 500;
|
|
}
|
|
.badge.ok { background: rgba(76, 175, 124, 0.15); color: var(--succeeded); }
|
|
.badge.warn { background: rgba(212, 162, 71, 0.15); color: var(--warn); }
|
|
.badge.bad { background: rgba(204, 84, 84, 0.15); color: var(--dead); }
|
|
.badge.upsert { background: rgba(79, 134, 198, 0.15); color: var(--queued); }
|
|
.badge.delete { background: rgba(204, 84, 84, 0.15); color: var(--dead); }
|
|
|
|
.stats-grid {
|
|
display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px;
|
|
}
|
|
.stat {
|
|
background: var(--bg); border: 1px solid var(--border);
|
|
border-radius: 6px; padding: 10px 12px;
|
|
}
|
|
.stat .label {
|
|
font-size: 11px; color: var(--muted); text-transform: uppercase;
|
|
letter-spacing: 0.04em;
|
|
}
|
|
.stat .value { font-size: 20px; font-weight: 600; margin-top: 2px; }
|
|
|
|
.breakdown {
|
|
margin-top: 6px; font-size: 12px; color: var(--muted);
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
}
|
|
.breakdown span { margin-right: 12px; }
|
|
|
|
/* Floating tooltip for truncated cells. Triggered via data-tip
|
|
on any element; positioned in JS so it escapes the table cell's
|
|
overflow:hidden context. */
|
|
#tooltip {
|
|
position: fixed;
|
|
z-index: 1000;
|
|
max-width: 480px;
|
|
padding: 6px 10px;
|
|
background: #0a0d11;
|
|
color: var(--text);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
line-height: 1.4;
|
|
word-break: break-all;
|
|
white-space: pre-wrap;
|
|
pointer-events: none;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
|
opacity: 0;
|
|
transition: opacity 0.08s ease-out;
|
|
}
|
|
#tooltip.visible { opacity: 1; }
|
|
[data-tip] { cursor: help; }
|
|
|
|
details.panel { padding: 0; }
|
|
details.panel > summary {
|
|
list-style: none; cursor: pointer; user-select: none;
|
|
display: flex; align-items: center; gap: 8px; padding: 16px;
|
|
font-size: 13px; font-weight: 600; letter-spacing: 0.04em;
|
|
text-transform: uppercase; color: var(--muted);
|
|
}
|
|
details.panel > summary::-webkit-details-marker { display: none; }
|
|
details.panel > summary::before { content: "▸"; color: var(--muted); }
|
|
details.panel[open] > summary::before { content: "▾"; }
|
|
details.panel > summary .spacer { flex: 1; }
|
|
details.panel > summary button {
|
|
background: var(--bg); color: var(--text); border: 1px solid var(--border);
|
|
padding: 4px 10px; border-radius: 4px; font: inherit; font-size: 11px;
|
|
text-transform: none; letter-spacing: 0; cursor: pointer;
|
|
}
|
|
details.panel > summary button:hover { background: var(--border); }
|
|
details.panel > .body { padding: 0 16px 16px; }
|
|
|
|
pre.config {
|
|
margin: 0; max-height: 600px; overflow: auto;
|
|
background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
|
padding: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
font-size: 12px; line-height: 1.5; color: var(--text);
|
|
white-space: pre; word-break: normal;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="tooltip"></div>
|
|
<div class="container">
|
|
<div class="topbar">
|
|
<span class="dot" id="health-dot"></span>
|
|
<h1>haiku-ingester · status</h1>
|
|
<span class="meta" id="health-detail"></span>
|
|
<span class="spacer"></span>
|
|
<span class="meta" id="last-refresh">never</span>
|
|
</div>
|
|
|
|
<div class="chips">
|
|
<div class="chip queued">
|
|
<div class="label">Queued</div>
|
|
<div class="value" id="chip-queued">—</div>
|
|
</div>
|
|
<div class="chip claimed">
|
|
<div class="label">Claimed</div>
|
|
<div class="value" id="chip-claimed">—</div>
|
|
</div>
|
|
<div class="chip succeeded">
|
|
<div class="label">Succeeded</div>
|
|
<div class="value" id="chip-succeeded">—</div>
|
|
</div>
|
|
<div class="chip dead">
|
|
<div class="label">Dead</div>
|
|
<div class="value" id="chip-dead">—</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panels">
|
|
<div class="panel full">
|
|
<h2>Stats</h2>
|
|
<div class="stats-grid">
|
|
<div class="stat">
|
|
<div class="label">Succeeded · 5m</div>
|
|
<div class="value" id="stat-5m">—</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="label">Succeeded · 30m</div>
|
|
<div class="value" id="stat-30m">—</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="label">Succeeded · 1h</div>
|
|
<div class="value" id="stat-1h">—</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="label">Workers busy</div>
|
|
<div class="value" id="stat-workers">—</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div class="label">Oldest queued</div>
|
|
<div class="value" id="stat-oldest">—</div>
|
|
</div>
|
|
</div>
|
|
<div class="breakdown" id="stat-breakdown"></div>
|
|
</div>
|
|
|
|
<div class="panel full">
|
|
<h2>Sources <span class="count" id="sources-count"></span></h2>
|
|
<div id="sources-body">
|
|
<div class="empty">loading…</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel full">
|
|
<h2>Providers <span class="count" id="providers-count"></span></h2>
|
|
<div id="providers-body">
|
|
<div class="empty">loading…</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<h2>Active jobs <span class="count" id="active-count"></span></h2>
|
|
<div id="active-body">
|
|
<div class="empty">loading…</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<h2>Dead letter <span class="count" id="dead-count"></span></h2>
|
|
<div id="dead-body">
|
|
<div class="empty">loading…</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel full">
|
|
<h2>Recently completed <span class="count" id="recent-count"></span></h2>
|
|
<div id="recent-body">
|
|
<div class="empty">loading…</div>
|
|
</div>
|
|
</div>
|
|
|
|
<details class="panel full" id="db-panel">
|
|
<summary>
|
|
<span>Database</span>
|
|
<span class="spacer"></span>
|
|
<button type="button" id="db-refresh">Refresh</button>
|
|
</summary>
|
|
<div class="body" id="db-body">
|
|
<div class="empty">expand to load…</div>
|
|
</div>
|
|
</details>
|
|
|
|
<details class="panel full" id="config-panel">
|
|
<summary>
|
|
<span>Configuration</span>
|
|
</summary>
|
|
<div class="body" id="config-body">
|
|
<div class="empty">expand to load…</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
"use strict";
|
|
const TOKEN_KEY = "haiku-ingester-token";
|
|
const POLL_MS = 3000;
|
|
|
|
let token = localStorage.getItem(TOKEN_KEY) || "";
|
|
let lastRefreshAt = null;
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
async function fetchJson(path) {
|
|
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
|
const res = await fetch(path, { headers });
|
|
if (res.status === 401) {
|
|
const entered = prompt(
|
|
"API requires a bearer token. Paste it here:",
|
|
token,
|
|
);
|
|
if (entered) {
|
|
token = entered.trim();
|
|
localStorage.setItem(TOKEN_KEY, token);
|
|
return fetchJson(path);
|
|
}
|
|
throw new Error("unauthorized");
|
|
}
|
|
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
async function postJson(path) {
|
|
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
|
const res = await fetch(path, { method: "POST", headers });
|
|
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
async function deleteJson(path) {
|
|
const headers = token ? { Authorization: `Bearer ${token}` } : {};
|
|
const res = await fetch(path, { method: "DELETE", headers });
|
|
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
function relTime(iso) {
|
|
if (!iso) return "—";
|
|
const ms = Date.now() - Date.parse(iso);
|
|
if (ms < 0) return "in the future";
|
|
return `${formatDuration(ms / 1000)} ago`;
|
|
}
|
|
|
|
function formatDuration(s) {
|
|
if (s == null || Number.isNaN(s)) return "—";
|
|
if (s < 1) return "<1s";
|
|
if (s < 60) return `${Math.floor(s)}s`;
|
|
if (s < 3600) return `${Math.floor(s / 60)}m ${Math.floor(s % 60)}s`;
|
|
if (s < 86400) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
|
|
return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`;
|
|
}
|
|
|
|
function shortId(id) {
|
|
return id ? id.slice(0, 8) : "—";
|
|
}
|
|
|
|
function formatBytes(n) {
|
|
if (n == null) return "—";
|
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
let i = 0;
|
|
let v = n;
|
|
while (v >= 1024 && i < units.length - 1) {
|
|
v /= 1024;
|
|
i++;
|
|
}
|
|
return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`;
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
if (s == null) return "";
|
|
return String(s)
|
|
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
.replace(/"/g, """).replace(/'/g, "'");
|
|
}
|
|
|
|
function setHealthDot(stale) {
|
|
$("health-dot").classList.toggle("stale", !!stale);
|
|
}
|
|
|
|
function renderChips(counts) {
|
|
$("chip-queued").textContent = counts.queued ?? 0;
|
|
$("chip-claimed").textContent = counts.claimed ?? 0;
|
|
$("chip-succeeded").textContent = counts.succeeded ?? 0;
|
|
$("chip-dead").textContent = counts.dead ?? 0;
|
|
}
|
|
|
|
function renderStats(stats) {
|
|
$("stat-5m").textContent = stats.throughput.succeeded_5m;
|
|
$("stat-30m").textContent = stats.throughput.succeeded_30m;
|
|
$("stat-1h").textContent = stats.throughput.succeeded_1h;
|
|
$("stat-workers").textContent =
|
|
`${stats.workers.busy} / ${stats.workers.total}`;
|
|
$("stat-oldest").textContent =
|
|
stats.oldest_queued_age_s == null
|
|
? "—"
|
|
: formatDuration(stats.oldest_queued_age_s);
|
|
|
|
const parts = [];
|
|
const dlq = stats.dlq_by_source || {};
|
|
const depth = stats.queue_depth_by_source || {};
|
|
const dlqEntries = Object.entries(dlq);
|
|
if (dlqEntries.length) {
|
|
parts.push(
|
|
"DLQ by source: " +
|
|
dlqEntries.map(([k, v]) => `<span>${escapeHtml(k)}: ${v}</span>`).join(""),
|
|
);
|
|
}
|
|
const depthEntries = Object.entries(depth);
|
|
if (depthEntries.length) {
|
|
parts.push(
|
|
"Backlog by source: " +
|
|
depthEntries
|
|
.map(([k, v]) => `<span>${escapeHtml(k)}: ${v}</span>`)
|
|
.join(""),
|
|
);
|
|
}
|
|
$("stat-breakdown").innerHTML = parts.join("<br>");
|
|
}
|
|
|
|
const SKIP_REASON_LABELS = {
|
|
pending_work: "queue busy",
|
|
circuit_open: "breaker open",
|
|
};
|
|
|
|
function renderSources(sources) {
|
|
$("sources-count").textContent = `(${sources.length})`;
|
|
if (!sources.length) {
|
|
$("sources-body").innerHTML =
|
|
'<div class="empty">no sources configured</div>';
|
|
return;
|
|
}
|
|
const rows = sources
|
|
.map((s) => {
|
|
let circuitBadge = '<span class="badge ok">OK</span>';
|
|
if (s.circuit_breaker_open) {
|
|
circuitBadge = '<span class="badge bad">OPEN</span>';
|
|
}
|
|
// Combine last_polled with skip_reason in one cell. Three cases:
|
|
// (a) polled OK → "5m ago"
|
|
// (b) polled + skipped → "5m ago [queue busy]" (sweep ran then
|
|
// backpressure kicked in)
|
|
// (c) never polled but → "[queue busy]" alone — em-dash would
|
|
// skipped read as broken; the badge tells the
|
|
// real story (restart with work still
|
|
// in queue).
|
|
const reason = s.last_skip_reason;
|
|
const label = reason
|
|
? SKIP_REASON_LABELS[reason] || reason
|
|
: null;
|
|
const skipBadge = reason
|
|
? ` <span class="badge warn" data-tip="Most recent sweep attempt was skipped (${escapeHtml(reason)}).">${escapeHtml(label)}</span>`
|
|
: "";
|
|
const polledCell = s.last_polled_at
|
|
? relTime(s.last_polled_at) + skipBadge
|
|
: reason
|
|
? skipBadge.trim()
|
|
: "—";
|
|
return `<tr>
|
|
<td>${escapeHtml(s.source_id)}</td>
|
|
<td>${escapeHtml(s.type)}</td>
|
|
<td>${polledCell}</td>
|
|
<td>${circuitBadge}</td>
|
|
<td class="actions">
|
|
<button onclick="refreshSource('${escapeHtml(s.source_id)}')">Refresh</button>
|
|
</td>
|
|
</tr>`;
|
|
})
|
|
.join("");
|
|
$("sources-body").innerHTML = `<table>
|
|
<thead><tr>
|
|
<th>ID</th><th>Type</th><th>Last polled</th><th>Circuit</th><th></th>
|
|
</tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>`;
|
|
}
|
|
|
|
function _truncateMiddle(s, n) {
|
|
if (!s || s.length <= n) return s;
|
|
const head = Math.ceil((n - 1) / 2);
|
|
const tail = Math.floor((n - 1) / 2);
|
|
return `${s.slice(0, head)}…${s.slice(-tail)}`;
|
|
}
|
|
|
|
function opBadge(op) {
|
|
const cls = op === "delete" ? "delete" : "upsert";
|
|
const label = op === "delete" ? "DEL" : "UP";
|
|
return `<span class="badge ${cls}">${label}</span>`;
|
|
}
|
|
|
|
function renderProviders(providers) {
|
|
const entries = providers.docling_serve || [];
|
|
$("providers-count").textContent = `(${entries.length})`;
|
|
if (!entries.length) {
|
|
$("providers-body").innerHTML =
|
|
'<div class="empty">no external providers configured</div>';
|
|
return;
|
|
}
|
|
const rows = entries
|
|
.map((p) => {
|
|
const badge = p.reachable
|
|
? '<span class="badge ok">REACHABLE</span>'
|
|
: '<span class="badge bad">UNREACHABLE</span>';
|
|
const detail = p.reachable
|
|
? p.status_code != null
|
|
? `HTTP ${p.status_code}`
|
|
: ""
|
|
: escapeHtml(p.error ?? "no response");
|
|
const detailClass = p.reachable ? "ok" : "err";
|
|
return `<tr>
|
|
<td>docling-serve</td>
|
|
<td class="uri" data-tip="${escapeHtml(p.base_url)}">${escapeHtml(p.base_url)}</td>
|
|
<td>${badge}</td>
|
|
<td class="${detailClass}" data-tip="${detail}">${detail}</td>
|
|
</tr>`;
|
|
})
|
|
.join("");
|
|
$("providers-body").innerHTML = `<table>
|
|
<thead><tr>
|
|
<th>Kind</th><th>URL</th><th>Status</th><th>Detail</th>
|
|
</tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>`;
|
|
}
|
|
|
|
function renderActive(jobs) {
|
|
$("active-count").textContent = `(${jobs.length})`;
|
|
if (!jobs.length) {
|
|
$("active-body").innerHTML = '<div class="empty">nothing running</div>';
|
|
return;
|
|
}
|
|
const rows = jobs
|
|
.map((j) => {
|
|
const tryTip = j.last_error
|
|
? ` data-tip="Previous attempt failed: ${escapeHtml(j.last_error)}"`
|
|
: "";
|
|
const tryCell =
|
|
j.attempts > 1
|
|
? `<td class="retried"${tryTip}>${j.attempts}/${j.max_attempts}</td>`
|
|
: `<td>${j.attempts}/${j.max_attempts}</td>`;
|
|
return `<tr>
|
|
<td class="id" data-tip="${escapeHtml(j.id)}">${shortId(j.id)}</td>
|
|
<td>${opBadge(j.op)}</td>
|
|
<td class="uri" data-tip="${escapeHtml(j.uri)}">${escapeHtml(j.uri)}</td>
|
|
<td class="worker">${escapeHtml(j.claimed_by ?? "—")}</td>
|
|
${tryCell}
|
|
<td>${relTime(j.claimed_at)}</td>
|
|
<td class="actions">
|
|
<button type="button" onclick="cancelJob('${escapeHtml(j.id)}')">Cancel</button>
|
|
</td>
|
|
</tr>`;
|
|
})
|
|
.join("");
|
|
$("active-body").innerHTML = `<table>
|
|
<thead><tr>
|
|
<th>ID</th><th>Op</th><th>URI</th><th>Worker</th><th>Try</th><th>Started</th><th></th>
|
|
</tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>`;
|
|
}
|
|
|
|
function renderDead(jobs) {
|
|
$("dead-count").textContent = `(${jobs.length})`;
|
|
if (!jobs.length) {
|
|
$("dead-body").innerHTML =
|
|
'<div class="empty">no jobs in the dead letter queue</div>';
|
|
return;
|
|
}
|
|
const rows = jobs
|
|
.map(
|
|
(j) => `<tr>
|
|
<td class="id" data-tip="${escapeHtml(j.id)}">${shortId(j.id)}</td>
|
|
<td class="uri" data-tip="${escapeHtml(j.uri)}">${escapeHtml(j.uri)}</td>
|
|
<td class="err" data-tip="${escapeHtml(j.last_error ?? "")}">${escapeHtml(j.last_error ?? "")}</td>
|
|
<td>${relTime(j.completed_at)}</td>
|
|
<td class="actions">
|
|
<button onclick="retryJob('${escapeHtml(j.id)}')">Retry</button>
|
|
</td>
|
|
</tr>`,
|
|
)
|
|
.join("");
|
|
$("dead-body").innerHTML = `<table>
|
|
<thead><tr>
|
|
<th>ID</th><th>URI</th><th>Error</th><th>Failed</th><th></th>
|
|
</tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>`;
|
|
}
|
|
|
|
function renderRecent(jobs) {
|
|
$("recent-count").textContent = `(${jobs.length})`;
|
|
if (!jobs.length) {
|
|
$("recent-body").innerHTML = '<div class="empty">nothing completed yet</div>';
|
|
return;
|
|
}
|
|
const rows = jobs
|
|
.map((j) => {
|
|
let duration = "—";
|
|
if (j.completed_at && j.enqueued_at) {
|
|
const ms = Date.parse(j.completed_at) - Date.parse(j.enqueued_at);
|
|
if (ms >= 0) duration = formatDuration(ms / 1000);
|
|
}
|
|
return `<tr>
|
|
<td>${opBadge(j.op)}</td>
|
|
<td class="uri" data-tip="${escapeHtml(j.uri)}">${escapeHtml(j.uri)}</td>
|
|
<td>${escapeHtml(j.source_id)}</td>
|
|
<td>${duration}</td>
|
|
<td>${relTime(j.completed_at)}</td>
|
|
</tr>`;
|
|
})
|
|
.join("");
|
|
$("recent-body").innerHTML = `<table>
|
|
<thead><tr>
|
|
<th>Op</th><th>URI</th><th>Source</th><th>Duration</th><th>Finished</th>
|
|
</tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>`;
|
|
}
|
|
|
|
async function refresh() {
|
|
try {
|
|
const [health, sources, providers, stats, active, dead, recent] =
|
|
await Promise.all([
|
|
fetchJson("health"),
|
|
fetchJson("sources"),
|
|
fetchJson("providers"),
|
|
fetchJson("stats"),
|
|
fetchJson("jobs?status=claimed&limit=20"),
|
|
fetchJson("jobs?status=dead&limit=10"),
|
|
fetchJson("jobs?status=succeeded&limit=10"),
|
|
]);
|
|
setHealthDot(false);
|
|
let detail = `${health.worker_count} workers · ${health.poller_count} pollers`;
|
|
if (health.worker_breaker_open) {
|
|
const fails = health.worker_breaker_consecutive_failures;
|
|
detail += ` · <span class="badge bad" data-tip="${fails} consecutive transient job failures; worker claims paused until cooldown elapses">WORKER BREAKER OPEN</span>`;
|
|
}
|
|
$("health-detail").innerHTML = detail;
|
|
renderChips(health.queue_counts);
|
|
renderStats(stats);
|
|
renderSources(sources);
|
|
renderProviders(providers);
|
|
renderActive(active);
|
|
renderDead(dead);
|
|
renderRecent(recent);
|
|
lastRefreshAt = new Date();
|
|
updateLastRefresh();
|
|
} catch (e) {
|
|
setHealthDot(true);
|
|
$("health-detail").textContent = `error: ${e.message}`;
|
|
}
|
|
}
|
|
|
|
function updateLastRefresh() {
|
|
if (!lastRefreshAt) {
|
|
$("last-refresh").textContent = "never";
|
|
return;
|
|
}
|
|
const ms = Date.now() - lastRefreshAt.getTime();
|
|
$("last-refresh").textContent = `${formatDuration(ms / 1000)} ago`;
|
|
}
|
|
|
|
// Action handlers (called from inline onclick — kept on window).
|
|
window.cancelJob = async (id) => {
|
|
if (!confirm(`Cancel job ${id.slice(0, 8)}…?`)) return;
|
|
try { await deleteJson(`jobs/${encodeURIComponent(id)}`); } catch (e) {}
|
|
refresh();
|
|
};
|
|
window.retryJob = async (id) => {
|
|
try { await postJson(`jobs/${encodeURIComponent(id)}/retry`); } catch (e) {}
|
|
refresh();
|
|
};
|
|
window.refreshSource = async (sid) => {
|
|
try {
|
|
await postJson(`sources/${encodeURIComponent(sid)}/refresh`);
|
|
} catch (e) {}
|
|
refresh();
|
|
};
|
|
|
|
// Floating tooltip: shows the full content of any [data-tip] element
|
|
// immediately on hover. Uses event delegation so it survives the
|
|
// table-body innerHTML reset each refresh().
|
|
(() => {
|
|
const tip = $("tooltip");
|
|
let current = null;
|
|
const place = (e) => {
|
|
const pad = 12;
|
|
const w = tip.offsetWidth;
|
|
const h = tip.offsetHeight;
|
|
let x = e.clientX + pad;
|
|
let y = e.clientY + pad;
|
|
if (x + w > window.innerWidth - 8) x = e.clientX - w - pad;
|
|
if (y + h > window.innerHeight - 8) y = e.clientY - h - pad;
|
|
tip.style.left = `${Math.max(8, x)}px`;
|
|
tip.style.top = `${Math.max(8, y)}px`;
|
|
};
|
|
document.addEventListener("mouseover", (e) => {
|
|
const el = e.target.closest("[data-tip]");
|
|
if (!el || el === current) return;
|
|
current = el;
|
|
tip.textContent = el.getAttribute("data-tip") || "";
|
|
tip.classList.add("visible");
|
|
place(e);
|
|
});
|
|
document.addEventListener("mousemove", (e) => {
|
|
if (current) place(e);
|
|
});
|
|
document.addEventListener("mouseout", (e) => {
|
|
if (!current) return;
|
|
const next = e.relatedTarget && e.relatedTarget.closest?.("[data-tip]");
|
|
if (next === current) return;
|
|
tip.classList.remove("visible");
|
|
current = null;
|
|
});
|
|
})();
|
|
|
|
// Database and Configuration panels are on-demand, not part of the
|
|
// POLL_MS loop: their data is database state, not live queue telemetry,
|
|
// and gathering it re-reads table manifests. Loaded lazily on first
|
|
// expand; the Database panel can be re-fetched with its Refresh button.
|
|
|
|
function renderDatabase(info) {
|
|
if (!info.exists) {
|
|
$("db-body").innerHTML =
|
|
'<div class="empty">database empty or not initialized</div>';
|
|
return;
|
|
}
|
|
const emb = info.embeddings || {};
|
|
const vi = info.vector_index || {};
|
|
const pkgs = info.packages || {};
|
|
const stat = (label, value) =>
|
|
`<div class="stat"><div class="label">${label}</div><div class="value">${value}</div></div>`;
|
|
const stats = [
|
|
stat("Stored version", escapeHtml(info.stored_version)),
|
|
stat("Vector dim", emb.vector_dim ?? "—"),
|
|
stat(
|
|
"Vector index",
|
|
vi.exists ? `✓ ${vi.indexed_rows}` : "✗ none",
|
|
),
|
|
stat("haiku.rag", escapeHtml(pkgs.haiku_rag ?? "—")),
|
|
stat("lancedb", escapeHtml(pkgs.lancedb ?? "—")),
|
|
].join("");
|
|
|
|
const tableRows = (info.tables || [])
|
|
.map((t) => {
|
|
if (!t.exists) {
|
|
return `<tr><td>${escapeHtml(t.name)}</td><td colspan="3"><span class="badge warn">absent</span></td></tr>`;
|
|
}
|
|
return `<tr>
|
|
<td>${escapeHtml(t.name)}</td>
|
|
<td>${t.num_rows}</td>
|
|
<td>${formatBytes(t.total_bytes)}</td>
|
|
<td>${t.num_versions}</td>
|
|
</tr>`;
|
|
})
|
|
.join("");
|
|
|
|
let migration = '<span class="badge ok">up to date</span>';
|
|
const pending = info.pending_migrations || [];
|
|
if (pending.length) {
|
|
const items = pending
|
|
.map(
|
|
(m) =>
|
|
`<span data-tip="${escapeHtml(m.description)}">${escapeHtml(m.version)}</span>`,
|
|
)
|
|
.join(" ");
|
|
migration = `<span class="badge warn" data-tip="run: haiku-rag migrate">${pending.length} pending</span> ${items}`;
|
|
}
|
|
let unindexed = "";
|
|
if (vi.exists && vi.unindexed_rows > 0) {
|
|
unindexed = ` <span class="badge warn" data-tip="run: haiku-rag create-index">${vi.unindexed_rows} unindexed</span>`;
|
|
}
|
|
|
|
$("db-body").innerHTML = `
|
|
<div class="stats-grid">${stats}</div>
|
|
<div class="breakdown">
|
|
<span>embeddings: ${escapeHtml(emb.provider ?? "?")}/${escapeHtml(emb.name ?? "?")}</span>
|
|
<span>path: ${escapeHtml(info.path)}</span>
|
|
</div>
|
|
<table>
|
|
<thead><tr><th>Table</th><th>Rows</th><th>Size</th><th>Versions</th></tr></thead>
|
|
<tbody>${tableRows}</tbody>
|
|
</table>
|
|
<div class="breakdown">Migrations: ${migration}${unindexed}</div>`;
|
|
}
|
|
|
|
let dbLoaded = false;
|
|
async function loadDatabase() {
|
|
$("db-body").innerHTML = '<div class="empty">loading…</div>';
|
|
try {
|
|
renderDatabase(await fetchJson("database"));
|
|
dbLoaded = true;
|
|
} catch (e) {
|
|
$("db-body").innerHTML = `<div class="empty">error: ${escapeHtml(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
let configLoaded = false;
|
|
async function loadConfig() {
|
|
$("config-body").innerHTML = '<div class="empty">loading…</div>';
|
|
try {
|
|
const data = await fetchJson("config");
|
|
$("config-body").innerHTML = '<pre class="config"></pre>';
|
|
$("config-body").querySelector("pre").textContent = data.yaml;
|
|
configLoaded = true;
|
|
} catch (e) {
|
|
$("config-body").innerHTML = `<div class="empty">error: ${escapeHtml(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
$("db-panel").addEventListener("toggle", (e) => {
|
|
if (e.target.open && !dbLoaded) loadDatabase();
|
|
});
|
|
$("db-refresh").addEventListener("click", (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
$("db-panel").open = true;
|
|
loadDatabase();
|
|
});
|
|
$("config-panel").addEventListener("toggle", (e) => {
|
|
if (e.target.open && !configLoaded) loadConfig();
|
|
});
|
|
|
|
setInterval(updateLastRefresh, 1000);
|
|
refresh();
|
|
setInterval(refresh, POLL_MS);
|
|
</script>
|
|
</body>
|
|
</html>
|