Files
cameleer-server/docs/superpowers/plans/2026-04-23-deployment-page-polish.md
hsiegeln 13f218d522 docs(plan): deployment page polish (9 TDD tasks)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:42:06 +02:00

1360 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 `useUploadJar` mutation implementation**
Open `ui/src/api/queries/admin/apps.ts`. Replace the existing `useUploadJar` export (lines 140-162) with the version below.
```ts
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**
```bash
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`:
```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 `PrimaryActionButton` updates**
Replace the full contents of `ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx`:
```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 0100. 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):
```css
/* 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**
```bash
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 `uploadPct` state**
Find the local UI state block near `index.tsx:88-95`:
```tsx
// Local UI state
const [name, setName] = useState('');
const [stagedJar, setStagedJar] = useState<File | null>(null);
```
Insert immediately after the `stagedJar` line:
```tsx
const [uploadPct, setUploadPct] = useState<number | null>(null);
```
- [ ] **Step 3.2: Update `computeMode` call**
Find (around `index.tsx:128-132`):
```tsx
const primaryMode = computeMode({
deploymentInProgress,
hasLocalEdits: dirty.anyLocalEdit,
serverDirtyAgainstDeploy,
});
```
Replace with:
```tsx
const uploading = uploadPct !== null;
const primaryMode = computeMode({
deploymentInProgress,
uploading,
hasLocalEdits: dirty.anyLocalEdit,
serverDirtyAgainstDeploy,
});
```
- [ ] **Step 3.3: Pass `onProgress` into the Save-path upload**
Find in `handleSave` (around `index.tsx:179-181`):
```tsx
if (stagedJar) {
await uploadJar.mutateAsync({ envSlug, appSlug: targetApp.slug, file: stagedJar });
}
```
Replace with:
```tsx
if (stagedJar) {
setUploadPct(0);
try {
await uploadJar.mutateAsync({
envSlug,
appSlug: targetApp.slug,
file: stagedJar,
onProgress: setUploadPct,
});
} finally {
setUploadPct(null);
}
}
```
- [ ] **Step 3.4: Pass `onProgress` into the Redeploy-path upload**
Find in `handleRedeploy` (around `index.tsx:262-264`):
```tsx
if (stagedJar) {
const newVersion = await uploadJar.mutateAsync({ envSlug, appSlug: app.slug, file: stagedJar });
versionId = newVersion.id;
} else {
```
Replace with:
```tsx
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 `uploadPct` to `PrimaryActionButton`**
Find the `<PrimaryActionButton ... />` render (around `index.tsx:389-393`):
```tsx
<PrimaryActionButton
mode={primaryMode}
enabled={primaryEnabled}
onClick={primaryMode === 'redeploy' ? handleRedeploy : handleSave}
/>
```
Replace with:
```tsx
<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`):
```tsx
const primaryEnabled = (() => {
if (primaryMode === 'deploying') return false;
if (primaryMode === 'save') return !!name.trim() && (isNetNew || dirty.anyLocalEdit);
return true; // redeploy always enabled
})();
```
Replace with:
```tsx
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**
```bash
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 `useStartupLogs` signature**
Open `ui/src/api/queries/logs.ts`. Replace the `useStartupLogs` function (lines 141-160) with:
```ts
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**
```bash
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`:
```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`:
```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:
```css
.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**
```bash
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:
```tsx
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.tsx` with collapse**
Replace the full contents of `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx`:
```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:
```css
/* 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**
```bash
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:
```tsx
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 `.replicaLabel` has adequate spacing**
Check `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.module.css`. If it does not already contain a `.replicaLabel` rule, append:
```css
.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**
```bash
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:
```tsx
const [tab, setTab] = useState<TabId>('logs');
```
to:
```tsx
const [tab, setTab] = useState<TabId>('config');
```
And the `tabs` array (lines 69-72):
```tsx
tabs={[
{ value: 'logs', label: 'Logs' },
{ value: 'config', label: 'Config' },
]}
```
to:
```tsx
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:
1. Click the Logs tab first, then assert on log content; or
2. 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**
```bash
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.md` deployment-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 row `Checkpoints (N) ▸/▾` toggles."
Update or add sub-bullet for `PrimaryActionButton`:
- "Primary button state machine: `save``uploading` (during JAR upload, shows `Uploading… N%` with tinted fill overlay) → `deploying` during active deploy. Upload progress sourced from `useUploadJar` (XHR `upload.onprogress`)."
Update or add sub-bullet for `StartupLogPanel`:
- "`StartupLogPanel` header mirrors the Runtime Application Log: `N entries` + sort toggle (↑/↓, default desc) + refresh icon (`RefreshCw`). Sort drives the backend fetch via `useStartupLogs(…, 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:
- "`CheckpointDetailDrawer` tabs: `Config | Logs`. Default tab `Config`. Replica filter uses DS `Select`."
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**
```bash
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:**
- `PrimaryActionMode` spelled the same in every task.
- `computeMode` signature: both the test (Task 2) and the call site (Task 3) use `{ deploymentInProgress, uploading, hasLocalEdits, serverDirtyAgainstDeploy }`.
- `useUploadJar` mutation arg type: `onProgress?: (pct: number) => void` — used identically in Tasks 1 and 3.
- `useStartupLogs` signature: 5th param named `sort`, 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.