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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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:
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user