Github|...

Admin dashboard

The operator dashboard the scheduler serves at /admin: sign-in, cluster overview, restart and backup actions, backend health, live logs and workflow runs.

Every scheduler serves an operator dashboard. It shows the health of the scheduler and its SSPs, the end-to-end sync latency, the status of your backends, live logs, and workflow runs as they happen.

The overview: sync latency, SSPs, backends, presence and the outbox queue.
The overview: sync latency, SSPs, backends, presence and the outbox queue.

Incident history

The Incidents navigation item keeps automatic lag, heartbeat failures, bootstrap integrity failures, removal/re-registration, recovery, and operator actions in one timeline. Every navigation entry has a labeled icon on desktop and mobile. The overview carries an Incidents tile: the open count, the last 24 hours as a strip with one marker per episode (as wide as it lasted, pulsing while open), and a link to the latest one.

The page opens on a rail (open now, count in the range, longest episode, latest) above a day-grouped feed. Filters are chips that apply the moment they are clicked: range (24 h, 7 days, 30 days), state and component, each with its count, plus a search over kind and summary. The chips filter the newest 400 episodes of the range on the client, so switching between them costs no request. Open an incident to see its duration, largest sampled event backlog, server version, operation id when present, and event timeline. Recovered means the scheduler observed recovery. Interrupted means the scheduler restarted before it could observe recovery. Lag followed by replay is described separately from an SSP re-registering; re-registration alone does not prove a container crash.

Every episode in one timeline, with filters that apply on the client.
Every episode in one timeline, with filters that apply on the client.

History is stored in incidents.json beside the scheduler WAL, independently of SurrealDB. Keep that directory on persistent storage. Retention is bounded by 30 days, 2,000 incidents, 64 events per incident (first and newest retained), and an 8 MiB file cap. Counts include compacted events. Ordinary events flush once per second; a scheduler restart or cloud restart request flushes its action before exit/dispatch. Abrupt termination can lose the last unflushed second. A clean restart that wipes the scheduler volume also wipes its incident history.

A missing or unreadable history file is reported in the dashboard; malformed history is not overwritten. The incident logging target stays enabled even with RUST_LOG=warn. A dropped log-feed segment becomes a visible history-gap record. Stored summaries contain no query bindings, SQL, or credentials. Detailed logs remain available through the Logs page and are independently subject to their retention limits. History starts when this version is deployed; it does not reconstruct older incidents from arbitrary log text.

The read-only MCP tools incidents_list and incident_get expose the same history as the dashboard. See the scheduler API.

Opening it

The dashboard is bundled into the official mono424/spooky-scheduler image and served on its own port:

http://<scheduler-host>:9668/admin

Why a second port

Everything on the ingest port (9667) is unauthenticated by design — including POST /proxy/query, which runs arbitrary SurrealQL against the scheduler’s replica. That port is meant to be reachable only from inside your deployment’s private network.

The dashboard is the first surface intended for a browser, so it gets a listener of its own. Publish 9668; keep 9667 private.

docker run -p 9668:9668 mono424/spooky-scheduler   # dashboard reachable
# 9667 stays on the internal network

Signing in

Two ways in on the scheduler’s own page, and a third from the Sp00ky Cloud console.

Admin accounts (the normal path)

Any account on the _00_admin roster can sign in with its ordinary application credentials. Grant access with the CLI:

spky admin add alice
spky admin list
spky admin remove alice

The scheduler signs in to your database through your app’s own record-access method, then checks the resulting user against _00_admin. Nothing extra to configure, and no second password to manage.

The access method is discovered from INFO FOR DB. If your schema defines several record accesses and you want to pin one, set SPKY_ADMIN_ACCESS. Both $username and $email are tried as the identifier parameter, so either spelling of a SIGNIN clause works.

Break-glass password

Set SPKY_ADMIN_PASSWORD to enable a password-only login that needs no database:

SPKY_ADMIN_PASSWORD='…'

This exists because the roster path needs a working database, and a database that is down is exactly when you most want the dashboard. A session established this way is flagged with a persistent banner in the UI.

Leave the variable unset to disable this path entirely; there is no default password.

From the Sp00ky Cloud console

On a cloud-linked scheduler, a member of the project’s workspace can open its dashboard from console.sp00ky.cloud without an account on the roster. The control plane signs a one-minute grant for that one project (an EdDSA JWT), and the console posts it to POST /admin/api/session as {grant}. The scheduler accepts it only if it is signed with a key from the control plane’s /.well-known/jwks.json, names this project as its audience, was issued moments ago, and has not been used before; it answers with a cloud session of 15 minutes, which the console renews from a fresh grant. Removing someone from the workspace therefore ends their access within 15 minutes. Self-hosted schedulers have no cloud link and refuse grants.

Session behaviour

How long a session lives depends on whether the scheduler has a cluster secret:

  • With SPKY_AUTH_SECRET set (always the case on Sp00ky Cloud), tokens are self-describing and HMAC-signed with a key derived from that secret. They verify without any server-side state, so they survive a scheduler restart, including one you trigger from the dashboard. Signing out adds the token to an in-memory revocation set; a restart forgets that set, so a revoked token comes back for the remainder of its own expiry. That trade is deliberate and is the whole cost of not logging you out when you press Restart.
  • Without a secret there is nothing to sign with that outlives the process, so sessions are random tokens held in memory and a restart means signing in again.

GET /admin/api/config reports which mode is active as sessions_persistent. GET /admin/api/me reports the session’s mode (roster, breakglass, or mcp for a token minted on the Access page) and its scope.

Failed logins are rate limited to 10 attempts per address per 5 minutes, and every failure returns the same response regardless of cause, so the endpoint cannot be used to discover which usernames exist.

Standalone use

The same bundle runs outside a scheduler. Opened from anywhere else, the sign-in form grows an endpoint field; point it at a scheduler’s admin port and it behaves identically.

pnpm --filter @spooky-sync/dashboard dev
# then enter http://your-scheduler:9668 in the endpoint field

