diff --git a/frontend/package.json b/frontend/package.json index b150467db..9108a6f89 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "@tanstack/react-table": "8.16.0", "ace-builds": "1.33.0", "ajv": "8.8.2", + "date-fns": "4.1.0", "ajv-draft-04": "^1.0.0", "ajv-formats": "3.0.1", "json-schema-faker": "0.5.6", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index c9f16ad2a..d8f66a605 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: ajv-formats: specifier: 3.0.1 version: 3.0.1(ajv@8.8.2) + date-fns: + specifier: 4.1.0 + version: 4.1.0 json-schema-faker: specifier: 0.5.6 version: 0.5.6 @@ -1886,6 +1889,9 @@ packages: date-fns@3.6.0: resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -6140,6 +6146,8 @@ snapshots: date-fns@3.6.0: {} + date-fns@4.1.0: {} + debug@3.2.7: dependencies: ms: 2.1.3 diff --git a/frontend/src/components/Topics/Topic/Messages/Filters/Filters.tsx b/frontend/src/components/Topics/Topic/Messages/Filters/Filters.tsx index 5f7dc659f..b01d88b0c 100644 --- a/frontend/src/components/Topics/Topic/Messages/Filters/Filters.tsx +++ b/frontend/src/components/Topics/Topic/Messages/Filters/Filters.tsx @@ -1,7 +1,12 @@ import 'react-datepicker/dist/react-datepicker.css'; -import { SerdeUsage, TopicMessageConsuming } from 'generated-sources'; +import { + SerdeUsage, + TopicMessageConsuming, + TopicMessage, +} from 'generated-sources'; import React, { ChangeEvent, useMemo, useState } from 'react'; +import { format } from 'date-fns'; import MultiSelect from 'components/common/MultiSelect/MultiSelect.styled'; import Select from 'components/common/Select/Select'; import { Button } from 'components/common/Button/Button'; @@ -18,6 +23,7 @@ import EditIcon from 'components/common/Icons/EditIcon'; import CloseIcon from 'components/common/Icons/CloseIcon'; import FlexBox from 'components/common/FlexBox/FlexBox'; import { useMessageFiltersStore } from 'lib/hooks/useMessageFiltersStore'; +import useDataSaver from 'lib/hooks/useDataSaver'; import * as S from './Filters.styled'; import { @@ -30,11 +36,29 @@ import { import FiltersSideBar from './FiltersSideBar'; import FiltersMetrics from './FiltersMetrics'; +interface MessageData { + Value: string | undefined; + Offset: number; + Key: string | undefined; + Partition: number; + Headers: { [key: string]: string | undefined } | undefined; + Timestamp: Date; +} + +type DownloadFormat = 'json' | 'csv'; + +function padCurrentDateTimeString(): string { + const now: Date = new Date(); + const dateTimeString: string = format(now, 'yyyy-MM-dd HH:mm:ss'); + return `_${dateTimeString}`; +} + export interface FiltersProps { phaseMessage?: string; consumptionStats?: TopicMessageConsuming; isFetching: boolean; abortFetchData: () => void; + messages?: TopicMessage[]; } const Filters: React.FC = ({ @@ -42,6 +66,7 @@ const Filters: React.FC = ({ isFetching, abortFetchData, phaseMessage, + messages = [], }) => { const { clusterName, topicName } = useAppParams(); @@ -67,7 +92,79 @@ const Filters: React.FC = ({ const { data: topic } = useTopicDetails({ clusterName, topicName }); const [createdEditedSmartId, setCreatedEditedSmartId] = useState(); - const remove = useMessageFiltersStore((state) => state.remove); + const remove = useMessageFiltersStore( + (state: { remove: (id: string) => void }) => state.remove + ); + + // Download functionality + const [showFormatSelector, setShowFormatSelector] = useState(false); + + const formatOptions = [ + { label: 'Export JSON', value: 'json' as DownloadFormat }, + { label: 'Export CSV', value: 'csv' as DownloadFormat }, + ]; + + const baseFileName = `topic-messages${padCurrentDateTimeString()}`; + + const savedMessagesJson: MessageData[] = messages.map( + (message: TopicMessage) => ({ + Value: message.value, + Offset: message.offset, + Key: message.key, + Partition: message.partition, + Headers: message.headers, + Timestamp: message.timestamp, + }) + ); + + const convertToCSV = useMemo(() => { + return (messagesData: MessageData[]) => { + const headers = [ + 'Value', + 'Offset', + 'Key', + 'Partition', + 'Headers', + 'Timestamp', + ] as const; + const rows = messagesData.map((msg) => + headers + .map((header) => { + const value = msg[header]; + if (header === 'Headers') { + return JSON.stringify(value || {}); + } + return String(value ?? ''); + }) + .join(',') + ); + return [headers.join(','), ...rows].join('\n'); + }; + }, []); + + const jsonSaver = useDataSaver( + `${baseFileName}.json`, + JSON.stringify(savedMessagesJson, null, '\t') + ); + const csvSaver = useDataSaver( + `${baseFileName}.csv`, + convertToCSV(savedMessagesJson) + ); + + const handleFormatSelect = (downloadFormat: DownloadFormat) => { + setShowFormatSelector(false); + + // Automatically download after format selection + if (downloadFormat === 'json') { + jsonSaver.saveFile(); + } else { + csvSaver.saveFile(); + } + }; + + const handleDownloadClick = () => { + setShowFormatSelector(!showFormatSelector); + }; const partitions = useMemo(() => { return (topic?.partitions || []).reduce<{ @@ -187,7 +284,84 @@ const Filters: React.FC = ({ - + + +
+ + {showFormatSelector && ( +
+ {formatOptions.map((option) => ( + + ))} +
+ )} +
+
{ isFetching={isFetching} phaseMessage={phase} abortFetchData={abortFetchData} + messages={messages} />