feat(ui): checkpoints table collapsible, default collapsed
This commit is contained in:
@@ -400,3 +400,39 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
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 },
|
deployedConfigSnapshot: { jarVersionId: 'v6id', agentConfig: null, containerConfig: {}, sensitiveKeys: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function expand() {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /checkpoints/i }));
|
||||||
|
}
|
||||||
|
|
||||||
describe('CheckpointsTable', () => {
|
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]}
|
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
||||||
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
|
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('v6')).toBeInTheDocument();
|
||||||
expect(screen.getByText('my-app-1.2.3.jar')).toBeInTheDocument();
|
expect(screen.getByText('my-app-1.2.3.jar')).toBeInTheDocument();
|
||||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||||
@@ -38,7 +51,7 @@ describe('CheckpointsTable', () => {
|
|||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
||||||
currentDeploymentId={null} jarRetentionCount={5} onSelect={onSelect} />);
|
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')!);
|
fireEvent.click(screen.getByText('v6').closest('tr')!);
|
||||||
expect(onSelect).toHaveBeenCalledWith('d1');
|
expect(onSelect).toHaveBeenCalledWith('d1');
|
||||||
});
|
});
|
||||||
@@ -47,6 +60,7 @@ describe('CheckpointsTable', () => {
|
|||||||
const noActor = { ...stoppedDep, createdBy: null };
|
const noActor = { ...stoppedDep, createdBy: null };
|
||||||
wrap(<CheckpointsTable deployments={[noActor]} versions={[v6]}
|
wrap(<CheckpointsTable deployments={[noActor]} versions={[v6]}
|
||||||
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
|
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
|
||||||
|
expand();
|
||||||
expect(screen.getByText('—')).toBeInTheDocument();
|
expect(screen.getByText('—')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,14 +68,14 @@ describe('CheckpointsTable', () => {
|
|||||||
const pruned = { ...stoppedDep, appVersionId: 'unknown' };
|
const pruned = { ...stoppedDep, appVersionId: 'unknown' };
|
||||||
wrap(<CheckpointsTable deployments={[pruned]} versions={[]}
|
wrap(<CheckpointsTable deployments={[pruned]} versions={[]}
|
||||||
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
|
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
|
||||||
|
expand();
|
||||||
expect(screen.getByText(/archived/i)).toBeInTheDocument();
|
expect(screen.getByText(/archived/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('excludes the currently-running deployment', () => {
|
it('renders nothing when there are no checkpoints', () => {
|
||||||
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
const { container } = wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
||||||
currentDeploymentId="d1" jarRetentionCount={5} onSelect={() => {}} />);
|
currentDeploymentId="d1" jarRetentionCount={5} onSelect={() => {}} />);
|
||||||
expect(screen.queryByText('v6')).toBeNull();
|
expect(container).toBeEmptyDOMElement();
|
||||||
expect(screen.getByText(/no past deployments/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('caps visible rows at jarRetentionCount and shows expander', () => {
|
it('caps visible rows at jarRetentionCount and shows expander', () => {
|
||||||
@@ -70,7 +84,7 @@ describe('CheckpointsTable', () => {
|
|||||||
}));
|
}));
|
||||||
wrap(<CheckpointsTable deployments={many} versions={[v6]}
|
wrap(<CheckpointsTable deployments={many} versions={[v6]}
|
||||||
currentDeploymentId={null} jarRetentionCount={3} onSelect={() => {}} />);
|
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.getAllByRole('row').length).toBeLessThanOrEqual(4);
|
||||||
expect(screen.getByText(/show older \(7\)/i)).toBeInTheDocument();
|
expect(screen.getByText(/show older \(7\)/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -78,6 +92,7 @@ describe('CheckpointsTable', () => {
|
|||||||
it('shows all rows when jarRetentionCount >= total', () => {
|
it('shows all rows when jarRetentionCount >= total', () => {
|
||||||
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
||||||
currentDeploymentId={null} jarRetentionCount={10} onSelect={() => {}} />);
|
currentDeploymentId={null} jarRetentionCount={10} onSelect={() => {}} />);
|
||||||
|
expand();
|
||||||
expect(screen.queryByText(/show older/i)).toBeNull();
|
expect(screen.queryByText(/show older/i)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,6 +102,7 @@ describe('CheckpointsTable', () => {
|
|||||||
}));
|
}));
|
||||||
wrap(<CheckpointsTable deployments={fifteen} versions={[v6]}
|
wrap(<CheckpointsTable deployments={fifteen} versions={[v6]}
|
||||||
currentDeploymentId={null} jarRetentionCount={null} onSelect={() => {}} />);
|
currentDeploymentId={null} jarRetentionCount={null} onSelect={() => {}} />);
|
||||||
|
expand();
|
||||||
expect(screen.getByText(/show older \(5\)/i)).toBeInTheDocument();
|
expect(screen.getByText(/show older \(5\)/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export function CheckpointsTable({
|
|||||||
onSelect,
|
onSelect,
|
||||||
}: CheckpointsTableProps) {
|
}: CheckpointsTableProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
const versionMap = new Map(versions.map((v) => [v.id, v]));
|
const versionMap = new Map(versions.map((v) => [v.id, v]));
|
||||||
|
|
||||||
const checkpoints = deployments
|
const checkpoints = deployments
|
||||||
@@ -37,75 +38,90 @@ export function CheckpointsTable({
|
|||||||
const hidden = checkpoints.length - visible.length;
|
const hidden = checkpoints.length - visible.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.checkpointsTable}>
|
<div className={styles.checkpointsSection}>
|
||||||
<table>
|
<button
|
||||||
<thead>
|
type="button"
|
||||||
<tr>
|
className={styles.checkpointsHeader}
|
||||||
<th>Version</th>
|
onClick={() => setOpen((v) => !v)}
|
||||||
<th>JAR</th>
|
aria-expanded={open}
|
||||||
<th>Deployed by</th>
|
>
|
||||||
<th>Deployed</th>
|
<span className={styles.checkpointsChevron}>{open ? '\u25BE' : '\u25B8'}</span>
|
||||||
<th>Strategy</th>
|
<span>Checkpoints</span>
|
||||||
<th>Outcome</th>
|
{' '}
|
||||||
<th aria-label="open"></th>
|
<span className={styles.checkpointsCount}>({checkpoints.length})</span>
|
||||||
</tr>
|
</button>
|
||||||
</thead>
|
{open && (
|
||||||
<tbody>
|
<div className={styles.checkpointsTable}>
|
||||||
{visible.map((d) => {
|
<table>
|
||||||
const v = versionMap.get(d.appVersionId);
|
<thead>
|
||||||
const archived = !v;
|
<tr>
|
||||||
const strategyLabel =
|
<th>Version</th>
|
||||||
d.deploymentStrategy === 'BLUE_GREEN' ? 'blue/green' : 'rolling';
|
<th>JAR</th>
|
||||||
return (
|
<th>Deployed by</th>
|
||||||
<tr
|
<th>Deployed</th>
|
||||||
key={d.id}
|
<th>Strategy</th>
|
||||||
className={archived ? styles.checkpointArchived : undefined}
|
<th>Outcome</th>
|
||||||
onClick={() => onSelect(d.id)}
|
<th aria-label="open"></th>
|
||||||
>
|
|
||||||
<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>
|
</tr>
|
||||||
);
|
</thead>
|
||||||
})}
|
<tbody>
|
||||||
</tbody>
|
{visible.map((d) => {
|
||||||
</table>
|
const v = versionMap.get(d.appVersionId);
|
||||||
{hidden > 0 && !expanded && (
|
const archived = !v;
|
||||||
<button
|
const strategyLabel =
|
||||||
type="button"
|
d.deploymentStrategy === 'BLUE_GREEN' ? 'blue/green' : 'rolling';
|
||||||
className={styles.showOlderBtn}
|
return (
|
||||||
onClick={() => setExpanded(true)}
|
<tr
|
||||||
>
|
key={d.id}
|
||||||
Show older ({hidden}) — archived, postmortem only
|
className={archived ? styles.checkpointArchived : undefined}
|
||||||
</button>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user