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). - 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. - 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`. - 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. - 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) + `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. - 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. - Unsaved-change router blocker uses DS `AlertDialog` (not `window.beforeunload`). Env switch intentionally discards edits without warning.
**Admin pages** (ADMIN-only, under `/admin/`): **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; 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 { .checkpointsTable tr.checkpointArchived {
opacity: 0.55; opacity: 0.55;
} }
@@ -281,9 +245,6 @@
} }
.logFill { flex: 1 1 auto; min-height: 200px; } .logFill { flex: 1 1 auto; min-height: 200px; }
/* HistoryDisclosure */
.historyRow { margin-top: 16px; }
/* Environment pill (Identity section) */ /* Environment pill (Identity section) */
.envPill { .envPill {
display: inline-block; display: inline-block;
@@ -401,38 +362,37 @@
z-index: 1; z-index: 1;
} }
/* Collapsible Checkpoints header */ /* Checkpoints row — lives inside .configGrid */
.checkpointsSection { .checkpointsTriggerCell {
display: flex; display: flex;
flex-direction: column; align-items: center;
} }
.checkpointsHeader { .checkpointsTrigger {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
background: none; background: none;
border: none; border: none;
padding: 8px 0; padding: 0;
font-size: 13px;
font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
cursor: pointer; cursor: pointer;
font: inherit;
text-align: left; text-align: left;
} }
.checkpointsHeader:hover { .checkpointsTrigger:hover {
color: var(--amber); color: var(--amber);
} }
.checkpointsTableFullRow {
grid-column: 1 / -1;
margin-top: 4px;
}
.checkpointsChevron { .checkpointsChevron {
color: var(--text-muted); color: var(--text-muted);
font-size: 11px; font-size: 11px;
width: 12px; width: 12px;
text-align: center; text-align: center;
} }
.checkpointsCount {
color: var(--text-muted);
font-weight: 400;
}

View File

