Hub-Bro

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:

  1. Add a data source — where the numbers come from.
  2. Create a dashboard — pick who can see it while creating it, not after.
  3. 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.

Doris and StarRocks speak the MySQL protocol on the query port 9030, not the HTTP port 8030. Connecting to 8030 fails in a way that looks like a credential problem but isn't.

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:

OptionWhat it does
Colour rulesColour a cell — or the whole row — by value. First matching rule wins, so put the strictest first.
Bar columnsDraws a fill behind the number. Columns that read as percentages snap to 0–100; anything else scales to the busiest row.
Number formatDecimals and a unit, per column. Only changes what is shown — sorting and colour rules still use the full value.
Link columnsTurns a column into links, with {column} filled from the row.
Set Max on a bar column when every value huddles at the bottom of its scale. A session column reading 0.0–0.6% on a 0–100 bar is a row of invisible hairlines; setting Max to 2 makes the differences readable.

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.

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.

Being invited to a dashboard grants the numbers on it, not access to the source behind them. Someone reading your GLPI dashboard cannot query GLPI.

Alerts

An alert rule is a query, a threshold, and a webhook. Three behaviours make it liveable:

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.

Two things to know. Meta's official Cloud API cannot post to groups and requires a pre-approved template for business-initiated messages — a daily ops report fits it badly. Unofficial gateways run a WhatsApp Web session and can get the number banned, so use a dedicated number rather than a personal one.

Users and roles

CapabilityAdminEditorViewer
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

SymptomUsually
Every source fails right after an upgradeSECRET_KEY changed. Restore the old one; the data is fine.
GLPI: ERROR_SESSION_TOKEN_MISSINGThe API URL has a path after apirest.php, or the tokens are empty.
A stat shows a huge number like 1755200000The value field picked up a timestamp. Set it to value explicitly.
A widget shows a number that never changesResponse caching. Press Refresh data, which clears the cache for that dashboard's sources.
A webhook to a container failslocalhost inside a container means the container. Use the service name or the host address.
A report stopped arrivingCheck the rule's history first — delivery failures are recorded there, not only in the server log.