12 KiB
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:
- No feedback during JAR upload — user clicks
SaveorRedeploy, the button spins, nothing happens visually until the upload finishes. On large JARs this feels broken. - Startup logs are fixed ascending with no way to see the newest line first and no manual refresh between polls.
- The checkpoints table is always visible, pushing the config tabs far down even when the user doesn't care about history right now.
- The replica dropdown in the checkpoint drawer uses a raw
<select>, visually out of place vs. the rest of the design system. - The drawer opens on
Logs— butConfigis the restore-decision content. Users currently always click over. - (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 uploadredeploy— server dirty against last deploy, no active upload, no active deployuploading— a JAR upload is in flight (applies during both Save and Redeploy paths)deploying— a deployment row exists with statusSTARTING
Progress propagation.
useUploadJarswitches fromfetch()toXMLHttpRequest. Fetch gives no upload-progress events; XHR'supload.onprogressdoes.useUploadJarmutation args gainonProgress?: (pct: number) => void.AppDeploymentPageholdsconst [uploadPct, setUploadPct] = useState<number | null>(null).handleSaveandhandleRedeploypassonProgress: setUploadPctinto the mutation and clear tonullin afinallyblock.computeModelearns a new inputuploading: boolean(derived fromuploadPct !== null). Order:deploying>uploading>save|redeploychoice.PrimaryActionButtongains an optionalprogress?: numberprop and renders a progress overlay whenmode === '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
Buttonchildren in a positioned container with an inner<div>whosewidth: ${pct}%and background is a translucent primary tint (viacolor-mix(in srgb, var(--primary) 30%, transparent)or equivalent CSS variable). Keeps DSButtonunmodified.
Edge cases.
- Upload fails →
onProgressstops, the XHR error rejects the mutation, existingcatchblock surfaces the toast,uploadPctis cleared infinally, button returns to whichever modecomputeModepicks. - 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) →
uploadPctstaysnull, mode goesredeploy→deployingwith nouploadingin 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).titleprop:"Newest first"/"Oldest first". - Refresh: DS
Button variant="ghost" size="sm"wrapping<RefreshCw size={14} />fromlucide-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.
useStartupLogssignature extends to:useStartupLogs(application, environment, deployCreatedAt, isStarting, sort: 'asc' | 'desc')sortis passed straight intoLogSearchParamsso 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 —
LogVieweris 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 aroundLogViewerinsideStartupLogPanel.LogVieweritself does not forward a scroll ref — confirmed in DSindex.es.d.ts— so we add a wrapping<div ref={scrollRef} className={...}>withoverflow: autoand callscrollRef.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.
CheckpointsTablerenders 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)invar(--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.
<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 (DSSelectdoesn'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
tabsarray in the<Tabs>call soConfigprecedesLogs. - Change the initial tab state from
useState<TabId>('logs')touseState<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, assertonProgressfires duringupload.onprogress, resolves on 2xx, rejects on non-2xx and onxhr.onerror.CheckpointsTable— collapse toggle: header-only when collapsed, full table visible when open; count in header matchescheckpoints.length.StartupLogPanel— sort toggle flips the query sort parameter; refresh callsrefetchand callsscrollToon the right end per sort direction.
Component rendering.
PrimaryActionButton— rendersUploading… 42%in'uploading'mode with the overlay element width bound toprogress.
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…(noUploading…). - 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 toLogsworks; 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 (
Restorebutton), 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
nullreturn (commitb3d1dd37) — not touched again.
6. Open questions
None — all decisions resolved during brainstorming.