216 lines
6.0 KiB
HTML
216 lines
6.0 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>pgsql-broker metrics</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
--bg: #0f1115;
|
|
--panel: #161a22;
|
|
--border: #262b36;
|
|
--text: #e6e9ef;
|
|
--muted: #8b93a7;
|
|
--accent: #5aa8ff;
|
|
--good: #4caf7d;
|
|
--bad: #e5636b;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
padding: 2rem;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
}
|
|
h1 {
|
|
font-size: 1.25rem;
|
|
margin: 0 0 0.25rem;
|
|
}
|
|
#status {
|
|
color: var(--muted);
|
|
font-size: 0.8rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
|
|
gap: 1rem;
|
|
}
|
|
.card {
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
}
|
|
.card h2 {
|
|
font-size: 0.9rem;
|
|
margin: 0 0 0.75rem;
|
|
color: var(--accent);
|
|
font-weight: 600;
|
|
}
|
|
.card .help {
|
|
color: var(--muted);
|
|
font-size: 0.75rem;
|
|
margin: -0.5rem 0 0.75rem;
|
|
}
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
font-size: 0.8rem;
|
|
}
|
|
td, th {
|
|
text-align: left;
|
|
padding: 0.2rem 0.4rem 0.2rem 0;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
td.value {
|
|
text-align: right;
|
|
font-variant-numeric: tabular-nums;
|
|
white-space: nowrap;
|
|
}
|
|
td.labels {
|
|
color: var(--muted);
|
|
}
|
|
.completed .value { color: var(--good); }
|
|
.failed .value { color: var(--bad); }
|
|
.empty {
|
|
color: var(--muted);
|
|
font-size: 0.8rem;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>pgsql-broker metrics</h1>
|
|
<div id="status">loading…</div>
|
|
<div class="grid" id="grid"></div>
|
|
|
|
<script>
|
|
(function () {
|
|
"use strict";
|
|
|
|
// Metric name -> { help, className } for the cards we render, in order.
|
|
var METRICS = [
|
|
{ name: "broker_jobs_completed_total", title: "Jobs completed", cls: "completed" },
|
|
{ name: "broker_jobs_failed_total", title: "Jobs failed (dead-lettered)", cls: "failed" },
|
|
{ name: "broker_jobs_requeued_total", title: "Jobs requeued (retries)", cls: "" },
|
|
{ name: "broker_jobs_queued", title: "Jobs currently queued", cls: "" },
|
|
{ name: "broker_job_duration_seconds_sum", title: "Job duration, total seconds by group/name", cls: "" },
|
|
{ name: "broker_job_duration_seconds_count", title: "Job duration, sample count by group/name", cls: "" },
|
|
{ name: "broker_databases", title: "Databases managed", cls: "" },
|
|
{ name: "broker_queues", title: "Queues per database", cls: "" }
|
|
];
|
|
|
|
// Parses Prometheus text exposition format into { name: [{labels, value}] }.
|
|
function parseMetrics(text) {
|
|
var byName = {};
|
|
var lines = text.split("\n");
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var line = lines[i];
|
|
if (!line || line[0] === "#") continue;
|
|
|
|
var name, labels = {}, rest;
|
|
var braceIdx = line.indexOf("{");
|
|
var spaceIdx;
|
|
if (braceIdx !== -1) {
|
|
name = line.slice(0, braceIdx);
|
|
var closeIdx = line.indexOf("}", braceIdx);
|
|
if (closeIdx === -1) continue;
|
|
var labelStr = line.slice(braceIdx + 1, closeIdx);
|
|
var labelRe = /(\w+)="((?:[^"\\]|\\.)*)"/g;
|
|
var m;
|
|
while ((m = labelRe.exec(labelStr)) !== null) {
|
|
labels[m[1]] = m[2].replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
}
|
|
rest = line.slice(closeIdx + 1).trim();
|
|
} else {
|
|
spaceIdx = line.indexOf(" ");
|
|
if (spaceIdx === -1) continue;
|
|
name = line.slice(0, spaceIdx);
|
|
rest = line.slice(spaceIdx + 1).trim();
|
|
}
|
|
|
|
var value = parseFloat(rest.split(" ")[0]);
|
|
if (isNaN(value)) continue;
|
|
|
|
if (!byName[name]) byName[name] = [];
|
|
byName[name].push({ labels: labels, value: value });
|
|
}
|
|
return byName;
|
|
}
|
|
|
|
function formatLabels(labels) {
|
|
var keys = Object.keys(labels).sort();
|
|
return keys.map(function (k) { return k + "=" + labels[k]; }).join(", ");
|
|
}
|
|
|
|
function formatValue(v) {
|
|
if (Number.isInteger(v)) return String(v);
|
|
return v.toFixed(3);
|
|
}
|
|
|
|
function render(byName) {
|
|
var grid = document.getElementById("grid");
|
|
grid.innerHTML = "";
|
|
|
|
METRICS.forEach(function (spec) {
|
|
var series = byName[spec.name] || [];
|
|
var card = document.createElement("div");
|
|
card.className = "card " + spec.cls;
|
|
|
|
var h2 = document.createElement("h2");
|
|
h2.textContent = spec.title;
|
|
card.appendChild(h2);
|
|
|
|
if (series.length === 0) {
|
|
var empty = document.createElement("div");
|
|
empty.className = "empty";
|
|
empty.textContent = "no data yet";
|
|
card.appendChild(empty);
|
|
} else {
|
|
var table = document.createElement("table");
|
|
series.sort(function (a, b) { return b.value - a.value; });
|
|
series.forEach(function (s) {
|
|
var tr = document.createElement("tr");
|
|
var tdLabels = document.createElement("td");
|
|
tdLabels.className = "labels";
|
|
tdLabels.textContent = formatLabels(s.labels) || "(none)";
|
|
var tdValue = document.createElement("td");
|
|
tdValue.className = "value";
|
|
tdValue.textContent = formatValue(s.value);
|
|
tr.appendChild(tdLabels);
|
|
tr.appendChild(tdValue);
|
|
table.appendChild(tr);
|
|
});
|
|
card.appendChild(table);
|
|
}
|
|
|
|
grid.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function refresh() {
|
|
fetch("metrics", { cache: "no-store" })
|
|
.then(function (resp) {
|
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
|
return resp.text();
|
|
})
|
|
.then(function (text) {
|
|
render(parseMetrics(text));
|
|
document.getElementById("status").textContent =
|
|
"last updated " + new Date().toLocaleTimeString();
|
|
})
|
|
.catch(function (err) {
|
|
document.getElementById("status").textContent =
|
|
"failed to load metrics: " + err.message;
|
|
});
|
|
}
|
|
|
|
refresh();
|
|
setInterval(refresh, 5000);
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|