Skip to content
BuildStackHQHosting
All docs

Docs

The dashboard

What each screen does, for the people on your team who will not use the CLI.

apps/dashboard is the screen a developer opens when a run failed. It is a Next.js App Router application that talks to the public API through @keel/api-client and nothing else: no database, no internal services. It runs as a Node server (output: "standalone") on Fargate behind the ALB, next to the API.

Environment

Variable Purpose
AUTH_ISSUER_URL Zitadel issuer, the same value the API uses. Discovery is read from <issuer>/.well-known/openid-configuration.
DASHBOARD_CLIENT_ID Client id of the keel-dashboard web application (KEEL_DASHBOARD_CLIENT_ID from zitadel:bootstrap). Authorization code with PKCE, no client secret.
NEXT_PUBLIC_API_URL Origin of the public API, for example http://localhost:3000. Only the server reaches it; the browser never holds a token.
DASHBOARD_PUBLIC_URL Origin the browser uses, default http://localhost:3001. Builds the redirect uri, every auth redirect and the cookie secure flag (https only). Zitadel must list <DASHBOARD_PUBLIC_URL>/auth/callback as a redirect uri.
SESSION_SECRET At least 32 characters. Derives the AES-256-GCM key that seals the session cookie. Rotating it signs everyone out.
AUTH_SCOPE Optional, default openid email profile offline_access. offline_access is what makes Zitadel issue a refresh token.
AUTH_AUDIENCE Optional. When set, urn:zitadel:iam:org:project:id:<AUTH_AUDIENCE>:aud is added to the scope so the access token carries the project id the API checks.

Copy apps/dashboard/.env.example to apps/dashboard/.env.local for local work.

Running it

pnpm --filter @keel/dashboard dev            # next dev on http://localhost:3001
pnpm --filter @keel/dashboard build          # next build, standalone output in .next/standalone
pnpm --filter @keel/dashboard start          # next start (development convenience)
pnpm --filter @keel/dashboard start:standalone   # what the container runs: node .next/standalone/apps/dashboard/server.js

The container image is apps/dashboard/Dockerfile: pinned node:22-alpine, non-root node user, the standalone bundle plus .next/static, listening on PORT=3001, health checked on /health. next/font downloads IBM Plex at build time, so the build stage needs outbound network.

