diff --git a/package.json b/package.json index c4df4c91..bb29edee 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "@mantine/hooks": "^7.5.1", "@mantine/notifications": "^7.5.1", "@monaco-editor/react": "^4.5.1", - "@tabler/icons-react": "^2.23.0", + "@tabler/icons-react": "^2.47.0", "@types/js-cookie": "^3.0.3", "axios": "^1.4.0", "dayjs": "^1.11.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d97d47aa..96157307 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,7 +16,7 @@ dependencies: version: 7.5.1(@mantine/core@7.5.1)(@mantine/hooks@7.5.1)(embla-carousel-react@7.1.0)(react-dom@18.2.0)(react@18.2.0) '@mantine/charts': specifier: ^7.5.3 - version: 7.5.3(@mantine/core@7.5.1)(@mantine/hooks@7.5.1)(react-dom@18.2.0)(react@18.2.0)(recharts@2.12.1) + version: 7.5.3(@mantine/core@7.5.1)(@mantine/hooks@7.5.1)(react-dom@18.2.0)(react@18.2.0)(recharts@2.12.2) '@mantine/code-highlight': specifier: ^7.5.1 version: 7.5.1(@mantine/core@7.5.1)(@mantine/hooks@7.5.1)(react-dom@18.2.0)(react@18.2.0) @@ -37,10 +37,10 @@ dependencies: version: 7.5.1(@mantine/core@7.5.1)(@mantine/hooks@7.5.1)(react-dom@18.2.0)(react@18.2.0) '@monaco-editor/react': specifier: ^4.5.1 - version: 4.5.1(monaco-editor@0.45.0)(react-dom@18.2.0)(react@18.2.0) + version: 4.5.1(monaco-editor@0.46.0)(react-dom@18.2.0)(react@18.2.0) '@tabler/icons-react': - specifier: ^2.23.0 - version: 2.23.0(react@18.2.0) + specifier: ^2.47.0 + version: 2.47.0(react@18.2.0) '@types/js-cookie': specifier: ^3.0.3 version: 3.0.3 @@ -654,7 +654,7 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false - /@mantine/charts@7.5.3(@mantine/core@7.5.1)(@mantine/hooks@7.5.1)(react-dom@18.2.0)(react@18.2.0)(recharts@2.12.1): + /@mantine/charts@7.5.3(@mantine/core@7.5.1)(@mantine/hooks@7.5.1)(react-dom@18.2.0)(react@18.2.0)(recharts@2.12.2): resolution: {integrity: sha512-TgoBVACbmAxCHZQOL/K8DlijPZ4Ogv8S4hVXdxFgwUnKUzvnLZanaan2Vu3s7111HYCY2qHwgJwuCNtKTGuARQ==} peerDependencies: '@mantine/core': 7.5.3 @@ -667,7 +667,7 @@ packages: '@mantine/hooks': 7.5.1(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - recharts: 2.12.1(react-dom@18.2.0)(react@18.2.0) + recharts: 2.12.2(react-dom@18.2.0)(react@18.2.0) dev: false /@mantine/code-highlight@7.5.1(@mantine/core@7.5.1)(@mantine/hooks@7.5.1)(react-dom@18.2.0)(react@18.2.0): @@ -765,24 +765,24 @@ packages: react: 18.2.0 dev: false - /@monaco-editor/loader@1.3.3(monaco-editor@0.45.0): + /@monaco-editor/loader@1.3.3(monaco-editor@0.46.0): resolution: {integrity: sha512-6KKF4CTzcJiS8BJwtxtfyYt9shBiEv32ateQ9T4UVogwn4HM/uPo9iJd2Dmbkpz8CM6Y0PDUpjnZzCwC+eYo2Q==} peerDependencies: monaco-editor: '>= 0.21.0 < 1' dependencies: - monaco-editor: 0.45.0 + monaco-editor: 0.46.0 state-local: 1.0.7 dev: false - /@monaco-editor/react@4.5.1(monaco-editor@0.45.0)(react-dom@18.2.0)(react@18.2.0): + /@monaco-editor/react@4.5.1(monaco-editor@0.46.0)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-NNDFdP+2HojtNhCkRfE6/D6ro6pBNihaOzMbGK84lNWzRu+CfBjwzGt4jmnqimLuqp5yE5viHS2vi+QOAnD5FQ==} peerDependencies: monaco-editor: '>= 0.25.0 < 1' react: ^16.8.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 dependencies: - '@monaco-editor/loader': 1.3.3(monaco-editor@0.45.0) - monaco-editor: 0.45.0 + '@monaco-editor/loader': 1.3.3(monaco-editor@0.46.0) + monaco-editor: 0.46.0 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) dev: false @@ -968,18 +968,18 @@ packages: '@swc/core-win32-x64-msvc': 1.3.67 dev: true - /@tabler/icons-react@2.23.0(react@18.2.0): - resolution: {integrity: sha512-+H4mC1EZVCzCRhnPwZEVTI0veVCJuAKlopeCnRlfsYcmzgJm6Ye234c4A2qrLPQoi1Y29uN9+kqCyuYW007jPg==} + /@tabler/icons-react@2.47.0(react@18.2.0): + resolution: {integrity: sha512-iqly2FvCF/qUbgmvS8E40rVeYY7laltc5GUjRxQj59DuX0x/6CpKHTXt86YlI2whg4czvd/c8Ce8YR08uEku0g==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 dependencies: - '@tabler/icons': 2.23.0 + '@tabler/icons': 2.47.0 prop-types: 15.8.1 react: 18.2.0 dev: false - /@tabler/icons@2.23.0: - resolution: {integrity: sha512-dU54aBwaxG0H+jQ4BdrqtYFN5L7PZevvlnzyL6XeOZgfDS3+sVNCtuG3JmpTEqQSwGLYC1IEwogPGA/Iit2bOA==} + /@tabler/icons@2.47.0: + resolution: {integrity: sha512-4w5evLh+7FUUiA1GucvGj2ReX2TvOjEr4ejXdwL/bsjoSkof6r1gQmzqI+VHrE2CpJpB3al7bCTulOkFa/RcyA==} dev: false /@types/command-line-args@5.2.0: @@ -1502,6 +1502,11 @@ packages: engines: {node: '>=6'} dev: false + /clsx@2.1.0: + resolution: {integrity: sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==} + engines: {node: '>=6'} + dev: false + /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: @@ -2818,8 +2823,8 @@ packages: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} dev: true - /monaco-editor@0.45.0: - resolution: {integrity: sha512-mjv1G1ZzfEE3k9HZN0dQ2olMdwIfaeAAjFiwNprLfYNRSz7ctv9XuCT7gPtBGrMUeV1/iZzYKj17Khu1hxoHOA==} + /monaco-editor@0.46.0: + resolution: {integrity: sha512-ADwtLIIww+9FKybWscd7OCfm9odsFYHImBRI1v9AviGce55QY8raT+9ihH8jX/E/e6QVSGM+pKj4jSUSRmALNQ==} dev: false /mri@1.2.0: @@ -3472,14 +3477,14 @@ packages: decimal.js-light: 2.5.1 dev: false - /recharts@2.12.1(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-35vUCEBPf+pM+iVgSgVTn86faKya5pc4JO6cYJL63qOK2zDEyzDn20Tdj+CDI/3z+VcpKyQ8ZBQ9OiQ+vuAbjg==} + /recharts@2.12.2(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-9bpxjXSF5g81YsKkTSlaX7mM4b6oYI1mIYck6YkUcWuL3tomADccI51/6thY4LmvhYuRTwpfrOvE80Zc3oBRfQ==} engines: {node: '>=14'} peerDependencies: react: ^16.0.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 dependencies: - clsx: 2.0.0 + clsx: 2.1.0 eventemitter3: 4.0.7 lodash: 4.17.21 react: 18.2.0 @@ -3487,7 +3492,7 @@ packages: react-is: 16.13.1 react-smooth: 4.0.0(react-dom@18.2.0)(react@18.2.0) recharts-scale: 0.4.5 - tiny-invariant: 1.3.1 + tiny-invariant: 1.3.3 victory-vendor: 36.9.1 dev: false @@ -3756,6 +3761,10 @@ packages: resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} dev: false + /tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + dev: false + /to-fast-properties@2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} diff --git a/src/@types/parseable/api/about.ts b/src/@types/parseable/api/about.ts index 511242e7..2149c362 100644 --- a/src/@types/parseable/api/about.ts +++ b/src/@types/parseable/api/about.ts @@ -5,7 +5,7 @@ export type AboutData = { license: string; mode: string; staging: string; - store: string; + store: { type: string; path: string }; updateAvailable: boolean; version: string; llmActive: boolean; diff --git a/src/@types/parseable/api/clusterInfo.ts b/src/@types/parseable/api/clusterInfo.ts new file mode 100644 index 00000000..41d02aff --- /dev/null +++ b/src/@types/parseable/api/clusterInfo.ts @@ -0,0 +1,21 @@ +export type Ingestor = { + domain_name: string; + reachable: boolean; + error: string | null; + status: string; + staging_path: string; + storage_path: string; +}; + +export type IngestorMetrics = { + address: string; + parseable_events_ingested: number; + parseable_staging_files: number; + process_resident_memory_bytes: number; + parseable_storage_size: { + staging: number; + data: number; + }; +}; + +export type ClusterInfo = Ingestor[]; diff --git a/src/api/cluster.ts b/src/api/cluster.ts new file mode 100644 index 00000000..84380172 --- /dev/null +++ b/src/api/cluster.ts @@ -0,0 +1,15 @@ +import { Ingestor, IngestorMetrics } from '@/@types/parseable/api/clusterInfo'; +import { Axios } from './axios'; +import { CLUSTER_INFO_URL, CLUSTER_METRICS_URL, INGESTOR_DELETE_URL } from './constants'; + +export const getClusterInfo = () => { + return Axios().get(CLUSTER_INFO_URL); +}; + +export const getClusterMetrics = () => { + return Axios().get(CLUSTER_METRICS_URL); +}; + +export const deleteIngestor = (ingestorUrl: string) => { + return Axios().delete(INGESTOR_DELETE_URL(ingestorUrl)); +}; \ No newline at end of file diff --git a/src/api/constants.ts b/src/api/constants.ts index 2d4199ef..b6b41700 100644 --- a/src/api/constants.ts +++ b/src/api/constants.ts @@ -8,6 +8,7 @@ export const LOG_STREAMS_ALERTS_URL = (streamName: string) => `${LOG_STREAM_LIST export const LOG_STREAMS_RETRNTION_URL = (streamName: string) => `${LOG_STREAM_LIST_URL}/${streamName}/retention`; export const LOG_STREAMS_STATS_URL = (streamName: string) => `${LOG_STREAM_LIST_URL}/${streamName}/stats`; export const DELETE_STREAMS_URL = (streamName: string) => `${LOG_STREAM_LIST_URL}/${streamName}`; +export const CREATE_STREAM_URL = (streamName: string) => `${LOG_STREAM_LIST_URL}/${streamName}`; // About Parsable Instance export const ABOUT_URL = `${API_V1}/about`; @@ -32,3 +33,7 @@ export const IS_LLM_ACTIVE_URL = `${LLM_QUERY_URL}/isactive`; // caching export const CACHING_STATUS_URL = (streamName: string) => `${LOG_STREAM_LIST_URL}/${streamName}/cache`; + +export const CLUSTER_INFO_URL = `${API_V1}/cluster/info`; +export const CLUSTER_METRICS_URL = `${API_V1}/cluster/metrics`; +export const INGESTOR_DELETE_URL = (ingestorUrl: string) => `${API_V1}/cluster/${ingestorUrl}`; diff --git a/src/api/logStream.ts b/src/api/logStream.ts index 00bc4906..52ff39b9 100644 --- a/src/api/logStream.ts +++ b/src/api/logStream.ts @@ -6,6 +6,7 @@ import { LOG_STREAMS_ALERTS_URL, LOG_STREAMS_RETRNTION_URL, LOG_STREAMS_STATS_URL, + CREATE_STREAM_URL, } from './constants'; import { LogStreamData, LogStreamSchemaData } from '@/@types/parseable/api/stream'; @@ -40,3 +41,7 @@ export const getLogStreamStats = (streamName: string) => { export const deleteLogStream = (streamName: string) => { return Axios().delete(DELETE_STREAMS_URL(streamName)); }; + +export const createLogStream = (streamName: string) => { + return Axios().put(CREATE_STREAM_URL(streamName)); +} diff --git a/src/components/Navbar/index.tsx b/src/components/Navbar/index.tsx index 3adb34e9..f35663c3 100644 --- a/src/components/Navbar/index.tsx +++ b/src/components/Navbar/index.tsx @@ -1,11 +1,11 @@ import { Box, Stack, Tooltip } from '@mantine/core'; -import { IconLogout, IconUser, IconBinaryTree2, IconInfoCircle, IconUserCog, IconHome } from '@tabler/icons-react'; +import { IconLogout, IconUser, IconBinaryTree2, IconInfoCircle, IconUserCog, IconHome, IconServerCog } from '@tabler/icons-react'; import { FC, useCallback, useEffect } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import { useNavigate } from 'react-router-dom'; import { useHeaderContext } from '@/layouts/MainLayout/Context'; import { useDisclosure } from '@mantine/hooks'; -import { HOME_ROUTE, LOGS_ROUTE, USERS_MANAGEMENT_ROUTE } from '@/constants/routes'; +import { HOME_ROUTE, LOGS_ROUTE, SYSTEMS_ROUTE, USERS_MANAGEMENT_ROUTE } from '@/constants/routes'; import InfoModal from './infoModal'; import { getStreamsSepcificAccess, getUserSepcificStreams } from './rolesHandler'; import Cookies from 'js-cookie'; @@ -36,6 +36,12 @@ const navItems = [ path: '/users', route: USERS_MANAGEMENT_ROUTE, }, + { + icon: IconServerCog, + label: 'Systems', + path: '/systems', + route: SYSTEMS_ROUTE, + } ]; const navActions = [ diff --git a/src/components/Navbar/infoModal.tsx b/src/components/Navbar/infoModal.tsx index 11caba53..71cca115 100644 --- a/src/components/Navbar/infoModal.tsx +++ b/src/components/Navbar/infoModal.tsx @@ -112,7 +112,7 @@ const InfoModal: FC = (props) => { Store - {getAboutData?.data.store} + {getAboutData?.data?.store?.type} Cache diff --git a/src/constants/routes.ts b/src/constants/routes.ts index e23b1e37..b5bae604 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -7,15 +7,17 @@ export const STATS_ROUTE = '/:streamName/stats'; export const CONFIG_ROUTE = '/:streamName/config'; export const USERS_MANAGEMENT_ROUTE = '/users'; export const OIDC_NOT_CONFIGURED_ROUTE = '/oidc-not-configured'; +export const SYSTEMS_ROUTE = '/systems'; export const PATHS = { - all: '/*', - home: '/', - logs: '/:streamName/logs', - login: '/login', - liveTail: '/:streamName/live-tail', - stats: '/:streamName/stats', - config: '/:streamName/config', - users: '/users', - oidcNotConfigured: '/oidc-not-configured' -} as {[key: string]: string} \ No newline at end of file + all: '/*', + home: '/', + logs: '/:streamName/logs', + login: '/login', + liveTail: '/:streamName/live-tail', + stats: '/:streamName/stats', + config: '/:streamName/config', + users: '/users', + oidcNotConfigured: '/oidc-not-configured', + systems: '/systems', +} as { [key: string]: string }; diff --git a/src/hooks/useClusterInfo.ts b/src/hooks/useClusterInfo.ts new file mode 100644 index 00000000..b96eef54 --- /dev/null +++ b/src/hooks/useClusterInfo.ts @@ -0,0 +1,72 @@ +import { useMutation, useQuery } from 'react-query'; +import { AxiosError, AxiosResponse, isAxiosError } from 'axios'; +import { getClusterInfo, getClusterMetrics, deleteIngestor } from '@/api/cluster'; +import { Ingestor, IngestorMetrics } from '@/@types/parseable/api/clusterInfo'; +import { notifyError, notifySuccess } from '@/utils/notification'; + +export const useClusterInfo = () => { + const { + data: clusterInfoData, + isError: getClusterInfoError, + isSuccess: getClusterInfoSuccess, + isLoading: getClusterInfoLoading, + refetch: getClusterInfoRefetch, + } = useQuery, Error>(['fetch-cluster-info'], () => getClusterInfo(), { + retry: false, + refetchOnWindowFocus: false, + }); + return { + clusterInfoData, + getClusterInfoError, + getClusterInfoSuccess, + getClusterInfoLoading, + getClusterInfoRefetch, + }; +}; + +export const useClusterMetrics = () => { + const { + data: clusterMetrics, + isError: getClusterMetricsError, + isSuccess: getClusterMetricsSuccess, + isLoading: getClusterMetricsLoading, + refetch: getClusterMetricsRefetch, + } = useQuery>(['fetch-cluster-metrics'], () => getClusterMetrics(), { + retry: false, + refetchOnWindowFocus: false, + }); + return { + clusterMetrics, + getClusterMetricsError, + getClusterMetricsSuccess, + getClusterMetricsLoading, + getClusterMetricsRefetch, + }; +}; + +export const useDeleteIngestor = () => { + const { + mutate: deleteIngestorMutation, + isSuccess: deleteIngestorIsSuccess, + isError: deleteIngestorIsError, + isLoading: deleteIngestorIsLoading, + } = useMutation((data: { ingestorUrl: string; onSuccess: () => void }) => deleteIngestor(data.ingestorUrl), { + onError: (data: AxiosError) => { + if (isAxiosError(data) && data.response) { + const error = data.response.data as string; + typeof error === 'string' && notifyError({ message: error }); + } + }, + onSuccess: (_data, variables) => { + variables.onSuccess && variables.onSuccess(); + notifySuccess({ message: 'Ingestor removed successfully' }); + }, + }); + + return { + deleteIngestorMutation, + deleteIngestorIsSuccess, + deleteIngestorIsError, + deleteIngestorIsLoading, + }; +}; diff --git a/src/hooks/useLogStream.tsx b/src/hooks/useLogStream.tsx index 478fdf52..1470235f 100644 --- a/src/hooks/useLogStream.tsx +++ b/src/hooks/useLogStream.tsx @@ -1,5 +1,7 @@ import { useMutation, useQuery } from 'react-query'; -import { deleteLogStream, getLogStreamList } from '@/api/logStream'; +import { deleteLogStream, getLogStreamList, createLogStream } from '@/api/logStream'; +import { AxiosError, isAxiosError } from 'axios'; +import { notifyError, notifySuccess } from '@/utils/notification'; export const useLogStream = () => { const { @@ -9,13 +11,30 @@ export const useLogStream = () => { isLoading: deleteLogStreamIsLoading, } = useMutation((data: { deleteStream: string }) => deleteLogStream(data.deleteStream), {}); + const { + mutate: createLogStreamMutation, + isSuccess: createLogStreamIsSuccess, + isError: createLogStreamIsError, + isLoading: createLogStreamIsLoading, + } = useMutation((data: { streamName: string, onSuccess: () => void}) => createLogStream(data.streamName), { + onError: (data: AxiosError) => { + if (isAxiosError(data) && typeof data.message === 'string') { + notifyError({ message: data.message }); + } + }, + onSuccess: (_data, variables) => { + variables.onSuccess && variables.onSuccess(); + notifySuccess({message: `Stream ${variables.streamName} created successfully`}) + }, + }); + const { data: getLogStreamListData, isError: getLogStreamListIsError, isSuccess: getLogStreamListIsSuccess, isLoading: getLogStreamListIsLoading, refetch: getLogStreamListRefetch, - } = useQuery(['fetch-log-stream-list', deleteLogStreamIsSuccess], () => getLogStreamList(), { + } = useQuery(['fetch-log-stream-list', deleteLogStreamIsSuccess, createLogStreamIsSuccess], () => getLogStreamList(), { retry: false, refetchOnWindowFocus: false, refetchOnMount: false, @@ -43,5 +62,9 @@ export const useLogStream = () => { getLogStreamListIsSuccess, getLogStreamListIsLoading, getLogStreamListRefetch, + createLogStreamMutation, + createLogStreamIsSuccess, + createLogStreamIsError, + createLogStreamIsLoading }; }; diff --git a/src/layouts/MainLayout/Context.tsx b/src/layouts/MainLayout/Context.tsx index 709d4cfa..2026fdf3 100644 --- a/src/layouts/MainLayout/Context.tsx +++ b/src/layouts/MainLayout/Context.tsx @@ -86,6 +86,7 @@ const accessKeyMap: { [key: string]: string } = { hasDeleteAccess: 'DeleteStream', hasUpdateAlertAccess: 'PutAlert', hasGetAlertAccess: 'GetAlert', + hasCreateStreamAccess: 'CreateStream' }; const generateUserAcccessMap = (accessRoles: string[] | null) => { diff --git a/src/pages/Home/CreateStreamModal.tsx b/src/pages/Home/CreateStreamModal.tsx new file mode 100644 index 00000000..1e89bc44 --- /dev/null +++ b/src/pages/Home/CreateStreamModal.tsx @@ -0,0 +1,51 @@ +import { Box, Button, Input, Modal, Stack, Text } from '@mantine/core'; +import { FC, useCallback, useState } from 'react'; +import styles from './styles/CreateStreamModal.module.css'; +import { useLogStream } from '@/hooks/useLogStream'; + +type CreateStreamModalProps = { + opened: boolean; + close(): void; +}; + +const CreateStreamModal: FC = (props) => { + const { opened, close } = props; + const classes = styles; + const [streamName, setStreamName] = useState(''); + const { container, aboutTitle } = classes; + + const { createLogStreamMutation } = useLogStream(); + const { getLogStreamListRefetch } = useLogStream(); + + const onSuccessCallback = useCallback(() => { + close(); + getLogStreamListRefetch(); + }, []); + + const onSubmit = useCallback(() => { + createLogStreamMutation({ streamName, onSuccess: onSuccessCallback }); + }, [streamName]); + + return ( + + + Create Stream + setStreamName(e.target.value)} placeholder="Enter stream name" /> + + + + + + + + + + + ); +}; + +export default CreateStreamModal; diff --git a/src/pages/Home/index.tsx b/src/pages/Home/index.tsx index 94a42335..f802259e 100644 --- a/src/pages/Home/index.tsx +++ b/src/pages/Home/index.tsx @@ -1,7 +1,7 @@ import { EmptySimple } from '@/components/Empty'; -import { Text, Button, Center, Box, Group, ActionIcon, Flex, Stack, Tooltip } from '@mantine/core'; -import { IconChevronRight, IconExternalLink } from '@tabler/icons-react'; -import { useEffect, type FC, useCallback } from 'react'; +import { Text, Button, Center, Box, Group, ActionIcon, Flex, Stack, Tooltip, ScrollArea } from '@mantine/core'; +import { IconChevronRight, IconExternalLink, IconPlus } from '@tabler/icons-react'; +import { useEffect, type FC, useCallback, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useDocumentTitle } from '@mantine/hooks'; import { useGetStreamMetadata } from '@/hooks/useGetStreamMetadata'; @@ -10,6 +10,7 @@ import { LogStreamRetention, LogStreamStat } from '@/@types/parseable/api/stream import { useHeaderContext } from '@/layouts/MainLayout/Context'; import cardStyles from './styles/Card.module.css'; import homeStyles from './styles/Home.module.css'; +import CreateStreamModal from './CreateStreamModal'; const EmptyStreamsView: FC = () => { const classes = homeStyles; @@ -36,10 +37,11 @@ const Home: FC = () => { const { container } = classes; const { methods: { streamChangeCleanup }, - state: { userSpecficStreams }, + state: { userSpecficStreams, userSpecificAccessMap }, } = useHeaderContext(); const navigate = useNavigate(); const { getStreamMetadata, metaData } = useGetStreamMetadata(); + const [createStreamModalOpen, setCreateStreamModalOpen] = useState(false); useEffect(() => { if (!Array.isArray(userSpecficStreams) || userSpecficStreams.length === 0) return; @@ -50,19 +52,47 @@ const Home: FC = () => { streamChangeCleanup(stream); navigate(`/${stream}/logs`); }, []); - - if (userSpecficStreams === null) return null; - if ((Array.isArray(userSpecficStreams) && userSpecficStreams.length === 0)) - return ; + const displayEmptyPlaceholder = Array.isArray(userSpecficStreams) && userSpecficStreams.length === 0; + const toggleCreateStreamModal = useCallback(() => { + setCreateStreamModalOpen((prev) => !prev); + }, []); return ( - - - {Object.entries(metaData || {}).map(([stream, data]) => { - return ; - })} - - + + + + + All Streams + + {userSpecificAccessMap.hasCreateStreamAccess && ( + + )} + + + {displayEmptyPlaceholder ? ( + + ) : ( + + {Object.entries(metaData || {}).map(([stream, data]) => { + return ; + })} + + )} + + ); }; diff --git a/src/pages/Home/styles/CreateStreamModal.module.css b/src/pages/Home/styles/CreateStreamModal.module.css new file mode 100644 index 00000000..7ff2bb99 --- /dev/null +++ b/src/pages/Home/styles/CreateStreamModal.module.css @@ -0,0 +1,9 @@ +.container { + height: 100%; + width: 100%; +} + +.aboutTitle { + font-size: 1.2rem; + font-weight: 600; +} diff --git a/src/pages/Systems/Ingestors.tsx b/src/pages/Systems/Ingestors.tsx new file mode 100644 index 00000000..a92ae2c6 --- /dev/null +++ b/src/pages/Systems/Ingestors.tsx @@ -0,0 +1,167 @@ +import { Stack, Text, Table, Tooltip, ThemeIcon, Skeleton, Box, Loader } from '@mantine/core'; +import { FC } from 'react'; +import classes from './styles/Systems.module.css'; +import { IconAlertCircle, IconBrandDatabricks, IconX } from '@tabler/icons-react'; +import { useClusterInfo, useClusterMetrics, useDeleteIngestor } from '@/hooks/useClusterInfo'; +import { Ingestor, IngestorMetrics } from '@/@types/parseable/api/clusterInfo'; +import { HumanizeNumber, formatBytes } from '@/utils/formatBytes'; + +type IngestorTableRow = { + ingestor: Ingestor; + metrics: IngestorMetrics | undefined; +}; + +const TrLoadingState = () => ( + + + +); + +function sanitizeIngestorUrl(url: string) { + if (url.startsWith("http://")) { + url = url.slice(7); + } else if (url.startsWith("https://")) { + url = url.slice(8); + } + + if (url.endsWith("/")) { + url = url.slice(0, -1); + } + + return url; +} + +const TableRow = (props: IngestorTableRow) => { + const { ingestor, metrics } = props; + const isOfflineIngestor = !ingestor.reachable; + const { deleteIngestorMutation, deleteIngestorIsLoading } = useDeleteIngestor(); + const {getClusterInfoRefetch} = useClusterInfo() + return ( + + + + {ingestor.domain_name} + {!ingestor.reachable && ( + + + + + + )} + + + {!metrics && !isOfflineIngestor ? ( + + ) : ( + <> + + + + {isOfflineIngestor ? '–' : HumanizeNumber(metrics?.parseable_events_ingested || 0)} + + + + + {isOfflineIngestor ? '–' : formatBytes(metrics?.parseable_storage_size.data || 0)} + + + {isOfflineIngestor ? '–' : formatBytes(metrics?.process_resident_memory_bytes || 0)} + + + {isOfflineIngestor ? '–' : HumanizeNumber(metrics?.parseable_staging_files || 0)} + + + {isOfflineIngestor ? '–' : formatBytes(metrics?.parseable_storage_size.staging || 0)} + + {ingestor.staging_path || 'Unknown'} + {ingestor.storage_path || 'Unknown'} + + )} + + + {ingestor.reachable ? 'Online' : 'Offline'} + + + + {!ingestor.reachable ? ( + deleteIngestorMutation({ ingestorUrl: sanitizeIngestorUrl(ingestor.domain_name), onSuccess: getClusterInfoRefetch })}> + {deleteIngestorIsLoading ? ( + + ) : ( + + + + )} + + ) : null} + + + ); +}; + +type IngestorTable = { + ingestors: Ingestor[] | undefined; + allMetrics: IngestorMetrics[] | undefined; +}; + +const TableHead = () => ( + + + Domain + Events Ingested + Storage + Memory Usage + Staging Files + Staging Size + Staging Path + Storage Path + Status + + + +); + +const IngestorsTable = (props: IngestorTable) => { + const { ingestors, allMetrics } = props; + if (!ingestors || !allMetrics) return null; + + return ( + + + + {ingestors.map((ingestor) => { + const metrics = allMetrics.find((ingestorMetric) => ingestorMetric.address === ingestor.domain_name); + return ; + })} + +
+ ); +}; + +const Ingestors: FC = () => { + const { clusterInfoData, getClusterInfoSuccess } = useClusterInfo(); + const { clusterMetrics, getClusterMetricsSuccess } = useClusterMetrics(); + const showTable = + getClusterInfoSuccess && + getClusterMetricsSuccess && + Array.isArray(clusterInfoData?.data) && + Array.isArray(clusterMetrics?.data); + if (!showTable) return null; + + const totalActiveMachines = clusterInfoData?.data.filter((ingestor) => ingestor.reachable).length; + const totalMachines = clusterInfoData?.data.length; + return ( + + + + + Ingestors + + {`${totalActiveMachines} / ${totalMachines} Active`} + + + + ); +}; + +export default Ingestors; diff --git a/src/pages/Systems/Queriers.tsx b/src/pages/Systems/Queriers.tsx new file mode 100644 index 00000000..34dab88d --- /dev/null +++ b/src/pages/Systems/Queriers.tsx @@ -0,0 +1,124 @@ +import { Stack, Text, Table, Skeleton } from '@mantine/core'; +import { FC, useEffect, useState } from 'react'; +import classes from './styles/Systems.module.css'; +import { IconHeartRateMonitor } from '@tabler/icons-react'; +import { PrometheusMetricResponse, SanitizedMetrics, parsePrometheusResponse, sanitizeIngestorData } from './utils'; + +const fetchIngestorMetrics = async () => { + const endpoint = `/api/v1/metrics`; + const response = await fetch(endpoint, { + headers: { + Accept: 'text/plain', + }, + }); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + return response.body; +}; + +const TrLoadingState = () => ( + + + +); + +const TableRow = () => { + const [isMetricsFetching, setMetricsFetching] = useState(true); + const [metrics, setMetrics] = useState(null); + + useEffect(() => { + const fetchData = async () => { + try { + const readableStream = await fetchIngestorMetrics(); + const reader = readableStream?.getReader(); + const chunks:string[] = []; + const readData = async () => { + while (reader) { + const { done, value } = await reader.read(); + if (done) { + console.log('Stream reading complete'); + break; + } + const chunk = new TextDecoder().decode(value); + chunks.push(chunk); + } + }; + await readData(); + const data = chunks.join('') + if (typeof data !== 'string') throw 'Invalid prometheus response'; + + const parsedMetrics: PrometheusMetricResponse | null = parsePrometheusResponse(data); + const sanitizedMetrics = parsedMetrics === null ? null : sanitizeIngestorData(parsedMetrics); + setMetrics(sanitizedMetrics); + setMetricsFetching(false); + } catch (error) { + console.log('Error fetching metrics', error); + } + }; + + fetchData(); + }, []); + + return ( + + + + {window.location.protocol}//{window.location.host} + + + {isMetricsFetching || metrics === null ? ( + + ) : ( + <> + {metrics.memoryUsage} + + )} + + + {'Online'} + + + + ); +}; + +const TableHead = () => ( + + + Domain + Memory Usage + Status + + + +); + +const QuerierTable = () => { + return ( + + + + + +
+ ); +}; + +const Querier: FC = () => { + return ( + + + + + Querier + + + + + ); +}; + +export default Querier; diff --git a/src/pages/Systems/StandaloneServer.tsx b/src/pages/Systems/StandaloneServer.tsx new file mode 100644 index 00000000..43c99bfd --- /dev/null +++ b/src/pages/Systems/StandaloneServer.tsx @@ -0,0 +1,146 @@ +import { Stack, Text, Table, Tooltip, Skeleton } from '@mantine/core'; +import { FC, useEffect, useState } from 'react'; +import classes from './styles/Systems.module.css'; +import { IconBrandDatabricks } from '@tabler/icons-react'; +import { PrometheusMetricResponse, SanitizedMetrics, parsePrometheusResponse, sanitizeIngestorData } from './utils'; +import { useAbout } from '@/hooks/useGetAbout'; +import { AboutData } from '@/@types/parseable/api/about'; + +type IngestorTableRow = { + stagingPath: string; + storePath: string; +}; + +const fetchIngestorMetrics = async () => { + const endpoint = `/api/v1/metrics`; + const response = await fetch(endpoint, { + headers: { + Accept: 'text/plain', + }, + }); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + return response.body; +}; + +const TrLoadingState = () => ( + + + +); + +const TableRow = (props: IngestorTableRow) => { + const [isMetricsFetching, setMetricsFetching] = useState(true); + const [metrics, setMetrics] = useState(null); + + useEffect(() => { + const fetchData = async () => { + try { + const readableStream = await fetchIngestorMetrics(); + const reader = readableStream?.getReader(); + const chunks: string[] = []; + const readData = async () => { + while (reader) { + const { done, value } = await reader.read(); + if (done) { + console.log('Stream reading complete'); + break; + } + const chunk = new TextDecoder().decode(value); + chunks.push(chunk); + } + }; + await readData(); + const data = chunks.join(''); + if (typeof data !== 'string') throw 'Invalid prometheus response'; + + const parsedMetrics: PrometheusMetricResponse | null = parsePrometheusResponse(data); + const sanitizedMetrics = parsedMetrics === null ? null : sanitizeIngestorData(parsedMetrics); + setMetrics(sanitizedMetrics); + setMetricsFetching(false); + } catch (error) { + console.log('Error fetching metrics', error); + } + }; + + fetchData(); + }, []); + + return ( + + + + {window.location.protocol}//{window.location.host} + + + {isMetricsFetching || metrics === null ? ( + + ) : ( + <> + + + {metrics.totalEventsIngested} + + + {metrics.totalBytesIngested} + {metrics.memoryUsage} + {metrics.stagingFilesCount} + {metrics.stagingSize} + {props.stagingPath || ''} + {props.storePath || ''} + + )} + + {'Online'} + + + ); +}; + +const TableHead = () => ( + + + Domain + Events Ingested + Storage + Memory Usage + Staging Files + Staging Size + Staging Path + Store + Status + + + +); + +const ServerTable = (props: AboutData) => { + return ( + + + + + +
+ ); +}; + +const StandaloneServer: FC = () => { + const { getAboutData } = useAbout(); + return ( + + + + + Parseable Server + + + {getAboutData?.data && } + + ); +}; + +export default StandaloneServer; diff --git a/src/pages/Systems/index.tsx b/src/pages/Systems/index.tsx new file mode 100644 index 00000000..370d2bbf --- /dev/null +++ b/src/pages/Systems/index.tsx @@ -0,0 +1,45 @@ +import { Box, Button, Stack } from '@mantine/core'; +import { FC } from 'react'; +import Ingestors from './Ingestors'; +import Queriers from './Queriers'; +import StandaloneServer from './StandaloneServer'; +import { useAbout } from '@/hooks/useGetAbout'; +import { IconBook2 } from '@tabler/icons-react'; + +const Systems: FC = () => { + const { getAboutData, getAboutIsLoading, getAboutIsError } = useAbout(); + if (getAboutIsLoading || getAboutIsError) return null; + + return ( + + {getAboutData?.data.mode === 'All' ? ( + + {/* + Know more about implementing distributed systems for enhanced efficiency + */} + + + + + + ) : ( + <> + + + + )} + + ); +}; + +export default Systems; diff --git a/src/pages/Systems/mockdata.ts b/src/pages/Systems/mockdata.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/pages/Systems/styles/Systems.module.css b/src/pages/Systems/styles/Systems.module.css new file mode 100644 index 00000000..2da641ef --- /dev/null +++ b/src/pages/Systems/styles/Systems.module.css @@ -0,0 +1,108 @@ +.sectionContainer { + width: 100%; + border: 1px solid var(--mantine-color-gray-3); + /* margin: 1rem; */ + padding: 0.675rem; + padding-bottom: 0; + border-radius: 0.275rem; +} + +.sectionTitleContainer { + flex-direction: row; + justify-content: space-between; + width: 100%; + padding: 1rem 1rem 0rem 0.5rem; +} + +.sectionTitle { + font-size: 1.2rem; + font-weight: 600; +} + +.usageIndicatorContainer { + background-color: var(--mantine-color-gray-2); + width: 100%; + height: 100%; + border-radius: 0.25rem; + height: 1.6rem; + justify-content: center; + &.ok { + background-color: var(--mantine-color-green-2); + } + &.alert { + background-color: var(--mantine-color-red-1); + } + .usageIndicatorText { + font-size: 0.8rem; + text-align: center; + z-index: 9; + font-weight: 500; + } + .usageLevelIndicator { + position: absolute; + height: 100%; + border-top-left-radius: 0.25rem; + border-bottom-left-radius: 0.25rem; + + &.ok { + background-color: var(--mantine-color-green-5); + } + &.alert { + background-color: var(--mantine-color-red-3); + } + } +} + +.statusChip { + height: 1.6rem; + background-color: var(--mantine-color-gray-2); + border-radius: var(--mantine-spacing-sm); + text-align: center; + align-items: center; + justify-content: center; + text-transform: capitalize; + font-size: 0.875rem; + font-weight: 600; + width: fit-content; + padding: 0 1rem; + &.offline { + background-color: var(--mantine-color-gray-1); + } + + &.online { + color: var(--mantine-color-green-9); + background-color: var(--mantine-color-green-1); + } +} + +.noAlertsIcon { + color: var(--mantine-color-green-4); +} + +.infoIcon { + background-color: transparent; + color: var(--mantine-color-red-5); + cursor: pointer; +} + +.infoDetailsContainer { + width: 10rem; +} + +.removeIcon { + color: var(--mantine-color-red-3); + height: 1rem; + width: 1rem; + cursor: pointer; +} + +.infoTitle { + font-size: 0.9rem; + font-weight: 600; +} + + +.infoText { + font-size: 0.875rem; + /* font-weight: 600; */ +} \ No newline at end of file diff --git a/src/pages/Systems/utils.ts b/src/pages/Systems/utils.ts new file mode 100644 index 00000000..227def60 --- /dev/null +++ b/src/pages/Systems/utils.ts @@ -0,0 +1,101 @@ +import { HumanizeNumber, formatBytes } from "@/utils/formatBytes"; + +interface Label { + [key: string]: string; +} + +interface Metric { + [key: string]: any; +} + +export interface PrometheusMetricResponse { + [key: string]: Metric[] | number | Label; +} + +export function parsePrometheusResponse(response: string): null | PrometheusMetricResponse { + const metrics: PrometheusMetricResponse = {}; + + if (typeof response === 'string') { + response + .trim() + .split('\n') + .forEach((line) => { + const matchWithLabels = line.match(/(\w+)\{([^\}]+)\}\s+(\d+)/); + const matchWithoutLabels = line.match(/(\w+)\s+(\d+)/); + + if (matchWithLabels) { + const metricName = matchWithLabels[1]; + const labelsStr = matchWithLabels[2]; + const labels: Label = labelsStr.split(',').reduce((acc: Label, label: string) => { + const [key, value] = label.split('='); + acc[key] = value.replace(/"/g, ''); + return acc; + }, {}); + const value = parseInt(matchWithLabels[3], 10); + + if (!metrics[metricName]) { + metrics[metricName] = []; + } + + if (Array.isArray(metrics[metricName])) { + (metrics[metricName] as Metric[]).push({ ...labels, value }); + } else { + metrics[metricName] = [{ ...labels, value }]; + } + } else if (matchWithoutLabels) { + const metricName = matchWithoutLabels[1]; + const value = parseInt(matchWithoutLabels[2], 10); + + if (!metrics[metricName]) { + metrics[metricName] = value; + } else if (typeof metrics[metricName] === 'number') { + metrics[metricName] = [{ value: metrics[metricName] }, { value }]; + } else { + (metrics[metricName] as Metric[]).push({ value }); + } + } + }); + } + + if (Object.keys(metrics).length === 0) { + return null; + } else { + return metrics; + } +} + +export const parseStreamDataMetrics = (metrics: Metric[] | number | Label | undefined) => { + if (!metrics || !Array.isArray(metrics)) { + return 0; + } + + return metrics.reduce((acc, streamDatum) => { + return streamDatum?.value ? acc + streamDatum.value : acc; + }, 0); +}; + +export type SanitizedMetrics = { + totalEventsIngested: string; + totalBytesIngested: string; + memoryUsage: string; + stagingFilesCount: string; + stagingSize: string; +}; + +export const sanitizeIngestorData = (prometheusResponse: PrometheusMetricResponse): SanitizedMetrics | null => { + const {parseable_events_ingested, parseable_staging_files, parseable_storage_size, process_resident_memory_bytes} = prometheusResponse + const streamWiseDataStorage = Array.isArray(parseable_storage_size) ? parseable_storage_size.filter((d) => d.type === 'data') : [] + const streamWiseStagingStorage = Array.isArray(parseable_storage_size) ? parseable_storage_size.filter((d) => d.type === 'staging') : [] + const totalEventsIngested = parseStreamDataMetrics(parseable_events_ingested); + const totalBytesIngested = parseStreamDataMetrics(streamWiseDataStorage); + const stagingFilesCount = parseStreamDataMetrics(parseable_staging_files); + const stagingSize = parseStreamDataMetrics(streamWiseStagingStorage); + const memoryUsage = typeof process_resident_memory_bytes === 'number' ? process_resident_memory_bytes : 0; + return { + totalEventsIngested: HumanizeNumber(totalEventsIngested), + totalBytesIngested: formatBytes(totalBytesIngested), + memoryUsage: formatBytes(memoryUsage), + stagingFilesCount: HumanizeNumber(stagingFilesCount), + stagingSize: formatBytes(stagingSize), + }; +}; \ No newline at end of file diff --git a/src/routes/elements.tsx b/src/routes/elements.tsx index 650824b4..e5964e64 100644 --- a/src/routes/elements.tsx +++ b/src/routes/elements.tsx @@ -60,3 +60,13 @@ export const UsersElement: FC = () => { ); }; + +const Systems = lazy(() => import('@/pages/Systems')); + +export const SystemsElement: FC = () => { + return ( + + + + ); +}; \ No newline at end of file diff --git a/src/routes/index.tsx b/src/routes/index.tsx index b07b8b7e..e3a81639 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -5,13 +5,14 @@ import { LOGS_ROUTE, OIDC_NOT_CONFIGURED_ROUTE, USERS_MANAGEMENT_ROUTE, + SYSTEMS_ROUTE } from '@/constants/routes'; import FullPageLayout from '@/layouts/FullPageLayout'; import NotFound from '@/pages/Errors/NotFound'; import type { FC } from 'react'; import { Route, Routes } from 'react-router-dom'; import PrivateRoute from './PrivateRoute'; -import { HomeElement, LoginElement, LogsElement, MainLayoutElement, UsersElement } from './elements'; +import { HomeElement, LoginElement, LogsElement, MainLayoutElement, SystemsElement, UsersElement } from './elements'; import AccessSpecificRoute from './AccessSpecificRoute'; import OIDCNotConFigured from '@/pages/Errors/OIDC'; @@ -28,6 +29,7 @@ const AppRouter: FC = () => { }> } /> + } /> } />