feat(ui): checkpoints table collapsible, default collapsed

This commit is contained in:
hsiegeln
2026-04-23 16:09:28 +02:00
parent 2c0cf7dc9c
commit f31975e0ef
3 changed files with 143 additions and 75 deletions

View File

@@ -400,3 +400,39 @@
position: relative;
z-index: 1;
}
/* 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;
}

View File

@@ -25,10 +25,23 @@ const stoppedDep: Deployment = {
deployedConfigSnapshot: { jarVersionId: 'v6id', agentConfig: null, containerConfig: {}, sensitiveKeys: null },
};
function expand() {
fireEvent.click(screen.getByRole('button', { name: /checkpoints/i }));
}
describe('CheckpointsTable', () => {
it('renders a row per checkpoint with version, jar, deployer', () => {
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();
@@ -38,7 +51,7 @@ describe('CheckpointsTable', () => {
const onSelect = vi.fn();
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={5} onSelect={onSelect} />);
// Use the version text as the row anchor (most stable selector)
expand();
fireEvent.click(screen.getByText('v6').closest('tr')!);
expect(onSelect).toHaveBeenCalledWith('d1');
});
@@ -47,6 +60,7 @@ describe('CheckpointsTable', () => {
const noActor = { ...stoppedDep, createdBy: null };
wrap(<CheckpointsTable deployments={[noActor]} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
expand();
expect(screen.getByText('—')).toBeInTheDocument();
});
@@ -54,14 +68,14 @@ describe('CheckpointsTable', () => {
const pruned = { ...stoppedDep, appVersionId: 'unknown' };
wrap(<CheckpointsTable deployments={[pruned]} versions={[]}
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
expand();
expect(screen.getByText(/archived/i)).toBeInTheDocument();
});
it('excludes the currently-running deployment', () => {
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
it('renders nothing when there are no checkpoints', () => {
const { container } = wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
currentDeploymentId="d1" jarRetentionCount={5} onSelect={() => {}} />);
expect(screen.queryByText('v6')).toBeNull();
expect(screen.getByText(/no past deployments/i)).toBeInTheDocument();
expect(container).toBeEmptyDOMElement();
});
it('caps visible rows at jarRetentionCount and shows expander', () => {
@@ -70,7 +84,7 @@ describe('CheckpointsTable', () => {
}));
wrap(<CheckpointsTable deployments={many} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={3} onSelect={() => {}} />);
// 3 visible rows + 1 header row = 4 rows max in the table
expand();
expect(screen.getAllByRole('row').length).toBeLessThanOrEqual(4);
expect(screen.getByText(/show older \(7\)/i)).toBeInTheDocument();
});
@@ -78,6 +92,7 @@ describe('CheckpointsTable', () => {
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();
});
@@ -87,6 +102,7 @@ describe('CheckpointsTable', () => {
}));
wrap(<CheckpointsTable deployments={fifteen} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={null} onSelect={() => {}} />);
expand();
expect(screen.getByText(/show older \(5\)/i)).toBeInTheDocument();
});
});

View File

@@ -22,6 +22,7 @@ export function CheckpointsTable({
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
@@ -37,75 +38,90 @@ export function CheckpointsTable({
const hidden = checkpoints.length - visible.length;
return (
<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>
<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>
);
})}
</tbody>
</table>
{hidden > 0 && !expanded && (
<button
type="button"
className={styles.showOlderBtn}
onClick={() => setExpanded(true)}
>
Show older ({hidden}) archived, postmortem only
</button>
</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>
);