The dashboard authenticates with a bearer token in localStorage, keyed per endpoint, rather than a cookie — a cross-origin cookie would need SameSite=None; Secure, which browsers refuse over plain HTTP, and plain HTTP at an IP address is exactly how a scheduler is usually reached.

What it shows

Overview — a grid of tiles, one per question: the end-to-end sync latency with a 30-minute chart as the large tile, then SSPs ready, backends healthy, ingest lag and the scheduler’s own details beside it. The page paints from the last state it saw the moment it opens (marked refreshing) and swaps in the live poll a moment later, so a reload never shows an empty frame. A tile whose subject is degraded is tinted, so the problem is the first thing on the page. The latency is the scheduler’s own heartbeat probe: it writes _00_heartbeat:probe upstream and times the full round trip through /ingest, the WAL, the broadcast and every SSP’s circuit. It measures the real sync path, not a synthetic ping.

SSPs — per-processor status, live view count, uptime and environment. An SSP that is bootstrapping or replaying shows a progress bar: tables loaded and rows read when the SSP reports it, and otherwise the phase with its elapsed time against the scheduler’s bootstrap budget.

Each SSP with its phase, live views, heartbeat and publication backlog.
Each SSP with its phase, live views, heartbeat and publication backlog.

Backends — status and response time for everything in SPKY_BACKENDS, with a detail page carrying a ~30-minute response-time history and the resolved healthcheck URL. Environment values are masked by the scheduler before they leave it. On Sp00ky Cloud each deploy pushes the list; the scheduler stores the last pushed list (env values masked) in _00_scheduler_state and reads it back when it starts, so a restart or reclone between deploys keeps its backends. A list in SPKY_BACKENDS always takes precedence. A backend that runs on a machine pool (run_on: { pool: ... }) is not probed: between jobs there may be no machine to answer, and that is not an outage. Its status is what the pool sweep last saw: idle at zero machines with nothing queued, starting while a machine boots or a job waits for one, healthy with a ready machine, unhealthy only when the pool’s boot-failure breaker is open or its last pass failed. No backend_down incident is ever opened for it; the pool’s own pool_breaker_open incident carries the reason when it cannot get a machine.

Your own services, their resolved health-check URLs and response times.
Your own services, their resolved health-check URLs and response times.

Runs — schedules, workflow runs and jobs on one page, because they are one machine seen from three sides: a schedule fires either a job (a row in an outbox table that a backend runs at a path) or a workflow run, and every step of a workflow run is itself a job. On separate pages one failure showed up three times; here it shows up once.

What is running and waiting, every schedule with its last fires, every run, and the queues.
What is running and waiting, every schedule with its last fires, every run, and the queues.

Top to bottom:

  • The numbers: running, waiting (with the oldest wait), jobs done per minute, and failures in the last hour. Stalled is called out separately from running: a processing row whose lease has expired, meaning nobody is working on it and a recovery sweep will reclaim it on its own clock.
  • Schedules: each one’s cadence in words, its last twelve fires as squares (red where a fire failed; click one to open what it ran), the last and next fire, and Run now and Pause on hover.
  • Runs: everything that executed, running and waiting first. A workflow run is one row and its step jobs live inside it; a job of its own (from a kind: job schedule, a durable instance, or your own code, which belongs to no schedule and no workflow) is one row. Each row carries its failure reason, so four failed runs do not cost four clicks to tell one problem from four. Filter by status, kind, what started it, schedule or queue, or search by name, id or error. A filter the scheduler can answer is pushed into SurrealQL rather than applied to the page already fetched, so failures on a busy table are found even when they are older than the newest page.
  • Queues: one row per outbox table, running against its _00_job_policy concurrency, its pace, what waits and what failed.

A run, job or schedule opens in a sheet beside the list. The sheet is part of the address (/admin/runs?open=run:<id>, job:<id>, schedule:<name>), so it survives a reload, and the old /admin/jobs/<id>, /admin/workflows/<id> and /admin/schedules/<name> links land on it.

A failed run: its steps on one time axis, the step that failed and why.
A failed run: its steps on one time axis, the step that failed and why.
  • A workflow run draws every step on the run’s own time axis, so where the time went is visible at a glance. A step still waiting on its dependencies is a dashed line rather than a bar, because it has not run. Click a step to open its job, which is where a failure says what actually happened: the step row keeps only the attempt that ended the job, while the job keeps every attempt.
  • A schedule shows its definition, the last 24 hours of outcomes, recent fires with why each ended as it did, and any quarantined keys with a way to release them. The tally comes from _00_run_rollup rather than counting run rows, so it stays accurate after retention has pruned them.

The scheduler runs one shared poller behind the live run list and one job sampler behind the queue figures, so ten open dashboards cost your database exactly what one does, and a closed one costs nothing. Counts cover work in flight plus the last hour of terminal work, never all of history: once retention keeps failures longer than successes, an all-time ratio reports a failure rate that climbs as successes age out. A bounded window is the only honest denominator, and it is the one spky jobs uses too.

What the Runs page costs your database

Every job aggregate is a scan of one of your own tables, so the scheduler takes them once , in a single sampler that every reader serves from — the same bargain the presence sampler strikes. It reads every 15 seconds while nobody has the page open and every 2 seconds while someone does, so ten open dashboards cost what one does and an idle cluster costs almost nothing. Tune both with SPKY_ADMIN_JOB_INTERVAL_SECS and SPKY_ADMIN_JOB_LIVE_INTERVAL_SECS, or set the first to 0 to switch the sampler off entirely: the list and its filters still work, the totals and the Overview then say they are not being measured rather than showing zeros.

