7f02d20e54
Replace the legacy AngularJS <log-viewer> on the container logs page with a modern React log viewer, reusing the existing streaming (#6) and formatting/ coloring pipeline. Features: line-number gutter, zerolog level + key=value coloring (from the existing formatter spans), from/to datetime range, Lines limit, Line numbers / Timestamp / Wrap lines toggles, Auto refresh (live tail on/off), Search + 'Filter search results', Copy, Download logs, and fullscreen. The viewer is source-agnostic (StreamLogsFn), so service/task logs can adopt it later; this PR wires container logs only. containerLogsController.js no longer opens its own live stream (React owns fetching now), preventing a double stream. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
109 lines
3.4 KiB
TypeScript
109 lines
3.4 KiB
TypeScript
import { waitFor } from '@testing-library/react';
|
|
import { act, renderHook } from '@testing-library/react-hooks';
|
|
|
|
import { StreamLogsParams } from '@/react/docker/containers/containers.service';
|
|
|
|
import { StreamLogsFn } from './types';
|
|
import { useLogViewer } from './useLogViewer';
|
|
|
|
const encoder = new TextEncoder();
|
|
|
|
describe('useLogViewer', () => {
|
|
it('fetches a single bounded snapshot (no follow, no reconnect) when auto refresh is off', async () => {
|
|
let callCount = 0;
|
|
let capturedParams: StreamLogsParams | undefined;
|
|
|
|
// A snapshot source: emits two complete lines plus a trailing partial line
|
|
// (no newline) then resolves — mirroring a non-following body that ends.
|
|
// eslint-disable-next-line func-style
|
|
const streamLogs: StreamLogsFn = (params, onChunk) => {
|
|
callCount += 1;
|
|
capturedParams = params;
|
|
onChunk(encoder.encode('first\nsecond\ntrailing-partial'));
|
|
return Promise.resolve();
|
|
};
|
|
|
|
const { result } = renderHook(() =>
|
|
useLogViewer({
|
|
streamLogs,
|
|
resourceId: 'container-1',
|
|
stripHeaders: false,
|
|
autoRefresh: false,
|
|
withTimestamps: false,
|
|
lineCount: 100,
|
|
since: new Date('2020-01-01T00:00:00Z'),
|
|
until: new Date('2020-01-02T00:00:00Z'),
|
|
reloadNonce: 0,
|
|
})
|
|
);
|
|
|
|
// The trailing partial line is flushed when the body ends, so all three
|
|
// lines are shown.
|
|
await waitFor(() => {
|
|
expect(result.current.logs).toHaveLength(3);
|
|
});
|
|
expect(result.current.logs.map((l) => l.line)).toEqual([
|
|
'first',
|
|
'second',
|
|
'trailing-partial',
|
|
]);
|
|
|
|
// follow off, until bound applied, and no reconnect (called exactly once).
|
|
expect(capturedParams?.follow).toBe(false);
|
|
expect(capturedParams?.until).toBe(
|
|
Math.floor(new Date('2020-01-02T00:00:00Z').getTime() / 1000)
|
|
);
|
|
expect(callCount).toBe(1);
|
|
});
|
|
|
|
it('clears the error banner when a following stream recovers on reconnect', async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
let call = 0;
|
|
// First connect rejects (surfaces the banner); the reconnect succeeds and
|
|
// delivers a line (should clear the banner).
|
|
// eslint-disable-next-line func-style
|
|
const streamLogs: StreamLogsFn = (_params, onChunk) => {
|
|
call += 1;
|
|
if (call === 1) {
|
|
return Promise.reject(new Error('boom'));
|
|
}
|
|
onChunk(encoder.encode('recovered\n'));
|
|
// stay open so no further reconnect fires during the test
|
|
return new Promise(() => {});
|
|
};
|
|
|
|
const { result } = renderHook(() =>
|
|
useLogViewer({
|
|
streamLogs,
|
|
resourceId: 'container-1',
|
|
stripHeaders: false,
|
|
autoRefresh: true,
|
|
withTimestamps: false,
|
|
lineCount: 100,
|
|
since: null,
|
|
until: null,
|
|
reloadNonce: 0,
|
|
})
|
|
);
|
|
|
|
// Let the initial rejection settle -> banner shows.
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
});
|
|
expect(result.current.error).toBe('boom');
|
|
|
|
// Advance past the 3s reconnect delay + the 250ms flush window.
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(3300);
|
|
});
|
|
|
|
expect(result.current.error).toBeUndefined();
|
|
expect(result.current.logs.map((l) => l.line)).toContain('recovered');
|
|
expect(call).toBe(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
});
|