Checked every tracked .md against the code rather than for plausibility. The API table, the metric names, the dashboard payload, the permissions cheat-sheet, every config field and SECURITY's numbers all already matched. Four things did not. ARCHITECTURE contradicted itself on the power lease: keying it on the host updated the description a few lines up but left a later paragraph still saying the lease is per device, complete with the extra sleep/wake cycle that no longer happens. Replaced with the consequence that is now true -- every device on a held machine reads busy, which is what disables the power button on its siblings. The startup catch-up was documented nowhere despite sending notifications. ARCHITECTURE's scheduler section now covers it and the heartbeat that decides what counts as downtime. frontend/README pointed at design/joulenap-remix/, which .gitignore excludes -- a path no clone has. It also filed the setup wizard under the settings panels, where it has not lived since it moved to src/wizard/, and said npm install where the lockfile wants npm ci. Two wordings tightened: the WoL interface is a dropdown now, not free text with an override; and "stores a copy needed to verify requests" read as though the API key were hashed, when it sits in config.yaml like every other secret.
16 KiB
Integrations
Upgrading from 0.9? Both payloads changed shape — the field-by-field 0.9 → 1.0 mapping is here
Breaking in 1.0. Both payloads changed shape, because a Joulenap install now has several routes and several backup servers — there is no single "next run" or "the datastore" left to report. Update any widget or query built on 0.9:
0.9 1.0 pbs_statepbss[].state(pbss.0.statefor the first box)next_runroutes[].next_runlast_run_status/last_run_timeroutes[].last_run_status/.last_run_timedatastore_used_pct/_used_bytes/_total_bytespbss[].datastore_used_pct/ …— state(the app's own idle/running/paused) is new and still top-leveljoulenap_next_run_timestamp_secondsjoulenap_route_next_run_timestamp_seconds{route=}joulenap_last_run_*joulenap_route_last_run_*{route=}joulenap_pbs_*,joulenap_datastore_*same names, now labelled {pbs=}joulenap_guest_last_backup_timestamp_seconds{vmid}{vmid, pve, pbs}Settings → Integrations always shows a snippet generated for the version you are running; if in doubt, copy from there rather than from this page.
Joulenap exposes two read-only, API-key-protected endpoints for other tools:
GET /api/dashboard— a flat JSON summary for homelab dashboards (Homepage, Homarr, Dashy, Glance). See below.GET /metrics— Prometheus metrics for Grafana. See Prometheus & Grafana.
Both use the same API key, generated once under Settings → Integrations; enabling the integration enables both.
Enabling it
- Open Joulenap → Settings → Integrations.
- Click Generate API key. The key is shown once — copy it somewhere
safe (a password manager, your dashboard's secret store, etc.). The
interface will not show it again: like every other secret it lives in
config.yamland is redacted from every API response, so recovering it means reading that file on the host — or generating a new one. - Pick your dashboard in the picker on that page to get a ready-to-paste config snippet with the key and endpoint URL already filled in.
- Disabling the integration (the Disable button) clears the key, and both endpoints immediately start rejecting requests again.
Regenerating the key invalidates the previous one immediately — update any dashboard or scrape config that used the old key.
Authentication
Every request to /api/dashboard and /metrics must include the API key,
either as:
- an
X-API-Keyheader (preferred, wherever the client supports custom request headers), or - a
?key=<your-api-key>query parameter appended to the URL, for dashboards/widgets that can't set custom headers — and for Prometheus, whoseparams:setting works on every version.
No key configured → 403 Forbidden (integration disabled). Wrong or missing
key → 401 Unauthorized.
Dashboard integration
GET /api/dashboard lets a homelab dashboard poll Joulenap and show your
backup status alongside your other services: whether each backup server is
asleep/awake/backing up, when each route next runs, how its last run went,
and how full each datastore is.
This endpoint is intentionally separate from the internal /api/status used
by Joulenap's own UI: it's a stable, additive-only public contract with
plain machine-readable values (no localization, no session cookie), guarded
by its own API key instead of a login session.
Response reference
GET /api/dashboard returns one top-level field and two lists — one entry
per route, one per backup server:
{
"state": "running",
"routes": [
{
"id": "nightly",
"name": "Nightly",
"kind": "backup",
"enabled": true,
"next_run": "2026-07-10T02:00:00Z",
"last_run_status": "success",
"last_run_time": "2026-07-09T02:00:00Z"
}
],
"pbss": [
{
"id": "pbs-01",
"state": "backing_up",
"datastore_used_pct": 62,
"datastore_used_bytes": 1900000000000,
"datastore_total_bytes": 3100000000000
}
]
}
| Field | Type | Values / meaning |
|---|---|---|
state |
string | idle | running | paused — what Joulenap is doing, not a backup server |
routes[].id / .name |
string | the route's id, and the name you gave it (falls back to the id) |
routes[].kind |
string | backup | sync | external | verify |
routes[].enabled |
boolean | false for a route you paused |
routes[].next_run |
string | null | ISO 8601; null when the route is not armed (disabled, or the kill-switch is off) |
routes[].last_run_status |
string | success | failed | never |
routes[].last_run_time |
string | null | ISO 8601 of that run's start; null if it never ran |
pbss[].id |
string | the backup server's id |
pbss[].state |
string | sleeping | online | backing_up (a run is holding its power lease) |
pbss[].datastore_used_pct |
number | null | percent used; null if never probed |
pbss[].datastore_used_bytes |
number | null | bytes used — served from cache while the box sleeps |
pbss[].datastore_total_bytes |
number | null | total bytes (free = total − used) |
Both lists follow your configuration order, so routes.0 is your first
configured route — not "the" route. A widget that only has room for one line
should pick a specific entry; one that can iterate should range over the list.
Per-dashboard setup
The endpoint URL is your Joulenap instance's origin plus /api/dashboard,
e.g. http://192.168.1.50:8080/api/dashboard. Replace <your-api-key> with
the key from step 2 above in every snippet below.
Homepage — the built-in customapi widget
Homepage's built-in customapi widget maps JSON response fields directly
onto labelled rows:
- Joulenap:
icon: http://192.168.1.50:8080/assets/joulenap-icon.svg
href: http://192.168.1.50:8080
widget:
type: customapi
url: http://192.168.1.50:8080/api/dashboard
headers:
X-API-Key: <your-api-key>
mappings:
- field: state
label: Joulenap
- field: routes.0.name
label: Route
- field: routes.0.next_run
label: Next run
format: relativeDate
- field: routes.0.last_run_status
label: Last run
- field: pbss.0.datastore_used_pct
label: Datastore
format: percent
# .0 is the first configured route / backup server — add more mappings for the rest.
Glance — the custom-api widget with a Go template
Glance's custom-api widget fetches the JSON and renders it through a Go
template:
- type: custom-api
title: Joulenap
url: http://192.168.1.50:8080/api/dashboard
headers:
X-API-Key: <your-api-key>
template: |
<div>State: {{ .JSON.String "state" }}</div>
{{ range .JSON.Array "routes" }}
<div>{{ .String "name" }}: {{ .String "last_run_status" }}
(next {{ .String "next_run" }})</div>
{{ end }}
{{ range .JSON.Array "pbss" }}
<div>{{ .String "id" }}: {{ .String "state" }}
— {{ .Int "datastore_used_pct" }}%</div>
{{ end }}
Glance ranges over both lists, so it grows with your config on its own — no index to pick and nothing to edit when you add a route or a second backup server.
Homarr — the Custom API widget (v1.65+, configured in the dashboard)
Note: Homarr's widget system changed significantly in 2026. Older Homarr releases only offered a generic iframe/link-style widget with no real JSON field mapping. As of the "Custom Widgets" feature (Homarr v1.65+), there is a proper, dashboard-managed Custom API widget — no YAML file to edit. If you're on an older version, upgrade for the field mapping described below; otherwise fall back to an iframe/link widget pointed at the
?key=URL.Configure it under Management → Custom Widgets → Add (or from the dashboard's widget picker → Custom API, depending on version):
- URL:
http://192.168.1.50:8080/api/dashboard- HTTP Method:
GET- Authentication:
API Key (Header)→ Header NameX-API-Key, value<your-api-key>(useAPI Key (Query)instead if your Homarr version only offers query-parameter auth, with parameter namekey)- Display Type:
Key Value(orCustom JSXfor full control over layout)- Map the fields you want to show:
Index into the lists —state idle | running | paused routes[] id, name, kind, enabled, next_run, last_run_status, last_run_time pbss[] id, state, datastore_used_pct, datastore_used_bytes, datastore_total_bytesroutes.0.next_runis the first configured route's next run.If your version can't set a custom header at all, use the query-string fallback for the URL field instead:
http://192.168.1.50:8080/api/dashboard?key=<your-api-key>
Dashy — the customapi widget
Note: Dashy's generic JSON widget is called
customapi(it was explicitly modeled after Homepage's widget of the same name), not a plain iframe. It supports request headers and the same kind of field mappings as Homepage:
- type: customapi
options:
url: http://192.168.1.50:8080/api/dashboard
headers:
X-API-Key: <your-api-key>
mappings:
- field: routes.0.name
label: Route
- field: routes.0.next_run
label: Next run
format: relativeDate
- field: routes.0.last_run_status
label: Last run
- field: pbss.0.datastore_used_pct
label: Datastore
format: percent
If your self-hosted Joulenap doesn't send CORS headers and the widget fails
to fetch, set the widget-level useProxy: true so Dashy fetches server-side
instead of from the browser. If your Dashy version predates the customapi
widget, use the query-string fallback
(http://192.168.1.50:8080/api/dashboard?key=<your-api-key>) with whatever
generic widget your version offers.
Prometheus & Grafana
GET /metrics exposes Joulenap's state in the Prometheus text format, so a
homelab Prometheus can scrape it and Grafana can graph it — and, more to the
point, so Alertmanager can tell you when a guest stops being backed up.
It's served at /metrics (not under /api) because that's Prometheus's
default metrics_path.
A scrape reads the database and does the same one-second TCP probe the dashboard uses. It never wakes the PBS, and datastore usage and per-guest backup times come from Joulenap's cache, so they keep reporting while the box is asleep — which is most of the time, by design.
Scrape config
Prometheus's params: works on every version, unlike custom scrape headers:
scrape_configs:
- job_name: joulenap
metrics_path: /metrics
params:
key: ["<your-api-key>"]
static_configs:
- targets: ["192.168.1.50:8080"]
A 60s scrape_interval is plenty — nothing here changes faster than a
backup cycle.
Metric reference
All metrics are gauges prefixed joulenap_. Almost everything is labelled:
per-route series carry route=, per-box series carry pbs=.
| Metric | Labels | Meaning |
|---|---|---|
joulenap_build_info |
version |
Always 1; the label carries the running version |
joulenap_scheduler_enabled |
— | 1 if the kill-switch is on (routes may be armed) |
joulenap_job_running |
— | 1 while a backup, sync, GC or verify run is in flight |
joulenap_queued_runs |
— | Runs waiting behind the one in flight |
joulenap_pbs_online |
pbs |
1 if this backup server answers on its API port, 0 while asleep |
joulenap_pbs_cpu_percent |
pbs |
CPU %, only present while that box is awake |
joulenap_pbs_memory_percent |
pbs |
Memory %, only while awake |
joulenap_pbs_uptime_seconds |
pbs |
Uptime, only while awake |
joulenap_datastore_used_bytes |
pbs, datastore |
Datastore bytes used (last known value) |
joulenap_datastore_total_bytes |
pbs, datastore |
Datastore size in bytes (last known value) |
joulenap_route_next_run_timestamp_seconds |
route |
Unix time this route next fires; absent when it isn't armed |
joulenap_route_last_run_timestamp_seconds |
route |
When this route's last finished run started |
joulenap_route_last_run_success |
route |
1 if it succeeded, 0 if it failed or was aborted |
joulenap_route_last_run_duration_seconds |
route |
How long that run took |
joulenap_route_last_run_guests |
route |
Guests backed up by that run |
joulenap_guest_last_backup_timestamp_seconds |
vmid, pve, pbs |
Each guest's most recent snapshot |
joulenap_runs_recent |
kind, status |
Finished runs in the history window |
joulenap_guest_last_backup_timestamp_seconds is labelled by the PVE the
guest lives on and the backup server holding the snapshot as well as the
vmid, because a vmid alone stopped being unique the moment a second PVE could
exist — and the same guest synced to two boxes is legitimately two series.
Two things worth knowing before you write queries:
- A value Joulenap doesn't have is an absent series, not a zero. A route
that has never run has no
joulenap_route_last_run_timestamp_seconds, and a disabled one has no..._next_run_...— publishing0would graph your last backup as January 1970. Useabsent()to alert on "never ran". joulenap_runs_recentis a gauge, not a counter. The daily prune job deletes runs older thanmaintenance.history.retention_days, so the number legitimately goes down —rate()andincrease()would be nonsense on it. It answers "how many failures are in my retention window", not "how many ever".
Useful queries
# Hours since each guest was last backed up
(time() - joulenap_guest_last_backup_timestamp_seconds) / 3600
# Datastore usage percent, per backup server
100 * joulenap_datastore_used_bytes / joulenap_datastore_total_bytes
# Days until each datastore is full, from the last week's growth
(joulenap_datastore_total_bytes - joulenap_datastore_used_bytes)
/ (deriv(joulenap_datastore_used_bytes[7d]) * 86400)
# Share of recent backup cycles that succeeded
joulenap_runs_recent{kind="cycle",status="success"}
/ sum by () (joulenap_runs_recent{kind="cycle"})
# Routes whose last run failed
joulenap_route_last_run_success == 0
# Hours since each route last ran
(time() - joulenap_route_last_run_timestamp_seconds) / 3600
The run kinds you'll see on joulenap_runs_recent are cycle (a backup
route), sync, monitor (an external route's watch), verify and gc.
Alerting rules
The one that justifies wiring this up at all — a guest quietly falling out of your backup set:
groups:
- name: joulenap
rules:
- alert: JoulenapGuestBackupStale
expr: time() - joulenap_guest_last_backup_timestamp_seconds > 172800
for: 1h
annotations:
summary: >-
Guest {{ $labels.vmid }} on {{ $labels.pve }} has no backup on
{{ $labels.pbs }} in over 48h
- alert: JoulenapRouteFailed
expr: joulenap_route_last_run_success == 0
for: 15m
annotations:
summary: "Route {{ $labels.route }} did not succeed on its last run"
- alert: JoulenapRouteNeverRan
expr: absent(joulenap_route_last_run_timestamp_seconds{route="nightly"})
for: 24h
annotations:
summary: "Route nightly has never completed a run"
- alert: JoulenapDatastoreFilling
expr: 100 * joulenap_datastore_used_bytes / joulenap_datastore_total_bytes > 85
for: 1h
annotations:
summary: "Datastore {{ $labels.datastore }} on {{ $labels.pbs }} is over 85% full"
absent() needs the series named in full, so the "never ran" alert has to
name the route — repeat the rule per route you care about, or template it out
of your own config.
Set the staleness threshold to comfortably more than your backup interval —
172800 (48h) suits a nightly schedule; a run that starts late or takes a
while shouldn't page you.