Using the Cycles Dashboard
The Cycles Admin Dashboard is a Vue 3 SPA that sits in front of cycles-server-admin and cycles-server. Everything it does is a call against those two backends — the dashboard itself holds no state. This page is the operator's tour: how to log in, what every page does, and which features are behind which admin key capability.
If you haven't deployed the dashboard yet, start with Deploy the Cycles Admin Dashboard. The examples below assume the dashboard is reachable at https://admin.example.com.
Login and capability gating
The only credential the dashboard accepts is an admin API key. On the login page:
- Enter the admin API key (the value of
ADMIN_API_KEYon the server). - The dashboard calls
GET /v1/auth/introspectto validate the key and retrieve the capability set. - Sidebar navigation, action buttons, and page access are all gated by capability booleans returned by introspect (
view_overview,view_budgets,manage_budgets,manage_reservations, etc.). Themanage_*flags default to "allow" when a server doesn't return them — only an explicitfalsehides the corresponding actions.
The key is stored in sessionStorage — it survives a page refresh but is cleared when the tab closes. It is never written to localStorage or a cookie. Idle timeout is 30 minutes; absolute timeout is 8 hours; the check runs every 15 seconds.
After 3 failed login attempts the dashboard enforces exponential backoff (5s → 10s → 20s → 40s → 60s cap). A 401 from any subsequent API call clears the session and redirects to login (with one carve-out: a 401 caused by calling an endpoint the running admin server doesn't have yet is surfaced as an in-view error instead of a logout). A 403 — authenticated key, forbidden operation — keeps the session and surfaces the error in the view, and network failures or timeouts never end the session.
Treat the admin key like a root credential
There is no user login, no SSO out of the box. Rotate the key regularly, keep it in a secrets manager, and consider putting the dashboard behind SSO or VPN. The dashboard does not weaken this — it uses whatever key you give it.
The eleven views
| View | Purpose |
|---|---|
| Overview | Aggregated health — counter strip, four donut charts (budget status / utilization / events by category / webhook fleet), and six attention cards for actionable work. See Overview screen. |
| Tenants | Tenant list with parent/child hierarchy columns and bulk actions |
Tenant detail (/tenants/:id) | Per-tenant drill-down with nested Budgets / API Keys / Policies tabs, spend rollup, children list, and a parent-tenant breadcrumb |
| Budgets | Tenant-scoped budget list with utilization and debt bars; inline RESET and RESET_SPENT |
| Events | Correlation-first investigation tool with expandable detail rows |
API Keys (/api-keys) | Cross-tenant key list with masked IDs, permissions, status filters |
| Webhooks | Subscription health (green / yellow / red) with status filters and bulk actions |
Webhook detail (/webhooks/:id) | Four-stat row (last-success chip, delivery-outcome donut, attempts histogram, response-time p50/p95/max — see WebhookDetailView stats row), delivery history, last error, signature rotation, pause/resume, replay, and test |
Reservations (/reservations) | Hung-reservation force-release during incident response (runtime-plane admin-on-behalf-of) |
| Audit | Compliance query tool with CSV / JSON export |
Evidence (/evidence) | Signed evidence-envelope viewer — paste a 64-hex evidence_id (or follow the deep link from force-release) to retrieve the envelope and check the signer key against the published JWK Set |
Most pages poll their backends on a page-specific interval — see the deployment guide for the cadence table. Audit is manual-only: you press Run Query explicitly to avoid drive-by queries against retention-expensive endpoints.
Overview screen
The Overview is the landing page. It opens on eight parallel fetches — the /v1/admin/overview aggregate plus seven list queries (API keys, the last 10 audit entries, budgets at ≥ 90% utilization, frozen budgets, closed tenants, budgets with debt, and webhooks) — that together hydrate the counter strip, the four donut charts, the six attention cards, and the recent-operator-activity feed. The fetches resolve independently, so one flaky endpoint degrades to an error banner instead of blanking the page.
The payload includes optional aggregates beyond the visible cards. recent_denials_by_reason is populated by v0.1.25.x admin servers and lets operators see denial distribution even when the recent-event sample is capped. quota_health, access_control_stats, and tenant_counts.in_observe_mode are reserved for v0.1.26+ action-governance servers; dashboards should render them when present and tolerate null or absent values on v0.1.25.x reference servers. See Action Governance Preview.
Counter strip
Top of page. Four tiles — Tenants, Budgets, Webhooks, and Events — each showing a server-aggregated total plus status chips (e.g. active / frozen / over / debt) that drill to the corresponding list view with the filter pre-applied. The Events tile's time window is server-driven: the overview payload carries event_window_seconds, and the tile renders it as "Events (Xm)" — there is no client-side window selector. Counter totals come from the server aggregate, so they reconcile by construction with the list pages' own counts (no client-side reduce drift).
The four donuts (v0.1.25.47–.52)
Beneath the counter strip sits a 4-up donut grid. Every slice is clickable and drills to the corresponding filtered list view — chart and list read from the same server aggregate so the numbers match.
| Donut | Slices | Slice-click target |
|---|---|---|
| Budget status | Active / Frozen / Over-limit / Closed | /budgets?status=ACTIVE|FROZEN|CLOSED or /budgets?filter=over_limit |
| Budget utilization | Healthy (<90%) / Near cap (90–99%) / Over cap (≥100%) | /budgets?utilization_min=…&utilization_max=… (integer percent, v0.1.25.50) |
| Events by category | budget / reservation / tenant / api_key / policy / webhook / system / runtime | /events?category=<name>&from=<window-start>&to=<now> — time window mirrors the counter-strip "Events (Xm)" window (v0.1.25.53) |
| Webhook fleet health | Active / Paused / Disabled | /webhooks?status=ACTIVE|PAUSED|DISABLED |
Each card title carries a muted "· click a slice" hint to telegraph interactivity. Dark-mode palette re-derives on toggle (the charts aren't just re-skinned images — they're vue-echarts instances driven by a reactive useChartTheme composable). Spec-terminal CLOSED budgets are filtered out of the utilization bucketing and total (v0.1.25.59) so a CLOSED budget at 120% doesn't inflate "Over cap" and CLOSED budgets don't inflate "Healthy" — FROZEN stays included because it's non-terminal. Independently, the client-fetched attention cards (every card except Recent denials, which comes from the server aggregate) exclude rows owned by CLOSED tenants (v0.1.25.45) so the transient Mode-B cascade window doesn't surface un-actionable work. Screen readers get an auto-rendered sr-only data table per pie chart (v0.1.25.56).
Under the donuts, six attention cards surface actionable work: Budgets at or near cap, Budgets with debt, Frozen budgets, Failing webhooks, Expiring API keys (7d), and Recent denials (1h). Each card's "View all" link carries the same filter the card applied, so drill-down and card count agree by construction. An alert banner above the counter strip enumerates whichever cards are firing as severity-colored jump-link pills.
Power-user features
Command palette — Cmd+K / Ctrl+K
Press Cmd+K on macOS or Ctrl+K on Linux/Windows to open the palette. It is a navigation tool, not an action runner — it never mutates anything and applies no capability gating. Two modes:
- Tenant fuzzy search (default). Type a tenant name or ID fragment; the palette filters a cached tenant list (60s TTL, up to 150 prefetched with a "Load more" affordance) and Enter jumps to the tenant detail page.
- Slash commands. Type
/to list them:/wh <subscription_id>(or/webhook) opens a webhook detail page,/tenant <tenant_id>(or/t) opens a tenant by exact ID,/key <key_id>opens the Audit view filtered by that key,/audit <log_id or resource_id>searches the audit log, and/event <event_id>filters the Events view. Budget and reservation ID jumps are intentionally not offered — those views don't honor the needed URL filters yet.
Bulk action lanes
The Tenants, Webhooks, and Budgets pages expose a filter-then-bulk workflow:
- Apply filters in the page toolbar (
status,plan,over_limit, etc.) until the row count is what you want to act on. - Click Bulk action. A side panel opens with the
expected_countpre-filled from the current filter. - Pick the action. Tenants:
SUSPEND,REACTIVATE,CLOSE. Webhooks:PAUSE,RESUME,DELETE. Budgets (v0.1.25.35+, requires admin v0.1.25.29+):CREDIT,DEBIT,RESET,RESET_SPENT,REPAY_DEBT. A blast-radius summary confirms before execution. - The dashboard calls
POST /v1/admin/tenants/bulk-action,/v1/admin/webhooks/bulk-action, or/v1/admin/budgets/bulk-actionwith the filter, theexpected_countsafety gate, and an idempotency key generated from the current session. - The result panel shows per-row
succeeded,failed,skippedlists — rendered in aBulkActionResultDialog(v0.1.25.34+) with per-row copy-ID affordances and operator-friendly error messages sourced from the sharederrorCodeMessagescatalog. Failed rows show the per-rowerror_code.
Row-select variant (v0.1.25.36). The Budgets view also supports row-select bulk Freeze and Unfreeze — select individual checkboxes across filtered rows rather than applying to the whole filter. This is a client-side fan-out over the per-budget freeze/unfreeze endpoints, not a POST /v1/admin/budgets/bulk-action request. Row-select bulk failures open the same BulkActionResultDialog with per-row status.
See Using Bulk Actions for the full request shape and error taxonomy.
Cross-surface correlation chip (v0.1.25.39)
Rows on the Events and Audit views and event-timeline entries carry correlation chips for up to three identifiers — trace_id, request_id, correlation_id (see Correlation and Tracing for what each one scopes). Webhook delivery-history rows do not render the chip affordance; the delivery export includes trace_id for offline joins. Clicking a chip pivots to the other view with that identifier pre-applied as a filter:
- Click
trace_idon an Audit row → EventsView filtered to the same trace. - Click
trace_idon an Events row → AuditView filtered to the originating entry. - Click
correlation_idon an EventTimeline row → EventsView filtered to all events in the same cluster (v0.1.25.37+). - Copy-to-clipboard icon on the chip for sharing into tickets or chat.
There is no pivot menu or deliveries side panel — the chip is a filtered navigation plus a copy affordance. Operator triage in v0.1.25 starts here: pull a trace_id out of a failing response header (X-Cycles-Trace-Id) or error body, paste it into the Audit page's trace_id filter (or the Events view), and follow the chips between surfaces. Requires cycles-server-admin v0.1.25.31+ for server-side support. See Correlation and Tracing.
Terminal-state row toggle (v0.1.25.46)
Default sort on every list view is created_at desc, which pins recently-transitioned terminal rows to the top — closed tenants, disabled webhooks, revoked / expired API keys, closed budgets. Before v0.1.25.46 these dominated the first screen and operators had to add an explicit status filter to get them out of the way.
Tenants, Budgets, Webhooks, and API Keys now hide terminal rows by default and surface a "Show closed (N)" / "Show disabled (N)" / "Show revoked (N)" toggle with the hidden count. Flipping the toggle partitions the list so active rows stay on top and terminal rows drop to the bottom — column-sort order is preserved within each group. Matches the GitHub / Linear / Gmail convention for done / archived items.
| View | Terminal definition |
|---|---|
| Tenants | status=CLOSED |
| Budgets | status=CLOSED (FROZEN stays visible — it's non-terminal) |
| Webhooks | status=DISABLED |
| API Keys | status IN (REVOKED, EXPIRED) |
Toggle state mirrors to URL as ?include_terminal=1 on top-level views so deep-links survive across reloads. Picking a terminal status explicitly from the dropdown (e.g. status=CLOSED) auto-engages the toggle so the list isn't silently empty. Tenant-detail sub-tabs (Budgets / API Keys / Policies) default off and don't mirror to URL (they share a URL with the parent).
WebhookDetailView stats row (v0.1.25.51)
Clicking a webhook subscription opens /webhooks/:id. Between the subscription card and the Delivery History table sits a four-up stat row that aggregates over recently-loaded deliveries:
| Stat | Meaning |
|---|---|
| Last success | Chip with traffic-light semantics — green if < 1h, amber 1h–24h, red ≥ 24h or no successful delivery on file. The fastest visual check that a subscription is still delivering. |
| Delivery outcome | Donut partitioning loaded deliveries by status (success / retrying / failed / stale). Clicking a slice sets the delivery-table status filter in place — no route change, because the filter is local. |
| Attempts per delivery | Histogram bucketed 0 / 1 / 2 / 3 / 4 / 5+ with a severity color ramp. Makes retry storms visible before you scan rows. |
| Response time | p50 / p95 / max computed via NIST nearest-rank over deliveries that carry response_time_ms. |
The stats aggregate whatever deliveries the history table has loaded — there's no second fetch. Scroll / Load More on the table re-computes the stats in place.
Freshness pill on page headers (v0.1.25.54)
Polling list views show a small muted "Updated Xm ago" pill on the PageHeader, beside the refresh button. It reads usePolling.lastSuccessAt — successful polls update it; failed polls leave it alone, so operators can tell at a glance whether they're looking at fresh data or a silent poll outage. Absent on manual-query pages (Audit) and on views that don't poll.
Parent-tenant breadcrumb
Tenant detail pages show a Parent link when the tenant has a parent_tenant_id, and a Children list of sub-tenants. Clicking a child threads ?parent=<source> into the URL so the back arrow returns to the tenant you came from (single hop — deeper A → B → C chains return to the immediately-previous tenant, not the root). There is no scope breadcrumb: the dashboard does not render a tenant → workspace → app trail; scope paths appear only as budget/reservation row data.
RESET_SPENT inline funding
On the Budgets page, every row has a funding dropdown. Alongside CREDIT, DEBIT, REPAY_DEBT, and RESET, the dropdown exposes RESET_SPENT — the v0.1.25.18+ funding operation that sets allocated to the supplied amount and resets spent (to zero, or an explicit override) while preserving reserved and debt. Picking it opens a confirmation dialog where you enter the new allocation and can either leave spent at zero (monthly rollover) or enter an explicit starting value (prorated correction).
See Rolling Over Billing Periods with RESET_SPENT for when to use each pattern.
Closed-tenant tombstone and cascade preview
As of v0.1.25.43 (consuming admin v0.1.25.36), the dashboard surfaces tenant-close cascade behavior through four coordinated affordances:
- Closed-tenant banner. When
tenant.status === 'CLOSED', an amber read-only banner renders at the top ofTenantDetailView: "Tenant closed — all owned objects are read-only." Immediately answers the "why won't this unfreeze?" question on closed-tenant pages. - CLOSE confirm-dialog cascade preview. Before closing, the confirmation dialog enumerates what will be terminated — owned budgets, webhook subscriptions, API keys, open reservations, with counts pulled from already-loaded tenant-detail state. Spells out "This cannot be undone." Useful for estimating blast radius before pulling the trigger.
TENANT_CLOSED409 humanizer. Any mutation that races the cascade (stale tab, deep-link, in-flight request) surfaces as "Tenant is closed — this object is read-only." instead of a raw 409. Lives alongside the existing error-code map inerrorCodeMessages.ts.- Tenant-cascade audit + event chip.
AuditViewandEventTimelinerows render a small amber "tenant cascade" chip when the event carries a_via_tenant_cascadesuffix (budget.closed_via_tenant_cascade,webhook.disabled_via_tenant_cascade,api_key.revoked_via_tenant_cascade,reservation.released_via_tenant_cascade, or audit operationtenant_close_cascade). Lets operators visually distinguish cascade-triggered state changes from user-driven ones when correlating bycorrelation_id.
Requires admin v0.1.25.36. Running the dashboard against admin .32 still renders the tombstone + dialog preview (pure client-side), but the cascade itself won't fire and frozen budgets on closed tenants continue to inflate the Overview alert counter. Running against .35 works for the common cascade path (budgets + reservations are cascaded and their mutations return TENANT_CLOSED), but policy / api-key / webhook-admin mutations against closed-tenant objects still go through without the Rule 2 guard — .36 closes those remaining endpoints.
See Tenant-Close Cascade Semantics for the full protocol contract.
Incident-response actions
Every destructive action is one-click with a confirmation and a blast-radius summary:
| Action | Page | Backend call |
|---|---|---|
| Freeze budget | Budgets / Budget detail | POST /v1/admin/budgets/freeze?scope={scope}&unit={unit} |
| Unfreeze budget | Budgets | POST /v1/admin/budgets/unfreeze?scope={scope}&unit={unit} |
| Bulk Freeze / Unfreeze budgets (v0.1.25.36+) | Budgets — row-select + floating toolbar | Client-side fan-out over POST /v1/admin/budgets/freeze / unfreeze |
| Suspend tenant | Tenants / Tenant detail | PATCH /v1/admin/tenants/{id} |
| Reactivate tenant | Tenants | PATCH /v1/admin/tenants/{id} |
| Revoke API key | API Keys | DELETE /v1/admin/api-keys/{id} |
| Pause webhook | Webhooks / Webhook detail | PATCH /v1/admin/webhooks/{id} |
| Resume webhook | Webhooks | PATCH /v1/admin/webhooks/{id} |
| Test webhook | Webhook detail | POST /v1/admin/webhooks/{id}/test |
| Replay webhook events | Webhook detail | POST /v1/admin/webhooks/{id}/replay |
| Force-release reservation | Reservations / Reservation detail | POST /v1/reservations/{id}/release with X-Admin-API-Key |
| Emergency tenant-wide freeze | Tenant detail | Bulk freeze across all budgets for the tenant |
| Close tenant (cascades owned objects, v0.1.25.43+) | Tenants / Tenant detail | PATCH /v1/admin/tenants/{id} — dashboard shows cascade preview before confirming |
Force-release uses dual authentication — the dashboard's nginx routes /v1/reservations* to cycles-server:7878 and the runtime server validates both keys before executing. The audit log tags the action with metadata.actor_type=admin_on_behalf_of. See Force-Releasing Stuck Reservations for the underlying flow.
Events investigation
The Events page is correlation-first, not time-first:
- Event rows carry a
correlation_idwhen applicable (event-stream cluster — threshold → trip → reset chains, or one admin operation's fan-out) plusrequest_id(the originating HTTP request). Clicking either filters to the related events; audit rows join viatrace_id/request_idrather thancorrelation_id. - Expandable detail rows show the full event payload — including
data,actor,metadata, and delivery outcome if the event went out over a webhook. - Filters: event type, category, tenant, scope, time range, correlation ID.
Events poll every 15 seconds (the most aggressive of any page) because incident response typically starts here.
Per-row Copy JSON (v0.1.25.37+) is available on every surface rendering an event, audit entry, event-timeline entry, or webhook delivery — part of the shared triage affordances extracted to the icon library in v0.1.25.40. Correlation chips render per row type: trace_id/request_id on Audit rows; correlation_id additionally on Event and event-timeline rows when present. Delivery rows carry trace_id in the export rather than a chip.
Audit page
Audit is the one page that is manual-only. You build a query, press Run Query, and the dashboard calls GET /v1/admin/audit/logs with the filter you built.
Supported filters (v0.1.25.33 UI + v0.1.25.27 server DSL): tenant_id, key_id, operation (IN-list), resource_type (typeahead + IN-list), resource_id, request_id, trace_id, error_code (IN-list), error_code_exclude (NOT-IN-list), status_min / status_max (range), free-text search, and time range (from / to). Deep-link URL params (?error_code_exclude=, ?status_min=) support sharable filter state. Results can be exported as CSV or JSON for compliance review.
Metadata fields such as metadata.actor_type, bulk-action metadata.idempotency_key, and per-row bulk outcomes are displayed in the expanded row and exported JSON, but they are not standalone server-side query parameters. Use top-level filters like operation, tenant_id, resource_type, resource_id, trace_id, or request_id to narrow the result set before inspecting metadata.
Bulk-action audit rows expand into a structured detail panel (v0.1.25.38) that renders succeeded_ids, failed_rows, skipped_rows, filter echo, and duration_ms as a first-class layout instead of raw JSON — per-row copy affordances are wired for immediate triage.
Failed-request entries (added in cycles-server-admin v0.1.25.20) are included in results. In v0.1.25.28+ servers, their tenant_id is __unauth__ (pre-auth failures) or __admin__ (admin-plane ops); pre-.28 rows continue to show the historical <unauthenticated> literal. All three are queryable from the Audit filter dropdown. Tiered retention — authenticated entries live 400 days by default, unauthenticated entries 30 days.
Monitoring the dashboard itself
The dashboard container is nginx serving a static SPA plus a reverse proxy — its own liveness check is GET / (returns the SPA shell; this is what the container's Docker HEALTHCHECK probes). Backend health is the health of cycles-server-admin. Two good synthetic monitoring targets:
GET /v1/admin/overviewwith theX-Admin-API-Keyheader — requires the admin key, but if it returns 200 the full stack (Redis + admin + auth) is working.GET /actuator/health/readinesson the admin server — the unauthenticated Redis-aware readiness probe; the bundled compose files use it as the healthcheck for the admin, runtime, and events services.
Alert on the overview payload's failing_webhooks and over_limit_scopes arrays. On servers that populate v0.1.26 action-governance fields, also alert on counters at limit in quota_health and on spikes in recent_denials_by_reason.ACTION_QUOTA_EXCEEDED, recent_denials_by_reason.ACTION_KIND_DENIED, or recent_denials_by_reason.ACTION_KIND_NOT_ALLOWED.
Mobile layout (v0.1.25.58)
The dashboard is admin-console density, not phone-native, but v0.1.25.58 landed a mobile-responsive sweep covering the paths operators actually take from a phone during incident response:
- Shell: Escape closes the drawer with focus-return to the hamburger; hamburger is sized 44×44 with
aria-expanded/aria-controls; root usesh-dvhso mobile Safari's collapsing URL bar doesn't cut off content. - Layout:
PageHeaderreflows to a column on narrow viewports;LoginViewandNotFoundViewfit 320-wide screens. - Menus and dialogs:
RowActionsMenuclamps horizontally to the viewport;FormDialogandConfirmActionfooters flex-wrap so buttons don't clip. - Tables: minimum widths tightened (AuditView 1000 → 900 px), with horizontal scroll as the fallback when rows don't fit.
Known deferrals. Virtualized list tables still use horizontal scroll on phones rather than a card layout; the command palette's soft-keyboard viewport handling is not wired to visualViewport; the bulk-action preview / result dialog tables overflow on narrow viewports; the TimeRangePicker popover can overflow horizontally. None block incident triage — they're follow-ups for a dedicated mobile pass.
Next steps
- Deploy the Cycles Admin Dashboard — deployment, routing, and hardening
- Using Bulk Actions — the API behind the bulk lanes
- Force-Releasing Stuck Reservations — runtime-plane incident response
- Rolling Over Billing Periods with RESET_SPENT — the funding operation behind the Budgets page dropdown
- Admin API reference — the endpoints every dashboard page calls