Logs — an explorer over the scheduler’s or any SSP’s log output. Opening it loads the source’s whole log ring (up to 10,000 lines), then follows the live tail. Above the lines, a histogram shows their volume over time with warnings and errors stacked in their colours. Drag across it to pick a time window (drag inside the window to move it, drag its edges to resize it), and the pill over the window zooms into it, back out, or clears it. The window becomes a time chip in the search beside the level and target filters, and the table follows as you drag. Lines are listed newest first; click one to read it whole beside the table, with its fields one per row and how often a line of the same shape appears in the buffer. Keys: / focuses the search, ↑/↓ move through the lines, Escape closes the line, then clears the window, then zooms out. If a viewer falls behind the stream, the count of skipped lines is shown rather than hidden.

A time window brushed across the log volume, read out in the pill and filtering the lines below.
A time window brushed across the log volume, read out in the pill and filtering the lines below.

Backends have no log stream: the scheduler reaches them with HTTP health checks and has no access to their output.

Resources (Sp00ky Cloud only) — what the deployment runs on, as the control plane measures it: every container with its CPU, memory (against its limit), disk and network, with the last 15 minutes of CPU and memory as sparklines; the dedicated machines and machine pool VMs; and the bucket volume with how much of it is used and free. A container’s disk is what its volumes take (the database’s data, the scheduler’s replica and event log, an SSP’s snapshot and arena) against that plus what is still free on the disk of the VM they run on, so a VM filling up shows on every container on it. On a Pro project with its own VMs the database is on the database VM and the rest on the project host. The scheduler cannot see its neighbours’ containers or the database’s volume, so all of it comes from Sp00ky Cloud through the cloud link: container samples every 15 seconds, disk and the bucket volume every 5 minutes. The Overview’s database and scheduler tiles and each SSP’s row show the same disk figures. The page is not in the navigation of a scheduler that is not linked.

Actions

Everything the dashboard can do, as opposed to show, goes through the scheduler. Every action logs the signed-in subject, and the long-running ones answer 202 with an operation the dashboard watches (see below).

Restarting an SSP

The scheduler never touches a container. Restarting an SSP means flagging it in the pool; the SSP’s next heartbeat is answered with 409 and a JSON ResyncDirective, and the SSP goes through the same register, bootstrap and verify path every SSP takes on boot.

ModeWhat happens
restartFlag, 409, re-bootstrap in place. The process keeps running and keeps its rows: it drops its views, registers again, keeps the tables that still match the replica and repairs the rest, then rebuilds its views.
cleanThe directive carries clean: true, so the SSP deletes its row checkpoints and its arena directory (and a snapshot.json an older build left), then exits. The relaunch is a cold rebuild from the database. Use it when the rows themselves are suspect.
reloadNo exit. The scheduler calls the SSP’s POST /admin/reload, which rebuilds the circuit in-process from the database. Schema changes do not need it (they are applied live), but it is there for a suspect circuit.

Nothing happens until the next heartbeat (5 s by default). An operation is marked done when the SSP has re-registered and reached ready, and failed if that has not happened within one heartbeat interval plus the scheduler’s bootstrap timeout plus 30 s. An SSP older than this release reads the directive as free text and restarts anyway, so a clean on it degrades to a plain restart; the operation records the SSP’s version so you can tell.

Restart all defaults to rolling: one SSP is flagged, the scheduler waits for it to come back ready, then flags the next, in id order. Queries are pinned to an SSP, so taking one down is already an outage for its clients; taking all of them down at once is an outage for everyone. All-at-once is still offered for the case where you want exactly that. A roll stops at the first SSP that does not come back, because continuing would take down the next one while this one is still missing. Only one roll runs at a time.

Restarting the scheduler

ModeWhat happens
restartThe scheduler answers 202, waits for the response to flush, and calls exit(0). Sp00ky Cloud runs it under Docker’s unless-stopped, which relaunches on any exit code, and the startup integrity check runs again on boot.
recloneResets the local replica and re-fetches everything from upstream SurrealDB, then flags every SSP to re-verify. The same thing POST /admin/resync on the ingest port does. Runs as an operation, because it can take minutes.
rehashKeeps the replica’s rows and recomputes the persisted snapshot hashes from them, then flags every SSP. Repairs hash-metadata drift without a refetch.

A scheduler run from a checkout has no supervisor and simply stops. GET /admin/api/config reports supervised (true when SPKY_ENV=cloud) so the dashboard can say so before you press the button rather than after. Reclone and rehash are refused with 503 while the scheduler is cloning or restoring, and with 409 while another reclone is running.

Cloud-only actions

Some things are not the scheduler’s to do: pulling a newer image, wiping its own volume (RocksDB holds the files open), bouncing SurrealDB, and the backup catalog with its schedule and retention. Those belong to Sp00ky Cloud, which owns the containers and the backup rows. When the scheduler is linked (see Configuration) the dashboard offers them and the scheduler forwards the request; when it is not, every such action answers 409 with Not linked to Sp00ky Cloud, and the dashboard shows the option disabled with that sentence beside it.

If Sp00ky Cloud refuses the scheduler’s credentials, the dashboard shows a 502 with code: cloud_auth and the sentence from the control plane. It is never relayed as a 401: that status means “not signed in” to the dashboard, and a cloud refusal has nothing to do with your session.

POST /admin/api/cloud/restart takes {roles, upgrade, clean, surreal} with exactly the meaning of spky restart: upgrade force-pulls the latest scheduler and SSP images, clean wipes the scheduler’s volume (and always includes the scheduler in the role set), surreal also bounces SurrealDB. Empty roles means scheduler plus SSPs. A request that recreates this scheduler will, by definition, not be reported done by it; the dashboard’s reconnect loop observes the outcome instead.

Operations

Every asynchronous action records an operation: kind, target, who asked, start and finish times, status (running, done, failed), the server’s own closing message, and free-form progress ({done, total, current} for a rolling restart, the registry state for a backup).

  • GET /admin/api/operations lists the most recent ones, newest first.
  • GET /admin/api/operations/stream is an SSE stream (event operations) that sends the whole list on every change.
  • GET /admin/api/overview embeds the running ones, so the poll the dashboard already makes shows activity without a second request.

Operations live in memory, bounded at 50. Finished ones are evicted oldest first; a running one is never evicted, however old, because a stuck operation is exactly what you still need to see. They are a UI convenience, not an audit log: the audit trail is the scheduler’s own log output.

