# w5 · m4 — Services list + lifecycle actions, Render-consistent, wired to bex-api

**Worker:** worker5 **Goal:** The dashboard home page renders the operator's real Apps from bex-api's `services` GraphQL query — using Render's `Service` field shapes and lifecycle operation names verbatim — and each row can be suspended / resumed / restarted, replacing the hardcoded `sampleServices`. **Status:** done (2026-07-08) — verified live against prod `api.bex.co` (screenshots in `.playwright-mcp/m4-*.png`); t006 `/simplify` applied 2026-07-08.

## Tasks (in order)

| id   | title                                                                                            | est | depends_on         |
| ---- | ------------------------------------------------------------------------------------------------ | --- | ------------------ |
| t001 | Capture Render's services-list IA (columns Name/Status/URL/Actions) mapped to bex-api `services`/`Service` — **DONE** | 30m | —                  |
| t002 | `services.graphql` query + codegen; wire home page to live `services`, drop `sampleServices`, compute stat tiles from real data — **DONE** | 45m | w5/m4/t001         |
| t003 | Loading / error / empty states for the list (skeleton rows, error card, "no services yet") — **DONE** | 40m | w5/m4/t002         |
| t004 | Lifecycle row actions: `suspendService`/`resumeService`/`restartServer` mutations w/ confirm dialog, in-flight disable, poll `phase`/`suspended` until converged — **DONE** | 60m | w5/m4/t002         |
| t005 | Verify live against prod `api.bex.co` (Kratos session); screenshot to `.playwright-mcp/` — **DONE** (`beancount-cms`, `eden-cms-v2`) | 30m | w5/m4/t003, w5/m4/t004 |
| t006 | Simplify — `/simplify` over the code this milestone changed — **DONE**                             | 30m | w5/m4/t005         |
| t007 | Test coverage — meaningful tests for query mapping + list states + lifecycle actions — **DONE** (263 tests green) | 30m | w5/m4/t005         |

## Definition of done

- The home page (`src/routes/index.tsx`) renders the real list of Apps from `https://api.bex.co/graphql` `services` query — **no `sampleServices` array remains in the tree**; the stat tiles (total / running / suspended) are computed from live data.
- The `Service` shape consumed matches Render's dashboard GraphQL verbatim: `id`, `name`, `type: "web_service"`, the **string** `suspended` enum (`"suspended"` / `"not_suspended"`, not a boolean), `dashboardUrl`, `createdAt`, `url` (the live URL), plus bex's `phase` / `replicas` / `revision` superset. **Correction vs. the original spec:** the GraphQL adapter exposes the live URL as a flat `url` field — `serviceDetails.url` is the **REST**-only nesting (`render.go`); the two surfaces differ here by design (`graphql.go:56`), so the query selects `url`.
- Each row's suspend / resume / restart triggers the matching Render-named mutation (`suspendService` / `resumeService` / `restartServer`) and the row's status badge converges after the operator reconciles (polled `phase`/`suspended`); the action is disabled while in flight.
- Loading shows skeleton rows, a failed query shows an explicit error card, and zero services shows an empty state.
- Any Render list column the UI wants but bex-api does not expose is **flagged as a gap**, not faked with a dashboard-only value.
- `yarn lint && yarn typecheck && yarn test && yarn build` all pass.

## Source + Goal linkage

