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`).
|
||||
- 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` (persists desired state without deploying) → `Redeploy` (applies desired state) → `Deploying…` during active deploy.
|
||||
- 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.
|
||||
- Deployment tab: `StatusCard` + `DeploymentProgress` (during STARTING / FAILED) + flex-grow `StartupLogPanel` (no fixed maxHeight) + `HistoryDisclosure`. Auto-activates when a deploy starts.
|
||||
- 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.
|
||||
- Unsaved-change router blocker uses DS `AlertDialog` (not `window.beforeunload`). Env switch intentionally discards edits without warning.
|
||||
|
||||
**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() {
|
||||
const qc = useQueryClient();
|
||||
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 form = new FormData();
|
||||
form.append('file', file);
|
||||
const res = await fetch(
|
||||
`${config.apiBaseUrl}${envBase(envSlug)}/${encodeURIComponent(appSlug)}/versions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
'X-Cameleer-Protocol-Version': '1',
|
||||
},
|
||||
body: form,
|
||||
|
||||
return new Promise<AppVersion>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(
|
||||
'POST',
|
||||
`${config.apiBaseUrl}${envBase(envSlug)}/${encodeURIComponent(appSlug)}/versions`,
|
||||
);
|
||||
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 }) =>
|
||||
qc.invalidateQueries({ queryKey: ['apps', envSlug, appSlug, 'versions'] }),
|
||||
|
||||
@@ -143,13 +143,14 @@ export function useStartupLogs(
|
||||
environment: string | undefined,
|
||||
deployCreatedAt: string | undefined,
|
||||
isStarting: boolean,
|
||||
sort: 'asc' | 'desc' = 'desc',
|
||||
) {
|
||||
const params: LogSearchParams = {
|
||||
application: application || undefined,
|
||||
environment: environment ?? '',
|
||||
source: 'container',
|
||||
from: deployCreatedAt || undefined,
|
||||
sort: 'asc',
|
||||
sort,
|
||||
limit: 500,
|
||||
};
|
||||
|
||||
|
||||
@@ -61,3 +61,15 @@
|
||||
font-size: 13px;
|
||||
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 { RefreshCw } from 'lucide-react';
|
||||
import { useStartupLogs } from '../api/queries/logs';
|
||||
import type { Deployment } from '../api/queries/admin/apps';
|
||||
import styles from './StartupLogPanel.module.css';
|
||||
@@ -14,10 +16,24 @@ interface StartupLogPanelProps {
|
||||
export function StartupLogPanel({ deployment, appSlug, envSlug, className }: StartupLogPanelProps) {
|
||||
const isStarting = deployment.status === 'STARTING';
|
||||
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;
|
||||
|
||||
@@ -36,13 +52,37 @@ export function StartupLogPanel({ deployment, appSlug, envSlug, className }: Sta
|
||||
<span className={`${styles.badge} ${styles.badgeStopped}`}>stopped</span>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,11 +113,6 @@
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.checkpointEmpty {
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Config tab shared */
|
||||
.configInline {
|
||||
display: flex;
|
||||
@@ -379,3 +374,65 @@
|
||||
background: var(--bg-inset);
|
||||
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;
|
||||
}
|
||||
|
||||
.replicaLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.emptyState {
|
||||
padding: 16px;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -60,11 +60,11 @@ describe('CheckpointDetailDrawer', () => {
|
||||
expect(screen.getByText(/alice/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Logs tab is selected by default', () => {
|
||||
it('Config tab is selected by default', () => {
|
||||
renderDrawer();
|
||||
// Tabs from DS may render as buttons or tabs role — be lenient on the query
|
||||
const logsTab = screen.getByText(/^logs$/i);
|
||||
expect(logsTab).toBeInTheDocument();
|
||||
const configTab = screen.getByText(/^config$/i);
|
||||
expect(configTab).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables Restore when JAR is pruned', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Select } from '@cameleer/design-system';
|
||||
import { useInfiniteApplicationLogs } from '../../../../api/queries/logs';
|
||||
import type { Deployment } from '../../../../api/queries/admin/apps';
|
||||
import { instanceIdsFor } from './instance-id';
|
||||
@@ -28,23 +29,24 @@ export function LogsPanel({ deployment, appSlug, envSlug }: Props) {
|
||||
isAtTop: true,
|
||||
});
|
||||
|
||||
const replicaOptions = [
|
||||
{ value: 'all', label: `all (${deployment.replicaStates.length})` },
|
||||
...deployment.replicaStates.map((_, i) => ({ value: String(i), label: String(i) })),
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.filterBar}>
|
||||
<label>
|
||||
Replica:
|
||||
<select
|
||||
<label className={styles.replicaLabel}>
|
||||
<span>Replica:</span>
|
||||
<Select
|
||||
value={String(replicaFilter)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setReplicaFilter(v === 'all' ? 'all' : Number(v));
|
||||
}}
|
||||
>
|
||||
<option value="all">all ({deployment.replicaStates.length})</option>
|
||||
{deployment.replicaStates.map((_, i) => (
|
||||
<option key={i} value={i}>{i}</option>
|
||||
))}
|
||||
</select>
|
||||
options={replicaOptions}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{logs.items.length === 0 && !logs.isLoading && (
|
||||
|
||||
@@ -24,7 +24,7 @@ type TabId = 'logs' | 'config';
|
||||
export function CheckpointDetailDrawer({
|
||||
open, onClose, deployment, version, appSlug, envSlug, onRestore, currentForm,
|
||||
}: Props) {
|
||||
const [tab, setTab] = useState<TabId>('logs');
|
||||
const [tab, setTab] = useState<TabId>('config');
|
||||
const archived = !version;
|
||||
|
||||
const title = (
|
||||
@@ -67,8 +67,8 @@ export function CheckpointDetailDrawer({
|
||||
active={tab}
|
||||
onChange={(t) => setTab(t as TabId)}
|
||||
tabs={[
|
||||
{ value: 'logs', label: 'Logs' },
|
||||
{ value: 'config', label: 'Config' },
|
||||
{ value: 'logs', label: 'Logs' },
|
||||
]}
|
||||
/>
|
||||
<div className={styles.tabContent}>
|
||||
|
||||
@@ -25,10 +25,23 @@ const stoppedDep: Deployment = {
|
||||
deployedConfigSnapshot: { jarVersionId: 'v6id', agentConfig: null, containerConfig: {}, sensitiveKeys: null },
|
||||
};
|
||||
|
||||
function expand() {
|
||||
fireEvent.click(screen.getByRole('button', { name: /checkpoints/i }));
|
||||
}
|
||||
|
||||
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]}
|
||||
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('my-app-1.2.3.jar')).toBeInTheDocument();
|
||||
expect(screen.getByText('alice')).toBeInTheDocument();
|
||||
@@ -38,7 +51,7 @@ describe('CheckpointsTable', () => {
|
||||
const onSelect = vi.fn();
|
||||
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
||||
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')!);
|
||||
expect(onSelect).toHaveBeenCalledWith('d1');
|
||||
});
|
||||
@@ -47,6 +60,7 @@ describe('CheckpointsTable', () => {
|
||||
const noActor = { ...stoppedDep, createdBy: null };
|
||||
wrap(<CheckpointsTable deployments={[noActor]} versions={[v6]}
|
||||
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
|
||||
expand();
|
||||
expect(screen.getByText('—')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -54,14 +68,14 @@ describe('CheckpointsTable', () => {
|
||||
const pruned = { ...stoppedDep, appVersionId: 'unknown' };
|
||||
wrap(<CheckpointsTable deployments={[pruned]} versions={[]}
|
||||
currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
|
||||
expand();
|
||||
expect(screen.getByText(/archived/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('excludes the currently-running deployment', () => {
|
||||
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
||||
it('renders nothing when there are no checkpoints', () => {
|
||||
const { container } = wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
||||
currentDeploymentId="d1" jarRetentionCount={5} onSelect={() => {}} />);
|
||||
expect(screen.queryByText('v6')).toBeNull();
|
||||
expect(screen.getByText(/no past deployments/i)).toBeInTheDocument();
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('caps visible rows at jarRetentionCount and shows expander', () => {
|
||||
@@ -70,7 +84,7 @@ describe('CheckpointsTable', () => {
|
||||
}));
|
||||
wrap(<CheckpointsTable deployments={many} versions={[v6]}
|
||||
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.getByText(/show older \(7\)/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -78,6 +92,7 @@ describe('CheckpointsTable', () => {
|
||||
it('shows all rows when jarRetentionCount >= total', () => {
|
||||
wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
|
||||
currentDeploymentId={null} jarRetentionCount={10} onSelect={() => {}} />);
|
||||
expand();
|
||||
expect(screen.queryByText(/show older/i)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -87,6 +102,7 @@ describe('CheckpointsTable', () => {
|
||||
}));
|
||||
wrap(<CheckpointsTable deployments={fifteen} versions={[v6]}
|
||||
currentDeploymentId={null} jarRetentionCount={null} onSelect={() => {}} />);
|
||||
expand();
|
||||
expect(screen.getByText(/show older \(5\)/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ export function CheckpointsTable({
|
||||
onSelect,
|
||||
}: CheckpointsTableProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const versionMap = new Map(versions.map((v) => [v.id, v]));
|
||||
|
||||
const checkpoints = deployments
|
||||
@@ -29,7 +30,7 @@ export function CheckpointsTable({
|
||||
.sort((a, b) => (b.deployedAt ?? '').localeCompare(a.deployedAt ?? ''));
|
||||
|
||||
if (checkpoints.length === 0) {
|
||||
return <div className={styles.checkpointEmpty}>No past deployments yet.</div>;
|
||||
return null;
|
||||
}
|
||||
|
||||
const cap = jarRetentionCount && jarRetentionCount > 0 ? jarRetentionCount : FALLBACK_CAP;
|
||||
@@ -37,6 +38,19 @@ export function CheckpointsTable({
|
||||
const hidden = checkpoints.length - visible.length;
|
||||
|
||||
return (
|
||||
<div className={styles.checkpointsSection}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.checkpointsHeader}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className={styles.checkpointsChevron}>{open ? '\u25BE' : '\u25B8'}</span>
|
||||
<span>Checkpoints</span>
|
||||
{' '}
|
||||
<span className={styles.checkpointsCount}>({checkpoints.length})</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className={styles.checkpointsTable}>
|
||||
<table>
|
||||
<thead>
|
||||
@@ -108,5 +122,7 @@ export function CheckpointsTable({
|
||||
</button>
|
||||
)}
|
||||
</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 styles from './AppDeploymentPage.module.css';
|
||||
|
||||
export type PrimaryActionMode = 'save' | 'redeploy' | 'deploying';
|
||||
export type PrimaryActionMode = 'save' | 'redeploy' | 'uploading' | 'deploying';
|
||||
|
||||
interface Props {
|
||||
mode: PrimaryActionMode;
|
||||
enabled: boolean;
|
||||
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') {
|
||||
return <Button size="sm" variant="primary" loading disabled>Deploying…</Button>;
|
||||
}
|
||||
@@ -20,14 +38,17 @@ export function PrimaryActionButton({ mode, enabled, onClick }: Props) {
|
||||
|
||||
export function computeMode({
|
||||
deploymentInProgress,
|
||||
uploading,
|
||||
hasLocalEdits,
|
||||
serverDirtyAgainstDeploy,
|
||||
}: {
|
||||
deploymentInProgress: boolean;
|
||||
uploading: boolean;
|
||||
hasLocalEdits: boolean;
|
||||
serverDirtyAgainstDeploy: boolean;
|
||||
}): PrimaryActionMode {
|
||||
if (deploymentInProgress) return 'deploying';
|
||||
if (uploading) return 'uploading';
|
||||
if (hasLocalEdits) return 'save';
|
||||
if (serverDirtyAgainstDeploy) return 'redeploy';
|
||||
return 'save';
|
||||
|
||||
@@ -88,6 +88,7 @@ export default function AppDeploymentPage() {
|
||||
// Local UI state
|
||||
const [name, setName] = useState('');
|
||||
const [stagedJar, setStagedJar] = useState<File | null>(null);
|
||||
const [uploadPct, setUploadPct] = useState<number | null>(null);
|
||||
const [tab, setTab] = useState<TabKey>('monitoring');
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(false);
|
||||
const [stopTarget, setStopTarget] = useState<string | null>(null);
|
||||
@@ -125,8 +126,10 @@ export default function AppDeploymentPage() {
|
||||
// redeploy path).
|
||||
const serverDirtyAgainstDeploy = app && dirtyLoading ? false : (dirtyState?.dirty ?? true);
|
||||
const deploymentInProgress = !!activeDeployment;
|
||||
const uploading = uploadPct !== null;
|
||||
const primaryMode = computeMode({
|
||||
deploymentInProgress,
|
||||
uploading,
|
||||
hasLocalEdits: dirty.anyLocalEdit,
|
||||
serverDirtyAgainstDeploy,
|
||||
});
|
||||
@@ -177,7 +180,17 @@ export default function AppDeploymentPage() {
|
||||
|
||||
// 2. Upload JAR if staged
|
||||
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
|
||||
@@ -260,8 +273,18 @@ export default function AppDeploymentPage() {
|
||||
let versionId: string;
|
||||
|
||||
if (stagedJar) {
|
||||
const newVersion = await uploadJar.mutateAsync({ envSlug, appSlug: app.slug, file: stagedJar });
|
||||
setUploadPct(0);
|
||||
try {
|
||||
const newVersion = await uploadJar.mutateAsync({
|
||||
envSlug,
|
||||
appSlug: app.slug,
|
||||
file: stagedJar,
|
||||
onProgress: setUploadPct,
|
||||
});
|
||||
versionId = newVersion.id;
|
||||
} finally {
|
||||
setUploadPct(null);
|
||||
}
|
||||
} else {
|
||||
if (!currentVersion) {
|
||||
toast({
|
||||
@@ -346,6 +369,7 @@ export default function AppDeploymentPage() {
|
||||
// ── Primary button enabled logic ───────────────────────────────────
|
||||
const primaryEnabled = (() => {
|
||||
if (primaryMode === 'deploying') return false;
|
||||
if (primaryMode === 'uploading') return false;
|
||||
if (primaryMode === 'save') return !!name.trim() && (isNetNew || dirty.anyLocalEdit);
|
||||
return true; // redeploy always enabled
|
||||
})();
|
||||
@@ -389,6 +413,7 @@ export default function AppDeploymentPage() {
|
||||
<PrimaryActionButton
|
||||
mode={primaryMode}
|
||||
enabled={primaryEnabled}
|
||||
progress={uploadPct ?? undefined}
|
||||
onClick={primaryMode === 'redeploy' ? handleRedeploy : handleSave}
|
||||
/>
|
||||
{app && latestDeployment && (
|
||||
|
||||
Reference in New Issue
Block a user