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
+ }>
+ Documentation
+
+
+ );
+};
+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
- }>
- Documentation
-
-
- );
- }
+ 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];
+}