Files
cameleer-server/docs/superpowers/specs/2026-04-23-deployment-page-polish-design.md

12 KiB
Raw Permalink Blame History

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 15. #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 (SaveRedeployDeploying…); 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 redeploydeploying 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:
    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.

<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 SaveUploading… → back to whatever the post-save mode is.
  • Redeploy-with-JAR: button transitions RedeployUploading…Deploying….
  • Redeploy-only (no staged JAR): button transitions RedeployDeploying… (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.