Files
cameleer-server/docs/superpowers/plans/2026-04-23-deployment-page-polish.md
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

47 KiB
Raw Blame History

Deployment Page Polish — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Deliver five targeted UX improvements on the unified app deployment page: JAR-upload progress inside the primary button, sort + refresh on startup logs, a collapsible checkpoints table (default collapsed), a DS-styled replica dropdown, and reversed drawer tab order (Config first).

Architecture: All changes are UI-only, confined to ui/src/pages/AppsTab/AppDeploymentPage/*, ui/src/components/StartupLogPanel.*, ui/src/api/queries/admin/apps.ts (useUploadJar switches to XMLHttpRequest), and ui/src/api/queries/logs.ts (useStartupLogs accepts a sort parameter). No backend or schema changes.

Tech Stack: React 18, TypeScript, Vitest + React Testing Library, @cameleer/design-system components (Button, Select, Tabs, SectionHeader), lucide-react (RefreshCw), TanStack Query.

Spec: docs/superpowers/specs/2026-04-23-deployment-page-polish-design.md


Files touched (summary)

Path Change
ui/src/api/queries/admin/apps.ts useUploadJar rewrites the mutation function to use XMLHttpRequest and exposes an onProgress callback
ui/src/api/queries/logs.ts useStartupLogs gains a 5th sort parameter
ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx Adds 'uploading' mode with a progress-overlay visual
ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css Adds .uploadBtnWrap, .uploadBtnFill, .checkpointsHeader styles
ui/src/pages/AppsTab/AppDeploymentPage/index.tsx Wires uploadPct state and feeds it into both upload call sites + the primary button
ui/src/components/StartupLogPanel.tsx New header layout (sort ↑/↓ + refresh icon), local sort state, scroll-ref
ui/src/components/StartupLogPanel.module.css Extends with .headerRight, .scrollWrap, .ghostIconBtn
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx Collapsible header + useState(false) default
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx Tests updated for collapsed-by-default behavior; stale empty-state assertion removed
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/LogsPanel.tsx Native <select> → DS Select
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx Tab order reversed; default tab 'config'
ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx Updated test for default tab

Commands — run all from ui/ unless stated otherwise

  • Test one file: npx vitest run <relative-path>
  • Test all: npx vitest run
  • Typecheck: npm run typecheck

Task 1: Extend useUploadJar to emit upload progress via XHR

Why: fetch() has no upload-progress event. Switching the mutation function to XMLHttpRequest surfaces upload.onprogress to the caller as a percentage.

Files:

  • Modify: ui/src/api/queries/admin/apps.ts:140-162

  • Step 1.1: Replace the useUploadJar mutation implementation

Open ui/src/api/queries/admin/apps.ts. Replace the existing useUploadJar export (lines 140-162) with the version below.

export function useUploadJar() {
  const qc = useQueryClient();
  return useMutation({
    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);

      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);
      });
    },
    onSuccess: (_data, { envSlug, appSlug }) =>
      qc.invalidateQueries({ queryKey: ['apps', envSlug, appSlug, 'versions'] }),
  });
}
  • Step 1.2: Typecheck

Run: npm run typecheck Expected: PASS (no type errors; the mutation arg type changed but no call sites use the onProgress field yet — field is optional.)

  • Step 1.3: Commit
git add ui/src/api/queries/admin/apps.ts
git commit -m "feat(ui): useUploadJar uses XHR and exposes onProgress"

Task 2: Add 'uploading' mode to PrimaryActionButton

Why: Button is the single locus for the action lifecycle. Adding 'uploading' with a progress-fill overlay lets the user see upload progress without looking elsewhere.

Files:

  • Modify: ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx

  • Modify: ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css

  • Create: ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.test.tsx

  • Step 2.1: Write failing component tests

Create ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.test.tsx:

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: React.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');
  });
});
  • Step 2.2: Run tests — expect failures

Run: npx vitest run src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.test.tsx Expected: FAILS with 'uploading' mode not handled and uploading input missing from computeMode.

  • Step 2.3: Implement PrimaryActionButton updates

Replace the full contents of ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx:

import { Button } from '@cameleer/design-system';
import styles from './AppDeploymentPage.module.css';

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, 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>;
  }
  if (mode === 'redeploy') {
    return <Button size="sm" variant="primary" disabled={!enabled} onClick={onClick}>Redeploy</Button>;
  }
  return <Button size="sm" variant="primary" disabled={!enabled} onClick={onClick}>Save</Button>;
}

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';
}
  • Step 2.4: Add CSS for the upload fill + label stacking

Open ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css. Add the following at the end of the file (before any existing trailing whitespace):

/* 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;
}

If the file does not already end with a trailing newline, add one.

  • Step 2.5: Run tests — expect pass

Run: npx vitest run src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.test.tsx Expected: PASS (all 7 tests).

  • Step 2.6: Typecheck

Run: npm run typecheck Expected: PASS. (This will fail at the AppDeploymentPage callsite because computeMode now requires an uploading input — fixed in Task 3.)

If typecheck fails only with the missing uploading field in index.tsx, that is expected; proceed.

  • Step 2.7: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.tsx \
        ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css \
        ui/src/pages/AppsTab/AppDeploymentPage/PrimaryActionButton.test.tsx
git commit -m "feat(ui): PrimaryActionButton gains uploading mode + progress overlay"

Task 3: Wire upload progress into AppDeploymentPage

Why: Parent holds the uploadPct state, passes onProgress into both upload call sites (Save and Redeploy), and forwards the pct into PrimaryActionButton.

Files:

  • Modify: ui/src/pages/AppsTab/AppDeploymentPage/index.tsx

  • Step 3.1: Add uploadPct state

Find the local UI state block near index.tsx:88-95:

  // Local UI state
  const [name, setName] = useState('');
  const [stagedJar, setStagedJar] = useState<File | null>(null);

Insert immediately after the stagedJar line:

  const [uploadPct, setUploadPct] = useState<number | null>(null);
  • Step 3.2: Update computeMode call

Find (around index.tsx:128-132):

  const primaryMode = computeMode({
    deploymentInProgress,
    hasLocalEdits: dirty.anyLocalEdit,
    serverDirtyAgainstDeploy,
  });

Replace with:

  const uploading = uploadPct !== null;
  const primaryMode = computeMode({
    deploymentInProgress,
    uploading,
    hasLocalEdits: dirty.anyLocalEdit,
    serverDirtyAgainstDeploy,
  });
  • Step 3.3: Pass onProgress into the Save-path upload

Find in handleSave (around index.tsx:179-181):

      if (stagedJar) {
        await uploadJar.mutateAsync({ envSlug, appSlug: targetApp.slug, file: stagedJar });
      }

Replace with:

      if (stagedJar) {
        setUploadPct(0);
        try {
          await uploadJar.mutateAsync({
            envSlug,
            appSlug: targetApp.slug,
            file: stagedJar,
            onProgress: setUploadPct,
          });
        } finally {
          setUploadPct(null);
        }
      }
  • Step 3.4: Pass onProgress into the Redeploy-path upload

Find in handleRedeploy (around index.tsx:262-264):

      if (stagedJar) {
        const newVersion = await uploadJar.mutateAsync({ envSlug, appSlug: app.slug, file: stagedJar });
        versionId = newVersion.id;
      } else {

Replace with:

      if (stagedJar) {
        setUploadPct(0);
        try {
          const newVersion = await uploadJar.mutateAsync({
            envSlug,
            appSlug: app.slug,
            file: stagedJar,
            onProgress: setUploadPct,
          });
          versionId = newVersion.id;
        } finally {
          setUploadPct(null);
        }
      } else {
  • Step 3.5: Forward uploadPct to PrimaryActionButton

Find the <PrimaryActionButton ... /> render (around index.tsx:389-393):

          <PrimaryActionButton
            mode={primaryMode}
            enabled={primaryEnabled}
            onClick={primaryMode === 'redeploy' ? handleRedeploy : handleSave}
          />

Replace with:

          <PrimaryActionButton
            mode={primaryMode}
            enabled={primaryEnabled}
            progress={uploadPct ?? undefined}
            onClick={primaryMode === 'redeploy' ? handleRedeploy : handleSave}
          />
  • Step 3.6: Also disable primary button during upload

Find the primaryEnabled computation (around index.tsx:347-351):

  const primaryEnabled = (() => {
    if (primaryMode === 'deploying') return false;
    if (primaryMode === 'save') return !!name.trim() && (isNetNew || dirty.anyLocalEdit);
    return true; // redeploy always enabled
  })();

Replace with:

  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
  })();
  • Step 3.7: Typecheck

Run: npm run typecheck Expected: PASS.

  • Step 3.8: Run all deployment page tests

Run: npx vitest run src/pages/AppsTab/AppDeploymentPage Expected: Existing tests still pass (CheckpointsTable still has 1 pre-existing failure to fix in Task 5; nothing else should break).

  • Step 3.9: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/index.tsx
git commit -m "feat(ui): wire JAR upload progress into the primary action button"

Task 4: useStartupLogs accepts sort parameter

Why: Sort direction must drive the backend fetch, not just client-side reordering — so the 500-row limit returns the lines closest to the user's interest.

Files:

  • Modify: ui/src/api/queries/logs.ts:141-160

  • Step 4.1: Change the useStartupLogs signature

Open ui/src/api/queries/logs.ts. Replace the useStartupLogs function (lines 141-160) with:

export function useStartupLogs(
  application: string | undefined,
  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,
    limit: 500,
  };

  return useLogs(params, {
    enabled: !!application && !!deployCreatedAt && !!environment,
    refetchInterval: isStarting ? 3_000 : false,
  });
}

Key changes: add 5th parameter sort defaulting to 'desc', pass it through to params.sort.

  • Step 4.2: Typecheck

Run: npm run typecheck Expected: PASS. StartupLogPanel calls useStartupLogs without a 5th arg — safe because sort defaults.

  • Step 4.3: Commit
git add ui/src/api/queries/logs.ts
git commit -m "feat(ui): useStartupLogs accepts sort parameter (default desc)"

Task 5: StartupLogPanel — sort toggle + refresh button + new header

Why: Users need to see the newest line on demand without waiting for the 3s poll. The visual must match the Application Log panel in Runtime.

Files:

  • Modify: ui/src/components/StartupLogPanel.tsx

  • Modify: ui/src/components/StartupLogPanel.module.css

  • Create: ui/src/components/StartupLogPanel.test.tsx

  • Step 5.1: Write failing tests

Create ui/src/components/StartupLogPanel.test.tsx:

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;
    }
  });
});
  • Step 5.2: Run tests — expect failures

Run: npx vitest run src/components/StartupLogPanel.test.tsx Expected: FAIL — current signature doesn't pass sort; no refresh button; no role mapping.

  • Step 5.3: Rewrite StartupLogPanel.tsx

Replace the full contents of ui/src/components/StartupLogPanel.tsx:

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';

interface StartupLogPanelProps {
  deployment: Deployment;
  appSlug: string;
  envSlug: string;
  className?: string;
}

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 query = useStartupLogs(appSlug, envSlug, deployment.createdAt, isStarting, sort);
  const entries = query.data?.data ?? [];

  const scrollToLatest = () => {
    const el = scrollRef.current;
    if (!el) 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;

  return (
    <div className={`${styles.panel}${className ? ` ${className}` : ''}`}>
      <div className={styles.header}>
        <div className={styles.headerLeft}>
          <span className={styles.title}>Startup Logs</span>
          {isStarting && (
            <>
              <span className={`${styles.badge} ${styles.badgeLive}`}> live</span>
              <span className={styles.pollingHint}>polling every 3s</span>
            </>
          )}
          {isFailed && (
            <span className={`${styles.badge} ${styles.badgeStopped}`}>stopped</span>
          )}
        </div>
        <div className={styles.headerRight}>
          <span className={styles.lineCount}>{entries.length} entries</span>
          <Button
            variant="ghost"
            size="sm"
            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"
            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>
  );
}
  • Step 5.4: Extend StartupLogPanel.module.css

Open ui/src/components/StartupLogPanel.module.css. Append the following at the end of the file:

.headerRight {
  display: flex;
  align-items: center;
  gap: 8px;
}

.scrollWrap {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
}

Also modify the existing .panel block — its overflow: hidden remains correct; no change needed. Leave .panel { ... flex-direction: column; min-height: 0; } intact so the new .scrollWrap owns overflow.

  • Step 5.5: Run tests — expect pass

Run: npx vitest run src/components/StartupLogPanel.test.tsx Expected: PASS (4 tests).

  • Step 5.6: Typecheck

Run: npm run typecheck Expected: PASS.

  • Step 5.7: Commit
git add ui/src/components/StartupLogPanel.tsx \
        ui/src/components/StartupLogPanel.module.css \
        ui/src/components/StartupLogPanel.test.tsx
git commit -m "feat(ui): startup logs — sort toggle + refresh button + desc default"

Task 6: Collapsible CheckpointsTable (default collapsed) + fix broken test

Why: History is rarely the user's focus on page load; collapsing reclaims vertical space. Also, a pre-existing test asserts on the removed "No past deployments yet" text — fix it.

Files:

  • Modify: ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx

  • Modify: ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css

  • Modify: ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx

  • Step 6.1: Update the broken test and add collapse tests

Replace the contents of ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx with:

import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider } from '@cameleer/design-system';
import type { ReactNode } from 'react';
import { CheckpointsTable } from './CheckpointsTable';
import type { Deployment, AppVersion } from '../../../api/queries/admin/apps';

function wrap(ui: ReactNode) {
  return render(<ThemeProvider>{ui}</ThemeProvider>);
}

const v6: AppVersion = {
  id: 'v6id', appId: 'a', version: 6, jarPath: '/j', jarChecksum: 'c',
  jarFilename: 'my-app-1.2.3.jar', jarSizeBytes: 1, detectedRuntimeType: null,
  detectedMainClass: null, uploadedAt: '2026-04-23T10:00:00Z',
};

const stoppedDep: Deployment = {
  id: 'd1', appId: 'a', appVersionId: 'v6id', environmentId: 'e',
  status: 'STOPPED', targetState: 'STOPPED', deploymentStrategy: 'BLUE_GREEN',
  replicaStates: [{ index: 0, containerId: 'c', containerName: 'n', status: 'STOPPED' }],
  deployStage: null, containerId: null, containerName: null, errorMessage: null,
  deployedAt: '2026-04-23T10:35:00Z', stoppedAt: '2026-04-23T10:52:00Z',
  createdAt: '2026-04-23T10:35:00Z', createdBy: 'alice',
  deployedConfigSnapshot: { jarVersionId: 'v6id', agentConfig: null, containerConfig: {}, sensitiveKeys: null },
};

function expand() {
  fireEvent.click(screen.getByRole('button', { name: /checkpoints/i }));
}

describe('CheckpointsTable', () => {
  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();
  });

  it('row click invokes onSelect with deploymentId', () => {
    const onSelect = vi.fn();
    wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={5} onSelect={onSelect} />);
    expand();
    fireEvent.click(screen.getByText('v6').closest('tr')!);
    expect(onSelect).toHaveBeenCalledWith('d1');
  });

  it('renders em-dash for null createdBy', () => {
    const noActor = { ...stoppedDep, createdBy: null };
    wrap(<CheckpointsTable deployments={[noActor]} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
    expand();
    expect(screen.getByText('—')).toBeInTheDocument();
  });

  it('marks pruned-JAR rows as archived', () => {
    const pruned = { ...stoppedDep, appVersionId: 'unknown' };
    wrap(<CheckpointsTable deployments={[pruned]} versions={[]}
      currentDeploymentId={null} jarRetentionCount={5} onSelect={() => {}} />);
    expand();
    expect(screen.getByText(/archived/i)).toBeInTheDocument();
  });

  it('renders nothing when there are no checkpoints', () => {
    const { container } = wrap(<CheckpointsTable deployments={[stoppedDep]} versions={[v6]}
      currentDeploymentId="d1" jarRetentionCount={5} onSelect={() => {}} />);
    expect(container).toBeEmptyDOMElement();
  });

  it('caps visible rows at jarRetentionCount and shows expander', () => {
    const many: Deployment[] = Array.from({ length: 10 }, (_, i) => ({
      ...stoppedDep, id: `d${i}`, deployedAt: `2026-04-23T10:${String(10 + i).padStart(2, '0')}:00Z`,
    }));
    wrap(<CheckpointsTable deployments={many} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={3} onSelect={() => {}} />);
    expand();
    // 3 visible rows + 1 header row = 4 rows max in the table
    expect(screen.getAllByRole('row').length).toBeLessThanOrEqual(4);
    expect(screen.getByText(/show older \(7\)/i)).toBeInTheDocument();
  });

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

  it('falls back to default cap of 10 when jarRetentionCount is 0 or null', () => {
    const fifteen: Deployment[] = Array.from({ length: 15 }, (_, i) => ({
      ...stoppedDep, id: `d${i}`, deployedAt: `2026-04-23T10:${String(10 + i).padStart(2, '0')}:00Z`,
    }));
    wrap(<CheckpointsTable deployments={fifteen} versions={[v6]}
      currentDeploymentId={null} jarRetentionCount={null} onSelect={() => {}} />);
    expand();
    expect(screen.getByText(/show older \(5\)/i)).toBeInTheDocument();
  });
});
  • Step 6.2: Run tests — expect failures

Run: npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx Expected: FAIL — no collapse header button exists yet.

  • Step 6.3: Rewrite CheckpointsTable.tsx with collapse

Replace the full contents of ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx:

import { useState } from 'react';
import { Badge } from '@cameleer/design-system';
import type { Deployment, AppVersion } from '../../../api/queries/admin/apps';
import { timeAgo } from '../../../utils/format-utils';
import styles from './AppDeploymentPage.module.css';

const FALLBACK_CAP = 10;

interface CheckpointsTableProps {
  deployments: Deployment[];
  versions: AppVersion[];
  currentDeploymentId: string | null;
  jarRetentionCount: number | null;
  onSelect: (deploymentId: string) => void;
}

export function CheckpointsTable({
  deployments,
  versions,
  currentDeploymentId,
  jarRetentionCount,
  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
    .filter((d) => d.deployedConfigSnapshot && d.id !== currentDeploymentId)
    .sort((a, b) => (b.deployedAt ?? '').localeCompare(a.deployedAt ?? ''));

  if (checkpoints.length === 0) {
    return null;
  }

  const cap = jarRetentionCount && jarRetentionCount > 0 ? jarRetentionCount : FALLBACK_CAP;
  const visible = expanded ? checkpoints : checkpoints.slice(0, cap);
  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>
              <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>
                  </tr>
                );
              })}
            </tbody>
          </table>
          {hidden > 0 && !expanded && (
            <button
              type="button"
              className={styles.showOlderBtn}
              onClick={() => setExpanded(true)}
            >
              Show older ({hidden})  archived, postmortem only
            </button>
          )}
        </div>
      )}
    </div>
  );
}
  • Step 6.4: Add CSS for the collapsible header

Open ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css. Append:

/* 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;
}
  • Step 6.5: Run tests — expect pass

Run: npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx Expected: PASS (9 tests).

  • Step 6.6: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.tsx \
        ui/src/pages/AppsTab/AppDeploymentPage/CheckpointsTable.test.tsx \
        ui/src/pages/AppsTab/AppDeploymentPage/AppDeploymentPage.module.css
git commit -m "feat(ui): checkpoints table collapsible, default collapsed"

Task 7: Replica dropdown uses DS Select

Why: Native <select> clashes with the rest of the drawer's DS styling.

Files:

  • Modify: ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/LogsPanel.tsx

  • Step 7.1: Replace native select with DS Select

Open ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/LogsPanel.tsx. Replace the whole file contents with:

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';
import styles from './CheckpointDetailDrawer.module.css';

interface Props {
  deployment: Deployment;
  appSlug: string;
  envSlug: string;
}

export function LogsPanel({ deployment, appSlug, envSlug }: Props) {
  const allInstanceIds = useMemo(
    () => instanceIdsFor(deployment, envSlug, appSlug),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [deployment.id, deployment.replicaStates, envSlug, appSlug]
  );

  const [replicaFilter, setReplicaFilter] = useState<'all' | number>('all');
  const filteredInstanceIds = replicaFilter === 'all'
    ? allInstanceIds
    : allInstanceIds.filter((_, i) => i === replicaFilter);

  const logs = useInfiniteApplicationLogs({
    application: appSlug,
    instanceIds: filteredInstanceIds,
    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 className={styles.replicaLabel}>
          <span>Replica:</span>
          <Select
            value={String(replicaFilter)}
            onChange={(e) => {
              const v = e.target.value;
              setReplicaFilter(v === 'all' ? 'all' : Number(v));
            }}
            options={replicaOptions}
          />
        </label>
      </div>
      {logs.items.length === 0 && !logs.isLoading && (
        <div className={styles.emptyState}>No logs for this deployment.</div>
      )}
      {logs.items.map((entry, i) => (
        <div key={i} className={styles.logRow}>
          <span className={styles.logTimestamp}>{entry.timestamp}</span>{' '}
          <span>[{entry.level}]</span>{' '}
          <span>{entry.message}</span>
        </div>
      ))}
    </div>
  );
}
  • Step 7.2: Ensure .replicaLabel has adequate spacing

Check ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.module.css. If it does not already contain a .replicaLabel rule, append:

.replicaLabel {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}
  • Step 7.3: Typecheck

Run: npm run typecheck Expected: PASS.

  • Step 7.4: Run existing drawer tests to confirm nothing regressed

Run: npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer Expected: PASS.

  • Step 7.5: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/LogsPanel.tsx \
        ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.module.css
git commit -m "refactor(ui): drawer replica filter uses DS Select"

Task 8: Drawer tab order (Config first, default Config)

Why: Config is the restore-decision content. Logs is post-mortem supporting material.

Files:

  • Modify: ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx

  • Modify (likely): ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx

  • Step 8.1: Inspect the drawer test

Run: npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx Note any test that relies on "logs" being the default tab — these will need updates after the code change in 8.2.

  • Step 8.2: Change default tab + reorder tab array

Open ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx. Change line 27:

  const [tab, setTab] = useState<TabId>('logs');

to:

  const [tab, setTab] = useState<TabId>('config');

And the tabs array (lines 69-72):

        tabs={[
          { value: 'logs', label: 'Logs' },
          { value: 'config', label: 'Config' },
        ]}

to:

        tabs={[
          { value: 'config', label: 'Config' },
          { value: 'logs', label: 'Logs' },
        ]}
  • Step 8.3: Fix any failing drawer tests

Re-run: npx vitest run src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx

If a test fails because it expects the Logs tab content to be visible on open, update that test to either:

  1. Click the Logs tab first, then assert on log content; or
  2. Assert on Config content for the default-open case.

The exact edit depends on what the existing tests assert — make the narrowest change that preserves the original test intent given the new default.

  • Step 8.4: Typecheck

Run: npm run typecheck Expected: PASS.

  • Step 8.5: Commit
git add ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx \
        ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx
git commit -m "ui(drawer): reorder tabs Config first, default to Config"

Task 9: Final verification + rules update

Why: Confirm the full test suite is green, and update .claude/rules/ui.md to reflect the new behavior a future session will need to know.

Files:

  • Modify: .claude/rules/ui.md

  • Step 9.1: Run the entire UI test suite

Run: npx vitest run Expected: PASS (all tests).

  • Step 9.2: Typecheck once more

Run: npm run typecheck Expected: PASS.

  • Step 9.3: Update .claude/rules/ui.md deployment-page section

Open .claude/rules/ui.md. Locate the Deployments bullet (currently starts with "Deployments — unified app deployment page"). Update the sub-bullet for CheckpointsTable to mention:

  • "Checkpoints render as a collapsible CheckpointsTable, default collapsed; header row Checkpoints (N) ▸/▾ toggles."

Update or add sub-bullet for PrimaryActionButton:

  • "Primary button state machine: saveuploading (during JAR upload, shows Uploading… N% with tinted fill overlay) → deploying during active deploy. Upload progress sourced from useUploadJar (XHR upload.onprogress)."

Update or add sub-bullet for StartupLogPanel:

  • "StartupLogPanel header mirrors the Runtime Application Log: 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. Refresh scrolls to the latest line (top for desc, bottom for asc)."

Update or add the CheckpointDetailDrawer sub-bullet:

  • "CheckpointDetailDrawer tabs: Config | Logs. Default tab Config. Replica filter uses DS Select."

Exact wording may be adapted to fit the file's existing voice and structure — but each of the four facts above must appear.

  • Step 9.4: Commit rules update
git add .claude/rules/ui.md
git commit -m "docs(rules): reflect deployment page polish changes"
  • Step 9.5: Run GitNexus re-index

Run (from repo root): npx gitnexus analyze --embeddings Expected: Indexed successfully.

  • Step 9.6: Manual smoke (human-run, document in PR)

Start the dev server (npm run dev in ui/ — backend must be running on :8081). Exercise:

  • Save with staged JAR: upload progress fills the Save button, returns to post-save state.
  • Redeploy with staged JAR: upload progress fills → Deploying… → back to Redeploy.
  • Redeploy without staged JAR: button goes straight to Deploying….
  • Upload failure: kill backend mid-upload → error toast; button returns to pre-click state.
  • Startup logs: flip sort; latest entry visible in both directions; click refresh.
  • Checkpoints: collapsed on load; open → rows visible; empty app shows nothing.
  • Drawer: row-click opens on Config; Replica filter is DS-styled.

Self-review

Spec coverage (against docs/superpowers/specs/2026-04-23-deployment-page-polish-design.md):

  • §2.1 Upload progress in button → Tasks 1, 2, 3 ✓
  • §2.2 Startup log sort + refresh → Tasks 4, 5 ✓
  • §2.3 Checkpoints collapsible default collapsed → Task 6 ✓
  • §2.4 Replica dropdown uses DS Select → Task 7 ✓
  • §2.5 Drawer tabs Config first, default Config → Task 8 ✓
  • §5 Non-goals (backend untouched; empty-checkpoints already shipped) — respected ✓

Placeholder scan: No TBDs/TODOs/"implement later" placeholders. Step 8.3 is deliberately conditional because the test content is unknown at plan-write time; the instruction provides concrete branching guidance rather than a placeholder.

Type consistency:

  • PrimaryActionMode spelled the same in every task.
  • computeMode signature: both the test (Task 2) and the call site (Task 3) use { deploymentInProgress, uploading, hasLocalEdits, serverDirtyAgainstDeploy }.
  • useUploadJar mutation arg type: onProgress?: (pct: number) => void — used identically in Tasks 1 and 3.
  • useStartupLogs signature: 5th param named sort, defaults to 'desc' — consistent between Tasks 4 and 5.
  • CSS class names (uploadBtnWrap, uploadBtnFill, uploadBtnLabel, checkpointsSection, checkpointsHeader, checkpointsChevron, checkpointsCount, scrollWrap, headerRight, replicaLabel) — each defined once and referenced from the matching TSX.

No gaps or inconsistencies.