Docs Asking questions

Dashboards

Saved reports as tiles on one screen, and the secret link that shows one to someone without an account.

A dashboard is a named layout of saved reports — trends, retention reports and funnels — as an ordered list of tiles, each { "kind", "report_id", "width" } with kind one of trend, retention, funnel and width one of half, full. Position is the array index. There is no /run endpoint: a tile runs by the call its report already answers to — GET /v1/events/stats, POST /v1/reports/retention or POST /v1/funnels/:id/run — with whatever range the viewer chose.

curl -X POST http://localhost:3000/v1/dashboards \
  -H "x-lyraflow-server-key: $LYRAFLOW_SERVER_KEY" \
  -H 'content-type: application/json' \
  -d '{ "name": "Overview",
        "tiles": [ { "kind": "trend", "report_id": 3, "width": "half" },
                   { "kind": "funnel", "report_id": 1, "width": "full" } ] }'
Method & pathDoes
GET /v1/dashboardsList every dashboard in the project — name, tile count, whether it is home, and "shared": true|false
POST /v1/dashboardsCreate one, with or without tiles
GET /v1/dashboards/:idRead one, each tile carrying its report in that report’s own shape, or null if it has been deleted; carries "share": { "token", "shared_at" }, or null if it is not shared
PATCH /v1/dashboards/:idRename it, replace its tiles, or set is_home
DELETE /v1/dashboards/:idDelete it — 204. The reports it showed are untouched, and its share link, if any, is gone with it
POST /v1/dashboards/:id/shareMint a secret link, or return the existing one — see Sharing a dashboard
DELETE /v1/dashboards/:id/shareRevoke the link — 204, or 404 not_shared if there is none — see Sharing a dashboard

A duplicate name within the same project is a 409. A non-numeric :id is a 400 naming invalid_dashboard_id; an id that does not exist, or belongs to another project, is a 404. A tile a write INTRODUCES that names a report which does not exist in this project is refused with 400 and {"error": "report_not_found", "kind", "report_id"}. Tiles already stored on the dashboard are not re-checked: deleting a report is allowed, the read returns that tile with "report": null rather than dropping it, and a PATCH carrying the whole layout — which is what an edit sends — must still be able to reorder, resize and remove around it. At most twelve tiles; the thirteenth is a 400. A report is on a dashboard at most once; a second tile naming the same report is a 400 too, naming that tile’s index in detail[].path (tiles.1). The same id under two kinds is two reports, and fine.

is_home: true makes this dashboard the project’s home and clears the previous one in the same transaction — there is exactly one per project, and the database enforces it. is_home: false clears it. Deleting the home leaves the project with none, and the web UI opens the feed again.

What is not stored is the range, for the same reason a saved trend or funnel never stores one. A dashboard reopens over whatever range the viewer has, never the one it was last looked at with. When the viewer picks no range, none is sent, and each of these endpoints applies its own default window — so the tiles on one dashboard are then showing different periods, which the web UI states under the picker.

Every row carries definition_version, and a stored layout this build cannot parse comes back "stale": true with "tiles": [] rather than failing the list — the same behaviour a saved report has.

Sharing a dashboard

A dashboard can be shared by a secret link. POST /v1/dashboards/:id/share mints a token and returns { "token", "shared_at" }; calling it again while the dashboard is already shared returns the same link rather than minting a new one, so a retried request or a second open tab cannot rotate the link out from under someone who already copied it. The link is https://<your host>/shared/<token>, and anyone holding it can open the dashboard with no login, pick a preset range and see every tile — the viewer page never shows the project name, and a tile there is inert: no link to the report behind it, no funnel step drill-down. DELETE /v1/dashboards/:id/share revokes it; the next request against that link is a 404, and every bookmark of it breaks. Calling it on a dashboard that is not currently shared — never shared, or already revoked — is a distinct 404 { "error": "not_shared" }, so a caller can tell “there was nothing to undo” from “that dashboard isn’t yours”. There is no separate rotate call — rotating a link is revoke then share again. One link per dashboard, and deleting the dashboard deletes its link with it. GET /v1/dashboards rows carry "shared": true|false, and GET /v1/dashboards/:id carries the token and its creation time as "share", or null when the dashboard has no link — the same pair the web UI’s dashboards list and Share card read.

