diff --git a/src/@types/parseable/api/query.ts b/src/@types/parseable/api/query.ts new file mode 100644 index 00000000..f9960408 --- /dev/null +++ b/src/@types/parseable/api/query.ts @@ -0,0 +1,7 @@ +export type LogsQuery = { + streamName: string; + startTime?: Date; + endTime?: Date; + limit?: number; + page?: number; +}; diff --git a/src/@types/parseable/api/stream.ts b/src/@types/parseable/api/stream.ts index 673d7148..6324f5ca 100644 --- a/src/@types/parseable/api/stream.ts +++ b/src/@types/parseable/api/stream.ts @@ -1 +1,15 @@ +import { Field } from '../dataType'; + export type LogStreamData = Array<{ name: string }>; + +export type LogStreamSchemaData = { + fields: Array; + metadata: Record; +}; + +export type LogsData = Array<{ + p_timestamp: string; + p_metadata: string; + p_tags: string; + [key: string]: string | number | null | Date; +}>; diff --git a/src/@types/parseable/dataType.ts b/src/@types/parseable/dataType.ts new file mode 100644 index 00000000..bc73bde9 --- /dev/null +++ b/src/@types/parseable/dataType.ts @@ -0,0 +1,69 @@ +//TODO: Need to check if this is proper +export interface Field { + name: string; + data_type: DataType; + nullable: boolean; + dict_id: number; + dict_is_ordered: boolean; + metadata: { [key: string]: string }; +} + +export type Timestamp = { Timestamp: [TimeUnit, string | null] }; +export type Time32 = { Time32: TimeUnit }; +export type Time64 = { Time64: TimeUnit }; +export type Duration = { Duration: TimeUnit }; +export type Interval = { Interval: IntervalUnit }; +export type FixedSizeBinary = { FixedSizeBinary: number }; +export type List = { List: Field }; +export type FixedSizeList = { FixedSizeList: [Field, number] }; +export type LargeList = { LargeList: Field }; +export type Struct = { Struct: Field }; +export type Union = { Union: [Field[], number[], UnionMode] }; +export type Dictionary = { Dictionary: [DataType, DataType] }; +export type Decimal128 = { Decimal128: [number, number] }; +export type Decimal256 = { Decimal256: [number, number] }; +export type MAP = { Map: [Field, boolean] }; +export type RunEndEncoded = { RunEndEncoded: [Field, Field] }; + +export type DataType = + | 'Null' + | 'Boolean' + | 'Int8' + | 'Int16' + | 'Int32' + | 'Int64' + | 'UInt8' + | 'UInt16' + | 'UInt32' + | 'UInt64' + | 'Float16' + | 'Float32' + | 'Float64' + | 'LargeBinary' + | 'Utf8' + | 'LargeUtf8' + | 'Date32' + | 'Date64' + | 'Binary' + | Timestamp + | Time32 + | Time64 + | Duration + | Interval + | FixedSizeBinary + | List + | FixedSizeList + | LargeList + | Struct + | Union + | Dictionary + | Decimal128 + | Decimal256 + | MAP + | RunEndEncoded; + +export type TimeUnit = 'Second' | 'Millisecond' | 'Microsecond' | 'Nanosecond'; + +export type IntervalUnit = 'YearMonth' | 'DayTime' | 'MonthDayNano'; + +export type UnionMode = 'Sparse' | 'Dense'; diff --git a/src/api/constants.ts b/src/api/constants.ts index 8b31b01b..0246af90 100644 --- a/src/api/constants.ts +++ b/src/api/constants.ts @@ -2,3 +2,6 @@ const API_V1 = 'api/v1'; export const HEALTH_LIVENESS_URL = `${API_V1}/liveness`; export const LOG_STREAM_LIST_URL = `${API_V1}/logstream`; +export const LOG_STREAMS_SCHEMA_URL = (streamName: string) => `${LOG_STREAM_LIST_URL}/${streamName}/schema`; +export const LOG_STREAMS_QUERY_URL = `${API_V1}/query`; +export const QUERY_URL = 'api/v1/query'; diff --git a/src/api/logStream.ts b/src/api/logStream.ts index edf7465c..7c034a99 100644 --- a/src/api/logStream.ts +++ b/src/api/logStream.ts @@ -1,7 +1,11 @@ import { Axios } from './axios'; -import { LOG_STREAM_LIST_URL } from './constants'; -import { LogStreamData } from '@/@types/parseable/api/stream'; +import { LOG_STREAMS_SCHEMA_URL, LOG_STREAM_LIST_URL } from './constants'; +import { LogStreamData, LogStreamSchemaData } from '@/@types/parseable/api/stream'; export const getLogStreamList = () => { return Axios().get(LOG_STREAM_LIST_URL); }; + +export const getLogStreamSchema = (streamName: string) => { + return Axios().get(LOG_STREAMS_SCHEMA_URL(streamName)); +}; diff --git a/src/api/query.ts b/src/api/query.ts new file mode 100644 index 00000000..2e723d70 --- /dev/null +++ b/src/api/query.ts @@ -0,0 +1,41 @@ +import { Axios } from './axios'; +import { LOG_STREAMS_QUERY_URL, QUERY_URL } from './constants'; +import { LogsQuery } from '@/@types/parseable/api/query'; +import dayjs from 'dayjs'; + +export const getQueryLogs = (logsQuery: LogsQuery) => { + const startOfDay = dayjs().startOf('day'); + const { + streamName, + startTime = startOfDay.toDate(), + endTime = startOfDay.endOf('day').toDate(), + limit = 30, + page = 1, + } = logsQuery; + + const offset = limit * page - limit; + + const query = `SELECT * FROM ${streamName} ORDER BY p_timestamp DESC LIMIT ${limit} OFFSET ${offset}`; + return Axios().post( + QUERY_URL, + { + query, + startTime, + endTime, + }, + {}, + ); +}; + +export const getQueryLogsTotalCount = (logsQuery: LogsQuery) => { + const startOfDay = dayjs().startOf('day'); + const { streamName, startTime = startOfDay.toDate(), endTime = startOfDay.endOf('day').toDate() } = logsQuery; + + const query = `SELECT COUNT(*) as 'totalCount' FROM ${streamName}`; + + return Axios().post(LOG_STREAMS_QUERY_URL, { + query, + startTime, + endTime, + }); +}; diff --git a/src/components/Button/Retry.tsx b/src/components/Button/Retry.tsx new file mode 100644 index 00000000..eb3be607 --- /dev/null +++ b/src/components/Button/Retry.tsx @@ -0,0 +1,21 @@ +import { Button, ButtonProps, px } from '@mantine/core'; +import { IconReload } from '@tabler/icons-react'; +import { FC } from 'react'; +import { useButtonStyles } from './styles'; + +type RetryProps = ButtonProps & { + onClick: () => void; +}; + +export const RetryBtn: FC = (props) => { + const { className, ...restProps } = props; + + const { classes, cx } = useButtonStyles(); + const { retryBtn } = classes; + + return ( + + ); +}; diff --git a/src/components/Button/styles.tsx b/src/components/Button/styles.tsx new file mode 100644 index 00000000..95fd7610 --- /dev/null +++ b/src/components/Button/styles.tsx @@ -0,0 +1,13 @@ +import { createStyles } from '@mantine/core'; + +export const useButtonStyles = createStyles((theme) => { + const { colors, primaryColor } = theme; + + const pColor = colors[primaryColor][1]; + + return { + retryBtn: { + backgroundColor: pColor, + }, + }; +}); diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index fd94060e..acb29389 100644 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -19,7 +19,7 @@ const Header: FC = (props) => { const { container, actionsContainer } = classes; return ( - + Parseable Logo diff --git a/src/components/Header/styles.tsx b/src/components/Header/styles.tsx index 54726834..ea870aae 100644 --- a/src/components/Header/styles.tsx +++ b/src/components/Header/styles.tsx @@ -14,7 +14,6 @@ export const useHeaderStyles = createStyles((theme) => { alignItems: 'center', justifyContent: 'space-between', paddingLeft: spacing.lg, - border: 'none', }, actionsContainer: { diff --git a/src/components/Mantine/theme.tsx b/src/components/Mantine/theme.tsx index ceebb547..c8e95f7b 100644 --- a/src/components/Mantine/theme.tsx +++ b/src/components/Mantine/theme.tsx @@ -1,4 +1,4 @@ -import type { CSSObject, MantineTheme, MantineThemeOverride } from '@mantine/core'; +import type { CSSObject, MantineThemeOverride } from '@mantine/core'; import { heights, widths, sizing } from './sizing'; const globalStyles = (): CSSObject => { @@ -41,8 +41,114 @@ export const theme: MantineThemeOverride = { }, }, components: { + Pagination: { + styles: ({ colors }) => { + return { + control: { + '&[data-active=true]': { + background: colors.brandSecondary[1], + + ':hover': { + background: colors.brandSecondary[1], + }, + }, + }, + }; + }, + }, + Checkbox: { + styles: ({ colors }) => { + const pColor = colors.brandSecondary[1]; + + return { + labelWrapper: { + width: '100%', + }, + label: { + cursor: 'pointer', + }, + input: { + cursor: 'pointer', + ':hover': { + borderColor: pColor, + }, + + '&:checked': { + backgroundColor: pColor, + borderColor: pColor, + }, + }, + }; + }, + }, + ScrollArea: { + styles: ({ colors }) => ({ + scrollbar: { + [`&[data-orientation="vertical"] .mantine-ScrollArea-thumb, + &[data-orientation="horizontal"] .mantine-ScrollArea-thumb`]: { + backgroundColor: colors.brandPrimary[2], + }, + }, + + corner: { + opacity: 1, + background: colors.gray[0], + }, + }), + }, + Table: { + styles: ({ spacing, radius, colors, fontSizes, other: { fontWeights } }) => ({ + root: { + borderRadius: radius.md, + background: colors.white, + borderCollapse: 'separate', + borderSpacing: 0, + padding: `${spacing.md} ${spacing.sm}`, + height: 20, + + '& tr th': { + background: colors.gray[2], + borderBottom: 'none !important', + padding: '0 !important', + overflow: 'hidden', + whiteSpace: 'nowrap', + }, + + '& tr th span': { + display: 'inline-block', + fontSize: fontSizes.sm, + fontWeight: fontWeights.semibold, + padding: spacing.sm, + textAlign: 'center', + width: '100%', + }, + + '& tr th:first-of-type': { + borderTopLeftRadius: radius.sm, + borderBottomLeftRadius: radius.sm, + }, + + '& tr th:last-of-type': { + borderTopRightRadius: radius.sm, + borderBottomRightRadius: radius.sm, + }, + }, + }), + }, + Drawer: { + defaultProps: ({ colors }) => { + return { + withinPortal: true, + overlayProps: { + color: colors.gray[3], + opacity: 0.55, + blur: 3, + }, + }; + }, + }, Modal: { - defaultProps: ({ colors }: MantineTheme) => ({ + defaultProps: ({ colors }) => ({ withinPortal: true, overlayProps: { color: colors.gray[3], @@ -52,7 +158,7 @@ export const theme: MantineThemeOverride = { }), }, Highlight: { - defaultProps: ({ colors, other }: MantineTheme) => ({ + defaultProps: ({ colors, other }) => ({ highlightStyles: { color: colors.dark, background: colors.yellow[3], diff --git a/src/components/Navbar/styles.tsx b/src/components/Navbar/styles.tsx index cab773b6..981e47fa 100644 --- a/src/components/Navbar/styles.tsx +++ b/src/components/Navbar/styles.tsx @@ -12,7 +12,6 @@ export const useNavbarStyles = createStyles((theme) => { background: pColor, paddingTop: spacing.lg, flexDirection: 'column', - border: 'none', alignItems: 'center', }, diff --git a/src/components/Table/index.tsx b/src/components/Table/index.tsx new file mode 100644 index 00000000..b7ac1975 --- /dev/null +++ b/src/components/Table/index.tsx @@ -0,0 +1,36 @@ +import type { ComponentPropsWithRef, FC } from 'react'; + +type ThProps = { + text: string; + onSort?: () => void; +}; + +export const Th: FC = (props) => { + const { text } = props; + + return ( + + {text} + + ); +}; + +type TheadProps = ComponentPropsWithRef<'thead'>; + +export const Thead: FC = (props) => { + const { children, ...restProps } = props; + + return ( + + {children} + + ); +}; + +type TbodyProps = ComponentPropsWithRef<'tbody'>; + +export const Tbody: FC = (props) => { + const { children, ...restProps } = props; + + return {children}; +}; diff --git a/src/hooks/useGetLogStreamList.ts b/src/hooks/useGetLogStreamList.ts index 9f41fdb1..4e1e7c86 100644 --- a/src/hooks/useGetLogStreamList.ts +++ b/src/hooks/useGetLogStreamList.ts @@ -24,9 +24,11 @@ export const useGetLogStreamList = () => { break; } default: { - setError('Something went wrong!.'); + setError('Failed to get log streams'); } } + } catch { + setError('Failed to get log streams'); } finally { setLoading(false); } @@ -36,5 +38,5 @@ export const useGetLogStreamList = () => { getData(); }, []); - return { data, error, loading }; + return { data, error, loading, getData }; }; diff --git a/src/hooks/useGetLogStreamSchema.ts b/src/hooks/useGetLogStreamSchema.ts new file mode 100644 index 00000000..ed333630 --- /dev/null +++ b/src/hooks/useGetLogStreamSchema.ts @@ -0,0 +1,40 @@ +import { LogStreamSchemaData } from '@/@types/parseable/api/stream'; +import { getLogStreamSchema } from '@/api/logStream'; +import { StatusCodes } from 'http-status-codes'; +import useMountedState from './useMountedState'; + +export const useGetLogStreamSchema = () => { + const [data, setData] = useMountedState(null); + const [error, setError] = useMountedState(null); + const [loading, setLoading] = useMountedState(false); + + const getDataSchema = async (streamName: string) => { + try { + setLoading(true); + setError(null); + const res = await getLogStreamSchema(streamName); + + switch (res.status) { + case StatusCodes.OK: { + const streams = res.data; + + setData(streams); + break; + } + default: { + setError('Failed to get log schema'); + } + } + } catch { + setError('Failed to get log schema'); + } finally { + setLoading(false); + } + }; + + const resetData = () => { + setData(null); + }; + + return { data, error, loading, getDataSchema, resetData }; +}; diff --git a/src/hooks/useQueryLogs.ts b/src/hooks/useQueryLogs.ts new file mode 100644 index 00000000..923a667b --- /dev/null +++ b/src/hooks/useQueryLogs.ts @@ -0,0 +1,67 @@ +import { LogsData } from '@/@types/parseable/api/stream'; +import { getQueryLogs, getQueryLogsTotalCount } from '@/api/query'; +import { StatusCodes } from 'http-status-codes'; +import useMountedState from './useMountedState'; +import { LogsQuery } from '@/@types/parseable/api/query'; + +export const useQueryLogs = () => { + const [data, setData] = useMountedState<{ + totalCount: number; + totalPages: number; + data: LogsData; + page: number; + } | null>(null); + const [error, setError] = useMountedState(null); + const [loading, setLoading] = useMountedState(true); + + const getQueryData = async (logsQuery: LogsQuery) => { + const { limit = 30, page = 1 } = logsQuery; + + try { + setLoading(true); + setError(null); + + const [logsQueryRes, logsQueryTotalCountRes] = await Promise.all([ + getQueryLogs(logsQuery), + getQueryLogsTotalCount(logsQuery), + ]); + + const data = logsQueryRes.data; + + if (logsQueryRes.status === StatusCodes.OK && logsQueryTotalCountRes.status === StatusCodes.OK) { + const totalCount = logsQueryTotalCountRes.data[0].totalCount; + const totalPages = Math.ceil(totalCount / limit); + + setData({ + data, + totalCount, + totalPages, + page, + }); + return; + } + + if (typeof data === 'string' && data.includes('Stream is not initialized yet')) { + setData({ + data: [], + totalCount: 0, + totalPages: 0, + page, + }); + return; + } + + setError('Failed to query log'); + } catch { + setError('Failed to query log'); + } finally { + setLoading(false); + } + }; + + const resetData = () => { + setData(null); + }; + + return { data, error, loading, getQueryData, resetData }; +}; diff --git a/src/layouts/MainLayout/index.tsx b/src/layouts/MainLayout/index.tsx index cf6d1f3a..471fd41e 100644 --- a/src/layouts/MainLayout/index.tsx +++ b/src/layouts/MainLayout/index.tsx @@ -1,6 +1,6 @@ import Header from '@/components/Header'; import Navbar from '@/components/Navbar'; -import { APP_MIN_WIDTH, NAVBAR_WIDTH } from '@/constants/theme'; +import { NAVBAR_WIDTH } from '@/constants/theme'; import { AppShell } from '@mantine/core'; import type { FC } from 'react'; import { Outlet } from 'react-router-dom'; @@ -14,7 +14,6 @@ const MainLayout: FC = () => { styles={() => ({ main: { display: 'flex', - minWidth: APP_MIN_WIDTH, }, })}> diff --git a/src/pages/Logs/Context.tsx b/src/pages/Logs/Context.tsx index f13e8169..bcee440d 100644 --- a/src/pages/Logs/Context.tsx +++ b/src/pages/Logs/Context.tsx @@ -1,3 +1,4 @@ +import { LogsData } from '@/@types/parseable/api/stream'; import useSubscribeState, { SubData } from '@/hooks/useSubscribeState'; import { FC, ReactNode, createContext, useContext } from 'react'; @@ -7,6 +8,8 @@ const { Provider } = Context; interface LogsPageContextState { subSelectedStream: SubData; + subLogStreamError: SubData; + subViewLog: SubData; } // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -23,9 +26,13 @@ interface LogsPageProviderProps { const LogsPageProvider: FC = ({ children }) => { const subSelectedStream = useSubscribeState(''); + const subLogStreamError = useSubscribeState(null); + const subViewLog = useSubscribeState(null); const state: LogsPageContextState = { subSelectedStream, + subLogStreamError, + subViewLog, }; const methods: LogsPageContextMethods = {}; diff --git a/src/pages/Logs/LogRow.tsx b/src/pages/Logs/LogRow.tsx new file mode 100644 index 00000000..1bd00857 --- /dev/null +++ b/src/pages/Logs/LogRow.tsx @@ -0,0 +1,63 @@ +import { LogStreamData, LogsData } from '@/@types/parseable/api/stream'; +import { parseLogData } from '@/utils'; +import { Box, px } from '@mantine/core'; +import { IconArrowNarrowRight } from '@tabler/icons-react'; +import { FC, Fragment } from 'react'; +import { useLogsPageContext } from './Context'; +import { useLogTableStyles } from './styles'; + +const skipFields = ['p_metadata', 'p_tags']; + +type LogRowProps = { + logData: LogsData; + logsSchema: LogStreamData; + isColumnActive: (columnName: string) => boolean; +}; + +const LogRow: FC = (props) => { + const { logData, logsSchema, isColumnActive } = props; + const { + state: { subViewLog }, + } = useLogsPageContext(); + + const onShow = (log: LogsData[number]) => { + subViewLog.set(log); + }; + + const { classes } = useLogTableStyles(); + const { trStyle } = classes; + + return ( + + {logData.map((log) => { + return ( + // Using p_timestamp as a key since it's guaranteed felid and unique + onShow(log)}> + {logsSchema.map((logSchema) => { + if (!isColumnActive(logSchema.name) || skipFields.includes(logSchema.name)) return null; + + // Using logSchema name and p_timestamp as a key since it's guaranteed felid and unique + return {parseLogData(log[logSchema.name])}; + })} + + + ); + })} + + ); +}; + +const TdArrow: FC = () => { + const { classes } = useLogTableStyles(); + const { tdArrow, tdArrowContainer } = classes; + + return ( + + + + + + ); +}; + +export default LogRow; diff --git a/src/pages/Logs/LogStreamList.tsx b/src/pages/Logs/LogStreamList.tsx index 1fa53499..7b94a042 100644 --- a/src/pages/Logs/LogStreamList.tsx +++ b/src/pages/Logs/LogStreamList.tsx @@ -1,4 +1,4 @@ -import { Box, Divider, TextInput, Tooltip, Highlight, UnstyledButton, Center, ScrollArea, px } from '@mantine/core'; +import { Box, Divider, TextInput, Tooltip, Highlight, UnstyledButton, ScrollArea, px, Center } from '@mantine/core'; import type { UnstyledButtonProps } from '@mantine/core'; import type { ComponentPropsWithRef, ChangeEvent, FC } from 'react'; import { useMemo, useEffect } from 'react'; @@ -8,17 +8,16 @@ import { IconChevronLeft, IconChevronRight, IconSearch } from '@tabler/icons-rea import { useGetLogStreamList } from '@/hooks/useGetLogStreamList'; import Loading from '@/components/Loading'; import EmptyBox from '@/components/Empty'; -import ErrorText from '@/components/Text/ErrorText'; -import { heights } from '@/components/Mantine/sizing'; import { useLogsPageContext } from './Context'; import { useDisclosure } from '@mantine/hooks'; +import { RetryBtn } from '@/components/Button/Retry'; const LogStreamList: FC = () => { const { - state: { subSelectedStream }, + state: { subSelectedStream, subLogStreamError }, } = useLogsPageContext(); - const { data: streams, loading, error } = useGetLogStreamList(); + const { data: streams, loading, error, getData } = useGetLogStreamList(); const [selectedStream, setSelectedStream] = useMountedState(''); const [search, setSearch] = useMountedState(''); @@ -33,7 +32,11 @@ const LogStreamList: FC = () => { }, [streams, search]); useEffect(() => { - if (streams) { + subLogStreamError.set(error); + }, [error]); + + useEffect(() => { + if (streams && !!streams.length) { subSelectedStream.set(streams[0].name); } }, [streams]); @@ -55,17 +58,17 @@ const LogStreamList: FC = () => { const { classes, cx } = useLogStreamListStyles(); const { container, - containerClose, streamContainer, streamContainerClose, searchInputStyle, streamListContainer, chevronBtn, chevronBtnClose, + retryContainer, } = classes; return ( - + { ))} {error && ( -
- {error} +
+
)} diff --git a/src/pages/Logs/LogTable.tsx b/src/pages/Logs/LogTable.tsx new file mode 100644 index 00000000..21652196 --- /dev/null +++ b/src/pages/Logs/LogTable.tsx @@ -0,0 +1,207 @@ +import Loading from '@/components/Loading'; +import { Tbody, Th, Thead } from '@/components/Table'; +import { useGetLogStreamSchema } from '@/hooks/useGetLogStreamSchema'; +import { useQueryLogs } from '@/hooks/useQueryLogs'; +import { Box, Center, Checkbox, Menu, Pagination, ScrollArea, Table, px, ActionIcon } from '@mantine/core'; +import dayjs from 'dayjs'; +import { FC, Fragment, useCallback, useEffect, useMemo, useState } from 'react'; +import { useLogsPageContext } from './Context'; +import LogRow from './LogRow'; +import { useLogTableStyles } from './styles'; +import useMountedState from '@/hooks/useMountedState'; +import ErrorText from '@/components/Text/ErrorText'; +import { IconDotsVertical } from '@tabler/icons-react'; +import { Field } from '@/@types/parseable/dataType'; +import EmptyBox from '@/components/Empty'; +import { RetryBtn } from '@/components/Button/Retry'; + +const skipFields = ['p_metadata', 'p_tags']; + +const LogTable: FC = () => { + const { + state: { subSelectedStream, subLogStreamError }, + } = useLogsPageContext(); + + const [logStreamError, setLogStreamError] = useMountedState(null); + const [columnToggles, setColumnToggles] = useMountedState>(new Map()); + const { data: logsSchema, getDataSchema, resetData, loading, error: logStreamSchemaError } = useGetLogStreamSchema(); + const { data: logs, getQueryData, loading: logsLoading, error: logsError, resetData: resetLogsData } = useQueryLogs(); + + const isColumnActive = useCallback( + (columnName: string) => { + if (!columnToggles.has(columnName)) return true; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return columnToggles.get(columnName)!; + }, + [columnToggles], + ); + + const toggleColumn = (columnName: string, value: boolean) => { + setColumnToggles(new Map(columnToggles.set(columnName, value))); + }; + + const onRetry = () => { + resetData(); + resetLogsData(); + getDataSchema(subSelectedStream.get()); + setColumnToggles(new Map()); + }; + + useEffect(() => { + const listener = subLogStreamError.subscribe(setLogStreamError); + return () => listener(); + }, []); + + useEffect(() => { + const listener = subSelectedStream.subscribe((streamName) => { + if (logsSchema) { + resetData(); + resetLogsData(); + } + getDataSchema(streamName); + setColumnToggles(new Map()); + }); + + return () => listener(); + }, [logsSchema]); + + useEffect(() => { + if (logsSchema) { + getQueryData({ + startTime: dayjs('2023-05-01T20:59:59.999Z').toDate(), + streamName: subSelectedStream.get(), + }); + } + }, [logsSchema]); + + const renderTh = useMemo(() => { + if (logsSchema) { + return logsSchema.fields.map((field) => { + if (!isColumnActive(field.name) || skipFields.includes(field.name)) return null; + + return ; + }); + } + + return null; + }, [logsSchema, columnToggles]); + + const { classes } = useLogTableStyles(); + + const { container, tableContainer, tableStyle, theadStyle, errorContainer } = classes; + + return ( + + {!(logStreamError || logStreamSchemaError || logsError) ? ( + !loading && !logsLoading && !!logsSchema && !!logs ? ( + !!logsSchema.fields.length && !!logs.data.length ? ( + + + + + {renderTh} + + + + + +
+
+ + {logs.totalPages > 1 && ( + { + getQueryData({ + startTime: dayjs('2023-05-01T20:59:59.999Z').toDate(), + streamName: subSelectedStream.get(), + page: value, + }); + }} + /> + )} +
+ ) : ( + + ) + ) : ( + + ) + ) : ( +
+ {logStreamError || logStreamSchemaError || logsError} + {(logsError || logStreamSchemaError) && } +
+ )} +
+ ); +}; + +type ThColumnMenuProps = { + logSchemaFields: Array; + columnToggles: Map; + toggleColumn: (columnName: string, value: boolean) => void; + isColumnActive: (columnName: string) => boolean; +}; + +const ThColumnMenu: FC = (props) => { + const { logSchemaFields, isColumnActive, toggleColumn } = props; + + const [opened, setOpened] = useState(false); + + const toggle = () => { + setOpened(!opened); + }; + + const { classes } = useLogTableStyles(); + const { thColumnMenuBtn, thColumnMenuDropdown } = classes; + + return ( + + +
+ + + + + +
+ + {logSchemaFields.map((field) => { + if (skipFields.includes(field.name)) return null; + + return ( + + toggleColumn(field.name, event.currentTarget.checked)} + /> + + ); + })} + +
+ + ); +}; + +export default LogTable; diff --git a/src/pages/Logs/ViewLog.tsx b/src/pages/Logs/ViewLog.tsx new file mode 100644 index 00000000..2b0a3beb --- /dev/null +++ b/src/pages/Logs/ViewLog.tsx @@ -0,0 +1,121 @@ +import useMountedState from '@/hooks/useMountedState'; +import { Box, Chip, CloseButton, Divider, Drawer, Text, px } from '@mantine/core'; +import { Prism } from '@mantine/prism'; +import type { FC } from 'react'; +import { useEffect, Fragment, useMemo } from 'react'; +import { useLogsPageContext } from './Context'; +import { useViewLogStyles } from './styles'; +import dayjs from 'dayjs'; + +const ViewLog: FC = () => { + const { + state: { subViewLog }, + } = useLogsPageContext(); + + const [log, setLog] = useMountedState(subViewLog.get()); + + useEffect(() => { + const listener = subViewLog.subscribe(setLog); + + return () => listener(); + }, []); + + const onClose = () => { + subViewLog.set(null); + }; + + const { classes } = useViewLogStyles(); + const { container } = classes; + + const p_metadata = useMemo(() => { + if (log) { + const metadata = log.p_metadata.split('^').filter(Boolean); + if (metadata.length) return metadata; + } + return []; + }, [log]); + + const p_tags = useMemo(() => { + if (log) { + const tags = log.p_tags.split('^').filter(Boolean); + if (tags.length) return tags; + } + return []; + }, [log]); + + return ( + +
+ + {!!log && ( + + + + + + {JSON.stringify(log, null, 2)} + + + )} + + ); +}; + +type HeaderProps = { + timeStamp: string; + onClose: () => void; +}; + +const Header: FC = (props) => { + const { onClose } = props; + const { classes } = useViewLogStyles(); + + const { headerContainer, headerTimeStampTitle, headerTimeStamp } = classes; + + const timeStamp = useMemo(() => dayjs(props.timeStamp).format('DD/MM/YYYY (hh:mm:ss A)'), []); + + return ( + + + Timestamp + {timeStamp} + + + + + ); +}; + +type DataChipProps = { + title: string; + dataList: string[]; +}; + +const DataChip: FC = (props) => { + const { dataList, title } = props; + const { classes } = useViewLogStyles(); + const { dataChipContainer } = classes; + + return dataList.length ? ( + + + + {[...dataList, ...dataList, ...dataList, ...dataList, ...dataList].map((data) => { + return ( + + {data} + + ); + })} + + + ) : null; +}; + +export default ViewLog; diff --git a/src/pages/Logs/index.tsx b/src/pages/Logs/index.tsx index 906c86ec..1cfacab0 100644 --- a/src/pages/Logs/index.tsx +++ b/src/pages/Logs/index.tsx @@ -1,10 +1,10 @@ -import { Box, Center, Text } from '@mantine/core'; +import { Box } from '@mantine/core'; import { useDocumentTitle } from '@mantine/hooks'; -import { FC, useEffect } from 'react'; +import { FC } from 'react'; import LogStreamList from './LogStreamList'; +import LogTable from './LogTable'; import { useLogsStyles } from './styles'; -import { useLogsPageContext } from './Context'; -import useMountedState from '@/hooks/useMountedState'; +import ViewLog from './ViewLog'; const Logs: FC = () => { useDocumentTitle('Parseable | Dashboard'); @@ -12,27 +12,11 @@ const Logs: FC = () => { const { classes } = useLogsStyles(); const { container } = classes; - const { - state: { subSelectedStream }, - } = useLogsPageContext(); - - const [selectedStream, setSelectedStream] = useMountedState(''); - - useEffect(() => { - const listener = subSelectedStream.subscribe(setSelectedStream); - - return () => listener(); - }, []); - return ( -
- {selectedStream} -
+ +
); }; diff --git a/src/pages/Logs/styles.tsx b/src/pages/Logs/styles.tsx index ae12f727..f6a8c4fc 100644 --- a/src/pages/Logs/styles.tsx +++ b/src/pages/Logs/styles.tsx @@ -1,11 +1,13 @@ import { HEADER_HEIGHT, NAVBAR_WIDTH } from '@/constants/theme'; -import { createStyles } from '@mantine/core'; +import { createStyles, px } from '@mantine/core'; export const useLogsStyles = createStyles(() => { return { container: { flex: 1, + width: '100%', display: 'flex', + position: 'relative', }, }; }); @@ -19,48 +21,43 @@ export const useLogStreamListStyles = createStyles((theme) => { return { container: { - background: colors.gray[0], - width: containerWidth, position: 'sticky', left: NAVBAR_WIDTH, + height: '100%', display: 'flex', - flexDirection: 'column', - transition: 'width 0.3s ease', - borderRightWidth: widths.px, - borderColor: colors.gray[1], - borderRightStyle: 'solid', - }, - - containerClose: { - width: 0, - borderRightStyle: 'none', }, streamContainer: { - flex: 1, + paddingBottom: spacing.lg, + width: containerWidth, + position: 'sticky', display: 'flex', flexDirection: 'column', maxHeight: `calc(${heights.screen} - ${HEADER_HEIGHT}px)`, visibility: 'visible', + background: colors.gray[0], + transition: 'width 0.3s ease', + borderRightWidth: widths.px, + borderColor: colors.gray[1], + borderRightStyle: 'solid', + overflowY: 'scroll', }, streamContainerClose: { - visibility: 'hidden', + borderRightStyle: 'none', + width: 0, }, chevronBtn: { - position: 'absolute', - top: '50%', + alignSelf: 'center', width: widths[6], height: heights[9], - transform: 'translate(0, -50%)', background: colors.gray[0], display: 'flex', justifyItems: 'center', alignItems: 'center', borderTopRightRadius: radius.md, borderBottomRightRadius: radius.md, - right: `calc(0px + -${widths[6]})`, borderRightWidth: widths.px, borderTopWidth: widths.px, borderBottomWidth: widths.px, @@ -120,5 +117,140 @@ export const useLogStreamListStyles = createStyles((theme) => { streamTextActive: { color: colors.white[0], }, + + retryContainer: { + height: heights.full, + width: widths.full, + }, + }; +}); + +export const useLogTableStyles = createStyles((theme) => { + const { spacing, other, radius, shadows, colors } = theme; + const { heights, widths } = other; + + return { + container: { + position: 'relative', + flex: 1, + maxHeight: `calc(${heights.screen} - ${HEADER_HEIGHT}px)`, + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + padding: px(spacing.sm), + }, + + tableContainer: { + position: 'relative', + boxShadow: shadows.xs, + borderRadius: radius.md, + overflow: 'scroll', + }, + + tableStyle: { + whiteSpace: 'nowrap', + overflow: 'scroll', + width: '100%', + paddingBottom: 0, + }, + + theadStyle: { + position: 'sticky', + top: 0, + zIndex: 1, + + '& th:last-of-type': { + position: 'sticky', + boxShadow: shadows.xl, + right: 0, + }, + }, + + trStyle: { + cursor: 'pointer', + + '&:hover': { + background: colors.gray[1], + }, + + '& td': { + height: heights[14], + textAlign: 'left', + verticalAlign: 'middle', + }, + }, + + tdArrowContainer: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + }, + + tdArrow: { + position: 'sticky', + right: 0, + background: colors.white[0], + boxShadow: shadows.xl, + + 'tr:hover &': { + background: colors.gray[1], + }, + }, + + thColumnMenuBtn: { + width: widths[10], + height: heights[10], + }, + + thColumnMenuDropdown: { + maxHeight: heights[96], + overflowX: 'hidden', + overflowY: 'scroll', + }, + + errorContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + height: heights.full, + }, + }; +}); + +export const useViewLogStyles = createStyles((theme) => { + const { spacing, other, colors, primaryColor, fontSizes } = theme; + const { fontWeights } = other; + const pColor = colors[primaryColor][1]; + + return { + container: { + padding: spacing.lg, + }, + + headerContainer: { + background: pColor, + padding: spacing.sm, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }, + + headerTimeStampTitle: { + color: colors.white, + fontSize: fontSizes.sm, + fontWeight: fontWeights.semibold, + }, + + headerTimeStamp: { + color: colors.white, + fontSize: fontSizes.sm, + fontWeight: fontWeights.medium, + }, + + dataChipContainer: { + display: 'flex', + flexWrap: 'wrap', + }, }; }); diff --git a/src/utils/index.ts b/src/utils/index.ts index 24a8e0dd..c8d1a233 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,5 @@ +import dayjs from 'dayjs'; + export const wait = (sec = 1) => new Promise((res) => setTimeout(res, sec * 1000)); export const randNum = (min = 1, max = 5) => Math.floor(Math.random() * (max - min + 1)) + min; @@ -12,3 +14,16 @@ export const scrollTo = (opts?: ScrollToOptions) => { const { y = 0, x = 0, behavior = 'auto' } = opts || {}; window.scrollTo({ top: y, left: x, behavior }); }; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const parseLogData = (value?: any) => { + if (typeof value === 'string' && dayjs(value).isValid()) { + return dayjs(value).format('DD/MM/YYYY HH:mm:ss'); + } + + if (value) { + return value; + } + + return 'N/A'; +}; diff --git a/vite.config.ts b/vite.config.ts index de58842a..94cc3d80 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,13 +1,13 @@ -import { defineConfig } from "vite"; -import react from "@vitejs/plugin-react-swc"; -import path from "path"; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react-swc'; +import path from 'path'; // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()], - resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), - }, - }, + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, });