Compare commits

...

13 Commits

Author SHA1 Message Date
hsiegeln
b6239bdb6b docs(rules): reflect deployment page polish (upload-in-button, sort/refresh, collapsible checkpoints, DS Select, tab reorder)
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 1m21s
CI / docker (push) Successful in 1m8s
CI / deploy-feature (push) Has been skipped
CI / deploy (push) Successful in 38s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 16:16:52 +02:00
hsiegeln
0ae27ad9ed ui(drawer): reorder tabs Config first, default to Config 2026-04-23 16:15:29 +02:00
hsiegeln
e00848dc65 refactor(ui): drawer replica filter uses DS Select 2026-04-23 16:13:54 +02:00
hsiegeln
f31975e0ef feat(ui): checkpoints table collapsible, default collapsed 2026-04-23 16:09:28 +02:00
hsiegeln
2c0cf7dc9c fix(ui): StartupLogPanel — defensive scrollTo + disable buttons while fetching 2026-04-23 16:05:35 +02:00
hsiegeln
fb7b15f539 feat(ui): startup logs — sort toggle + refresh button + desc default 2026-04-23 16:00:44 +02:00
hsiegeln
1d7009d69c feat(ui): useStartupLogs accepts sort parameter (default desc) 2026-04-23 15:58:02 +02:00
hsiegeln
99a91a57be feat(ui): wire JAR upload progress into the primary action button 2026-04-23 15:54:23 +02:00
hsiegeln
427988bcc8 feat(ui): PrimaryActionButton gains uploading mode + progress overlay 2026-04-23 15:49:27 +02:00
hsiegeln
a208f2eec7 feat(ui): useUploadJar uses XHR and exposes onProgress 2026-04-23 15:44:50 +02:00
hsiegeln
13f218d522 docs(plan): deployment page polish (9 TDD tasks)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:42:06 +02:00
hsiegeln
900fba5af6 docs(spec): deployment page polish (upload-in-button, sort/refresh, collapsible checkpoints, DS Select, tab reorder)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:36:57 +02:00
hsiegeln
b3d1dd377d ui(deploy): hide CheckpointsTable when no past deployments exist
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 14:34:09 +02:00
18 changed files with 2036 additions and 124 deletions

View File

@@ -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/`):

File diff suppressed because it is too large Load Diff

View File

@@ -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 15. #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.

View File

@@ -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'] }),

View File

@@ -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,
};

View File

@@ -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;
}

View 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;
}
});
});

View File

@@ -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>
{entries.length > 0 ? (
<LogViewer entries={entries as unknown as LogEntry[]} />
) : (
<div className={styles.empty}>Waiting for container output...</div>
)}
</div>
);
}

View File

@@ -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;
}

View File

@@ -49,6 +49,12 @@
font-size: 12px;
}
.replicaLabel {
display: inline-flex;
align-items: center;
gap: 8px;
}
.emptyState {
padding: 16px;
color: var(--text-muted);

View File

@@ -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', () => {

View File

@@ -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:&nbsp;
<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 && (

View File

@@ -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}>

View File

@@ -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();
});
});

View File

@@ -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,75 +38,90 @@ export function CheckpointsTable({
const hidden = checkpoints.length - visible.length;
return (
<div className={styles.checkpointsTable}>
<table>
<thead>
<tr>
<th>Version</th>
<th>JAR</th>
<th>Deployed by</th>
<th>Deployed</th>
<th>Strategy</th>
<th>Outcome</th>
<th aria-label="open"></th>
</tr>
</thead>
<tbody>
{visible.map((d) => {
const v = versionMap.get(d.appVersionId);
const archived = !v;
const strategyLabel =
d.deploymentStrategy === 'BLUE_GREEN' ? 'blue/green' : 'rolling';
return (
<tr
key={d.id}
className={archived ? styles.checkpointArchived : undefined}
onClick={() => onSelect(d.id)}
>
<td>
<Badge label={v ? `v${v.version}` : '?'} color="auto" />
</td>
<td className={styles.jarCell}>
{v ? (
<span className={styles.jarName}>{v.jarFilename}</span>
) : (
<>
<span className={styles.jarStrike}>JAR pruned</span>
<div className={styles.archivedHint}>archived JAR pruned</div>
</>
)}
</td>
<td>
{d.createdBy ?? <span className={styles.muted}></span>}
</td>
<td>
{d.deployedAt && timeAgo(d.deployedAt)}
<div className={styles.isoSubline}>{d.deployedAt}</div>
</td>
<td>
<span className={styles.strategyPill}>{strategyLabel}</span>
</td>
<td>
<span
className={`${styles.outcomePill} ${styles[`outcome-${d.status}` as keyof typeof styles] || ''}`}
>
{d.status}
</span>
</td>
<td className={styles.chevron}></td>
<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>
<tr>
<th>Version</th>
<th>JAR</th>
<th>Deployed by</th>
<th>Deployed</th>
<th>Strategy</th>
<th>Outcome</th>
<th aria-label="open"></th>
</tr>
);
})}
</tbody>
</table>
{hidden > 0 && !expanded && (
<button
type="button"
className={styles.showOlderBtn}
onClick={() => setExpanded(true)}
>
Show older ({hidden}) archived, postmortem only
</button>
</thead>
<tbody>
{visible.map((d) => {
const v = versionMap.get(d.appVersionId);
const archived = !v;
const strategyLabel =
d.deploymentStrategy === 'BLUE_GREEN' ? 'blue/green' : 'rolling';
return (
<tr
key={d.id}
className={archived ? styles.checkpointArchived : undefined}
onClick={() => onSelect(d.id)}
>
<td>
<Badge label={v ? `v${v.version}` : '?'} color="auto" />
</td>
<td className={styles.jarCell}>
{v ? (
<span className={styles.jarName}>{v.jarFilename}</span>
) : (
<>
<span className={styles.jarStrike}>JAR pruned</span>
<div className={styles.archivedHint}>archived JAR pruned</div>
</>
)}
</td>
<td>
{d.createdBy ?? <span className={styles.muted}></span>}
</td>
<td>
{d.deployedAt && timeAgo(d.deployedAt)}
<div className={styles.isoSubline}>{d.deployedAt}</div>
</td>
<td>
<span className={styles.strategyPill}>{strategyLabel}</span>
</td>
<td>
<span
className={`${styles.outcomePill} ${styles[`outcome-${d.status}` as keyof typeof styles] || ''}`}
>
{d.status}
</span>
</td>
<td className={styles.chevron}></td>
</tr>
);
})}
</tbody>
</table>
{hidden > 0 && !expanded && (
<button
type="button"
className={styles.showOlderBtn}
onClick={() => setExpanded(true)}
>
Show older ({hidden}) archived, postmortem only
</button>
)}
</div>
)}
</div>
);

View File

@@ -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');
});
});

View File

@@ -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 0100. 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';

View File

@@ -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 });
versionId = newVersion.id;
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 && (