- **Source:** `/pm-brainstorm for w5 to work on dashboard` (2026-07-06) + user directive "all apis and uis should be consistent with render.com". `src/routes/index.tsx:29-33` carries `sampleServices` with a literal "replace with a real Apollo query once wired up" TODO. Wiring path (Apollo + Kratos session + codegen + CORS) proven by w3/m3 (metrics PoC). Render `Service`/operation shapes per `docs/ADR006-bex-api.md` (verified against Render's OpenAPI spec + dashboard GraphQL).
- **Goal linkage:** `docs/ADR008-vision.md` human-facing dashboard pillar + pillar-1 API-first — `services` and the lifecycle verbs are already exposed via REST/GraphQL/MCP, so the dashboard is a pure Render-shaped client, never a dashboard-only feature. `GOAL.md` #2 (basic obs for operation).
- **Expected outcome:** an operator opens the dashboard and sees + controls their real running Apps; the home page stops showing fake data and reads as Render's services list.
- **Why now:** the metrics PoC (w3/m3) just proved the Apollo+Kratos+CORS path end-to-end; services is the highest-traffic next page and the one the metrics page already deep-links into (`/services/$serviceId/metrics`). Wiring it before any further page work stabilizes the primary string/data surface once, on Render-consistent shapes.

## Implementation notes (2026-07-06)

- **Files added:** `src/features/services/{api/services.graphql, types.ts, lib/status.ts, hooks/use-services.ts, hooks/use-service-lifecycle.ts, components/service-row-actions.tsx}` and their `__tests__`; **changed:** `src/routes/index.tsx`, `src/features/services/locales/{en,zh}.ts`, `src/graphql/definitions.ts`, `src/routes/__tests__/index.test.tsx`, `vite.config.ts`.
- **`phase` is capitalized, and orthogonal to `suspended`.** The operator emits `Pending`/`Building`/`Deploying`/`Running`/`Hibernated`/`Failed` (`lego/types/v1alpha1/app_types.go:123`), **not** the lowercase `"running"`/`"suspended"` the old `sampleServices` used. There is **no `suspended` phase** — a parked App reports `phase: Hibernated` **and** `suspended: "suspended"`. `deriveStatus` (`lib/status.ts`) therefore lets suspension win over phase, matches phase case-insensitively, and the `statSuspended` tile counts the `suspended` enum, not a phase. This was the main correctness trap the original task text didn't flag.
- **Codegen was hand-mirrored.** `yarn codegen` introspects prod `api.bex.co/graphql`, which requires a `CODEGEN_SESSION_TOKEN` (401 without one). The full schema (incl. `Service`, `services`, the three mutations) was already captured in `definitions.ts`; the four new operation documents were authored in the exact codegen format and verified to round-trip via `graphql.print`. Re-run `yarn codegen` with a token to confirm byte-identical output when convenient.
- **Live-view dev proxy (`vite.config.ts`).** Added `graphqlDevProxy` — the API counterpart of the existing `kratosDevProxy` — so a localhost page can reach prod bex-api despite CORS only allowing `dashboard.bex.co`. Run `VITE_KRATOS_PUBLIC_URL=http://localhost:5173/kratos VITE_API_URL=http://localhost:5173/graphql yarn dev`; the browser's host-only `ory_kratos_session` cookie rides the same-origin `/graphql` call and bex-api re-checks it (`auth.go:233`). This is what made the live screenshots possible.
- **Verified (t005):** logged-in home page lists real Apps `beancount-cms` + `eden-cms-v2` (both Running), tiles Total 2 / Running 2 / Suspended 0, row menu shows Suspend + Restart, and the Suspend confirm dialog renders — cancelled without disrupting the real Apps. Screenshots: `.playwright-mcp/m4-services-list.png`, `m4-row-actions-menu.png`, `m4-suspend-confirm.png`.
- **Extra columns (learned from Render's real services table, `.playwright-mcp/render-services-table.png`).** Render's list is `Name · Status · Runtime · Region · Updated · ⋯`. Added the Render-derivable metadata already carried by the `services` query (no API change — the Render superset already exposes them): **Instances** (`replicas`), **Revision** (`revision`), **Created** (`createdAt`, compact relative age via `lib/format.ts`, Render's "Updated" analog). Deliberate **gaps left alone, not faked** per the Render-consistency contract: **Region** (bex is single-cluster — no per-App region) and a true **Updated**/last-deploy time (only `createdAt` is exposed; a real one needs a status-condition timestamp surfaced on the `Service` type). A **Runtime** column would need a one-line backend add to expose `AppView.Image`. Live table screenshot: `.playwright-mcp/m4-services-table-extended.png`.
