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 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-23 13:25:55 +02:00
parent 9756a20223
commit b0995d84bc
6 changed files with 259 additions and 0 deletions

View File

@@ -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<Parameters<typeof CheckpointDetailDrawer>[0]> = {}) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={qc}>
<ThemeProvider>
<CheckpointDetailDrawer
open
onClose={() => {}}
deployment={baseDep}
version={v}
appSlug="my-app"
envSlug="prod"
onRestore={() => {}}
{...propOverrides}
/>
</ThemeProvider>
</QueryClientProvider>
);
}
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();
});
});

View File

@@ -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 <div style={{ padding: 16, color: 'var(--text-muted)' }}>Config panel implemented in Task 11</div>;
}

View File

@@ -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 (
<div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '8px 0', fontSize: 12 }}>
<label>
Replica:&nbsp;
<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>
</label>
</div>
{logs.items.length === 0 && !logs.isLoading && (
<div style={{ padding: 16, color: 'var(--text-muted)' }}>No logs for this deployment.</div>
)}
{logs.items.map((entry, i) => (
<div key={i} style={{ fontFamily: 'monospace', fontSize: 11, padding: '2px 0' }}>
<span style={{ color: 'var(--text-muted)' }}>{entry.timestamp}</span>{' '}
<span>[{entry.level}]</span>{' '}
<span>{entry.message}</span>
</div>
))}
</div>
);
}

View File

@@ -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<TabId>('logs');
const archived = !version;
const title = (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Badge label={version ? `v${version.version}` : '?'} color="auto" />
<span style={{ fontFamily: 'monospace', fontSize: 13 }}>
{version?.jarFilename ?? 'JAR pruned'}
</span>
<span style={{ fontSize: 11, padding: '2px 8px', borderRadius: 3, background: 'var(--bg-inset)' }}>
{deployment.status}
</span>
</div>
);
const footer = (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
Restoring hydrates the form you'll still need to Redeploy.
</span>
<Button
variant="primary"
disabled={archived}
title={archived ? 'JAR was pruned by the environment retention policy' : undefined}
onClick={() => { onRestore(deployment.id); onClose(); }}
>
Restore this checkpoint
</Button>
</div>
);
return (
<SideDrawer open={open} onClose={onClose} title={title} size="lg" footer={footer}>
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5, marginBottom: 12 }}>
Deployed by <b>{deployment.createdBy ?? ''}</b>
{deployment.deployedAt && <> · {timeAgo(deployment.deployedAt)} ({deployment.deployedAt})</>}
{' · '}Strategy: {deployment.deploymentStrategy === 'BLUE_GREEN' ? 'blue/green' : 'rolling'}
{' · '}{deployment.replicaStates.length} replicas
</div>
<Tabs
active={tab}
onChange={(t) => setTab(t as TabId)}
tabs={[
{ value: 'logs', label: 'Logs' },
{ value: 'config', label: 'Config' },
]}
/>
<div style={{ marginTop: 12 }}>
{tab === 'logs' && (
<LogsPanel deployment={deployment} appSlug={appSlug} envSlug={envSlug} />
)}
{tab === 'config' && (
<ConfigPanel deployment={deployment} appSlug={appSlug} envSlug={envSlug} archived={archived} />
)}
</div>
</SideDrawer>
);
}

View File

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

View File

@@ -0,0 +1,12 @@
import type { Deployment } from '../../../../api/queries/admin/apps';
export function instanceIdsFor(
deployment: Pick<Deployment, 'id' | 'replicaStates'>,
envSlug: string,
appSlug: string,
): string[] {
const generation = deployment.id.replace(/-/g, '').slice(0, 8);
return deployment.replicaStates.map((r) =>
`${envSlug}-${appSlug}-${r.index}-${generation}`
);
}