feat(ui): checkpoints table collapsible, default collapsed
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user