Getting started
The first person to open the app registers the admin account, and self-registration then closes permanently. Every account after that is created from the Users page. This matters if you put Hub-Bro somewhere reachable: nobody who finds the URL can sign themselves up.
Then the loop is always the same three steps:
- Add a data source — where the numbers come from.
- Create a dashboard — pick who can see it while creating it, not after.
- Add a widget — choose the source, write the query, pick a shape.
To try it without connecting anything real, add a REST source pointing at
https://jsonplaceholder.typicode.com/users and put a table
widget on it.
Data sources
A source holds a connection, not a query. The query lives on the widget, so one source feeds many widgets and one credential is stored once.
Prometheus
Base URL only — include the path prefix if it sits behind a proxy, for
example https://10.1.6.62/prometheus. Hub-Bro appends
/api/v1/query itself. If a proxy in front asks for a login,
fill in the username and password fields.
Labels come back as their own columns (instance,
job, whatever your exporter sets), which is what makes
filtering and joining possible.
GLPI
The API URL is the apirest.php endpoint, not the web UI:
http://glpi.example/apirest.php. You need an App-Token from
Setup → General → API and a user token from your own profile.
Sessions, pagination and filter pushdown are handled for you.
SQL
PostgreSQL, MySQL, MariaDB, SQLite, Doris and StarRocks. Each widget holds
one statement, and only SELECT (or WITH … SELECT)
is accepted — enforced on the server, not in the form.
REST API
Any JSON endpoint. Choose GET or POST, add headers, and for POST supply a JSON body — which is what Elasticsearch, GraphQL and most DQL-style endpoints need. The body is not encrypted, so put credentials in headers.
Data path is a dot-path to the array inside the response, e.g.
data.items. Leave it blank if the response is already a list.
Certificates
There is no "verify SSL" checkbox, because it always got answered wrongly — switched off for an internal host and then left off for a public API. Verification is decided by the address instead: private ranges and hostnames without a dot skip it, anything reachable from the internet does not. Sources saved before this keep whatever they had.
Widgets
Seven types: line, bar, pie, stat, gauge, table, and markdown text. Two are worth explaining beyond their names.
Stat with supporting numbers
A stat shows one big number and a line of smaller ones underneath, filled
from the same result with {column} placeholders. Pair it with
Count into buckets, which collapses many rows into a single
row of named counts:
Query sysCmFailoverStatusId{job="f5ltm"}
Count into buckets
Column to count value
Name for the total total
Buckets active 4 → 4
standby 3 → 3
Value field total → 8
Supporting numbers Active {active} | Standby {standby}
One fetch, so the numbers in one tile can never disagree with each other — which three separate widgets refreshing independently can.
Several queries in one table
Prometheus cannot return a table: one query gives one value per series. A table widget on a Prometheus source therefore takes a list of queries, each supplying one column, joined on a label they all carry:
Queries
sysGlobalHostCpuUsageRatio{job="f5ltm"} → CPU
sysCmFailoverStatusId{job="f5ltm"} → Failover
Join on instance
The join is an outer join: a device that answers one query but not another keeps its row with a blank. An inner join would hide exactly the device you need to look at.
Tables
Four options that change how much a table tells you at a glance:
| Option | What it does |
|---|---|
| Colour rules | Colour a cell — or the whole row — by value. First matching rule wins, so put the strictest first. |
| Bar columns | Draws a fill behind the number. Columns that read as percentages snap to 0–100; anything else scales to the busiest row. |
| Number format | Decimals and a unit, per column. Only changes what is shown — sorting and colour rules still use the full value. |
| Link columns | Turns a column into links, with {column} filled from the row. |
Landing on one row
A link can carry a search: append ?q={ticket_id} and the page
it opens starts with that text in its search box, narrowed to that row. The
box stays editable, so nobody is trapped there. The filtering happens in the
browser, which is why a public link can never steer the query the server runs.
Dashboards
Drag widgets anywhere on a 12-column grid; the layout saves itself. Widgets can be locked so a stray drag on a wall display doesn't rearrange the board.
- Folders and pins — group dashboards, and pin the ones you open daily to the top of their group.
- Cross-filtering — click a slice, bar or row to filter every widget; click again to release.
- Time range — a picker from 15 minutes to 30 days. Widgets follow it, or pin their own window.
- History — every distinct layout is kept and restorable.
- Drill-down — a widget can link to another dashboard, and the back button returns to where you came from rather than the list.
Sharing
A dashboard is workspace or private, chosen when you create it so a private one is never briefly visible to everyone. Private ones can be shared with named people.
A share link is different: an unguessable URL that needs no
account. Add ?kiosk=1&refresh=60 for a wall display —
no chrome, refreshing every 60 seconds.
Alerts
An alert rule is a query, a threshold, and a webhook. Three behaviours make it liveable:
- It speaks on change. A metric sitting at critical for six hours is one message, not seventy-two.
- Fire after N checks is the flap guard: with 2, a single bad reading stays quiet.
- Recovery is always reported. A channel that only carries bad news gets muted.
Destinations: Slack (also Mattermost and Rocket.Chat), Microsoft Teams, generic JSON, or a custom body. Use Test to prove it works before you need it.
Scheduled reports
A report is the opposite promise from an alert: it arrives at its time whether the news is good or bad. That is the point — silence from an alert is ambiguous, but a report that stops arriving is itself a signal.
Mode Send a report on a schedule
Send at 08:00
Days mon,tue,wed,thu,fri (blank = every day)
Timezone Asia/Jakarta
Message Ticket report
Breach {breach} | Warning {warning} | On track {on_track}
The placeholders come from the query's own columns, so this pairs with Count into buckets exactly as a stat tile does. A missed window is not made up: if the server was down at 08:00 and returns at 11:00 the report still goes out, but yesterday's is never replayed today.
Sending to WhatsApp
WhatsApp has no standard incoming webhook, and every gateway names its
fields differently. Rather than baking in a vendor, choose
Custom body and write the JSON yourself, with
{message} where the text goes:
URL http://waha:3000/api/sendText
Token header X-Api-Key
Token your gateway key
Body {"session": "default",
"chatId": "6281234567890@c.us",
"text": "{message}",
"linkPreview": false}
The token travels as a header, never in the URL, so it does not land in an access log on the way. Both are stored encrypted and masked afterwards.
Users and roles
| Capability | Admin | Editor | Viewer |
|---|---|---|---|
| View dashboards, export CSV/PNG | ✓ | ✓ | ✓ |
| Create and edit dashboards, share links | ✓ | ✓ | — |
| Choose a source when building a widget | ✓ | ✓ | — |
| Data sources page, add and edit sources | ✓ | — | — |
| Source health page | ✓ | — | — |
| Manage users | ✓ | — | — |
Viewers cannot see source configs, because those expose internal hostnames and which systems exist even with credentials masked. They also cannot call the ad-hoc query endpoint — otherwise "read-only" would still allow arbitrary SQL against a SQL source.
You cannot demote, deactivate or delete the last remaining admin, or yourself. Deactivating someone takes effect immediately, even if they hold a valid token.
Deploying
The image is published on
Docker Hub as
fadlanfasya/hub-bro, so a server needs only a
docker-compose.yml and a .env — no source
checkout. Grab the compose file from
the repository.
cp .env.example .env
# generate a key and put it in .env as SECRET_KEY
docker run --rm python:3.12-slim \
python -c "import secrets; print(secrets.token_urlsafe(48))"
docker compose pull
docker compose up -d
The app is then on http://<server>:8080 — UI and API on
one port, no separate web server needed. For TLS, put nginx in front with
the bundled docker-compose.nginx.yml.
SECRET_KEY must stay the same forever. It
decrypts your stored source credentials. Changing it does not delete them —
it makes them unreadable, which looks like every source failing at once.
Back it up somewhere other than the server.
Upgrading is docker compose pull && docker compose up -d.
Dashboards, users and credentials live in the hubbro-data
volume and are untouched by an image update. New columns are added
automatically on start.
When something breaks
| Symptom | Usually |
|---|---|
| Every source fails right after an upgrade | SECRET_KEY changed. Restore the old one; the data is fine. |
GLPI: ERROR_SESSION_TOKEN_MISSING | The API URL has a path after apirest.php, or the tokens are empty. |
| A stat shows a huge number like 1755200000 | The value field picked up a timestamp. Set it to value explicitly. |
| A widget shows a number that never changes | Response caching. Press Refresh data, which clears the cache for that dashboard's sources. |
| A webhook to a container fails | localhost inside a container means the container. Use the service name or the host address. |
| A report stopped arriving | Check the rule's history first — delivery failures are recorded there, not only in the server log. |