diff --git a/apps/tup-ui/src/components/auth/LoginComponent/LoginComponent.spec.tsx b/apps/tup-ui/src/components/auth/LoginComponent/LoginComponent.spec.tsx index 022e7c54b..f2a97cb58 100644 --- a/apps/tup-ui/src/components/auth/LoginComponent/LoginComponent.spec.tsx +++ b/apps/tup-ui/src/components/auth/LoginComponent/LoginComponent.spec.tsx @@ -42,7 +42,7 @@ describe('LoginComponent', () => { it('should display an error message on bad passwords', async () => { // Mock a 403 error server.use( - rest.post('http://localhost:8000/auth', (req, res, ctx) => { + rest.post('http://localhost:8001/auth', (req, res, ctx) => { return res.once( ctx.status(403), ctx.json({ message: 'Invalid username/password' }) @@ -71,7 +71,7 @@ describe('LoginComponent', () => { it('should display an error message for other status codes', async () => { // Mock a 403 error server.use( - rest.post('http://localhost:8000/auth', (req, res, ctx) => { + rest.post('http://localhost:8001/auth', (req, res, ctx) => { return res.once( ctx.status(500), ctx.json({ message: 'Internal server error' }) diff --git a/apps/tup-ui/src/components/sysmon/SystemMonitor.module.css b/apps/tup-ui/src/components/sysmon/SystemMonitor.module.css new file mode 100644 index 000000000..ae17dfbab --- /dev/null +++ b/apps/tup-ui/src/components/sysmon/SystemMonitor.module.css @@ -0,0 +1,77 @@ +.root { + --table-border: 1px solid black; + + margin: 0; + width: 100%; + + font-size: 0.875rem; /* 14px (approved deviation from design) */ +} + +.rows tr:nth-child(even) { + background-color: rgb(0 0 0 / 3%); +} +.rows tr:hover { + background-color: rgb(0 0 0 / 5%); +} +.rows td { + padding: 5px 8px; +} + +.header { + border-bottom: var(--table-border); +} + +/* Columns */ +@media only screen and (min-width: 1200px) { + /* name */ + .root tr > *:nth-child(1) { + width: 22%; + } + /* status */ + .root tr > *:nth-child(2) { + width: 30%; + } + /* load */ + .root tr > *:nth-child(3) { + width: 16%; + } + /* run */ + .root tr > *:nth-child(4) { + width: 16%; + } + /* queue */ + .root tr > *:nth-child(5) { + width: 16%; + } +} +@media only screen and (max-width: 1199px) { + /* name */ + .root tr > *:nth-child(1) { + width: 22%; + } + /* status */ + .root tr > *:nth-child(2) { + width: 24%; + } + /* load */ + .root tr > *:nth-child(3) { + width: 18%; + } + /* run */ + .root tr > *:nth-child(4) { + width: 18%; + } + /* queue */ + .root tr > *:nth-child(5) { + width: 18%; + } +} + +/* Messaging */ +.error { + display: flex; + justify-content: center; + align-items: center; + color: var(--global-color-accent--normal); + padding: 30px; +} diff --git a/apps/tup-ui/src/components/sysmon/SystemMonitor.test.tsx b/apps/tup-ui/src/components/sysmon/SystemMonitor.test.tsx new file mode 100644 index 000000000..e44df2696 --- /dev/null +++ b/apps/tup-ui/src/components/sysmon/SystemMonitor.test.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import SystemMonitor from './SystemMonitor'; +import { testRender } from '../../utils'; +import { waitFor } from '@testing-library/react'; + +describe('System Monitor Component', () => { + it('display a no-systems message when there is no data', async () => { + const { getByText } = testRender(); + await waitFor(() => + expect(getByText('No systems being monitored')).toBeDefined() + ); + }); + it('should display the system name in each row', async () => { + const { getByText } = testRender(); + await waitFor(() => expect(getByText('Frontera')).toBeDefined()); + }); +}); diff --git a/apps/tup-ui/src/components/sysmon/SystemMonitor.tsx b/apps/tup-ui/src/components/sysmon/SystemMonitor.tsx new file mode 100644 index 000000000..771ec034a --- /dev/null +++ b/apps/tup-ui/src/components/sysmon/SystemMonitor.tsx @@ -0,0 +1,97 @@ +import React, { useMemo } from 'react'; +import { useTable, Column } from 'react-table'; +import { LoadingSpinner, Message } from '@tacc/core-components'; +import { Display, Operational, Load } from './SystemMonitorCells'; +import { SystemMonitorSystem, useSystemMonitor } from '../../hooks'; +import styles from './SystemMonitor.module.css'; + +const SystemMonitor: React.FC<{ hosts?: Array }> = ({ hosts }) => { + const { systems, isLoading, error } = useSystemMonitor(hosts); + const columns = useMemo[]>( + () => [ + { + accessor: 'display_name', + Header: 'Name', + Cell: Display, + }, + { + accessor: 'isOperational', + Header: 'Status', + Cell: Operational, + }, + { + accessor: 'loadPercentage', + Header: 'Load', + Cell: Load, + }, + { + accessor: ({ jobs }) => (jobs ? jobs.running : '--'), + Header: 'Running', + }, + { + accessor: ({ jobs }) => (jobs ? jobs.queued : '--'), + Header: 'Queued', + }, + ], + [] + ); + const { getTableProps, getTableBodyProps, rows, prepareRow, headerGroups } = + useTable({ + columns, + data: systems, + }); + + if (isLoading) { + return ; + } + + if (error) { + return ( + + Unable to gather system information + + ); + } + + return ( + and its use of `o-fixed-header-table` + // TODO: Create global table styles & Make use them + className={`multi-system InfiniteScrollTable o-fixed-header-table ${styles['root']}`} + > + + {headerGroups.map((headerGroup) => ( + + {headerGroup.headers.map((column) => ( + + ))} + + ))} + + + {rows.length ? ( + rows.map((row, idx) => { + prepareRow(row); + return ( + + {row.cells.map((cell) => ( + + ))} + + ); + }) + ) : ( + + + + )} + +
{column.render('Header')}
{cell.render('Cell')}
No systems being monitored
+ ); +}; + +export default SystemMonitor; diff --git a/apps/tup-ui/src/components/sysmon/SystemMonitorCells.tsx b/apps/tup-ui/src/components/sysmon/SystemMonitorCells.tsx new file mode 100644 index 000000000..6c496d035 --- /dev/null +++ b/apps/tup-ui/src/components/sysmon/SystemMonitorCells.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Pill } from '@tacc/core-components'; +import { Cell } from 'react-table'; +import { SystemMonitorSystem } from '../../hooks'; + +export const Display: React.FC<{ cell: Cell }> = ({ + cell: { value }, +}) => {value}; + +export const Operational: React.FC<{ + cell: Cell; +}> = ({ cell: { value } }) => { + if (value) { + return Operational; + } + return Maintenance; +}; + +export const Load: React.FC<{ + cell: Cell; +}> = ({ cell: { value } }) => {value ? `${value}%` : '--'}; diff --git a/apps/tup-ui/src/components/sysmon/index.js b/apps/tup-ui/src/components/sysmon/index.js new file mode 100644 index 000000000..f98693f58 --- /dev/null +++ b/apps/tup-ui/src/components/sysmon/index.js @@ -0,0 +1 @@ +export { default } from './SystemMonitor'; diff --git a/apps/tup-ui/src/hooks/index.ts b/apps/tup-ui/src/hooks/index.ts index d02c518a1..382148be7 100644 --- a/apps/tup-ui/src/hooks/index.ts +++ b/apps/tup-ui/src/hooks/index.ts @@ -55,6 +55,49 @@ export type AuthBody = { password: string; }; +export type SystemMonitorTest = { + type: string; + status: boolean; + timestamp: string; +}; + +export type SystemMonitorRawSystem = { + hostname: string; + displayName: string; + ssh?: SystemMonitorTest; + tests?: { + heartbeat?: SystemMonitorTest; + ssh?: SystemMonitorTest; + }; + timestamp: string; + jobs?: { + running: number; + queued: number; + other: number; + }; + totalCpu: number; + usedCpu: number; + load: number; + heartbeat?: SystemMonitorTest; +}; + +export type SystemMonitorRaw = { + [hostname: string]: SystemMonitorRawSystem; +}; + +export type SystemMonitorSystem = { + hostname: string; + display_name: string; + isOperational: boolean; + loadPercentage?: number; + jobs?: { + running: number; + queued: number; + other: number; + }; +}; + export { default as useAuth } from './useAuth'; export { default as useProfile } from './useProfile'; export { default as useJwt } from './useJwt'; +export { default as useSystemMonitor } from './useSystemMonitor'; diff --git a/apps/tup-ui/src/hooks/requests.spec.tsx b/apps/tup-ui/src/hooks/requests.spec.tsx index 52c30105f..61f637f86 100644 --- a/apps/tup-ui/src/hooks/requests.spec.tsx +++ b/apps/tup-ui/src/hooks/requests.spec.tsx @@ -21,11 +21,14 @@ describe('requests', () => { jest.spyOn(axios, 'get').mockResolvedValue({ data: 'response' }); - const { result } = renderHook(() => useGet('/endpoint', 'key'), { - wrapper: TestWrapper, - }); + const { result } = renderHook( + () => useGet({ endpoint: '/endpoint', key: 'key' }), + { + wrapper: TestWrapper, + } + ); await waitFor(() => expect(result.current.data).toEqual('response')); - expect(axios.get).toHaveBeenCalledWith('http://localhost:8000/endpoint', { + expect(axios.get).toHaveBeenCalledWith('http://localhost:8001/endpoint', { headers: { 'x-tup-token': 'abc123' }, }); }); @@ -38,13 +41,16 @@ describe('requests', () => { (axios.post as jest.Mock).mockResolvedValue({ data: 'response', }); - const { result } = renderHook(() => usePost('/endpoint'), { - wrapper: TestWrapper, - }); + const { result } = renderHook( + () => usePost({ endpoint: '/endpoint' }), + { + wrapper: TestWrapper, + } + ); act(() => result.current.mutate('body')); await waitFor(() => expect(result.current.data).toEqual('response')); expect(axios.post).toHaveBeenCalledWith( - 'http://localhost:8000/endpoint', + 'http://localhost:8001/endpoint', 'body', { headers: { 'x-tup-token': 'abc123' } } ); diff --git a/apps/tup-ui/src/hooks/requests.ts b/apps/tup-ui/src/hooks/requests.ts index 62b32ae29..b741ff385 100644 --- a/apps/tup-ui/src/hooks/requests.ts +++ b/apps/tup-ui/src/hooks/requests.ts @@ -8,36 +8,51 @@ import { UseMutationOptions, } from 'react-query'; -export function useGet( - endpoint: string, - key: string, - options: Omit< - UseQueryOptions, - 'queryKey' | 'queryFn' - > = {} -) { +type UseGetParams = { + endpoint: string; + key: string; + options?: Omit, 'queryKey' | 'queryFn'>; + baseUrl?: string; +}; + +export function useGet({ + endpoint, + key, + options = {}, + baseUrl: alternateBaseUrl, +}: UseGetParams) { const client = axios; const { baseUrl } = useConfig(); const { jwt } = useJwt(); const getUtil = async () => { - const request = await client.get(`${baseUrl}${endpoint}`, { - headers: { 'x-tup-token': jwt ?? '' }, - }); + const request = await client.get( + `${alternateBaseUrl ?? baseUrl}${endpoint}`, + { + headers: { 'x-tup-token': jwt ?? '' }, + } + ); return request.data; }; return useQuery(key, () => getUtil(), options); } -export function usePost( - endpoint: string, - options: UseMutationOptions = {} -) { +type UsePostParams = { + endpoint: string; + options?: UseMutationOptions; + baseUrl?: string; +}; + +export function usePost({ + endpoint, + options = {}, + baseUrl: alternateBaseUrl, +}: UsePostParams) { const client = axios; const { baseUrl } = useConfig(); const { jwt } = useJwt(); const postUtil = async (body: BodyType) => { const response = await client.post( - `${baseUrl}${endpoint}`, + `${alternateBaseUrl ?? baseUrl}${endpoint}`, body, { headers: { 'x-tup-token': jwt ?? '' }, diff --git a/apps/tup-ui/src/hooks/useAuth.ts b/apps/tup-ui/src/hooks/useAuth.ts index 332ecba53..7870c1e5b 100644 --- a/apps/tup-ui/src/hooks/useAuth.ts +++ b/apps/tup-ui/src/hooks/useAuth.ts @@ -29,7 +29,10 @@ const useAuth = () => { ); // Use the post hook with the auth endpoint and the callback - const mutation = usePost('/auth', { onSuccess }); + const mutation = usePost({ + endpoint: '/auth', + options: { onSuccess }, + }); // Rename some of the default react-query functions to be meaningful to this hook's functions const { mutate: login, ...extra } = mutation; diff --git a/apps/tup-ui/src/hooks/useConfig.ts b/apps/tup-ui/src/hooks/useConfig.ts index b56422508..e10b87cec 100644 --- a/apps/tup-ui/src/hooks/useConfig.ts +++ b/apps/tup-ui/src/hooks/useConfig.ts @@ -2,7 +2,7 @@ const useConfig = () => { const config = window.__TUP_CONFIG__; // return a default if no config exists on the window. - return config ?? { baseUrl: 'http://localhost:8000' }; + return config ?? { baseUrl: 'http://localhost:8001' }; }; export default useConfig; diff --git a/apps/tup-ui/src/hooks/useProfile.ts b/apps/tup-ui/src/hooks/useProfile.ts index bb26b8006..19d9f6e41 100644 --- a/apps/tup-ui/src/hooks/useProfile.ts +++ b/apps/tup-ui/src/hooks/useProfile.ts @@ -4,7 +4,10 @@ import { useGet } from './requests'; // Query to retrieve the user's profile object. const useProfile = (): UseQueryResult => { - const query = useGet('/auth/profile', 'profile'); + const query = useGet({ + endpoint: '/users/profile', + key: 'profile', + }); return query; }; diff --git a/apps/tup-ui/src/hooks/useSystemMonitor.ts b/apps/tup-ui/src/hooks/useSystemMonitor.ts new file mode 100644 index 000000000..88d59d441 --- /dev/null +++ b/apps/tup-ui/src/hooks/useSystemMonitor.ts @@ -0,0 +1,97 @@ +import { useMemo } from 'react'; +import { UseQueryResult } from 'react-query'; +import { + SystemMonitorSystem, + SystemMonitorRaw, + SystemMonitorRawSystem, +} from '.'; +import { useGet } from './requests'; + +const getSystemDisplayName = (hostname: string): string => { + const first = hostname.split('.')[0]; + return first.charAt(0).toUpperCase() + first.slice(1); +}; + +const getSystemType = ( + rawSystem: SystemMonitorRawSystem +): 'compute' | 'storage' => (rawSystem.jobs ? 'compute' : 'storage'); + +const wasUpdatedRecently = (timestamp: string): boolean => { + const updated = new Date(timestamp).getTime(); + const now = new Date().getTime(); + return now - 600000 > updated; +}; + +const isSystemDown = (rawSystem: SystemMonitorRawSystem): boolean => { + if (getSystemType(rawSystem) === 'compute') { + if (!rawSystem.load || !rawSystem.jobs) { + return false; + } + if (rawSystem.load * 100 > 99 || !rawSystem.jobs.running) { + return false; + } + } + if ( + rawSystem.tests && + Object.values(rawSystem.tests).some( + (test) => !test.status || !wasUpdatedRecently(test.timestamp) + ) + ) { + return false; + } + return true; +}; + +type UseSystemMonitorResult = { + systems: Array; +} & UseQueryResult; + +// Query to retrieve the user's profile object. +const useSystemMonitor = ( + hosts: Array = [ + 'frontera.tacc.utexas.edu', + 'stampede2.tacc.utexas.edu', + 'maverick2.tacc.utexas.edu', + 'longhorn.tacc.utexas.edu', + ] +): UseSystemMonitorResult => { + const query = useGet({ + endpoint: '/sysmon', + key: 'sysmon', + }); + const { data } = query; + const systems = useMemo>(() => { + const result: Array = []; + hosts.forEach((host) => { + if (!data?.[host]) { + result.push({ + hostname: host, + display_name: getSystemDisplayName(host), + isOperational: false, + loadPercentage: 0, + jobs: { + running: 0, + queued: 0, + other: 0, + }, + }); + return; + } + const rawSystem = data[host]; + const isCompute = getSystemType(rawSystem) === 'compute'; + result.push({ + hostname: rawSystem.hostname, + display_name: rawSystem.displayName, + isOperational: !isSystemDown(rawSystem), + loadPercentage: isCompute + ? Math.floor(rawSystem.load * 100) + : undefined, + jobs: isCompute ? rawSystem.jobs : undefined, + }); + }); + return result; + }, [data, hosts]); + return { systems, ...query }; +}; + +export default useSystemMonitor; diff --git a/apps/tup-ui/src/mocks/fixtures/index.ts b/apps/tup-ui/src/mocks/fixtures/index.ts index adffd96b6..43e003cc6 100644 --- a/apps/tup-ui/src/mocks/fixtures/index.ts +++ b/apps/tup-ui/src/mocks/fixtures/index.ts @@ -1,2 +1,3 @@ export { default as mockProfile } from './profile'; export { default as mockJwt } from './auth'; +export { rawSystemMonitorOutput, systemMonitorOutput } from './sysmon'; diff --git a/apps/tup-ui/src/mocks/fixtures/sysmon.ts b/apps/tup-ui/src/mocks/fixtures/sysmon.ts new file mode 100644 index 000000000..3342810e9 --- /dev/null +++ b/apps/tup-ui/src/mocks/fixtures/sysmon.ts @@ -0,0 +1,150 @@ +export const rawSystemMonitorOutput = { + 'lonestar6.tacc.utexas.edu': { + hostname: 'lonestar6.tacc.utexas.edu', + displayName: 'Lonestar6', + ssh: { type: 'ssh', status: true, timestamp: '2022-08-24T00:54:02Z' }, + tests: { + heartbeat: { + type: 'heartbeat', + status: true, + timestamp: '2022-08-24T00:54:02.111Z', + }, + ssh: { type: 'ssh', status: true, timestamp: '2022-08-24T00:54:02.114Z' }, + }, + timestamp: '2022-08-24T00:54:03.509Z', + jobs: { running: 320, queued: 189, other: 5 }, + totalCpu: 72928, + usedCpu: 67200, + load: 0.9214567792891619, + heartbeat: { + type: 'heartbeat', + status: true, + timestamp: '2022-08-24T00:54:02Z', + }, + }, + 'frontera.tacc.utexas.edu': { + hostname: 'frontera.tacc.utexas.edu', + displayName: 'Frontera', + tests: { + heartbeat: { + type: 'heartbeat', + status: true, + timestamp: '2022-08-24T00:54:02.153Z', + }, + ssh: { type: 'ssh', status: true, timestamp: '2022-08-24T00:54:02.155Z' }, + }, + timestamp: '2022-08-24T00:54:10.093Z', + jobs: { running: 263, queued: 520, other: 160 }, + heartbeat: { + type: 'heartbeat', + status: true, + timestamp: '2022-08-24T00:54:02Z', + }, + ssh: { type: 'ssh', status: true, timestamp: '2022-08-24T00:54:02Z' }, + totalCpu: 472200, + usedCpu: 463600, + load: 0.9817873782295637, + }, + 'stampede2.tacc.utexas.edu': { + hostname: 'stampede2.tacc.utexas.edu', + displayName: 'Stampede2', + tests: { + heartbeat: { + type: 'heartbeat', + status: true, + timestamp: '2022-08-11T17:39:02.746Z', + }, + ssh: { type: 'ssh', status: true, timestamp: '2022-08-11T17:39:02.750Z' }, + }, + heartbeat: { + type: 'heartbeat', + status: true, + timestamp: '2022-08-11T17:39:02Z', + }, + ssh: { type: 'ssh', status: true, timestamp: '2022-08-11T17:39:02Z' }, + timestamp: '2022-08-11T17:39:10.954Z', + jobs: { running: 570, queued: 598, other: 158 }, + totalCpu: 1223040, + usedCpu: 1119776, + load: 0.9155677655677655, + }, + 'longhorn.tacc.utexas.edu': { + timestamp: '2022-08-24T00:54:02.782Z', + hostname: 'longhorn.tacc.utexas.edu', + displayName: 'Longhorn', + jobs: { running: 11, queued: 3, other: 2 }, + heartbeat: { + type: 'heartbeat', + status: true, + timestamp: '2022-08-24T00:54:02Z', + }, + totalCpu: 16000, + usedCpu: 7200, + load: 0.45, + ssh: { type: 'ssh', status: true, timestamp: '2022-08-24T00:54:02Z' }, + tests: { + ssh: { type: 'ssh', status: true, timestamp: '2022-08-24T00:54:02.578Z' }, + heartbeat: { + type: 'heartbeat', + status: true, + timestamp: '2022-08-24T00:54:02.574Z', + }, + }, + }, + 'maverick2.tacc.utexas.edu': { + timestamp: '2022-08-24T00:54:02.576Z', + hostname: 'maverick2.tacc.utexas.edu', + displayName: 'Maverick2', + jobs: { running: 16, queued: 1, other: 0 }, + totalCpu: 748, + usedCpu: 428, + load: 0.5721925133689839, + }, +}; + +export const systemMonitorOutput = [ + { + hostname: 'frontera.tacc.utexas.edu', + display_name: 'Frontera', + isOperational: true, + loadPercentage: 98.17873782295638, + jobs: { + running: 263, + queued: 520, + other: 160, + }, + }, + { + hostname: 'stampede2.tacc.utexas.edu', + display_name: 'Stampede2', + isOperational: true, + loadPercentage: 91.55677655677655, + jobs: { + running: 570, + queued: 598, + other: 158, + }, + }, + { + hostname: 'maverick2.tacc.utexas.edu', + display_name: 'Maverick2', + isOperational: true, + loadPercentage: 57.21925133689839, + jobs: { + running: 16, + queued: 1, + other: 0, + }, + }, + { + hostname: 'longhorn.tacc.utexas.edu', + display_name: 'Longhorn', + isOperational: true, + loadPercentage: 45, + jobs: { + running: 11, + queued: 3, + other: 2, + }, + }, +]; diff --git a/apps/tup-ui/src/mocks/handlers.ts b/apps/tup-ui/src/mocks/handlers.ts index 88513b51b..0ea7f7942 100644 --- a/apps/tup-ui/src/mocks/handlers.ts +++ b/apps/tup-ui/src/mocks/handlers.ts @@ -1,13 +1,17 @@ import { rest } from 'msw'; -import { mockProfile, mockJwt } from './fixtures'; +import { mockProfile, mockJwt, rawSystemMonitorOutput } from './fixtures'; export const handlers = [ - rest.get('http://localhost:8000/auth/profile', (req, res, ctx) => { + rest.get('http://localhost:8001/users/profile', (req, res, ctx) => { // Respond with a TAS user profile return res(ctx.json(mockProfile)); }), - rest.post('http://localhost:8000/auth', (req, res, ctx) => { + rest.post('http://localhost:8001/auth', (req, res, ctx) => { // Respond with a valid jwt return res(ctx.json(mockJwt)); }), + rest.get('http://localhost:8001/sysmon', (req, res, ctx) => { + // Respond with mock system monitor output + return res(ctx.json(rawSystemMonitorOutput)); + }), ]; diff --git a/apps/tup-ui/src/pages/Dashboard/Layout.tsx b/apps/tup-ui/src/pages/Dashboard/Layout.tsx index f94aa507e..c8bd8be6c 100644 --- a/apps/tup-ui/src/pages/Dashboard/Layout.tsx +++ b/apps/tup-ui/src/pages/Dashboard/Layout.tsx @@ -1,11 +1,15 @@ import React from 'react'; import { ProfileComponent } from '../../components/profile'; import { RequireAuth } from '../../components/utils'; +import SystemMonitor from '../../components/sysmon'; const Layout: React.FC = () => { return ( - +
+ + +
); }; diff --git a/package-lock.json b/package-lock.json index 06beabc66..448022684 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "@types/react": "18.0.8", "@types/react-dom": "18.0.3", "@types/react-router-dom": "5.3.3", + "@types/react-table": "^7.7.12", "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "~5.18.0", "@typescript-eslint/parser": "~5.18.0", @@ -6458,6 +6459,15 @@ "@types/react-router": "*" } }, + "node_modules/@types/react-table": { + "version": "7.7.12", + "resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-7.7.12.tgz", + "integrity": "sha512-bRUent+NR/WwtDGwI/BqhZ8XnHghwHw0HUKeohzB5xN3K2qKWYE5w19e7GCuOkL1CXD9Gi1HFy7TIm2AvgWUHg==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", @@ -28484,6 +28494,15 @@ "@types/react-router": "*" } }, + "@types/react-table": { + "version": "7.7.12", + "resolved": "https://registry.npmjs.org/@types/react-table/-/react-table-7.7.12.tgz", + "integrity": "sha512-bRUent+NR/WwtDGwI/BqhZ8XnHghwHw0HUKeohzB5xN3K2qKWYE5w19e7GCuOkL1CXD9Gi1HFy7TIm2AvgWUHg==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/resolve": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz", diff --git a/package.json b/package.json index 9b66dd6c6..87cc4fe99 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@types/react": "18.0.8", "@types/react-dom": "18.0.3", "@types/react-router-dom": "5.3.3", + "@types/react-table": "^7.7.12", "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "~5.18.0", "@typescript-eslint/parser": "~5.18.0",