47 KiB
Deployment Page Polish — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Deliver five targeted UX improvements on the unified app deployment page: JAR-upload progress inside the primary button, sort + refresh on startup logs, a collapsible checkpoints table (default collapsed), a DS-styled replica dropdown, and reversed drawer tab order (Config first).
Architecture: All changes are UI-only, confined to ui/src/pages/AppsTab/AppDeploymentPage/*, ui/src/components/StartupLogPanel.*, ui/src/api/queries/admin/apps.ts (useUploadJar switches to XMLHttpRequest), and ui/src/api/queries/logs.ts (useStartupLogs accepts a sort parameter). No backend or schema changes.
Tech Stack: React 18, TypeScript, Vitest + React Testing Library, @cameleer/design-system components (Button, Select, Tabs, SectionHeader), lucide-react (RefreshCw), TanStack Query.
Spec: docs/superpowers/specs/2026-04-23-deployment-page-polish-design.md
Files touched (summary)
| Path | Change |
|---|---|
ui/src/api/queries/admin/apps.ts |
useUploadJar rewrites the mutation function to use XMLHttpRequest and exposes an onProgress callback |
ui/src/api/queries/logs.ts |
useStartupLogs gains a 5th sort parameter |
ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx |
Adds 'uploading' mode with a progress-overlay visual |
ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css |
Adds .uploadBtnWrap, .uploadBtnFill, .checkpointsHeader styles |
ui/src/pages/AppsTab/AppDeploymentPage/index.tsx |
Wires uploadPct state and feeds it into both upload call sites + the primary button |
ui/src/components/StartupLogPanel.tsx |
New header layout (sort ↑/↓ + refresh icon), local sort state, scroll-ref |
ui/src/components/StartupLogPanel.module.css |
Extends with .headerRight, .scrollWrap, .ghostIconBtn |
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx |
Collapsible header + useState(false) default |
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx |
Tests updated for collapsed-by-default behavior; stale empty-state assertion removed |
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/LogsPanel.tsx |
Native <select> → DS Select |
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx |
Tab order reversed; default tab 'config' |
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx |
Updated test for default tab |
Commands — run all from ui/ unless stated otherwise
- Test one file:
npx vitest run <relative-path> - Test all:
npx vitest run - Typecheck:
npm run typecheck
Task 1: Extend useUploadJar to emit upload progress via XHR
Why: fetch() has no upload-progress event. Switching the mutation function to XMLHttpRequest surfaces upload.onprogress to the caller as a percentage.
Files:
-
Modify:
ui/src/api/queries/admin/apps.ts:140-162 -
Step 1.1: Replace the
useUploadJarmutation implementation
Open ui/src/api/queries/admin/apps.ts. Replace the existing useUploadJar export (lines 140-162) with the version below.
export function useUploadJar() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ envSlug, appSlug, file, onProgress }: {
envSlug: string;
appSlug: string;
file: File;
onProgress?: (pct: number) => void;
}) => {
const token = useAuthStore.getState().accessToken;
const form = new FormData();
form.append('file', file);
return new Promise<AppVersion>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open(
'POST',
`${config.apiBaseUrl}${envBase(envSlug)}/${encodeURIComponent(appSlug)}/versions`,
);
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.setRequestHeader('X-Cameleer-Protocol-Version', '1');
xhr.upload.onprogress = (e) => {
if (!onProgress || !e.lengthComputable) return;
onProgress(Math.round((e.loaded / e.total) * 100));
};
xhr.onload = () => {
if (xhr.status < 200 || xhr.status >= 300) {
reject(new Error(`Upload failed: ${xhr.status}`));
return;
}
try {
resolve(JSON.parse(xhr.responseText) as AppVersion);
} catch (err) {
reject(err instanceof Error ? err : new Error('Invalid response'));
}
};
xhr.onerror = () => reject(new Error('Upload network error'));
xhr.onabort = () => reject(new Error('Upload aborted'));
xhr.send(form);
});
},
onSuccess: (_data, { envSlug, appSlug }) =>
qc.invalidateQueries({ queryKey: ['apps', envSlug, appSlug, 'versions'] }),
});
}
- Step 1.2: Typecheck
Run: npm run typecheck
Expected: PASS (no type errors; the mutation arg type changed but no call sites use the onProgress field yet — field is optional.)
- Step 1.3: Commit
git add ui/src/api/queries/admin/apps.ts
git commit -m "feat(ui): useUploadJar uses XHR and exposes onProgress"
Task 2: Add 'uploading' mode to PrimaryActionButton
Why: Button is the single locus for the action lifecycle. Adding 'uploading' with a progress-fill overlay lets the user see upload progress without looking elsewhere.
Files:
-
Modify:
ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx -
Modify:
ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css -
Create:
ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.test.tsx -
Step 2.1: Write failing component tests
Create ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.test.tsx:
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ThemeProvider } from '@cameleer/design-system';
import { PrimaryActionButton, computeMode } from './PrimaryActionButton';
function wrap(ui: React.ReactElement) {
return render(<ThemeProvider>{ui}</ThemeProvider>);
}
describe('PrimaryActionButton', () => {
it('renders Save in save mode', () => {
wrap(<PrimaryActionButton mode="save" enabled onClick={() => {}} />);
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('renders Redeploy in redeploy mode', () => {
wrap(<PrimaryActionButton mode="redeploy" enabled onClick={() => {}} />);
expect(screen.getByRole('button', { name: /redeploy/i })).toBeInTheDocument();
});
it('renders Deploying… disabled in deploying mode', () => {
wrap(<PrimaryActionButton mode="deploying" enabled={false} onClick={() => {}} />);
const btn = screen.getByRole('button', { name: /deploying/i });
expect(btn).toBeDisabled();
});
it('renders Uploading… NN% with progress overlay in uploading mode', () => {
wrap(<PrimaryActionButton mode="uploading" enabled={false} progress={42} onClick={() => {}} />);
const btn = screen.getByRole('button', { name: /uploading/i });
expect(btn).toBeDisabled();
expect(btn).toHaveTextContent('42%');
const fill = btn.querySelector('[data-upload-fill]') as HTMLElement | null;
expect(fill).not.toBeNull();
expect(fill!.style.width).toBe('42%');
});
});
describe('computeMode', () => {
it('returns uploading when uploading flag set, even if deploymentInProgress is false', () => {
expect(computeMode({ deploymentInProgress: false, uploading: true, hasLocalEdits: true, serverDirtyAgainstDeploy: false })).toBe('uploading');
});
it('returns deploying when deploymentInProgress even if uploading flag set', () => {
expect(computeMode({ deploymentInProgress: true, uploading: true, hasLocalEdits: false, serverDirtyAgainstDeploy: false })).toBe('deploying');
});
it('returns save when local edits without upload or deploy', () => {
expect(computeMode({ deploymentInProgress: false, uploading: false, hasLocalEdits: true, serverDirtyAgainstDeploy: false })).toBe('save');
});
it('returns redeploy when server dirty and no local edits', () => {
expect(computeMode({ deploymentInProgress: false, uploading: false, hasLocalEdits: false, serverDirtyAgainstDeploy: true })).toBe('redeploy');
});
});
- Step 2.2: Run tests — expect failures
Run: npx vitest run src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.test.tsx
Expected: FAILS with 'uploading' mode not handled and uploading input missing from computeMode.
- Step 2.3: Implement
PrimaryActionButtonupdates
Replace the full contents of ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx:
import { Button } from '@cameleer/design-system';
import styles from './AppDeploymentPage.module.css';
export type PrimaryActionMode = 'save' | 'redeploy' | 'uploading' | 'deploying';
interface Props {
mode: PrimaryActionMode;
enabled: boolean;
onClick: () => void;
/** Upload percentage 0–100. Only meaningful when mode === 'uploading'. */
progress?: number;
}
export function PrimaryActionButton({ mode, enabled, onClick, progress }: Props) {
if (mode === 'uploading') {
const pct = Math.max(0, Math.min(100, Math.round(progress ?? 0)));
return (
<span className={styles.uploadBtnWrap}>
<Button size="sm" variant="primary" disabled>
<span
data-upload-fill
className={styles.uploadBtnFill}
style={{ width: `${pct}%` }}
/>
<span className={styles.uploadBtnLabel}>Uploading… {pct}%</span>
</Button>
</span>
);
}
if (mode === 'deploying') {
return <Button size="sm" variant="primary" loading disabled>Deploying…</Button>;
}
if (mode === 'redeploy') {
return <Button size="sm" variant="primary" disabled={!enabled} onClick={onClick}>Redeploy</Button>;
}
return <Button size="sm" variant="primary" disabled={!enabled} onClick={onClick}>Save</Button>;
}
export function computeMode({
deploymentInProgress,
uploading,
hasLocalEdits,
serverDirtyAgainstDeploy,
}: {
deploymentInProgress: boolean;
uploading: boolean;
hasLocalEdits: boolean;
serverDirtyAgainstDeploy: boolean;
}): PrimaryActionMode {
if (deploymentInProgress) return 'deploying';
if (uploading) return 'uploading';
if (hasLocalEdits) return 'save';
if (serverDirtyAgainstDeploy) return 'redeploy';
return 'save';
}
- Step 2.4: Add CSS for the upload fill + label stacking
Open ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css. Add the following at the end of the file (before any existing trailing whitespace):
/* Primary-button upload progress overlay
* Wraps the DS Button so the inner Button can retain its own background
* while we overlay a tinted progress fill and label on top. */
.uploadBtnWrap {
display: inline-block;
}
.uploadBtnWrap button {
position: relative;
overflow: hidden;
}
.uploadBtnFill {
position: absolute;
inset: 0 auto 0 0;
background: color-mix(in srgb, var(--primary) 35%, transparent);
transition: width 120ms linear;
pointer-events: none;
z-index: 0;
}
.uploadBtnLabel {
position: relative;
z-index: 1;
}
If the file does not already end with a trailing newline, add one.
- Step 2.5: Run tests — expect pass
Run: npx vitest run src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.test.tsx
Expected: PASS (all 7 tests).
- Step 2.6: Typecheck
Run: npm run typecheck
Expected: PASS. (This will fail at the AppDeploymentPage callsite because computeMode now requires an uploading input — fixed in Task 3.)
If typecheck fails only with the missing uploading field in index.tsx, that is expected; proceed.
- Step 2.7: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx \
ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css \
ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.test.tsx
git commit -m "feat(ui): PrimaryActionButton gains uploading mode + progress overlay"
Task 3: Wire upload progress into AppDeploymentPage
Why: Parent holds the uploadPct state, passes onProgress into both upload call sites (Save and Redeploy), and forwards the pct into PrimaryActionButton.
Files:
-
Modify:
ui/src/pages/AppsTab/AppDeploymentPage/index.tsx -
Step 3.1: Add
uploadPctstate
Find the local UI state block near index.tsx:88-95:
// Local UI state
const [name, setName] = useState('');
const [stagedJar, setStagedJar] = useState<File | null>(null);
Insert immediately after the stagedJar line:
const [uploadPct, setUploadPct] = useState<number | null>(null);
- Step 3.2: Update
computeModecall
Find (around index.tsx:128-132):
const primaryMode = computeMode({
deploymentInProgress,
hasLocalEdits: dirty.anyLocalEdit,
serverDirtyAgainstDeploy,
});
Replace with:
const uploading = uploadPct !== null;
const primaryMode = computeMode({
deploymentInProgress,
uploading,
hasLocalEdits: dirty.anyLocalEdit,
serverDirtyAgainstDeploy,
});
- Step 3.3: Pass
onProgressinto the Save-path upload
Find in handleSave (around index.tsx:179-181):
if (stagedJar) {
await uploadJar.mutateAsync({ envSlug, appSlug: targetApp.slug, file: stagedJar });
}
Replace with:
if (stagedJar) {
setUploadPct(0);
try {
await uploadJar.mutateAsync({
envSlug,
appSlug: targetApp.slug,
file: stagedJar,
onProgress: setUploadPct,
});
} finally {
setUploadPct(null);
}
}
- Step 3.4: Pass
onProgressinto the Redeploy-path upload
Find in handleRedeploy (around index.tsx:262-264):
if (stagedJar) {
const newVersion = await uploadJar.mutateAsync({ envSlug, appSlug: app.slug, file: stagedJar });
versionId = newVersion.id;
} else {
Replace with:
if (stagedJar) {
setUploadPct(0);
try {
const newVersion = await uploadJar.mutateAsync({
envSlug,
appSlug: app.slug,
file: stagedJar,
onProgress: setUploadPct,
});
versionId = newVersion.id;
} finally {
setUploadPct(null);
}
} else {
- Step 3.5: Forward
uploadPcttoPrimaryActionButton
Find the <PrimaryActionButton ... /> render (around index.tsx:389-393):
<PrimaryActionButton
mode={primaryMode}
enabled={primaryEnabled}
onClick={primaryMode === 'redeploy' ? handleRedeploy : handleSave}
/>
Replace with:
<PrimaryActionButton
mode={primaryMode}
enabled={primaryEnabled}
progress={uploadPct ?? undefined}
onClick={primaryMode === 'redeploy' ? handleRedeploy : handleSave}
/>
- Step 3.6: Also disable primary button during upload
Find the primaryEnabled computation (around index.tsx:347-351):
const primaryEnabled = (() => {
if (primaryMode === 'deploying') return false;
if (primaryMode === 'save') return !!name.trim() && (isNetNew || dirty.anyLocalEdit);
return true; // redeploy always enabled
})();
Replace with:
const primaryEnabled = (() => {
if (primaryMode === 'deploying') return false;
if (primaryMode === 'uploading') return false;
if (primaryMode === 'save') return !!name.trim() && (isNetNew || dirty.anyLocalEdit);
return true; // redeploy always enabled
})();
- Step 3.7: Typecheck
Run: npm run typecheck
Expected: PASS.
- Step 3.8: Run all deployment page tests
Run: npx vitest run src/pages/AppsTab/AppDeploymentPage
Expected: Existing tests still pass (CheckpointsTable still has 1 pre-existing failure to fix in Task 5; nothing else should break).
- Step 3.9: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/index.tsx
git commit -m "feat(ui): wire JAR upload progress into the primary action button"
Task 4: useStartupLogs accepts sort parameter
Why: Sort direction must drive the backend fetch, not just client-side reordering — so the 500-row limit returns the lines closest to the user's interest.
Files:
-
Modify:
ui/src/api/queries/logs.ts:141-160 -
Step 4.1: Change the
useStartupLogssignature
Open ui/src/api/queries/logs.ts. Replace the useStartupLogs function (lines 141-160) with:
export function useStartupLogs(
application: string | undefined,
environment: string | undefined,
deployCreatedAt: string | undefined,
isStarting: boolean,
sort: 'asc' | 'desc' = 'desc',
) {
const params: LogSearchParams = {
application: application || undefined,
environment: environment ?? '',
source: 'container',
from: deployCreatedAt || undefined,
sort,
limit: 500,
};
return useLogs(params, {
enabled: !!application && !!deployCreatedAt && !!environment,
refetchInterval: isStarting ? 3_000 : false,
});
}
Key changes: add 5th parameter sort defaulting to 'desc', pass it through to params.sort.
- Step 4.2: Typecheck
Run: npm run typecheck
Expected: PASS. StartupLogPanel calls useStartupLogs without a 5th arg — safe because sort defaults.
- Step 4.3: Commit
git add ui/src/api/queries/logs.ts
git commit -m "feat(ui): useStartupLogs accepts sort parameter (default desc)"
Task 5: StartupLogPanel — sort toggle + refresh button + new header
Why: Users need to see the newest line on demand without waiting for the 3s poll. The visual must match the Application Log panel in Runtime.
Files:
-
Modify:
ui/src/components/StartupLogPanel.tsx -
Modify:
ui/src/components/StartupLogPanel.module.css -
Create:
ui/src/components/StartupLogPanel.test.tsx -
Step 5.1: Write failing tests
Create ui/src/components/StartupLogPanel.test.tsx:
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@cameleer/design-system';
import type { ReactNode } from 'react';
import { StartupLogPanel } from './StartupLogPanel';
import type { Deployment } from '../api/queries/admin/apps';
import * as logsModule from '../api/queries/logs';
function wrap(ui: ReactNode) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<ThemeProvider>
<QueryClientProvider client={qc}>{ui}</QueryClientProvider>
</ThemeProvider>,
);
}
const baseDep: Deployment = {
id: 'dep1', appId: 'a', appVersionId: 'v', environmentId: 'e',
status: 'STARTING', targetState: 'RUNNING', deploymentStrategy: 'BLUE_GREEN',
replicaStates: [], deployStage: 'START_REPLICAS',
containerId: null, containerName: null, errorMessage: null,
deployedAt: null, stoppedAt: null,
createdAt: '2026-04-23T10:00:00Z', createdBy: null,
};
const entries = [
{ timestamp: '2026-04-23T10:00:01Z', level: 'INFO' as const, message: 'hello',
loggerName: null, threadName: null, stackTrace: null, exchangeId: null,
instanceId: null, application: null, mdc: null, source: 'container' as const },
];
describe('StartupLogPanel', () => {
beforeEach(() => { vi.restoreAllMocks(); });
it('passes default sort=desc to useStartupLogs', () => {
const spy = vi.spyOn(logsModule, 'useStartupLogs').mockReturnValue({
data: { data: entries, nextCursor: null, hasMore: false, levelCounts: {} },
} as ReturnType<typeof logsModule.useStartupLogs>);
wrap(<StartupLogPanel deployment={baseDep} appSlug="app" envSlug="dev" />);
expect(spy).toHaveBeenCalledWith('app', 'dev', '2026-04-23T10:00:00Z', true, 'desc');
});
it('toggle flips sort to asc', () => {
const spy = vi.spyOn(logsModule, 'useStartupLogs').mockReturnValue({
data: { data: entries, nextCursor: null, hasMore: false, levelCounts: {} },
} as ReturnType<typeof logsModule.useStartupLogs>);
wrap(<StartupLogPanel deployment={baseDep} appSlug="app" envSlug="dev" />);
fireEvent.click(screen.getByRole('button', { name: /oldest first|newest first/i }));
expect(spy).toHaveBeenLastCalledWith('app', 'dev', '2026-04-23T10:00:00Z', true, 'asc');
});
it('refresh button calls refetch', () => {
const refetch = vi.fn();
vi.spyOn(logsModule, 'useStartupLogs').mockReturnValue({
data: { data: entries, nextCursor: null, hasMore: false, levelCounts: {} },
refetch,
} as unknown as ReturnType<typeof logsModule.useStartupLogs>);
wrap(<StartupLogPanel deployment={baseDep} appSlug="app" envSlug="dev" />);
fireEvent.click(screen.getByRole('button', { name: /refresh/i }));
expect(refetch).toHaveBeenCalled();
});
it('scrolls to top after refresh when sort=desc', async () => {
const refetch = vi.fn().mockResolvedValue({});
vi.spyOn(logsModule, 'useStartupLogs').mockReturnValue({
data: { data: entries, nextCursor: null, hasMore: false, levelCounts: {} },
refetch,
} as unknown as ReturnType<typeof logsModule.useStartupLogs>);
const scrollTo = vi.fn();
const origScrollTo = Element.prototype.scrollTo;
Element.prototype.scrollTo = scrollTo as unknown as typeof Element.prototype.scrollTo;
try {
wrap(<StartupLogPanel deployment={baseDep} appSlug="app" envSlug="dev" />);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /refresh/i }));
await Promise.resolve();
});
// default sort is desc → scroll to top (top === 0)
expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 0 }));
} finally {
Element.prototype.scrollTo = origScrollTo;
}
});
});
- Step 5.2: Run tests — expect failures
Run: npx vitest run src/components/StartupLogPanel.test.tsx
Expected: FAIL — current signature doesn't pass sort; no refresh button; no role mapping.
- Step 5.3: Rewrite
StartupLogPanel.tsx
Replace the full contents of ui/src/components/StartupLogPanel.tsx:
import { useRef, useState } from 'react';
import { LogViewer, Button } from '@cameleer/design-system';
import type { LogEntry } from '@cameleer/design-system';
import { RefreshCw } from 'lucide-react';
import { useStartupLogs } from '../api/queries/logs';
import type { Deployment } from '../api/queries/admin/apps';
import styles from './StartupLogPanel.module.css';
interface StartupLogPanelProps {
deployment: Deployment;
appSlug: string;
envSlug: string;
className?: string;
}
export function StartupLogPanel({ deployment, appSlug, envSlug, className }: StartupLogPanelProps) {
const isStarting = deployment.status === 'STARTING';
const isFailed = deployment.status === 'FAILED';
const [sort, setSort] = useState<'asc' | 'desc'>('desc');
const scrollRef = useRef<HTMLDivElement>(null);
const query = useStartupLogs(appSlug, envSlug, deployment.createdAt, isStarting, sort);
const entries = query.data?.data ?? [];
const scrollToLatest = () => {
const el = scrollRef.current;
if (!el) return;
// asc → latest at bottom; desc → latest at top
const top = sort === 'asc' ? el.scrollHeight : 0;
el.scrollTo({ top, behavior: 'smooth' });
};
const handleRefresh = async () => {
await query.refetch?.();
scrollToLatest();
};
if (entries.length === 0 && !isStarting) return null;
return (
<div className={`${styles.panel}${className ? ` ${className}` : ''}`}>
<div className={styles.header}>
<div className={styles.headerLeft}>
<span className={styles.title}>Startup Logs</span>
{isStarting && (
<>
<span className={`${styles.badge} ${styles.badgeLive}`}>● live</span>
<span className={styles.pollingHint}>polling every 3s</span>
</>
)}
{isFailed && (
<span className={`${styles.badge} ${styles.badgeStopped}`}>stopped</span>
)}
</div>
<div className={styles.headerRight}>
<span className={styles.lineCount}>{entries.length} entries</span>
<Button
variant="ghost"
size="sm"
onClick={() => setSort((s) => (s === 'asc' ? 'desc' : 'asc'))}
title={sort === 'asc' ? 'Oldest first' : 'Newest first'}
aria-label={sort === 'asc' ? 'Oldest first' : 'Newest first'}
>
{sort === 'asc' ? '\u2191' : '\u2193'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
title="Refresh"
aria-label="Refresh"
>
<RefreshCw size={14} />
</Button>
</div>
</div>
<div ref={scrollRef} className={styles.scrollWrap}>
{entries.length > 0 ? (
<LogViewer entries={entries as unknown as LogEntry[]} />
) : (
<div className={styles.empty}>Waiting for container output...</div>
)}
</div>
</div>
);
}
- Step 5.4: Extend
StartupLogPanel.module.css
Open ui/src/components/StartupLogPanel.module.css. Append the following at the end of the file:
.headerRight {
display: flex;
align-items: center;
gap: 8px;
}
.scrollWrap {
flex: 1;
min-height: 0;
overflow-y: auto;
}
Also modify the existing .panel block — its overflow: hidden remains correct; no change needed. Leave .panel { ... flex-direction: column; min-height: 0; } intact so the new .scrollWrap owns overflow.
- Step 5.5: Run tests — expect pass
Run: npx vitest run src/components/StartupLogPanel.test.tsx
Expected: PASS (4 tests).
- Step 5.6: Typecheck
Run: npm run typecheck
Expected: PASS.
- Step 5.7: Commit
git add ui/src/components/StartupLogPanel.tsx \
ui/src/components/StartupLogPanel.module.css \
ui/src/components/StartupLogPanel.test.tsx
git commit -m "feat(ui): startup logs — sort toggle + refresh button + desc default"
Task 6: Collapsible CheckpointsTable (default collapsed) + fix broken test
Why: History is rarely the user's focus on page load; collapsing reclaims vertical space. Also, a pre-existing test asserts on the removed "No past deployments yet" text — fix it.
Files:
-
Modify:
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx -
Modify:
ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css -
Modify:
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx -
Step 6.1: Update the broken test and add collapse tests
Replace the contents of ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx with:
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '@cameleer/design-system';
import type { ReactNode } from 'react';
import { CheckpointsTable } from './CheckpointsTable';
import type { Deployment, AppVersion } from '../../../api/queries/admin/apps';
function wrap(ui: ReactNode) {
return render(<ThemeProvider>{ui}</ThemeProvider>);
}
const v6: AppVersion = {
id: 'v6id', appId: 'a', version: 6, jarPath: '/j', jarChecksum: 'c',
jarFilename: 'my-app-1.2.3.jar', jarSizeBytes: 1, detectedRuntimeType: null,
detectedMainClass: null, uploadedAt: '2026-04-23T10:00:00Z',
};
const stoppedDep: Deployment = {
id: 'd1', appId: 'a', appVersionId: 'v6id', environmentId: 'e',
status: 'STOPPED', targetState: 'STOPPED', deploymentStrategy: 'BLUE_GREEN',
replicaStates: [{ index: 0, containerId: 'c', containerName: 'n', status: 'STOPPED' }],
deployStage: null, containerId: null, containerName: null, errorMessage: null,
deployedAt: '2026-04-23T10:35:00Z', stoppedAt: '2026-04-23T10:52:00Z',
createdAt: '2026-04-23T10:35:00Z', createdBy: 'alice',
deployedConfigSnapshot: { jarVersionId: 'v6id', agentConfig: null, containerConfig: {}, sensitiveKeys: null },
};
function expand() {
fireEvent.click(screen.getByRole('button', { name: /checkpoints/i }));
}
describe('CheckpointsTable', () => {
it('defaults to collapsed — header visible, rows hidden', () => {
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
expect(screen.getByRole('button', { name: /checkpoints \(1\)/i })).toBeInTheDocument();
expect(screen.queryByText('v6')).toBeNull();
expect(screen.queryByText('my-app-1.2.3.jar')).toBeNull();
});
it('clicking header expands to show rows', () => {
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
expand();
expect(screen.getByText('v6')).toBeInTheDocument();
expect(screen.getByText('my-app-1.2.3.jar')).toBeInTheDocument();
expect(screen.getByText('alice')).toBeInTheDocument();
});
it('row click invokes onSelect with deploymentId', () => {
const onSelect = vi.fn();
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={5} onSelect={onSelect} />);
expand();
fireEvent.click(screen.getByText('v6').closest('tr')!);
expect(onSelect).toHaveBeenCalledWith('d1');
});
it('renders em-dash for null createdBy', () => {
const noActor = { ...stoppedDep, createdBy: null };
wrap(<CheckpointsTable deployments={[noActor]} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
expand();
expect(screen.getByText('—')).toBeInTheDocument();
});
it('marks pruned-JAR rows as archived', () => {
const pruned = { ...stoppedDep, appVersionId: 'unknown' };
wrap(<CheckpointsTable deployments={[pruned]} versions={[]}
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
expand();
expect(screen.getByText(/archived/i)).toBeInTheDocument();
});
it('renders nothing when there are no checkpoints', () => {
const { container } = wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
currentDeploymentId="d1" jarRetentionCount={5} onSelect={() => {}} />);
expect(container).toBeEmptyDOMElement();
});
it('caps visible rows at jarRetentionCount and shows expander', () => {
const many: Deployment[] = Array.from({ length: 10 }, (_, i) => ({
...stoppedDep, id: `d${i}`, deployedAt: `2026-04-23T10:${String(10 + i).padStart(2, '0')}:00Z`,
}));
wrap(<CheckpointsTable deployments={many} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={3} onSelect={() => {}} />);
expand();
// 3 visible rows + 1 header row = 4 rows max in the table
expect(screen.getAllByRole('row').length).toBeLessThanOrEqual(4);
expect(screen.getByText(/show older \(7\)/i)).toBeInTheDocument();
});
it('shows all rows when jarRetentionCount >= total', () => {
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={10} onSelect={() => {}} />);
expand();
expect(screen.queryByText(/show older/i)).toBeNull();
});
it('falls back to default cap of 10 when jarRetentionCount is 0 or null', () => {
const fifteen: Deployment[] = Array.from({ length: 15 }, (_, i) => ({
...stoppedDep, id: `d${i}`, deployedAt: `2026-04-23T10:${String(10 + i).padStart(2, '0')}:00Z`,
}));
wrap(<CheckpointsTable deployments={fifteen} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={null} onSelect={() => {}} />);
expand();
expect(screen.getByText(/show older \(5\)/i)).toBeInTheDocument();
});
});
- Step 6.2: Run tests — expect failures
Run: npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx
Expected: FAIL — no collapse header button exists yet.
- Step 6.3: Rewrite
CheckpointsTable.tsxwith collapse
Replace the full contents of ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx:
import { useState } from 'react';
import { Badge } from '@cameleer/design-system';
import type { Deployment, AppVersion } from '../../../api/queries/admin/apps';
import { timeAgo } from '../../../utils/format-utils';
import styles from './AppDeploymentPage.module.css';
const FALLBACK_CAP = 10;
interface CheckpointsTableProps {
deployments: Deployment[];
versions: AppVersion[];
currentDeploymentId: string | null;
jarRetentionCount: number | null;
onSelect: (deploymentId: string) => void;
}
export function CheckpointsTable({
deployments,
versions,
currentDeploymentId,
jarRetentionCount,
onSelect,
}: CheckpointsTableProps) {
const [expanded, setExpanded] = useState(false);
const [open, setOpen] = useState(false);
const versionMap = new Map(versions.map((v) => [v.id, v]));
const checkpoints = deployments
.filter((d) => d.deployedConfigSnapshot && d.id !== currentDeploymentId)
.sort((a, b) => (b.deployedAt ?? '').localeCompare(a.deployedAt ?? ''));
if (checkpoints.length === 0) {
return null;
}
const cap = jarRetentionCount && jarRetentionCount > 0 ? jarRetentionCount : FALLBACK_CAP;
const visible = expanded ? checkpoints : checkpoints.slice(0, cap);
const hidden = checkpoints.length - visible.length;
return (
<div className={styles.checkpointsSection}>
<button
type="button"
className={styles.checkpointsHeader}
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
<span className={styles.checkpointsChevron}>{open ? '\u25BE' : '\u25B8'}</span>
<span>Checkpoints</span>
<span className={styles.checkpointsCount}>({checkpoints.length})</span>
</button>
{open && (
<div className={styles.checkpointsTable}>
<table>
<thead>
<tr>
<th>Version</th>
<th>JAR</th>
<th>Deployed by</th>
<th>Deployed</th>
<th>Strategy</th>
<th>Outcome</th>
<th aria-label="open"></th>
</tr>
</thead>
<tbody>
{visible.map((d) => {
const v = versionMap.get(d.appVersionId);
const archived = !v;
const strategyLabel =
d.deploymentStrategy === 'BLUE_GREEN' ? 'blue/green' : 'rolling';
return (
<tr
key={d.id}
className={archived ? styles.checkpointArchived : undefined}
onClick={() => onSelect(d.id)}
>
<td>
<Badge label={v ? `v${v.version}` : '?'} color="auto" />
</td>
<td className={styles.jarCell}>
{v ? (
<span className={styles.jarName}>{v.jarFilename}</span>
) : (
<>
<span className={styles.jarStrike}>JAR pruned</span>
<div className={styles.archivedHint}>archived — JAR pruned</div>
</>
)}
</td>
<td>
{d.createdBy ?? <span className={styles.muted}>—</span>}
</td>
<td>
{d.deployedAt && timeAgo(d.deployedAt)}
<div className={styles.isoSubline}>{d.deployedAt}</div>
</td>
<td>
<span className={styles.strategyPill}>{strategyLabel}</span>
</td>
<td>
<span
className={`${styles.outcomePill} ${styles[`outcome-${d.status}` as keyof typeof styles] || ''}`}
>
{d.status}
</span>
</td>
<td className={styles.chevron}>›</td>
</tr>
);
})}
</tbody>
</table>
{hidden > 0 && !expanded && (
<button
type="button"
className={styles.showOlderBtn}
onClick={() => setExpanded(true)}
>
Show older ({hidden}) — archived, postmortem only
</button>
)}
</div>
)}
</div>
);
}
- Step 6.4: Add CSS for the collapsible header
Open ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css. Append:
/* Collapsible Checkpoints header */
.checkpointsSection {
display: flex;
flex-direction: column;
}
.checkpointsHeader {
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: none;
padding: 8px 0;
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
cursor: pointer;
text-align: left;
}
.checkpointsHeader:hover {
color: var(--amber);
}
.checkpointsChevron {
color: var(--text-muted);
font-size: 11px;
width: 12px;
text-align: center;
}
.checkpointsCount {
color: var(--text-muted);
font-weight: 400;
}
- Step 6.5: Run tests — expect pass
Run: npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx
Expected: PASS (9 tests).
- Step 6.6: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx \
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx \
ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css
git commit -m "feat(ui): checkpoints table collapsible, default collapsed"
Task 7: Replica dropdown uses DS Select
Why: Native <select> clashes with the rest of the drawer's DS styling.
Files:
-
Modify:
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/LogsPanel.tsx -
Step 7.1: Replace native select with DS
Select
Open ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/LogsPanel.tsx. Replace the whole file contents with:
import { useMemo, useState } from 'react';
import { Select } from '@cameleer/design-system';
import { useInfiniteApplicationLogs } from '../../../../api/queries/logs';
import type { Deployment } from '../../../../api/queries/admin/apps';
import { instanceIdsFor } from './instance-id';
import styles from './CheckpointDetailDrawer.module.css';
interface Props {
deployment: Deployment;
appSlug: string;
envSlug: string;
}
export function LogsPanel({ deployment, appSlug, envSlug }: Props) {
const allInstanceIds = useMemo(
() => instanceIdsFor(deployment, envSlug, appSlug),
// eslint-disable-next-line react-hooks/exhaustive-deps
[deployment.id, deployment.replicaStates, envSlug, appSlug]
);
const [replicaFilter, setReplicaFilter] = useState<'all' | number>('all');
const filteredInstanceIds = replicaFilter === 'all'
? allInstanceIds
: allInstanceIds.filter((_, i) => i === replicaFilter);
const logs = useInfiniteApplicationLogs({
application: appSlug,
instanceIds: filteredInstanceIds,
isAtTop: true,
});
const replicaOptions = [
{ value: 'all', label: `all (${deployment.replicaStates.length})` },
...deployment.replicaStates.map((_, i) => ({ value: String(i), label: String(i) })),
];
return (
<div>
<div className={styles.filterBar}>
<label className={styles.replicaLabel}>
<span>Replica:</span>
<Select
value={String(replicaFilter)}
onChange={(e) => {
const v = e.target.value;
setReplicaFilter(v === 'all' ? 'all' : Number(v));
}}
options={replicaOptions}
/>
</label>
</div>
{logs.items.length === 0 && !logs.isLoading && (
<div className={styles.emptyState}>No logs for this deployment.</div>
)}
{logs.items.map((entry, i) => (
<div key={i} className={styles.logRow}>
<span className={styles.logTimestamp}>{entry.timestamp}</span>{' '}
<span>[{entry.level}]</span>{' '}
<span>{entry.message}</span>
</div>
))}
</div>
);
}
- Step 7.2: Ensure
.replicaLabelhas adequate spacing
Check ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.module.css. If it does not already contain a .replicaLabel rule, append:
.replicaLabel {
display: inline-flex;
align-items: center;
gap: 8px;
}
- Step 7.3: Typecheck
Run: npm run typecheck
Expected: PASS.
- Step 7.4: Run existing drawer tests to confirm nothing regressed
Run: npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer
Expected: PASS.
- Step 7.5: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/LogsPanel.tsx \
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.module.css
git commit -m "refactor(ui): drawer replica filter uses DS Select"
Task 8: Drawer tab order (Config first, default Config)
Why: Config is the restore-decision content. Logs is post-mortem supporting material.
Files:
-
Modify:
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx -
Modify (likely):
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx -
Step 8.1: Inspect the drawer test
Run: npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx
Note any test that relies on "logs" being the default tab — these will need updates after the code change in 8.2.
- Step 8.2: Change default tab + reorder tab array
Open ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx. Change line 27:
const [tab, setTab] = useState<TabId>('logs');
to:
const [tab, setTab] = useState<TabId>('config');
And the tabs array (lines 69-72):
tabs={[
{ value: 'logs', label: 'Logs' },
{ value: 'config', label: 'Config' },
]}
to:
tabs={[
{ value: 'config', label: 'Config' },
{ value: 'logs', label: 'Logs' },
]}
- Step 8.3: Fix any failing drawer tests
Re-run: npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx
If a test fails because it expects the Logs tab content to be visible on open, update that test to either:
- Click the Logs tab first, then assert on log content; or
- Assert on Config content for the default-open case.
The exact edit depends on what the existing tests assert — make the narrowest change that preserves the original test intent given the new default.
- Step 8.4: Typecheck
Run: npm run typecheck
Expected: PASS.
- Step 8.5: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx \
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx
git commit -m "ui(drawer): reorder tabs Config first, default to Config"
Task 9: Final verification + rules update
Why: Confirm the full test suite is green, and update .claude/rules/ui.md to reflect the new behavior a future session will need to know.
Files:
-
Modify:
.claude/rules/ui.md -
Step 9.1: Run the entire UI test suite
Run: npx vitest run
Expected: PASS (all tests).
- Step 9.2: Typecheck once more
Run: npm run typecheck
Expected: PASS.
- Step 9.3: Update
.claude/rules/ui.mddeployment-page section
Open .claude/rules/ui.md. Locate the Deployments bullet (currently starts with "Deployments — unified app deployment page"). Update the sub-bullet for CheckpointsTable to mention:
- "Checkpoints render as a collapsible
CheckpointsTable, default collapsed; header rowCheckpoints (N) ▸/▾toggles."
Update or add sub-bullet for PrimaryActionButton:
- "Primary button state machine:
save→uploading(during JAR upload, showsUploading… N%with tinted fill overlay) →deployingduring active deploy. Upload progress sourced fromuseUploadJar(XHRupload.onprogress)."
Update or add sub-bullet for StartupLogPanel:
- "
StartupLogPanelheader mirrors the Runtime Application Log:N entries+ sort toggle (↑/↓, default desc) + refresh icon (RefreshCw). Sort drives the backend fetch viauseStartupLogs(…, sort)so the 500-line limit returns the window closest to the user's interest. Refresh scrolls to the latest line (top for desc, bottom for asc)."
Update or add the CheckpointDetailDrawer sub-bullet:
- "
CheckpointDetailDrawertabs:Config | Logs. Default tabConfig. Replica filter uses DSSelect."
Exact wording may be adapted to fit the file's existing voice and structure — but each of the four facts above must appear.
- Step 9.4: Commit rules update
git add .claude/rules/ui.md
git commit -m "docs(rules): reflect deployment page polish changes"
- Step 9.5: Run GitNexus re-index
Run (from repo root): npx gitnexus analyze --embeddings
Expected: Indexed successfully.
- Step 9.6: Manual smoke (human-run, document in PR)
Start the dev server (npm run dev in ui/ — backend must be running on :8081). Exercise:
- Save with staged JAR: upload progress fills the Save button, returns to post-save state.
- Redeploy with staged JAR: upload progress fills → Deploying… → back to Redeploy.
- Redeploy without staged JAR: button goes straight to
Deploying…. - Upload failure: kill backend mid-upload → error toast; button returns to pre-click state.
- Startup logs: flip sort; latest entry visible in both directions; click refresh.
- Checkpoints: collapsed on load; open → rows visible; empty app shows nothing.
- Drawer: row-click opens on Config; Replica filter is DS-styled.
Self-review
Spec coverage (against docs/superpowers/specs/2026-04-23-deployment-page-polish-design.md):
- §2.1 Upload progress in button → Tasks 1, 2, 3 ✓
- §2.2 Startup log sort + refresh → Tasks 4, 5 ✓
- §2.3 Checkpoints collapsible default collapsed → Task 6 ✓
- §2.4 Replica dropdown uses DS Select → Task 7 ✓
- §2.5 Drawer tabs Config first, default Config → Task 8 ✓
- §5 Non-goals (backend untouched; empty-checkpoints already shipped) — respected ✓
Placeholder scan: No TBDs/TODOs/"implement later" placeholders. Step 8.3 is deliberately conditional because the test content is unknown at plan-write time; the instruction provides concrete branching guidance rather than a placeholder.
Type consistency:
PrimaryActionModespelled the same in every task.computeModesignature: both the test (Task 2) and the call site (Task 3) use{ deploymentInProgress, uploading, hasLocalEdits, serverDirtyAgainstDeploy }.useUploadJarmutation arg type:onProgress?: (pct: number) => void— used identically in Tasks 1 and 3.useStartupLogssignature: 5th param namedsort, defaults to'desc'— consistent between Tasks 4 and 5.- CSS class names (
uploadBtnWrap,uploadBtnFill,uploadBtnLabel,checkpointsSection,checkpointsHeader,checkpointsChevron,checkpointsCount,scrollWrap,headerRight,replicaLabel) — each defined once and referenced from the matching TSX.
No gaps or inconsistencies.