From 33031ece3ddbee646e77573c0f41b2607122d90b Mon Sep 17 00:00:00 2001 From: Balaji Date: Tue, 23 Jan 2024 23:34:09 +0530 Subject: [PATCH] ui-feat - homepage with quick stats of all streams (#184) --- src/@types/parseable/api/query.ts | 10 ++ src/components/Header/SubHeader.tsx | 14 ++ src/components/Navbar/index.tsx | 71 +++++----- src/components/Navbar/rolesHandler.ts | 6 +- src/hooks/useGetStreamMetadata.ts | 50 +++++++ src/layouts/MainLayout/Context.tsx | 49 ++++++- src/pages/Home/index.tsx | 188 ++++++++++++++++++++++---- src/pages/Home/styles.tsx | 44 +++++- src/routes/elements.tsx | 10 +- src/utils/formatBytes.ts | 18 +++ 10 files changed, 382 insertions(+), 78 deletions(-) create mode 100644 src/hooks/useGetStreamMetadata.ts diff --git a/src/@types/parseable/api/query.ts b/src/@types/parseable/api/query.ts index dbb41d35..8e31e20d 100644 --- a/src/@types/parseable/api/query.ts +++ b/src/@types/parseable/api/query.ts @@ -38,3 +38,13 @@ export type LogSelectedTimeRange = { state : "fixed"| "custom"; value : string; }; + +export type UserRoles = { + roleName: { + privilege: string; + resource?: { + stream: string; + tag: string; + }; + }[]; +}; diff --git a/src/components/Header/SubHeader.tsx b/src/components/Header/SubHeader.tsx index 7ce3f9ab..5c404656 100644 --- a/src/components/Header/SubHeader.tsx +++ b/src/components/Header/SubHeader.tsx @@ -134,6 +134,20 @@ export const LogsHeader: FC = () => { ); }; +export const HomeHeader: FC = () => { + const { classes } = useLogQueryStyles(); + const { container, innerContainer } = classes; + return ( + + + + + + + + ); +}; + export const ConfigHeader: FC = () => { const { classes } = useLogQueryStyles(); const { container, innerContainer } = classes; diff --git a/src/components/Navbar/index.tsx b/src/components/Navbar/index.tsx index ca16b9d7..b05142ab 100644 --- a/src/components/Navbar/index.tsx +++ b/src/components/Navbar/index.tsx @@ -19,9 +19,8 @@ import { useNavbarStyles } from './styles'; import { useLocation, useParams } from 'react-router-dom'; import { notifications } from '@mantine/notifications'; import { useNavigate } from 'react-router-dom'; -import { DEFAULT_FIXED_DURATIONS, useHeaderContext } from '@/layouts/MainLayout/Context'; +import { useHeaderContext } from '@/layouts/MainLayout/Context'; import useMountedState from '@/hooks/useMountedState'; -import dayjs from 'dayjs'; import { useDisclosure } from '@mantine/hooks'; import { USERS_MANAGEMENT_ROUTE } from '@/constants/routes'; import InfoModal from './infoModal'; @@ -36,7 +35,9 @@ const baseURL = import.meta.env.VITE_PARSEABLE_URL ?? '/'; const isSecureConnection = window.location.protocol === 'https:'; const links = [ { icon: IconTableShortcut, label: 'Explore', pathname: '/logs', requiredAccess: ['Query', 'GetSchema'] }, - ...(!isSecureConnection ? [{ icon: IconTimelineEvent, label: 'Live tail', pathname: '/live-tail', requiredAccess: ['GetLiveTail'] }] : []), + ...(!isSecureConnection + ? [{ icon: IconTimelineEvent, label: 'Live tail', pathname: '/live-tail', requiredAccess: ['GetLiveTail'] }] + : []), { icon: IconReportAnalytics, label: 'Stats', pathname: '/stats', requiredAccess: ['GetStats'] }, { icon: IconSettings, label: 'Config', pathname: '/config', requiredAccess: ['PutAlert'] }, ]; @@ -51,12 +52,13 @@ const Navbar: FC = (props) => { const username = Cookies.get('username'); const { - state: { subNavbarTogle }, + state: { subNavbarTogle, subAppContext }, + methods: { streamChangeCleanup, setUserRoles, setSelectedStream }, } = useHeaderContext(); - const [activeStream, setActiveStream] = useMountedState(''); + const selectedStream = subAppContext.get().selectedStream; const [searchValue, setSearchValue] = useMountedState(''); - const [currentPage, setCurrentPage] = useMountedState('/logs'); + const [currentPage, setCurrentPage] = useMountedState('/'); const [deleteStream, setDeleteStream] = useMountedState(''); const [userSepecficStreams, setUserSepecficStreams] = useMountedState(null); const [userSepecficAccess, setUserSepecficAccess] = useMountedState(null); @@ -85,16 +87,16 @@ const Navbar: FC = (props) => { window.location.href = `${baseURL}api/v1/o/logout?redirect=${window.location.origin}/login`; }; - const { - state: { subLogQuery, subLogSelectedTimeRange, subLogSearch, subRefreshInterval }, - } = useHeaderContext(); - useEffect(() => { if (location.pathname.split('/')[2]) { setCurrentPage(`/${location.pathname.split('/')[2]}`); } - if (userSepecficStreams && userSepecficStreams.length === 0) { - setActiveStream(''); + if (location.pathname === '/') { + setSelectedStream(''); + setCurrentPage('/'); + setUserSepecficAccess(getStreamsSepcificAccess(getUserRolesData?.data)); + } else if (userSepecficStreams && userSepecficStreams.length === 0) { + setSelectedStream(''); setSearchValue(''); setDisableLink(true); navigate('/'); @@ -126,33 +128,19 @@ const Navbar: FC = (props) => { }, [userSepecficStreams]); const handleChange = (value: string, page: string = currentPage) => { - handleChangeWithoutRiderection(value, page); + const targetPage = page === '/' ? '/logs' : page; + handleChangeWithoutRiderection(value, targetPage); + setUserSepecficAccess(getStreamsSepcificAccess(getUserRolesData?.data, value)); if (page !== '/users') { - navigate(`/${value}${page}`); + navigate(`/${value}${targetPage}`); } }; const handleChangeWithoutRiderection = (value: string, page: string = currentPage) => { - setActiveStream(value); + setSelectedStream(value); setSearchValue(value); setCurrentPage(page); - const now = dayjs(); - setUserSepecficAccess(getStreamsSepcificAccess(getUserRolesData?.data, value)); - subLogQuery.set((state) => { - state.streamName = value || ''; - state.startTime = now.subtract(DEFAULT_FIXED_DURATIONS.milliseconds, 'milliseconds').toDate(); - state.endTime = now.toDate(); - state.access = getStreamsSepcificAccess(getUserRolesData?.data, value); - }); - subLogSelectedTimeRange.set((state) => { - state.state = 'fixed'; - state.value = DEFAULT_FIXED_DURATIONS.name; - }); - subLogSearch.set((state) => { - state.search = ''; - state.filters = {}; - }); - subRefreshInterval.set(null); + streamChangeCleanup(value); setDisableLink(false); }; const handleCloseDelete = () => { @@ -167,6 +155,7 @@ const Navbar: FC = (props) => { useEffect(() => { if (getLogStreamListData?.data && getLogStreamListData?.data.length > 0 && getUserRolesData?.data) { + getUserRolesData?.data && setUserRoles(getUserRolesData?.data); // TODO: move user context main context const userStreams = getUserSepcificStreams(getUserRolesData?.data, getLogStreamListData?.data as any); setUserSepecficStreams(userStreams as any); } else { @@ -209,10 +198,10 @@ const Navbar: FC = (props) => { placeholder="Pick one" onChange={(value) => handleChange(value || '')} nothingFound="No options" - value={activeStream} + value={selectedStream} searchValue={searchValue} onSearchChange={(value) => setSearchValue(value)} - onDropdownClose={() => setSearchValue(activeStream)} + onDropdownClose={() => setSearchValue(selectedStream)} onDropdownOpen={() => setSearchValue('')} data={userSepecficStreams?.map((stream: any) => ({ value: stream.name, label: stream.name })) ?? []} searchable @@ -231,8 +220,9 @@ const Navbar: FC = (props) => { )} {links.map((link) => { if ( - link.requiredAccess && - !userSepecficAccess?.some((access: string) => link.requiredAccess.includes(access)) + (link.requiredAccess && + !userSepecficAccess?.some((access: string) => link.requiredAccess.includes(access))) || + selectedStream === '' ) { return null; } @@ -243,14 +233,15 @@ const Navbar: FC = (props) => { sx={{ paddingLeft: 53 }} disabled={disableLink} onClick={() => { - handleChange(activeStream, link.pathname); + handleChange(selectedStream, link.pathname); }} key={link.label} className={(currentPage === link.pathname && linkBtnActive) || linkBtn} /> ); })} - {!userSepecficAccess?.some((access: string) => ['DeleteStream'].includes(access)) ? null : ( + {!userSepecficAccess?.some((access: string) => ['DeleteStream'].includes(access)) || + selectedStream === '' ? null : ( } @@ -305,14 +296,14 @@ const Navbar: FC = (props) => { onChange={(e) => { setDeleteStream(e.target.value); }} - placeholder={`Type the name of the stream to confirm. i.e. ${activeStream}`} + placeholder={`Type the name of the stream to confirm. i.e. ${selectedStream}`} required /> diff --git a/src/components/Navbar/rolesHandler.ts b/src/components/Navbar/rolesHandler.ts index c7e99904..11fd85c9 100644 --- a/src/components/Navbar/rolesHandler.ts +++ b/src/components/Navbar/rolesHandler.ts @@ -1,3 +1,5 @@ +import { UserRoles } from '@/layouts/MainLayout/Context'; + const adminAccess = [ 'Ingest', 'Query', @@ -43,11 +45,11 @@ const writerAccess = [ const readerAccess = ['Query', 'ListStream', 'GetSchema', 'GetStats', 'GetRetention', 'GetAlert', 'GetLiveTail']; const ingesterAccess = ['Ingest']; -const getStreamsSepcificAccess = (rolesWithRoleName: object[], stream?: string) => { +const getStreamsSepcificAccess = (rolesWithRoleName: UserRoles, stream?: string) => { let access: string[] = []; let roles: any[] = []; for (var prop in rolesWithRoleName) { - roles = [...roles, ...(rolesWithRoleName[prop] as any)]; + roles = [...roles, ...rolesWithRoleName[prop]]; } roles.forEach((role: any) => { if (role.privilege === 'admin') { diff --git a/src/hooks/useGetStreamMetadata.ts b/src/hooks/useGetStreamMetadata.ts new file mode 100644 index 00000000..b3537b6f --- /dev/null +++ b/src/hooks/useGetStreamMetadata.ts @@ -0,0 +1,50 @@ +import { LogStreamRetention, LogStreamStat } from '@/@types/parseable/api/stream'; +import { getLogStreamRetention, getLogStreamStats } from '@/api/logStream'; +import { useCallback, useState } from 'react'; + +type MetaData = { + [key: string]: { + stats: LogStreamStat | {}; + retention: LogStreamRetention | []; + }; +}; + +// until dedicated endpoint been provided - fetch one by one +export const useGetStreamMetadata = () => { + const [isLoading, setLoading] = useState(false); + const [error, setError] = useState(false); + const [metaData, setMetadata] = useState(null); + + const getStreamMetadata = useCallback(async (streams: string[]) => { + setLoading(true); + try { + // stats + const allStatsReqs = streams.map((stream) => getLogStreamStats(stream)); + const allStatsRes = await Promise.all(allStatsReqs); + + // retention + const allretentionReqs = streams.map((stream) => getLogStreamRetention(stream)); + const allretentionRes = await Promise.all(allretentionReqs); + + const metadata = streams.reduce((acc, stream, index) => { + return { + ...acc, + [stream]: { stats: allStatsRes[index]?.data || {}, retention: allretentionRes[index]?.data || [] }, + }; + }, {}); + setMetadata(metadata); + } catch { + setError(true); + setMetadata(null); + } finally { + setLoading(false); + } + }, []); + + return { + isLoading, + error, + getStreamMetadata, + metaData, + }; +}; diff --git a/src/layouts/MainLayout/Context.tsx b/src/layouts/MainLayout/Context.tsx index ee5dbcc3..584d7d25 100644 --- a/src/layouts/MainLayout/Context.tsx +++ b/src/layouts/MainLayout/Context.tsx @@ -1,6 +1,7 @@ import { AboutData } from '@/@types/parseable/api/about'; import { SortOrder, type LogsQuery, type LogsSearch, type LogSelectedTimeRange } from '@/@types/parseable/api/query'; import { LogStreamData } from '@/@types/parseable/api/stream'; +import { getStreamsSepcificAccess } from '@/components/Navbar/rolesHandler'; import { FIXED_DURATIONS } from '@/constants/timeConstants'; import useSubscribeState, { SubData } from '@/hooks/useSubscribeState'; import dayjs from 'dayjs'; @@ -35,7 +36,7 @@ interface HeaderContextState { } export type UserRoles = { - roleName: { + [roleName: string]: { privilege: string; resource?: { stream: string; @@ -47,7 +48,7 @@ export type UserRoles = { export type PageOption = '/' | '/explore' | '/sql' | '/management' | '/team'; export type AppContext = { - selectedStream: string | null; + selectedStream: string; activePage: PageOption | null; action: string[] | null; userSpecificStreams: string[] | null; @@ -57,6 +58,9 @@ export type AppContext = { // eslint-disable-next-line @typescript-eslint/no-empty-interface interface HeaderContextMethods { resetTimeInterval: () => void; + streamChangeCleanup: (streamName: string) => void; + setUserRoles: (userRoles: UserRoles) => void; + setSelectedStream: (stream: string) => void; } interface HeaderContextValue { @@ -70,7 +74,7 @@ interface HeaderProviderProps { const MainLayoutPageProvider: FC = ({ children }) => { const subAppContext = useSubscribeState({ - selectedStream: null, + selectedStream: '', activePage: null, action: null, userSpecificStreams: null, @@ -118,7 +122,7 @@ const MainLayoutPageProvider: FC = ({ children }) => { }; const resetTimeInterval = useCallback(() => { - if (subLogSelectedTimeRange.get().state==='fixed') { + if (subLogSelectedTimeRange.get().state === 'fixed') { const now = dayjs(); const timeDiff = subLogQuery.get().endTime.getTime() - subLogQuery.get().startTime.getTime(); subLogQuery.set((state) => { @@ -127,7 +131,42 @@ const MainLayoutPageProvider: FC = ({ children }) => { }); } }, []); - const methods: HeaderContextMethods = {resetTimeInterval}; + + const streamChangeCleanup = useCallback((stream: string) => { + const now = dayjs(); + subLogQuery.set((state) => { + state.streamName = stream; + state.startTime = now.subtract(DEFAULT_FIXED_DURATIONS.milliseconds, 'milliseconds').toDate(); + state.endTime = now.toDate(); + state.access = getStreamsSepcificAccess(subAppContext.get().userRoles || {}, stream); + }); + subLogSelectedTimeRange.set((state) => { + state.state = 'fixed'; + state.value = DEFAULT_FIXED_DURATIONS.name; + }); + subLogSearch.set((state) => { + state.search = ''; + state.filters = {}; + }); + subRefreshInterval.set(null); + subAppContext.set((state) => { + state.selectedStream = stream; + }); + }, []); + + const setUserRoles = useCallback((userRoles: UserRoles) => { + subAppContext.set((state) => { + state.userRoles = userRoles; + }); + }, []); + + const setSelectedStream = useCallback((stream: string) => { + subAppContext.set((state) => { + state.selectedStream = stream; + }); + }, []); + + const methods: HeaderContextMethods = { resetTimeInterval, streamChangeCleanup, setUserRoles, setSelectedStream }; return {children}; }; diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx index c8b8b698..cfe058aa 100644 --- a/src/pages/Home/index.tsx +++ b/src/pages/Home/index.tsx @@ -1,34 +1,168 @@ import { EmptySimple } from '@/components/Empty'; -import { Text, Button, Center } from '@mantine/core'; -import { IconExternalLink } from '@tabler/icons-react'; -import type { FC } from 'react'; -import { Navigate, useLocation } from 'react-router-dom'; -import { useHomeStyles } from './styles'; +import { Text, Button, Center, Box, Group, ActionIcon, Flex } from '@mantine/core'; +import { IconChevronRight, IconExternalLink } from '@tabler/icons-react'; +import { useEffect, type FC, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { cardStyles, useHomeStyles } from './styles'; +import { useDocumentTitle } from '@mantine/hooks'; +import { useLogStream } from '@/hooks/useLogStream'; +import { useGetStreamMetadata } from '@/hooks/useGetStreamMetadata'; +import { HumanizeNumber, formatBytes } from '@/utils/formatBytes'; +import { LogStreamRetention, LogStreamStat } from '@/@types/parseable/api/stream'; +import { useHeaderContext } from '@/layouts/MainLayout/Context'; -const Home: FC = () => { - const location = useLocation(); - const pathname = location.state?.from?.pathname; +const EmptyStreamsView: FC = () => { + const { classes } = useHomeStyles(); + const { messageStyle, btnStyle, noDataViewContainer } = classes; + return ( +
+ + No Stream found on this account + +
+ ); +}; +const Home: FC = () => { + useDocumentTitle('Parseable | Streams'); const { classes } = useHomeStyles(); - const { container, messageStyle ,btnStyle} = classes; - if (pathname) { - return ; - } else { - return ( -
- - No Stream found on this account - -
- ); - } + const { container } = classes; + const { getLogStreamListData, getLogStreamListIsLoading, getLogStreamListIsError } = useLogStream(); + const { + methods: { streamChangeCleanup }, + } = useHeaderContext(); + const navigate = useNavigate(); + const { getStreamMetadata, metaData } = useGetStreamMetadata(); + + const streams = Array.isArray(getLogStreamListData?.data) + ? getLogStreamListData?.data.map((stream) => stream.name) || [] + : []; + + useEffect(() => { + if (streams.length === 0) return; + getStreamMetadata(streams); + }, [getLogStreamListData?.data]); + + const navigateToStream = useCallback((stream: string) => { + streamChangeCleanup(stream); + navigate(`/${stream}/logs`); + }, []); + + if (getLogStreamListIsError || getLogStreamListIsLoading) return null; // implement loading state + if (streams.length === 0) return ; + + return ( + + + {Object.entries(metaData || {}).map(([stream, data]) => { + return ; + })} + + + ); }; export default Home; + +const BigNumber = (props: { label: string; value: any; color?: string }) => { + const { classes } = cardStyles(); + + return ( + + + {props.label} + + + {props.value} + + + ); +}; + +const sizetoInteger = (str: string) => { + if (!str || typeof str !== 'string') return null; + + const strChuncks = str?.split(' '); + return Array.isArray(strChuncks) && !isNaN(Number(strChuncks[0])) ? parseInt(strChuncks[0]) : null; +}; + +const sanitizeBytes = (str: any) => { + const size = sizetoInteger(str); + return size ? formatBytes(size) : '–'; +}; + +const sanitizeCount = (val: any) => { + return typeof val === 'number' ? HumanizeNumber(val) : '–'; +}; + +const calcCompressionRate = (storageSize: string, ingestionSize: string) => { + const parsedStorageSize = sizetoInteger(storageSize); + const parsedIngestionSize = sizetoInteger(ingestionSize); + + if (parsedIngestionSize === null || parsedStorageSize === null) return '–'; + + if (parsedIngestionSize === 0) return '0%'; + + const rate = (100 - (parsedStorageSize / parsedIngestionSize) * 100).toPrecision(4); + return `${rate}%`; +}; + +type StreamInfoProps = { + stream: string; + data: { + stats: LogStreamStat | {}; + retention: LogStreamRetention | []; + }; + navigateToStream: (stream: string) => void; +}; + +const StreamInfo: FC = (props) => { + const { classes } = cardStyles(); + const { + stream, + data: { stats = {}, retention = [] }, + navigateToStream, + } = props; + const streamRetention = retention?.length ? retention[0].duration : 'Not Set'; + const ingestionCount = (stats as LogStreamStat)?.ingestion?.count; + const ingestionSize = (stats as LogStreamStat)?.ingestion?.size; + const storageSize = (stats as LogStreamStat)?.storage?.size; + return ( + { + navigateToStream(stream); + }} + w="100%"> + + + + {'Stream'} + + + {stream} + + + + + + + + + + + + + + + + + ); +}; diff --git a/src/pages/Home/styles.tsx b/src/pages/Home/styles.tsx index bfd0bdd5..2a53ee9c 100644 --- a/src/pages/Home/styles.tsx +++ b/src/pages/Home/styles.tsx @@ -1,9 +1,15 @@ -import { NAVBAR_WIDTH } from '@/constants/theme'; +import { NAVBAR_WIDTH } from '@/constants/theme'; import { createStyles } from '@mantine/core'; export const useHomeStyles = createStyles((theme) => { - const { spacing ,colors} = theme; + const { spacing, colors } = theme; return { container: { + flex: 1, + display: 'flex', + position: 'relative', + flexDirection: 'column', + }, + noDataViewContainer: { height: '100%', width: `calc(100% - ${NAVBAR_WIDTH}px)`, paddingTop: spacing.xl, @@ -20,7 +26,39 @@ export const useHomeStyles = createStyles((theme) => { color: colors.white, backgroundColor: colors.brandPrimary[0], }, - }; }); +export const cardStyles = createStyles((theme) => { + const { colors, primaryColor } = theme; + return { + container: { + height: '100%', + width: '100%', + flexDirection: 'column', + justifyContent: 'center', + alignItems: 'center', + }, + messageStyle: { + marginTop: theme.spacing.md, + color: theme.colors.gray[6], + }, + streamBox: { + borderRadius: theme.radius.md, + boxShadow: `0px 2px 8px 0px rgba(0, 0, 0, 0.2)`, + height: 95, + padding: theme.spacing.md, + cursor: 'pointer', + marginLeft: theme.spacing.md, + marginRight: theme.spacing.md, + }, + streamBoxCol: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + paddingRight: 40, + borderRight: `1px solid ${theme.colors.gray[2]}`, + color: colors[primaryColor][0] + }, + }; +}); diff --git a/src/routes/elements.tsx b/src/routes/elements.tsx index 4f5cf431..8b50e7b1 100644 --- a/src/routes/elements.tsx +++ b/src/routes/elements.tsx @@ -7,13 +7,21 @@ import MainLayoutPageProvider from '@/layouts/MainLayout/Context'; import MainLayout from '@/layouts/MainLayout'; import { ConfigHeader, + HomeHeader, LiveTailHeader, LogsHeader, StatsHeader, UsersManagementHeader, } from '@/components/Header/SubHeader'; -export const HomeElement: FC = () => ; +export const HomeElement: FC = () => { + return ( + + + + + ); +}; const Login = lazy(() => import('@/pages/Login')); diff --git a/src/utils/formatBytes.ts b/src/utils/formatBytes.ts index 64c2819b..975bf9cd 100644 --- a/src/utils/formatBytes.ts +++ b/src/utils/formatBytes.ts @@ -6,3 +6,21 @@ export const formatBytes = (a: number, b: number = 1) => { ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'][d] }`; }; + +export function HumanizeNumber(val: number) { + // Thousands, millions, billions etc.. + let s = ['', ' K', ' M', ' B', ' T']; + + // Dividing the value by 3. + let sNum = Math.floor(('' + val).length / 3); + + // Calculating the precised value. + let sVal = parseFloat((sNum != 0 ? val / Math.pow(1000, sNum) : val).toPrecision(4)); + + if (sVal % 1 != 0) { + return sVal.toFixed(1) + s[sNum]; + } + + // Appending the letter to precised val. + return sVal + s[sNum]; +}