feat: initial Blocky widget for Homepage dashboard

Parses Blocky's /metrics (Prometheus text) endpoint to show:
- Blocking status (Enabled/Disabled)
- Blocked domains count (denylist entries)
- Total queries served
- Cache hit rate %

No REST stats endpoint exists in Blocky, so the proxy handler
fetches and parses the Prometheus text format directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Alkim Ake Gozen 2026-05-21 11:59:09 +09:00
commit cc34c22aa5
7 changed files with 227 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
.DS_Store

70
INSTALL.md Normal file
View file

@ -0,0 +1,70 @@
# Installation
These files integrate as a standard service widget into the [Homepage](https://gethomepage.dev) dashboard source.
Homepage has no plugin system — you need to patch the source and rebuild.
## 1. Copy widget files
```bash
cp -r src/widgets/blocky /path/to/homepage/src/widgets/
```
## 2. Register the widget
**`src/widgets/widgets.js`** — add import and export:
```js
import blocky from "./blocky/widget";
// ... existing imports ...
export default {
blocky,
// ... existing widgets ...
};
```
**`src/widgets/components.js`** — add dynamic import:
```js
const components = {
blocky: dynamic(() => import("./blocky/component")),
// ... existing components ...
};
```
## 3. Add translations
In `public/locales/en/common.json`, merge the contents of `translations/en.json`
into the top-level object. Repeat for any other locale files you use.
## 4. Homepage config
```yaml
# services.yaml
- Infrastructure:
- Pi DNS:
href: http://10.0.50.5:4000
description: DNS ad-blocker
icon: blocky.png
widget:
type: blocky
url: http://10.0.50.5:4000
```
## What the widget shows
| Field | Source metric |
|---|---|
| Status | `blocky_blocking_enabled` (Enabled / Disabled) |
| Blocked Domains | `blocky_denylist_cache_entries{group="ads"}` |
| Total Queries | `blocky_cache_hits_total` + `blocky_cache_misses_total` |
| Cache Hit Rate | hits / (hits + misses) × 100 |
Data is pulled from Blocky's Prometheus `/metrics` endpoint — the only source
that exposes query counts. The REST `/api/blocking/status` endpoint only returns
the blocking toggle state.
## Upstream
There is an open feature request at
https://github.com/gethomepage/homepage/discussions/2732 (currently 8 upvotes —
Homepage requires 20 before considering a PR). Once it crosses that threshold,
this widget can be submitted as a PR to the official repo.

47
README.md Normal file
View file

@ -0,0 +1,47 @@
# homepage-blocky-widget
A [Blocky](https://0xerr0r.github.io/blocky/) service widget for the
[Homepage](https://gethomepage.dev) dashboard.
Blocky is a DNS ad-blocker and resolver. This widget shows live stats pulled
from Blocky's Prometheus `/metrics` endpoint.
## Preview
```
┌─────────────────────────────────────────┐
│ Pi DNS │
│ DNS ad-blocker │
│ │
│ Status Enabled │
│ Blocked Domains 84,242 │
│ Total Queries 333 │
│ Cache Hit Rate 10.5% │
└─────────────────────────────────────────┘
```
## Requirements
- Blocky with `ports.http` configured (default: 4000)
- Prometheus metrics enabled in Blocky config:
```yaml
prometheus:
enable: true
path: /metrics
```
## Installation
See [INSTALL.md](INSTALL.md).
## Why a custom widget?
Blocky has no REST endpoint for query statistics — only `/api/blocking/status`
(toggle state) and `/metrics` (Prometheus text format). This widget includes a
small proxy handler that fetches and parses the metrics endpoint, returning the
relevant values as JSON to the React component.
There is an open feature request at the official Homepage repo:
https://github.com/gethomepage/homepage/discussions/2732
Once it reaches the required 20 upvotes, this widget can be submitted upstream.

View file

@ -0,0 +1,46 @@
import { useTranslation } from "next-i18next";
import Block from "components/services/widget/block";
import Container from "components/services/widget/container";
import useWidgetAPI from "utils/proxy/use-widget-api";
export default function Component({ service }) {
const { t } = useTranslation();
const { widget } = service;
const { data, error } = useWidgetAPI(widget, "metrics");
if (error) {
return <Container service={service} error={error} />;
}
if (!data) {
return (
<Container service={service}>
<Block label="blocky.status" />
<Block label="blocky.domains" />
<Block label="blocky.queries" />
<Block label="blocky.cacheHitRate" />
</Container>
);
}
return (
<Container service={service}>
<Block
label="blocky.status"
value={data.enabled ? t("blocky.enabled") : t("blocky.disabled")}
/>
<Block
label="blocky.domains"
value={t("common.number", { value: data.denylistEntries })}
/>
<Block
label="blocky.queries"
value={t("common.number", { value: data.totalQueries })}
/>
<Block
label="blocky.cacheHitRate"
value={`${data.cacheHitRate.toFixed(1)}%`}
/>
</Container>
);
}

View file

@ -0,0 +1,38 @@
import { httpProxy } from "utils/proxy/http";
import getServiceWidget from "utils/service-helpers";
import { formatApiCall } from "utils/proxy/api-helpers";
import createLogger from "utils/logger";
const logger = createLogger("blocky");
function parseMetric(text, name) {
// Matches bare metric or metric with labels: my_metric{label="x"} 1.0
const re = new RegExp(`^${name}(?:{[^}]*})?\\s+([0-9.e+\\-]+)`, "m");
const m = text.match(re);
return m ? parseFloat(m[1]) : 0;
}
export default async function blockyProxyHandler(req, res) {
const { group, service } = req.query;
const widget = await getServiceWidget(group, service);
const url = formatApiCall("{url}/metrics", widget);
const [status, , data] = await httpProxy(url);
if (status !== 200) {
logger.error("HTTP %d fetching Blocky metrics from %s", status, url);
return res.status(status).json({ error: `HTTP ${status}` });
}
const text = data.toString();
const hits = parseMetric(text, "blocky_cache_hits_total");
const misses = parseMetric(text, "blocky_cache_misses_total");
const total = hits + misses;
return res.json({
enabled: parseMetric(text, "blocky_blocking_enabled") === 1,
denylistEntries: parseMetric(text, "blocky_denylist_cache_entries"),
totalQueries: total,
cacheHitRate: total > 0 ? (hits / total) * 100 : 0,
});
}

View file

@ -0,0 +1,14 @@
import blockyProxyHandler from "./proxy";
const widget = {
api: "{url}/{endpoint}",
proxyHandler: blockyProxyHandler,
mappings: {
metrics: {
endpoint: "metrics",
},
},
};
export default widget;

10
translations/en.json Normal file
View file

@ -0,0 +1,10 @@
{
"blocky": {
"status": "Status",
"enabled": "Enabled",
"disabled": "Disabled",
"domains": "Blocked Domains",
"queries": "Total Queries",
"cacheHitRate": "Cache Hit Rate"
}
}