238 lines
8.0 KiB
JavaScript

function renderDashboardPage() {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TinyLink Dashboard</title>
<style>
:root {
--bg: #f5f6f8;
--panel: #ffffff;
--ink: #1e2430;
--muted: #6b7480;
--line: #dde2e8;
--accent: #005f73;
--warn: #b26a00;
--danger: #b00020;
--ok: #2f7d32;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background: radial-gradient(circle at top right, #e8f1f5 0%, var(--bg) 55%);
color: var(--ink);
}
.wrap {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.hero {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
margin-bottom: 16px;
}
h1 { margin: 0; font-size: 28px; }
.muted { color: var(--muted); }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 10px;
padding: 12px;
box-shadow: 0 4px 12px rgba(16, 24, 40, 0.05);
}
.card h2 {
margin: 0;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.value {
font-size: 30px;
margin-top: 6px;
font-weight: 700;
}
.row {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
margin-bottom: 12px;
}
@media (min-width: 980px) {
.row {
grid-template-columns: 1fr 1fr;
}
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
th, td {
border-bottom: 1px solid var(--line);
padding: 8px;
text-align: left;
vertical-align: top;
}
th {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.pill {
display: inline-block;
border-radius: 999px;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
color: white;
background: var(--muted);
}
.pill.ok { background: var(--ok); }
.pill.warn { background: var(--warn); }
.pill.danger { background: var(--danger); }
code {
background: #f2f4f8;
border: 1px solid #e2e7ef;
border-radius: 4px;
padding: 1px 4px;
font-family: "IBM Plex Mono", monospace;
}
</style>
</head>
<body>
<div class="wrap">
<div class="hero">
<h1>TinyLink Dashboard</h1>
<div class="muted" id="last-refresh">loading...</div>
</div>
<section class="grid">
<article class="card"><h2>Pending</h2><div class="value" id="pending">-</div></article>
<article class="card"><h2>Retrying</h2><div class="value" id="retrying">-</div></article>
<article class="card"><h2>Dead Letters</h2><div class="value" id="dead">-</div></article>
<article class="card"><h2>Delivery Attempts</h2><div class="value" id="attempts">-</div></article>
<article class="card"><h2>Avg Latency (ms)</h2><div class="value" id="latency">-</div></article>
</section>
<section class="row">
<article class="card">
<h2>Instrument Connectors</h2>
<table>
<thead><tr><th>Instrument</th><th>Connector</th><th>Status</th><th>Address</th></tr></thead>
<tbody id="instrument-table"><tr><td colspan="4" class="muted">loading...</td></tr></tbody>
</table>
</article>
<article class="card">
<h2>Queue Tail</h2>
<table>
<thead><tr><th>ID</th><th>Status</th><th>Attempts</th><th>Next Attempt</th><th>Error</th></tr></thead>
<tbody id="queue-table"><tr><td colspan="5" class="muted">loading...</td></tr></tbody>
</table>
</article>
</section>
<section class="card">
<h2>Recent Delivery Attempts</h2>
<table>
<thead><tr><th>Time</th><th>Outbox</th><th>Attempt</th><th>Status</th><th>HTTP</th><th>Latency</th><th>Body</th></tr></thead>
<tbody id="recent-table"><tr><td colspan="7" class="muted">loading...</td></tr></tbody>
</table>
</section>
</div>
<script>
const fmtDate = (value) => value ? new Date(value).toLocaleString() : '-';
const clip = (value, max = 80) => {
const text = value == null ? '' : String(value);
return text.length > max ? text.slice(0, max) + '...' : text;
};
const statusPill = (status) => {
const normalized = String(status || '').toLowerCase();
const cls = normalized === 'up' || normalized === 'success' || normalized === 'processed'
? 'ok'
: normalized === 'retrying' || normalized === 'pending'
? 'warn'
: normalized === 'down' || normalized === 'dead_letter' || normalized === 'failure'
? 'danger'
: '';
return '<span class="pill ' + cls + '">' + (status || '-') + '</span>';
};
async function refresh() {
const [summary, queue, instruments, recent] = await Promise.all([
fetch('/dashboard/api/summary').then((r) => r.json()),
fetch('/dashboard/api/queue').then((r) => r.json()),
fetch('/dashboard/api/instruments').then((r) => r.json()),
fetch('/dashboard/api/recent').then((r) => r.json())
]);
document.getElementById('pending').textContent = summary.metrics.pending;
document.getElementById('retrying').textContent = summary.metrics.retrying;
document.getElementById('dead').textContent = summary.metrics.deadLetters;
document.getElementById('attempts').textContent = summary.metrics.attempts;
document.getElementById('latency').textContent = Math.round(summary.metrics.avgLatency || 0);
document.getElementById('last-refresh').textContent = 'last refresh ' + new Date().toLocaleTimeString();
const instrumentRows = (instruments.items || []).map((item) => {
return '<tr>' +
'<td><code>' + (item.instrument_id || '-') + '</code></td>' +
'<td>' + (item.connector || '-') + '</td>' +
'<td>' + statusPill(item.status) + '</td>' +
'<td>' + (item.address || '-') + '</td>' +
'</tr>';
}).join('');
document.getElementById('instrument-table').innerHTML = instrumentRows || '<tr><td colspan="4" class="muted">no connectors</td></tr>';
const queueRows = (queue.items || []).map((item) => {
return '<tr>' +
'<td><code>' + item.id + '</code></td>' +
'<td>' + statusPill(item.status) + '</td>' +
'<td>' + item.attempts + '</td>' +
'<td>' + fmtDate(item.next_attempt_at_iso) + '</td>' +
'<td title="' + (item.last_error || '') + '">' + clip(item.last_error || '-') + '</td>' +
'</tr>';
}).join('');
document.getElementById('queue-table').innerHTML = queueRows || '<tr><td colspan="5" class="muted">queue empty</td></tr>';
const recentRows = (recent.items || []).map((item) => {
return '<tr>' +
'<td>' + fmtDate(item.created_at) + '</td>' +
'<td><code>' + item.outbox_id + '</code></td>' +
'<td>' + item.attempt + '</td>' +
'<td>' + statusPill(item.status) + '</td>' +
'<td>' + (item.response_code == null ? '-' : item.response_code) + '</td>' +
'<td>' + (item.latency_ms == null ? '-' : item.latency_ms) + '</td>' +
'<td title="' + (item.response_body || '') + '">' + clip(item.response_body || '-', 120) + '</td>' +
'</tr>';
}).join('');
document.getElementById('recent-table').innerHTML = recentRows || '<tr><td colspan="7" class="muted">no attempts yet</td></tr>';
}
refresh().catch((err) => {
document.getElementById('last-refresh').textContent = 'dashboard unavailable: ' + err.message;
});
setInterval(() => {
refresh().catch(() => {});
}, 5000);
</script>
</body>
</html>`;
}
module.exports = {
renderDashboardPage
};