@@ -26,25 +26,27 @@ const stoppedDep: Deployment = {
}; };
function expand() { function expand() {
fireEvent.click(screen.getByRole('button', { name: /checkpoints/i })); fireEvent.click(screen.getByRole('button', { name: /expand|collapse/i }));
} }
describe('CheckpointsTable', () => { 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]} 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.getByText('Checkpoints')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /expand \(1\)/i })).toBeInTheDocument();
expect(screen.queryByText('v6')).toBeNull(); expect(screen.queryByText('v6')).toBeNull();
expect(screen.queryByText('my-app-1.2.3.jar')).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]} wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />); currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
expand(); 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();
expect(screen.getByRole('button', { name: /collapse \(1\)/i })).toBeInTheDocument();
}); });
it('row click invokes onSelect with deploymentId', () => { it('row click invokes onSelect with deploymentId', () => {
@@ -105,4 +107,23 @@ describe('CheckpointsTable', () => {
expand(); expand();
expect(screen.getByText(/show older \(5\)/i)).toBeInTheDocument(); 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; const hidden = checkpoints.length - visible.length;
return ( return (
<div className={styles.checkpointsSection}> <>
<button <span className={styles.configLabel}>Checkpoints</span>
type="button" <div className={styles.checkpointsTriggerCell}>
className={styles.checkpointsHeader} <button
onClick={() => setOpen((v) => !v)} type="button"
aria-expanded={open} className={styles.checkpointsTrigger}
> onClick={() => setOpen((v) => !v)}
<span className={styles.checkpointsChevron}>{open ? '\u25BE' : '\u25B8'}</span> aria-expanded={open}
<span>Checkpoints</span> >
{' '} <span className={styles.checkpointsChevron}>{open ? '\u25BE' : '\u25B8'}</span>
<span className={styles.checkpointsCount}>({checkpoints.length})</span> {open ? 'Collapse' : 'Expand'} ({checkpoints.length})
</button> </button>
</div>
{open && ( {open && (
<div className={styles.checkpointsTable}> <div className={styles.checkpointsTableFullRow}>
<table> <div className={styles.checkpointsTable}>
<thead> <table>
<tr> <thead>
<th>Version</th> <tr>
<th>JAR</th> <th>Version</th>
<th>Deployed by</th> <th>JAR</th>
<th>Deployed</th> <th>Deployed by</th>
<th>Strategy</th> <th>Deployed</th>
<th>Outcome</th> <th>Strategy</th>
<th aria-label="open"></th> <th>Outcome</th>
</tr> <th aria-label="open"></th>
</thead> </tr>
<tbody> </thead>
{visible.map((d) => { <tbody>
const v = versionMap.get(d.appVersionId); {visible.map((d) => {
const archived = !v; const v = versionMap.get(d.appVersionId);
const strategyLabel = const archived = !v;
d.deploymentStrategy === 'BLUE_GREEN' ? 'blue/green' : 'rolling'; const strategyLabel =
return ( d.deploymentStrategy === 'BLUE_GREEN' ? 'blue/green' : 'rolling';
<tr return (
key={d.id} <tr
className={archived ? styles.checkpointArchived : undefined} key={d.id}
onClick={() => onSelect(d.id)} className={archived ? styles.checkpointArchived : undefined}
> onClick={() => onSelect(d.id)}
<td> >
<Badge label={v ? `v${v.version}` : '?'} color="auto" /> <td>
</td> <Badge label={v ? `v${v.version}` : '?'} color="auto" />
<td className={styles.jarCell}> </td>
{v ? ( <td className={styles.jarCell}>
<span className={styles.jarName}>{v.jarFilename}</span> {v ? (
) : ( <span className={styles.jarName}>{v.jarFilename}</span>
<> ) : (
<span className={styles.jarStrike}>JAR pruned</span> <>
<div className={styles.archivedHint}>archived JAR pruned</div> <span className={styles.jarStrike}>JAR pruned</span>
</> <div className={styles.archivedHint}>archived JAR pruned</div>
)} </>
</td> )}
<td> </td>
{d.createdBy ?? <span className={styles.muted}></span>} <td>
</td> {d.createdBy ?? <span className={styles.muted}></span>}
<td> </td>
{d.deployedAt && timeAgo(d.deployedAt)} <td>
<div className={styles.isoSubline}>{d.deployedAt}</div> {d.deployedAt && timeAgo(d.deployedAt)}
</td> <div className={styles.isoSubline}>
<td> {d.deployedAt && new Date(d.deployedAt).toLocaleString()}
<span className={styles.strategyPill}>{strategyLabel}</span> </div>
</td> </td>
<td> <td>
<span <span className={styles.strategyPill}>{strategyLabel}</span>
className={`${styles.outcomePill} ${styles[`outcome-${d.status}` as keyof typeof styles] || ''}`} </td>
> <td>
{d.status} <span
</span> className={`${styles.outcomePill} ${styles[`outcome-${d.status}` as keyof typeof styles] || ''}`}
</td> >
<td className={styles.chevron}></td> {d.status}
</tr> </span>
); </td>
})} <td className={styles.chevron}></td>
</tbody> </tr>
</table> );
{hidden > 0 && !expanded && ( })}
<button </tbody>
type="button" </table>
className={styles.showOlderBtn} {hidden > 0 && !expanded && (
onClick={() => setExpanded(true)} <button
> type="button"
Show older ({hidden}) archived, postmortem only className={styles.showOlderBtn}
</button> onClick={() => setExpanded(true)}
)} >
Show older ({hidden}) archived, postmortem only
</button>
)}
</div>
</div> </div>
)} )}
</div> </>
); );
} }

View File

@@ -3,7 +3,6 @@ import { DeploymentProgress } from '../../../../components/DeploymentProgress';
import { StartupLogPanel } from '../../../../components/StartupLogPanel'; import { StartupLogPanel } from '../../../../components/StartupLogPanel';
import { EmptyState } from '@cameleer/design-system'; import { EmptyState } from '@cameleer/design-system';
import { StatusCard } from './StatusCard'; import { StatusCard } from './StatusCard';
import { HistoryDisclosure } from './HistoryDisclosure';
import styles from '../AppDeploymentPage.module.css'; import styles from '../AppDeploymentPage.module.css';
interface Props { interface Props {
@@ -40,8 +39,6 @@ export function DeploymentTab({ deployments, versions, appSlug, envSlug, externa
)} )}
<StartupLogPanel deployment={latest} appSlug={appSlug} envSlug={envSlug} <StartupLogPanel deployment={latest} appSlug={appSlug} envSlug={envSlug}
className={styles.logFill} /> className={styles.logFill} />
<HistoryDisclosure deployments={deployments} versions={versions}
appSlug={appSlug} envSlug={envSlug} />
</div> </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; stagedJar: File | null;
onStagedJarChange: (file: File | null) => void; onStagedJarChange: (file: File | null) => void;
deploying: boolean; deploying: boolean;
checkpointsSlot?: ReactNode;
children?: ReactNode; children?: ReactNode;
} }
export function IdentitySection({ export function IdentitySection({
mode, environment, app, currentVersion, mode, environment, app, currentVersion,
name, onNameChange, stagedJar, onStagedJarChange, deploying, children, name, onNameChange, stagedJar, onStagedJarChange, deploying,
checkpointsSlot, children,
}: IdentitySectionProps) { }: IdentitySectionProps) {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const slug = app?.slug ?? slugify(name); const slug = app?.slug ?? slugify(name);
@@ -109,6 +111,8 @@ export function IdentitySection({
</span> </span>
)} )}
</div> </div>
{checkpointsSlot}
</div> </div>
{children} {children}
</div> </div>

View File

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