Rewrite demoTimeline.ts around the three-route scenario and rebuild the scripted replay inside devStub.ts on top of the 1.0 endpoints, then bring the shipped documentation in line with what actually ships. Demo: - demoTimeline.ts keys its online windows by PBS id, so one field covers a single-box backup and a sync route's two; steps carry the per-device names the backend really emits, plus their detail. - The demo auto-plays: it opens mid-backup on Nightly and the queued Lab route starts by itself when that lands, with the target left awake between them and the skipped power-off recording why. The queue and the power lease are visible without a click. - The clock ticks and the fixture calendar shifts by whole weeks, so weekdays and times survive and the schedules stay self-consistent. Dev stub mode keeps its frozen clock; every replay mutation sits behind the demo flag. - Restore the "fake data" banner and make logout reload rather than strand the visitor on a login form. - build:demo now type-checks first, which it never did. Fix the expanded run history row refetching its detail only once, so a run in flight showed a frozen step timeline while its task log kept streaming. Docs: - ARCHITECTURE: the route model, the queue and lease, a cycle per kind, the migration, and REST tables rebuilt from the shipped routers. - CONFIG-WIZARD: the two device flows, and the /remote grant a sync route needs on a peer configured before 1.0. - INTEGRATIONS: the new dashboard payload, snippets matching the ones the app generates, the labelled metric names, and a 0.9 mapping table. - README, INSTALL: routes, the five settings tabs, upgrading from 0.9, and the Node version CI and the image actually build with. - SECURITY: transport pinning, auth hardening, the two API-key endpoints outside the session, and what Joulenap deliberately does not do. - CONTRIBUTING: npm test is a separate CI step, and the demo section now describes the demo that exists. - CHANGELOG: the 1.0.0 entry, including the breaking dashboard and metrics shapes and the exclude guest mode widening to all.
16 KiB
Integrations
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.). Joulenap only stores a copy needed to verify requests; it won't show you the key again.
- 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
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
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
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
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.