1017 lines
36 KiB
Markdown
1017 lines
36 KiB
Markdown
# Checkpoints Grid Row 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:** Move the Checkpoints section into the Identity & Artifact config grid as an in-grid row, localize the Deployed-column sub-line, and remove the now-redundant History disclosure from the Deployment tab.
|
||
|
||
**Architecture:** `CheckpointsTable` is rewritten to return a React.Fragment whose children become direct grid cells inside `IdentitySection`'s `.configGrid`. Label + trigger occupy one grid row; when opened, a second grid row spans both columns for the full-width table. `IdentitySection` gains a `checkpointsSlot` prop (distinct from `children`, which continues to host the portal-rendered drawer). The Deployed cell's ISO sub-line is replaced by `new Date(iso).toLocaleString()` inline. `HistoryDisclosure` is deleted — the checkpoints table + drawer now cover its job.
|
||
|
||
**Tech Stack:** React 18 + TypeScript, CSS modules, Vitest + React Testing Library.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-04-23-checkpoints-grid-row-design.md`
|
||
|
||
---
|
||
|
||
## Files touched (summary)
|
||
|
||
| Path | Change |
|
||
|------|--------|
|
||
| `ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx` | Add `checkpointsSlot?: ReactNode` prop; render inside `.configGrid` after the JAR row |
|
||
| `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx` | Rewrite as React.Fragment (label span + trigger cell + optional full-width row); swap ISO sub-line for `toLocaleString()` |
|
||
| `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx` | Update `expand()` helper (button name) + add locale sub-line assertion |
|
||
| `ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css` | Add `.checkpointsTriggerCell`, `.checkpointsTrigger`, `.checkpointsTableFullRow`; remove obsolete classes |
|
||
| `ui/src/pages/AppsTab/AppDeploymentPage/index.tsx` | Route `CheckpointsTable` via `checkpointsSlot`; drawer stays in `children` |
|
||
| `ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/DeploymentTab.tsx` | Remove `HistoryDisclosure` import + render |
|
||
| `ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/HistoryDisclosure.tsx` | **Delete** |
|
||
|
||
---
|
||
|
||
## Commands — run from `ui/`
|
||
|
||
- **Test one file:** `npx vitest run <relative-path>`
|
||
- **All tests:** `npx vitest run`
|
||
- **Typecheck:** `npm run typecheck`
|
||
|
||
---
|
||
|
||
## Task 1: `IdentitySection` accepts `checkpointsSlot`
|
||
|
||
**Why:** Give the parent page a way to inject the checkpoints row *inside* the grid, separate from `children` (which still hosts the drawer portal).
|
||
|
||
**Files:**
|
||
- Modify: `ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx`
|
||
|
||
- [ ] **Step 1.1: Add the prop to the interface**
|
||
|
||
Open `ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx`. Find `IdentitySectionProps` (around line 18-29):
|
||
|
||
```tsx
|
||
interface IdentitySectionProps {
|
||
mode: 'net-new' | 'deployed';
|
||
environment: Environment;
|
||
app: App | null;
|
||
currentVersion: AppVersion | null;
|
||
name: string;
|
||
onNameChange: (next: string) => void;
|
||
stagedJar: File | null;
|
||
onStagedJarChange: (file: File | null) => void;
|
||
deploying: boolean;
|
||
children?: ReactNode;
|
||
}
|
||
```
|
||
|
||
Add the `checkpointsSlot` field:
|
||
|
||
```tsx
|
||
interface IdentitySectionProps {
|
||
mode: 'net-new' | 'deployed';
|
||
environment: Environment;
|
||
app: App | null;
|
||
currentVersion: AppVersion | null;
|
||
name: string;
|
||
onNameChange: (next: string) => void;
|
||
stagedJar: File | null;
|
||
onStagedJarChange: (file: File | null) => void;
|
||
deploying: boolean;
|
||
checkpointsSlot?: ReactNode;
|
||
children?: ReactNode;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 1.2: Destructure `checkpointsSlot` + render inside the grid**
|
||
|
||
Find the function signature:
|
||
|
||
```tsx
|
||
export function IdentitySection({
|
||
mode, environment, app, currentVersion,
|
||
name, onNameChange, stagedJar, onStagedJarChange, deploying, children,
|
||
}: IdentitySectionProps) {
|
||
```
|
||
|
||
Add `checkpointsSlot` to the destructure list:
|
||
|
||
```tsx
|
||
export function IdentitySection({
|
||
mode, environment, app, currentVersion,
|
||
name, onNameChange, stagedJar, onStagedJarChange, deploying,
|
||
checkpointsSlot, children,
|
||
}: IdentitySectionProps) {
|
||
```
|
||
|
||
Then find the closing of the Application JAR row, which looks like:
|
||
|
||
```tsx
|
||
<span className={styles.configLabel}>Application JAR</span>
|
||
<div className={styles.fileRow}>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".jar"
|
||
className={styles.visuallyHidden}
|
||
onChange={(e) => onStagedJarChange(e.target.files?.[0] ?? null)}
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
type="button"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={deploying}
|
||
>
|
||
{currentVersion ? 'Change JAR' : 'Select JAR'}
|
||
</Button>
|
||
{stagedJar && (
|
||
<span className={styles.stagedJar}>
|
||
staged: {stagedJar.name} ({formatBytes(stagedJar.size)})
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Change the closing to render `checkpointsSlot` INSIDE the grid, just before `</div>` that closes `.configGrid`:
|
||
|
||
```tsx
|
||
<span className={styles.configLabel}>Application JAR</span>
|
||
<div className={styles.fileRow}>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".jar"
|
||
className={styles.visuallyHidden}
|
||
onChange={(e) => onStagedJarChange(e.target.files?.[0] ?? null)}
|
||
/>
|
||
<Button
|
||
size="sm"
|
||
variant="secondary"
|
||
type="button"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={deploying}
|
||
>
|
||
{currentVersion ? 'Change JAR' : 'Select JAR'}
|
||
</Button>
|
||
{stagedJar && (
|
||
<span className={styles.stagedJar}>
|
||
staged: {stagedJar.name} ({formatBytes(stagedJar.size)})
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{checkpointsSlot}
|
||
</div>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
`checkpointsSlot` is expected to be a React.Fragment whose inner children are grid-direct cells.
|
||
|
||
- [ ] **Step 1.3: Typecheck**
|
||
|
||
Run (from `ui/`): `npm run typecheck`
|
||
Expected: PASS. (Call sites don't pass `checkpointsSlot` yet — that's fine because the prop is optional.)
|
||
|
||
- [ ] **Step 1.4: Commit**
|
||
|
||
```bash
|
||
git add ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx
|
||
git commit -m "feat(ui): IdentitySection accepts checkpointsSlot rendered inside configGrid"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Rewrite `CheckpointsTable` as grid-fragment + locale sub-line
|
||
|
||
**Why:** The table must emit grid-ready children (label + trigger cell + optional full-width row), drop its own wrapping `<div>`, and localize the Deployed sub-line.
|
||
|
||
**Files:**
|
||
- Modify: `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx`
|
||
|
||
- [ ] **Step 2.1: Replace the full file contents**
|
||
|
||
Replace the entire contents of `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx` with:
|
||
|
||
```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 (
|
||
<>
|
||
<span className={styles.configLabel}>Checkpoints</span>
|
||
<div className={styles.checkpointsTriggerCell}>
|
||
<button
|
||
type="button"
|
||
className={styles.checkpointsTrigger}
|
||
onClick={() => setOpen((v) => !v)}
|
||
aria-expanded={open}
|
||
>
|
||
<span className={styles.checkpointsChevron}>{open ? '\u25BE' : '\u25B8'}</span>
|
||
{open ? 'Collapse' : 'Expand'} ({checkpoints.length})
|
||
</button>
|
||
</div>
|
||
{open && (
|
||
<div className={styles.checkpointsTableFullRow}>
|
||
<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 && new Date(d.deployedAt).toLocaleString()}
|
||
</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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
Key points:
|
||
- Returns `<>...</>` (React.Fragment). Its children become direct children of the parent `.configGrid`.
|
||
- Trigger button text: `Expand (N)` when closed, `Collapse (N)` when open.
|
||
- `.checkpointsTableFullRow` wraps the inner `.checkpointsTable` so the full-width row owns the grid span.
|
||
- The Deployed sub-line uses `new Date(d.deployedAt).toLocaleString()` instead of the raw ISO.
|
||
- The "Show older" button sits inside the full-width row (unchanged behavior).
|
||
|
||
- [ ] **Step 2.2: Typecheck**
|
||
|
||
Run: `npm run typecheck`
|
||
Expected: PASS (no callers pass new props; the component's signature is unchanged).
|
||
|
||
Note: tests will now FAIL until Task 3 updates them — this is expected. Do not run tests here.
|
||
|
||
- [ ] **Step 2.3: Commit**
|
||
|
||
```bash
|
||
git add ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx
|
||
git commit -m "feat(ui): CheckpointsTable emits grid fragment + locale sub-line"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Update `CheckpointsTable.test.tsx`
|
||
|
||
**Why:** The trigger button's accessible name changed from `/checkpoints \(1\)/i` to `/expand|collapse/i`. Also add a locale sub-line assertion.
|
||
|
||
**Files:**
|
||
- Modify: `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx`
|
||
|
||
- [ ] **Step 3.1: Replace the full test file**
|
||
|
||
Replace the entire 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: /expand|collapse/i }));
|
||
}
|
||
|
||
describe('CheckpointsTable', () => {
|
||
it('defaults to collapsed — label + trigger visible, rows hidden', () => {
|
||
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
||
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
|
||
expect(screen.getByText('Checkpoints')).toBeInTheDocument();
|
||
expect(screen.getByRole('button', { name: /expand \(1\)/i })).toBeInTheDocument();
|
||
expect(screen.queryByText('v6')).toBeNull();
|
||
expect(screen.queryByText('my-app-1.2.3.jar')).toBeNull();
|
||
});
|
||
|
||
it('clicking trigger expands to show rows; label flips to Collapse', () => {
|
||
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();
|
||
expect(screen.getByRole('button', { name: /collapse \(1\)/i })).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();
|
||
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();
|
||
});
|
||
|
||
it('Deployed sub-line is locale-formatted (not the raw ISO)', () => {
|
||
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
||
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
|
||
expand();
|
||
// Raw ISO would contain 'T' and end in 'Z' — localized form must not.
|
||
const raw = '2026-04-23T10:35:00Z';
|
||
expect(screen.queryByText(raw)).toBeNull();
|
||
// The actual sub-line must exist as SOME locale-rendered string near the row.
|
||
// Use the createdBy cell as an anchor and walk to the sibling Deployed cell.
|
||
const row = screen.getByText('alice').closest('tr')!;
|
||
const cells = row.querySelectorAll('td');
|
||
// Column order: Version, JAR, Deployed by, Deployed, Strategy, Outcome, chevron
|
||
const deployedCell = cells[3];
|
||
expect(deployedCell).toBeDefined();
|
||
const text = deployedCell.textContent ?? '';
|
||
expect(text).not.toContain('T10:35');
|
||
expect(text).not.toContain('Z');
|
||
expect(text.length).toBeGreaterThan(0);
|
||
});
|
||
});
|
||
```
|
||
|
||
Key changes:
|
||
- `expand()` now targets `/expand|collapse/i`.
|
||
- The default-collapsed test checks for `/expand \(1\)/i` (trigger shows `Expand (1)` when closed).
|
||
- A new post-expand assertion on `/collapse \(1\)/i` confirms the label flip.
|
||
- One new test: `Deployed sub-line is locale-formatted (not the raw ISO)` — asserts neither `'T10:35'` nor `'Z'` appears in the Deployed cell's text content, and the cell has content. This avoids depending on the CI's locale while still proving the raw-ISO path is gone.
|
||
|
||
- [ ] **Step 3.2: Run tests — expect pass**
|
||
|
||
Run: `npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx`
|
||
Expected: PASS (10 tests).
|
||
|
||
If a test fails because the `Deployed sub-line is locale-formatted` anchor strategy doesn't find `alice` (e.g. because the parent still wrapped this in a plain `<div>` somewhere), STOP and report — the Task 2 rewrite is wrong.
|
||
|
||
- [ ] **Step 3.3: Commit**
|
||
|
||
```bash
|
||
git add ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx
|
||
git commit -m "test(ui): CheckpointsTable covers fragment layout + locale sub-line"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: CSS updates — add new classes, remove obsolete ones
|
||
|
||
**Why:** Support the in-grid layout; clean up dead CSS from the retired row-list view and `HistoryDisclosure`.
|
||
|
||
**Files:**
|
||
- Modify: `ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css`
|
||
|
||
- [ ] **Step 4.1: Add the three new classes**
|
||
|
||
Open `ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css`. Append at the end of the file (after all existing rules):
|
||
|
||
```css
|
||
/* Checkpoints row — lives inside .configGrid */
|
||
.checkpointsTriggerCell {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.checkpointsTrigger {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
background: none;
|
||
border: none;
|
||
padding: 0;
|
||
color: var(--text-primary);
|
||
cursor: pointer;
|
||
font: inherit;
|
||
text-align: left;
|
||
}
|
||
|
||
.checkpointsTrigger:hover {
|
||
color: var(--amber);
|
||
}
|
||
|
||
.checkpointsTableFullRow {
|
||
grid-column: 1 / -1;
|
||
margin-top: 4px;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4.2: Remove the obsolete `.checkpointsSection` / `.checkpointsHeader` / `.checkpointsCount` block**
|
||
|
||
Find and delete this block (currently near the bottom, added in a prior task):
|
||
|
||
```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;
|
||
}
|
||
```
|
||
|
||
**Important:** After deleting that block, re-add `.checkpointsChevron` (still used by the trigger button) as a standalone rule at the end of the file:
|
||
|
||
```css
|
||
.checkpointsChevron {
|
||
color: var(--text-muted);
|
||
font-size: 11px;
|
||
width: 12px;
|
||
text-align: center;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4.3: Remove retired row-list + history classes**
|
||
|
||
Still in the same file, find and delete these rules (left over from the retired `Checkpoints.tsx` row-list and the soon-to-be-deleted `HistoryDisclosure`):
|
||
|
||
```css
|
||
.checkpointsRow {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.disclosureToggle {
|
||
background: none;
|
||
border: none;
|
||
color: var(--text-muted);
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
padding: 4px 0;
|
||
}
|
||
|
||
.checkpointList {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
padding: 6px 0 0 12px;
|
||
}
|
||
|
||
.checkpointRow {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.checkpointMeta {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.checkpointArchived {
|
||
color: var(--warning);
|
||
font-size: 12px;
|
||
}
|
||
```
|
||
|
||
**Do NOT delete** `.checkpointsTable tr.checkpointArchived { opacity: 0.55; }` — that variant IS still used by the table body.
|
||
|
||
Also find and delete:
|
||
|
||
```css
|
||
/* HistoryDisclosure */
|
||
.historyRow { margin-top: 16px; }
|
||
```
|
||
|
||
- [ ] **Step 4.4: Typecheck**
|
||
|
||
Run: `npm run typecheck`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 4.5: Run table tests to confirm no missing-class regressions**
|
||
|
||
Run: `npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx`
|
||
Expected: PASS (10 tests).
|
||
|
||
- [ ] **Step 4.6: Commit**
|
||
|
||
```bash
|
||
git add ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css
|
||
git commit -m "refactor(ui): checkpoints in-grid styles + drop retired row-list/history CSS"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Wire `CheckpointsTable` through `checkpointsSlot` in the page
|
||
|
||
**Why:** Move the render from `children` into the new slot so the table ends up inside the grid; keep the drawer in `children`.
|
||
|
||
**Files:**
|
||
- Modify: `ui/src/pages/AppsTab/AppDeploymentPage/index.tsx`
|
||
|
||
- [ ] **Step 5.1: Update the `<IdentitySection>` render**
|
||
|
||
Open `ui/src/pages/AppsTab/AppDeploymentPage/index.tsx`. Find the `<IdentitySection>` JSX (around lines 437-474):
|
||
|
||
```tsx
|
||
<IdentitySection
|
||
mode={mode}
|
||
environment={env}
|
||
app={app}
|
||
currentVersion={currentVersion}
|
||
name={name}
|
||
onNameChange={setName}
|
||
stagedJar={stagedJar}
|
||
onStagedJarChange={setStagedJar}
|
||
deploying={deploymentInProgress}
|
||
>
|
||
{app && (
|
||
<>
|
||
<CheckpointsTable
|
||
deployments={deployments}
|
||
versions={versions}
|
||
currentDeploymentId={currentDeployment?.id ?? null}
|
||
jarRetentionCount={jarRetentionCount}
|
||
onSelect={setSelectedCheckpointId}
|
||
/>
|
||
{selectedDep && (
|
||
<CheckpointDetailDrawer
|
||
open
|
||
onClose={() => setSelectedCheckpointId(null)}
|
||
deployment={selectedDep}
|
||
version={selectedDepVersion}
|
||
appSlug={app.slug}
|
||
envSlug={selectedEnv ?? ''}
|
||
currentForm={form}
|
||
onRestore={(deploymentId) => {
|
||
handleRestore(deploymentId);
|
||
setSelectedCheckpointId(null);
|
||
}}
|
||
/>
|
||
)}
|
||
</>
|
||
)}
|
||
</IdentitySection>
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```tsx
|
||
<IdentitySection
|
||
mode={mode}
|
||
environment={env}
|
||
app={app}
|
||
currentVersion={currentVersion}
|
||
name={name}
|
||
onNameChange={setName}
|
||
stagedJar={stagedJar}
|
||
onStagedJarChange={setStagedJar}
|
||
deploying={deploymentInProgress}
|
||
checkpointsSlot={
|
||
app ? (
|
||
<CheckpointsTable
|
||
deployments={deployments}
|
||
versions={versions}
|
||
currentDeploymentId={currentDeployment?.id ?? null}
|
||
jarRetentionCount={jarRetentionCount}
|
||
onSelect={setSelectedCheckpointId}
|
||
/>
|
||
) : undefined
|
||
}
|
||
>
|
||
{app && selectedDep && (
|
||
<CheckpointDetailDrawer
|
||
open
|
||
onClose={() => setSelectedCheckpointId(null)}
|
||
deployment={selectedDep}
|
||
version={selectedDepVersion}
|
||
appSlug={app.slug}
|
||
envSlug={selectedEnv ?? ''}
|
||
currentForm={form}
|
||
onRestore={(deploymentId) => {
|
||
handleRestore(deploymentId);
|
||
setSelectedCheckpointId(null);
|
||
}}
|
||
/>
|
||
)}
|
||
</IdentitySection>
|
||
```
|
||
|
||
Key difference: `<CheckpointsTable>` moves into the new `checkpointsSlot` prop; the drawer stays as the only `children`.
|
||
|
||
- [ ] **Step 5.2: Typecheck**
|
||
|
||
Run: `npm run typecheck`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5.3: Run all deployment page tests**
|
||
|
||
Run: `npx vitest run src/pages/AppsTab/AppDeploymentPage`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5.4: Commit**
|
||
|
||
```bash
|
||
git add ui/src/pages/AppsTab/AppDeploymentPage/index.tsx
|
||
git commit -m "refactor(ui): route CheckpointsTable via IdentitySection.checkpointsSlot"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Remove `HistoryDisclosure` from the Deployment tab
|
||
|
||
**Why:** The checkpoints table + drawer now cover the same information; History is dead weight.
|
||
|
||
**Files:**
|
||
- Modify: `ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/DeploymentTab.tsx`
|
||
- Delete: `ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/HistoryDisclosure.tsx`
|
||
|
||
- [ ] **Step 6.1: Remove the import and render from `DeploymentTab.tsx`**
|
||
|
||
Open `ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/DeploymentTab.tsx`. It currently looks like:
|
||
|
||
```tsx
|
||
import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps';
|
||
import { DeploymentProgress } from '../../../../components/DeploymentProgress';
|
||
import { StartupLogPanel } from '../../../../components/StartupLogPanel';
|
||
import { EmptyState } from '@cameleer/design-system';
|
||
import { StatusCard } from './StatusCard';
|
||
import { HistoryDisclosure } from './HistoryDisclosure';
|
||
import styles from '../AppDeploymentPage.module.css';
|
||
|
||
interface Props {
|
||
deployments: Deployment[];
|
||
versions: AppVersion[];
|
||
appSlug: string;
|
||
envSlug: string;
|
||
externalUrl: string;
|
||
}
|
||
|
||
export function DeploymentTab({ deployments, versions, appSlug, envSlug, externalUrl }: Props) {
|
||
const latest = deployments
|
||
.slice()
|
||
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))[0] ?? null;
|
||
|
||
if (!latest) {
|
||
return <EmptyState title="No deployments yet" description="Save your configuration and click Redeploy to launch." />;
|
||
}
|
||
|
||
const version = versions.find((v) => v.id === latest.appVersionId) ?? null;
|
||
|
||
return (
|
||
<div className={styles.deploymentTab}>
|
||
<StatusCard
|
||
deployment={latest}
|
||
version={version}
|
||
externalUrl={externalUrl}
|
||
/>
|
||
{latest.status === 'STARTING' && (
|
||
<DeploymentProgress currentStage={latest.deployStage} failed={false} />
|
||
)}
|
||
{latest.status === 'FAILED' && (
|
||
<DeploymentProgress currentStage={latest.deployStage} failed />
|
||
)}
|
||
<StartupLogPanel deployment={latest} appSlug={appSlug} envSlug={envSlug}
|
||
className={styles.logFill} />
|
||
<HistoryDisclosure deployments={deployments} versions={versions}
|
||
appSlug={appSlug} envSlug={envSlug} />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```tsx
|
||
import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps';
|
||
import { DeploymentProgress } from '../../../../components/DeploymentProgress';
|
||
import { StartupLogPanel } from '../../../../components/StartupLogPanel';
|
||
import { EmptyState } from '@cameleer/design-system';
|
||
import { StatusCard } from './StatusCard';
|
||
import styles from '../AppDeploymentPage.module.css';
|
||
|
||
interface Props {
|
||
deployments: Deployment[];
|
||
versions: AppVersion[];
|
||
appSlug: string;
|
||
envSlug: string;
|
||
externalUrl: string;
|
||
}
|
||
|
||
export function DeploymentTab({ deployments, versions, appSlug, envSlug, externalUrl }: Props) {
|
||
const latest = deployments
|
||
.slice()
|
||
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))[0] ?? null;
|
||
|
||
if (!latest) {
|
||
return <EmptyState title="No deployments yet" description="Save your configuration and click Redeploy to launch." />;
|
||
}
|
||
|
||
const version = versions.find((v) => v.id === latest.appVersionId) ?? null;
|
||
|
||
return (
|
||
<div className={styles.deploymentTab}>
|
||
<StatusCard
|
||
deployment={latest}
|
||
version={version}
|
||
externalUrl={externalUrl}
|
||
/>
|
||
{latest.status === 'STARTING' && (
|
||
<DeploymentProgress currentStage={latest.deployStage} failed={false} />
|
||
)}
|
||
{latest.status === 'FAILED' && (
|
||
<DeploymentProgress currentStage={latest.deployStage} failed />
|
||
)}
|
||
<StartupLogPanel deployment={latest} appSlug={appSlug} envSlug={envSlug}
|
||
className={styles.logFill} />
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
Changes: remove the `import { HistoryDisclosure }` line and the `<HistoryDisclosure ... />` JSX render at the bottom. Note that `versions` is still used (line with `const version = ...`), so keep the `versions` prop in the destructure.
|
||
|
||
- [ ] **Step 6.2: Delete `HistoryDisclosure.tsx`**
|
||
|
||
Delete the file:
|
||
|
||
```bash
|
||
rm ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/HistoryDisclosure.tsx
|
||
```
|
||
|
||
- [ ] **Step 6.3: Typecheck**
|
||
|
||
Run: `npm run typecheck`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6.4: Run deployment page tests**
|
||
|
||
Run: `npx vitest run src/pages/AppsTab/AppDeploymentPage`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6.5: Commit**
|
||
|
||
```bash
|
||
git add ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/DeploymentTab.tsx \
|
||
ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/HistoryDisclosure.tsx
|
||
git commit -m "ui(deploy): remove redundant HistoryDisclosure from Deployment tab"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Final verification + rules + reindex
|
||
|
||
**Why:** Confirm the full suite is green; update `.claude/rules/ui.md` so future sessions know Checkpoints lives inside the Identity grid and History is gone.
|
||
|
||
**Files:**
|
||
- Modify: `.claude/rules/ui.md`
|
||
|
||
- [ ] **Step 7.1: Run the entire UI test suite**
|
||
|
||
Run (from `ui/`): `npx vitest run`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 7.2: Typecheck**
|
||
|
||
Run: `npm run typecheck`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 7.3: Update `.claude/rules/ui.md`**
|
||
|
||
Open `.claude/rules/ui.md` and locate the Deployments bullet that begins "Checkpoints render as a collapsible `CheckpointsTable`…". Replace the full sentence with:
|
||
|
||
> "Checkpoints render as a collapsible `CheckpointsTable` (default **collapsed**) **inside the Identity & Artifact `configGrid`** as an in-grid row (`Checkpoints | ▸ Expand (N)`). When expanded, the table spans both grid columns via `grid-column: 1 / -1`. Columns: Version · JAR (filename) · Deployed by · Deployed (relative + user-locale sub-line via `toLocaleString()`) · Strategy · Outcome · ›. Row click opens `CheckpointDetailDrawer`. Drawer tabs are ordered **Config | Logs** with `Config` as the default. Replica filter uses DS `Select`. Restore lives in the drawer footer. Visible row cap = `Environment.jarRetentionCount` (default 10 if 0/null); older rows accessible via 'Show older (N)' expander. Currently-running deployment is excluded — represented separately by `StatusCard`. The empty-checkpoints case returns `null` (no row)."
|
||
|
||
Then locate the Deployment-tab bullet that starts "Deployment tab: `StatusCard` + `DeploymentProgress` … + `HistoryDisclosure`." and replace with:
|
||
|
||
> "Deployment tab: `StatusCard` + `DeploymentProgress` (during STARTING / FAILED) + flex-grow `StartupLogPanel` (no fixed maxHeight). Auto-activates when a deploy starts. `HistoryDisclosure` was retired — per-deployment config and logs live in the Checkpoints drawer. `StartupLogPanel` header mirrors the Runtime Application Log pattern: title + live/stopped badge + `N entries` + sort toggle (↑/↓, default **desc**) + refresh icon (`RefreshCw`). Sort drives the backend fetch via `useStartupLogs(…, sort)`. Refresh scrolls to the latest edge (top for desc, bottom for asc). Sort + refresh buttons disable while a refetch is in flight."
|
||
|
||
- [ ] **Step 7.4: Commit rules update**
|
||
|
||
```bash
|
||
git add .claude/rules/ui.md
|
||
git commit -m "docs(rules): checkpoints live inside Identity grid; HistoryDisclosure retired"
|
||
```
|
||
|
||
- [ ] **Step 7.5: Run GitNexus re-index**
|
||
|
||
Run (from repo root): `npx gitnexus analyze --embeddings`
|
||
Expected: Indexed successfully.
|
||
|
||
- [ ] **Step 7.6: Manual smoke (human-run, report in PR body)**
|
||
|
||
Start `npm run dev` in `ui/` (backend on :8081). Verify:
|
||
- Identity & Artifact section shows `Checkpoints | ▸ Expand (N)` as the last row. Collapsed on load.
|
||
- Clicking the trigger reveals the full table spanning both grid columns; button flips to `▾ Collapse (N)`.
|
||
- Deployed column shows `5h ago` as primary; sub-line shows a localized date/time (e.g. `4/23/2026, 12:35:00 PM` in `en-US`).
|
||
- Click a row → `CheckpointDetailDrawer` opens on Config tab (unchanged).
|
||
- App with no checkpoints: no Checkpoints row at all.
|
||
- Deployment tab no longer shows the History disclosure below Startup Logs.
|
||
|
||
---
|
||
|
||
## Self-review
|
||
|
||
**Spec coverage:**
|
||
|
||
- §2.1 Checkpoints row in the grid → Tasks 1, 2, 4, 5 ✓
|
||
- §2.2 Deployed locale sub-line → Task 2 (component change) + Task 3 (test) ✓
|
||
- §2.3 Remove History → Task 6 ✓
|
||
- §3 Page wiring (split slot from children) → Task 5 ✓
|
||
- §4 Files list — every file mentioned has a touch task ✓
|
||
- §5 Testing — Task 3 updates 9 existing tests + adds the locale assertion ✓
|
||
- §6 Non-goals respected: `CheckpointDetailDrawer`, `timeAgo`, drawer layout, other callers of `timeAgo` are untouched ✓
|
||
|
||
**Placeholder scan:** No "TBD"/"TODO"/"similar to Task N"/"add appropriate X". Every step has either a concrete code block or an exact shell command. Step 3.2's failure path names what would be wrong and says to STOP and report — concrete guidance, not a placeholder.
|
||
|
||
**Type consistency:**
|
||
- `checkpointsSlot?: ReactNode` appears consistently in Task 1 (prop), Task 5 (caller).
|
||
- `.checkpointsChevron` is removed as part of a block in Task 4.2 and re-added standalone in the same step — explicit handling, no dangling references. `CheckpointsTable` in Task 2 references it.
|
||
- `.checkpointsTriggerCell`, `.checkpointsTrigger`, `.checkpointsTableFullRow` are defined once in Task 4.1 and referenced in Task 2.
|
||
- `new Date(iso).toLocaleString()` pattern appears once in Task 2 and is asserted in Task 3.
|
||
|
||
No gaps or inconsistencies.
|