Cloud page: each machine shows its cost ($/h · Running · About $X so far), as the sidebar does

Merged
TE
5d ago
+1629-22

From HQ #4755 (cloud comparison, 2026-10-30) and #5306's Usage row:

The in-app Cloud page (webviews/src/pages/cloud) shows no cost; hourly appears nowhere in it. Its CloudMachine in ops.ts doesn't carry hourly_rate_usd or running_since.

Small next slice: the cost line on the Cloud page's machine list and detail, from the same server fields the sidebar reads. This needs the backend projection to carry them, so it's a client change plus a backend one.

Leo's ask behind #5306: "How about more cmux-platform audits, improvements, features, fixes, optimizations, polish, plus the ui that gets people (or agents) using it".

What changes

The Cloud page reads machines from the Cloud app server (first-party-apps/cloud/server), which relays CloudDO's cloud.machine.list / cloud.machine.upsert from backend/apps/api. That record had no cost fields, and the app server's Machine drops fields it doesn't know. The sidebar's numbers come from classic /api/vm (web/services/vms/machineCost.ts), which the page doesn't call. So this PR changes three layers:

  • Backend (backend/apps/api, CloudDO): publicMachine now adds hourly_rate_usd and running_since (domains/cloud-cost.ts). The rate is Freestyle's vCPU and memory list price for the machine's size. Both transports run on Freestyle (the direct provider and the VM API's upstream), so 4 vCPU / 8 GB comes to $0.2645/h. Nothing here reads classic /api/vm; its rate table is cited only so the two clouds show the same number. running_since is a new private running_at (set at bind and when a start the provider confirmed lands; older rows fall back to last_power_at) while the machine is running, and null otherwise. Both fields are optional in the protocol's CloudMachine schema, and the catalog, TS client, Rust wire types and cmux-app.d.ts are regenerated from it. The contract doc lists them. Per cmux-platform docs/port-cloud-worker.md rule 1, the fix goes into cmux first and moves over with the port.
  • Cloud app server (Rust): Machine passes both fields through. The rate is kept as a JSON number, so the type stays Eq.
  • Cloud page: cost.ts formats the line in the sidebar's words and uses the sidebar's translations (SidebarMachineCost+Text.swift): $0.26/h · Running 2h 14m · About $0.59 so far, Under $0.01 so far, or $0.13/h when running while paused.
    • In the list, a running machine's subtitle shows this line in place of "Running". It already says "Running 2h 14m".
    • Other states show Paused · $0.13/h when running.
    • The detail's Overview gets a Cost row.
    • Nothing shows when the server sends no rate, for example a classic machine.

Timer: useCostClock refreshes "now" once a minute, and only while a shown machine is running with a start time. The effect's cleanup clears it when no shown machine is running anymore, or when the list or detail unmounts. It does no per-row work beyond formatting the rows already on screen.

Size

About 1,550 lines, of which about 1,240 are generated or localization:

  • Localizable.xcstrings: 792 lines (6 keys × 21 languages).
  • cloud-operations.json: 441 lines (the two fields inlined in each machine-bearing op).

The hand-written part is about 300 lines with tests. I didn't split it: the backend fields mean nothing without a reader, and the page can't show anything without the fields.

Risk

risk:normal.

  • The backend change is additive: two optional fields on a public read. No write path or private field changes. last_power_at stays out of the record, and only its value while running is exposed.
  • Old clients ignore the fields, and the page shows nothing without them.
  • The rate is a display estimate, not a bill (the same caveat as the sidebar).

Tests

  • webviews/src/pages/cloud/cost.test.ts: wording, two-unit durations, under a minute, paused, and no rate.
  • strings.test.ts: every language, matching placeholders.
  • backend/apps/api/test/cloud-machine-cost.test.ts: rates match classic ($0.2645, $0.1322); running carries running_since; paused gets null; last_power_at stays private.
  • models.rs machine_cost_fields_relay_to_the_page: round trip, null, and older records.

Observed / Not run

  • Ran locally:
    • bun test src/pages/cloud: 47 pass.
    • tsc -p . for backend/apps/api and backend/packages/protocol: clean.
    • bun run catalog:check and gen-cmux-global.ts: up to date.
    • rustfmt --check on models.rs: clean.
    • prepush-gate.py: no new violations.
  • Web lint: the only reds were check:design-tokens, check:layers, vp fmt --check and vp lint. All four fail on webviews/src/pages/settings at base 5f1a9ce9cfb, and this PR doesn't touch that folder.
  • CI at c59bef6155b5, all green:
    • Backend vitest: 227 files, including cloud-routes, cloud-start-pause and cloud-machine-cost.
    • first-party-apps/cloud/server cargo test, including machine_cost_fields_relay_to_the_page.
    • cloud/cost.test.ts in the webview shards.
  • The reds at this head are all outside the files this PR changes:
    • react-apps-check: the optchat inspector page is stale, plus agent-pane and new-tab tests.
    • The clippy panic ratchet: hits in cmux-automations and cmux-tui.
    • tooling-tests: the test registry's linux-guard lane.
    • cmux-next checks: Tier routing.
    • Also release-notary (502s), the review-line link job, and the Release compile (no compile result, since this PR has no Swift change).

Review

  • The router-review (gpt-6-astra) BLOCK at a4a09b19 was fixed in d1881ade: the vectors now carry the cost fields for the exact-shape route test.
  • Its two P2s at d1881ade were fixed in c59bef61:
    • running_since now comes from a private running_at, set when a start lands or at bind, not when the start request was made.
    • The resize vector's rate now matches its new size.
  • SHIP at c59bef6155b551f5e05d504f9504d15dd6c4e8a8, with no follow-ups.

Screenshots

These come from this head's gallery advisory, pages.cloud machines state, at 2x (synthetic mock data), cropped to the machine list and the Overview. I viewed the after images.

  • Visual verdict: one line per row, with no new control on any row.
    • The running row's subtitle swaps "Running" for the cost line, which already says "Running 2h 14m".
    • The paused row adds "· $0.13/h when running".
    • The Overview adds one Cost row.
    • The row and the Cost row both fit at the narrow width (560).
BeforeAfter
Light
Dark

Changelog

  • Added: The Cloud page shows each machine's cost, as the sidebar does: its hourly rate, how long it has run and about how much so far.

Agent session: agent session

🤖 Generated with Claude Code