diff --git a/src/components/Header/Dropdown.tsx b/src/components/Header/Dropdown.tsx index 0dcaa733..4fc1179c 100644 --- a/src/components/Header/Dropdown.tsx +++ b/src/components/Header/Dropdown.tsx @@ -3,13 +3,26 @@ import { Select } from '@mantine/core'; type DropdownProps = { data: string[]; + placeholder?: string; + searchable?: boolean; + value?: string | null; onChange: (value: string) => void; }; const Dropdown: FC = (props) => { - const { data, onChange } = props; + const { data, onChange, placeholder = "Export", searchable = false, value = null } = props; - return + ); }; export default Dropdown; diff --git a/src/components/Header/LiveTailFilter.tsx b/src/components/Header/LiveTailFilter.tsx index ee0b7dbc..f0525e01 100644 --- a/src/components/Header/LiveTailFilter.tsx +++ b/src/components/Header/LiveTailFilter.tsx @@ -54,7 +54,13 @@ const LiveTailFilter: FC = () => { {schemaData.length > 0 && ( <> - item.name)} onChange={handleDropdownValue} /> + item.name)} + searchable + onChange={handleDropdownValue} + placeholder="Column" + value={searchField} + /> ; - -const SecondaryHeader: FC = (props) => { - return ( - - }> - } /> - } /> - } /> - } /> - } /> - } /> - - - ); -}; - -export default SecondaryHeader; diff --git a/src/components/Header/SubHeader.tsx b/src/components/Header/SubHeader.tsx index e7ec796b..18f9b532 100644 --- a/src/components/Header/SubHeader.tsx +++ b/src/components/Header/SubHeader.tsx @@ -1,4 +1,4 @@ -import { Box } from '@mantine/core'; +import { Box, Header as MantineHeader } from '@mantine/core'; import type { FC } from 'react'; import HeaderBreadcrumbs from './HeaderBreadcrumbs'; import RefreshInterval from './RefreshInterval'; @@ -10,25 +10,48 @@ import ReloadUser from './ReloadUser'; import DocsUser from './UserDocs'; import StreamingButton from './StreamingButton'; import LiveTailFilter from './LiveTailFilter'; +import Dropdown from './Dropdown'; +import { useHeaderStyles } from './styles'; +import { HEADER_HEIGHT } from '@/constants/theme'; +import { downloadDataAsCSV, downloadDataAsJson } from '@/utils/exportHelpers'; +import { useLogsPageContext } from '@/pages/Logs/Context'; +import { useHeaderContext } from '@/layouts/MainLayout/Context'; + +type HeaderLayoutProps = { + children: React.ReactNode; +}; + +const HeaderLayout: FC = (props) => { + const { classes } = useHeaderStyles(); + const { container, navContainer } = classes; + + return ( + + {props.children} + + ); +}; export const StatsHeader: FC = () => { const { classes } = useLogQueryStyles(); const { container, innerContainer } = classes; return ( - - - - + + + + + + - - - - + + + + - + ); }; @@ -37,20 +60,22 @@ export const QueryHeader: FC = () => { const { container, innerContainer } = classes; return ( - - - - + + + + + + - - - - - + + + + + - + ); }; @@ -59,48 +84,69 @@ export const LiveTailHeader: FC = () => { const { container, innerContainer } = classes; return ( - - - - + + + + + + - - - - - - {/* */} - {/* */} + + + + + {/* */} + {/* */} + - + ); }; export const LogsHeader: FC = () => { const { classes } = useLogQueryStyles(); const { container, innerContainer } = classes; + const { + methods: { makeExportData }, + } = useLogsPageContext(); + const { + state: { subLogQuery }, + } = useHeaderContext(); + + const exportHandler = (fileType: string) => { + const query = subLogQuery.get(); + const filename = `${query.streamName}-logs`; + if (fileType === 'CSV') { + downloadDataAsCSV(makeExportData('CSV'), filename) + } else if (fileType === 'JSON') { + downloadDataAsJson(makeExportData('JSON'), filename) + } + }; return ( - - - - + + + + + + - - - - - - {/* */} + + + + + {/* */} - - + + + + - + ); }; @@ -124,19 +170,21 @@ export const UsersManagementHeader: FC = () => { const { container, innerContainer } = classes; return ( - - - - + + + + + + - - - - - + + + + + - + ); }; @@ -145,12 +193,14 @@ export const AllRouteHeader: FC = () => { const { container, innerContainer } = classes; return ( - - - - + + + + + + - + ); }; diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index 70446cb4..ce7ae37e 100644 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -1,7 +1,5 @@ import PrimaryHeader from "./PrimaryHeader"; -import SecondaryHeader from "./SecondaryHeader"; export { PrimaryHeader, - SecondaryHeader } \ No newline at end of file diff --git a/src/hooks/useQueryLogs.ts b/src/hooks/useQueryLogs.ts index cfdd4aac..e32f347b 100644 --- a/src/hooks/useQueryLogs.ts +++ b/src/hooks/useQueryLogs.ts @@ -3,7 +3,7 @@ import { getQueryLogs } from '@/api/query'; import { StatusCodes } from 'http-status-codes'; import useMountedState from './useMountedState'; import { useCallback, useEffect, useMemo, useRef, useTransition } from 'react'; -import { LOG_QUERY_LIMITS } from '@/pages/Logs/Context'; +import { LOG_QUERY_LIMITS, useLogsPageContext } from '@/pages/Logs/Context'; import { parseLogData } from '@/utils'; type QueryLogs = { @@ -32,11 +32,14 @@ export const useQueryLogs = () => { }, }); const [isPending, startTransition] = useTransition(); + const { + state: { subLogQueryData }, + } = useLogsPageContext(); const data: Log[] | null = useMemo(() => { if (_dataRef.current) { const logs = _dataRef.current; - const temp = []; + const temp: Log[] = []; const { search, filters, sort } = querySearch; const searchText = search.trim().toLowerCase(); const filteredKeys = Object.keys(filters); @@ -78,6 +81,10 @@ export const useQueryLogs = () => { return res * order; }); + subLogQueryData.set((state) => { + state.filteredData = temp; + state.rawData = logs; + }); return temp; } diff --git a/src/layouts/MainLayout/index.tsx b/src/layouts/MainLayout/index.tsx index d17f0b84..6edea930 100644 --- a/src/layouts/MainLayout/index.tsx +++ b/src/layouts/MainLayout/index.tsx @@ -1,4 +1,4 @@ -import { PrimaryHeader, SecondaryHeader } from '@/components/Header'; +import { PrimaryHeader } from '@/components/Header'; import Navbar from '@/components/Navbar'; import { NAVBAR_WIDTH } from '@/constants/theme'; import { Box } from '@mantine/core'; @@ -21,7 +21,6 @@ const MainLayout: FC = () => { display: 'flex', flexDirection: 'column', }}> - diff --git a/src/pages/Logs/Context.tsx b/src/pages/Logs/Context.tsx index 1c6c0492..1a078243 100644 --- a/src/pages/Logs/Context.tsx +++ b/src/pages/Logs/Context.tsx @@ -2,6 +2,8 @@ import type { Log } from '@/@types/parseable/api/query'; import useSubscribeState, { SubData } from '@/hooks/useSubscribeState'; import type { FC } from 'react'; import { ReactNode, createContext, useContext } from 'react'; +import { LogStreamSchemaData } from '@/@types/parseable/api/stream'; +import { sanitizeCSVData } from '@/utils/exportHelpers'; const Context = createContext({}); @@ -19,10 +21,19 @@ interface LogsPageContextState { subLogStreamError: SubData; subViewLog: SubData; subGapTime: SubData; + subLogQueryData: SubData; + subLogStreamSchema: SubData; +} + +type LogQueryData = { + rawData: Log[] | [], + filteredData: Log[] | [] } // eslint-disable-next-line @typescript-eslint/no-empty-interface -interface LogsPageContextMethods {} +interface LogsPageContextMethods { + makeExportData: (type: string) => Log[]; +} interface LogsPageContextValue { state: LogsPageContextState; @@ -37,15 +48,36 @@ const LogsPageProvider: FC = ({ children }) => { const subLogStreamError = useSubscribeState(null); const subViewLog = useSubscribeState(null); const subGapTime = useSubscribeState(null); + const subLogQueryData = useSubscribeState({ + rawData: [], + filteredData: [], + }); + const subLogStreamSchema = useSubscribeState(null); const state: LogsPageContextState = { subLogStreamError, subViewLog, subGapTime, + subLogQueryData, + subLogStreamSchema }; - const methods: LogsPageContextMethods = {}; + const makeExportData = (type: string): Log[] => { + const { rawData, filteredData: _filteredData } = subLogQueryData.get(); // filteredData - records filtered with in-page search + if (type === 'JSON') { + return rawData + } else if (type === 'CSV') { + const fields = subLogStreamSchema.get()?.fields + const headers = Array.isArray(fields) ? fields.map(field => field.name) : [] + const sanitizedCSVData = sanitizeCSVData(rawData, headers) + return [headers, ...sanitizedCSVData] + } else { + return [] + } + } + const methods: LogsPageContextMethods = { makeExportData }; + return {children}; }; diff --git a/src/pages/Logs/LogTable.tsx b/src/pages/Logs/LogTable.tsx index ebfb027d..b304e816 100644 --- a/src/pages/Logs/LogTable.tsx +++ b/src/pages/Logs/LogTable.tsx @@ -41,7 +41,7 @@ const loadLimit = 9000; const LogTable: FC = () => { const { - state: { subLogStreamError }, + state: { subLogStreamError, subLogStreamSchema }, } = useLogsPageContext(); const { state: { subLogSearch, subLogQuery, subRefreshInterval, subLogSelectedTimeRange }, @@ -239,7 +239,7 @@ const LogTable: FC = () => { getDataSchema(state.streamName); } }); - + subLogStreamSchema.set(logsSchema) return () => { streamErrorListener(); subLogQueryListener(); diff --git a/src/routes/elements.tsx b/src/routes/elements.tsx index 49e84243..834dedad 100644 --- a/src/routes/elements.tsx +++ b/src/routes/elements.tsx @@ -6,6 +6,14 @@ import SuspensePage from './SuspensePage'; import QueryPageProvider from '@/pages/Query/Context'; import MainLayoutPageProvider from '@/layouts/MainLayout/Context'; import MainLayout from '@/layouts/MainLayout'; +import { + ConfigHeader, + LiveTailHeader, + LogsHeader, + QueryHeader, + StatsHeader, + UsersManagementHeader, +} from '@/components/Header/SubHeader'; export const HomeElement: FC = () => ; @@ -25,6 +33,7 @@ export const LogsElement: FC = () => { return ( + @@ -37,6 +46,7 @@ export const QueryElement: FC = () => { return ( + @@ -56,6 +66,7 @@ const LiveTail = lazy(() => import('@/pages/LiveTail')); export const LiveTailElement: FC = () => { return ( + ); @@ -66,6 +77,7 @@ const Stats = lazy(() => import('@/pages/Stats')); export const StatsElement: FC = () => { return ( + ); @@ -76,6 +88,7 @@ const Config = lazy(() => import('@/pages/Config')); export const ConfigElement: FC = () => { return ( + ); @@ -86,6 +99,7 @@ const Users = lazy(() => import('@/pages/AccessManagement')); export const UsersElement: FC = () => { return ( + ); diff --git a/src/utils/exportHelpers.ts b/src/utils/exportHelpers.ts new file mode 100644 index 00000000..4347a9a6 --- /dev/null +++ b/src/utils/exportHelpers.ts @@ -0,0 +1,48 @@ +import type { Log } from '@/@types/parseable/api/query'; + +type Data = Log[] | null; + +export const downloadDataAsJson = (data: Data, filename: string) => { + if (data === null || data.length === 0) return; + + const jsonString = JSON.stringify(data, null, 2); + const blob = new Blob([jsonString], { type: 'application/json' }); + const downloadLink = document.createElement('a'); + downloadLink.href = URL.createObjectURL(blob); + downloadLink.download = `${filename}.json`; + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); +}; + +export const downloadDataAsCSV = (data: Data, filename: string) => { + if (data === null || data.length === 0) return; + + const csvString = data + .map((row) => + Object.values(row) + .map((value) => (value !== null ? `\"${value}\"` : '')) + .join(','), + ) + .join('\n'); + const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8;' }); + const downloadLink = document.createElement('a'); + downloadLink.href = URL.createObjectURL(blob); + downloadLink.download = `${filename}.csv`; + document.body.appendChild(downloadLink); + downloadLink.click(); + document.body.removeChild(downloadLink); +}; + +// makes sure no records has missing cells +export const sanitizeCSVData = (data: Data, headers: string[]): any[] => { + if (data) { + return data.map((d) => { + return headers.reduce((acc, header) => { + return { ...acc, [header]: d[header] || '' }; + }, {}); + }); + } else { + return []; + } +};