What a link exposes is the dashboard’s name, each tile’s report’s name, the event names it queries, every breakdown value a trend is split by and every filter value in a stored where — the same list the web UI’s Share card shows before it creates a link. A trend split by email address publishes those addresses to whoever holds the link; sharing is a decision about the report, not only about the dashboard around it.

The viewer surface takes no key or session at all — it is the first unauthenticated read path in this API; every other read requires a server key or a session, and ingest requires a write key. A token is not a server key or a session cookie, and presenting one where either is expected authenticates nothing; the token only ever resolves through the two routes below.

curl http://localhost:3000/v1/shared/$TOKEN

GET /v1/shared/:token returns { "name", "updated_at", "stale", "tiles" }, each tile carrying its report in that report’s own shape, the same as GET /v1/dashboards/:id. An unknown, malformed, revoked or deleted token all answer the same 404 { "error": "share_not_found" } — one body for all four, so a guess and an expired link cannot be told apart from the outside.

curl -X POST http://localhost:3000/v1/shared/$TOKEN/tiles/0/run \
  -H 'content-type: application/json' -d '{ "range": "30d" }'

POST /v1/shared/:token/tiles/:index/run runs one tile and returns { "kind", "result" } in the shape of GET /v1/events/stats, POST /v1/reports/retention or POST /v1/funnels/:id/run. range is one of auto, 24h, 7d, 30d, 90d, 180d or 365d — presets only, never a since/until pair, because the result cache below needs a finite key space per link and a caller free to name arbitrary bounds could mint an unbounded number of them against one token. The query itself is built entirely from the tile’s stored report; a link cannot ask anything the dashboard does not already ask. A bad index is 404 tile_not_found; a tile whose report has since been deleted is 404 report_not_found; a malformed range is 400 invalid_range; and a preset that report’s own ceiling refuses — a funnel range past 90 days, a trend that would exceed the bucket cap — gets that report endpoint’s own 400, the same one its authenticated route would send. A trend or retention tile whose stored where this build cannot parse is 400 stale_definition, the same flag the dashboard’s own read marks the tile with. A funnel is not this — a funnel store throws rather than flagging, so a funnel whose stored steps this build cannot read comes back as a plain 400 naming the definition version in its error message, the same message GET /v1/funnels/:id sends for that row. Running a funnel through a link does not update the funnel’s cached last run; that number belongs to the operator’s own screen.

Three bounds apply to every link, because the caller is anonymous. At most 120 requests a minute count against one token — page loads (the GET) and tile runs together, not runs alone — refused with 429 { "error": "too_many_runs" } and retry-after: 60. At most 3 runs execute for one token at once; a fourth gets the same 429 with retry-after: 1. A tile’s result is cached for 60 seconds per link, tile and preset, so a link opened by fifty readers costs one query per tile rather than fifty, and an edit to the dashboard or to a report it shows reaches the shared page within a minute rather than at once.

The token is in the URL, and Lyraflow’s own request log redacts it. Both the viewer page /shared/<token> and the API call it makes, /v1/shared/<token>, are logged with the token replaced by [redacted]. Nothing in front of Lyraflow does that: a reverse proxy keeps its own access log, and nginx’s default combined format writes the whole request line — so if you run one, every page load of a shared dashboard puts a working link into access.log, where it is readable by anyone who can read that file. Redact it there, or treat those logs as holding credentials.

What this does not do. There is no embedding flow — the link opens as a full page, not an iframe snippet — but the server sends no frame header on any page today, this one included, so nothing here actually stops another site from framing it; that gap is tracked, not solved, by this feature. There is no password on a link, no expiry, no count of who opened it, no way to share a single report on its own, and no way to list shared dashboards other than the shared flag on GET /v1/dashboards. Unlike a server key, the token is stored in plaintext rather than hashed, because the Share card has to show the link again after it is created — a database leak already exposes every event, person and segment in the project, so a plaintext link alongside that does not raise the stake. A link is a credential: anyone who has it has the dashboard, and revoking it is the only remedy for one that has spread further than intended.

This page is the Dashboards section of the product README at v0.15.0. It is generated from that file rather than written here, so a correction belongs upstream.