1360 lines
47 KiB
Markdown
1360 lines
47 KiB
Markdown
# 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 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):
|
||
|
||
```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.
|