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