KumaBar: putting Uptime Kuma in the browser toolbar
I run Uptime Kuma, and I check it by opening a tab. Which means I check it when I remember to, which means I find out something is down from someone else. So I built KumaBar — a small Chromium extension that keeps a count of down monitors in the toolbar and fires a desktop notification when state changes.
Chrome, Brave, Edge. Manifest V3. No build step, no bundler, no dependencies — plain ES modules the browser loads directly.
Why /metrics and not the Socket.io API
Uptime Kuma’s dashboard talks to the backend over Socket.io. That’s the obvious integration point, and it’s the wrong one here for two reasons.
First, it authenticates with your actual login credentials. Second, it wants a persistent connection — and an MV3 service worker is the opposite of persistent. Chrome kills it whenever it goes idle. You’d spend all your effort fighting the lifecycle to keep a socket alive that the browser is actively trying to tear down.
Kuma also ships a /metrics endpoint in Prometheus text format, authenticated with an API key over HTTP Basic auth. A plain HTTP GET on a schedule survives the service worker lifecycle cleanly, and an API key is scoped and revocable in a way your password isn’t.
The whole integration is one fetch:
const headers = {
Authorization: "Basic " + btoa(`:${apiKey}`),
};
const response = await fetch(`${base}/metrics`, {
headers,
credentials: "omit",
cache: "no-store",
});
Note: the colon matters. Kuma expects an empty username with the API key as the password.
curl -u ":YOUR_API_KEY", notcurl -u "YOUR_API_KEY".
Alarms, not setInterval
This is the part that catches people out when they first write an MV3 extension. setInterval in a service worker is useless — the worker gets shut down and your timer goes with it. Everything periodic has to go through chrome.alarms, which wakes the worker back up.
const ALARM = "kuma-poll";
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === ALARM) refresh();
});
async function scheduleAlarm() {
const { intervalMinutes } = await getSettings();
await chrome.alarms.clear(ALARM);
// Chrome enforces a floor on alarm periods; 1 minute is safe everywhere.
const period = Math.max(1, Number(intervalMinutes) || 1);
chrome.alarms.create(ALARM, { periodInMinutes: period, delayInMinutes: period });
}
The corollary is that the worker holds no state between wakes. Every poll writes a snapshot to chrome.storage.local, and the popup reads from storage rather than asking the worker for anything. The worker is a function that runs on a timer, not a process.
Down/recovery notifications need the previous status to diff against, so that gets read back out of storage each time too:
const monitors = await fetchMonitors(settings);
const { monitors: previous = [] } = await chrome.storage.local.get("monitors");
await notifyTransitions(previous, monitors, settings);
Parsing Prometheus text
Kuma emits lines like this:
monitor_status{monitor_name="API, staging",monitor_id="3",monitor_type="http"} 1
monitor_response_time{monitor_name="API, staging",monitor_id="3"} 142
monitor_cert_days_remaining{monitor_name="API, staging",monitor_id="3"} 18
Look at that monitor name. Splitting the label block on , corrupts it, and monitor URLs contain commas too. So the label parser is a hand-rolled scanner that walks the string, respects quotes, and honours backslash escapes. It’s forty lines and it’s the only part of the codebase that needed real care.
Multiple metric lines describe the same monitor, so they get folded into one object keyed by monitor_id:
const id = labels.monitor_id ?? name;
const monitor = byId.get(id) ?? { id, name, url: null, status: 2 };
switch (metric) {
case "monitor_status": monitor.status = value; break;
case "monitor_response_time": if (value >= 0) monitor.responseTime = Math.round(value); break;
case "monitor_cert_days_remaining": monitor.certDays = Math.round(value); break;
}
Status codes map to 0 = down, 1 = up, 2 = pending, 3 = maintenance. Each gets a severity weight so the list sorts problems to the top, then alphabetically — you should never have to scroll to find the broken thing.
A 200 that isn’t a success
The most useful thing I added wasn’t a feature, it was a diagnostic. During testing I kept getting empty monitor lists from responses that were technically fine, and “no monitors found” told me nothing.
A 200 with zero monitors in it almost never means you have no monitors. It means something other than Kuma answered — an SSO login page, a reverse proxy, a different Prometheus exporter. So instead of returning empty, the code inspects the body and says which:
const looksHtml = /^</.test(trimmed) || contentType.includes("html");
if (looksHtml) {
const title = trimmed.match(/<title[^>]*>([^<]{1,80})/i)?.[1]?.trim();
return `Got an HTML page titled "${title}", not metrics — a login page or proxy is answering instead of Kuma`;
}
If it is valid Prometheus text but nothing starts with monitor_, it says so and names the metrics it actually saw. If the body is genuinely empty, it points at the real cause: /metrics only includes active monitors, so a fully paused instance legitimately returns nothing.
Related: people paste whatever URL they were looking at. https://status.example.com/metrics, or .../dashboard/4. Naively appending /metrics gives you /metrics/metrics, which Kuma’s SPA answers with a cheerful 200 HTML page — a typo that looks exactly like a connection failure. So the base URL gets normalised before use:
export function normaliseBase(url) {
return (url || "")
.trim()
.replace(/[?#].*$/, "")
.replace(/\/+$/, "")
.replace(/\/metrics$/i, "")
.replace(/\/dashboard(\/.*)?$/i, "")
.replace(/\/+$/, "");
}
Permissions
A monitoring extension asking for https://*/* up front is a reasonable thing to be suspicious of. The manifest declares optional_host_permissions instead:
{
"permissions": ["storage", "alarms", "notifications"],
"optional_host_permissions": ["https://*/*", "http://*/*"]
}
The extension therefore installs with no network access at all. When you hit Save & Test in Settings, it requests access to the single origin you typed and nothing else. Every poll checks the grant is still there before running:
const origin = new URL(normaliseBase(baseUrl)).origin;
return await chrome.permissions.contains({ origins: [`${origin}/*`] });
Note:
chrome.storage.localis not encrypted on disk, and that’s where the API key lives. Generate a dedicated key for this and be prepared to rotate it — don’t reuse one that’s wired into anything else.
If Kuma sits behind Cloudflare Access, there’s an optional service-token pair that gets sent as CF-Access-Client-Id / CF-Access-Client-Secret headers.
Failing without blanking
If a poll fails — VPN dropped, tunnel down, laptop moved networks — the popup keeps showing the last known monitor list behind a banner explaining what went wrong. Blanking the UI on a transient network error throws away the only information you had, and “unknown” looks a lot like “fine” at a glance.
The badge follows the same logic: red count for down, orange for pending, empty when clear, and ! only when a poll failed and there’s no cached data to fall back on. Hovering the icon gives you the names of what’s down without opening anything.
Setup
git clone https://github.com/roninimous/kumabar.git
- Open
chrome://extensions(orbrave://extensions,edge://extensions) - Enable Developer mode → Load unpacked → select the folder
- In Uptime Kuma: Settings → API Keys → Add API Key
- Click the KumaBar icon → Settings, paste the base URL and the
uk1_…key - Save & Test, and approve the permission prompt
Verify the endpoint independently if it doesn’t connect:
curl -u ":YOUR_API_KEY" https://status.example.com/metrics
You want lines starting with monitor_status{…}. HTML back means the problem is upstream of the extension.
What it doesn’t do
- Polling only runs while the browser is open. This is a glanceable indicator, not an alerting system — keep your real notification channels.
- One minute is the floor on the interval, imposed by Chrome.
/metricsexposes current state only. No history, no charts, no pausing or editing monitors.- On older Kuma builds without the
monitor_idlabel, monitors key off the name, so renaming one reads as a new monitor.
Source is MIT on GitHub. Not on the Web Store — load it unpacked.
Originally posted on noukeosombath.com.