feat(ui): CheckpointsTable component (replaces row list)
Full-width table with Version / JAR / Deployed-by / Deployed / Strategy / Outcome columns, pagination cap (jarRetentionCount, default 10), pruned-JAR archived state, empty state, and row-click onSelect handler. 8/8 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -309,3 +309,69 @@
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* CheckpointsTable */
|
||||
.checkpointsTable {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.checkpointsTable table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.checkpointsTable th {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-inset);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.checkpointsTable td {
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
vertical-align: top;
|
||||
}
|
||||
.checkpointsTable tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
.checkpointsTable tbody tr:hover {
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
.jarCell { font-family: monospace; font-size: 12px; }
|
||||
.jarName { font-family: monospace; }
|
||||
.jarStrike { text-decoration: line-through; }
|
||||
.archivedHint { font-size: 11px; color: var(--amber, #f59e0b); }
|
||||
.isoSubline { font-size: 11px; color: var(--text-muted); }
|
||||
.muted { color: var(--text-muted); }
|
||||
.strategyPill,
|
||||
.outcomePill {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
background: var(--bg-inset);
|
||||
}
|
||||
/* outcome status colors */
|
||||
.outcome-STOPPED { color: var(--text-muted); }
|
||||
.outcome-DEGRADED {
|
||||
background: var(--amber-bg, rgba(245, 158, 11, 0.18));
|
||||
color: var(--amber, #f59e0b);
|
||||
}
|
||||
.chevron { color: var(--text-muted); font-size: 14px; text-align: right; }
|
||||
.showOlderBtn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.showOlderBtn:hover {
|
||||
background: var(--bg-inset);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
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 },
|
||||
};
|
||||
|
||||
describe('CheckpointsTable', () => {
|
||||
it('renders a row per checkpoint with version, jar, deployer', () => {
|
||||
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
||||
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
|
||||
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} />);
|
||||
// Use the version text as the row anchor (most stable selector)
|
||||
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={() => {}} />);
|
||||
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={() => {}} />);
|
||||
expect(screen.getByText(/archived/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('excludes the currently-running deployment', () => {
|
||||
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
||||
currentDeploymentId="d1" jarRetentionCount={5} onSelect={() => {}} />);
|
||||
expect(screen.queryByText('v6')).toBeNull();
|
||||
expect(screen.getByText(/no past deployments/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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={() => {}} />);
|
||||
// 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={() => {}} />);
|
||||
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={() => {}} />);
|
||||
expect(screen.getByText(/show older \(5\)/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
112
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx
Normal file
112
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
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 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 <div className={styles.checkpointEmpty}>No past deployments yet.</div>;
|
||||
}
|
||||
|
||||
const cap = (jarRetentionCount ?? 0) > 0 ? jarRetentionCount! : FALLBACK_CAP;
|
||||
const visible = expanded ? checkpoints : checkpoints.slice(0, cap);
|
||||
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>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{hidden > 0 && !expanded && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.showOlderBtn}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
Show older ({hidden}) — archived, postmortem only
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user