From 13ed8eb58a81e26ec500b715b5e0ffd851a8dec2 Mon Sep 17 00:00:00 2001 From: leon-up9 <97597983+leon-up9@users.noreply.github.com> Date: Sun, 3 Jul 2022 09:48:56 +0300 Subject: [PATCH] Ui/TRA-4607 - replay mizu requests (#1165) * modal & keyValueTable added * added pulse animation KeyValueTable Behavior improved CodeEditor addded * style changed * codeEditor styling support query params * send request data * finally stop loading * select width * methods and requesr format * icon changed & moved near request tab * accordions added and response presented * 2 way params biding * remove redundant * host path fixed * fix path input * icon styles * fallback for format body * refresh button * changes * remove redundant * closing tag * capitilized character * PR comments * removed props * small changes * color added to reponse data Co-authored-by: Leon <> --- ui-common/package.json | 2 + .../EntryDetailed/EntryDetailed.tsx | 5 +- .../EntrySections/EntrySections.tsx | 62 +++-- .../EntryViewer/AutoRepresentation.tsx | 74 +++++ .../EntryDetailed/EntryViewer/EntryViewer.tsx | 93 +------ .../EntryViewer/SectionsRepresentation.tsx | 34 +++ .../EntryDetailed/EntryViewer/replay.svg | 1 + .../components/EntryDetailed/assets/run.svg | 4 + .../TrafficViewer/TrafficViewer.module.sass | 9 + .../TrafficViewer/TrafficViewer.tsx | 2 + .../TrafficViewer/TrafficViewerApi.ts | 3 +- .../components/UI/CodeEditor/CodeEditor.tsx | 54 ++++ .../components/UI/HoverImage/HoverImage.tsx | 51 ++++ .../KeyValueTable/KeyValueTable.module.sass | 29 ++ .../UI/KeyValueTable/KeyValueTable.tsx | 79 ++++++ .../UI/KeyValueTable/delete-active.svg | 3 + .../components/UI/KeyValueTable/delete.svg | 3 + ui-common/src/components/UI/Tabs/Tabs.tsx | 3 +- .../ReplayRequestModal.module.sass | 83 ++++++ .../ReplayRequestModal/ReplayRequestModal.tsx | 257 ++++++++++++++++++ .../ReplayRequestModal/assets/close.svg | 4 + .../ReplayRequestModal/assets/refresh.svg | 3 + .../ReplayRequestModal/assets/spinner.svg | 6 + .../ServiceMapModal.module.sass | 4 +- ui-common/src/helpers/Utils.ts | 21 +- ui-common/src/hooks/useDebounce.ts | 24 ++ ui-common/src/recoil/entryData/atom.ts | 8 + ui-common/src/recoil/entryData/index.ts | 3 + .../src/recoil/replayRequestModalOpen/atom.ts | 8 + .../recoil/replayRequestModalOpen/index.ts | 2 + .../src/recoil/serviceMapModalOpen/atom.ts | 8 - .../src/recoil/serviceMapModalOpen/index.ts | 2 - ui/src/helpers/api.js | 5 + ui/src/index.sass | 8 + 34 files changed, 820 insertions(+), 137 deletions(-) create mode 100644 ui-common/src/components/EntryDetailed/EntryViewer/AutoRepresentation.tsx create mode 100644 ui-common/src/components/EntryDetailed/EntryViewer/SectionsRepresentation.tsx create mode 100644 ui-common/src/components/EntryDetailed/EntryViewer/replay.svg create mode 100644 ui-common/src/components/EntryDetailed/assets/run.svg create mode 100644 ui-common/src/components/UI/CodeEditor/CodeEditor.tsx create mode 100644 ui-common/src/components/UI/HoverImage/HoverImage.tsx create mode 100644 ui-common/src/components/UI/KeyValueTable/KeyValueTable.module.sass create mode 100644 ui-common/src/components/UI/KeyValueTable/KeyValueTable.tsx create mode 100644 ui-common/src/components/UI/KeyValueTable/delete-active.svg create mode 100644 ui-common/src/components/UI/KeyValueTable/delete.svg create mode 100644 ui-common/src/components/modals/ReplayRequestModal/ReplayRequestModal.module.sass create mode 100644 ui-common/src/components/modals/ReplayRequestModal/ReplayRequestModal.tsx create mode 100644 ui-common/src/components/modals/ReplayRequestModal/assets/close.svg create mode 100644 ui-common/src/components/modals/ReplayRequestModal/assets/refresh.svg create mode 100644 ui-common/src/components/modals/ReplayRequestModal/assets/spinner.svg create mode 100644 ui-common/src/hooks/useDebounce.ts create mode 100644 ui-common/src/recoil/entryData/atom.ts create mode 100644 ui-common/src/recoil/entryData/index.ts create mode 100644 ui-common/src/recoil/replayRequestModalOpen/atom.ts create mode 100644 ui-common/src/recoil/replayRequestModalOpen/index.ts delete mode 100644 ui-common/src/recoil/serviceMapModalOpen/atom.ts delete mode 100644 ui-common/src/recoil/serviceMapModalOpen/index.ts diff --git a/ui-common/package.json b/ui-common/package.json index a598f775e..0cb0c99db 100644 --- a/ui-common/package.json +++ b/ui-common/package.json @@ -42,6 +42,7 @@ "@mui/styles": "^5.8.0", "@types/lodash": "^4.14.182", "@uiw/react-textarea-code-editor": "^1.6.0", + "ace-builds": "^1.6.0", "axios": "^0.27.2", "core-js": "^3.22.7", "highlight.js": "^11.5.1", @@ -54,6 +55,7 @@ "node-fetch": "^3.2.4", "numeral": "^2.0.6", "protobuf-decoder": "^0.1.2", + "react-ace": "^9.0.0", "react-graph-vis": "^1.0.7", "react-lowlight": "^3.0.0", "react-router-dom": "^6.3.0", diff --git a/ui-common/src/components/EntryDetailed/EntryDetailed.tsx b/ui-common/src/components/EntryDetailed/EntryDetailed.tsx index bc570cb04..ac7a782ff 100644 --- a/ui-common/src/components/EntryDetailed/EntryDetailed.tsx +++ b/ui-common/src/components/EntryDetailed/EntryDetailed.tsx @@ -5,7 +5,7 @@ import makeStyles from '@mui/styles/makeStyles'; import Protocol from "../UI/Protocol/Protocol" import Queryable from "../UI/Queryable/Queryable"; import { toast } from "react-toastify"; -import { RecoilState, useRecoilValue } from "recoil"; +import { RecoilState, useRecoilState, useRecoilValue } from "recoil"; import focusedEntryIdAtom from "../../recoil/focusedEntryId"; import TrafficViewerApi from "../TrafficViewer/TrafficViewerApi"; import TrafficViewerApiAtom from "../../recoil/TrafficViewerApi/atom"; @@ -13,6 +13,7 @@ import queryAtom from "../../recoil/query/atom"; import useWindowDimensions, { useRequestTextByWidth } from "../../hooks/WindowDimensionsHook"; import { TOAST_CONTAINER_ID } from "../../configs/Consts"; import spinner from "assets/spinner.svg"; +import entryDataAtom from "../../recoil/entryData"; const useStyles = makeStyles(() => ({ entryTitle: { @@ -107,7 +108,7 @@ export const EntryDetailed = () => { const trafficViewerApi = useRecoilValue(TrafficViewerApiAtom as RecoilState) const query = useRecoilValue(queryAtom); const [isLoading, setIsLoading] = useState(false); - const [entryData, setEntryData] = useState(null); + const [entryData, setEntryData] = useRecoilState(entryDataAtom) useEffect(() => { setEntryData(null); diff --git a/ui-common/src/components/EntryDetailed/EntrySections/EntrySections.tsx b/ui-common/src/components/EntryDetailed/EntrySections/EntrySections.tsx index 4043e9ae9..6ff5b9682 100644 --- a/ui-common/src/components/EntryDetailed/EntrySections/EntrySections.tsx +++ b/ui-common/src/components/EntryDetailed/EntrySections/EntrySections.tsx @@ -117,6 +117,39 @@ interface EntryBodySectionProps { selector?: string, } +export const formatRequest = (body: any, contentType: string, decodeBase64: boolean = true, isBase64Encoding: boolean = false, isPretty: boolean = true): string => { + if (!decodeBase64 || !body) return body; + + const chunk = body.slice(0, MAXIMUM_BYTES_TO_FORMAT); + const bodyBuf = isBase64Encoding ? atob(chunk) : chunk; + + try { + if (jsonLikeFormats.some(format => contentType?.indexOf(format) > -1)) { + if (!isPretty) return bodyBuf; + return jsonBeautify(JSON.parse(bodyBuf), null, 2, 80); + } else if (xmlLikeFormats.some(format => contentType?.indexOf(format) > -1)) { + if (!isPretty) return bodyBuf; + return xmlBeautify(bodyBuf, { + indentation: ' ', + filter: (node) => node.type !== 'Comment', + collapseContent: true, + lineSeparator: '\n' + }); + } else if (protobufFormats.some(format => contentType?.indexOf(format) > -1)) { + // Replace all non printable characters (ASCII) + const protobufDecoder = new ProtobufDecoder(bodyBuf, true); + const protobufDecoded = protobufDecoder.decode().toSimple(); + if (!isPretty) return JSON.stringify(protobufDecoded); + return jsonBeautify(protobufDecoded, null, 2, 80); + } + } catch (error) { + console.error(error) + throw error + } + + return bodyBuf; +} + export const EntryBodySection: React.FC = ({ title, color, @@ -139,42 +172,17 @@ export const EntryBodySection: React.FC = ({ !isLineNumbersGreaterThenOne && setShowLineNumbers(false); }, [isLineNumbersGreaterThenOne, isPretty]) - const formatTextBody = useCallback((body: any): string => { - if (!decodeBase64) return body; - - const chunk = body.slice(0, MAXIMUM_BYTES_TO_FORMAT); - const bodyBuf = isBase64Encoding ? atob(chunk) : chunk; - + const formatTextBody = useCallback((body) => { try { - if (jsonLikeFormats.some(format => contentType?.indexOf(format) > -1)) { - if (!isPretty) return bodyBuf; - return jsonBeautify(JSON.parse(bodyBuf), null, 2, 80); - } else if (xmlLikeFormats.some(format => contentType?.indexOf(format) > -1)) { - if (!isPretty) return bodyBuf; - return xmlBeautify(bodyBuf, { - indentation: ' ', - filter: (node) => node.type !== 'Comment', - collapseContent: true, - lineSeparator: '\n' - }); - } else if (protobufFormats.some(format => contentType?.indexOf(format) > -1)) { - // Replace all non printable characters (ASCII) - const protobufDecoder = new ProtobufDecoder(bodyBuf, true); - const protobufDecoded = protobufDecoder.decode().toSimple(); - if (!isPretty) return JSON.stringify(protobufDecoded); - return jsonBeautify(protobufDecoded, null, 2, 80); - } + return formatRequest(body, contentType, decodeBase64, isBase64Encoding, isPretty) } catch (error) { if (String(error).includes("More than one message in")) { if (isDecodeGrpc) setIsDecodeGrpc(false); } else if (String(error).includes("Failed to parse")) { console.warn(error); - } else { - console.error(error); } } - return bodyBuf; }, [isPretty, contentType, isDecodeGrpc, decodeBase64, isBase64Encoding]) const formattedText = useMemo(() => formatTextBody(content), [formatTextBody, content]); diff --git a/ui-common/src/components/EntryDetailed/EntryViewer/AutoRepresentation.tsx b/ui-common/src/components/EntryDetailed/EntryViewer/AutoRepresentation.tsx new file mode 100644 index 000000000..8ce70aecd --- /dev/null +++ b/ui-common/src/components/EntryDetailed/EntryViewer/AutoRepresentation.tsx @@ -0,0 +1,74 @@ +import React, { useState, useCallback } from "react" +import { useRecoilValue, useSetRecoilState } from "recoil" +import entryDataAtom from "../../../recoil/entryData" +import SectionsRepresentation from "./SectionsRepresentation"; +import { EntryTablePolicySection } from "../EntrySections/EntrySections"; +import { ReactComponent as ReplayIcon } from './replay.svg'; +import styles from './EntryViewer.module.sass'; +import { Tabs } from "../../UI"; +import replayRequestModalOpenAtom from "../../../recoil/replayRequestModalOpen"; + +const enabledProtocolsForReplay = ["http"] + +export const AutoRepresentation: React.FC = ({ representation, isRulesEnabled, rulesMatched, elapsedTime, color, isDisplayReplay = false }) => { + const entryData = useRecoilValue(entryDataAtom) + const setIsOpenRequestModal = useSetRecoilState(replayRequestModalOpenAtom) + const isReplayDisplayed = useCallback(() => { + return enabledProtocolsForReplay.find(x => x === entryData.protocol.name) && isDisplayReplay + }, [entryData.protocol.name, isDisplayReplay]) + + const TABS = [ + { + tab: 'Request', + badge: isReplayDisplayed() && setIsOpenRequestModal(true)} /> + } + ]; + const [currentTab, setCurrentTab] = useState(TABS[0].tab); + + // Don't fail even if `representation` is an empty string + if (!representation) { + return ; + } + + const { request, response } = JSON.parse(representation); + + let responseTabIndex = 0; + let rulesTabIndex = 0; + + if (response) { + TABS.push( + { + tab: 'Response', + badge: null + } + ); + responseTabIndex = TABS.length - 1; + } + + if (isRulesEnabled) { + TABS.push( + { + tab: 'Rules', + badge: null + } + ); + rulesTabIndex = TABS.length - 1; + } + + return
+ {
+
+ +
+ {currentTab === TABS[0].tab && + + } + {response && currentTab === TABS[responseTabIndex].tab && + + } + {isRulesEnabled && currentTab === TABS[rulesTabIndex].tab && + + } +
} +
; +} diff --git a/ui-common/src/components/EntryDetailed/EntryViewer/EntryViewer.tsx b/ui-common/src/components/EntryDetailed/EntryViewer/EntryViewer.tsx index 5c0ceb10d..4c6391973 100644 --- a/ui-common/src/components/EntryDetailed/EntryViewer/EntryViewer.tsx +++ b/ui-common/src/components/EntryDetailed/EntryViewer/EntryViewer.tsx @@ -1,91 +1,5 @@ -import React, {useState} from 'react'; -import styles from './EntryViewer.module.sass'; -import Tabs from "../../UI/Tabs/Tabs"; -import {EntryTableSection, EntryBodySection, EntryTablePolicySection} from "../EntrySections/EntrySections"; - -enum SectionTypes { - SectionTable = "table", - SectionBody = "body", -} - -const SectionsRepresentation: React.FC = ({data, color}) => { - const sections = [] - - if (data) { - for (const [i, row] of data.entries()) { - switch (row.type) { - case SectionTypes.SectionTable: - sections.push( - - ) - break; - case SectionTypes.SectionBody: - sections.push( - - ) - break; - default: - break; - } - } - } - - return {sections}; -} - -const AutoRepresentation: React.FC = ({representation, isRulesEnabled, rulesMatched, elapsedTime, color}) => { - var TABS = [ - { - tab: 'Request' - } - ]; - const [currentTab, setCurrentTab] = useState(TABS[0].tab); - - // Don't fail even if `representation` is an empty string - if (!representation) { - return ; - } - - const {request, response} = JSON.parse(representation); - - let responseTabIndex = 0; - let rulesTabIndex = 0; - - if (response) { - TABS.push( - { - tab: 'Response', - } - ); - responseTabIndex = TABS.length - 1; - } - - if (isRulesEnabled) { - TABS.push( - { - tab: 'Rules', - } - ); - rulesTabIndex = TABS.length - 1; - } - - return
- {
-
- -
- {currentTab === TABS[0].tab && - - } - {response && currentTab === TABS[responseTabIndex].tab && - - } - {isRulesEnabled && currentTab === TABS[rulesTabIndex].tab && - - } -
} -
; -} +import React from 'react'; +import { AutoRepresentation } from './AutoRepresentation'; interface Props { representation: any; @@ -95,13 +9,14 @@ interface Props { elapsedTime: number; } -const EntryViewer: React.FC = ({representation, isRulesEnabled, rulesMatched, elapsedTime, color}) => { +const EntryViewer: React.FC = ({ representation, isRulesEnabled, rulesMatched, elapsedTime, color }) => { return }; diff --git a/ui-common/src/components/EntryDetailed/EntryViewer/SectionsRepresentation.tsx b/ui-common/src/components/EntryDetailed/EntryViewer/SectionsRepresentation.tsx new file mode 100644 index 000000000..135b0f7e8 --- /dev/null +++ b/ui-common/src/components/EntryDetailed/EntryViewer/SectionsRepresentation.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { EntryTableSection, EntryBodySection } from "../EntrySections/EntrySections"; + +enum SectionTypes { + SectionTable = "table", + SectionBody = "body", +} + +const SectionsRepresentation: React.FC = ({ data, color }) => { + const sections = [] + + if (data) { + for (const [i, row] of data.entries()) { + switch (row.type) { + case SectionTypes.SectionTable: + sections.push( + + ) + break; + case SectionTypes.SectionBody: + sections.push( + + ) + break; + default: + break; + } + } + } + + return {sections}; +} + +export default SectionsRepresentation diff --git a/ui-common/src/components/EntryDetailed/EntryViewer/replay.svg b/ui-common/src/components/EntryDetailed/EntryViewer/replay.svg new file mode 100644 index 000000000..0d8ae976a --- /dev/null +++ b/ui-common/src/components/EntryDetailed/EntryViewer/replay.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui-common/src/components/EntryDetailed/assets/run.svg b/ui-common/src/components/EntryDetailed/assets/run.svg new file mode 100644 index 000000000..1c7ca94cc --- /dev/null +++ b/ui-common/src/components/EntryDetailed/assets/run.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui-common/src/components/TrafficViewer/TrafficViewer.module.sass b/ui-common/src/components/TrafficViewer/TrafficViewer.module.sass index 4857b5495..246ae4373 100644 --- a/ui-common/src/components/TrafficViewer/TrafficViewer.module.sass +++ b/ui-common/src/components/TrafficViewer/TrafficViewer.module.sass @@ -88,8 +88,17 @@ .greenIndicatorContainer border: 2px #6fcf9770 solid +@keyframes biggerIndication + 0% + transform: scale(2.0) + 100% + transform: scale(0.7) + + + .greenIndicator background-color: #27AE60 + animation: biggerIndication 1.5s ease-out 0s alternate infinite none running .orangeIndicatorContainer border: 2px #fabd5970 solid diff --git a/ui-common/src/components/TrafficViewer/TrafficViewer.tsx b/ui-common/src/components/TrafficViewer/TrafficViewer.tsx index a713c1b10..bf1c03d0c 100644 --- a/ui-common/src/components/TrafficViewer/TrafficViewer.tsx +++ b/ui-common/src/components/TrafficViewer/TrafficViewer.tsx @@ -20,6 +20,7 @@ import tappingStatusAtom from "../../recoil/tappingStatus/atom"; import { TOAST_CONTAINER_ID } from "../../configs/Consts"; import leftOffTopAtom from "../../recoil/leftOffTop"; import { DEFAULT_LEFTOFF, DEFAULT_FETCH, DEFAULT_FETCH_TIMEOUT_MS } from '../../hooks/useWS'; +import ReplayRequestModalContainer from "../modals/ReplayRequestModal/ReplayRequestModal"; const useLayoutStyles = makeStyles(() => ({ details: { @@ -278,6 +279,7 @@ const TrafficViewerContainer: React.FC = ({ pauseOnFocusLoss draggable pauseOnHover/> + } diff --git a/ui-common/src/components/TrafficViewer/TrafficViewerApi.ts b/ui-common/src/components/TrafficViewer/TrafficViewerApi.ts index b3a8a8af1..be1bc62a9 100644 --- a/ui-common/src/components/TrafficViewer/TrafficViewerApi.ts +++ b/ui-common/src/components/TrafficViewer/TrafficViewerApi.ts @@ -2,7 +2,8 @@ type TrafficViewerApi = { validateQuery: (query: any) => any tapStatus: () => any fetchEntries: (leftOff: any, direction: number, query: any, limit: number, timeoutMs: number) => any - getEntry: (entryId: any, query: string) => any + getEntry: (entryId: any, query: string) => any, + replayRequest: (request: { method: string, url: string, data: string, headers: {} }) => Promise, webSocket: { close: () => void } diff --git a/ui-common/src/components/UI/CodeEditor/CodeEditor.tsx b/ui-common/src/components/UI/CodeEditor/CodeEditor.tsx new file mode 100644 index 000000000..bdbd41a7d --- /dev/null +++ b/ui-common/src/components/UI/CodeEditor/CodeEditor.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import AceEditor from "react-ace"; +import { config } from 'ace-builds'; + +import "ace-builds/src-noconflict/ext-searchbox"; +import "ace-builds/src-noconflict/mode-python"; +import "ace-builds/src-noconflict/mode-json"; +import "ace-builds/src-noconflict/theme-github"; +import "ace-builds/src-noconflict/mode-javascript"; +import "ace-builds/src-noconflict/mode-xml"; +import "ace-builds/src-noconflict/mode-html"; + + + +config.set( + "basePath", + "https://cdn.jsdelivr.net/npm/ace-builds@1.4.6/src-noconflict/" +); +config.setModuleUrl( + "ace/mode/javascript_worker", + "https://cdn.jsdelivr.net/npm/ace-builds@1.4.6/src-noconflict/worker-javascript.js" +); + +export interface CodeEditorProps { + code: string, + onChange?: (code: string) => void, + language?: string +} +const CodeEditor: React.FC = ({ + language, + onChange, + code +}) => { + return ( + + ); +} + +export default CodeEditor diff --git a/ui-common/src/components/UI/HoverImage/HoverImage.tsx b/ui-common/src/components/UI/HoverImage/HoverImage.tsx new file mode 100644 index 000000000..761a3ab44 --- /dev/null +++ b/ui-common/src/components/UI/HoverImage/HoverImage.tsx @@ -0,0 +1,51 @@ +import React from "react"; + +export type HoverImageProps = { + src: string; + hoverSrc: string; + disabled?: boolean; + className?: string; + style?: any; + onClick?: React.MouseEventHandler; + alt?: string +}; +const HoverImage: React.FC = ({ + src, + hoverSrc, + style, + disabled, + onClick, + className, + alt = "" +}) => { + const [imageSrc, setImageSrc] = React.useState(src); + + const mouseOver = React.useCallback(() => { + setImageSrc(hoverSrc); + }, [hoverSrc]); + + const mouseOut = React.useCallback(() => { + setImageSrc(src); + }, [src]); + + const handleClick = (e: React.MouseEvent) => { + if (!onClick) return; + if (!disabled) { + onClick(e); + } + }; + + return ( + {alt} + ); +}; + +export default HoverImage; diff --git a/ui-common/src/components/UI/KeyValueTable/KeyValueTable.module.sass b/ui-common/src/components/UI/KeyValueTable/KeyValueTable.module.sass new file mode 100644 index 000000000..e8a611a9d --- /dev/null +++ b/ui-common/src/components/UI/KeyValueTable/KeyValueTable.module.sass @@ -0,0 +1,29 @@ +@import '../../../variables.module' + +.keyValueTableContainer + width: 100% + background-color: inherit + border-radius: 4px + overflow-x: auto + overflow-y: auto + height: 100% + padding: 10px 0 + box-sizing: border-box + + .headerRow + display: flex + margin: 15px + align-items: center + +.roundInputContainer + background-color: $main-background-color + border-radius: 15px + margin-right: 5px + padding: 5px + + input + border: none + outline: none + background-color: transparent + font-size: 15px + width: 100% diff --git a/ui-common/src/components/UI/KeyValueTable/KeyValueTable.tsx b/ui-common/src/components/UI/KeyValueTable/KeyValueTable.tsx new file mode 100644 index 000000000..e592c2006 --- /dev/null +++ b/ui-common/src/components/UI/KeyValueTable/KeyValueTable.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { useEffect, useState } from "react"; +import styles from "./KeyValueTable.module.sass" +import deleteIcon from "delete.svg" +import deleteIconActive from "delete-active.svg" +import HoverImage from "../HoverImage/HoverImage"; + +interface KeyValueTableProps { + data: any + onDataChange: (data: any) => void + keyPlaceholder?: string + valuePlaceholder?: string +} + +type Row = { key: string, value: string } + +const KeyValueTable: React.FC = ({ data, onDataChange, keyPlaceholder, valuePlaceholder }) => { + + const [keyValueData, setKeyValueData] = useState([] as Row[]) + + useEffect(() => { + if (!data) return; + const currentState = [...data, { key: "", value: "" }] + setKeyValueData(currentState) + }, [data]) + + const deleteRow = (index) => { + const newRows = [...keyValueData]; + newRows.splice(index, 1); + setKeyValueData(newRows); + onDataChange(newRows.filter(row => row.key)) + } + + const addNewRow = (data: Row[]) => { + return data.filter(x => x.key === "").length === 0 ? [...data, { key: '', value: '' }] : data + } + + const setNewVal = (mapFunc, index) => { + let currentData = keyValueData.map((row, i) => i === index ? mapFunc(row) : row) + if (currentData.every(row => row.key)) { + onDataChange(currentData) + currentData = addNewRow(currentData) + } + else { + onDataChange(currentData.filter(row => row.key)) + } + + setKeyValueData(currentData); + } + + return
+ {keyValueData?.map((row, index) => { + return
+
+ setNewVal((row) => { return { key: event.target.value, value: row.value } }, index)} + value={row.key} + autoComplete="off" + spellCheck={false} /> +
+
+ setNewVal((row) => { return { key: row.key, value: event.target.value } }, index)} + value={row.value?.toString()} + autoComplete="off" + spellCheck={false} /> +
+ {(row.key !== "" || row.value !== "") && deleteRow(index)} hoverSrc={deleteIconActive} />} +
+ })} +
+} + +export default KeyValueTable diff --git a/ui-common/src/components/UI/KeyValueTable/delete-active.svg b/ui-common/src/components/UI/KeyValueTable/delete-active.svg new file mode 100644 index 000000000..e30f8c487 --- /dev/null +++ b/ui-common/src/components/UI/KeyValueTable/delete-active.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-common/src/components/UI/KeyValueTable/delete.svg b/ui-common/src/components/UI/KeyValueTable/delete.svg new file mode 100644 index 000000000..0e5ac9eb2 --- /dev/null +++ b/ui-common/src/components/UI/KeyValueTable/delete.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-common/src/components/UI/Tabs/Tabs.tsx b/ui-common/src/components/UI/Tabs/Tabs.tsx index 9d9bc189d..25d6f73b7 100644 --- a/ui-common/src/components/UI/Tabs/Tabs.tsx +++ b/ui-common/src/components/UI/Tabs/Tabs.tsx @@ -30,10 +30,11 @@ const useTabsStyles = makeStyles((theme : Theme) => createStyles({ }, tab: { - display: 'inline-block', + display: 'inline-flex', textTransform: 'uppercase', color: variables.blueColor, cursor: 'pointer', + alignItems: "center" }, tabsAlignLeft: { diff --git a/ui-common/src/components/modals/ReplayRequestModal/ReplayRequestModal.module.sass b/ui-common/src/components/modals/ReplayRequestModal/ReplayRequestModal.module.sass new file mode 100644 index 000000000..3ce2064a2 --- /dev/null +++ b/ui-common/src/components/modals/ReplayRequestModal/ReplayRequestModal.module.sass @@ -0,0 +1,83 @@ +@import '../ServiceMapModal/ServiceMapModal.module' +@import '../../../variables.module' + +.modalContainer + flex-direction: column + margin: 0 $modalMargin-from-edge + padding: 0 + overflow-y: auto + +.keyValueContainer + background-color: $content-section-color + height: 30% + border-radius: 5px + +.sectionHeader + font-weight: 600 + font-size: 1.2rem + +.path + display: flex + + input + border-radius: 0 5px 5px 0 + flex: 1 + font-size: 15px + color: unset + border-left-width: 0px + text-indent: 5px + + .hostPort + border-radius : 0 + border-width: 1px 1px 1px 0px + + select + border-radius: 5px 0 0 5px + text-transform: uppercase + flex: 0 0 100px + text-align: center + font-size: 15px + font-weight: 600 +.tabs + margin-top: 25px + +.tabContent + height: 30% + border-radius: 5px + margin-top: 15px + +.codeEditor + width: 100% + position: relative + height: 300px + border-radius: inherit + max-height: 40vh + min-height: 50px + +.executeButton + text-transform: uppercase + width: fit-content + margin-left: 10px + +.responseContainer + height: 80% + display: flex + justify-content: center + align-items: center + +.note + color: $data-background-color + padding: 10px + margin-top: 10px + box-sizing: border-box + display: flex + font-style: italic + font-weight: 300 + background-color: $light-gray + border-left: solid 4px $failure-color + line-height: 18px + overflow: hidden + + b::after + content: '\b' + display: inline diff --git a/ui-common/src/components/modals/ReplayRequestModal/ReplayRequestModal.tsx b/ui-common/src/components/modals/ReplayRequestModal/ReplayRequestModal.tsx new file mode 100644 index 000000000..e85be8fea --- /dev/null +++ b/ui-common/src/components/modals/ReplayRequestModal/ReplayRequestModal.tsx @@ -0,0 +1,257 @@ +import { Accordion, AccordionDetails, AccordionSummary, Backdrop, Box, Button, Fade, Modal } from "@mui/material"; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import React, { Fragment, useCallback, useEffect, useState } from "react"; +import { useCommonStyles } from "../../../helpers/commonStyle"; +import { Tabs } from "../../UI"; +import KeyValueTable from "../../UI/KeyValueTable/KeyValueTable"; +import CodeEditor from "../../UI/CodeEditor/CodeEditor"; +import { useRecoilValue, RecoilState, useRecoilState } from "recoil"; +import TrafficViewerApiAtom from "../../../recoil/TrafficViewerApi/atom"; +import TrafficViewerApi from "../../TrafficViewer/TrafficViewerApi"; +import { toast } from "react-toastify"; +import { TOAST_CONTAINER_ID } from "../../../configs/Consts"; +import styles from './ReplayRequestModal.module.sass' +import closeIcon from "assets/close.svg" +import spinnerImg from "assets/spinner.svg" +import refreshImg from "assets/refresh.svg" +import { formatRequest } from "../../EntryDetailed/EntrySections/EntrySections"; +import entryDataAtom from "../../../recoil/entryData"; +import { AutoRepresentation } from "../../EntryDetailed/EntryViewer/AutoRepresentation"; +import useDebounce from "../../../hooks/useDebounce" +import replayRequestModalOpenAtom from "../../../recoil/replayRequestModalOpen"; +import { Utils } from "../../../helpers/Utils"; + +const modalStyle = { + position: 'absolute', + top: '6%', + left: '50%', + transform: 'translate(-50%, 0%)', + width: '89vw', + height: '82vh', + bgcolor: '#F0F5FF', + borderRadius: '5px', + boxShadow: 24, + p: 4, + color: '#000', + padding: "1px 1px", + paddingBottom: "15px" +}; + +interface ReplayRequestModalProps { + isOpen: boolean; + onClose: () => void; +} + +enum RequestTabs { + Params = "params", + Headers = "headers", + Body = "body" +} + +const HTTP_METHODS = ["get", "post", "put", "head", "options", "delete"] +const TABS = [{ tab: RequestTabs.Headers }, { tab: RequestTabs.Params }, { tab: RequestTabs.Body }]; + +const convertParamsToArr = (paramsObj) => Object.entries(paramsObj).map(([key, value]) => { return { key, value } }) + +const getQueryStringParams = (link: String) => { + + if (link) { + const decodedURL = decodeQueryParam(link) + const query = decodedURL.split('?')[1] + const urlSearchParams = new URLSearchParams(query); + return Object.fromEntries(urlSearchParams.entries()); + } + + return "" +}; + +const decodeQueryParam = (p) => { + return decodeURIComponent(p.replace(/\+/g, ' ')); +} + +const ReplayRequestModal: React.FC = ({ isOpen, onClose }) => { + const entryData = useRecoilValue(entryDataAtom) + const request = entryData.data.request + const [method, setMethod] = useState(request?.method?.toLowerCase() as string) + const getHostUrl = useCallback(() => { + return entryData.data.dst.name ? entryData.data?.dst?.name : entryData.data.dst.ip + }, [entryData.data.dst.ip, entryData.data.dst.name]) + const [hostPortInput, setHostPortInput] = useState(`${entryData.base.proto.name}://${getHostUrl()}:${entryData.data.dst.port}`) + const [pathInput, setPathInput] = useState(request.path); + const commonClasses = useCommonStyles(); + const [currentTab, setCurrentTab] = useState(TABS[0].tab); + const [response, setResponse] = useState(null); + const [postData, setPostData] = useState(request?.postData?.text || JSON.stringify(request?.postData?.params)); + const [params, setParams] = useState(convertParamsToArr(request?.queryString || {})) + const [headers, setHeaders] = useState(convertParamsToArr(request?.headers || {})) + const trafficViewerApi = useRecoilValue(TrafficViewerApiAtom as RecoilState) + const [isLoading, setIsLoading] = useState(false) + const [requestExpanded, setRequestExpanded] = useState(true) + const [responseExpanded, setResponseExpanded] = useState(false) + + const debouncedPath = useDebounce(pathInput, 500); + + const onParamsChange = useCallback((newParams) => { + setParams(newParams); + let newUrl = `${debouncedPath ? debouncedPath.split('?')[0] : ""}` + newParams.forEach(({ key, value }, index) => { + newUrl += index > 0 ? '&' : '?' + newUrl += `${key}` + (value ? `=${value}` : "") + }) + + setPathInput(newUrl) + + }, [debouncedPath]) + + useEffect(() => { + const newParams = getQueryStringParams(debouncedPath); + setParams(convertParamsToArr(newParams)) + }, [debouncedPath]) + + const onModalClose = () => { + setRequestExpanded(true) + setResponseExpanded(true) + onClose() + } + + const resetModel = useCallback(() => { + setMethod(request?.method?.toLowerCase() as string) + setHostPortInput(`${entryData.base.proto.name}://${getHostUrl()}:${entryData.data.dst.port}`) + setPathInput(request.path); + setResponse(null); + setPostData(request?.postData?.text || JSON.stringify(request?.postData?.params)); + setParams(convertParamsToArr(request?.queryString || {})) + setHeaders(convertParamsToArr(request?.headers || {})) + setRequestExpanded(true) + }, [entryData.base.proto.name, entryData.data.dst.port, getHostUrl, request?.headers, request?.method, request.path, request?.postData?.params, request?.postData?.text, request?.queryString]) + + const onRefreshRequest = useCallback((event) => { + event.stopPropagation() + resetModel() + }, [resetModel]) + + + const sendRequest = useCallback(async () => { + setResponse(null) + const headersData = headers.reduce((prev, corrent) => { + prev[corrent.key] = corrent.value + return prev + }, {}) + const buildUrl = `${hostPortInput}${pathInput}` + const requestData = { url: buildUrl, headers: headersData, data: postData, method } + try { + setIsLoading(true) + const response = await trafficViewerApi.replayRequest(requestData) + setResponse(response?.data?.representation) + if (response.errorMessage) { + toast.error(response.errorMessage, { containerId: TOAST_CONTAINER_ID }); + } + else { + setRequestExpanded(false) + setResponseExpanded(true) + } + + } catch (error) { + setRequestExpanded(true) + toast.error("Error occurred while fetching response", { containerId: TOAST_CONTAINER_ID }); + console.error(error); + } + finally { + setIsLoading(false) + } + + }, [headers, hostPortInput, method, pathInput, postData, trafficViewerApi]) + + let innerComponent + switch (currentTab) { + case RequestTabs.Params: + innerComponent =
+ break; + case RequestTabs.Headers: + innerComponent = +
setHeaders(heaedrs)} key={"Header"} valuePlaceholder="New Headers Value" keyPlaceholder="New Headers Key" /> +
+ * X-Mizu Header added to reuqests +
+ break; + case RequestTabs.Body: + const formatedCode = formatRequest(postData || "", request?.postData?.mimeType) + innerComponent =
+ +
+ break; + default: + innerComponent = null + break; + } + + return ( + + + +
+ close +
+
+
+ Replay Request +
+
+
+ setRequestExpanded(!requestExpanded)}> + } aria-controls="response-content"> + REQUEST + Refresh Reuqest + + +
+ + setHostPortInput(event.target.value)} className={`${commonClasses.textField} ${styles.hostPort}`} /> + setPathInput(event.target.value)} /> + +
+ +
+ {innerComponent} +
+
+
+ {isLoading && spinner} + {response && !isLoading && ( setResponseExpanded(!responseExpanded)}> + } aria-controls="response-content"> + RESPONSE + + + + + )} +
+
+
+
+ ); +} + +const ReplayRequestModalContainer = () => { + const [isOpenRequestModal, setIsOpenRequestModal] = useRecoilState(replayRequestModalOpenAtom) + return isOpenRequestModal && < ReplayRequestModal isOpen={isOpenRequestModal} onClose={() => setIsOpenRequestModal(false)} /> +} + +export default ReplayRequestModalContainer diff --git a/ui-common/src/components/modals/ReplayRequestModal/assets/close.svg b/ui-common/src/components/modals/ReplayRequestModal/assets/close.svg new file mode 100644 index 000000000..1221c0331 --- /dev/null +++ b/ui-common/src/components/modals/ReplayRequestModal/assets/close.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui-common/src/components/modals/ReplayRequestModal/assets/refresh.svg b/ui-common/src/components/modals/ReplayRequestModal/assets/refresh.svg new file mode 100644 index 000000000..de0615856 --- /dev/null +++ b/ui-common/src/components/modals/ReplayRequestModal/assets/refresh.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui-common/src/components/modals/ReplayRequestModal/assets/spinner.svg b/ui-common/src/components/modals/ReplayRequestModal/assets/spinner.svg new file mode 100644 index 000000000..16ac582fa --- /dev/null +++ b/ui-common/src/components/modals/ReplayRequestModal/assets/spinner.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/ui-common/src/components/modals/ServiceMapModal/ServiceMapModal.module.sass b/ui-common/src/components/modals/ServiceMapModal/ServiceMapModal.module.sass index 94e1eaf50..ae47f3ddf 100644 --- a/ui-common/src/components/modals/ServiceMapModal/ServiceMapModal.module.sass +++ b/ui-common/src/components/modals/ServiceMapModal/ServiceMapModal.module.sass @@ -1,6 +1,8 @@ @import "../../../variables.module" @import "../../../components" +$modalMargin-from-edge : 35px + .closeIcon position: absolute right: 20px @@ -24,7 +26,7 @@ display: flex align-content: center align-items: center - margin-left: 35px + margin-left: $modalMargin-from-edge margin-bottom: 25px margin-top: 25px diff --git a/ui-common/src/helpers/Utils.ts b/ui-common/src/helpers/Utils.ts index ad0273abb..3394889ee 100644 --- a/ui-common/src/helpers/Utils.ts +++ b/ui-common/src/helpers/Utils.ts @@ -42,17 +42,26 @@ export class Utils { return Array.from(map); } + static isJson = (str) => { + try { + JSON.parse(str); + } catch (e) { + return false; + } + return true; + } + static stringToColor = (str) => { - let colors = ["#e51c23", "#e91e63", "#9c27b0", "#673ab7", "#3f51b5", "#5677fc", "#03a9f4", "#00bcd4", "#009688", "#259b24", "#8bc34a", "#afb42b", "#ff9800", "#ff5722", "#795548", "#607d8b"] - + let colors = ["#e51c23", "#e91e63", "#9c27b0", "#673ab7", "#3f51b5", "#5677fc", "#03a9f4", "#00bcd4", "#009688", "#259b24", "#8bc34a", "#afb42b", "#ff9800", "#ff5722", "#795548", "#607d8b"] + let hash = 0; - if (str.length === 0) return hash; + if (str.length === 0) return hash; for (let i = 0; i < str.length; i++) { - hash = str.charCodeAt(i) + ((hash << 5) - hash); - hash = hash & hash; + hash = str.charCodeAt(i) + ((hash << 5) - hash); + hash = hash & hash; } hash = ((hash % colors.length) + colors.length) % colors.length; return colors[hash]; -} + } } diff --git a/ui-common/src/hooks/useDebounce.ts b/ui-common/src/hooks/useDebounce.ts new file mode 100644 index 000000000..847da5d15 --- /dev/null +++ b/ui-common/src/hooks/useDebounce.ts @@ -0,0 +1,24 @@ +import { useState, useEffect } from "react"; + +const useDebounce = (value, delay) => { + // State and setters for debounced value + const [debouncedValue, setDebouncedValue] = useState(value); + useEffect( + () => { + // Update debounced value after delay + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + // Cancel the timeout if value changes (also on delay change or unmount) + // This is how we prevent debounced value from updating if value is changed ... + // .. within the delay period. Timeout gets cleared and restarted. + return () => { + clearTimeout(handler); + }; + }, + [value, delay] // Only re-call effect if value or delay changes + ); + return debouncedValue; +} + +export default useDebounce diff --git a/ui-common/src/recoil/entryData/atom.ts b/ui-common/src/recoil/entryData/atom.ts new file mode 100644 index 000000000..b7f874d98 --- /dev/null +++ b/ui-common/src/recoil/entryData/atom.ts @@ -0,0 +1,8 @@ +import { atom } from "recoil"; + +const entryDataAtom = atom({ + key: "entryDataAtom", + default: null +}); + +export default entryDataAtom; diff --git a/ui-common/src/recoil/entryData/index.ts b/ui-common/src/recoil/entryData/index.ts new file mode 100644 index 000000000..6462bfbd8 --- /dev/null +++ b/ui-common/src/recoil/entryData/index.ts @@ -0,0 +1,3 @@ +import entryDataAtom from "./atom" + +export default entryDataAtom diff --git a/ui-common/src/recoil/replayRequestModalOpen/atom.ts b/ui-common/src/recoil/replayRequestModalOpen/atom.ts new file mode 100644 index 000000000..64e78d9af --- /dev/null +++ b/ui-common/src/recoil/replayRequestModalOpen/atom.ts @@ -0,0 +1,8 @@ +import { atom } from "recoil" + +const replayRequestModalOpenAtom = atom({ + key: "replayRequestModalOpenAtom", + default: false +}) + +export default replayRequestModalOpenAtom; diff --git a/ui-common/src/recoil/replayRequestModalOpen/index.ts b/ui-common/src/recoil/replayRequestModalOpen/index.ts new file mode 100644 index 000000000..2a30ac752 --- /dev/null +++ b/ui-common/src/recoil/replayRequestModalOpen/index.ts @@ -0,0 +1,2 @@ +import replayRequestModalOpenAtom from "./atom"; +export default replayRequestModalOpenAtom; diff --git a/ui-common/src/recoil/serviceMapModalOpen/atom.ts b/ui-common/src/recoil/serviceMapModalOpen/atom.ts deleted file mode 100644 index 181255bbb..000000000 --- a/ui-common/src/recoil/serviceMapModalOpen/atom.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { atom } from "recoil" - -const serviceMapModalOpenAtom = atom({ - key: "serviceMapModalOpenAtom", - default: false -}) - -export default serviceMapModalOpenAtom; diff --git a/ui-common/src/recoil/serviceMapModalOpen/index.ts b/ui-common/src/recoil/serviceMapModalOpen/index.ts deleted file mode 100644 index 5d094a22c..000000000 --- a/ui-common/src/recoil/serviceMapModalOpen/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import atom from "./atom"; -export default atom; diff --git a/ui/src/helpers/api.js b/ui/src/helpers/api.js index f6edfb448..4cab7fa3c 100644 --- a/ui/src/helpers/api.js +++ b/ui/src/helpers/api.js @@ -57,6 +57,11 @@ export default class Api { return response.data; } + replayRequest = async (requestData) => { + const response = await client.post(`/replay/`, requestData); + return response.data; + } + getAuthStatus = async () => { const response = await client.get("/status/auth"); return response.data; diff --git a/ui/src/index.sass b/ui/src/index.sass index 3670de848..4dccb5fb2 100644 --- a/ui/src/index.sass +++ b/ui/src/index.sass @@ -49,6 +49,14 @@ button /**** * Select ***/ +select + background: url("data:image/svg+xml,") no-repeat + background-position: calc(100% - 0.75rem) center !important + -moz-appearance: none !important + -webkit-appearance: none !important + appearance: none !important + padding-right: 1rem !important + .select display: flex align-items: center