|
| 1 | +import { Box, Stack } from '@mantine/core'; |
| 2 | +import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'; |
| 3 | +import classes from '../../Stream/styles/JSONView.module.css'; |
| 4 | +import EmptyBox from '@/components/Empty'; |
| 5 | +import { useAppStore } from '@/layouts/MainLayout/providers/AppProvider'; |
| 6 | +import { |
| 7 | + PRIMARY_HEADER_HEIGHT, |
| 8 | + STREAM_PRIMARY_TOOLBAR_CONTAINER_HEIGHT, |
| 9 | + STREAM_SECONDARY_TOOLBAR_HRIGHT, |
| 10 | +} from '@/constants/theme'; |
| 11 | +import { Log } from '@/@types/parseable/api/query'; |
| 12 | +import _ from 'lodash'; |
| 13 | +import { IconCheck, IconCopy, IconDotsVertical } from '@tabler/icons-react'; |
| 14 | +import { copyTextToClipboard } from '@/utils'; |
| 15 | +import timeRangeUtils from '@/utils/timeRangeUtils'; |
| 16 | +import { useCorrelationStore } from '../providers/CorrelationProvider'; |
| 17 | +import { ErrorView, LoadingView } from '@/pages/Stream/Views/Explore/LoadingViews'; |
| 18 | +import { formatLogTs, isJqSearch } from '@/pages/Stream/providers/LogsProvider'; |
| 19 | + |
| 20 | +type ContextMenuState = { |
| 21 | + visible: boolean; |
| 22 | + x: number; |
| 23 | + y: number; |
| 24 | + row: Log | null; |
| 25 | +}; |
| 26 | + |
| 27 | +// const { setInstantSearchValue, applyInstantSearch, applyJqSearch } = correlationStoreReducers; |
| 28 | + |
| 29 | +const Item = (props: { header: string | null; value: string; highlight: boolean }) => { |
| 30 | + return ( |
| 31 | + <span className={classes.itemContainer}> |
| 32 | + <span style={{ background: props.highlight ? 'yellow' : 'transparent' }} className={classes.itemHeader}> |
| 33 | + {props.header}: {props.value} |
| 34 | + </span> |
| 35 | + </span> |
| 36 | + ); |
| 37 | +}; |
| 38 | + |
| 39 | +export const CopyIcon = (props: { value: Log | string }) => { |
| 40 | + const copyIconRef = useRef<HTMLDivElement>(null); |
| 41 | + const copiedIconRef = useRef<HTMLDivElement>(null); |
| 42 | + |
| 43 | + const onCopy = async (e: React.MouseEvent<HTMLDivElement>) => { |
| 44 | + e.stopPropagation(); |
| 45 | + if (copyIconRef.current && copiedIconRef.current) { |
| 46 | + copyIconRef.current.style.display = 'none'; |
| 47 | + copiedIconRef.current.style.display = 'flex'; |
| 48 | + } |
| 49 | + await copyTextToClipboard(props.value); |
| 50 | + setTimeout(() => { |
| 51 | + if (copyIconRef.current && copiedIconRef.current) { |
| 52 | + copiedIconRef.current.style.display = 'none'; |
| 53 | + copyIconRef.current.style.display = 'flex'; |
| 54 | + } |
| 55 | + }, 1500); |
| 56 | + }; |
| 57 | + |
| 58 | + return ( |
| 59 | + <Stack style={{ alignItems: 'center', justifyContent: 'center', marginLeft: 2 }} className={classes.toggleIcon}> |
| 60 | + <Box ref={copyIconRef} style={{ display: 'flex', height: 'auto' }} onClick={onCopy} className={classes.copyIcon}> |
| 61 | + <IconCopy stroke={1.2} size={'0.8rem'} /> |
| 62 | + </Box> |
| 63 | + <Box ref={copiedIconRef} style={{ display: 'none', color: 'green' }}> |
| 64 | + <IconCheck stroke={1.2} size={'0.8rem'} /> |
| 65 | + </Box> |
| 66 | + </Stack> |
| 67 | + ); |
| 68 | +}; |
| 69 | + |
| 70 | +const localTz = timeRangeUtils.getLocalTimezone(); |
| 71 | + |
| 72 | +const Row = (props: { |
| 73 | + log: Log; |
| 74 | + searchValue: string; |
| 75 | + disableHighlight: boolean; |
| 76 | + isRowHighlighted: boolean; |
| 77 | + showEllipses: boolean; |
| 78 | + setContextMenu: any; |
| 79 | + shouldHighlight: (header: string | null, val: number | string | Date | null) => boolean; |
| 80 | +}) => { |
| 81 | + const [isSecureHTTPContext] = useAppStore((store) => store.isSecureHTTPContext); |
| 82 | + const { log, disableHighlight, shouldHighlight, isRowHighlighted, showEllipses, setContextMenu } = props; |
| 83 | + |
| 84 | + return ( |
| 85 | + <Stack |
| 86 | + style={{ flexDirection: 'row', background: isRowHighlighted ? '#E8EDFE' : 'white' }} |
| 87 | + className={classes.rowContainer} |
| 88 | + gap={0}> |
| 89 | + {showEllipses && ( |
| 90 | + <div |
| 91 | + className={classes.actionIconContainer} |
| 92 | + onClick={(event) => { |
| 93 | + event.stopPropagation(); |
| 94 | + setContextMenu({ |
| 95 | + visible: true, |
| 96 | + x: event.pageX, |
| 97 | + y: event.pageY, |
| 98 | + row: log, |
| 99 | + }); |
| 100 | + }}> |
| 101 | + <IconDotsVertical stroke={1.2} size={'0.8rem'} color="#545beb" /> |
| 102 | + </div> |
| 103 | + )} |
| 104 | + <span> |
| 105 | + {_.isObject(log) ? ( |
| 106 | + _.map(log, (value, key) => { |
| 107 | + //skiping fields with empty strings |
| 108 | + if (!_.toString(value)) return; |
| 109 | + const fieldTypeMap = { |
| 110 | + datetime: 'text', |
| 111 | + host: 'text', |
| 112 | + id: 'text', |
| 113 | + method: 'text', |
| 114 | + p_metadata: 'text', |
| 115 | + p_tags: 'text', |
| 116 | + p_timestamp: 'timestamp', |
| 117 | + referrer: 'text', |
| 118 | + status: 'number', |
| 119 | + 'user-identifier': 'text', |
| 120 | + }; |
| 121 | + const isTimestamp = _.get(fieldTypeMap, key, null) === 'timestamp'; |
| 122 | + const sanitizedValue = isTimestamp ? `${formatLogTs(_.toString(value))} (${localTz})` : _.toString(value); |
| 123 | + return ( |
| 124 | + <Item |
| 125 | + header={key} |
| 126 | + key={key} |
| 127 | + value={sanitizedValue} |
| 128 | + highlight={disableHighlight ? false : shouldHighlight(key, value)} |
| 129 | + /> |
| 130 | + ); |
| 131 | + }) |
| 132 | + ) : ( |
| 133 | + <Item |
| 134 | + header={null} |
| 135 | + value={_.toString(log)} |
| 136 | + highlight={disableHighlight ? false : shouldHighlight(null, _.toString(log))} |
| 137 | + /> |
| 138 | + )} |
| 139 | + </span> |
| 140 | + {isSecureHTTPContext ? <CopyIcon value={log} /> : null} |
| 141 | + </Stack> |
| 142 | + ); |
| 143 | +}; |
| 144 | + |
| 145 | +const JsonRows = (props: { isSearching: boolean; setContextMenu: any }) => { |
| 146 | + const [{ pageData, instantSearchValue }] = useCorrelationStore((store) => store.tableOpts); |
| 147 | + const disableHighlight = props.isSearching || _.isEmpty(instantSearchValue) || isJqSearch(instantSearchValue); |
| 148 | + |
| 149 | + const shouldHighlight = useCallback( |
| 150 | + (header: string | null, val: number | string | Date | null) => { |
| 151 | + return String(val).includes(instantSearchValue) || String(header).includes(instantSearchValue); |
| 152 | + }, |
| 153 | + [instantSearchValue], |
| 154 | + ); |
| 155 | + |
| 156 | + return ( |
| 157 | + <Stack gap={0} style={{ flex: 1 }}> |
| 158 | + {_.map(pageData, (d, index) => ( |
| 159 | + <Row |
| 160 | + log={d} |
| 161 | + key={index} |
| 162 | + searchValue={instantSearchValue} |
| 163 | + disableHighlight={disableHighlight} |
| 164 | + shouldHighlight={shouldHighlight} |
| 165 | + isRowHighlighted={false} |
| 166 | + showEllipses={false} |
| 167 | + setContextMenu={props.setContextMenu} |
| 168 | + /> |
| 169 | + ))} |
| 170 | + </Stack> |
| 171 | + ); |
| 172 | +}; |
| 173 | + |
| 174 | +// const Toolbar = ({ |
| 175 | +// isSearching, |
| 176 | +// setSearching, |
| 177 | +// }: { |
| 178 | +// isSearching: boolean; |
| 179 | +// setSearching: React.Dispatch<React.SetStateAction<boolean>>; |
| 180 | +// }) => { |
| 181 | +// const [localSearchValue, setLocalSearchValue] = useState<string>(''); |
| 182 | +// const searchInputRef = useRef<HTMLInputElement>(null); |
| 183 | + |
| 184 | +// const [searchValue, setCorrelationData] = useCorrelationStore((store) => store.tableOpts.instantSearchValue); |
| 185 | +// const [{ rawData, filteredData }] = useCorrelationStore((store) => store.data); |
| 186 | + |
| 187 | +// const debouncedSearch = useCallback( |
| 188 | +// _.debounce(async (val: string) => { |
| 189 | +// if (val.trim() === '') { |
| 190 | +// setCorrelationData((store) => setInstantSearchValue(store, '')); |
| 191 | +// setCorrelationData(applyInstantSearch); |
| 192 | +// } else { |
| 193 | +// const isJq = isJqSearch(val); |
| 194 | +// if (isJq) { |
| 195 | +// const jqResult = await jqSearch(rawData, val); |
| 196 | +// setCorrelationData((store) => applyJqSearch(store, jqResult)); |
| 197 | +// } else { |
| 198 | +// setCorrelationData(applyInstantSearch); |
| 199 | +// } |
| 200 | +// } |
| 201 | +// setSearching(false); |
| 202 | +// }, 500), |
| 203 | +// [rawData], |
| 204 | +// ); |
| 205 | + |
| 206 | +// const handleSearch = useCallback(() => { |
| 207 | +// if (localSearchValue.trim()) { |
| 208 | +// setSearching(true); |
| 209 | +// setCorrelationData((store) => setInstantSearchValue(store, localSearchValue)); |
| 210 | +// debouncedSearch(localSearchValue); |
| 211 | +// } |
| 212 | +// }, [localSearchValue, debouncedSearch, setSearching]); |
| 213 | + |
| 214 | +// const handleInputChange = useCallback( |
| 215 | +// (e: React.ChangeEvent<HTMLInputElement>) => { |
| 216 | +// const value = e.target.value; |
| 217 | +// setLocalSearchValue(value); |
| 218 | +// if (value.trim() === '') { |
| 219 | +// debouncedSearch(value); |
| 220 | +// } |
| 221 | +// }, |
| 222 | +// [debouncedSearch], |
| 223 | +// ); |
| 224 | + |
| 225 | +// useHotkeys([['mod+K', () => searchInputRef.current?.focus()]]); |
| 226 | + |
| 227 | +// const handleKeyDown = useCallback( |
| 228 | +// (e: React.KeyboardEvent<HTMLInputElement>) => { |
| 229 | +// if (e.key === 'Enter' && !isSearching && localSearchValue.trim()) { |
| 230 | +// handleSearch(); |
| 231 | +// } |
| 232 | +// }, |
| 233 | +// [isSearching, localSearchValue], |
| 234 | +// ); |
| 235 | + |
| 236 | +// if (_.isEmpty(rawData)) return null; |
| 237 | + |
| 238 | +// const inputStyles = { |
| 239 | +// '--input-left-section-width': '2rem', |
| 240 | +// '--input-right-section-width': '6rem', |
| 241 | +// width: '100%', |
| 242 | +// } as React.CSSProperties; |
| 243 | + |
| 244 | +// return ( |
| 245 | +// <div className={classes.headerWrapper}> |
| 246 | +// <TextInput |
| 247 | +// leftSection={isSearching ? <Loader size="sm" /> : <IconSearch stroke={2.5} size="0.9rem" />} |
| 248 | +// placeholder="Search loaded data with text or jq. For jq input try `jq .[]`" |
| 249 | +// value={localSearchValue} |
| 250 | +// onChange={handleInputChange} |
| 251 | +// onKeyDown={handleKeyDown} |
| 252 | +// ref={searchInputRef} |
| 253 | +// classNames={{ input: classes.inputField }} |
| 254 | +// style={inputStyles} |
| 255 | +// rightSection={ |
| 256 | +// searchValue && !isSearching ? ( |
| 257 | +// <Text style={{ fontSize: '0.7rem', textAlign: 'end' }} lineClamp={1}> |
| 258 | +// {filteredData.length} Matches |
| 259 | +// </Text> |
| 260 | +// ) : null |
| 261 | +// } |
| 262 | +// /> |
| 263 | +// <Button |
| 264 | +// onClick={handleSearch} |
| 265 | +// disabled={!localSearchValue.trim() || isSearching} |
| 266 | +// style={{ width: '10%' }} |
| 267 | +// leftSection={<IconSearch stroke={2.5} size="0.9rem" />}> |
| 268 | +// Search |
| 269 | +// </Button> |
| 270 | +// </div> |
| 271 | +// ); |
| 272 | +// }; |
| 273 | + |
| 274 | +const TableContainer = (props: { children: ReactNode }) => { |
| 275 | + return <Box className={classes.container}>{props.children}</Box>; |
| 276 | +}; |
| 277 | + |
| 278 | +const CorrleationJSONView = (props: { errorMessage: string | null; hasNoData: boolean; showTable: boolean }) => { |
| 279 | + const [maximized] = useAppStore((store) => store.maximized); |
| 280 | + const [contextMenu, setContextMenu] = useState<ContextMenuState>({ |
| 281 | + visible: false, |
| 282 | + x: 0, |
| 283 | + y: 0, |
| 284 | + row: null, |
| 285 | + }); |
| 286 | + |
| 287 | + const contextMenuRef = useRef<HTMLDivElement>(null); |
| 288 | + const { errorMessage, hasNoData, showTable } = props; |
| 289 | + const [isSearching] = useState(false); |
| 290 | + const primaryHeaderHeight = !maximized |
| 291 | + ? PRIMARY_HEADER_HEIGHT + STREAM_PRIMARY_TOOLBAR_CONTAINER_HEIGHT + STREAM_SECONDARY_TOOLBAR_HRIGHT |
| 292 | + : 0; |
| 293 | + |
| 294 | + useEffect(() => { |
| 295 | + const handleClickOutside = (event: MouseEvent) => { |
| 296 | + if (contextMenuRef.current && !contextMenuRef.current.contains(event.target as Node)) { |
| 297 | + closeContextMenu(); |
| 298 | + } |
| 299 | + }; |
| 300 | + |
| 301 | + if (contextMenu.visible) { |
| 302 | + document.addEventListener('mousedown', handleClickOutside); |
| 303 | + } |
| 304 | + |
| 305 | + return () => { |
| 306 | + document.removeEventListener('mousedown', handleClickOutside); |
| 307 | + }; |
| 308 | + }, [contextMenu.visible]); |
| 309 | + |
| 310 | + const closeContextMenu = () => setContextMenu({ visible: false, x: 0, y: 0, row: null }); |
| 311 | + |
| 312 | + return ( |
| 313 | + <TableContainer> |
| 314 | + {/* <Toolbar isSearching={isSearching} setSearching={setSearching} /> */} |
| 315 | + {!errorMessage ? ( |
| 316 | + showTable ? ( |
| 317 | + <Box className={classes.innerContainer} style={{ maxHeight: `calc(100vh - ${primaryHeaderHeight}px )` }}> |
| 318 | + <Box |
| 319 | + className={classes.innerContainer} |
| 320 | + style={{ display: 'flex', flexDirection: 'row', maxHeight: `calc(100vh - ${primaryHeaderHeight}px )` }}> |
| 321 | + <Stack gap={0} style={{ width: '100%' }}> |
| 322 | + <Stack style={{ overflowY: 'scroll' }}> |
| 323 | + <JsonRows isSearching={isSearching} setContextMenu={setContextMenu} /> |
| 324 | + </Stack> |
| 325 | + </Stack> |
| 326 | + </Box> |
| 327 | + {contextMenu.visible && ( |
| 328 | + <div |
| 329 | + ref={contextMenuRef} |
| 330 | + style={{ |
| 331 | + top: contextMenu.y, |
| 332 | + left: contextMenu.x, |
| 333 | + }} |
| 334 | + className={classes.contextMenuContainer} |
| 335 | + onClick={closeContextMenu}></div> |
| 336 | + )} |
| 337 | + </Box> |
| 338 | + ) : hasNoData ? ( |
| 339 | + <> |
| 340 | + <EmptyBox message="No Matching Rows" /> |
| 341 | + </> |
| 342 | + ) : ( |
| 343 | + <LoadingView /> |
| 344 | + ) |
| 345 | + ) : ( |
| 346 | + <ErrorView message={errorMessage || 'Failed to query logs'} /> |
| 347 | + )} |
| 348 | + </TableContainer> |
| 349 | + ); |
| 350 | +}; |
| 351 | + |
| 352 | +export default CorrleationJSONView; |
0 commit comments