238 lines
8.0 KiB
JavaScript
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
|
|
};
|