Compare commits

...

9 Commits

Author SHA1 Message Date
hsiegeln
0fc9c8cb4c docs(rules): checkpoints live inside Identity grid; HistoryDisclosure retired
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m21s
CI / docker (push) Successful in 1m6s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 17:15:05 +02:00
hsiegeln
fe4a6dbf24 ui(deploy): remove redundant HistoryDisclosure from Deployment tab 2026-04-23 17:13:45 +02:00
hsiegeln
9cfe3985d0 refactor(ui): route CheckpointsTable via IdentitySection.checkpointsSlot 2026-04-23 17:12:12 +02:00
hsiegeln
18da187960 refactor(ui): checkpoints in-grid styles + drop retired row-list/history CSS 2026-04-23 17:10:42 +02:00
hsiegeln
9c1bd24f16 test(ui): CheckpointsTable covers fragment layout + locale sub-line 2026-04-23 17:08:57 +02:00
hsiegeln
177673ba62 feat(ui): CheckpointsTable emits grid fragment + locale sub-line 2026-04-23 17:03:31 +02:00
hsiegeln
77f5c82dfe feat(ui): IdentitySection accepts checkpointsSlot rendered inside configGrid 2026-04-23 17:01:52 +02:00
hsiegeln
663a6624a7 docs(plan): checkpoints grid row + locale time + remove History (7 TDD tasks)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:54:42 +02:00
hsiegeln
cc3cd610b2 docs(spec): checkpoints into identity grid + locale time + remove History
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:51:08 +02:00
10 changed files with 1419 additions and 239 deletions

View File