Workflows, schedules and jobs

ActionWhat happens
Cancel a runSets kill_requested and runs the kill immediately: every dispatched step’s job gets /job/kill broadcast to the ready SSPs. Sub-second, with the flag as the durable fallback the next sweep would act on. 409 unless the run is running.
RerunA brand-new ad-hoc run with the same frozen DAG, input and target table, trigger: manual, and rerun_of pointing at the source. It has no owning schedule run, never counts towards concurrency: skip, and rolls up under (ad-hoc). Any source status is allowed.
Retry from failedOnly for a failed or killed run. Resets failed and skipped steps to blocked, keeps successful steps and their outputs, reopens the run (and its owning schedule run, so the outcome is mirrored there) and dispatches the reset steps again under new job ids of the form <table>:wf_<run key>_<step>_r<n>. Old job rows are left as history; a job row is never deleted or re-created under the same id. Refused with 409 step <name>'s job is still processing; wait for the kill to settle while a killed step’s job has not gone terminal on its SSP, because resetting it before the kill lands would run the step twice.
Pause / resume a scheduleWrites paused on the _00_schedule row, the same field spky schedules pause writes.
Run a schedule nowWrites trigger_requested_at. 409 while the schedule is paused (pause wins over a queued trigger, so the fire would silently never happen) or disabled in its config.
Kill / retry a step’s jobThe cluster /job/kill (broadcast to every ready SSP) and /job/retry (one SSP is picked). The SSP’s own verdict is relayed: a 409 for a job that is not terminal, a 404 for one it does not know.
Note

Retrying the latest fire of a schedule leaves _00_schedule.last_run_status at failed even when the retry succeeds: the engine only lets a failure overwrite an equal last_run_at. The run and schedule-run rows are correct; only that summary field lags.

A scheduler upgraded before spky deploy has migrated the schedule tables answers 409 asking you to run it: rerun and retry write fields (trigger, rerun_of, retry_count as option<int>, last_retry_at on _00_workflow_run) that older schemas do not have.

Access

The Access page mints long-lived tokens for MCP clients, so an AI agent can do everything this dashboard does through the scheduler admin MCP at /admin/api/mcp. A token has a label, a scope and a lifetime of up to 365 days, and is shown once; the page prints the claude mcp add command and the Cursor and VS Code snippets with everything filled in. Paste a token back to revoke it.

ScopeMeaning
readMay only make GET requests to the API. Any other method answers 403 This token is read-only
fullEverything the signed-in operator can do

The bearer middleware enforces that on every request, so a read token binds an agent exactly as it binds a browser. POST /admin/api/mcp is the single exception, and it has to be: MCP carries reads over POST as well, so the endpoint is exempted from the method check and enforces scope per tool instead. A read token’s tools/list lists only read tools, and calling a write tool anyway comes back as a tool error rather than a result. The reach is the same either way, only the place it is checked differs.

Roster and break-glass sessions are always full. An MCP token cannot mint further tokens; only a person’s own session can. Tokens follow the session rules: signed and restart-proof when SPKY_AUTH_SECRET is set, in memory otherwise.

Minting an MCP token, and the tools an agent can then call.
Minting an MCP token, and the tools an agent can then call.

Backups

The catalog, the schedule Sp00ky Cloud runs it on, and restore.
The catalog, the schedule Sp00ky Cloud runs it on, and restore.

The Backups tab merges two sources rather than choosing between them:

  • This scheduler executes backups and restores. Its registries know the live state of anything in flight, including restore stages nothing else can see. They are in memory and forget on restart, which the tab says under Restore history when it is empty.
  • Sp00ky Cloud, when linked, owns the catalog, the schedule, retention and deletion.

