# Deployment page polish — design

**Date:** 2026-04-23
**Scope:** six targeted UX improvements on the unified deployment page (`ui/src/pages/AppsTab/AppDeploymentPage/*`)
**Status:** Draft — pending user review

## 1. Motivation

The unified deployment page landed recently. Exercising it surfaced six rough edges:

1. No feedback during JAR upload — user clicks `Save` or `Redeploy`, the button spins, nothing happens visually until the upload finishes. On large JARs this feels broken.
2. Startup logs are fixed ascending with no way to see the newest line first and no manual refresh between polls.
3. The checkpoints table is always visible, pushing the config tabs far down even when the user doesn't care about history right now.
4. The replica dropdown in the checkpoint drawer uses a raw `<select>`, visually out of place vs. the rest of the design system.
5. The drawer opens on `Logs` — but `Config` is the restore-decision content. Users currently always click over.
6. (Shipped as `b3d1dd37`.) The "No past deployments yet." empty-state hint was noise — removed.

This spec covers changes 1–5. #6 is listed for completeness only.

## 2. Design

### 2.1 Upload progress inside the primary action button

**Rationale.** Putting upload progress inside the button the user just clicked keeps all state-machine feedback in a single locus. The button already advertises the active action (`Save` → `Redeploy` → `Deploying…`); adding `Uploading…` is a natural extension.

**State machine.**

```
PrimaryActionMode: 'save' | 'redeploy' | 'uploading' | 'deploying'
```

- `save` — config dirty, no active deploy, no active upload
- `redeploy` — server dirty against last deploy, no active upload, no active deploy
- `uploading` — a JAR upload is in flight (applies during both Save and Redeploy paths)
- `deploying` — a deployment row exists with status `STARTING`

**Progress propagation.**

- `useUploadJar` switches from `fetch()` to `XMLHttpRequest`. Fetch gives no upload-progress events; XHR's `upload.onprogress` does.
- `useUploadJar` mutation args gain `onProgress?: (pct: number) => void`.
- `AppDeploymentPage` holds `const [uploadPct, setUploadPct] = useState<number | null>(null)`. `handleSave` and `handleRedeploy` pass `onProgress: setUploadPct` into the mutation and clear to `null` in a `finally` block.
- `computeMode` learns a new input `uploading: boolean` (derived from `uploadPct !== null`). Order: `deploying` > `uploading` > `save|redeploy` choice.
- `PrimaryActionButton` gains an optional `progress?: number` prop and renders a progress overlay when `mode === 'uploading'`.

**Button visual during `uploading`.**

- Label: `Uploading… 42%` (rounded integer).
- Disabled (same UX as `deploying`).
- A tinted-primary fill grows from left to right behind the label. Implementation: wrap the DS `Button` children in a positioned container with an inner `<div>` whose `width: ${pct}%` and background is a translucent primary tint (via `color-mix(in srgb, var(--primary) 30%, transparent)` or equivalent CSS variable). Keeps DS `Button` unmodified.

**Edge cases.**

- Upload fails → `onProgress` stops, the XHR error rejects the mutation, existing `catch` block surfaces the toast, `uploadPct` is cleared in `finally`, button returns to whichever mode `computeMode` picks.
- User navigates away mid-upload → the unsaved-changes blocker already exists and challenges the navigation. Cancellation semantics (whether to `xhr.abort()` when the mutation is superseded) are handled by the mutation's own lifecycle — out of scope for this change.
- No staged JAR (redeploy-only) → `uploadPct` stays `null`, mode goes `redeploy` → `deploying` with no `uploading` in between (unchanged from today).

### 2.2 Startup log panel — sort + manual refresh

**Layout.** Mirror the Application Log panel in `AgentHealth.tsx:899-917`:

```
┌──────────────────────────────────────────────────────────────┐
│ STARTUP LOGS  ● live  polling every 3s    42 entries  ↓  ↻   │
└──────────────────────────────────────────────────────────────┘
│ <LogViewer entries...>                                        │
└──────────────────────────────────────────────────────────────┘
```

- Reuse `ui/src/styles/log-panel.module.css` (`logCard`, `logHeader`, `headerActions`).
- Sort toggle: DS `Button variant="ghost" size="sm"` with unicode arrow (`↓` desc / `↑` asc). `title` prop: `"Newest first"` / `"Oldest first"`.
- Refresh: DS `Button variant="ghost" size="sm"` wrapping `<RefreshCw size={14} />` from `lucide-react`. `title="Refresh"`.

**Sort semantics.**

- Default sort is **desc** (newest first). User's pain point is that the interesting lines are the most recent ones.
- `useStartupLogs` signature extends to:
  ```ts
  useStartupLogs(application, environment, deployCreatedAt, isStarting, sort: 'asc' | 'desc')
  ```
- `sort` is passed straight into `LogSearchParams` so the **backend** fetch respects it. Limit remains 500. The 500-line cap applies from the sort direction, so desc gets the latest 500 and asc gets the oldest 500. (Pre-existing limitation; not addressed here.)
- Display direction matches fetch direction — `LogViewer` is passed whatever order the server returns.

**Refresh behavior.**

- Calls the TanStack Query `refetch()`.
- After the refetch resolves, scroll the panel's content container to the "latest" edge:
  - `sort === 'asc'` → scroll to bottom (newest is at the bottom).
  - `sort === 'desc'` → scroll to top (newest is at the top).
