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 = (