From b0995d84bc551e7cc6315cceabf1ed0824a59547 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Thu, 23 Apr 2026 13:25:55 +0200 Subject: [PATCH] feat(ui): CheckpointDetailDrawer container + LogsPanel Adds the CheckpointDetailDrawer with Logs/Config tabs. LogsPanel scopes logs to a deployment's replicas via instanceIds derived from replicaStates + generation suffix. Stub ConfigPanel placeholder for Task 11. Co-Authored-By: Claude Sonnet 4.6 --- .../CheckpointDetailDrawer.test.tsx | 74 +++++++++++++++++ .../CheckpointDetailDrawer/ConfigPanel.tsx | 13 +++ .../CheckpointDetailDrawer/LogsPanel.tsx | 60 ++++++++++++++ .../CheckpointDetailDrawer/index.tsx | 81 +++++++++++++++++++ .../instance-id.test.ts | 19 +++++ .../CheckpointDetailDrawer/instance-id.ts | 12 +++ 6 files changed, 259 insertions(+) create mode 100644 ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx create mode 100644 ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.tsx create mode 100644 ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/LogsPanel.tsx create mode 100644 ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx create mode 100644 ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/instance-id.test.ts create mode 100644 ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/instance-id.ts diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx new file mode 100644 index 00000000..17d8be95 --- /dev/null +++ b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/CheckpointDetailDrawer.test.tsx @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ThemeProvider } from '@cameleer/design-system'; +import { CheckpointDetailDrawer } from './index'; + +// Mock the logs hook so the test doesn't try to fetch +vi.mock('../../../../api/queries/logs', () => ({ + useInfiniteApplicationLogs: () => ({ items: [], isLoading: false, hasNextPage: false, fetchNextPage: vi.fn(), isFetchingNextPage: false, refresh: vi.fn() }), +})); + +const baseDep: any = { + id: 'aaa11111-2222-3333-4444-555555555555', + 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 }, +}; + +const v: any = { + 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', +}; + +function renderDrawer(propOverrides: Partial[0]> = {}) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + {}} + deployment={baseDep} + version={v} + appSlug="my-app" + envSlug="prod" + onRestore={() => {}} + {...propOverrides} + /> + + + ); +} + +describe('CheckpointDetailDrawer', () => { + it('renders header with version + jar + status', () => { + renderDrawer(); + expect(screen.getByText('v6')).toBeInTheDocument(); + expect(screen.getByText('my-app-1.2.3.jar')).toBeInTheDocument(); + expect(screen.getByText('STOPPED')).toBeInTheDocument(); + }); + + it('renders meta line with createdBy', () => { + renderDrawer(); + expect(screen.getByText(/alice/)).toBeInTheDocument(); + }); + + it('Logs 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(); + }); + + it('disables Restore when JAR is pruned', () => { + renderDrawer({ version: undefined }); + expect(screen.getByRole('button', { name: /restore/i })).toBeDisabled(); + }); +}); diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.tsx new file mode 100644 index 00000000..77596d5a --- /dev/null +++ b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/ConfigPanel.tsx @@ -0,0 +1,13 @@ +// ConfigPanel.tsx — stub for Task 10; Task 11 implements snapshot/diff modes +import type { Deployment } from '../../../../api/queries/admin/apps'; + +interface Props { + deployment: Deployment; + appSlug: string; + envSlug: string; + archived: boolean; +} + +export function ConfigPanel(_: Props) { + return
Config panel — implemented in Task 11
; +} diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/LogsPanel.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/LogsPanel.tsx new file mode 100644 index 00000000..d6058adf --- /dev/null +++ b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/LogsPanel.tsx @@ -0,0 +1,60 @@ +import { useMemo, useState } from 'react'; +import { useInfiniteApplicationLogs } from '../../../../api/queries/logs'; +import type { Deployment } from '../../../../api/queries/admin/apps'; +import { instanceIdsFor } from './instance-id'; + +interface Props { + deployment: Deployment; + appSlug: string; + envSlug: string; +} + +export function LogsPanel({ deployment, appSlug, envSlug }: Props) { + const allInstanceIds = useMemo( + () => instanceIdsFor(deployment, envSlug, appSlug), + [deployment, 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, + }); + + return ( +
+
+ +
+ {logs.items.length === 0 && !logs.isLoading && ( +
No logs for this deployment.
+ )} + {logs.items.map((entry, i) => ( +
+ {entry.timestamp}{' '} + [{entry.level}]{' '} + {entry.message} +
+ ))} +
+ ); +} diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx new file mode 100644 index 00000000..d0ba3b50 --- /dev/null +++ b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/index.tsx @@ -0,0 +1,81 @@ +import { useState } from 'react'; +import { SideDrawer } from '../../../../components/SideDrawer'; +import { Tabs, Button, Badge } from '@cameleer/design-system'; +import type { Deployment, AppVersion } from '../../../../api/queries/admin/apps'; +import { LogsPanel } from './LogsPanel'; +import { ConfigPanel } from './ConfigPanel'; +import { timeAgo } from '../../../../utils/format-utils'; + +interface Props { + open: boolean; + onClose: () => void; + deployment: Deployment; + version?: AppVersion; + appSlug: string; + envSlug: string; + onRestore: (deploymentId: string) => void; +} + +type TabId = 'logs' | 'config'; + +export function CheckpointDetailDrawer({ + open, onClose, deployment, version, appSlug, envSlug, onRestore, +}: Props) { + const [tab, setTab] = useState('logs'); + const archived = !version; + + const title = ( +
+ + + {version?.jarFilename ?? 'JAR pruned'} + + + {deployment.status} + +
+ ); + + const footer = ( +
+ + Restoring hydrates the form — you'll still need to Redeploy. + + +
+ ); + + return ( + +
+ Deployed by {deployment.createdBy ?? '—'} + {deployment.deployedAt && <> · {timeAgo(deployment.deployedAt)} ({deployment.deployedAt})} + {' · '}Strategy: {deployment.deploymentStrategy === 'BLUE_GREEN' ? 'blue/green' : 'rolling'} + {' · '}{deployment.replicaStates.length} replicas +
+ setTab(t as TabId)} + tabs={[ + { value: 'logs', label: 'Logs' }, + { value: 'config', label: 'Config' }, + ]} + /> +
+ {tab === 'logs' && ( + + )} + {tab === 'config' && ( + + )} +
+
+ ); +} diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/instance-id.test.ts b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/instance-id.test.ts new file mode 100644 index 00000000..fd680eb1 --- /dev/null +++ b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/instance-id.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { instanceIdsFor } from './instance-id'; + +describe('instanceIdsFor', () => { + it('derives N instance_ids from replicaStates + deployment id', () => { + expect(instanceIdsFor({ + id: 'aaa11111-2222-3333-4444-555555555555', + replicaStates: [{ index: 0 }, { index: 1 }, { index: 2 }], + } as any, 'prod', 'my-app')).toEqual([ + 'prod-my-app-0-aaa11111', + 'prod-my-app-1-aaa11111', + 'prod-my-app-2-aaa11111', + ]); + }); + + it('returns empty array when no replicas', () => { + expect(instanceIdsFor({ id: 'x', replicaStates: [] } as any, 'e', 'a')).toEqual([]); + }); +}); diff --git a/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/instance-id.ts b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/instance-id.ts new file mode 100644 index 00000000..7cfb4649 --- /dev/null +++ b/ui/src/pages/AppsTab/AppDeploymentPage/CheckpointDetailDrawer/instance-id.ts @@ -0,0 +1,12 @@ +import type { Deployment } from '../../../../api/queries/admin/apps'; + +export function instanceIdsFor( + deployment: Pick, + envSlug: string, + appSlug: string, +): string[] { + const generation = deployment.id.replace(/-/g, '').slice(0, 8); + return deployment.replicaStates.map((r) => + `${envSlug}-${appSlug}-${r.index}-${generation}` + ); +}