- Requires a `useRef<HTMLDivElement>` on a new scroll wrapper around `LogViewer` inside `StartupLogPanel`. `LogViewer` itself does not forward a scroll ref — confirmed in DS `index.es.d.ts` — so we add a wrapping `<div ref={scrollRef} className={...}>` with `overflow: auto` and call `scrollRef.current.scrollTo({ top, behavior: 'smooth' })` after the refetch resolves.

**Polling behavior unchanged.** 3-second polling while `isStarting` still happens via `refetchInterval`. Manual refresh is orthogonal.

### 2.3 Checkpoints table collapsible, default collapsed

**Rationale.** Deployments happen infrequently; when the user is on this page, they're usually tuning *current* config, not reviewing history. Collapsing by default reclaims vertical space for the config tabs.

**Behavior.**

- `CheckpointsTable` renders a clickable header row **above** the table body:
  ```
  ▸ Checkpoints (7)        ← collapsed (default)
  ▾ Checkpoints (7)        ← expanded
  ```
- Chevron and label are part of the same `<button>` (keyboard-accessible). No separate icon component needed — unicode `▸`/`▾` match the existing codebase style.
- Local component state: `const [open, setOpen] = useState(false)`.
- When `open === false`, the `<table>` and the "Show older" expander are not rendered — only the header row.
- The existing "no checkpoints" early-return (`null`) is preserved — no header row at all when there's nothing to show.

**Styling.** New `.checkpointsHeader` class in `AppDeploymentPage.module.css`:
- Same horizontal padding as the table cells, small gap between chevron and label, subdued color on hover.
- Muted count `(N)` in `var(--text-muted)`.

### 2.4 Replica dropdown uses DS Select

**Change.** In `CheckpointDetailDrawer/LogsPanel.tsx:36-47`, replace the native `<select>` with `Select` from `@cameleer/design-system`.

```tsx
<Select
  value={String(replicaFilter)}
  onChange={(e) => {
    const v = e.target.value;
    setReplicaFilter(v === 'all' ? 'all' : Number(v));
  }}
  options={[
    { value: 'all', label: `all (${deployment.replicaStates.length})` },
    ...deployment.replicaStates.map((_, i) => ({ value: String(i), label: String(i) })),
  ]}
/>
```

- Label stays `Replica:` as a sibling element (DS `Select` doesn't include an inline label slot).
- No behavior change beyond styling.

### 2.5 Drawer tabs — Config first, default Config

**Change.** In `CheckpointDetailDrawer/index.tsx`:

- Reverse the `tabs` array in the `<Tabs>` call so `Config` precedes `Logs`.
- Change the initial tab state from `useState<TabId>('logs')` to `useState<TabId>('config')`.

Rationale: `Config` is the restore-decision content (what variables, what resources, what monitoring settings did this checkpoint have). `Logs` is supporting/post-mortem material. The first tab should be the one users land on for the default question.

## 3. Files to touch

| File | Change |
|------|--------|
| `ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx` | Add `'uploading'` mode + `progress` prop; `computeMode` takes `uploading` input |
| `ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css` | `.checkpointsHeader`; progress-overlay styles for the primary button |
| `ui/src/pages/AppsTab/AppDeploymentPage/index.tsx` | `uploadPct` state; pass `onProgress` into both upload call sites |
| `ui/src/api/queries/admin/apps.ts` | `useUploadJar` → XHR; `onProgress` mutation arg |
| `ui/src/components/StartupLogPanel.tsx` | Header layout rewrite; sort state; refresh handler; scroll ref |
| `ui/src/components/StartupLogPanel.module.css` | Header-action styles if not covered by shared `log-panel.module.css` |
| `ui/src/api/queries/logs.ts` | `useStartupLogs` adds `sort` parameter |
| `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx` | Collapse state + header row |
| `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/LogsPanel.tsx` | DS `Select` for replica filter |
| `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx` | Tab reorder + default `'config'` |

## 4. Testing

**Unit.**

- `useUploadJar` — mock XHR, assert `onProgress` fires during `upload.onprogress`, resolves on 2xx, rejects on non-2xx and on `xhr.onerror`.
- `CheckpointsTable` — collapse toggle: header-only when collapsed, full table visible when open; count in header matches `checkpoints.length`.
- `StartupLogPanel` — sort toggle flips the query sort parameter; refresh calls `refetch` and calls `scrollTo` on the right end per sort direction.

**Component rendering.**

- `PrimaryActionButton` — renders `Uploading… 42%` in `'uploading'` mode with the overlay element width bound to `progress`.

**Manual smoke.**

- Save-with-JAR: button transitions `Save` → `Uploading…` → back to whatever the post-save mode is.
- Redeploy-with-JAR: button transitions `Redeploy` → `Uploading…` → `Deploying…`.
- Redeploy-only (no staged JAR): button transitions `Redeploy` → `Deploying…` (no `Uploading…`).
- Upload fails (simulate 500 from backend): button returns to pre-click mode; error toast shown.
- Startup logs: flip sort; refresh; confirm latest line is visible in each direction.
- Checkpoints: collapsed on load; expanding shows the 10-row table + "Show older" expander if applicable.
- Drawer: open it → lands on `Config`; switching to `Logs` works; replica filter looks like DS components.

## 5. Non-goals

- No change to the startup-logs backend endpoint; the 500-line cap and 3s polling stay.
- No change to the checkpoint drawer's footer (`Restore` button), header, or meta line.
- No change to deployment creation, stop, or delete flows.
- No new test-infra scaffolding (XHR mocking uses what's already in Vitest).
- Already-shipped: the empty-checkpoints `null` return (commit `b3d1dd37`) — not touched again.

## 6. Open questions

None — all decisions resolved during brainstorming.
