Compare commits
13 Commits
e36c82c4db
...
b6239bdb6b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6239bdb6b | ||
|
|
0ae27ad9ed | ||
|
|
e00848dc65 | ||
|
|
f31975e0ef | ||
|
|
2c0cf7dc9c | ||
|
|
fb7b15f539 | ||
|
|
1d7009d69c | ||
|
|
99a91a57be | ||
|
|
427988bcc8 | ||
|
|
a208f2eec7 | ||
|
|
13f218d522 | ||
|
|
900fba5af6 | ||
|
|
b3d1dd377d |
@@ -14,9 +14,9 @@ The UI has 4 main tabs: **Exchanges**, **Dashboard**, **Runtime**, **Deployments
|
|||||||
- Routes: `/apps` (list, `AppListView` in `AppsTab.tsx`), `/apps/new` + `/apps/:slug` (both render `AppDeploymentPage`).
|
- Routes: `/apps` (list, `AppListView` in `AppsTab.tsx`), `/apps/new` + `/apps/:slug` (both render `AppDeploymentPage`).
|
||||||
- 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` (persists desired state without deploying) → `Redeploy` (applies desired state) → `Deploying…` during active deploy.
|
- 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 `CheckpointsTable` (DataTable-style) below the Identity section. Columns: Version · JAR (filename) · Deployed by · Deployed (relative + ISO) · Strategy · Outcome · ›. Row click opens `CheckpointDetailDrawer` (project-local `SideDrawer` primitive). Drawer has Logs and Config tabs; Config has Snapshot / Diff vs current view modes. 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 legacy `Checkpoints.tsx` row-list component is gone.
|
- 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.
|
- 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.
|
||||||
- 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/`):
|
||||||
|
|||||||
1359
docs/superpowers/plans/2026-04-23-deployment-page-polish.md
Normal file
1359
docs/superpowers/plans/2026-04-23-deployment-page-polish.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
|||||||
|
# Deployment page polish — design
|
||||||
|
|
||||||
|
**Date:** 2026-04-23
|
||||||
|
**Scope:** six targeted UX improvements on the unified deployment page (`ui/src/pages/AppsTab/AppDeploymentPage/*`)
|
||||||
|
**Status:** Draft — pending user review
|
||||||
|
|
||||||
|
## 1. Motivation
|
||||||
|
|
||||||
|
The unified deployment page landed recently. Exercising it surfaced six rough edges:
|
||||||
|
|
||||||
|
1. No feedback during JAR upload — user clicks `Save` or `Redeploy`, the button spins, nothing happens visually until the upload finishes. On large JARs this feels broken.
|
||||||
|
2. Startup logs are fixed ascending with no way to see the newest line first and no manual refresh between polls.
|
||||||
|
3. The checkpoints table is always visible, pushing the config tabs far down even when the user doesn't care about history right now.
|
||||||
|
4. The replica dropdown in the checkpoint drawer uses a raw `<select>`, visually out of place vs. the rest of the design system.
|
||||||
|
5. The drawer opens on `Logs` — but `Config` is the restore-decision content. Users currently always click over.
|
||||||
|
6. (Shipped as `b3d1dd37`.) The "No past deployments yet." empty-state hint was noise — removed.
|
||||||
|
|
||||||
|
This spec covers changes 1–5. #6 is listed for completeness only.
|
||||||
|
|
||||||
|
## 2. Design
|
||||||
|
|
||||||
|
### 2.1 Upload progress inside the primary action button
|
||||||
|
|
||||||
|
**Rationale.** Putting upload progress inside the button the user just clicked keeps all state-machine feedback in a single locus. The button already advertises the active action (`Save` → `Redeploy` → `Deploying…`); adding `Uploading…` is a natural extension.
|
||||||
|
|
||||||
|
**State machine.**
|
||||||
|
|
||||||
|
```
|
||||||
|
PrimaryActionMode: 'save' | 'redeploy' | 'uploading' | 'deploying'
|
||||||
|
```
|
||||||
|
|
||||||
|
- `save` — config dirty, no active deploy, no active upload
|
||||||
|
- `redeploy` — server dirty against last deploy, no active upload, no active deploy
|
||||||
|
- `uploading` — a JAR upload is in flight (applies during both Save and Redeploy paths)
|
||||||
|
- `deploying` — a deployment row exists with status `STARTING`
|
||||||
|
|
||||||
|
**Progress propagation.**
|
||||||
|
|
||||||
|
- `useUploadJar` switches from `fetch()` to `XMLHttpRequest`. Fetch gives no upload-progress events; XHR's `upload.onprogress` does.
|
||||||
|
- `useUploadJar` mutation args gain `onProgress?: (pct: number) => void`.
|
||||||
|
- `AppDeploymentPage` holds `const [uploadPct, setUploadPct] = useState<number | null>(null)`. `handleSave` and `handleRedeploy` pass `onProgress: setUploadPct` into the mutation and clear to `null` in a `finally` block.
|
||||||
|
- `computeMode` learns a new input `uploading: boolean` (derived from `uploadPct !== null`). Order: `deploying` > `uploading` > `save|redeploy` choice.
|
||||||
|
- `PrimaryActionButton` gains an optional `progress?: number` prop and renders a progress overlay when `mode === 'uploading'`.
|
||||||
|
|
||||||
|
**Button visual during `uploading`.**
|
||||||
|
|
||||||
|
- Label: `Uploading… 42%` (rounded integer).
|
||||||
|
- Disabled (same UX as `deploying`).
|
||||||
|
- A tinted-primary fill grows from left to right behind the label. Implementation: wrap the DS `Button` children in a positioned container with an inner `<div>` whose `width: ${pct}%` and background is a translucent primary tint (via `color-mix(in srgb, var(--primary) 30%, transparent)` or equivalent CSS variable). Keeps DS `Button` unmodified.
|
||||||
|
|
||||||
|
**Edge cases.**
|
||||||
|
|
||||||
|
- Upload fails → `onProgress` stops, the XHR error rejects the mutation, existing `catch` block surfaces the toast, `uploadPct` is cleared in `finally`, button returns to whichever mode `computeMode` picks.
|
||||||
|
- User navigates away mid-upload → the unsaved-changes blocker already exists and challenges the navigation. Cancellation semantics (whether to `xhr.abort()` when the mutation is superseded) are handled by the mutation's own lifecycle — out of scope for this change.
|
||||||
|
- No staged JAR (redeploy-only) → `uploadPct` stays `null`, mode goes `redeploy` → `deploying` with no `uploading` in between (unchanged from today).
|
||||||
|
|
||||||
|
### 2.2 Startup log panel — sort + manual refresh
|
||||||
|
|
||||||
|
**Layout.** Mirror the Application Log panel in `AgentHealth.tsx:899-917`:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ STARTUP LOGS ● live polling every 3s 42 entries ↓ ↻ │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
│ <LogViewer entries...> │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Reuse `ui/src/styles/log-panel.module.css` (`logCard`, `logHeader`, `headerActions`).
|
||||||
|
- Sort toggle: DS `Button variant="ghost" size="sm"` with unicode arrow (`↓` desc / `↑` asc). `title` prop: `"Newest first"` / `"Oldest first"`.
|
||||||
|
- Refresh: DS `Button variant="ghost" size="sm"` wrapping `<RefreshCw size={14} />` from `lucide-react`. `title="Refresh"`.
|
||||||
|
|
||||||
|
**Sort semantics.**
|
||||||
|
|
||||||
|
- Default sort is **desc** (newest first). User's pain point is that the interesting lines are the most recent ones.
|
||||||
|
- `useStartupLogs` signature extends to:
|
||||||
|
```ts
|
||||||
|
useStartupLogs(application, environment, deployCreatedAt, isStarting, sort: 'asc' | 'desc')
|
||||||
|
```
|
||||||
|
- `sort` is passed straight into `LogSearchParams` so the **backend** fetch respects it. Limit remains 500. The 500-line cap applies from the sort direction, so desc gets the latest 500 and asc gets the oldest 500. (Pre-existing limitation; not addressed here.)
|
||||||
|
- Display direction matches fetch direction — `LogViewer` is passed whatever order the server returns.
|
||||||
|
|
||||||
|
**Refresh behavior.**
|
||||||
|
|
||||||
|
- Calls the TanStack Query `refetch()`.
|
||||||
|
- After the refetch resolves, scroll the panel's content container to the "latest" edge:
|
||||||
|
- `sort === 'asc'` → scroll to bottom (newest is at the bottom).
|
||||||
|
- `sort === 'desc'` → scroll to top (newest is at the top).
|
||||||
|
- Requires a `useRef<HTMLDivElement>` on a new scroll wrapper around `LogViewer` inside `StartupLogPanel`. `LogViewer` itself does not forward a scroll ref — confirmed in DS `index.es.d.ts` — so we add a wrapping `<div ref={scrollRef} className={...}>` with `overflow: auto` and call `scrollRef.current.scrollTo({ top, behavior: 'smooth' })` after the refetch resolves.
|
||||||
|
|
||||||
|
**Polling behavior unchanged.** 3-second polling while `isStarting` still happens via `refetchInterval`. Manual refresh is orthogonal.
|
||||||
|
|
||||||
|
### 2.3 Checkpoints table collapsible, default collapsed
|
||||||
|
|
||||||
|
**Rationale.** Deployments happen infrequently; when the user is on this page, they're usually tuning *current* config, not reviewing history. Collapsing by default reclaims vertical space for the config tabs.
|
||||||
|
|
||||||
|
**Behavior.**
|
||||||
|
|
||||||
|
- `CheckpointsTable` renders a clickable header row **above** the table body:
|
||||||
|
```
|
||||||
|
▸ Checkpoints (7) ← collapsed (default)
|
||||||
|
▾ Checkpoints (7) ← expanded
|
||||||
|
```
|
||||||
|
- Chevron and label are part of the same `<button>` (keyboard-accessible). No separate icon component needed — unicode `▸`/`▾` match the existing codebase style.
|
||||||
|
- Local component state: `const [open, setOpen] = useState(false)`.
|
||||||
|
- When `open === false`, the `<table>` and the "Show older" expander are not rendered — only the header row.
|
||||||
|
- The existing "no checkpoints" early-return (`null`) is preserved — no header row at all when there's nothing to show.
|
||||||
|
|
||||||
|
**Styling.** New `.checkpointsHeader` class in `AppDeploymentPage.module.css`:
|
||||||
|
- Same horizontal padding as the table cells, small gap between chevron and label, subdued color on hover.
|
||||||
|
- Muted count `(N)` in `var(--text-muted)`.
|
||||||
|
|
||||||
|
### 2.4 Replica dropdown uses DS Select
|
||||||
|
|
||||||
|
**Change.** In `CheckpointDetailDrawer/LogsPanel.tsx:36-47`, replace the native `<select>` with `Select` from `@cameleer/design-system`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Select
|
||||||
|
value={String(replicaFilter)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setReplicaFilter(v === 'all' ? 'all' : Number(v));
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: 'all', label: `all (${deployment.replicaStates.length})` },
|
||||||
|
...deployment.replicaStates.map((_, i) => ({ value: String(i), label: String(i) })),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Label stays `Replica:` as a sibling element (DS `Select` doesn't include an inline label slot).
|
||||||
|
- No behavior change beyond styling.
|
||||||
|
|
||||||
|
### 2.5 Drawer tabs — Config first, default Config
|
||||||
|
|
||||||
|
**Change.** In `CheckpointDetailDrawer/index.tsx`:
|
||||||
|
|
||||||
|
- Reverse the `tabs` array in the `<Tabs>` call so `Config` precedes `Logs`.
|
||||||
|
- Change the initial tab state from `useState<TabId>('logs')` to `useState<TabId>('config')`.
|
||||||
|
|
||||||
|
Rationale: `Config` is the restore-decision content (what variables, what resources, what monitoring settings did this checkpoint have). `Logs` is supporting/post-mortem material. The first tab should be the one users land on for the default question.
|
||||||
|
|
||||||
|
## 3. Files to touch
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx` | Add `'uploading'` mode + `progress` prop; `computeMode` takes `uploading` input |
|
||||||
|
| `ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css` | `.checkpointsHeader`; progress-overlay styles for the primary button |
|
||||||
|
| `ui/src/pages/AppsTab/AppDeploymentPage/index.tsx` | `uploadPct` state; pass `onProgress` into both upload call sites |
|
||||||
|
| `ui/src/api/queries/admin/apps.ts` | `useUploadJar` → XHR; `onProgress` mutation arg |
|
||||||
|
| `ui/src/components/StartupLogPanel.tsx` | Header layout rewrite; sort state; refresh handler; scroll ref |
|
||||||
|
| `ui/src/components/StartupLogPanel.module.css` | Header-action styles if not covered by shared `log-panel.module.css` |
|
||||||
|
| `ui/src/api/queries/logs.ts` | `useStartupLogs` adds `sort` parameter |
|
||||||
|
| `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx` | Collapse state + header row |
|
||||||
|
| `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/LogsPanel.tsx` | DS `Select` for replica filter |
|
||||||
|
| `ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx` | Tab reorder + default `'config'` |
|
||||||
|
|
||||||
|
## 4. Testing
|
||||||
|
|
||||||
|
**Unit.**
|
||||||
|
|
||||||
|
- `useUploadJar` — mock XHR, assert `onProgress` fires during `upload.onprogress`, resolves on 2xx, rejects on non-2xx and on `xhr.onerror`.
|
||||||
|
- `CheckpointsTable` — collapse toggle: header-only when collapsed, full table visible when open; count in header matches `checkpoints.length`.
|
||||||
|
- `StartupLogPanel` — sort toggle flips the query sort parameter; refresh calls `refetch` and calls `scrollTo` on the right end per sort direction.
|
||||||
|
|
||||||
|
**Component rendering.**
|
||||||
|
|
||||||
|
- `PrimaryActionButton` — renders `Uploading… 42%` in `'uploading'` mode with the overlay element width bound to `progress`.
|
||||||
|
|
||||||
|
**Manual smoke.**
|
||||||
|
|
||||||
|
- Save-with-JAR: button transitions `Save` → `Uploading…` → back to whatever the post-save mode is.
|
||||||
|
- Redeploy-with-JAR: button transitions `Redeploy` → `Uploading…` → `Deploying…`.
|
||||||
|
- Redeploy-only (no staged JAR): button transitions `Redeploy` → `Deploying…` (no `Uploading…`).
|
||||||
|
- Upload fails (simulate 500 from backend): button returns to pre-click mode; error toast shown.
|
||||||
|
- Startup logs: flip sort; refresh; confirm latest line is visible in each direction.
|
||||||
|
- Checkpoints: collapsed on load; expanding shows the 10-row table + "Show older" expander if applicable.
|
||||||
|
- Drawer: open it → lands on `Config`; switching to `Logs` works; replica filter looks like DS components.
|
||||||
|
|
||||||
|
## 5. Non-goals
|
||||||
|
|
||||||
|
- No change to the startup-logs backend endpoint; the 500-line cap and 3s polling stay.
|
||||||
|
- No change to the checkpoint drawer's footer (`Restore` button), header, or meta line.
|
||||||
|
- No change to deployment creation, stop, or delete flows.
|
||||||
|
- No new test-infra scaffolding (XHR mocking uses what's already in Vitest).
|
||||||
|
- Already-shipped: the empty-checkpoints `null` return (commit `b3d1dd37`) — not touched again.
|
||||||
|
|
||||||
|
## 6. Open questions
|
||||||
|
|
||||||
|
None — all decisions resolved during brainstorming.
|
||||||
@@ -140,21 +140,47 @@ export function useAppVersions(envSlug: string | undefined, appSlug: string | un
|
|||||||
export function useUploadJar() {
|
export function useUploadJar() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({ envSlug, appSlug, file }: { envSlug: string; appSlug: string; file: File }) => {
|
mutationFn: ({ envSlug, appSlug, file, onProgress }: {
|
||||||
|
envSlug: string;
|
||||||
|
appSlug: string;
|
||||||
|
file: File;
|
||||||
|
onProgress?: (pct: number) => void;
|
||||||
|
}) => {
|
||||||
const token = useAuthStore.getState().accessToken;
|
const token = useAuthStore.getState().accessToken;
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('file', file);
|
form.append('file', file);
|
||||||
const res = await fetch(
|
|
||||||
`${config.apiBaseUrl}${envBase(envSlug)}/${encodeURIComponent(appSlug)}/versions`, {
|
return new Promise<AppVersion>((resolve, reject) => {
|
||||||
method: 'POST',
|
const xhr = new XMLHttpRequest();
|
||||||
headers: {
|
xhr.open(
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
'POST',
|
||||||
'X-Cameleer-Protocol-Version': '1',
|
`${config.apiBaseUrl}${envBase(envSlug)}/${encodeURIComponent(appSlug)}/versions`,
|
||||||
},
|
);
|
||||||
body: form,
|
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||||
|
xhr.setRequestHeader('X-Cameleer-Protocol-Version', '1');
|
||||||
|
|
||||||
|
xhr.upload.onprogress = (e) => {
|
||||||
|
if (!onProgress || !e.lengthComputable) return;
|
||||||
|
onProgress(Math.round((e.loaded / e.total) * 100));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status < 200 || xhr.status >= 300) {
|
||||||
|
reject(new Error(`Upload failed: ${xhr.status}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(xhr.responseText) as AppVersion);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err instanceof Error ? err : new Error('Invalid response'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = () => reject(new Error('Upload network error'));
|
||||||
|
xhr.onabort = () => reject(new Error('Upload aborted'));
|
||||||
|
|
||||||
|
xhr.send(form);
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Upload failed: ${res.status}`);
|
|
||||||
return res.json() as Promise<AppVersion>;
|
|
||||||
},
|
},
|
||||||
onSuccess: (_data, { envSlug, appSlug }) =>
|
onSuccess: (_data, { envSlug, appSlug }) =>
|
||||||
qc.invalidateQueries({ queryKey: ['apps', envSlug, appSlug, 'versions'] }),
|
qc.invalidateQueries({ queryKey: ['apps', envSlug, appSlug, 'versions'] }),
|
||||||
|
|||||||
@@ -143,13 +143,14 @@ export function useStartupLogs(
|
|||||||
environment: string | undefined,
|
environment: string | undefined,
|
||||||
deployCreatedAt: string | undefined,
|
deployCreatedAt: string | undefined,
|
||||||
isStarting: boolean,
|
isStarting: boolean,
|
||||||
|
sort: 'asc' | 'desc' = 'desc',
|
||||||
) {
|
) {
|
||||||
const params: LogSearchParams = {
|
const params: LogSearchParams = {
|
||||||
application: application || undefined,
|
application: application || undefined,
|
||||||
environment: environment ?? '',
|
environment: environment ?? '',
|
||||||
source: 'container',
|
source: 'container',
|
||||||
from: deployCreatedAt || undefined,
|
from: deployCreatedAt || undefined,
|
||||||
sort: 'asc',
|
sort,
|
||||||
limit: 500,
|
limit: 500,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -61,3 +61,15 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerRight {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollWrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|||||||
86
ui/src/components/StartupLogPanel.test.tsx
Normal file
86
ui/src/components/StartupLogPanel.test.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { ThemeProvider } from '@cameleer/design-system';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { StartupLogPanel } from './StartupLogPanel';
|
||||||
|
import type { Deployment } from '../api/queries/admin/apps';
|
||||||
|
import * as logsModule from '../api/queries/logs';
|
||||||
|
|
||||||
|
function wrap(ui: ReactNode) {
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
return render(
|
||||||
|
<ThemeProvider>
|
||||||
|
<QueryClientProvider client={qc}>{ui}</QueryClientProvider>
|
||||||
|
</ThemeProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseDep: Deployment = {
|
||||||
|
id: 'dep1', appId: 'a', appVersionId: 'v', environmentId: 'e',
|
||||||
|
status: 'STARTING', targetState: 'RUNNING', deploymentStrategy: 'BLUE_GREEN',
|
||||||
|
replicaStates: [], deployStage: 'START_REPLICAS',
|
||||||
|
containerId: null, containerName: null, errorMessage: null,
|
||||||
|
deployedAt: null, stoppedAt: null,
|
||||||
|
createdAt: '2026-04-23T10:00:00Z', createdBy: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const entries = [
|
||||||
|
{ timestamp: '2026-04-23T10:00:01Z', level: 'INFO' as const, message: 'hello',
|
||||||
|
loggerName: null, threadName: null, stackTrace: null, exchangeId: null,
|
||||||
|
instanceId: null, application: null, mdc: null, source: 'container' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('StartupLogPanel', () => {
|
||||||
|
beforeEach(() => { vi.restoreAllMocks(); });
|
||||||
|
|
||||||
|
it('passes default sort=desc to useStartupLogs', () => {
|
||||||
|
const spy = vi.spyOn(logsModule, 'useStartupLogs').mockReturnValue({
|
||||||
|
data: { data: entries, nextCursor: null, hasMore: false, levelCounts: {} },
|
||||||
|
} as ReturnType<typeof logsModule.useStartupLogs>);
|
||||||
|
wrap(<StartupLogPanel deployment={baseDep} appSlug="app" envSlug="dev" />);
|
||||||
|
expect(spy).toHaveBeenCalledWith('app', 'dev', '2026-04-23T10:00:00Z', true, 'desc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggle flips sort to asc', () => {
|
||||||
|
const spy = vi.spyOn(logsModule, 'useStartupLogs').mockReturnValue({
|
||||||
|
data: { data: entries, nextCursor: null, hasMore: false, levelCounts: {} },
|
||||||
|
} as ReturnType<typeof logsModule.useStartupLogs>);
|
||||||
|
wrap(<StartupLogPanel deployment={baseDep} appSlug="app" envSlug="dev" />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /oldest first|newest first/i }));
|
||||||
|
expect(spy).toHaveBeenLastCalledWith('app', 'dev', '2026-04-23T10:00:00Z', true, 'asc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refresh button calls refetch', () => {
|
||||||
|
const refetch = vi.fn();
|
||||||
|
vi.spyOn(logsModule, 'useStartupLogs').mockReturnValue({
|
||||||
|
data: { data: entries, nextCursor: null, hasMore: false, levelCounts: {} },
|
||||||
|
refetch,
|
||||||
|
} as unknown as ReturnType<typeof logsModule.useStartupLogs>);
|
||||||
|
wrap(<StartupLogPanel deployment={baseDep} appSlug="app" envSlug="dev" />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /refresh/i }));
|
||||||
|
expect(refetch).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrolls to top after refresh when sort=desc', async () => {
|
||||||
|
const refetch = vi.fn().mockResolvedValue({});
|
||||||
|
vi.spyOn(logsModule, 'useStartupLogs').mockReturnValue({
|
||||||
|
data: { data: entries, nextCursor: null, hasMore: false, levelCounts: {} },
|
||||||
|
refetch,
|
||||||
|
} as unknown as ReturnType<typeof logsModule.useStartupLogs>);
|
||||||
|
const scrollTo = vi.fn();
|
||||||
|
const origScrollTo = Element.prototype.scrollTo;
|
||||||
|
Element.prototype.scrollTo = scrollTo as unknown as typeof Element.prototype.scrollTo;
|
||||||
|
try {
|
||||||
|
wrap(<StartupLogPanel deployment={baseDep} appSlug="app" envSlug="dev" />);
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /refresh/i }));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
// default sort is desc → scroll to top (top === 0)
|
||||||
|
expect(scrollTo).toHaveBeenCalledWith(expect.objectContaining({ top: 0 }));
|
||||||
|
} finally {
|
||||||
|
Element.prototype.scrollTo = origScrollTo;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { LogViewer } from '@cameleer/design-system';
|
import { useRef, useState } from 'react';
|
||||||
|
import { LogViewer, Button } from '@cameleer/design-system';
|
||||||
import type { LogEntry } from '@cameleer/design-system';
|
import type { LogEntry } from '@cameleer/design-system';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
import { useStartupLogs } from '../api/queries/logs';
|
import { useStartupLogs } from '../api/queries/logs';
|
||||||
import type { Deployment } from '../api/queries/admin/apps';
|
import type { Deployment } from '../api/queries/admin/apps';
|
||||||
import styles from './StartupLogPanel.module.css';
|
import styles from './StartupLogPanel.module.css';
|
||||||
@@ -14,10 +16,24 @@ interface StartupLogPanelProps {
|
|||||||
export function StartupLogPanel({ deployment, appSlug, envSlug, className }: StartupLogPanelProps) {
|
export function StartupLogPanel({ deployment, appSlug, envSlug, className }: StartupLogPanelProps) {
|
||||||
const isStarting = deployment.status === 'STARTING';
|
const isStarting = deployment.status === 'STARTING';
|
||||||
const isFailed = deployment.status === 'FAILED';
|
const isFailed = deployment.status === 'FAILED';
|
||||||
|
const [sort, setSort] = useState<'asc' | 'desc'>('desc');
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data } = useStartupLogs(appSlug, envSlug, deployment.createdAt, isStarting);
|
const query = useStartupLogs(appSlug, envSlug, deployment.createdAt, isStarting, sort);
|
||||||
|
const entries = query.data?.data ?? [];
|
||||||
|
|
||||||
const entries = data?.data ?? [];
|
const scrollToLatest = () => {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
if (!el || typeof el.scrollTo !== 'function') return;
|
||||||
|
// asc → latest at bottom; desc → latest at top
|
||||||
|
const top = sort === 'asc' ? el.scrollHeight : 0;
|
||||||
|
el.scrollTo({ top, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
await query.refetch?.();
|
||||||
|
scrollToLatest();
|
||||||
|
};
|
||||||
|
|
||||||
if (entries.length === 0 && !isStarting) return null;
|
if (entries.length === 0 && !isStarting) return null;
|
||||||
|
|
||||||
@@ -36,13 +52,37 @@ export function StartupLogPanel({ deployment, appSlug, envSlug, className }: Sta
|
|||||||
<span className={`${styles.badge} ${styles.badgeStopped}`}>stopped</span>
|
<span className={`${styles.badge} ${styles.badgeStopped}`}>stopped</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={styles.lineCount}>{entries.length} lines</span>
|
<div className={styles.headerRight}>
|
||||||
|
<span className={styles.lineCount}>{entries.length} entries</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={query.isFetching}
|
||||||
|
onClick={() => setSort((s) => (s === 'asc' ? 'desc' : 'asc'))}
|
||||||
|
title={sort === 'asc' ? 'Oldest first' : 'Newest first'}
|
||||||
|
aria-label={sort === 'asc' ? 'Oldest first' : 'Newest first'}
|
||||||
|
>
|
||||||
|
{sort === 'asc' ? '\u2191' : '\u2193'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={query.isFetching}
|
||||||
|
onClick={handleRefresh}
|
||||||
|
title="Refresh"
|
||||||
|
aria-label="Refresh"
|
||||||
|
>
|
||||||
|
<RefreshCw size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref={scrollRef} className={styles.scrollWrap}>
|
||||||
|
{entries.length > 0 ? (
|
||||||
|
<LogViewer entries={entries as unknown as LogEntry[]} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.empty}>Waiting for container output...</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{entries.length > 0 ? (
|
|
||||||
<LogViewer entries={entries as unknown as LogEntry[]} />
|
|
||||||
) : (
|
|
||||||
<div className={styles.empty}>Waiting for container output...</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,11 +113,6 @@
|
|||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkpointEmpty {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Config tab shared */
|
/* Config tab shared */
|
||||||
.configInline {
|
.configInline {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -379,3 +374,65 @@
|
|||||||
background: var(--bg-inset);
|
background: var(--bg-inset);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Primary-button upload progress overlay
|
||||||
|
* Wraps the DS Button so the inner Button can retain its own background
|
||||||
|
* while we overlay a tinted progress fill and label on top. */
|
||||||
|
.uploadBtnWrap {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadBtnWrap button {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadBtnFill {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
background: color-mix(in srgb, var(--primary) 35%, transparent);
|
||||||
|
transition: width 120ms linear;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadBtnLabel {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsible Checkpoints header */
|
||||||
|
.checkpointsSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkpointsHeader {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkpointsHeader:hover {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkpointsChevron {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
width: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkpointsCount {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,6 +49,12 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.replicaLabel {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.emptyState {
|
.emptyState {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@@ -60,11 +60,11 @@ describe('CheckpointDetailDrawer', () => {
|
|||||||
expect(screen.getByText(/alice/)).toBeInTheDocument();
|
expect(screen.getByText(/alice/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Logs tab is selected by default', () => {
|
it('Config tab is selected by default', () => {
|
||||||
renderDrawer();
|
renderDrawer();
|
||||||
// Tabs from DS may render as buttons or tabs role — be lenient on the query
|
// Tabs from DS may render as buttons or tabs role — be lenient on the query
|
||||||
const logsTab = screen.getByText(/^logs$/i);
|
const configTab = screen.getByText(/^config$/i);
|
||||||
expect(logsTab).toBeInTheDocument();
|
expect(configTab).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('disables Restore when JAR is pruned', () => {
|
it('disables Restore when JAR is pruned', () => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Select } from '@cameleer/design-system';
|
||||||
import { useInfiniteApplicationLogs } from '../../../../api/queries/logs';
|
import { useInfiniteApplicationLogs } from '../../../../api/queries/logs';
|
||||||
import type { Deployment } from '../../../../api/queries/admin/apps';
|
import type { Deployment } from '../../../../api/queries/admin/apps';
|
||||||
import { instanceIdsFor } from './instance-id';
|
import { instanceIdsFor } from './instance-id';
|
||||||
@@ -28,23 +29,24 @@ export function LogsPanel({ deployment, appSlug, envSlug }: Props) {
|
|||||||
isAtTop: true,
|
isAtTop: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const replicaOptions = [
|
||||||
|
{ value: 'all', label: `all (${deployment.replicaStates.length})` },
|
||||||
|
...deployment.replicaStates.map((_, i) => ({ value: String(i), label: String(i) })),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.filterBar}>
|
<div className={styles.filterBar}>
|
||||||
<label>
|
<label className={styles.replicaLabel}>
|
||||||
Replica:
|
<span>Replica:</span>
|
||||||
<select
|
<Select
|
||||||
value={String(replicaFilter)}
|
value={String(replicaFilter)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
setReplicaFilter(v === 'all' ? 'all' : Number(v));
|
setReplicaFilter(v === 'all' ? 'all' : Number(v));
|
||||||
}}
|
}}
|
||||||
>
|
options={replicaOptions}
|
||||||
<option value="all">all ({deployment.replicaStates.length})</option>
|
/>
|
||||||
{deployment.replicaStates.map((_, i) => (
|
|
||||||
<option key={i} value={i}>{i}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{logs.items.length === 0 && !logs.isLoading && (
|
{logs.items.length === 0 && !logs.isLoading && (
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ type TabId = 'logs' | 'config';
|
|||||||
export function CheckpointDetailDrawer({
|
export function CheckpointDetailDrawer({
|
||||||
open, onClose, deployment, version, appSlug, envSlug, onRestore, currentForm,
|
open, onClose, deployment, version, appSlug, envSlug, onRestore, currentForm,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [tab, setTab] = useState<TabId>('logs');
|
const [tab, setTab] = useState<TabId>('config');
|
||||||
const archived = !version;
|
const archived = !version;
|
||||||
|
|
||||||
const title = (
|
const title = (
|
||||||
@@ -67,8 +67,8 @@ export function CheckpointDetailDrawer({
|
|||||||
active={tab}
|
active={tab}
|
||||||
onChange={(t) => setTab(t as TabId)}
|
onChange={(t) => setTab(t as TabId)}
|
||||||
tabs={[
|
tabs={[
|
||||||
{ value: 'logs', label: 'Logs' },
|
|
||||||
{ value: 'config', label: 'Config' },
|
{ value: 'config', label: 'Config' },
|
||||||
|
{ value: 'logs', label: 'Logs' },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<div className={styles.tabContent}>
|
<div className={styles.tabContent}>
|
||||||
|
|||||||
@@ -25,10 +25,23 @@ const stoppedDep: Deployment = {
|
|||||||
deployedConfigSnapshot: { jarVersionId: 'v6id', agentConfig: null, containerConfig: {}, sensitiveKeys: null },
|
deployedConfigSnapshot: { jarVersionId: 'v6id', agentConfig: null, containerConfig: {}, sensitiveKeys: null },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function expand() {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /checkpoints/i }));
|
||||||
|
}
|
||||||
|
|
||||||
describe('CheckpointsTable', () => {
|
describe('CheckpointsTable', () => {
|
||||||
it('renders a row per checkpoint with version, jar, deployer', () => {
|
it('defaults to collapsed — header 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.queryByText('v6')).toBeNull();
|
||||||
|
expect(screen.queryByText('my-app-1.2.3.jar')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking header expands to show rows', () => {
|
||||||
|
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
||||||
|
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
|
||||||
|
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();
|
||||||
@@ -38,7 +51,7 @@ describe('CheckpointsTable', () => {
|
|||||||
const onSelect = vi.fn();
|
const onSelect = vi.fn();
|
||||||
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
||||||
currentDeploymentId={null} jarRetentionCount={5} onSelect={onSelect} />);
|
currentDeploymentId={null} jarRetentionCount={5} onSelect={onSelect} />);
|
||||||
// Use the version text as the row anchor (most stable selector)
|
expand();
|
||||||
fireEvent.click(screen.getByText('v6').closest('tr')!);
|
fireEvent.click(screen.getByText('v6').closest('tr')!);
|
||||||
expect(onSelect).toHaveBeenCalledWith('d1');
|
expect(onSelect).toHaveBeenCalledWith('d1');
|
||||||
});
|
});
|
||||||
@@ -47,6 +60,7 @@ describe('CheckpointsTable', () => {
|
|||||||
const noActor = { ...stoppedDep, createdBy: null };
|
const noActor = { ...stoppedDep, createdBy: null };
|
||||||
wrap(<CheckpointsTable deployments={[noActor]} versions={[v6]}
|
wrap(<CheckpointsTable deployments={[noActor]} versions={[v6]}
|
||||||
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
|
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
|
||||||
|
expand();
|
||||||
expect(screen.getByText('—')).toBeInTheDocument();
|
expect(screen.getByText('—')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,14 +68,14 @@ describe('CheckpointsTable', () => {
|
|||||||
const pruned = { ...stoppedDep, appVersionId: 'unknown' };
|
const pruned = { ...stoppedDep, appVersionId: 'unknown' };
|
||||||
wrap(<CheckpointsTable deployments={[pruned]} versions={[]}
|
wrap(<CheckpointsTable deployments={[pruned]} versions={[]}
|
||||||
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
|
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
|
||||||
|
expand();
|
||||||
expect(screen.getByText(/archived/i)).toBeInTheDocument();
|
expect(screen.getByText(/archived/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('excludes the currently-running deployment', () => {
|
it('renders nothing when there are no checkpoints', () => {
|
||||||
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
const { container } = wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
||||||
currentDeploymentId="d1" jarRetentionCount={5} onSelect={() => {}} />);
|
currentDeploymentId="d1" jarRetentionCount={5} onSelect={() => {}} />);
|
||||||
expect(screen.queryByText('v6')).toBeNull();
|
expect(container).toBeEmptyDOMElement();
|
||||||
expect(screen.getByText(/no past deployments/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('caps visible rows at jarRetentionCount and shows expander', () => {
|
it('caps visible rows at jarRetentionCount and shows expander', () => {
|
||||||
@@ -70,7 +84,7 @@ describe('CheckpointsTable', () => {
|
|||||||
}));
|
}));
|
||||||
wrap(<CheckpointsTable deployments={many} versions={[v6]}
|
wrap(<CheckpointsTable deployments={many} versions={[v6]}
|
||||||
currentDeploymentId={null} jarRetentionCount={3} onSelect={() => {}} />);
|
currentDeploymentId={null} jarRetentionCount={3} onSelect={() => {}} />);
|
||||||
// 3 visible rows + 1 header row = 4 rows max in the table
|
expand();
|
||||||
expect(screen.getAllByRole('row').length).toBeLessThanOrEqual(4);
|
expect(screen.getAllByRole('row').length).toBeLessThanOrEqual(4);
|
||||||
expect(screen.getByText(/show older \(7\)/i)).toBeInTheDocument();
|
expect(screen.getByText(/show older \(7\)/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -78,6 +92,7 @@ describe('CheckpointsTable', () => {
|
|||||||
it('shows all rows when jarRetentionCount >= total', () => {
|
it('shows all rows when jarRetentionCount >= total', () => {
|
||||||
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
||||||
currentDeploymentId={null} jarRetentionCount={10} onSelect={() => {}} />);
|
currentDeploymentId={null} jarRetentionCount={10} onSelect={() => {}} />);
|
||||||
|
expand();
|
||||||
expect(screen.queryByText(/show older/i)).toBeNull();
|
expect(screen.queryByText(/show older/i)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,6 +102,7 @@ describe('CheckpointsTable', () => {
|
|||||||
}));
|
}));
|
||||||
wrap(<CheckpointsTable deployments={fifteen} versions={[v6]}
|
wrap(<CheckpointsTable deployments={fifteen} versions={[v6]}
|
||||||
currentDeploymentId={null} jarRetentionCount={null} onSelect={() => {}} />);
|
currentDeploymentId={null} jarRetentionCount={null} onSelect={() => {}} />);
|
||||||
|
expand();
|
||||||
expect(screen.getByText(/show older \(5\)/i)).toBeInTheDocument();
|
expect(screen.getByText(/show older \(5\)/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export function CheckpointsTable({
|
|||||||
onSelect,
|
onSelect,
|
||||||
}: CheckpointsTableProps) {
|
}: CheckpointsTableProps) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
const versionMap = new Map(versions.map((v) => [v.id, v]));
|
const versionMap = new Map(versions.map((v) => [v.id, v]));
|
||||||
|
|
||||||
const checkpoints = deployments
|
const checkpoints = deployments
|
||||||
@@ -29,7 +30,7 @@ export function CheckpointsTable({
|
|||||||
.sort((a, b) => (b.deployedAt ?? '').localeCompare(a.deployedAt ?? ''));
|
.sort((a, b) => (b.deployedAt ?? '').localeCompare(a.deployedAt ?? ''));
|
||||||
|
|
||||||
if (checkpoints.length === 0) {
|
if (checkpoints.length === 0) {
|
||||||
return <div className={styles.checkpointEmpty}>No past deployments yet.</div>;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cap = jarRetentionCount && jarRetentionCount > 0 ? jarRetentionCount : FALLBACK_CAP;
|
const cap = jarRetentionCount && jarRetentionCount > 0 ? jarRetentionCount : FALLBACK_CAP;
|
||||||
@@ -37,75 +38,90 @@ export function CheckpointsTable({
|
|||||||
const hidden = checkpoints.length - visible.length;
|
const hidden = checkpoints.length - visible.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.checkpointsTable}>
|
<div className={styles.checkpointsSection}>
|
||||||
<table>
|
<button
|
||||||
<thead>
|
type="button"
|
||||||
<tr>
|
className={styles.checkpointsHeader}
|
||||||
<th>Version</th>
|
onClick={() => setOpen((v) => !v)}
|
||||||
<th>JAR</th>
|
aria-expanded={open}
|
||||||
<th>Deployed by</th>
|
>
|
||||||
<th>Deployed</th>
|
<span className={styles.checkpointsChevron}>{open ? '\u25BE' : '\u25B8'}</span>
|
||||||
<th>Strategy</th>
|
<span>Checkpoints</span>
|
||||||
<th>Outcome</th>
|
{' '}
|
||||||
<th aria-label="open"></th>
|
<span className={styles.checkpointsCount}>({checkpoints.length})</span>
|
||||||
</tr>
|
</button>
|
||||||
</thead>
|
{open && (
|
||||||
<tbody>
|
<div className={styles.checkpointsTable}>
|
||||||
{visible.map((d) => {
|
<table>
|
||||||
const v = versionMap.get(d.appVersionId);
|
<thead>
|
||||||
const archived = !v;
|
<tr>
|
||||||
const strategyLabel =
|
<th>Version</th>
|
||||||
d.deploymentStrategy === 'BLUE_GREEN' ? 'blue/green' : 'rolling';
|
<th>JAR</th>
|
||||||
return (
|
<th>Deployed by</th>
|
||||||
<tr
|
<th>Deployed</th>
|
||||||
key={d.id}
|
<th>Strategy</th>
|
||||||
className={archived ? styles.checkpointArchived : undefined}
|
<th>Outcome</th>
|
||||||
onClick={() => onSelect(d.id)}
|
<th aria-label="open"></th>
|
||||||
>
|
|
||||||
<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>
|
</tr>
|
||||||
);
|
</thead>
|
||||||
})}
|
<tbody>
|
||||||
</tbody>
|
{visible.map((d) => {
|
||||||
</table>
|
const v = versionMap.get(d.appVersionId);
|
||||||
{hidden > 0 && !expanded && (
|
const archived = !v;
|
||||||
<button
|
const strategyLabel =
|
||||||
type="button"
|
d.deploymentStrategy === 'BLUE_GREEN' ? 'blue/green' : 'rolling';
|
||||||
className={styles.showOlderBtn}
|
return (
|
||||||
onClick={() => setExpanded(true)}
|
<tr
|
||||||
>
|
key={d.id}
|
||||||
Show older ({hidden}) — archived, postmortem only
|
className={archived ? styles.checkpointArchived : undefined}
|
||||||
</button>
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { ThemeProvider } from '@cameleer/design-system';
|
||||||
|
import { PrimaryActionButton, computeMode } from './PrimaryActionButton';
|
||||||
|
|
||||||
|
function wrap(ui: ReactElement) {
|
||||||
|
return render(<ThemeProvider>{ui}</ThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PrimaryActionButton', () => {
|
||||||
|
it('renders Save in save mode', () => {
|
||||||
|
wrap(<PrimaryActionButton mode="save" enabled onClick={() => {}} />);
|
||||||
|
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Redeploy in redeploy mode', () => {
|
||||||
|
wrap(<PrimaryActionButton mode="redeploy" enabled onClick={() => {}} />);
|
||||||
|
expect(screen.getByRole('button', { name: /redeploy/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Deploying… disabled in deploying mode', () => {
|
||||||
|
wrap(<PrimaryActionButton mode="deploying" enabled={false} onClick={() => {}} />);
|
||||||
|
const btn = screen.getByRole('button', { name: /deploying/i });
|
||||||
|
expect(btn).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Uploading… NN% with progress overlay in uploading mode', () => {
|
||||||
|
wrap(<PrimaryActionButton mode="uploading" enabled={false} progress={42} onClick={() => {}} />);
|
||||||
|
const btn = screen.getByRole('button', { name: /uploading/i });
|
||||||
|
expect(btn).toBeDisabled();
|
||||||
|
expect(btn).toHaveTextContent('42%');
|
||||||
|
const fill = btn.querySelector('[data-upload-fill]') as HTMLElement | null;
|
||||||
|
expect(fill).not.toBeNull();
|
||||||
|
expect(fill!.style.width).toBe('42%');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeMode', () => {
|
||||||
|
it('returns uploading when uploading flag set, even if deploymentInProgress is false', () => {
|
||||||
|
expect(computeMode({ deploymentInProgress: false, uploading: true, hasLocalEdits: true, serverDirtyAgainstDeploy: false })).toBe('uploading');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns deploying when deploymentInProgress even if uploading flag set', () => {
|
||||||
|
expect(computeMode({ deploymentInProgress: true, uploading: true, hasLocalEdits: false, serverDirtyAgainstDeploy: false })).toBe('deploying');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns save when local edits without upload or deploy', () => {
|
||||||
|
expect(computeMode({ deploymentInProgress: false, uploading: false, hasLocalEdits: true, serverDirtyAgainstDeploy: false })).toBe('save');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns redeploy when server dirty and no local edits', () => {
|
||||||
|
expect(computeMode({ deploymentInProgress: false, uploading: false, hasLocalEdits: false, serverDirtyAgainstDeploy: true })).toBe('redeploy');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +1,32 @@
|
|||||||
import { Button } from '@cameleer/design-system';
|
import { Button } from '@cameleer/design-system';
|
||||||
|
import styles from './AppDeploymentPage.module.css';
|
||||||
|
|
||||||
export type PrimaryActionMode = 'save' | 'redeploy' | 'deploying';
|
export type PrimaryActionMode = 'save' | 'redeploy' | 'uploading' | 'deploying';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
mode: PrimaryActionMode;
|
mode: PrimaryActionMode;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
/** Upload percentage 0–100. Only meaningful when mode === 'uploading'. */
|
||||||
|
progress?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PrimaryActionButton({ mode, enabled, onClick }: Props) {
|
export function PrimaryActionButton({ mode, enabled, onClick, progress }: Props) {
|
||||||
|
if (mode === 'uploading') {
|
||||||
|
const pct = Math.max(0, Math.min(100, Math.round(progress ?? 0)));
|
||||||
|
return (
|
||||||
|
<span className={styles.uploadBtnWrap}>
|
||||||
|
<Button size="sm" variant="primary" disabled>
|
||||||
|
<span
|
||||||
|
data-upload-fill
|
||||||
|
className={styles.uploadBtnFill}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
<span className={styles.uploadBtnLabel}>Uploading… {pct}%</span>
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (mode === 'deploying') {
|
if (mode === 'deploying') {
|
||||||
return <Button size="sm" variant="primary" loading disabled>Deploying…</Button>;
|
return <Button size="sm" variant="primary" loading disabled>Deploying…</Button>;
|
||||||
}
|
}
|
||||||
@@ -20,14 +38,17 @@ export function PrimaryActionButton({ mode, enabled, onClick }: Props) {
|
|||||||
|
|
||||||
export function computeMode({
|
export function computeMode({
|
||||||
deploymentInProgress,
|
deploymentInProgress,
|
||||||
|
uploading,
|
||||||
hasLocalEdits,
|
hasLocalEdits,
|
||||||
serverDirtyAgainstDeploy,
|
serverDirtyAgainstDeploy,
|
||||||
}: {
|
}: {
|
||||||
deploymentInProgress: boolean;
|
deploymentInProgress: boolean;
|
||||||
|
uploading: boolean;
|
||||||
hasLocalEdits: boolean;
|
hasLocalEdits: boolean;
|
||||||
serverDirtyAgainstDeploy: boolean;
|
serverDirtyAgainstDeploy: boolean;
|
||||||
}): PrimaryActionMode {
|
}): PrimaryActionMode {
|
||||||
if (deploymentInProgress) return 'deploying';
|
if (deploymentInProgress) return 'deploying';
|
||||||
|
if (uploading) return 'uploading';
|
||||||
if (hasLocalEdits) return 'save';
|
if (hasLocalEdits) return 'save';
|
||||||
if (serverDirtyAgainstDeploy) return 'redeploy';
|
if (serverDirtyAgainstDeploy) return 'redeploy';
|
||||||
return 'save';
|
return 'save';
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export default function AppDeploymentPage() {
|
|||||||
// Local UI state
|
// Local UI state
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [stagedJar, setStagedJar] = useState<File | null>(null);
|
const [stagedJar, setStagedJar] = useState<File | null>(null);
|
||||||
|
const [uploadPct, setUploadPct] = useState<number | null>(null);
|
||||||
const [tab, setTab] = useState<TabKey>('monitoring');
|
const [tab, setTab] = useState<TabKey>('monitoring');
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||||
const [stopTarget, setStopTarget] = useState<string | null>(null);
|
const [stopTarget, setStopTarget] = useState<string | null>(null);
|
||||||
@@ -125,8 +126,10 @@ export default function AppDeploymentPage() {
|
|||||||
// redeploy path).
|
// redeploy path).
|
||||||
const serverDirtyAgainstDeploy = app && dirtyLoading ? false : (dirtyState?.dirty ?? true);
|
const serverDirtyAgainstDeploy = app && dirtyLoading ? false : (dirtyState?.dirty ?? true);
|
||||||
const deploymentInProgress = !!activeDeployment;
|
const deploymentInProgress = !!activeDeployment;
|
||||||
|
const uploading = uploadPct !== null;
|
||||||
const primaryMode = computeMode({
|
const primaryMode = computeMode({
|
||||||
deploymentInProgress,
|
deploymentInProgress,
|
||||||
|
uploading,
|
||||||
hasLocalEdits: dirty.anyLocalEdit,
|
hasLocalEdits: dirty.anyLocalEdit,
|
||||||
serverDirtyAgainstDeploy,
|
serverDirtyAgainstDeploy,
|
||||||
});
|
});
|
||||||
@@ -177,7 +180,17 @@ export default function AppDeploymentPage() {
|
|||||||
|
|
||||||
// 2. Upload JAR if staged
|
// 2. Upload JAR if staged
|
||||||
if (stagedJar) {
|
if (stagedJar) {
|
||||||
await uploadJar.mutateAsync({ envSlug, appSlug: targetApp.slug, file: stagedJar });
|
setUploadPct(0);
|
||||||
|
try {
|
||||||
|
await uploadJar.mutateAsync({
|
||||||
|
envSlug,
|
||||||
|
appSlug: targetApp.slug,
|
||||||
|
file: stagedJar,
|
||||||
|
onProgress: setUploadPct,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setUploadPct(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Save container config
|
// 3. Save container config
|
||||||
@@ -260,8 +273,18 @@ export default function AppDeploymentPage() {
|
|||||||
let versionId: string;
|
let versionId: string;
|
||||||
|
|
||||||
if (stagedJar) {
|
if (stagedJar) {
|
||||||
const newVersion = await uploadJar.mutateAsync({ envSlug, appSlug: app.slug, file: stagedJar });
|
setUploadPct(0);
|
||||||
versionId = newVersion.id;
|
try {
|
||||||
|
const newVersion = await uploadJar.mutateAsync({
|
||||||
|
envSlug,
|
||||||
|
appSlug: app.slug,
|
||||||
|
file: stagedJar,
|
||||||
|
onProgress: setUploadPct,
|
||||||
|
});
|
||||||
|
versionId = newVersion.id;
|
||||||
|
} finally {
|
||||||
|
setUploadPct(null);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (!currentVersion) {
|
if (!currentVersion) {
|
||||||
toast({
|
toast({
|
||||||
@@ -346,6 +369,7 @@ export default function AppDeploymentPage() {
|
|||||||
// ── Primary button enabled logic ───────────────────────────────────
|
// ── Primary button enabled logic ───────────────────────────────────
|
||||||
const primaryEnabled = (() => {
|
const primaryEnabled = (() => {
|
||||||
if (primaryMode === 'deploying') return false;
|
if (primaryMode === 'deploying') return false;
|
||||||
|
if (primaryMode === 'uploading') return false;
|
||||||
if (primaryMode === 'save') return !!name.trim() && (isNetNew || dirty.anyLocalEdit);
|
if (primaryMode === 'save') return !!name.trim() && (isNetNew || dirty.anyLocalEdit);
|
||||||
return true; // redeploy always enabled
|
return true; // redeploy always enabled
|
||||||
})();
|
})();
|
||||||
@@ -389,6 +413,7 @@ export default function AppDeploymentPage() {
|
|||||||
<PrimaryActionButton
|
<PrimaryActionButton
|
||||||
mode={primaryMode}
|
mode={primaryMode}
|
||||||
enabled={primaryEnabled}
|
enabled={primaryEnabled}
|
||||||
|
progress={uploadPct ?? undefined}
|
||||||
onClick={primaryMode === 'redeploy' ? handleRedeploy : handleSave}
|
onClick={primaryMode === 'redeploy' ? handleRedeploy : handleSave}
|
||||||
/>
|
/>
|
||||||
{app && latestDeployment && (
|
{app && latestDeployment && (
|
||||||
|
|||||||
Reference in New Issue
Block a user