Compare commits
9 Commits
b6239bdb6b
...
0fc9c8cb4c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fc9c8cb4c | ||
|
|
fe4a6dbf24 | ||
|
|
9cfe3985d0 | ||
|
|
18da187960 | ||
|
|
9c1bd24f16 | ||
|
|
177673ba62 | ||
|
|
77f5c82dfe | ||
|
|
663a6624a7 | ||
|
|
cc3cd610b2 |
@@ -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/`):
|
||||
|
||||
1016
docs/superpowers/plans/2026-04-23-checkpoints-grid-row.md
Normal file
1016
docs/superpowers/plans/2026-04-23-checkpoints-grid-row.md
Normal file
File diff suppressed because it is too large
Load Diff
252
docs/superpowers/specs/2026-04-23-checkpoints-grid-row-design.md
Normal file
252
docs/superpowers/specs/2026-04-23-checkpoints-grid-row-design.md
Normal 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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,19 +38,21 @@ export function CheckpointsTable({
|
||||
const hidden = checkpoints.length - visible.length;
|
||||
|
||||
return (
|
||||
<div className={styles.checkpointsSection}>
|
||||
<>
|
||||
<span className={styles.configLabel}>Checkpoints</span>
|
||||
<div className={styles.checkpointsTriggerCell}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.checkpointsHeader}
|
||||
className={styles.checkpointsTrigger}
|
||||
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>
|
||||
{open ? 'Collapse' : 'Expand'} ({checkpoints.length})
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div className={styles.checkpointsTableFullRow}>
|
||||
<div className={styles.checkpointsTable}>
|
||||
<table>
|
||||
<thead>
|
||||
@@ -94,7 +96,9 @@ export function CheckpointsTable({
|
||||
</td>
|
||||
<td>
|
||||
{d.deployedAt && timeAgo(d.deployedAt)}
|
||||
<div className={styles.isoSubline}>{d.deployedAt}</div>
|
||||
<div className={styles.isoSubline}>
|
||||
{d.deployedAt && new Date(d.deployedAt).toLocaleString()}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={styles.strategyPill}>{strategyLabel}</span>
|
||||
@@ -122,7 +126,8 @@ export function CheckpointsTable({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -469,9 +469,8 @@ export default function AppDeploymentPage() {
|
||||
stagedJar={stagedJar}
|
||||
onStagedJarChange={setStagedJar}
|
||||
deploying={deploymentInProgress}
|
||||
>
|
||||
{app && (
|
||||
<>
|
||||
checkpointsSlot={
|
||||
app ? (
|
||||
<CheckpointsTable
|
||||
deployments={deployments}
|
||||
versions={versions}
|
||||
@@ -479,7 +478,10 @@ export default function AppDeploymentPage() {
|
||||
jarRetentionCount={jarRetentionCount}
|
||||
onSelect={setSelectedCheckpointId}
|
||||
/>
|
||||
{selectedDep && (
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{app && selectedDep && (
|
||||
<CheckpointDetailDrawer
|
||||
open
|
||||
onClose={() => setSelectedCheckpointId(null)}
|
||||
@@ -494,8 +496,6 @@ export default function AppDeploymentPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</IdentitySection>
|
||||
|
||||
{/* ── Config tabs ── */}
|
||||
|
||||
Reference in New Issue
Block a user