diff --git a/client/package.json b/client/package.json index 6bdd08b7d0..09375fa6e4 100644 --- a/client/package.json +++ b/client/package.json @@ -24,6 +24,7 @@ "@patternfly/react-charts": "^6.67.1", "@patternfly/react-code-editor": "^4.82.115", "@patternfly/react-core": "^4.214.1", + "@patternfly/react-styles": "^4.92.6", "@patternfly/react-table": "^4.83.1", "@patternfly/react-tokens": "^4.66.1", "@react-keycloak/web": "^3.4.0", diff --git a/client/src/app/api/rest.ts b/client/src/app/api/rest.ts index 7bd4a3a3c4..1a834cc307 100644 --- a/client/src/app/api/rest.ts +++ b/client/src/app/api/rest.ts @@ -105,6 +105,7 @@ export const ASSESSMENTS = PATHFINDER + "/assessments"; const jsonHeaders = { headers: { Accept: "application/json" } }; const formHeaders = { headers: { Accept: "multipart/form-data" } }; const fileHeaders = { headers: { Accept: "application/json" } }; +const yamlHeaders = { headers: { Accept: "application/x-yaml" } }; type Direction = "asc" | "desc"; @@ -128,17 +129,6 @@ const buildQuery = (params: any) => { return query; }; -//Volumes -// poll clean task -export const getTaskById = ({ - queryKey, -}: { - queryKey: QueryKey; -}): AxiosPromise => { - const [_, processId] = queryKey; - return axios.get(`${TASKS}/${processId}`); -}; - // Business services export const getBusinessServices = (): AxiosPromise> => { @@ -426,6 +416,33 @@ export const getApplicationImports = ( .get(`${APP_IMPORT}?importSummary.id=${importSummaryID}&isValid=${isValid}`) .then((response) => response.data); +export const getApplicationAnalysis = ( + applicationId: number, + format: "json" | "yaml" +): Promise => { + const headers = format === "yaml" ? yamlHeaders : jsonHeaders; + return axios + .get(`${APPLICATIONS}/${applicationId}/analysis`, headers) + .then((response) => response.data); +}; + +export function getTaskById(id: number, format: "json"): Promise; +export function getTaskById(id: number, format: "yaml"): Promise; +export function getTaskById( + id: number, + format: "json" | "yaml" +): Promise { + if (format === "yaml") { + return axios + .get(`${TASKS}/${id}`, yamlHeaders) + .then((response) => response.data); + } else { + return axios + .get(`${TASKS}/${id}`, jsonHeaders) + .then((response) => response.data); + } +} + export const getTasks = () => axios.get(TASKS).then((response) => response.data); diff --git a/client/src/app/pages/applications/applications-table-analyze/applications-table-analyze.tsx b/client/src/app/pages/applications/applications-table-analyze/applications-table-analyze.tsx index 655975c984..32f1010674 100644 --- a/client/src/app/pages/applications/applications-table-analyze/applications-table-analyze.tsx +++ b/client/src/app/pages/applications/applications-table-analyze/applications-table-analyze.tsx @@ -71,6 +71,8 @@ import { NotificationsContext } from "@app/shared/notifications-context"; import { ConfirmDialog } from "@app/shared/components/confirm-dialog/confirm-dialog"; import { ApplicationDetailDrawerAnalysis } from "../components/application-detail-drawer"; import { useQueryClient } from "@tanstack/react-query"; +import { SimpleDocumentViewerModal } from "@app/shared/components/simple-task-viewer"; +import { getTaskById } from "@app/api/rest"; const ENTITY_FIELD = "entity"; @@ -94,6 +96,11 @@ export const ApplicationsTableAnalyze: React.FC = () => { const [isApplicationImportModalOpen, setIsApplicationImportModalOpen] = React.useState(false); + const [taskToView, setTaskToView] = React.useState<{ + name: string; + task: number | undefined; + }>(); + // Router const history = useHistory(); @@ -364,7 +371,7 @@ export const ApplicationsTableAnalyze: React.FC = () => { isAriaDisabled: !getTask(row), onClick: () => { const task = getTask(row); - if (task) window.open(`/hub/tasks/${task.id}`, "_blank"); + if (task) setTaskToView({ name: row.name, task: task.id }); }, }); } @@ -727,6 +734,7 @@ export const ApplicationsTableAnalyze: React.FC = () => { "dialog.message.delete" )}`} + {isConfirmDialogOpen && ( { }} /> )} + + + title={`Analysis details for ${taskToView?.name}`} + fetch={getTaskById} + documentId={taskToView?.task} + onClose={() => setTaskToView(undefined)} + /> ); }; diff --git a/client/src/app/pages/applications/components/application-detail-drawer/application-detail-drawer-analysis.tsx b/client/src/app/pages/applications/components/application-detail-drawer/application-detail-drawer-analysis.tsx index 812e1b3769..68cf5bde58 100644 --- a/client/src/app/pages/applications/components/application-detail-drawer/application-detail-drawer-analysis.tsx +++ b/client/src/app/pages/applications/components/application-detail-drawer/application-detail-drawer-analysis.tsx @@ -23,6 +23,8 @@ import { import { EmptyTextMessage } from "@app/shared/components"; import { useFetchFacts } from "@app/queries/facts"; import { ApplicationFacts } from "./application-facts"; +import { SimpleDocumentViewerModal } from "@app/shared/components/simple-task-viewer"; +import { getApplicationAnalysis, getTaskById } from "@app/api/rest"; export interface IApplicationDetailDrawerAnalysisProps extends Pick< @@ -39,6 +41,8 @@ export const ApplicationDetailDrawerAnalysis: React.FC< const { identities } = useFetchIdentities(); const { facts, isFetching } = useFetchFacts(application?.id); + const [appAnalysisToView, setAppAnalysisToView] = React.useState(); + const [taskIdToView, setTaskIdToView] = React.useState(); let matchingSourceCredsRef: Identity | undefined; let matchingMavenCredsRef: Identity | undefined; @@ -47,10 +51,6 @@ export const ApplicationDetailDrawerAnalysis: React.FC< matchingMavenCredsRef = getKindIDByRef(identities, application, "maven"); } - const openAnalysisDetails = () => { - if (task) window.open(`/hub/tasks/${task.id}`, "_blank"); - }; - const notAvailable = ; const updatedApplication = applications?.find( @@ -101,19 +101,25 @@ export const ApplicationDetailDrawerAnalysis: React.FC< {task?.state === "Succeeded" && application ? ( <> - + + title={`Analysis for ${application?.name}`} + fetch={getApplicationAnalysis} + documentId={appAnalysisToView} + onClose={() => setAppAnalysisToView(undefined)} + /> ) : task?.state === "Failed" ? ( - <> - {task ? ( + task ? ( + <> - ) : ( - - - Failed - - )} - + + title={`Analysis details for ${application?.name}`} + fetch={getTaskById} + documentId={taskIdToView} + onClose={() => setTaskIdToView(undefined)} + /> + + ) : ( + + + Failed + + ) ) : ( notAvailable )} diff --git a/client/src/app/shared/components/simple-task-viewer/index.ts b/client/src/app/shared/components/simple-task-viewer/index.ts new file mode 100644 index 0000000000..942d4ce2bf --- /dev/null +++ b/client/src/app/shared/components/simple-task-viewer/index.ts @@ -0,0 +1 @@ +export * from "./simple-document-viewer"; diff --git a/client/src/app/shared/components/simple-task-viewer/simple-document-viewer.tsx b/client/src/app/shared/components/simple-task-viewer/simple-document-viewer.tsx new file mode 100644 index 0000000000..326c0318d5 --- /dev/null +++ b/client/src/app/shared/components/simple-task-viewer/simple-document-viewer.tsx @@ -0,0 +1,239 @@ +import * as React from "react"; +import { CodeEditor, Language } from "@patternfly/react-code-editor"; +import { + Button, + EmptyState, + EmptyStateIcon, + EmptyStateVariant, + Modal, + ModalProps, + Spinner, + Title, + ToggleGroup, + ToggleGroupItem, +} from "@patternfly/react-core"; +import { css } from "@patternfly/react-styles"; +import editorStyles from "@patternfly/react-styles/css/components/CodeEditor/code-editor"; +import CodeIcon from "@patternfly/react-icons/dist/esm/icons/code-icon"; + +import "./viewer.css"; + +export { Language } from "@patternfly/react-code-editor"; + +interface FetchFunction { + /** Fetch a yaml document for the given document */ + (documentId: number, format: Language.yaml): Promise; + + /** Fetch a JSON document as a `FetchType` object for the given document */ + (documentId: number, format: Language.json): Promise; +} + +/** The subset of MonacoEditor component functions we want to use. */ +type ControlledEditor = { + focus: () => void; + setPosition: (position: object) => void; +}; + +export interface ISimpleDocumentViewerProps { + /** The id of the document to display, or `undefined` to display the empty state. */ + documentId: number | undefined; + + /** Filename, without extension, to use with the download file action. */ + downloadFilename?: string; + + /** + * Initial language of the document. Also used for the file extensions with + * the download file action. Defaults to `Language.yaml`. + */ + language?: Language.yaml | Language.json; + + /** + * Height of the document viewer, or `"full"` to take up all of the available + * vertical space. Defaults to "450px". + */ + height?: string | "full"; + + /** Function that will fetch the document to display. */ + fetch: FetchFunction; +} + +/** + * Fetch and then use the `@patternfly/react-code-editor` to display a document in + * read-only mode with language highlighting applied. + */ +export const SimpleDocumentViewer = ({ + documentId, + downloadFilename, + language = Language.yaml, + height = "450px", + fetch, +}: ISimpleDocumentViewerProps) => { + const editorRef = React.useRef(); + + const [code, setCode] = React.useState(undefined); + const [currentLanguage, setCurrentLanguage] = React.useState(language); + + React.useEffect(() => { + setCode(undefined); + if (documentId) { + if (currentLanguage === Language.yaml) { + fetch(documentId, currentLanguage).then((yaml) => { + setCode(yaml.toString()); + focusAndHomePosition(); + }); + } else { + fetch(documentId, currentLanguage).then((json) => { + setCode(JSON.stringify(json, undefined, 2)); + focusAndHomePosition(); + }); + } + } + }, [documentId, currentLanguage]); + + const focusAndHomePosition = () => { + if (editorRef.current) { + editorRef.current.focus(); + editorRef.current.setPosition({ column: 0, lineNumber: 1 }); + } + }; + + return ( + { + editorRef.current = editor as ControlledEditor; + }} + showEditor={code !== undefined} + emptyState={ +
+ + + + Loading {currentLanguage} + + +
+ } + customControls={[ +
+ + + + + + JSON + + } + buttonId="code-language-select-json" + isSelected={currentLanguage === "json"} + isDisabled={!code && currentLanguage !== "json"} + onChange={() => setCurrentLanguage(Language.json)} + /> + + + + + YAML + + } + buttonId="code-language-select-yaml" + isSelected={currentLanguage === "yaml"} + isDisabled={!code && currentLanguage !== "yaml"} + onChange={() => setCurrentLanguage(Language.yaml)} + /> + +
, + ]} + /> + ); +}; + +export interface ISimpleDocumentViewerModalProps + extends ISimpleDocumentViewerProps { + /** Simple text content of the modal header. */ + title?: string; + + /** A callback for when the close button is clicked. */ + onClose?: ModalProps["onClose"]; + + /** + * Position of the modal, `"top"` aligned or `"normal"`/centered on the view. + * Defaults to `top`. + */ + position?: "top" | "normal"; + + /** + * Flag indicating if the modal should be displayed as tall as possible. + * Defaults to `true`. + */ + isFullHeight?: boolean; +} + +/** + * Inside of a Modal window, fetch and then use the `SimpleDocumentViewer` to display + * a document in read-only mode with language highlighting applied. The modal will be + * displayed if the `documentId` is set. If `documentId` is `undefined`, the modal is + * closed. + */ +export const SimpleDocumentViewerModal = ({ + title, + documentId, + onClose, + position = "top", + isFullHeight = true, + ...rest +}: ISimpleDocumentViewerModalProps) => { + const isOpen = documentId !== undefined; + + return ( + + Close + , + ]} + > + + documentId={documentId} + height={isFullHeight ? "full" : undefined} + {...rest} + /> + + ); +}; diff --git a/client/src/app/shared/components/simple-task-viewer/viewer.css b/client/src/app/shared/components/simple-task-viewer/viewer.css new file mode 100644 index 0000000000..c8256d80ae --- /dev/null +++ b/client/src/app/shared/components/simple-task-viewer/viewer.css @@ -0,0 +1,76 @@ +/* Make our document viewer modal height the maximum height (assuming Modal position="top") */ +.simple-task-viewer.full-height-top { + height: var(--pf-c-modal-box--m-align-top--MaxHeight); +} + +.simple-task-viewer.full-height { + height: var(--pf-c-modal-box--MaxHeight); +} + +/* Match empty state layout to the editor layout so they take up exactly the same space */ +.simple-task-viewer .simple-task-viewer-empty-state { + padding: var(--pf-c-code-editor__code--PaddingTop) + var(--pf-c-code-editor__code--PaddingRight) + var(--pf-c-code-editor__code--PaddingBottom) + var(--pf-c-code-editor__code--PaddingLeft); +} + +/* + Make all of the containers of the MonacoEditor component take the maximum height so the + editor can always be as tall as possible in the Modal. + */ +.simple-task-viewer[class*="full-height"] .pf-c-modal-box__body { + display: flex; + flex-direction: column; +} + +.simple-task-viewer[class*="full-height"] + .pf-c-modal-box__body + .pf-c-code-editor { + flex-grow: 1; +} + +.simple-task-viewer[class*="full-height"] + .pf-c-modal-box__body + .pf-c-code-editor + .pf-c-file-upload { + height: 100%; +} + +.simple-task-viewer[class*="full-height"] + .pf-c-modal-box__body + .pf-c-code-editor + .pf-c-file-upload + .pf-c-code-editor__main { + flex-grow: 1; +} + +.simple-task-viewer[class*="full-height"] + .pf-c-modal-box__body + .pf-c-code-editor + .pf-c-file-upload + .pf-c-code-editor__main + .pf-c-code-editor__code { + height: 100%; +} + +/* + Tweak the code editor so we can put the language selection toggle group in the + "Language Label" location +*/ +.simple-task-viewer .pf-c-code-editor__controls { + flex-grow: 1; +} + +.simple-task-viewer .pf-c-code-editor__header-main { + display: none; +} + +.simple-task-viewer .pf-c-code-editor__tab.language-toggle-group-container { + padding: 0; + border: none; +} + +.simple-task-viewer .language-toggle-group { + --pf-c-toggle-group__button--FontSize: var(--pf-global--FontSize--md); +} diff --git a/package-lock.json b/package-lock.json index 9c16d57f49..3362b7d6d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@patternfly/react-charts": "^6.67.1", "@patternfly/react-code-editor": "^4.82.115", "@patternfly/react-core": "^4.214.1", + "@patternfly/react-styles": "^4.92.6", "@patternfly/react-table": "^4.83.1", "@patternfly/react-tokens": "^4.66.1", "@react-keycloak/web": "^3.4.0", @@ -18193,6 +18194,7 @@ "@patternfly/react-charts": "^6.67.1", "@patternfly/react-code-editor": "^4.82.115", "@patternfly/react-core": "^4.214.1", + "@patternfly/react-styles": "^4.92.6", "@patternfly/react-table": "^4.83.1", "@patternfly/react-tokens": "^4.66.1", "@react-keycloak/web": "^3.4.0",