diff --git a/package.json b/package.json index aa3ec792..aaf60512 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "axios": "^1.4.0", "dayjs": "^1.11.10", "embla-carousel-react": "7.1.0", + "html2canvas": "^1.4.1", "http-status-codes": "^2.2.0", "immer": "^10.0.2", "jq-web": "^0.5.1", @@ -44,8 +45,10 @@ "react-beautiful-dnd": "^13.1.1", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.10", + "react-grid-layout": "^1.4.4", "react-query": "^3.39.3", "react-querybuilder": "^6.5.5", + "react-resizable": "^3.0.5", "react-resizable-panels": "^0.0.53", "react-router-dom": "^6.14.0", "react-window": "^1.8.9" @@ -57,6 +60,7 @@ "@types/react": "^18.2.14", "@types/react-beautiful-dnd": "^13.1.4", "@types/react-dom": "^18.2.6", + "@types/react-grid-layout": "^1.3.5", "@types/react-window": "^1.8.5", "@typescript-eslint/eslint-plugin": "^5.60.1", "@typescript-eslint/parser": "^5.60.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06a90fe9..7eb2d3d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: embla-carousel-react: specifier: 7.1.0 version: 7.1.0(react@18.2.0) + html2canvas: + specifier: ^1.4.1 + version: 1.4.1 http-status-codes: specifier: ^2.2.0 version: 2.2.0 @@ -101,12 +104,18 @@ importers: react-error-boundary: specifier: ^4.0.10 version: 4.0.10(react@18.2.0) + react-grid-layout: + specifier: ^1.4.4 + version: 1.4.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react-query: specifier: ^3.39.3 version: 3.39.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react-querybuilder: specifier: ^6.5.5 version: 6.5.5(react@18.2.0) + react-resizable: + specifier: ^3.0.5 + version: 3.0.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0) react-resizable-panels: specifier: ^0.0.53 version: 0.0.53(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -135,6 +144,9 @@ importers: '@types/react-dom': specifier: ^18.2.6 version: 18.2.6 + '@types/react-grid-layout': + specifier: ^1.3.5 + version: 1.3.5 '@types/react-window': specifier: ^1.8.5 version: 1.8.5 @@ -725,6 +737,9 @@ packages: '@types/react-dom@18.2.6': resolution: {integrity: sha512-2et4PDvg6PVCyS7fuTc4gPoksV58bW0RwSxWKcPRcHZf0PRUGq03TKcD/rUHe3azfV6/5/biUBJw+HhCQjaP0A==} + '@types/react-grid-layout@1.3.5': + resolution: {integrity: sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==} + '@types/react-redux@7.1.25': resolution: {integrity: sha512-bAGh4e+w5D8dajd6InASVIyCo4pZLJ66oLb80F9OBLO1gKESbZcRCJpTT6uLXX+HAB57zw1WTdwJdAsewuTweg==} @@ -889,6 +904,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + big-integer@1.6.52: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} @@ -930,6 +949,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + clsx@1.2.1: + resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} + engines: {node: '>=6'} + clsx@2.0.0: resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} engines: {node: '>=6'} @@ -984,6 +1007,9 @@ packages: css-box-model@1.2.1: resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==} + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -1258,6 +1284,9 @@ packages: fast-diff@1.3.0: resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} + fast-equals@4.0.3: + resolution: {integrity: sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==} + fast-equals@5.0.1: resolution: {integrity: sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==} engines: {node: '>=6.0.0'} @@ -1426,6 +1455,10 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + http-status-codes@2.2.0: resolution: {integrity: sha512-feERVo9iWxvnejp3SEfm/+oNG517npqL2/PIA8ORjyOZjGC7TwCRQsZylciLS64i6pJ0wRYz3rkXLRwbtFa8Ng==} @@ -1908,11 +1941,23 @@ packages: peerDependencies: react: ^18.2.0 + react-draggable@4.4.6: + resolution: {integrity: sha512-LtY5Xw1zTPqHkVmtM3X8MUOxNDOUhv/khTgBgrUvwaS064bwVvxT+q5El0uUFNx5IEPKXuRejr7UqLwBIg5pdw==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + react-error-boundary@4.0.10: resolution: {integrity: sha512-pvVKdi77j2OoPHo+p3rorgE43OjDWiqFkaqkJz8sJKK6uf/u8xtzuaVfj5qJ2JnDLIgF1De3zY5AJDijp+LVPA==} peerDependencies: react: '>=16.13.1' + react-grid-layout@1.4.4: + resolution: {integrity: sha512-7+Lg8E8O8HfOH5FrY80GCIR1SHTn2QnAYKh27/5spoz+OHhMmEhU/14gIkRzJOtympDPaXcVRX/nT1FjmeOUmQ==} + peerDependencies: + react: '>= 16.3.0' + react-dom: '>= 16.3.0' + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -1980,6 +2025,11 @@ packages: react: ^16.14.0 || ^17.0.0 || ^18.0.0 react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 + react-resizable@3.0.5: + resolution: {integrity: sha512-vKpeHhI5OZvYn82kXOs1bC8aOXktGU5AmKAgaZS4F5JPburCtbmDPqE7Pzp+1kN4+Wb81LlF33VpGwWwtXem+w==} + peerDependencies: + react: '>= 16.3' + react-router-dom@6.14.0: resolution: {integrity: sha512-YEwlApKwzMMMbGbhh+Q7MsloTldcwMgHxUY/1g0uA62+B1hZo2jsybCWIDCL8zvIDB1FA0pBKY9chHbZHt+2dQ==} engines: {node: '>=14'} @@ -2059,6 +2109,9 @@ packages: resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} engines: {node: '>=0.10'} + resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2193,6 +2246,9 @@ packages: engines: {node: '>=12.17'} hasBin: true + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -2316,6 +2372,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} @@ -2857,6 +2916,10 @@ snapshots: dependencies: '@types/react': 18.2.14 + '@types/react-grid-layout@1.3.5': + dependencies: + '@types/react': 18.2.14 + '@types/react-redux@7.1.25': dependencies: '@types/hoist-non-react-statics': 3.3.1 @@ -3061,6 +3124,8 @@ snapshots: balanced-match@1.0.2: {} + base64-arraybuffer@1.0.2: {} + big-integer@1.6.52: {} brace-expansion@1.1.11: @@ -3112,6 +3177,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + clsx@1.2.1: {} + clsx@2.0.0: {} clsx@2.1.0: {} @@ -3170,6 +3237,10 @@ snapshots: dependencies: tiny-invariant: 1.3.1 + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + cssesc@3.0.0: {} csstype@3.1.2: {} @@ -3529,6 +3600,8 @@ snapshots: fast-diff@1.3.0: {} + fast-equals@4.0.3: {} + fast-equals@5.0.1: {} fast-glob@3.2.12: @@ -3696,6 +3769,11 @@ snapshots: dependencies: react-is: 16.13.1 + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + http-status-codes@2.2.0: {} human-signals@1.1.1: {} @@ -4168,11 +4246,29 @@ snapshots: react: 18.2.0 scheduler: 0.23.0 + react-draggable@4.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + clsx: 1.2.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-error-boundary@4.0.10(react@18.2.0): dependencies: '@babel/runtime': 7.21.5 react: 18.2.0 + react-grid-layout@1.4.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + clsx: 2.1.1 + fast-equals: 4.0.3 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-draggable: 4.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react-resizable: 3.0.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + resize-observer-polyfill: 1.5.1 + react-is@16.13.1: {} react-is@17.0.2: {} @@ -4234,6 +4330,15 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + + react-resizable@3.0.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0): + dependencies: + prop-types: 15.8.1 + react: 18.2.0 + react-draggable: 4.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + transitivePeerDependencies: + - react-dom + react-router-dom@6.14.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: '@remix-run/router': 1.7.0 @@ -4325,6 +4430,8 @@ snapshots: repeat-string@1.6.1: {} + resize-observer-polyfill@1.5.1: {} + resolve-from@4.0.0: {} resolve@1.22.2: @@ -4460,6 +4567,10 @@ snapshots: typical: 7.1.1 wordwrapjs: 5.1.0 + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + text-table@0.2.0: {} tiny-invariant@1.3.1: {} @@ -4564,6 +4675,10 @@ snapshots: util-deprecate@1.0.2: {} + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + victory-vendor@36.9.2: dependencies: '@types/d3-array': 3.2.1 diff --git a/src/@types/parseable/api/dashboards.ts b/src/@types/parseable/api/dashboards.ts new file mode 100644 index 00000000..095f1eeb --- /dev/null +++ b/src/@types/parseable/api/dashboards.ts @@ -0,0 +1,84 @@ +import { UseFormReturnType } from '@mantine/form'; +import { Log } from './query'; + +export type VizType = (typeof visualizations)[number]; +export type TileSize = (typeof tileSizes)[number]; +export type UnitType = (typeof tickUnits)[number] | null; + +// viz type constants +export const visualizations = ['pie-chart', 'donut-chart', 'line-chart', 'bar-chart', 'area-chart', 'table'] as const; +export const circularChartTypes = ['pie-chart', 'donut-chart'] as const; +export const tickUnits = ['bytes', 'utc-timestamp'] as const; +export const graphTypes = ['line-chart', 'bar-chart', 'area-chart'] as const; + +// vize size constants +export const tileSizeWidthMap = { sm: 4, md: 6, lg: 8, xl: 12 }; +export const tileSizes = ['sm', 'md', 'lg', 'xl']; + +export type ColorConfig = { + field_name: string; + color_palette: string; +}; + +export type TickConfig = { + key: string; + unit: string; +}; + +export type Visualization = { + visualization_type: VizType; + size: TileSize; + circular_chart_config?: null | { name_key: string; value_key: string } | {}; + graph_config?: null | { x_key: string; y_keys: string[] } | {}; + color_config: ColorConfig[]; + tick_config: TickConfig[]; +}; + +export type Dashboard = { + name: string; + description: string; + refresh_interval: number; + tiles: Tile[]; + dashboard_id: string; + time_filter: null | { + from: string; + to: string; + }; +}; + +export type CreateDashboardType = Omit; + +export type UpdateDashboardType = Omit & { + tiles: EditTileType[]; +}; + +export type TileQuery = { query: string; startTime: Date; endTime: Date }; + +export type TileData = Log[]; + +export type TileQueryResponse = { + fields: string[]; + records: TileData; +}; + +export interface FormOpts extends Omit { + isQueryValidated: boolean; + data: TileQueryResponse; + dashboardId: string | null; + tile_id?: string; +} + +export type TileFormType = UseFormReturnType FormOpts>; + +export type Tile = { + name: string; + description: string; + visualization: Visualization; + query: string; + tile_id: string; + order: number; +}; + +export type EditTileType = Omit & { + tile_id?: string; +}; diff --git a/src/api/constants.ts b/src/api/constants.ts index 111a48c9..edace540 100644 --- a/src/api/constants.ts +++ b/src/api/constants.ts @@ -23,8 +23,12 @@ export const LOG_STREAMS_SCHEMA_URL = (streamName: string) => `${LOG_STREAM_LIST export const LOG_QUERY_URL = (params?: Params) => `${API_V1}/query` + parseParamsToQueryString(params); export const LOG_STREAMS_ALERTS_URL = (streamName: string) => `${LOG_STREAM_LIST_URL}/${streamName}/alert`; export const LIST_SAVED_FILTERS_URL = (userId: string) => `${API_V1}/filters/${userId}`; +export const LIST_DASHBOARDS = (userId: string) => `${API_V1}/dashboards/${userId}`; export const UPDATE_SAVED_FILTERS_URL = (filterId: string) => `${API_V1}/filters/filter/${filterId}`; +export const UPDATE_DASHBOARDS_URL = (dashboardId: string) => `${API_V1}/dashboards/dashboard/${dashboardId}`; +export const DELETE_DASHBOARDS_URL = (dashboardId: string) => `${API_V1}/dashboards/dashboard/${dashboardId}`; export const CREATE_SAVED_FILTERS_URL = `${API_V1}/filters`; +export const CREATE_DASHBOARDS_URL = `${API_V1}/dashboards`; export const DELETE_SAVED_FILTERS_URL = (filterId: string) => `${API_V1}/filters/filter/${filterId}`; 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`; diff --git a/src/api/dashboard.ts b/src/api/dashboard.ts new file mode 100644 index 00000000..e5140d4b --- /dev/null +++ b/src/api/dashboard.ts @@ -0,0 +1,52 @@ +import { Axios } from './axios'; +import { + CREATE_DASHBOARDS_URL, + DELETE_DASHBOARDS_URL, + LIST_DASHBOARDS, + LOG_QUERY_URL, + UPDATE_DASHBOARDS_URL, +} from './constants'; +import timeRangeUtils from '@/utils/timeRangeUtils'; +import _ from 'lodash'; +import { + CreateDashboardType, + Dashboard, + TileQuery, + TileQueryResponse, + UpdateDashboardType, +} from '@/@types/parseable/api/dashboards'; + +const { optimizeEndTime } = timeRangeUtils; + +export const getDashboards = (userId: string) => { + return Axios().get(LIST_DASHBOARDS(userId)); +}; + +export const putDashboard = (dashboardId: string, dashboard: UpdateDashboardType) => { + return Axios().put(UPDATE_DASHBOARDS_URL(dashboardId), dashboard); +}; + +export const postDashboard = (dashboard: CreateDashboardType, userId: string) => { + return Axios().post(CREATE_DASHBOARDS_URL, { ...dashboard, user_id: userId }); +}; + +export const removeDashboard = (dashboardId: string) => { + return Axios().delete(DELETE_DASHBOARDS_URL(dashboardId)); +}; + +// using just for the dashboard tile now +// refactor once the fields are included in the /query in the response +export const getQueryData = (opts?: TileQuery) => { + if (_.isEmpty(opts)) throw 'Invalid Arguments'; + + const { query, startTime, endTime } = opts; + return Axios().post( + LOG_QUERY_URL({ fields: true }), + { + query, + startTime, + endTime: optimizeEndTime(endTime), + }, + {}, + ); +}; diff --git a/src/components/Navbar/index.tsx b/src/components/Navbar/index.tsx index ceebabd8..2c63ca9d 100644 --- a/src/components/Navbar/index.tsx +++ b/src/components/Navbar/index.tsx @@ -7,12 +7,13 @@ import { IconServerCog, IconHomeStats, IconListDetails, + IconLayoutDashboard, } from '@tabler/icons-react'; import { FC, useCallback, useEffect } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import { useNavigate } from 'react-router-dom'; import { useDisclosure } from '@mantine/hooks'; -import { HOME_ROUTE, CLUSTER_ROUTE, USERS_MANAGEMENT_ROUTE, STREAM_ROUTE } from '@/constants/routes'; +import { HOME_ROUTE, CLUSTER_ROUTE, USERS_MANAGEMENT_ROUTE, STREAM_ROUTE, DASHBOARDS_ROUTE } from '@/constants/routes'; import InfoModal from './infoModal'; import { getStreamsSepcificAccess, getUserSepcificStreams } from './rolesHandler'; import Cookies from 'js-cookie'; @@ -35,6 +36,12 @@ const navItems = [ path: '/', route: HOME_ROUTE, }, + { + icon: IconLayoutDashboard, + label: 'Dashboards', + path: '/dashboards', + route: DASHBOARDS_ROUTE, + }, { icon: IconListDetails, label: 'Stream', @@ -91,10 +98,11 @@ const Navbar: FC = () => { const [infoModalOpened, { toggle: toggleInfoModal, close: closeInfoModal }] = useDisclosure(false); const { getLogStreamListData } = useLogStream(); const { getUserRolesData, getUserRolesMutation } = useUser(); + const shouldRedirectToHome = _.isEmpty(userSpecificStreams) || _.isNil(userSpecificStreams); const navigateToPage = useCallback( (route: string) => { if (route === STREAM_ROUTE) { - if (_.isEmpty(userSpecificStreams) || _.isNil(userSpecificStreams)) return navigate('/'); + if (shouldRedirectToHome) return navigate('/'); const defaultStream = currentStream && currentStream.length !== 0 ? currentStream : userSpecificStreams[0].name; const stream = !streamName || streamName.length === 0 ? defaultStream : streamName; @@ -105,6 +113,9 @@ const Navbar: FC = () => { navigate(path); } } else { + if (shouldRedirectToHome && route === DASHBOARDS_ROUTE) { + return navigate('/'); + } return navigate(route); } }, @@ -185,10 +196,10 @@ const Navbar: FC = () => { navAction.key === 'about' ? toggleInfoModal : navAction.key === 'user' - ? toggleUserModal - : navAction.key === 'logout' - ? signOutHandler - : () => {}; + ? toggleUserModal + : navAction.key === 'logout' + ? signOutHandler + : () => {}; return ( void }) => { + const [activeDashboard, setDashboardsStore] = useDashboardsStore((store) => store.activeDashboard); + + const username = Cookies.get('username'); + const { + isError: fetchDashaboardsError, + isSuccess: fetchDashboardsSuccess, + isLoading: fetchDashboardsLoading, + refetch: fetchDashboards, + } = useQuery(['dashboards'], () => getDashboards(username || ''), { + retry: false, + enabled: false, // not on mount + refetchOnWindowFocus: false, + onSuccess: (data) => { + const firstDashboard = _.head(data.data); + if (!activeDashboard && firstDashboard && opts.updateTimeRange) { + opts.updateTimeRange(firstDashboard); + } + setDashboardsStore((store) => setDashboards(store, data.data || [])); + }, + onError: () => { + setDashboardsStore((store) => setDashboards(store, [])); + }, + }); + + const { mutate: createDashboard, isLoading: isCreatingDashboard } = useMutation( + (data: { dashboard: CreateDashboardType; onSuccess?: () => void }) => postDashboard(data.dashboard, username || ''), + { + onSuccess: (response, variables) => { + const { dashboard_id } = response.data; + if (_.isString(dashboard_id) && !_.isEmpty(dashboard_id)) { + setDashboardsStore((store) => selectDashboard(store, null, response.data)); + } + fetchDashboards(); + variables.onSuccess && variables.onSuccess(); + notifySuccess({ message: 'Created Successfully' }); + }, + onError: (data: AxiosError) => { + if (isAxiosError(data) && data.response) { + const error = data.response?.data as string; + typeof error === 'string' && notifyError({ message: error }); + } else if (data.message && typeof data.message === 'string') { + notifyError({ message: data.message }); + } + }, + }, + ); + + const { mutate: updateDashboard, isLoading: isUpdatingDashboard } = useMutation( + (data: { dashboard: UpdateDashboardType; onSuccess?: () => void }) => { + return putDashboard(data.dashboard.dashboard_id, data.dashboard); + }, + { + onSuccess: (_data, variables) => { + fetchDashboards(); + variables.onSuccess && variables.onSuccess(); + notifySuccess({ message: 'Updated Successfully' }); + }, + onError: (data: AxiosError) => { + if (isAxiosError(data) && data.response) { + const error = data.response?.data as string; + typeof error === 'string' && notifyError({ message: error }); + } else if (data.message && typeof data.message === 'string') { + notifyError({ message: data.message }); + } + }, + }, + ); + + const { mutate: deleteDashboard, isLoading: isDeleting } = useMutation( + (data: { dashboardId: string; onSuccess?: () => void }) => removeDashboard(data.dashboardId), + { + onSuccess: (_data, variables) => { + fetchDashboards().then(() => { + variables.onSuccess && variables.onSuccess(); + }); + notifySuccess({ message: 'Deleted Successfully' }); + }, + onError: (data: AxiosError) => { + if (isAxiosError(data) && data.response) { + const error = data.response?.data as string; + typeof error === 'string' && notifyError({ message: error }); + } else if (data.message && typeof data.message === 'string') { + notifyError({ message: data.message }); + } + }, + }, + ); + + return { + fetchDashaboardsError, + fetchDashboardsSuccess, + fetchDashboardsLoading, + fetchDashboards, + + createDashboard, + isCreatingDashboard, + updateDashboard, + isUpdatingDashboard, + + deleteDashboard, + isDeleting, + }; +}; + +export const useTileQuery = (opts?: { tileId?: string; onSuccess?: (data: TileQueryResponse) => void }) => { + const [, setDashboardsStore] = useDashboardsStore((_store) => null); + const { onSuccess } = opts || {}; + const [fetchState, setFetchState] = useState<{ + isLoading: boolean; + isError: null | boolean; + isSuccess: null | boolean; + }>({ isLoading: false, isError: null, isSuccess: null }); + + const fetchTileData = useCallback( + async (queryOpts: TileQuery) => { + try { + setFetchState({ isLoading: true, isError: null, isSuccess: null }); + const res = await getQueryData(queryOpts); + const tileData = _.isEmpty(res) ? { records: [], fields: [] } : res.data; + opts?.tileId && setDashboardsStore((store) => setTileData(store, opts.tileId || '', tileData)); + opts?.onSuccess && opts.onSuccess(tileData); + setFetchState({ isLoading: false, isError: false, isSuccess: true }); + } catch (e: any) { + setFetchState({ isLoading: false, isError: true, isSuccess: false }); + notifyError({ message: _.isString(e.response.data) ? e.response.data : 'Unable to fetch tile data' }); + } + }, + [onSuccess], + ); + + return { + ...fetchState, + fetchTileData, + }; +}; diff --git a/src/pages/Dashboards/Charts.tsx b/src/pages/Dashboards/Charts.tsx new file mode 100644 index 00000000..8d0b8f84 --- /dev/null +++ b/src/pages/Dashboards/Charts.tsx @@ -0,0 +1,351 @@ +import { TileData, TileQueryResponse, UnitType } from '@/@types/parseable/api/dashboards'; +import { AreaChart, BarChart, DonutChart, LineChart, PieChart, getFilteredChartTooltipPayload } from '@mantine/charts'; +import { Paper, Stack, Text } from '@mantine/core'; +import _ from 'lodash'; +import { circularChartTypes, graphTypes } from '@/@types/parseable/api/dashboards'; +import { IconAlertTriangle } from '@tabler/icons-react'; +import classes from './styles/Charts.module.css'; +import { CodeHighlight } from '@mantine/code-highlight'; +import { Log } from '@/@types/parseable/api/query'; +import { tickFormatter } from './utils'; + +export const chartColorsMap = { + black: 'dark.5', + gray: 'gray.5', + red: 'red.5', + pink: 'pink.5', + grape: 'grape.5', + violet: 'violet.5', + indigo: 'indigo.5', + cyan: 'cyan.5', + blue: 'blue.5', + teal: 'teal.5', + green: 'green.5', + lime: 'lime.5', + yellow: 'yellow.5', + orange: 'orange.5', +}; + +export const colors = [ + 'indigo', + 'cyan', + 'teal', + 'yellow', + 'grape', + 'violet', + 'blue', + 'pink', + 'lime', + 'orange', + 'red', + 'green', +]; +export const nullColor = 'gray'; + +export const getGraphVizComponent = (viz: string) => { + if (viz === 'line-chart') { + return Line; + } else if (viz === 'bar-chart') { + return Bar; + } else if (viz === 'area-chart') { + return Area; + } else { + return null; + } +}; + +export const getCircularVizComponent = (viz: string) => { + if (viz === 'donut-chart') { + return Donut; + } else if (viz === 'pie-chart') { + return Pie; + } else { + return null; + } +}; + +export type CircularChartData = { + name: string; + value: number; + color: string; +}[]; + +export type SeriesType = { + name: string; + color: string; +}[]; + +export const isCircularChart = (viz: string) => _.includes(circularChartTypes, viz); +export const isGraph = (viz: string) => _.includes(graphTypes, viz); + +const invalidConfigMsg = 'Invalid chart config'; +const noDataMsg = 'No data available'; +const invalidDataMsg = 'Invalid chart data'; + +const WarningView = (props: { msg: string | null }) => { + return ( + + + {props.msg} + + ); +}; + +const validateCircularChartData = (data: CircularChartData) => { + return _.every(data, (d) => _.isNumber(d.value)); +}; + +export const renderCircularChart = (opts: { + queryResponse: TileQueryResponse | null; + name_key: string; + value_key: string; + chart: string; + unit: UnitType; +}) => { + const { queryResponse, name_key, value_key, chart, unit } = opts; + + const VizComponent = getCircularVizComponent(chart); + const data = makeCircularChartData(queryResponse?.records || [], name_key, value_key); + + const isInvalidKey = _.isEmpty(name_key) || _.isEmpty(value_key); + const hasNoData = _.isEmpty(data); + const isValidData = validateCircularChartData(data); + const warningMsg = isInvalidKey ? invalidConfigMsg : hasNoData ? noDataMsg : !isValidData ? invalidDataMsg : null; + + return ( + + {warningMsg ? : VizComponent ? : null} + + ); +}; + +export const renderJsonView = (opts: { queryResponse: TileQueryResponse | null }) => { + return ( + + + + ); +}; + +export const renderGraph = (opts: { + queryResponse: TileQueryResponse | null; + x_key: string; + y_keys: string[]; + chart: string; + xUnit: UnitType; + yUnit: UnitType; +}) => { + const { queryResponse, x_key, y_keys, chart, xUnit, yUnit } = opts; + const VizComponent = getGraphVizComponent(chart); + const seriesData = makeSeriesData(queryResponse?.records || [], y_keys); + + const data = queryResponse?.records || []; + const isInvalidKey = _.isEmpty(x_key) || _.isEmpty(y_keys); + const hasNoData = _.isEmpty(seriesData) || _.isEmpty(data); + const warningMsg = isInvalidKey ? invalidConfigMsg : hasNoData ? noDataMsg : null; + + return ( + + {warningMsg ? ( + + ) : VizComponent ? ( + + ) : null} + + ); +}; + +export const makeCircularChartData = (data: Log[], name_key: string, value_key: string): CircularChartData => { + if (!_.isArray(data)) return []; + + const topN = 5; + const chartData = _.reduce( + data, + (acc, rec: Log) => { + if (!_.has(rec, name_key) || !_.has(rec, value_key)) { + return acc; + } + + const key = _.toString(rec[name_key]); + return { + ...acc, + [key]: rec[value_key], + }; + }, + {}, + ); + const topNKeys = _.keys(chartData).slice(0, topN); + const topNObject = _.pick(chartData, topNKeys); + const restObject = _.omit(chartData, topNKeys); + + let usedColors: string[] = []; + const { topNArcs } = _.reduce( + topNObject, + (acc: { topNArcs: CircularChartData; index: number }, value, key) => { + const { topNArcs, index } = acc; + const colorkey = _.difference(colors, usedColors)[index] || nullColor; + usedColors = [...usedColors, colorkey]; + const colorKey = _.difference(colors, usedColors)[index] || nullColor; + const color = colorKey in chartColorsMap ? chartColorsMap[colorKey as keyof typeof chartColorsMap] : nullColor; + return { topNArcs: [...topNArcs, { name: key, value, color: color || 'gray.4' }], index: index + 1 }; + }, + { topNArcs: [], index: 0 }, + ); + + const restArcValue = _.sum(_.values(restObject)); + return [...topNArcs, ...(restArcValue !== 0 ? [{ name: 'Others', value: restArcValue, color: 'gray.4' }] : [])]; +}; + +const makeSeriesData = (data: Log[], y_key: string[]) => { + if (!_.isArray(data)) return []; + + let usedColors: string[] = []; + + return _.reduce( + y_key, + (acc, key: string, index: number) => { + const colorKey = _.difference(colors, usedColors)[index] || nullColor; + const color = colorKey in chartColorsMap ? chartColorsMap[colorKey as keyof typeof chartColorsMap] : nullColor; + return [...acc, { color: color || 'gray.4', name: key }]; + }, + [], + ); +}; + +const Donut = (props: { data: CircularChartData; unit: UnitType }) => { + return ; +}; + +const Pie = (props: { data: CircularChartData; unit: UnitType }) => { + return ( + + ); +}; + +interface ChartTooltipProps { + label: string; + payload: Record[] | undefined; + xUnit: UnitType; + yUnit: UnitType; + chartType: 'line' | 'bar' | 'area' | 'donut' | 'pie'; +} + +function ChartTooltip({ label, payload, xUnit, yUnit, chartType }: ChartTooltipProps) { + if (!payload) return null; + + const sanitizedPayload = chartType === 'area' ? getFilteredChartTooltipPayload(payload) : payload; + return ( + + + {tickFormatter(label, xUnit)} + + + {sanitizedPayload.map((item: any, index: number) => { + const { name = '', value = null } = item; + return ( + + {name} + {tickFormatter(value, yUnit)} + + ); + })} + + + ); +} + +const Line = (props: { data: TileData; dataKey: string; series: SeriesType; xUnit: UnitType; yUnit: UnitType }) => { + return ( + tickFormatter(value, props.yUnit), + }} + xAxisProps={{ + tickFormatter: (value) => tickFormatter(value, props.xUnit), + }} + tooltipProps={{ + content: ({ label, payload }) => ( + + ), + }} + /> + ); +}; + +const Bar = (props: { data: TileData; dataKey: string; series: SeriesType; xUnit: UnitType; yUnit: UnitType }) => { + return ( + tickFormatter(value, props.yUnit), + }} + xAxisProps={{ + tickFormatter: (value) => tickFormatter(value, props.xUnit), + }} + tooltipProps={{ + content: ({ label, payload }) => ( + + ), + }} + /> + ); +}; + +const Area = (props: { data: TileData; dataKey: string; series: SeriesType; xUnit: UnitType; yUnit: UnitType }) => { + return ( + tickFormatter(value, props.yUnit), + }} + xAxisProps={{ + tickFormatter: (value) => tickFormatter(value, props.xUnit), + }} + tooltipProps={{ + content: ({ label, payload }) => ( + + ), + }} + /> + ); +}; + +const charts = { + Donut, +}; + +export default charts; diff --git a/src/pages/Dashboards/CreateDashboardModal.tsx b/src/pages/Dashboards/CreateDashboardModal.tsx new file mode 100644 index 00000000..180228f5 --- /dev/null +++ b/src/pages/Dashboards/CreateDashboardModal.tsx @@ -0,0 +1,164 @@ +import { Box, Button, Loader, Modal, Select, Stack, Text, TextInput } from '@mantine/core'; +import classes from './styles/CreateDashboardModal.module.css'; +import { useDashboardsStore, dashboardsStoreReducers } from './providers/DashboardsProvider'; +import { useCallback, useEffect } from 'react'; +import _ from 'lodash'; +import { useDashboardsQuery } from '@/hooks/useDashboards'; +import { useForm } from '@mantine/form'; +import { useLogsStore } from '../Stream/providers/LogsProvider'; +import timeRangeUtils from '@/utils/timeRangeUtils'; +import { Tile } from '@/@types/parseable/api/dashboards'; + +const { toggleCreateDashboardModal, toggleEditDashboardModal } = dashboardsStoreReducers; +const { makeTimeRangeOptions, getDefaultTimeRangeOption } = timeRangeUtils; + +const DEFAULT_REFRESH_INTERVAL = 60; + +type FormOpts = { + name: string; + description: string; + refresh_interval: number; + tiles: Tile[]; + dashboard_id?: string; + time_filter: null | string; +}; + +const useDashboardForm = (opts: FormOpts) => { + const form = useForm({ + mode: 'controlled', + initialValues: opts, + validate: { + name: (val) => (_.isEmpty(val) ? 'Name cannot be empty' : null), + description: (_val) => (null), + }, + validateInputOnChange: true, + validateInputOnBlur: true, + }); + + const onChangeValue = useCallback((key: string, value: any) => { + form.setFieldValue(key, value); + }, []); + + return { form, onChangeValue }; +}; + +const defaultOpts = { + name: '', + description: '', + refresh_interval: DEFAULT_REFRESH_INTERVAL, + tiles: [], + time_filter: null, +}; + +const CreateDashboardModal = () => { + const [createMode, setDashbaordsStore] = useDashboardsStore((store) => store.createDashboardModalOpen); + const [editMode] = useDashboardsStore((store) => store.editDashboardModalOpen); + const [timeRange] = useLogsStore((store) => store.timeRange); + const [activeDashboard] = useDashboardsStore((store) => store.activeDashboard); + const timeRangeOptions = makeTimeRangeOptions({ + selected: editMode && activeDashboard ? activeDashboard.time_filter : null, + current: timeRange, + }); + const selectedTimeRangeOption = getDefaultTimeRangeOption(timeRangeOptions); + const { form } = useDashboardForm(defaultOpts); + const { createDashboard, updateDashboard, isCreatingDashboard, isUpdatingDashboard } = useDashboardsQuery({}); + const showLoader = isCreatingDashboard || isUpdatingDashboard; + + useEffect(() => { + if (editMode) { + const formValues = { + ...defaultOpts, + ...(activeDashboard ? { ...activeDashboard, time_filter: selectedTimeRangeOption.value } : {}), + }; + form.setValues(formValues); + } else { + form.setValues(defaultOpts); + } + }, [editMode, createMode]); + + const closeModal = useCallback(() => { + if (createMode) { + setDashbaordsStore((store) => toggleCreateDashboardModal(store, false)); + } else { + setDashbaordsStore((store) => toggleEditDashboardModal(store, false)); + } + }, [createMode, editMode]); + + const onSubmit = useCallback(() => { + const dashboard = form.values; + const timeFilter = + _.find(timeRangeOptions, (option) => option.value === dashboard.time_filter)?.time_filter || null; + if (editMode) { + const dashboardId = dashboard.dashboard_id; + if (dashboardId) { + updateDashboard({ + dashboard: { ...dashboard, dashboard_id: dashboardId, time_filter: timeFilter }, + onSuccess: closeModal, + }); + } + } else { + createDashboard({ dashboard: { ...dashboard, time_filter: timeFilter }, onSuccess: closeModal }); + } + }, [form.values, editMode, createMode, timeRangeOptions]); + + return ( + + + + + + + + Time Range + + + + {errorMsg && {errorMsg}} + + + {llmActive ? ( + + setAiQuery(e.target.value)} + placeholder={ + isValidStream + ? 'Enter plain text to generate SQL query using OpenAI' + : 'Choose a schema to generate AI query' + } + style={{ flex: 1 }} + disabled={!isValidStream} + /> + + + ) : ( + + + Know More: How to enable SQL generation with OpenAI ? + + + )} + + + + + + + + + + + + + + + + + ); +}; + +const Config = (props: { form: TileFormType; onChangeValue: (key: string, value: any) => void }) => { + const { form, onChangeValue } = props; + const [dashboards] = useDashboardsStore((store) => store.dashboards); + const allDashboards = useMemo( + () => _.map(dashboards, (dashboard) => ({ label: dashboard.name, value: dashboard.dashboard_id })), + [dashboards], + ); + return ( + + + + + ({ label: _.get(vizLabelMap, viz, viz), value: viz }))} + classNames={{ label: classes.fieldTitle }} + label="Type" + placeholder="Type" + key="visualization.visualization_type" + {...props.form.getInputProps('visualization.visualization_type')} + style={{ width: '50%' }} + /> + ({ label: field, value: field }))} + classNames={{ label: classes.fieldTitle }} + label="Name" + placeholder="Name" + key="visualization.circular_chart_config.name_key" + {...props.form.getInputProps('visualization.circular_chart_config.name_key')} + style={{ width: '50%' }} + /> + ({ label: field, value: field }))} + classNames={{ label: classes.fieldTitle }} + label="X Axis" + placeholder="X Axis" + key="visualization.graph_config.x_key" + {...props.form.getInputProps('visualization.graph_config.x_key')} + style={{ width: '50%' }} + /> + ({ label: field, value: field }))} + classNames={{ label: classes.fieldTitle }} + placeholder="Y Axis" + key={currentKeyPath} + {...props.form.getInputProps(currentKeyPath)} + style={{ width: '50%' }} + /> +