docs(spec): deployment page polish (upload-in-button, sort/refresh, collapsible checkpoints, DS Select, tab reorder)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user