-
-
Notifications
You must be signed in to change notification settings - Fork 218
FE: Messages: Implement messages export #740
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 25 commits
b008fa9
c55f8c0
252c8a9
1aab310
9817ee5
628ecf1
b393c2d
d841a95
eef6107
cc31f80
578982d
0615fc5
f0f3508
8ef27ad
a5f925b
bff1aa0
f8aa582
38abcd0
66c67ee
297f754
7819ca6
416c57a
55a8fa3
b0c419c
72cc645
9ccacb8
e32d02e
4c00bb7
1346b6d
14ff067
8c13134
7479f01
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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,18 +36,37 @@ 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<FiltersProps> = ({ | ||
| consumptionStats, | ||
| isFetching, | ||
| abortFetchData, | ||
| phaseMessage, | ||
| messages = [], | ||
| }) => { | ||
| const { clusterName, topicName } = useAppParams<RouteParamsClusterTopic>(); | ||
|
|
||
|
|
@@ -67,7 +92,79 @@ const Filters: React.FC<FiltersProps> = ({ | |
|
|
||
| const { data: topic } = useTopicDetails({ clusterName, topicName }); | ||
| const [createdEditedSmartId, setCreatedEditedSmartId] = useState<string>(); | ||
| 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<FiltersProps> = ({ | |
| </Button> | ||
| </FlexBox> | ||
|
|
||
| <Search placeholder="Search" value={search} onChange={setSearch} /> | ||
| <FlexBox gap="8px" alignItems="center"> | ||
| <Search placeholder="Search" value={search} onChange={setSearch} /> | ||
| <div style={{ position: 'relative' }}> | ||
| <Button | ||
| disabled={isFetching || messages.length === 0} | ||
| buttonType="secondary" | ||
| buttonSize="M" | ||
| onClick={handleDownloadClick} | ||
| style={{ | ||
| minWidth: '40px', | ||
| padding: '8px', | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| justifyContent: 'center', | ||
| }} | ||
| > | ||
| <svg | ||
| width="24" | ||
|
||
| height="24" | ||
| viewBox="0 0 18 18" | ||
| fill="currentColor" | ||
| > | ||
| <path d="M4.24 5.8a.75.75 0 001.06-.04l1.95-2.1v6.59a.75.75 0 001.5 0V3.66l1.95 2.1a.75.75 0 101.1-1.02l-3.25-3.5a.75.75 0 00-1.101.001L4.2 4.74a.75.75 0 00.04 1.06z" /> | ||
|
||
| <path d="M1.75 9a.75.75 0 01.75.75v3c0 .414.336.75.75.75h9.5a.75.75 0 00.75-.75v-3a.75.75 0 011.5 0v3A2.25 2.25 0 0112.75 15h-9.5A2.25 2.25 0 011 12.75v-3A.75.75 0 011.75 9z" /> | ||
| </svg>{' '} | ||
| Export | ||
| </Button> | ||
| {showFormatSelector && ( | ||
| <div | ||
| style={{ | ||
| position: 'absolute', | ||
| top: '100%', | ||
| right: '0', | ||
| zIndex: 1000, | ||
| backgroundColor: 'white', | ||
| border: '1px solid #ccc', | ||
| borderRadius: '4px', | ||
| boxShadow: '0 2px 8px rgba(0,0,0,0.1)', | ||
| padding: '8px', | ||
| minWidth: '120px', | ||
| }} | ||
| > | ||
| {formatOptions.map((option) => ( | ||
| <button | ||
| key={option.value} | ||
| type="button" | ||
| onClick={() => handleFormatSelect(option.value)} | ||
| onKeyDown={(e) => { | ||
| if (e.key === 'Enter' || e.key === ' ') { | ||
| handleFormatSelect(option.value); | ||
| } | ||
| }} | ||
| style={{ | ||
| padding: '8px 12px', | ||
| cursor: 'pointer', | ||
| borderRadius: '4px', | ||
| fontSize: '12px', | ||
| border: 'none', | ||
| background: 'transparent', | ||
| width: '100%', | ||
| textAlign: 'left', | ||
| }} | ||
| onMouseEnter={(e) => { | ||
| const target = e.currentTarget; | ||
| target.style.backgroundColor = '#f5f5f5'; | ||
| }} | ||
| onMouseLeave={(e) => { | ||
| const target = e.currentTarget; | ||
| target.style.backgroundColor = 'transparent'; | ||
| }} | ||
| > | ||
| {option.label} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| )} | ||
| </div> | ||
| </FlexBox> | ||
| </FlexBox> | ||
| <FlexBox | ||
| gap="10px" | ||
|
|
||



There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typescript will infer the value correctly don't need return type
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not a TS expert, but even if this is the case, isn't adding the type explicitly a good pattern for self-documentation?