Skip to content

Commit dc7f118

Browse files
authored
feat: adds log explorer component (#2283)
* feat: adds log explorer component * fix: type * fix: converted to react-query for historical logs, and integrated julin work for live mode * fix: lint * fix: fix lint issue after mobx removal * fix: react doctor fixes
1 parent cc77615 commit dc7f118

File tree

14 files changed

+1881
-321
lines changed

14 files changed

+1881
-321
lines changed

frontend/src/components/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const FEATURE_FLAGS = {
1212
enableApiKeyConfigurationAgent: false,
1313
enableDataplaneObservabilityServerless: false,
1414
enableDataplaneObservability: false,
15+
enableNewPipelineLogs: false,
1516
};
1617

1718
// Cloud-managed tag keys for service account integration

frontend/src/components/pages/rp-connect/pipeline/index.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@ import { Spinner } from 'components/redpanda-ui/components/spinner';
2323
import { Tabs, TabsContent, TabsContents, TabsList, TabsTrigger } from 'components/redpanda-ui/components/tabs';
2424
import { Heading } from 'components/redpanda-ui/components/typography';
2525
import { cn } from 'components/redpanda-ui/lib/utils';
26+
import { LogExplorer } from 'components/ui/connect/log-explorer';
2627
import { LintHintList } from 'components/ui/lint-hint/lint-hint-list';
2728
import { YamlEditorCard } from 'components/ui/yaml/yaml-editor-card';
29+
import { isFeatureFlagEnabled, isServerless } from 'config';
2830
import { useDebounce } from 'hooks/use-debounce';
2931
import { useDebouncedValue } from 'hooks/use-debounced-value';
3032
import type { editor } from 'monaco-editor';
@@ -256,7 +258,9 @@ export default function PipelinePage() {
256258

257259
// Derive lint hints from response (replaces useEffect + setState)
258260
const responseLintHints = useMemo(() => {
259-
if (!lintResponse) return {};
261+
if (!lintResponse) {
262+
return {};
263+
}
260264
const hints: Record<string, LintHint> = {};
261265
for (const [idx, hint] of Object.entries(lintResponse.lintHints || [])) {
262266
hints[`hint_${idx}`] = hint;
@@ -554,7 +558,11 @@ export default function PipelinePage() {
554558
<TabsContents>
555559
<TabsContent value="configuration">{content}</TabsContent>
556560
<TabsContent value="logs">
557-
<LogsTab pipeline={pipeline} />
561+
{isFeatureFlagEnabled('enableNewPipelineLogs') ? (
562+
<LogExplorer pipeline={pipeline} serverless={isServerless()} />
563+
) : (
564+
<LogsTab pipeline={pipeline} />
565+
)}
558566
</TabsContent>
559567
</TabsContents>
560568
</Tabs>

frontend/src/components/pages/rp-connect/pipelines-details.tsx

Lines changed: 25 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import { ConnectError } from '@connectrpc/connect';
1313
import { Alert, AlertIcon, Box, Button, createStandaloneToast, DataTable, Flex, SearchField } from '@redpanda-data/ui';
1414
import { Link } from '@tanstack/react-router';
1515
import type { ColumnDef, SortingState } from '@tanstack/react-table';
16+
import { Button as RegistryButton } from 'components/redpanda-ui/components/button';
1617
import { isEmbedded, isFeatureFlagEnabled } from 'config';
18+
import { RefreshCcw } from 'lucide-react';
1719
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
1820
import { toast as sonnerToast } from 'sonner';
1921
import { formatToastErrorMessageGRPC } from 'utils/toast.utils';
@@ -40,6 +42,7 @@ import {
4042
import type { TopicMessage } from '../../../state/rest-interfaces';
4143
import { PartitionOffsetOrigin } from '../../../state/ui';
4244
import { sanitizeString } from '../../../utils/filter-helper';
45+
import { isFilterMatch } from '../../../utils/message-table-helpers';
4346
import { DefaultSkeleton, QuickTable, TimestampDisplay } from '../../../utils/tsx-utils';
4447
import { decodeURIComponentPercents, delay, encodeBase64 } from '../../../utils/utils';
4548
import PageContent from '../../misc/page-content';
@@ -259,7 +262,7 @@ const PipelineEditor = (p: { pipeline: Pipeline }) => {
259262
);
260263
};
261264

262-
export const LogsTab = (p: { pipeline: Pipeline }) => {
265+
export const LogsTab = ({ pipeline, variant = 'card' }: { pipeline: Pipeline; variant?: 'ghost' | 'card' }) => {
263266
const topicName = '__redpanda.connect.logs';
264267
const topic = api.topics?.first((x) => x.topicName === topicName);
265268

@@ -273,18 +276,19 @@ export const LogsTab = (p: { pipeline: Pipeline }) => {
273276
const searchRef = useRef<MessageSearch | null>(null);
274277
const [refreshCount, setRefreshCount] = useState(0);
275278

279+
// biome-ignore lint/correctness/useExhaustiveDependencies: intentional to force message search to re-run when pipeline.id and refreshCount changes
276280
useEffect(() => {
277281
searchRef.current?.stopSearch();
278282
const search = createMessageSearch();
279283
searchRef.current = search;
280284
queueMicrotask(() => setLogState({ messages: [], isComplete: false }));
281-
executeMessageSearch(search, topicName, p.pipeline.id).finally(() => {
285+
executeMessageSearch(search, topicName, pipeline.id).finally(() => {
282286
setLogState({ messages: [...search.messages], isComplete: true });
283287
});
284288
return () => {
285289
search.stopSearch();
286290
};
287-
}, [refreshCount]);
291+
}, [refreshCount, pipeline.id]);
288292

289293
useEffect(() => {
290294
const interval = setInterval(() => {
@@ -320,7 +324,9 @@ export const LogsTab = (p: { pipeline: Pipeline }) => {
320324
if (loadedMessages && loadedMessages.length === 1) {
321325
setLogState((prev) => {
322326
const idx = prev.messages.findIndex((x) => x.partitionID === partitionID && x.offset === offset);
323-
if (idx === -1) return prev;
327+
if (idx === -1) {
328+
return prev;
329+
}
324330
const updated = [...prev.messages];
325331
updated[idx] = loadedMessages[0];
326332
return { ...prev, messages: updated };
@@ -348,11 +354,7 @@ export const LogsTab = (p: { pipeline: Pipeline }) => {
348354
header: 'Value',
349355
accessorKey: 'value',
350356
cell: ({ row: { original } }) => (
351-
<MessagePreview
352-
isCompactTopic={isCompactTopic}
353-
msg={original}
354-
previewFields={() => []}
355-
/>
357+
<MessagePreview isCompactTopic={isCompactTopic} msg={original} previewFields={() => []} />
356358
),
357359
size: Number.MAX_SAFE_INTEGER,
358360
},
@@ -364,11 +366,7 @@ export const LogsTab = (p: { pipeline: Pipeline }) => {
364366
({ row: { original } }: { row: { original: TopicMessage } }) => (
365367
<ExpandedMessage
366368
loadLargeMessage={() =>
367-
loadLargeMessage(
368-
searchRef.current?.searchRequest?.topicName ?? '',
369-
original.partitionID,
370-
original.offset
371-
)
369+
loadLargeMessage(searchRef.current?.searchRequest?.topicName ?? '', original.partitionID, original.offset)
372370
}
373371
msg={original}
374372
/>
@@ -377,26 +375,27 @@ export const LogsTab = (p: { pipeline: Pipeline }) => {
377375
);
378376

379377
const filteredMessages = useMemo(
380-
() => messages.filter((x) => {
381-
if (!logsQuickSearch) {
382-
return true;
383-
}
384-
return isFilterMatch(logsQuickSearch, x);
385-
}),
378+
() =>
379+
messages.filter((x) => {
380+
if (!logsQuickSearch) {
381+
return true;
382+
}
383+
return isFilterMatch(logsQuickSearch, x);
384+
}),
386385
[messages, logsQuickSearch]
387386
);
388387

389388
return (
390389
<>
391390
<Box my="1rem">The logs below are for the last five hours.</Box>
392391

393-
<Section minWidth="800px">
394-
<Flex mb="6">
392+
<Section borderColor={variant === 'ghost' ? 'transparent' : undefined} minWidth="800px" overflowY="auto">
393+
<div className="mb-6 flex items-center justify-between gap-2">
395394
<SearchField searchText={logsQuickSearch} setSearchText={setLogsQuickSearch} width="230px" />
396-
<Button ml="auto" onClick={() => setRefreshCount((c) => c + 1)} variant="outline">
397-
Refresh logs
398-
</Button>
399-
</Flex>
395+
<RegistryButton onClick={() => setRefreshCount((c) => c + 1)} size="icon" variant="ghost">
396+
<RefreshCcw />
397+
</RegistryButton>
398+
</div>
400399

401400
<DataTable<TopicMessage>
402401
columns={messageTableColumns}
@@ -415,20 +414,6 @@ export const LogsTab = (p: { pipeline: Pipeline }) => {
415414
);
416415
};
417416

418-
function isFilterMatch(str: string, m: TopicMessage) {
419-
const lowerStr = str.toLowerCase();
420-
if (m.offset.toString().toLowerCase().includes(lowerStr)) {
421-
return true;
422-
}
423-
if (m.keyJson?.toLowerCase().includes(lowerStr)) {
424-
return true;
425-
}
426-
if (m.valueJson?.toLowerCase().includes(lowerStr)) {
427-
return true;
428-
}
429-
return false;
430-
}
431-
432417
function executeMessageSearch(search: MessageSearch, topicName: string, pipelineId: string) {
433418
const filterCode: string = `return key == "${pipelineId}";`;
434419

frontend/src/components/pages/topics/Tab.Messages/index.tsx

Lines changed: 1 addition & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ import { appGlobal } from '../../../../state/app-global';
104104
import { useTopicSettingsStore } from '../../../../stores/topic-settings-store';
105105
import { IsDev } from '../../../../utils/env';
106106
import { sanitizeString, wrapFilterFragment } from '../../../../utils/filter-helper';
107+
import { trimSlidingWindow } from '../../../../utils/message-table-helpers';
107108
import { sortingParser } from '../../../../utils/sorting-parser';
108109
import { getTopicFilters, setTopicFilters } from '../../../../utils/topic-filters-session';
109110
import {
@@ -327,52 +328,6 @@ async function loadLargeMessage({
327328
}
328329
}
329330

330-
/**
331-
* Pure function for sliding-window trimming of messages.
332-
* Keeps at most maxResults + pageSize messages in the window,
333-
* trimming only pages before the user's current view.
334-
*/
335-
function trimSlidingWindow({
336-
messages,
337-
maxResults,
338-
pageSize,
339-
currentGlobalPage,
340-
windowStartPage,
341-
virtualStartIndex,
342-
}: {
343-
messages: TopicMessage[];
344-
maxResults: number;
345-
pageSize: number;
346-
currentGlobalPage: number;
347-
windowStartPage: number;
348-
virtualStartIndex: number;
349-
}): { messages: TopicMessage[]; windowStartPage: number; virtualStartIndex: number; trimCount: number } {
350-
const maxWindowSize = maxResults + pageSize;
351-
352-
if (maxResults < pageSize || messages.length <= maxWindowSize) {
353-
return { messages, windowStartPage, virtualStartIndex, trimCount: 0 };
354-
}
355-
356-
const excess = messages.length - maxWindowSize;
357-
const currentLocalPage = Math.max(0, currentGlobalPage - windowStartPage);
358-
359-
// Never trim the page the user is currently viewing or the one before it
360-
const maxPagesToTrim = Math.max(0, currentLocalPage - 1);
361-
const pagesToTrim = Math.min(Math.floor(excess / pageSize), maxPagesToTrim);
362-
const trimCount = pagesToTrim * pageSize;
363-
364-
if (trimCount === 0) {
365-
return { messages, windowStartPage, virtualStartIndex, trimCount: 0 };
366-
}
367-
368-
return {
369-
messages: messages.slice(trimCount),
370-
windowStartPage: windowStartPage + pagesToTrim,
371-
virtualStartIndex: virtualStartIndex + trimCount,
372-
trimCount,
373-
};
374-
}
375-
376331
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: this is because of the refactoring effort, the scope will be minimised eventually
377332
export const TopicMessageView: FC<TopicMessageViewProps> = (props) => {
378333
'use no memo';

frontend/src/components/pages/topics/Tab.Messages/message-display/message-display.test.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,11 +177,7 @@ describe('topic message rendering', () => {
177177
const msg = buildMessage();
178178

179179
render(
180-
<ExpandedMessage
181-
loadLargeMessage={() => Promise.resolve()}
182-
msg={msg}
183-
onDownloadRecord={onDownloadRecord}
184-
/>
180+
<ExpandedMessage loadLargeMessage={() => Promise.resolve()} msg={msg} onDownloadRecord={onDownloadRecord} />
185181
);
186182

187183
const downloadButton = screen.getByRole('button', { name: /download record/i });

0 commit comments

Comments
 (0)