feat(ui): startup logs — sort toggle + refresh button + desc default
This commit is contained in:
@@ -61,3 +61,15 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.headerRight {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollWrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|||||||
86
ui/src/components/StartupLogPanel.test.tsx
Normal file
86
ui/src/components/StartupLogPanel.test.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { LogViewer } from '@cameleer/design-system';
|
import { useRef, useState } from 'react';
|
||||||
|
import { LogViewer, Button } from '@cameleer/design-system';
|
||||||
import type { LogEntry } from '@cameleer/design-system';
|
import type { LogEntry } from '@cameleer/design-system';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
import { useStartupLogs } from '../api/queries/logs';
|
import { useStartupLogs } from '../api/queries/logs';
|
||||||
import type { Deployment } from '../api/queries/admin/apps';
|
import type { Deployment } from '../api/queries/admin/apps';
|
||||||
import styles from './StartupLogPanel.module.css';
|
import styles from './StartupLogPanel.module.css';
|
||||||
@@ -14,10 +16,24 @@ interface StartupLogPanelProps {
|
|||||||
export function StartupLogPanel({ deployment, appSlug, envSlug, className }: StartupLogPanelProps) {
|
export function StartupLogPanel({ deployment, appSlug, envSlug, className }: StartupLogPanelProps) {
|
||||||
const isStarting = deployment.status === 'STARTING';
|
const isStarting = deployment.status === 'STARTING';
|
||||||
const isFailed = deployment.status === 'FAILED';
|
const isFailed = deployment.status === 'FAILED';
|
||||||
|
const [sort, setSort] = useState<'asc' | 'desc'>('desc');
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data } = useStartupLogs(appSlug, envSlug, deployment.createdAt, isStarting);
|
const query = useStartupLogs(appSlug, envSlug, deployment.createdAt, isStarting, sort);
|
||||||
|
const entries = query.data?.data ?? [];
|
||||||
|
|
||||||
const entries = 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;
|
if (entries.length === 0 && !isStarting) return null;
|
||||||
|
|
||||||
@@ -36,13 +52,35 @@ export function StartupLogPanel({ deployment, appSlug, envSlug, className }: Sta
|
|||||||
<span className={`${styles.badge} ${styles.badgeStopped}`}>stopped</span>
|
<span className={`${styles.badge} ${styles.badgeStopped}`}>stopped</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={styles.lineCount}>{entries.length} lines</span>
|
<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>
|
||||||
{entries.length > 0 ? (
|
|
||||||
<LogViewer entries={entries as unknown as LogEntry[]} />
|
|
||||||
) : (
|
|
||||||
<div className={styles.empty}>Waiting for container output...</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user