Auth flow

  1. Every request except /auth/* and /health passes through proxy.ts. Without a valid keel_session cookie the browser is redirected to /auth/login?next=<path>; /api/* calls get 401 instead.
  2. GET /auth/login reads the discovery document, generates state, nonce and a PKCE verifier, seals them into the short lived keel_login cookie (Path=/auth, 10 minutes) and redirects to Zitadel's authorization endpoint.
  3. GET /auth/callback checks state against the cookie, exchanges the code with the verifier, verifies the id token (issuer, audience, signature against the JWKS, nonce) and builds the session: access token, refresh token, expiry, subject, email and name. Zitadel puts email in the id token; when it is missing the userinfo endpoint fills it in. The session is sealed with AES-GCM into the keel_session cookie (HttpOnly, SameSite=Lax, 30 days) and the browser lands on next.
  4. When the access token is within 30 seconds of expiry, proxy.ts refreshes it with the refresh token before the page renders and rewrites the cookie on both the request and the response. A failed refresh clears the session and sends the browser back to login.
  5. POST /auth/logout clears the cookies and redirects to Zitadel's end_session endpoint with post_logout_redirect_uri set to /auth/login.

next is never trusted as given. safeNextPath resolves it against DASHBOARD_PUBLIC_URL, requires the same origin, rejects backslashes and control characters (so /\evil.example, //evil.example and absolute urls all collapse to /), refuses /auth/*, and returns the normalized path. It runs when the login starts, again in the callback before the redirect, and in the organization switcher.

proxy.ts also sets a per-request Content-Security-Policy: script-src 'self' 'nonce-<random>' 'strict-dynamic' (Next.js reads the nonce from the request header and stamps it on its own scripts), default-src 'self', object-src 'none', base-uri 'self', frame-ancestors 'none', and form-action 'self' <issuer origin> so the sign out form may redirect to Zitadel and nowhere else. Development adds 'unsafe-eval' for the tooling; https deployments add upgrade-insecure-requests.

Server code gets an API client with keelContext() (lib/api/keel-client.ts): it reads the session, lists the caller's organizations once per request, picks the one in the keel_org cookie (id or slug) or the first membership, and returns createKeelClient({ auth: { bearer }, organization }). The header switcher writes keel_org through a server action and reloads the page. A user with no organization is sent to /organizations/new; the form posts to POST /v1/organizations and selects the new one.

Route handlers under /api exist for what the browser has to do live and cannot do with a token it does not have: /api/agents/{id}/metrics proxies GET /v1/agents/{id}/metrics for the Metrics section's range and replica switches and its 30 s refresh, /api/runs/{id}/events pipes the API's SSE stream (?logs=false leaves the log events out), /api/runs/{id}/steps pages the trace, /api/runs/{id}/steps/{sequence}/payload fetches the presigned payload server side and returns it inline up to 1 MB (beyond that, the presigned url for download; a body the trace retention sweep deleted is never fetched at all because the step carries inputPayload/outputPayload and the panel says "Payload expired"), /api/runs/{id}/logs pages the container logs by chunk cursor, /api/agents/{id}/logs searches the hot log table, /api/agents/{id}/logs/tail pipes the log tail SSE stream (forwarding Last-Event-ID), /api/logs/{lineId}/context fetches the lines around one, /api/builds/{id}/logs pages build logs, /api/builds/{id} answers one build with its revision for the live build card, /api/services and /api/agents/{id}/service answer the service list and one service detail for the live state components. POST /api/agents/{id}/builds/upload?deploy= and POST /api/sites/{id}/deployments stream a tar.gz the browser packed straight through to the API's raw upload endpoints (lib/api/upload-proxy.ts); the browser sends them with XMLHttpRequest so it can show upload progress and cancel an upload in flight. Everything else is a server component or a server action.

The repositories settings page lists the organization's GitHub App installations (GET /v1/github/installations); "Install GitHub App" is a plain link to /settings/repositories/install, a route handler that asks GET /v1/github/install-url?returnTo=<dashboard>/settings/repositories and redirects the browser to GitHub. After the App's setup redirect lands on the API and the API redirects back, the page reads installation_id and account from the query and shows the new installation in the connect form's select. Connections can only be made against a recorded installation.

Screens

Route What it shows
/runs/{id} The product. Header with run id, agent, status, error class, trigger (a service run links back to its agent and shows none, runs until the service stops instead of a deadline, since services have no ceiling), timestamps, deadline, the metered cost from the run's usage (weighted minutes, billable minutes and the size weight; "metered when the run ends" until the usage record exists), attempt and resume lineage, peak memory, callback url. Ordered step list with sequence, kind, name, http status, tokens, start time, duration and status; the failing step is highlighted, expanded, scrolled into view and focused on load. Payloads load on expand and render pretty printed JSON with a raw toggle, copy and download. Live updates over SSE while the run is active. Cancel (with confirmation), resume only when the run reports resumable (a checkpoint exists on a checkpoint-enabled revision), and a callbacks panel with every delivery attempt and a redeliver action. Keyboard: j/k or arrows move, Enter or Space expands, Escape collapses everything, Home/End jump. A terminal trace longer than one page is loaded from the tail (GET /v1/runs/{id}/steps?order=desc), so the failing last step of a 10,000 step run is on screen immediately; the earlier pages fill in behind it and the keyboard focus follows the prepended rows. Active runs still load forward and catch up over SSE with afterSequence. A Logs tab next to the trace shows the container's stdout and stderr (redacted by the supervisor): loaded lazily when opened, paged by chunk through /api/runs/{id}/logs, appended live from log events on the same SSE stream, rendered as a fixed height virtualised list so a 50 MB log stays scrollable, stderr lines tinted with the danger token and levels parsed on the client. The toolbar (Logs for the query language) filters the loaded lines locally so it works for runs of any age: a debounced search box that highlights matches, time range presets (whole run, 15m, 1h, 24h, 7d, custom from and to), level chips, a stream select, a Live toggle that pins the view to the tail while the run is active, pauses when the user scrolls up and offers a n new lines pill to resume, Context on a line to reveal its hidden neighbours, copy line and copy permalink (?line=n<ordinal> reopens the run on the Logs tab scrolled to the line). Keyboard: / focuses search, j/k move, Enter shows context, l toggles live, Escape clears.
/agents/{id}/logs Search across every run and replica of the agent for the last 7 days through /api/agents/{id}/logs (newest first, Load older lines pages by cursor), the last hour by default, with the same toolbar plus a replica select for services; filters live in the URL (?q=&range=&from=&to=&levels=&replica=&stream=). Live streams /api/agents/{id}/logs/tail (SSE, filtered the same way, resumed from the last line id), is on by default for a running service, pauses when scrolled up, and reports when another tab replaced it. Context and Enter fetch /api/logs/{lineId}/context; ?line=<id> opens a line with 100 lines around it and a Back to search button. Usable at phone width: replica, stream and level columns collapse, stderr keeps its left border.
/runs, /agents/{id}/runs Run history with status, agent, trigger, start, duration and error class. Filters by agent and status live in the url (?agentId=&status=), cursor pagination with ?cursor=, a pulsing dot on running rows.
/agents Name, lifecycle, mode badge (run or service) with a live service state dot for service agents (the states come from GET /v1/services on render and are refreshed every five seconds through /api/services), enabled trigger kinds, last run outcome, current revision, all from the single GET /v1/agents page (lastRun and triggerKinds come with each item). Zero agents shows the keel init / keel deploy onboarding snippet.
/agents/new?kind=agent or service The deploy wizard, reached from the New launcher (Agent and Service choices). Five steps with a reducer in lib/wizards/new-agent-wizard.ts: name and slug (the slug follows the name until edited); source, either Connect GitHub (the install round trip goes through /agents/new/install and comes back with installation_id and account, the whole draft is saved to sessionStorage on every change and picked up again for thirty minutes, so a refresh or the install round trip resumes where it was, with a Start over button; the packed archive cannot be persisted, so a resumed draft whose upload is gone lands back on the source step, then an installation, a repository from GET /v1/github/installations/{id}/repositories, the branch prefilled with the default and a Dockerfile path where empty means auto) or Upload (drag a folder, a .zip or a .tar.gz up to 100 MB compressed; the browser packs everything into one tar.gz in lib/wizards/archive/, skipping node_modules, .git, .venv, __pycache__, .DS_Store and every .env file so local credentials never reach the build context, and lifting a single root folder); runtime (run mode only explains itself; a service asks for the ingress port, the health check path, the replicas within the plan's maxReplicasPerService and an optional workspace size within workspaceGbPerAgent); environment variables in a key and value editor (names validated like the manifest, KEEL_ and PORT refused, duplicates refused, and each row can be marked secret); review, which lists exactly what will happen. Deploy creates the agent (POST /v1/agents), stores the variables on it with PUT /v1/agents/{id}/env (Environment and secrets) before any build starts so the plain ones stay editable without a deploy and the secret ones go to the vault instead of the revision manifest, then either connects and binds the repository and starts a build with the deploy spec, or uploads the archive with the deploy spec in the query; the browser lands on /agents/{id}?build={buildId}. A failed step keeps the created agent so a retry does not create a duplicate; a taken slug is reported inline.
/agents/{id} A live Build card at the top while a build is queued or building, or for the ?build= the wizard hands over: status badge, the outcome in one line (detected runtime, revision sequence once deployed, the error otherwise), the log tail polled through /api/builds/{id} and /api/builds/{id}/logs with backoff, polling paused while the tab is hidden and stopped once the build settles, and a page refresh when it does. Revisions with image digest, resources, limits and egress hosts; triggers; source binding (with a form for owners, admins and members to bind or change the repository, branch, Dockerfile path where empty means auto per Builds without a Dockerfile, and auto deploy), an Upload new version dialog (the same drop zone as the wizard; the build replaces only the image of the current revision), and builds with their source (git, push or upload with the archive size), detected runtime and paged logs; pause, resume, archive, trigger with a JSON payload editor and optional callback url (run mode only); build the branch head. A service agent shows its kind badge (Agent or App) next to the mode badge in the header and gets a Metrics section (Metrics): a range switch (1h, 24h, 7d), a replica filter, and small multiples for CPU (with the min to max band), memory (used against the limit line), requests per minute stacked by status class, p50 and p95 latency and network in and out, with restart markers; the charts are inline SVG from @keel/ui (LineChart, StackedBarChart, no chart library) rendered on the server for the first paint and refreshed every 30 s while the tab is visible (the previous charts stay at reduced opacity while a refresh is in flight), each chart is keyboard focusable (arrow keys walk the buckets, the tooltip is read out), and Show table swaps the charts for the bucket table. An empty range explains when data arrives. The series colours are the --chart-1 to --chart-5 tokens, stepped separately for light and dark and checked with the palette validator against each surface; because a stacked column drops the classes with no traffic, any two of its segments can end up touching, so 4xx and 5xx also carry a 45 degree and a 135 degree hatch (ChartTexture in @keel/ui, repeated in the legend swatch) and identity never rests on colour alone. A service agent also gets a Service panel: state badge and dot, the kind badge, up since and a live uptime counter, restarts, last exit code and time, next restart, a link to the current run, whether ingress is connected, the public hostname in a copy field with the alias underneath, a workspace meter against the plan size, a Replicas card and Start (primary while stopped), Restart and Stop (each behind a confirmation dialog) plus Rotate alias for owners, admins and members. The Replicas card shows ready of desired replicas ready, one row per replica (#, state badge: Starting, Ready, Unhealthy, Draining, Waiting to restart or Stopped, ready yes/no, a live uptime counter, restarts with the next restart time while held, a link to the run) and, for writers, a Scale to number input capped at the plan's maxReplicas with an Apply button (Enter submits; a count outside 1..max is marked invalid and cannot be applied; the toast reminds that each replica is billed as one service) with the price under it (n replicas at 19.00 EUR per replica-month, 38.00 EUR per month when up, from pricing on the service detail). Under the Replicas card a Billed as control (writers only) switches the kind between Agent or bot and App service through PATCH /v1/agents/{id}; it is disabled while the service is running and explains that the kind only changes while the service is stopped. During a deploy the card shows a rollout bar (Rolling out: n of m replicas on the new revision, revision id underneath) or, when the new replica never became ready, Rollout failed, the previous revision keeps serving. The panel subscribes to the current run's SSE stream and applies the service events as they arrive, re-reads /api/agents/{id}/service after each one and after the run ends, and polls every two seconds while a transition, a rollout or a scale up is settling. Next to it a Deploy card explains that deploying a revision to a running service is a rolling restart (the platform starts the new run, ends the old one, keeps the workspace and hostname) and only records the revision for a stopped one. Below the panel a Domains section (Custom domains) lists the custom domains of the service as cards with a status pill (waiting for DNS, verifying, active, failed), a one-line explanation of what is missing, the TXT and CNAME records to create with copy buttons for name and value (apex A/AAAA alternatives behind a disclosure), Verify now and Remove (behind a confirmation dialog) for writers, and an add form with inline validation; the section is hidden for run-mode agents and the form for agents without ingress. An Environment section (Environment and secrets, GET /v1/agents/{id}/env) on every agent lists its variables as a table (name, kind badge, value with secrets masked behind a Reveal button that fetches the export for owners and admins and shows the refusal inline for other roles, updated time with the actor in the title), inline Edit and Delete per row, an add form with name validation (reserved KEEL_* and PORT explained before the request), a Paste .env dialog that imports dotenv text with an option to store every entry as a secret, the count against the limit with the precedence rule, and the last 20 changes from the audit log (set, import, export with the names involved and the actor). When the service is running and restartRequired is set a banner offers Restart to apply, which calls the rolling restart.
/services Every service of the organization with its kind badge (Agent or App, from serviceKind on the list item), live state, uptime, up since and hostname; the empty state shows the bot onboarding commands.
/sites/new The site wizard, reached from the launcher's Static site choice: name and slug, the built folder or archive (must contain index.html at its root; dotfiles are skipped), SPA fallback and 404 page, review, deploy. Deploy creates the site, patches the settings, streams the archive to POST /v1/sites/{id}/deployments with upload progress, activates the deployment and shows the public URL in a copy field with links to the site and to its settings and domains. The reducer is lib/wizards/new-site-wizard.ts.
/sites Every static site with its public URL, live deployment and last update, the count against the plan and the stored bytes; writers create a site inline (slug and name). The empty state shows keel init --template static and keel sites deploy.
/sites/{id} Public URL in a copy field, the CLI deploy command, live and rollback deployment, the deployment history (state, files, size, created, by; failed deployments keep their reason) with Activate on ready ones, Roll back and Archive site behind confirmation dialogs, the serving settings form (name, not found page, SPA fallback, response headers and redirects as one rule per line) with the domain's validation errors, and a Domains section (GET /v1/sites/{id}/domains) with the same add form, DNS records, verify and remove actions as a service agent's.
/data-stores Managed Postgres and Valkey (GET /v1/data-stores): name, kind, status badge (provisioning, ready, deleting with the purge date, failed), sampled size against the plan allowance (not sampled yet before the first sample), the secret name the URL is injected as, and created time. Owners, admins and members get a Create data store dialog (name validated as a lowercase slug of at most 32 characters with a live preview of the resulting secret name, kind Postgres or Valkey with the plan price) that posts through a server action and shows the API problem (data_stores_not_in_plan, data_store_limit_reached, data_store_name_taken) as a form error; a plan without features.dataStores shows the Business and Enterprise notice instead of the button. The empty state shows keel db create main and the secrets: snippet. While a store is provisioning or deleting the page refreshes itself every five seconds.
/data-stores/{id} Kind, status, size with allowance, price per month, secret name, created and, for a deleting store, the purge date. A How to connect section explains that the URL lives in the organization secret and shows the keel.yaml secrets: snippet and the process.env.<SECRET> hint; the URL itself is never rendered. Reveal connection URL (owners and admins, POST /v1/data-stores/{id}/connection) shows the URL once in a copy field with a warning that it contains the password and that every reveal is audited, and explains the 403 for other roles. Rotate credentials sits behind a confirmation that says running services keep the old URL until they are restarted. Delete asks the user to type the store name, calls DELETE /v1/data-stores/{id}, reports the seven day purge date in a toast and returns to the list. All actions are disabled while the store is provisioning or deleting.
/usage Runs, weighted minutes (finalized plus in flight) and concurrency as meters against the plan allowances, overage so far, spend cap and a warning when it is reached, an Always-on services card with a row per kind (services.agent and services.app from the usage endpoint: hours, running count, projected spend and the price per replica-month, or a note that the kind is not in the plan), a Managed data stores card with Postgres and Valkey store hours, active count, projected spend and the monthly price per kind (hidden when the plan prices neither kind; the projection is part of the projected spend), current period bounds, and the plan ceiling card from GET /v1/organizations/me/plan (limits, included features, the data store allowance and prices, verified endpoint list version).
/settings/members Invite by email, change roles, remove members and pending invitations.
/settings/api-keys Create a scoped key with an optional expiry in days (at most 365); the plaintext is shown once in a copy field and never again. The table shows when each key expires or that it has expired. Revoke with confirmation.
/settings/secrets Names only. Set (create or replace) and delete; values are never displayed.
/settings/signing-secrets Current and previous fingerprints, rotate (new secret shown once), the verification snippet from Outbound callbacks.
/settings/repositories Connect a GitHub App installation and repository, list and disconnect.
/settings/audit-log Newest first, cursor paged.
/organizations/new Create organization; the landing page for a first login with no membership.

Every screen has an empty state, a loading skeleton that preserves layout, and an error state with a retry. All colours, spacing and type come from @keel/ui/tokens.css; the primitives (Table, Tabs, Dialog, Toast, Skeleton, KeyValue, CodeBlock, CopyField, EmptyState, ErrorState, PageHeader, Select, Meter, SegmentedControl, LineChart, StackedBarChart, Wordmark) live in packages/ui and are shared with apps/web.

Local UI work without Postgres or Zitadel

apps/dashboard/test/fixture-api is a node:http server that plays both the API and the identity provider:

  • /.well-known/openid-configuration, /oauth/v2/authorize, /oauth/v2/token, /oauth/v2/keys, /oidc/v1/userinfo, /oidc/v1/end_session: a fake issuer built with jose, the same pattern as apps/api/test/support/jwks-server.ts. The authorize page lists the fixture users; dev@example.test belongs to two organizations, new@example.test to none.
  • /v1/...: the endpoints the dashboard uses, served from fixtures/*.json in the @keel/api-contracts response shapes (a vitest validates every fixture against the schemas). Requests need a bearer token from the fake issuer and honour Keel-Organization. Mutations (cancel, resume, redeliver, pause, trigger, keys, secrets, members, repositories, organizations) update the in-memory state; POST /__reset restores it.
  • /v1/runs/{id}/events streams a running run: two steps exist, three more arrive over time, cancelling ends the stream. Another run fails at step 4 of 5, one has 1,200 steps to exercise paging, one has a payload above 1 MB to exercise the download path.
  • /v1/services, /v1/agents/{id}/service, service/start|stop|restart and ingress/rotate-alias (fixtures/services.json): support-bot is a stopped agent service with ingress, webhook-relay is an app service that is up with a running service run, two restarts and a 700 MB workspace; the detail carries pricing (49 EUR per replica-month for agents, 19 EUR for apps) and PATCH /v1/agents/{id} changes serviceKind, answering 409 service_kind_locked while the service is not stopped. Start moves a service through starting to up after FIXTURE_SERVICE_SETTLE_MS (default 1200 ms) and stop through stopping to stopped; the transitions are emitted as service events on the run's stream.
  • /v1/agents/{id}/metrics (routes-metrics.ts): deterministic sine shaped series with one missing bucket and a restart marker for webhook-relay while it is up, empty series for support-bot, 400 for a bad range.
  • /v1/data-stores, /v1/data-stores/{id}, DELETE, rotate and connection (fixtures/data-stores.json): Acme Robotics has a ready Postgres store main (700 MB sampled, secret DATABASE_URL) and a ready Valkey store cache (not sampled yet, secret CACHE_REDIS_URL); Beta Labs is on a plan without data stores. Create validates the name, rejects duplicates with data_store_name_taken, enforces maxDataStores, adds the secret name to the secrets list and moves the store from provisioning to ready after FIXTURE_DATA_STORE_SETTLE_MS (default 1200 ms). Delete sets deleting with a purge date seven days out and removes the secret; rotate bumps a generation that changes the fake password; connection returns a fake URL for owners and admins and 403 insufficient_permission otherwise.
  • POST /v1/agents, GET /v1/github/installations/{id}/repositories, POST /v1/agents/{id}/builds/upload, GET /v1/builds/{id} and POST /v1/sites/{id}/deployments (routes-builds.ts): the wizard endpoints. Uploads are read raw, checked to be a gzip tar, and a build moves through building with log lines to succeeded after FIXTURE_BUILD_SETTLE_MS (default 1500 ms), creating a revision whose manifest carries the deploy spec (an ingress binding appears for services); POST /v1/agents/{id}/builds progresses the same way. The fixture installation sees two repositories.
  • /blobs/...: the "presigned" payload store.
pnpm --filter @keel/dashboard fixture-api      # http://127.0.0.1:4010
AUTH_ISSUER_URL=http://127.0.0.1:4010 NEXT_PUBLIC_API_URL=http://127.0.0.1:4010 DASHBOARD_CLIENT_ID=keel-dashboard-fixture \
SESSION_SECRET=local-development-secret-with-at-least-32-chars pnpm --filter @keel/dashboard dev

Tests

pnpm --filter @keel/dashboard test        # vitest: session cookie codec, duration and cost formatting, trace keyboard reducer, wizard reducers and deploy sequencing, browser tar and zip packing, build progress reducer, oidc against the fake issuer, fixture contract validation
pnpm --filter @keel/dashboard build && pnpm --filter @keel/dashboard test:e2e   # playwright, desktop and mobile

Playwright starts the fixture server and the standalone build (test/start-standalone.ts, the same artifact the container runs) and walks the journeys: login and return to the requested page, first login with no organization, open the failing run, payload expansion and the 1 MB download path, live steps and cancel, callbacks and redelivery, the 1,200 step trace, the Logs tab (stderr tint, search with highlights, level and stream filters, context, a 6,000 line virtualised log, live tail follow, a permalinked line, the empty state), the agent log search journeys (test/e2e/log-search.spec.ts: search across replicas with a phrase, level, replica and stream filters, context, the live tail with the pill after scrolling up, a permalinked line, the keyboard walk, the empty window and the 7 day widening; the fixture's POST /__logs/emit appends four live relay lines that any open tail picks up), keyboard-only trace walk, organization switch, run history filters, agent detail actions, api key creation with the plaintext shown once, secrets, signing secret rotation, and the services journeys (test/e2e/services.spec.ts): the services list and the mode badge, start and stop a service watching starting, up, stopping and stopped arrive over SSE, copy the hostname, restart a running service, a service run without a deadline and the service hours per kind on the usage page, the kind badge and the price per replica on the list and the detail with a kind change that is refused while the service runs and applied once it is stopped, and a keyboard-only pass through Start, the Stop confirmation and alias rotation, and the data store journeys (test/e2e/data-stores.spec.ts): the list with the plan notice on Beta Labs, creating a store through the dialog including a rejected duplicate name and watching it provision, revealing, hiding and rotating the connection URL, deleting with the typed confirmation and seeing the deleting status with the purge date, and a keyboard-only pass through the create dialog. KEEL_E2E_DASHBOARD_PORT and KEEL_E2E_FIXTURE_PORT move the two servers off 3101 and 4010 so several runs can share one machine. The wizard journeys (test/e2e/new-agent.spec.ts, test/e2e/new-site.spec.ts) create a service from an uploaded archive and watch the build card reach Revision 1 is live, deploy a run mode agent from a dropped folder and upload a second version from the agent page, connect GitHub through the install round trip with the draft restored, walk the steps with the keyboard, hit the taken slug error, and drop a build folder for a site that ends on the public URL, all on desktop and phone. test/e2e/screenshots.spec.ts writes test/screenshots/run-detail-desktop.png and run-detail-mobile.png for review. The metrics journey (test/e2e/metrics.spec.ts) checks the five charts, their legends and latest values, the range and replica switches, keyboard bucket navigation with its tooltip, the table view, that the charts fit the viewport without a horizontal scrollbar, and the empty state.

API gaps met while building it

  • Revision by id: there is no GET /v1/revisions/{id}, so the run header cannot show the task size or image of the revision that ran without paging GET /v1/agents/{id}/revisions.
  • Live agent list: the SSE stream is per run; the run history table refreshes on navigation only.