diff --git a/ui/.eslintignore b/ui/.eslintignore index 6d8bc7a10..9555c93ea 100644 --- a/ui/.eslintignore +++ b/ui/.eslintignore @@ -11,3 +11,4 @@ # hidden configuration files !.eslintrc.js !.prettierrc.js +vite.config.ts diff --git a/ui/.eslintrc.js b/ui/.eslintrc.js index f5db34973..cb27c3fa4 100644 --- a/ui/.eslintrc.js +++ b/ui/.eslintrc.js @@ -39,6 +39,14 @@ module.exports = { optionalDependencies: false, }, ], + + // The following rules began throwing errors after tooling updates. These should probably + // be re-enabled and fixed at some point + 'react/jsx-no-bind': 'off', + '@typescript-eslint/no-floating-promises': 'off', + '@typescript-eslint/no-misused-promises': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + // End disabled tooling rules }, overrides: [ diff --git a/ui/public/index.html b/ui/index.html similarity index 96% rename from ui/public/index.html rename to ui/index.html index d9ae652dd..04ffe991a 100644 --- a/ui/public/index.html +++ b/ui/index.html @@ -24,7 +24,7 @@ Learn how to configure a non-root public URL by running `npm run build`. --> StackRox Infra - +
diff --git a/ui/package.json b/ui/package.json index b3eb81453..7e18426d5 100644 --- a/ui/package.json +++ b/ui/package.json @@ -3,8 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { - "start": "PORT=3001 HTTPS=true NODE_OPTIONS=--openssl-legacy-provider EXTEND_ESLINT=true react-scripts start", - "build": "NODE_OPTIONS=--openssl-legacy-provider EXTEND_ESLINT=true react-scripts build", + "start": "PORT=3001 HTTPS=true NODE_OPTIONS=--openssl-legacy-provider EXTEND_ESLINT=true vite", + "build": "NODE_OPTIONS=--openssl-legacy-provider EXTEND_ESLINT=true vite build", "test": "react-scripts test", "eject": "react-scripts eject", "lint-check": "npm-run-all lint-check:*", @@ -19,16 +19,18 @@ "@patternfly/patternfly": "^6.1.0", "@patternfly/react-core": "^6.0.0", "@patternfly/react-icons": "^6.0.0", + "@tanstack/react-query": "^5.66.9", "axios": "^0.21.4", + "eslint": "^7.11.0", "formik": "^2.4.2", "history": "^5.0.1", "lodash": "^4.17.21", "moment": "^2.29.4", - "namor": "^2.0.3", + "random-words": "^2.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^8.0.0", - "react-router-dom": "^6.14.1", + "react-router-dom": "^6.29.0", "yup": "^1.3.3" }, "devDependencies": { @@ -37,19 +39,22 @@ "@testing-library/user-event": "^13.2.1", "@types/jest": "^27.0.2", "@types/lodash": "^4.14.195", - "@types/node": "^20.5.4", + "@types/node": "^22.13.4", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.14", "eslint-config-airbnb-typescript": "^12.0.0", "eslint-config-prettier": "^7.2.0", + "eslint-config-react-app": "^7.0.1", "eslint-plugin-eslint-comments": "^3.2.0", "eslint-plugin-prettier": "^3.4.1", "http-proxy-middleware": "^2.0.6", "npm-run-all": "^4.1.5", "prettier": "^2.2.1", - "react-scripts": "^4.0.1", - "typescript": "~5.1.6" + "typescript": "~5.1.6", + "vite": "^6.1.0", + "vite-tsconfig-paths": "^4.3.2" }, "browserslist": { "production": [ diff --git a/ui/src/client/flavorInfoQueryOptions.ts b/ui/src/client/flavorInfoQueryOptions.ts new file mode 100644 index 000000000..4f80e3f16 --- /dev/null +++ b/ui/src/client/flavorInfoQueryOptions.ts @@ -0,0 +1,19 @@ +import { FlavorServiceApi, V1Flavor } from 'generated/client'; +import { QueryClient } from '@tanstack/react-query'; +import configuration from './configuration'; + +const flavorService = new FlavorServiceApi(configuration); + +export function flavorInfoQueryOptions(flavorId: string) { + return { + queryKey: ['flavorInfo', flavorId], + queryFn: () => flavorService.info(flavorId), + staleTime: 60 * 60 * 1000, // One hour - this info almost never changes + }; +} + +export function prefetchFlavors(queryClient: QueryClient, flavors: V1Flavor[]) { + flavors.forEach(async ({ ID }) => { + await queryClient.prefetchQuery(flavorInfoQueryOptions(ID ?? '')); + }); +} diff --git a/ui/src/containers/App.tsx b/ui/src/containers/App.tsx index 16ef43d88..909ded810 100644 --- a/ui/src/containers/App.tsx +++ b/ui/src/containers/App.tsx @@ -1,6 +1,7 @@ import React, { ReactElement } from 'react'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import { Flex, Page } from '@patternfly/react-core'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import UserAuthProvider from 'containers/UserAuthProvider'; import AppHeader from 'containers/AppHeader'; @@ -10,6 +11,14 @@ import LaunchClusterPage from 'containers/LaunchClusterPage'; import ClusterInfoPage from 'containers/ClusterInfoPage'; import FourOhFour from 'components/FourOhFour'; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 10 * 1000, + }, + }, +}); + function AppRoutes(): ReactElement { return ( @@ -26,15 +35,17 @@ export default function App(): ReactElement { return ( - - }> - - - + + + }> + + + + ); diff --git a/ui/src/containers/ClusterInfoPage/ClusterInfoPage.tsx b/ui/src/containers/ClusterInfoPage/ClusterInfoPage.tsx index 012994823..f183859aa 100644 --- a/ui/src/containers/ClusterInfoPage/ClusterInfoPage.tsx +++ b/ui/src/containers/ClusterInfoPage/ClusterInfoPage.tsx @@ -1,10 +1,10 @@ -import React, { useState, useCallback, ReactElement } from 'react'; +import React, { useState, ReactElement } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { Button, Divider, Flex, PageSection, Title } from '@patternfly/react-core'; import { DownloadIcon, TrashIcon } from '@patternfly/react-icons'; +import { useQuery } from '@tanstack/react-query'; import { ClusterServiceApi, V1Status } from 'generated/client'; -import useApiQuery from 'client/useApiQuery'; import configuration from 'client/configuration'; import FullPageSpinner from 'components/FullPageSpinner'; import FullPageError from 'components/FullPageError'; @@ -20,56 +20,80 @@ const clusterService = new ClusterServiceApi(configuration); export default function ClusterInfoPage(): ReactElement { const navigate = useNavigate(); const { clusterId = '' } = useParams(); - const fetchClusterInfo = useCallback(() => clusterService.info(clusterId), [clusterId]); - const { loading, error, data: cluster } = useApiQuery(fetchClusterInfo, { pollInterval: 10000 }); + + const { + isLoading: clusterInfoLoading, + error: clusterInfoError, + data: clusterInfoData, + } = useQuery({ + queryKey: ['clusterInfo', clusterId], + queryFn: () => clusterService.info(clusterId), + refetchInterval: 10000, + }); + + const cluster = clusterInfoData?.data; const [deletionModalOpen, setDeletionModalOpen] = useState(false); const [downloadArtifactsOpen, setDownloadArtifactsOpen] = useState(false); - if (loading) { - return ; - } + const clusterIsReady = cluster?.Status === V1Status.Ready; - if (error || !cluster?.ID) { - return ; - } - - const clusterIsReady = cluster.Status === V1Status.Ready; + const { + isLoading: clusterLogsLoading, + error: clusterLogsError, + data: clusterLogsData, + } = useQuery({ + queryKey: ['clusterLogs', clusterId], + queryFn: () => clusterService.logs(clusterId), + refetchInterval: 10000, + }); return ( <>
- - - - - {cluster.ID} - {cluster.Description && ` (${cluster.Description})`} -{' '} - {cluster.Status || V1Status.Failed} - - {!!cluster && } + {clusterInfoLoading ? ( + + ) : clusterInfoError || !cluster?.ID ? ( + + ) : ( + + + + + {cluster.ID} + {cluster.Description && ` (${cluster.Description})`} -{' '} + {cluster.Status || V1Status.Failed} + + {!!cluster && } + + {cluster.Connect && ( + <> + + + + )} + {cluster.URL && ( + <> + + + URL:{' '} + + {cluster.URL} + + + + )} - {cluster.Connect && ( - <> - - - - )} - {cluster.URL && ( - <> - - - URL:{' '} - - {cluster.URL} - - - - )} - - + + )} - + {clusterLogsLoading ? ( + + ) : clusterLogsError || !clusterLogsData?.data.Logs ? ( + + ) : ( + + )}
@@ -96,7 +120,7 @@ export default function ClusterInfoPage(): ReactElement { {deletionModalOpen && ( setDeletionModalOpen(false)} onDeleted={(): void => navigate('/')} /> @@ -104,7 +128,7 @@ export default function ClusterInfoPage(): ReactElement { {downloadArtifactsOpen && ( setDownloadArtifactsOpen(false)} /> )} diff --git a/ui/src/containers/ClusterInfoPage/ClusterLogs.tsx b/ui/src/containers/ClusterInfoPage/ClusterLogs.tsx index 26ede6d4e..44e7805ac 100644 --- a/ui/src/containers/ClusterInfoPage/ClusterLogs.tsx +++ b/ui/src/containers/ClusterInfoPage/ClusterLogs.tsx @@ -1,42 +1,27 @@ -import React, { useCallback, ReactElement } from 'react'; +import React, { ReactElement } from 'react'; -import { ClusterServiceApi } from 'generated/client'; -import useApiQuery from 'client/useApiQuery'; -import configuration from 'client/configuration'; -import FullPageSpinner from 'components/FullPageSpinner'; -import FullPageError from 'components/FullPageError'; +import { V1Log } from 'generated/client'; import { CodeBlock, CodeBlockCode } from '@patternfly/react-core'; -const clusterService = new ClusterServiceApi(configuration); - type Props = { - clusterId: string; + logs: V1Log[]; }; -export default function ClusterLogs({ clusterId }: Props): ReactElement { - const fetchClusterLogs = useCallback(() => clusterService.logs(clusterId), [clusterId]); - const { loading, error, data } = useApiQuery(fetchClusterLogs, { pollInterval: 10000 }); - - if (loading) { - return ; - } - - if (error || !data?.Logs) { - return ; - } - +export default function ClusterLogs({ logs }: Props): ReactElement { // combine all log entries into a single log with sections split same way infractl does - const logs = data.Logs.map((logEntry) => { - if (!logEntry.Name || !logEntry.Body) return ''; + const logsJoined = logs + .map((logEntry) => { + if (!logEntry.Name || !logEntry.Body) return ''; - const logEntryHeaderBorder = '-'.repeat(logEntry.Name.length); - const logText = atob(logEntry.Body); - return `${logEntry.Name}\n${logEntryHeaderBorder}\n${logEntry.Message || ''}\n${logText}`; - }).join('\n\n'); + const logEntryHeaderBorder = '-'.repeat(logEntry.Name.length); + const logText = atob(logEntry.Body); + return `${logEntry.Name}\n${logEntryHeaderBorder}\n${logEntry.Message || ''}\n${logText}`; + }) + .join('\n\n'); return ( - {logs} + {logsJoined} ); } diff --git a/ui/src/containers/ClusterInfoPage/DeleteClusterModal.tsx b/ui/src/containers/ClusterInfoPage/DeleteClusterModal.tsx index 595a2e552..ddb6ef039 100644 --- a/ui/src/containers/ClusterInfoPage/DeleteClusterModal.tsx +++ b/ui/src/containers/ClusterInfoPage/DeleteClusterModal.tsx @@ -1,7 +1,7 @@ import React, { ReactElement } from 'react'; import { Alert, Button } from '@patternfly/react-core'; -import { V1Cluster, ClusterServiceApi } from 'generated/client'; +import { ClusterServiceApi } from 'generated/client'; import configuration from 'client/configuration'; import useApiOperation from 'client/useApiOperation'; import Modal from 'components/Modal'; @@ -11,18 +11,22 @@ import assertDefined from 'utils/assertDefined'; const clusterService = new ClusterServiceApi(configuration); type Props = { - cluster: V1Cluster; + clusterId: string; onCancel: () => void; onDeleted: () => void; }; -export default function DeleteClusterModal({ cluster, onCancel, onDeleted }: Props): ReactElement { +export default function DeleteClusterModal({ + clusterId, + onCancel, + onDeleted, +}: Props): ReactElement { const [deleteCluster, { called, loading, error }] = useApiOperation(() => { - assertDefined(cluster.ID); // swagger definitions are too permitting - return clusterService._delete(cluster.ID); // eslint-disable-line no-underscore-dangle + assertDefined(clusterId); // swagger definitions are too permitting + return clusterService._delete(clusterId); // eslint-disable-line no-underscore-dangle }); - assertDefined(cluster.ID); // swagger definitions are too permitting + assertDefined(clusterId); // swagger definitions are too permitting if (!called) { // waiting for user confirmation @@ -39,7 +43,7 @@ export default function DeleteClusterModal({ cluster, onCancel, onDeleted }: Pro @@ -48,10 +52,10 @@ export default function DeleteClusterModal({ cluster, onCancel, onDeleted }: Pro } if (loading) { - const message = `Cluster ${cluster.ID} is being destroyed now.`; + const message = `Cluster ${clusterId} is being destroyed now.`; // waiting for server response return ( - {}} header={`Deleting ${cluster.ID}...`}> + {}} header={`Deleting ${clusterId}...`}> ); @@ -61,7 +65,7 @@ export default function DeleteClusterModal({ cluster, onCancel, onDeleted }: Pro // operation failed const message = `Could not delete cluster. Server error occurred: "${error.message}".`; return ( - + ); @@ -69,7 +73,7 @@ export default function DeleteClusterModal({ cluster, onCancel, onDeleted }: Pro // no need to check for data response from the server, "no error happened" means operation was successful return ( - + ); diff --git a/ui/src/containers/ClusterInfoPage/DownloadArtifactsModal.tsx b/ui/src/containers/ClusterInfoPage/DownloadArtifactsModal.tsx index d86fbf619..71ab6d62d 100644 --- a/ui/src/containers/ClusterInfoPage/DownloadArtifactsModal.tsx +++ b/ui/src/containers/ClusterInfoPage/DownloadArtifactsModal.tsx @@ -1,11 +1,11 @@ -import React, { ReactElement, useCallback } from 'react'; +import React, { ReactElement } from 'react'; import { Button, ClipboardCopy, Flex, List, ListItem } from '@patternfly/react-core'; -import { V1Cluster, ClusterServiceApi, V1Artifact } from 'generated/client'; +import { ClusterServiceApi, V1Artifact } from 'generated/client'; import configuration from 'client/configuration'; import Modal from 'components/Modal'; -import useApiQuery from 'client/useApiQuery'; import assertDefined from 'utils/assertDefined'; +import { useQuery } from '@tanstack/react-query'; const clusterService = new ClusterServiceApi(configuration); @@ -36,14 +36,15 @@ function ArtifactsList({ artifacts }: ArtifactsListProps): ReactElement { } type ArtifactsProps = { - cluster: V1Cluster; + clusterId: string; }; -function Artifacts({ cluster }: ArtifactsProps): ReactElement { - const fetchArtifacts = useCallback(() => clusterService.artifacts(cluster.ID || ''), [ - cluster.ID, - ]); - const { loading, error, data: artifacts } = useApiQuery(fetchArtifacts); +function Artifacts({ clusterId }: ArtifactsProps): ReactElement { + const { isLoading: loading, error, data: rawData } = useQuery({ + queryKey: ['clusterArtifacts', clusterId], + queryFn: () => clusterService.artifacts(clusterId || ''), + }); + const artifacts = rawData?.data.Artifacts; if (loading) { return

Loading...

; @@ -53,14 +54,14 @@ function Artifacts({ cluster }: ArtifactsProps): ReactElement { return

Cannot load artifacts: {error.message}

; } - if (artifacts?.Artifacts?.length) { + if (artifacts?.length) { return (
- +

Note: You can download all artifacts at the command line with:

- {`infractl artifacts --download-dir= ${cluster.ID ?? ''}`} + {`infractl artifacts --download-dir= ${clusterId ?? ''}`}
@@ -71,12 +72,12 @@ function Artifacts({ cluster }: ArtifactsProps): ReactElement { } type Props = { - cluster: V1Cluster; + clusterId: string; onClose: () => void; }; -export default function DownloadArtifactsModal({ cluster, onClose }: Props): ReactElement { - assertDefined(cluster.ID); +export default function DownloadArtifactsModal({ clusterId, onClose }: Props): ReactElement { + assertDefined(clusterId); const closeButton = (