@@ -15,8 +15,8 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments
- Identity & Artifact section always visible; name editable pre-first-deploy, read-only after. JAR picker client-stages; new JAR + any form edits flip the primary button from `Save` to `Redeploy`. Environment fixed to the currently-selected env (no selector).
- Config sub-tabs: **Monitoring | Resources | Variables | Sensitive Keys | Deployment | ● Traces & Taps | ● Route Recording**. The four staged tabs feed dirty detection; the `●` live tabs apply in real-time (amber LiveBanner + default `?apply=live` on their writes) and never mark dirty.
- Primary action state machine: `Save``Uploading… N%` (during JAR upload; button shows percent with a tinted progress-fill overlay) → `Redeploy``Deploying…` during active deploy. Upload progress sourced from `useUploadJar` (XHR `upload.onprogress` → page-level `uploadPct` state). The button is disabled during `uploading` and `deploying`.
- Checkpoints render as a collapsible `CheckpointsTable` (default **collapsed**) below the Identity section. Header row `Checkpoints (N)` / `▾ Checkpoints (N)` toggles the table. Columns: Version · JAR (filename) · Deployed by · Deployed (relative + ISO) · Strategy · Outcome · . Row click opens `CheckpointDetailDrawer` (project-local `SideDrawer` primitive). Drawer tabs are ordered **Config | Logs** with `Config` as the default. Config panel has Snapshot / Diff vs current view modes. Replica filter in the Logs panel uses DS `Select`. Restore lives in the drawer footer (forces review). Visible row cap = `Environment.jarRetentionCount` (default 10 if 0/null); older rows accessible via "Show older (N)" expander. Currently-running deployment is excluded — represented separately by `StatusCard`. The empty-checkpoints case returns `null` (no header). The legacy `Checkpoints.tsx` row-list component is gone.
- Deployment tab: `StatusCard` + `DeploymentProgress` (during STARTING / FAILED) + flex-grow `StartupLogPanel` (no fixed maxHeight) + `HistoryDisclosure`. Auto-activates when a deploy starts. `StartupLogPanel` header mirrors the Runtime Application Log pattern: title + live/stopped badge + `N entries` + sort toggle (↑/↓, default **desc**) + refresh icon (`RefreshCw`). Sort drives the backend fetch via `useStartupLogs(…, sort)` so the 500-line limit returns the window closest to the user's interest; display order matches fetch order. Refresh scrolls to the latest edge (top for desc, bottom for asc). Sort + refresh buttons disable while a refetch is in flight. 3s polling while STARTING is unchanged.
- Checkpoints render as a collapsible `CheckpointsTable` (default **collapsed**) **inside the Identity & Artifact `configGrid`** as an in-grid row (`Checkpoints | ▸ Expand (N)` / `▾ Collapse (N)`). `CheckpointsTable` returns a React.Fragment of grid-ready children so the label + trigger align with the other identity rows; when opened, a third grid child spans both columns via `grid-column: 1 / -1` so the 7-column table gets full width. Wired through `IdentitySection.checkpointsSlot``CheckpointDetailDrawer` stays in `IdentitySection.children` because it portals. Columns: Version · JAR (filename) · Deployed by · Deployed (relative `timeAgo` + user-locale sub-line via `new Date(iso).toLocaleString()`) · Strategy · Outcome · . Row click opens the drawer. Drawer tabs are ordered **Config | Logs** with `Config` as the default. Config panel has Snapshot / Diff vs current view modes. Replica filter in the Logs panel uses DS `Select`. Restore lives in the drawer footer (forces review). Visible row cap = `Environment.jarRetentionCount` (default 10 if 0/null); older rows accessible via "Show older (N)" expander. Currently-running deployment is excluded — represented separately by `StatusCard`. The empty-checkpoints case returns `null` (no row). The legacy `Checkpoints.tsx` row-list component is gone.
- Deployment tab: `StatusCard` + `DeploymentProgress` (during STARTING / FAILED) + flex-grow `StartupLogPanel` (no fixed maxHeight). Auto-activates when a deploy starts. The former `HistoryDisclosure` is retired — per-deployment config and logs live in the Checkpoints drawer. `StartupLogPanel` header mirrors the Runtime Application Log pattern: title + live/stopped badge + `N entries` + sort toggle (↑/↓, default **desc**) + refresh icon (`RefreshCw`). Sort drives the backend fetch via `useStartupLogs(…, sort)` so the 500-line limit returns the window closest to the user's interest; display order matches fetch order. Refresh scrolls to the latest edge (top for desc, bottom for asc). Sort + refresh buttons disable while a refetch is in flight. 3s polling while STARTING is unchanged.
- Unsaved-change router blocker uses DS `AlertDialog` (not `window.beforeunload`). Env switch intentionally discards edits without warning.
**Admin pages** (ADMIN-only, under `/admin/`):

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,252 @@
# Checkpoints in the Identity grid + locale time + remove History — design
**Date:** 2026-04-23
**Scope:** three targeted UX changes on the unified app deployment page, follow-up to `2026-04-23-deployment-page-polish-design.md`.
**Status:** Draft — pending user review.
## 1. Motivation
The previous polish shipped a collapsible `CheckpointsTable` as a standalone section below the Identity & Artifact block. That made the visual hierarchy noisy — Checkpoints became a third section between Identity and the config tabs, competing for attention. The proper home for "how many past deployments exist and what were they" is *inside* the Identity panel, as one more row in its config grid.
Three changes:
1. Move the checkpoints section into the Identity & Artifact config grid as an in-grid row.
2. Format the Deployed-column sub-line to the user's locale (replaces the raw ISO string).
3. Remove the redundant `HistoryDisclosure` from the Deployment tab — the checkpoints table covers the same information and the per-deployment log drill-down now lives in the drawer.
## 2. Design
### 2.1 Checkpoints row in the Identity config grid
**Current structure** (`IdentitySection.tsx`):
```tsx
<div className={styles.section}>
<SectionHeader>Identity & Artifact</SectionHeader>
<div className={styles.configGrid}>
... label + value cells (Application Name, Slug, Environment, External URL, Current Version, Application JAR) ...
</div>
{children} {/* CheckpointsTable + CheckpointDetailDrawer currently render here */}
</div>
```
**New structure:**
```tsx
<div className={styles.section}>
<SectionHeader>Identity & Artifact</SectionHeader>
<div className={styles.configGrid}>
... existing label + value cells ...
{checkpointsSlot} {/* NEW: rendered as direct grid children via React.Fragment */}
</div>
{children} {/* still used — for the portal-rendered CheckpointDetailDrawer */}
</div>
```
**Slot contract.** `IdentitySection` gains a new prop:
```ts
interface IdentitySectionProps {
// ... existing props ...
checkpointsSlot?: ReactNode;
children?: ReactNode;
}
```
`checkpointsSlot` is expected to be a React.Fragment whose children are grid-direct cells (spans / divs). React fragments are transparent to CSS grid, so the inner elements become direct children of `configGrid` and flow into grid cells like the existing rows.
**`CheckpointsTable` rewrite.** Instead of wrapping itself in `<div className={styles.checkpointsSection}>`, the component returns a Fragment of grid-ready children:
```tsx
if (checkpoints.length === 0) {
return null;
}
return (
<>
<span className={styles.configLabel}>Checkpoints</span>
<div className={styles.checkpointsTriggerCell}>
<button
type="button"
className={styles.checkpointsTrigger}
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
<span className={styles.checkpointsChevron}>{open ? '\u25BE' : '\u25B8'}</span>
{open ? 'Collapse' : 'Expand'} ({checkpoints.length})
</button>
</div>
{open && (
<div className={styles.checkpointsTableFullRow}>
<table>...</table>
{hidden > 0 && !expanded && (
<button type="button" className={styles.showOlderBtn} onClick={...}>
Show older (N) archived, postmortem only
</button>
)}
</div>
)}
</>
);
```
**Why this layout.**
- The trigger button sits in the value column (180px label + 1fr value). When closed, the row reads `Checkpoints ▸ Expand (5)`.
- When opened, a third grid child appears: a div that spans both columns (`grid-column: 1 / -1`) containing the `<table>` + optional "Show older" button. This gives the 7-column table the full grid width so columns don't crush.
- The trigger remains in the value cell of the label row above — collapse/expand stays attached to its label.
**CSS changes** (`AppDeploymentPage.module.css`):
*Add:*
```css
.checkpointsTriggerCell {
display: flex;
align-items: center;
}
.checkpointsTrigger {
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: none;
padding: 0;
color: var(--text-primary);
cursor: pointer;
font: inherit;
text-align: left;
}
.checkpointsTrigger:hover {
color: var(--amber);
}
.checkpointsTableFullRow {
grid-column: 1 / -1;
margin-top: 4px;
}
```
*Remove (no longer referenced):*
- `.checkpointsSection`
- `.checkpointsHeader` + `.checkpointsHeader:hover`
- `.checkpointsCount`
*Keep:* `.checkpointsChevron` (still used by the trigger for the arrow). `.checkpointsTable`, `.jarCell`, `.jarName`, `.jarStrike`, `.archivedHint`, `.isoSubline`, `.muted`, `.strategyPill`, `.outcomePill`, `.outcome-*`, `.chevron`, `.showOlderBtn`, `.checkpointArchived` — all still referenced by the table body.
*Also remove* (cleanup — unrelated dead weight from the retired `Checkpoints.tsx` row-list view, safe to delete because no TSX references remain):
- `.checkpointsRow`
- `.disclosureToggle`
- `.checkpointList`
- `.checkpointRow`
- `.checkpointMeta`
- Standalone `.checkpointArchived { color: var(--warning); font-size: 12px; }` (the table-row variant `.checkpointsTable tr.checkpointArchived { opacity: 0.55; }` stays)
- `.historyRow` (see §2.3)
### 2.2 Deployed-column locale sub-line
In `CheckpointsTable.tsx`, the Deployed `<td>` currently renders:
```tsx
<td>
{d.deployedAt && timeAgo(d.deployedAt)}
<div className={styles.isoSubline}>{d.deployedAt}</div>
</td>
```
Replace with:
```tsx
<td>
{d.deployedAt && timeAgo(d.deployedAt)}
<div className={styles.isoSubline}>
{d.deployedAt && new Date(d.deployedAt).toLocaleString()}
</div>
</td>
```
`new Date(iso).toLocaleString()` uses the browser's resolved locale via the Intl API. No locale plumbing, no new util.
Primary "5h ago" display stays unchanged.
### 2.3 Remove the History disclosure from the Deployment tab
`HistoryDisclosure.tsx` renders a collapsible `DataTable` + nested `StartupLogPanel`. It duplicates information now surfaced via `CheckpointsTable` + `CheckpointDetailDrawer` (which has its own LogsPanel).
**Changes:**
- Delete `ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/HistoryDisclosure.tsx`.
- `ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/DeploymentTab.tsx` — remove the import and the `<HistoryDisclosure ... />` render at the bottom of the tab.
- `ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css` — drop the `.historyRow` rule (covered in §2.1's CSS cleanup list).
## 3. Page wiring
`ui/src/pages/AppsTab/AppDeploymentPage/index.tsx` currently passes the table + drawer together as `children` to `IdentitySection`:
```tsx
<IdentitySection ...>
{app && (
<>
<CheckpointsTable ... />
{selectedDep && <CheckpointDetailDrawer ... />}
</>
)}
</IdentitySection>
```
After the change:
```tsx
<IdentitySection
...
checkpointsSlot={app ? <CheckpointsTable ... /> : undefined}
>
{app && selectedDep && <CheckpointDetailDrawer ... />}
</IdentitySection>
```
The drawer continues to pass through as `children` because `SideDrawer` uses `createPortal` — it can live at any DOM depth, but conceptually sits outside the Identity grid so it doesn't become a stray grid cell.
## 4. Files touched
| Path | Change |
|------|--------|
| `ui/src/pages/AppsTab/AppDeploymentPage/IdentitySection.tsx` | Add `checkpointsSlot?: ReactNode`; render inside `configGrid` after JAR row |
| `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx` | Return React.Fragment of grid-ready children; replace header wrapper with `checkpointsTrigger` button; locale sub-line in Deployed cell |
| `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx` | Update `expand()` helper to target `/expand|collapse/i`; add test asserting locale sub-line differs from raw ISO |
| `ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css` | Add `.checkpointsTriggerCell`, `.checkpointsTrigger`, `.checkpointsTableFullRow`; remove obsolete classes listed in §2.1 |
| `ui/src/pages/AppsTab/AppDeploymentPage/index.tsx` | Split `checkpointsSlot` out of `children`; drawer stays in `children` |
| `ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/DeploymentTab.tsx` | Remove `HistoryDisclosure` import + render |
| `ui/src/pages/AppsTab/AppDeploymentPage/DeploymentTab/HistoryDisclosure.tsx` | **Delete** |
## 5. Testing
**Unit (vitest + RTL):**
- Update `CheckpointsTable.test.tsx`:
- `expand()` helper targets `screen.getByRole('button', { name: /expand|collapse/i })`.
- The "defaults to collapsed" test asserts the trigger button exists and reads `Expand (1)`; rows hidden.
- The "clicking header expands" test clicks the button (now labeled `Expand`); after click, button label is `Collapse`; rows visible.
- One new test: render the table with `deployedAt: '2026-04-23T10:35:00Z'`, expand, grab the `.isoSubline` element, assert its text contains neither the raw ISO `T` nor `Z`, i.e. it was parsed into a localized form. (Avoids asserting the exact string — CI locales vary.)
**Manual smoke:**
- Page loads → `Checkpoints | ▸ Expand (N)` as a grid row under Application JAR. Collapsed by default.
- Click trigger → text swaps to `▾ Collapse (N)`; table appears below, spanning full grid width.
- Deployed column sub-line shows a local-format date/time (e.g. `4/23/2026, 12:35:00 PM` in `en-US`).
- Deployment tab no longer shows `▶ History (N)` below `Startup Logs`.
- `CheckpointDetailDrawer` still opens on row click (unaffected).
- Empty state: app with no checkpoints shows no Checkpoints row at all.
## 6. Non-goals
- No changes to `CheckpointDetailDrawer` layout or behavior.
- No changes to `timeAgo` (other components still use it).
- No new locale-formatting helpers; `toLocaleString()` inline at the one callsite.
- Not touching primary Deployed column display (keeps "5h ago").
- No changes to the `CheckpointsTable` columns themselves.
## 7. Open questions
None — all resolved during brainstorming.

View File

@@ -73,42 +73,6 @@
border: 0;
}
.checkpointsRow {
margin-top: 8px;
}
.disclosureToggle {
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
font-size: 13px;
padding: 4px 0;
}
.checkpointList {
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px 0 0 12px;
}
.checkpointRow {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
}
.checkpointMeta {
color: var(--text-muted);
}
.checkpointArchived {
color: var(--warning);
font-size: 12px;
}
.checkpointsTable tr.checkpointArchived {
opacity: 0.55;
}
@@ -281,9 +245,6 @@
}
.logFill { flex: 1 1 auto; min-height: 200px; }
/* HistoryDisclosure */
.historyRow { margin-top: 16px; }
/* Environment pill (Identity section) */
.envPill {
display: inline-block;
@@ -401,38 +362,37 @@
z-index: 1;
}
/* Collapsible Checkpoints header */
.checkpointsSection {
/* Checkpoints row — lives inside .configGrid */
.checkpointsTriggerCell {
display: flex;
flex-direction: column;
align-items: center;
}
.checkpointsHeader {
.checkpointsTrigger {
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: none;
padding: 8px 0;
font-size: 13px;
font-weight: 600;
padding: 0;
color: var(--text-primary);
cursor: pointer;
font: inherit;
text-align: left;
}
.checkpointsHeader:hover {
.checkpointsTrigger:hover {
color: var(--amber);
}
.checkpointsTableFullRow {
grid-column: 1 / -1;
margin-top: 4px;
}
.checkpointsChevron {
color: var(--text-muted);
font-size: 11px;
width: 12px;
text-align: center;
}
.checkpointsCount {
color: var(--text-muted);
font-weight: 400;
}

View File

@@ -26,25 +26,27 @@ const stoppedDep: Deployment = {
};
function expand() {
fireEvent.click(screen.getByRole('button', { name: /checkpoints/i }));
fireEvent.click(screen.getByRole('button', { name: /expand|collapse/i }));
}
describe('CheckpointsTable', () => {
it('defaults to collapsed — header visible, rows hidden', () => {
it('defaults to collapsed — label + trigger 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.getByText('Checkpoints')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /expand \(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', () => {
it('clicking trigger expands to show rows; label flips to Collapse', () => {
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();
expect(screen.getByRole('button', { name: /collapse \(1\)/i })).toBeInTheDocument();
});
it('row click invokes onSelect with deploymentId', () => {
@@ -105,4 +107,23 @@ describe('CheckpointsTable', () => {
expand();
expect(screen.getByText(/show older \(5\)/i)).toBeInTheDocument();
});
it('Deployed sub-line is locale-formatted (not the raw ISO)', () => {
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
expand();
// Raw ISO would contain 'T' and end in 'Z' — localized form must not.
const raw = '2026-04-23T10:35:00Z';
expect(screen.queryByText(raw)).toBeNull();
// Use the createdBy cell as an anchor and walk to the sibling Deployed cell.
const row = screen.getByText('alice').closest('tr')!;
const cells = row.querySelectorAll('td');
// Column order: Version, JAR, Deployed by, Deployed, Strategy, Outcome, chevron
const deployedCell = cells[3];
expect(deployedCell).toBeDefined();
const text = deployedCell.textContent ?? '';
expect(text).not.toContain('T10:35');
expect(text).not.toContain('Z');
expect(text.length).toBeGreaterThan(0);
});
});

View File

@@ -38,91 +38,96 @@ export function CheckpointsTable({
const hidden = checkpoints.length - visible.length;
return (
<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>
<>
<span className={styles.configLabel}>Checkpoints</span>
<div className={styles.checkpointsTriggerCell}>
<button
type="button"
className={styles.checkpointsTrigger}
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
<span className={styles.checkpointsChevron}>{open ? '\u25BE' : '\u25B8'}</span>
{open ? 'Collapse' : 'Expand'} ({checkpoints.length})
</button>
</div>
{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>
</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 className={styles.checkpointsTableFullRow}>
<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 && new Date(d.deployedAt).toLocaleString()}
</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>
</>
);
}

View File

@@ -3,7 +3,6 @@ import { DeploymentProgress } from '../../../../components/DeploymentProgress';
import { StartupLogPanel } from '../../../../components/StartupLogPanel';
import { EmptyState } from '@cameleer/design-system';
import { StatusCard } from './StatusCard';
import { HistoryDisclosure } from './HistoryDisclosure';
import styles from '../AppDeploymentPage.module.css';
interface Props {
@@ -40,8 +39,6 @@ export function DeploymentTab({ deployments, versions, appSlug, envSlug, externa
)}
<StartupLogPanel deployment={latest} appSlug={appSlug} envSlug={envSlug}
className={styles.logFill} />
<HistoryDisclosure deployments={deployments} versions={versions}
appSlug={appSlug} envSlug={envSlug} />
</div>
);
}

View File

@@ -1,75 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { DataTable } from '@cameleer/design-system';
import type { Column } from '@cameleer/design-system';
import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps';
import { timeAgo } from '../../../../utils/format-utils';
import { StartupLogPanel } from '../../../../components/StartupLogPanel';
import styles from '../AppDeploymentPage.module.css';
interface Props {
deployments: Deployment[];
versions: AppVersion[];
appSlug: string;
envSlug: string;
}
export function HistoryDisclosure({ deployments, versions, appSlug, envSlug }: Props) {
const [open, setOpen] = useState(false);
const [expanded, setExpanded] = useState<string | null>(null);
const logPanelRef = useRef<HTMLDivElement | null>(null);
const versionMap = new Map(versions.map((v) => [v.id, v]));
useEffect(() => {
if (expanded && logPanelRef.current) {
logPanelRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, [expanded]);
const rows = deployments
.slice()
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
const columns: Column<Deployment>[] = [
{ key: 'createdAt', header: 'Started', render: (_, d) => timeAgo(d.createdAt) },
{
key: 'appVersionId', header: 'Version',
render: (_, d) => {
const v = versionMap.get(d.appVersionId);
return v ? `v${v.version}` : '?';
},
},
{ key: 'status', header: 'Status' },
{
key: 'deployedAt', header: 'Duration',
render: (_, d) => d.deployedAt && d.createdAt
? `${Math.round((Date.parse(d.deployedAt) - Date.parse(d.createdAt)) / 1000)}s`
: '—',
},
];
return (
<div className={styles.historyRow}>
<button type="button" className={styles.disclosureToggle} onClick={() => setOpen(!open)}>
{open ? '▼' : '▶'} History ({rows.length})
</button>
{open && (
<>
<DataTable
columns={columns}
data={rows}
onRowClick={(row) => setExpanded(expanded === row.id ? null : row.id)}
/>
{expanded && (() => {
const d = rows.find((r) => r.id === expanded);
if (!d) return null;
return (
<div ref={logPanelRef}>
<StartupLogPanel deployment={d} appSlug={appSlug} envSlug={envSlug} />
</div>
);
})()}
</>
)}
</div>
);
}

View File

@@ -25,12 +25,14 @@ interface IdentitySectionProps {
stagedJar: File | null;
onStagedJarChange: (file: File | null) => void;
deploying: boolean;
checkpointsSlot?: ReactNode;
children?: ReactNode;
}
export function IdentitySection({
mode, environment, app, currentVersion,
name, onNameChange, stagedJar, onStagedJarChange, deploying, children,
name, onNameChange, stagedJar, onStagedJarChange, deploying,
checkpointsSlot, children,
}: IdentitySectionProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const slug = app?.slug ?? slugify(name);
@@ -109,6 +111,8 @@ export function IdentitySection({
</span>
)}
</div>
{checkpointsSlot}
</div>
{children}
</div>

View File

@@ -469,9 +469,8 @@ export default function AppDeploymentPage() {
stagedJar={stagedJar}
onStagedJarChange={setStagedJar}
deploying={deploymentInProgress}
>
{app && (
<>
checkpointsSlot={
app ? (
<CheckpointsTable
deployments={deployments}
versions={versions}
@@ -479,22 +478,23 @@ export default function AppDeploymentPage() {
jarRetentionCount={jarRetentionCount}
onSelect={setSelectedCheckpointId}
/>
{selectedDep && (
<CheckpointDetailDrawer
open
onClose={() => setSelectedCheckpointId(null)}
deployment={selectedDep}
version={selectedDepVersion}
appSlug={app.slug}
envSlug={selectedEnv ?? ''}
currentForm={form}
onRestore={(deploymentId) => {
handleRestore(deploymentId);
setSelectedCheckpointId(null);
}}
/>
)}
</>
) : undefined
}
>
{app && selectedDep && (
<CheckpointDetailDrawer
open
onClose={() => setSelectedCheckpointId(null)}
deployment={selectedDep}
version={selectedDepVersion}
appSlug={app.slug}
envSlug={selectedEnv ?? ''}
currentForm={form}
onRestore={(deploymentId) => {
handleRestore(deploymentId);
setSelectedCheckpointId(null);
}}
/>
)}
</IdentitySection>