diff --git a/ui/src/components/StartupLogPanel.module.css b/ui/src/components/StartupLogPanel.module.css index 4551ec5b..529a3a47 100644 --- a/ui/src/components/StartupLogPanel.module.css +++ b/ui/src/components/StartupLogPanel.module.css @@ -61,3 +61,15 @@ font-size: 13px; color: var(--text-muted); } + +.headerRight { + display: flex; + align-items: center; + gap: 8px; +} + +.scrollWrap { + flex: 1; + min-height: 0; + overflow-y: auto; +} diff --git a/ui/src/components/StartupLogPanel.test.tsx b/ui/src/components/StartupLogPanel.test.tsx new file mode 100644 index 00000000..8f679858 --- /dev/null +++ b/ui/src/components/StartupLogPanel.test.tsx @@ -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( + + {ui} + , + ); +} + +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); + wrap(); + 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); + wrap(); + 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); + wrap(); + 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); + const scrollTo = vi.fn(); + const origScrollTo = Element.prototype.scrollTo; + Element.prototype.scrollTo = scrollTo as unknown as typeof Element.prototype.scrollTo; + try { + wrap(); + 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; + } + }); +}); diff --git a/ui/src/components/StartupLogPanel.tsx b/ui/src/components/StartupLogPanel.tsx index 27e156ce..cc71b95e 100644 --- a/ui/src/components/StartupLogPanel.tsx +++ b/ui/src/components/StartupLogPanel.tsx @@ -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 { RefreshCw } from 'lucide-react'; import { useStartupLogs } from '../api/queries/logs'; import type { Deployment } from '../api/queries/admin/apps'; import styles from './StartupLogPanel.module.css'; @@ -14,10 +16,24 @@ interface StartupLogPanelProps { export function StartupLogPanel({ deployment, appSlug, envSlug, className }: StartupLogPanelProps) { const isStarting = deployment.status === 'STARTING'; const isFailed = deployment.status === 'FAILED'; + const [sort, setSort] = useState<'asc' | 'desc'>('desc'); + const scrollRef = useRef(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; @@ -36,13 +52,35 @@ export function StartupLogPanel({ deployment, appSlug, envSlug, className }: Sta stopped )} - {entries.length} lines +
+ {entries.length} entries + + +
+ +
+ {entries.length > 0 ? ( + + ) : ( +
Waiting for container output...
+ )}
- {entries.length > 0 ? ( - - ) : ( -
Waiting for container output...
- )} ); }