Linked, the catalog is the control plane’s list with each row joined to the scheduler’s registry entry by id (the cloud worker uses its own row id as the scheduler’s backup id, so the join is exact). Unlinked with S3 configured, the catalog is a listing of {slug}/*.surql.gz in the bucket, where the slug is SPKY_CLOUD_PROJECT or, failing that, SPKY_PROJECT_SLUG (default default). That is enough to create and restore; there is no schedule, retention or delete, and the tab says so instead of hiding the panels.

A restore reports the stages the scheduler actually goes through:

queued → running → main_db → replica → done
                                   ↘ failed

main_db means the main SurrealDB has been wiped and re-imported; replica means the scheduler’s own replica followed. A restore that ends failed with the main database restored but the replica not is the deliberately stuck Restoring state described under Cloud → Backups; the tab calls it out with the recovery step.

Warning

A restore wipes the database, evicts every SSP (they re-bootstrap against the restored state) and requires your migrations to be run again. The dialog asks you to type restore before it proceeds.

Who is connected, and what they registered

Two surfaces, split by the question each answers.

Overview carries the rollup, because “is anyone using this?” is a landing-page question: a band of four tiles — users, sessions and registered views, each with its count and its history, and a view-health tile with the hot / large / slow / errored counts. Each chart keeps its own scale; views outnumber users by one to two orders of magnitude, and a shared axis would flatten the users line onto the baseline.

Every registered live query, what it costs and who is watching it.
Every registered live query, what it costs and who is watching it.

Views is the tab you open to ask which queries are registered: a filterable, sortable table of every live query with its owner, SSP, row count, subscribers, materialization percentiles and time to expiry. Clicking one opens its SurrealQL and params, every subscribing session with its age, that view’s heap inside its SSP, and the other live sessions running the identical query.

Everything is derived from _00_query, the registry of live queries. One row there is one client session x query — the query id is hashed with a per-browser-session salt, so two tabs of the same person register two rows. A reload keeps its tab’s salt and re-registers onto the rows it already had. That is what makes the rollup possible: distinct auth_id counts people, distinct clientId counts open tabs, and the row count is registered views.

Liveness is TTL-shaped, not instant

A client proves it is still watching by refreshing lastActiveAt on a0.9 × ttl timer — about nine minutes at the default 10m. A row counts as live while lastActiveAt + ttl > time::now(), the same rule the TTL sweep uses. A browser tab that closes normally hands its rows back on pagehide: fn::query::leave pulls their expiry in to one minute, and the sweep removes them after that. The minute is a grace for a reload of the same tab, which re-registers onto the same rows and finds its views still warm. A tab that never sends that release (a crash, a mobile tab killed in the background, a lost request) decays out over the full ttl instead. So does every signed-out tab: an anonymous row answers only to the WebSocket sessions that registered it, and the release goes out as a separate HTTP request. Every row on the Views tab carries its own expires so you can see which ones are on the way out. There is no finer signal to be had: the client’s WebSocket goes to SurrealDB, not to the scheduler, so there is no connection to count.

One background sampler feeds both surfaces, so ten open dashboards cost the database exactly what one does, and the totals ride the overview poll the app already makes rather than a request of their own.

Hot views

A view whose updateCount climbs at least SPKY_ADMIN_HOT_VIEW_UPDATES_PER_SEC times a second (default 5) is hot. The count on _00_query is a running total, so the sampler reads it on every tick and divides the difference by the gap since the last one. The Views tab shows each row’s current rate next to its update count, marks hot rows, and opens with a banner naming the hottest ones; Hot only (?hot=true) narrows the table to them. A row that was hot earlier but has calmed down keeps its peak (peaked 125/s) for as long as it stays registered. The view’s own page shows the rate and the peak with its time.

The shape this catches is a bulk write into a table somebody has open: an import, a backfill, a script. Every update is a delta the SSP publishes and every subscriber downloads, so one view moving a hundred times a second can fill the SSP’s publication queue and stall registrations for everyone. Pace the writer, or window the view so most of those rows never land in it.

While any view is hot, one hot_views incident stays open under the component views, so a burst nobody was watching still leaves a record. It closes once every view has stayed under the rate for a minute (a pause between import slices does not split one episode in two) and its summary names the peak, the view key and the user. A rate needs two samples, so a view registered less than one presence interval ago has none yet, and the rate is measured over that interval (15s by default).

Live updates and caching

The dashboard keeps one WebSocket open to GET /admin/api/v2/live instead of polling. It subscribes to topics (the overview, presence, the metrics series, jobs, workflow runs, incidents, operations, the scheduler’s log tail); the scheduler computes each topic once however many dashboards watch it and sends a snapshot followed by small patches, so an idle cluster sends nothing and ten open tabs cost what one does. The session token is the socket’s first message, never part of its URL. After a network blip the dashboard resumes from the last patch it holds; the scheduler keeps a minute of them.

On https pages the dashboard caches the topics in the browser (SQLite in the Origin Private File System), so a reload paints at once and then asks only for what changed. Signing out clears that cache. A scheduler reached over plain http keeps them in memory only. If a proxy blocks WebSockets the dashboard falls back to polling on its own.

Configuration

VariableDefaultMeaning
SPKY_ADMIN_ENABLEDtrueSet to 0/false/off to disable the admin listener entirely
SPKY_ADMIN_PORT9668Admin listener port
SPKY_ADMIN_HOST0.0.0.0Admin listener bind address
SPKY_ADMIN_DIR/usr/share/spooky/dashboardWhere the built dashboard lives
SPKY_ADMIN_PASSWORDunsetBreak-glass password. Unset disables password-only login
SPKY_ADMIN_ACCESSautoPins the record-access method used for sign-in
SPKY_ADMIN_SESSION_TTL_SECS28800Session lifetime (8 hours)
SPKY_ADMIN_PRESENCE_INTERVAL_SECS15How often the presence sampler reads _00_query. One sampler serves every open dashboard
SPKY_ADMIN_SLOW_VIEW_MS250Materialization p99 at which a view counts as slow. ?slow_ms= overrides it per request
SPKY_ADMIN_LARGE_VIEW_ROWS1000Row count from which a view is flagged large on the Views tab and the Overview. Every row of an unwindowed live view is republished as an edge on each cold registration, so a few thousand at once can stall the database; ?large=true filters the list to them
SPKY_ADMIN_HOT_VIEW_UPDATES_PER_SEC5Updates per second, between two presence samples, from which a view is flagged hot on the Views tab and the Overview and opens a hot_views incident. ?hot=true filters the list to them. See Hot views
SPKY_ADMIN_PRESENCE_MAX_ROWS20000Ceiling on rows one presence or views query may pull back
SPKY_ADMIN_JOB_INTERVAL_SECS15How often the job sampler reads the outbox tables while nobody has the Jobs page open. 0 switches the sampler off: the Jobs list still works, the totals and the Overview tile report that they are not measured
SPKY_ADMIN_JOB_LIVE_INTERVAL_SECS2The cadence while at least one dashboard holds /admin/api/jobs/stream open. Clamped to no slower than the idle interval
SPKY_ADMIN_ALLOWED_ORIGINSunset (any)Comma-separated browser origins allowed to call the API and open the live socket. Unset or * allows any (the bearer token, never a cookie, is what authenticates); the scheduler’s own page is always allowed. Sp00ky Cloud sets it to the console and the project’s admin host
SPKY_ADMIN_CLOUD_LOGINtrueWhether console grants are accepted on a cloud-linked scheduler. 0/false/off refuses them
SPKY_ADMIN_CLOUD_JWKSunsetThe control plane’s grant keys as a JWKS document. Without it (or for an unknown key) they are fetched from {SPKY_CLOUD_API_URL}/.well-known/jwks.json
SPKY_ADMIN_CLOUD_ISSUERSPKY_CLOUD_API_URLThe issuer a grant must name
SPKY_ADMIN_CLOUD_SESSION_TTL_SECS900Lifetime of a session opened with a grant (capped by SPKY_ADMIN_SESSION_TTL_SECS)
SPKY_ADMIN_DATAtrueSet to 0/false/off/no to refuse /admin/api/data and hide the console’s Database, Users and Storage pages for this project. On Sp00ky Cloud, set it under deployment.env for the scheduler
SPKY_BUCKET_DIRunsetA directory holding file: buckets, mounted read-only at the same path SurrealDB uses. Buckets under it list their subfolders in the Storage page. Writes still go through SurrealDB
SPKY_CLOUD_API_URLunsetSp00ky Cloud API base URL. Injected into the scheduler container by the control plane
SPKY_CLOUD_PROJECTunsetThe project slug, injected alongside it
SPKY_PROJECT_SLUGdefaultStorage prefix for backups on an unlinked scheduler

Backup storage is configured separately, and those five variables carry no SPKY_ prefix: S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, S3_BUCKET and S3_REGION. The backup plane switches on as soon as any of the first four is present, and each one falls back to a local-MinIO default when it is not, so set them deliberately rather than partially. Sp00ky Cloud injects them for you; self-hosters set them by hand, and Self-hosting lists them with their defaults. Without them the Backups tab still loads and says storage is unconfigured, and POST /admin/api/backups answers 503.

The scheduler counts as linked when SPKY_CLOUD_API_URL, SPKY_CLOUD_PROJECT and SPKY_AUTH_SECRET are all set. The first two are not secrets; the credential is the SPKY_AUTH_SECRET the scheduler already holds as its cluster identity, which the control plane accepts on a small, allow-listed route family for that one project. No new credential is minted or stored. GET /admin/api/config reports the result as cloud_linked.

Running from a checkout

The dashboard is shipped as files rather than embedded in the binary, so cargo build -p scheduler needs no Node toolchain. A scheduler with no bundle starts normally, logs one warning, and serves a short placeholder page at /admin; the API on that port is unaffected.

To build and serve it locally:

pnpm --filter @spooky-sync/dashboard build
SPKY_ADMIN_DIR=apps/dashboard/dist cargo run -p scheduler

API

Every endpoint is under /admin/api on the admin port. All require Authorization: Bearer <token> except GET /admin/api/config.

Two routes sit outside that prefix: the built dashboard itself, served from /admin, and GET /, which answers a 301 to /admin/ so typing the host and port with no path lands on the dashboard.

EndpointPurpose
GET /admin/api/configScheduler id, version, breakglass_available, cloud_linked, cloud_login, supervised, sessions_persistent, project_slug, and live (the live protocol version) with its topics. Unauthenticated
POST /admin/api/sessionSign in. {username, password}, {password} for break-glass, or {grant} from the Sp00ky Cloud console
GET /admin/api/v2/liveWebSocket. First message {"op":"auth","token":…}; then {"op":"sub","ref","topic","args","since"} per topic. The host answers snap (full state) and diff (from, seq, patch), and expiring before the session ends. Closes 4401 for a lost session
GET /admin/api/v2/snapshotThe same topic states for a reader that polls. ?subs=[{"topic":"overview"},{"topic":"metrics","args":{"name":"sync"}}]
POST /admin/api/logoutRevoke the current token
GET /admin/api/meCurrent session’s subject, label, mode and scope
GET /admin/api/overviewScheduler, SSP and backend entities plus totals, and the presence rollup
GET /admin/api/presenceLive users, sessions and views, the sample ring behind the charts, the heaviest users and the per-SSP split. Served from the sampler’s memory
GET /admin/api/viewsRegistered live queries. ?limit=, ?user=, ?ssp=, ?sort=, ?slow_ms=, ?q=, ?shared=, ?large=, ?hot=, ?include_expired=. Every row carries update_rate (per second, null until sampled twice), update_rate_peak, update_rate_peak_at_ms and hot
GET /admin/api/views/:keyOne view: SurrealQL, params, subscribers, percentiles, its SSP’s memory attribution, and the other sessions running the identical query
GET /admin/api/backendsBackend list
GET /admin/api/backends/:nameOne backend with probe history and masked env
GET /admin/api/logsSSE. ?source=scheduler or ssp:<id>, &tail=, &backfill=
GET /admin/api/workflows/runsRun list. ?name=, ?schedule=, ?status=, ?rerun_of=, ?limit=
GET /admin/api/workflows/runs/:idOne run with its steps and DAG
GET /admin/api/workflows/streamSSE. Run list, pushed on change
GET /admin/api/jobsOutbox jobs across every table, newest activity first, with the queue totals and a per-table breakdown. ?status=, ?table=, ?origin=schedule|workflow|app, ?q=, ?limit=. Unfiltered it is served from the sampler’s memory and costs the database nothing
GET /admin/api/jobs/streamSSE. Event jobs, the list and totals pushed on change. Holding it open is what puts the sampler on its live cadence. 501 when the sampler is disabled
GET /admin/api/jobs/:idOne outbox job: status, path, payload, result, every attempt in errors, retry budget, assignee, lease, and the origin block naming the schedule fire or workflow step it came from. 404 unless the id’s table is in _00_retention.job_tables
GET /admin/api/schedulesSchedule definitions with next/last fire
GET /admin/api/schedules/:nameOne schedule, its recent fires, and its _00_run_rollup tally
GET /admin/api/operationsRecent operations, newest first
GET /admin/api/operations/streamSSE. Event operations, the whole list on every change
POST /admin/api/ssps/:id/restart{mode: "restart" | "clean" | "reload"}. 202 with the operation
POST /admin/api/ssps/restart-all{mode: "restart" | "clean", rolling: true}. 409 while a roll is running
POST /admin/api/scheduler/restart{mode: "restart" | "reclone" | "rehash"}
POST /admin/api/cloud/restart{roles, upgrade, clean, surreal}, forwarded to Sp00ky Cloud. 409 when unlinked
GET /admin/api/cloud/deploymentThe deployment as Sp00ky Cloud sees it. 409 when unlinked
GET /admin/api/cloud/statsThe Resources page: services (status, allocation, newest sample, cpu_series, mem_series, disk), machines, pool_machines, bucket_volume (size_gb, usage.fs_bytes/used_bytes/free_bytes). 409 when unlinked
GET /admin/api/backupsCatalog, local registry, restores, schedule config, storage status
POST /admin/api/backups{name}. 202 with the operation and backup_id. 503 without S3 storage
PUT /admin/api/backups/config{enabled, schedule, retention}. 409 when unlinked
DELETE /admin/api/backups/:idSoft-delete in the catalog. 409 when unlinked
POST /admin/api/backups/:id/restore202 with the operation and restore_id. 503 unless the scheduler is Ready
GET /admin/api/backups/:id/restore{cloud, local, stage} for the latest restore of that backup
POST /admin/api/workflows/runs/:id/cancel202 {run, status: "killed" | "kill_requested"}
POST /admin/api/workflows/runs/:id/rerun201 {run, rerun_of}
POST /admin/api/workflows/runs/:id/retry200 {run, retry_count, reset, kept}
POST /admin/api/schedules/:name/pause{name, paused: true}
POST /admin/api/schedules/:name/resume{name, paused: false}
POST /admin/api/schedules/:name/trigger{name, triggered_at}. 409 when paused or config-disabled
GET /admin/api/poolsEvery machine pool: sizing, pause and breaker state, machines by state, busy slots, jobs waiting for a machine
GET /admin/api/pools/:name/machinesOne pool’s machines, newest first, terminal ones included: state, slots in use, last contact, why it was taken away
POST /admin/api/pools/:name/pauseNo new jobs assigned and no machines created; running jobs finish. Survives redeploys
POST /admin/api/pools/:name/resumeResume a paused pool
POST /admin/api/machines/:id/drainThe machine takes no new jobs, finishes what it runs, and is destroyed
POST /admin/api/jobs/:id/killThe cluster kill, relaying the SSPs’ answer
POST /admin/api/jobs/:id/retryThe cluster retry, relaying the SSP’s answer
POST /admin/api/jobs/clear{table, all}. Batch-deletes terminal rows, never processing ones. 200 {cleared, total, more}
POST /admin/api/tokens{label, scope: "read" | "full", ttl_days}. 201 with the token, shown once. 403 for an MCP or read-only session
DELETE /admin/api/tokens{token}. Revokes it. 204
POST /admin/api/mcpThe scheduler admin MCP server: JSON-RPC over Streamable HTTP, stateless. GET and DELETE answer 405
When sign-in says the database is unreachable

A 503 on the login screen — “Cannot reach the tenant database, so no sign-in can be checked” — is not a credential problem and no password will fix it. The roster path signs in through your database, so it needs that database reachable; when it is not, the scheduler says so instead of answering 401. Check the scheduler’s log for the reason and the database container’s health. A tenant database can reach a state where it keeps answering existing connections and its REST endpoints while refusing new SDK sessions, so “the app still works” does not rule this out; restarting the database container clears it.

Upgrading to canary.212

retry_count on _00_workflow_run became option<int>: as a plain int every update to a run created before the field existed failed validation, which blocked cancel and the engine’s own finalize on those rows. spky migrate applies the new definition and a one-time backfill; run it once after upgrading.

The workflow endpoints are served by the scheduler rather than read from the database directly because _00_workflow_run, _00_step_run and _00_schedule_run are PERMISSIONS NONE — no record-auth token can read them. The scheduler holds root and is the cluster’s only schedule ticker, which makes it the right place to serve them.

Why a row ended the way it did

Every skip, kill and failure the engine records carries a structured cause on the row’s error field — a stable code, a sentence, and whatever ids make the sentence actionable — and the dashboard renders that rather than a JSON payload. The ones worth knowing by name:

codeOnMeans
concurrency_skip_00_schedule_runThe fire was suppressed by concurrency: skip. blocked_by is the run holding the key and since is when it started. A run wedged in running suppresses that key’s fires for as long as it stays there, so the blocking run is what needs acting on — not the skipped rows
upstream_failed_00_step_runThe step is below a failure under on_failure: continue-independent. blocked_by names the dependency one hop up, so the chain can be walked a step at a time
run_halted_00_step_runThe step had not started when the run halted under on_failure: halt. failed_steps names what actually broke, which is elsewhere in the DAG
not_started_00_step_runThe run was killed or reaped before this step started. run_error carries the run’s own reason
never_dispatched_00_step_runThe step was promoted but its job row was never created, and the run stopped before recovery re-dispatched it
lost_dispatch_00_step_runThe step exhausted its dispatch attempts without ever getting a job
job_failed_00_step_run, _00_schedule_runThe step’s job failed. Normally the job’s last errors entry verbatim; synthesized, and saying so, when the job recorded none
job_missing_00_step_run, _00_schedule_runThe job row is gone — deleted, or pruned by retention while the step still pointed at it
step_failed_00_workflow_runA step failed and the run failed with it. The step’s own row carries the real reason
deadline_exceeded_00_workflow_run, _00_schedule_runThe reaper stopped a run that passed its deadline: (or the project’s run_deadline_secs) without finishing, and killed its jobs
killed_00_workflow_run, _00_schedule_runAn operator cancelled the run
spawn_failed_00_workflow_run, _00_schedule_runThe fire could not create what it needed — the job row, or the run’s steps
bad_dag_00_workflow_runThe frozen DAG on the run does not validate. A redeploy cannot fix a run already in flight; its DAG is frozen at spawn
no_target_table_00_step_runThe step names no outbox table and its schedule has no target_table

_00_schedule.last_error is the exception: it is a bare string, because it is why a schedule could not be planned or fanned out at all (a bad cron, a failing forEach) — which happens before any run row exists to carry a cause.

Each step in GET /admin/api/workflows/runs/:id carries both created_at and started_at, and the distinction matters: the engine creates every step row when the run is spawned, so created_at on a step deep in a DAG is the run’s start, not the step’s. started_at is stamped when the step’s job is created and is the only honest answer to “when did this begin”. The Timeline draws from it, and falls back to the moment the step’s last dependency finished — marked as an estimate — for runs recorded before the engine stamped it.

Incident details show publication backlog when an SSP heartbeat sample was available: peak queued operations and bytes, longest observed wait, parked batches, last successful publication, and related view links. Bulk database session generation and reconnect timing distinguish database recovery from circuit evaluation. The SSP page also shows its current publication backlog. These are sampled measurements; a spike between samples can be missed.

Project data

/admin/api/data is what the console’s Database, Users, Feature flags and Storage pages read. The scheduler and the SSP node serve the same routes from one implementation (schedule_core::admin_data). GET /admin/api/config says whether a host has them, as data, and the flag routes, as flags. These pages are in the console only for now; the scheduler-hosted /admin does not show them.

A read-only session gets the GET routes. An MCP token gets none of them, whatever its scope, and no MCP tool reaches them. Every change is logged with the session’s subject, and the SQL editor’s log line names the first word of the statement, never its text.

EndpointPurpose
GET /data/tablesNames, kinds and shapes of the tables. Nothing is counted
GET /data/tables/:tableIts fields (with credential, required, readonly), indexes and events
GET /data/tables/:table/rows?limit= (500 at most), ?start=, ?sort=, ?dir=, ?filter=[{"field","op","value"}] (16 at most). op is eq, ne, gt, gte, lt, lte, contains, starts_with, is_none or not_none. A value is cast by the field’s type. Top-level fields only, and not a credential: filtering on one is a way to read it
GET /data/tables/:table/count?filter=. A scan, stopped at 10 s: {"count": null, "timed_out": true}
GET /data/tables/:table/row?id=One row, with long values kept
POST /data/tables/:table/rows{id?, set}. A relation takes set.in and set.out
PUT /data/tables/:table/row?id={set, unset}. null and unset both remove a value
DELETE /data/tables/:table/row?id=One row
POST /data/tables/:table/rows/delete{ids}, 500 at most
POST /data/query{sql}, as root. A lone SELECT is given a TIMEOUT 30s and a LIMIT 1001 when it has neither (one past what is returned, so truncated can say there was more), and 1000 rows of any one statement come back. Refused to read-only sessions
GET /data/users?q=, ?filter=admins|online, ?limit=, ?start=, ?sort=, ?dir=, ?source=. Where the users are comes from the SIGNIN clause of a record access (?source= picks one), and each carries is_admin, views and sessions. Newest first only when an index on the created field makes that free (created_indexed); otherwise ask with ?sort=
GET /data/users/statstotal, admins, online. null for one it could not read
GET /data/users/user?id=The row, roster entry, live sessions and feature flags: features lists every flag with the variant the user gets and the one they are pinned to, features_assignable whether flags apply to their table
POST /data/users{identifier, password, fields?}
PUT /data/users/user/password?id={password}, 8 characters or more, hashed with the algorithm SIGNIN compares with. 501 when SIGNIN does not show one
PUT /data/users/user/admin?id={admin, note?}: what spky admin add and remove do. Nobody can remove themselves
DELETE /data/users/user?id=The row, its roster entry and its feature flags. Nobody can delete themselves
GET /data/flagsEvery flag with its rollouts and how many users are pinned, and whether the schema has the flag tables (installed), fn::feature::assign (evaluator) and a user table (users_table)
POST /data/flags{key, description?, variants?, default_variant?}, as spky flag create: on, no rules. Answers evaluate: true
GET /data/flags/flag?key=The flag, users per variant (counts, null when the count timed out) and the pinned users by name, 500 at most (pins_total counts them all)
PUT /data/flags/flag?key=Any of {enabled, description, default_variant, variants, rollouts: [{variant, percent}]}. rollouts replaces every rollout rule. A variant the default or a rule still uses cannot be removed. evaluate says whether assignments can have moved
DELETE /data/flags/flag?key=The flag and every assignment of it
POST /data/flags/flag/evaluate?key=Re-evaluate one page of users in id order: ?limit= (500 at most), ?after= the next of the page before. total on the first page, next: null after the last
PUT /data/flags/flag/user?key=&id={variant} pins a user record to a variant (an allowlist rule), {variant: null} unpins them. Only that user is re-evaluated
GET /data/bucketsEach bucket’s backend and limit, whether it can be browsed, and lists_folders: false for a file: bucket whose subfolders this host cannot see
GET /data/buckets/:bucket/files?prefix=, ?start=, ?limit=. One folder’s files and folders, and next when there is more. counts gives each folder’s entries, up to 1000, when the folder was read from disk
GET /data/buckets/:bucket/file?key={key, size, updated, data}, data in base64, up to 32 MB. &meta=1 leaves data out
PUT /data/buckets/:bucket/file?key={data} in base64. 409 when the key is in use, unless &overwrite=1
DELETE /data/buckets/:bucket/file?key=One file
POST /data/buckets/:bucket/rename{key, target}. 409 when the target is in use
POST /data/buckets/:bucket/delete{keys}, 500 at most

What the browse routes keep back:

  • Credentials. A field named like one (password, secret, token, private_key, api_key, in any case and spelling), or the field a SIGNIN clause compares a password against, reads as {"$redacted": true} whatever its shape, and cannot be written, filtered or sorted on. Only a number under token stays: tokens_used is a count. This goes by name. A value carrying a mask cannot be written back at all, so a form cannot replace a secret with its mask. POST /data/query returns what the statement selects.
  • Definitions. A credential’s default, value and assert, every event’s then and every bucket’s permissions stay behind: any of them can hold a literal.
  • _00_push_credential and _00_push_subscription, which hold push keys and device tokens, are not listed and answer 403.
  • Long values. Over 4 KB in a list (512 KB on one row) a value reads as {"$trimmed": true, "bytes": n, "preview": "..."}.
  • _00_* tables answer 403 to every write. The roster moves through its own route.

Regenerating these screenshots

They are captured from the real dashboard driven by a fixed fixture, not by hand:

pnpm --filter @spooky-sync/dashboard screenshots

See apps/dashboard/screenshots/README.md for what to update when a route is added or a response shape changes.