From 1b4b52223375a1ac97713beb285af8b4d6e5ec40 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:15:30 +0200 Subject: [PATCH] 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 --- .../AppDeploymentPage.module.css | 66 +++++++++++ .../CheckpointsTable.test.tsx | 92 ++++++++++++++ .../AppDeploymentPage/CheckpointsTable.tsx | 112 ++++++++++++++++++ 3 files changed, 270 insertions(+) create mode 100644 ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx create mode 100644 ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css b/ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css index 425b4db7..d439806e 100644 --- a/ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css +++ b/ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css @@ -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); +} diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx new file mode 100644 index 00000000..2b3e738a --- /dev/null +++ b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx @@ -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({ui}); +} + +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( {}} />); + 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(); + // 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( {}} />); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('marks pruned-JAR rows as archived', () => { + const pruned = { ...stoppedDep, appVersionId: 'unknown' }; + wrap( {}} />); + expect(screen.getByText(/archived/i)).toBeInTheDocument(); + }); + + it('excludes the currently-running deployment', () => { + wrap( {}} />); + 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( {}} />); + // 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( {}} />); + 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( {}} />); + expect(screen.getByText(/show older \(5\)/i)).toBeInTheDocument(); + }); +}); diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx new file mode 100644 index 00000000..676ab7d9 --- /dev/null +++ b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx @@ -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
No past deployments yet.
; + } + + const cap = (jarRetentionCount ?? 0) > 0 ? jarRetentionCount! : FALLBACK_CAP; + const visible = expanded ? checkpoints : checkpoints.slice(0, cap); + const hidden = checkpoints.length - visible.length; + + return ( +
+ + + + + + + + + + + + + + {visible.map((d) => { + const v = versionMap.get(d.appVersionId); + const archived = !v; + const strategyLabel = + d.deploymentStrategy === 'BLUE_GREEN' ? 'blue/green' : 'rolling'; + return ( + onSelect(d.id)} + > + + + + + + + + + ); + })} + +
VersionJARDeployed byDeployedStrategyOutcome
+ + + {v ? ( + {v.jarFilename} + ) : ( + <> + JAR pruned +
archived — JAR pruned
+ + )} +
+ {d.createdBy ?? } + + {d.deployedAt && timeAgo(d.deployedAt)} +
{d.deployedAt}
+
+ {strategyLabel} + + + {d.status} + +
+ {hidden > 0 && !expanded && ( + + )} +
+ ); +}