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 <>
This commit is contained in:
leon-up9 2022-07-03 09:48:56 +03:00 committed by GitHub
parent 48619b3e1c
commit 13ed8eb58a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 820 additions and 137 deletions

View File

@ -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",

View File

@ -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<TrafficViewerApi>)
const query = useRecoilValue(queryAtom);
const [isLoading, setIsLoading] = useState(false);
const [entryData, setEntryData] = useState(null);
const [entryData, setEntryData] = useRecoilState(entryDataAtom)
useEffect(() => {
setEntryData(null);

View File

@ -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<EntryBodySectionProps> = ({
title,
color,
@ -139,42 +172,17 @@ export const EntryBodySection: React.FC<EntryBodySectionProps> = ({
!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]);

View File

@ -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<any> = ({ 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() && <span title="Replay Request"><ReplayIcon fill={color} stroke={color} style={{ marginLeft: "10px", cursor: "pointer", height: "22px" }} onClick={() => setIsOpenRequestModal(true)} /></span>
}
];
const [currentTab, setCurrentTab] = useState(TABS[0].tab);
// Don't fail even if `representation` is an empty string
if (!representation) {
return <React.Fragment></React.Fragment>;
}
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 <div className={styles.Entry}>
{<div className={styles.body}>
<div className={styles.bodyHeader}>
<Tabs tabs={TABS} currentTab={currentTab} color={color} onChange={setCurrentTab} leftAligned />
</div>
{currentTab === TABS[0].tab && <React.Fragment>
<SectionsRepresentation data={request} color={color} requestRepresentation={request} />
</React.Fragment>}
{response && currentTab === TABS[responseTabIndex].tab && <React.Fragment>
<SectionsRepresentation data={response} color={color} />
</React.Fragment>}
{isRulesEnabled && currentTab === TABS[rulesTabIndex].tab && <React.Fragment>
<EntryTablePolicySection title={'Rule'} color={color} latency={elapsedTime} arrayToIterate={rulesMatched ? rulesMatched : []} />
</React.Fragment>}
</div>}
</div>;
}

View File

@ -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<any> = ({data, color}) => {
const sections = []
if (data) {
for (const [i, row] of data.entries()) {
switch (row.type) {
case SectionTypes.SectionTable:
sections.push(
<EntryTableSection key={i} title={row.title} color={color} arrayToIterate={JSON.parse(row.data)}/>
)
break;
case SectionTypes.SectionBody:
sections.push(
<EntryBodySection key={i} title={row.title} color={color} content={row.data} encoding={row.encoding} contentType={row.mimeType} selector={row.selector}/>
)
break;
default:
break;
}
}
}
return <React.Fragment>{sections}</React.Fragment>;
}
const AutoRepresentation: React.FC<any> = ({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 <React.Fragment></React.Fragment>;
}
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 <div className={styles.Entry}>
{<div className={styles.body}>
<div className={styles.bodyHeader}>
<Tabs tabs={TABS} currentTab={currentTab} color={color} onChange={setCurrentTab} leftAligned/>
</div>
{currentTab === TABS[0].tab && <React.Fragment>
<SectionsRepresentation data={request} color={color}/>
</React.Fragment>}
{response && currentTab === TABS[responseTabIndex].tab && <React.Fragment>
<SectionsRepresentation data={response} color={color}/>
</React.Fragment>}
{isRulesEnabled && currentTab === TABS[rulesTabIndex].tab && <React.Fragment>
<EntryTablePolicySection title={'Rule'} color={color} latency={elapsedTime} arrayToIterate={rulesMatched ? rulesMatched : []}/>
</React.Fragment>}
</div>}
</div>;
}
import React from 'react';
import { AutoRepresentation } from './AutoRepresentation';
interface Props {
representation: any;
@ -95,13 +9,14 @@ interface Props {
elapsedTime: number;
}
const EntryViewer: React.FC<Props> = ({representation, isRulesEnabled, rulesMatched, elapsedTime, color}) => {
const EntryViewer: React.FC<Props> = ({ representation, isRulesEnabled, rulesMatched, elapsedTime, color }) => {
return <AutoRepresentation
representation={representation}
isRulesEnabled={isRulesEnabled}
rulesMatched={rulesMatched}
elapsedTime={elapsedTime}
color={color}
isDisplayReplay={true}
/>
};

View File

@ -0,0 +1,34 @@
import React from "react";
import { EntryTableSection, EntryBodySection } from "../EntrySections/EntrySections";
enum SectionTypes {
SectionTable = "table",
SectionBody = "body",
}
const SectionsRepresentation: React.FC<any> = ({ data, color }) => {
const sections = []
if (data) {
for (const [i, row] of data.entries()) {
switch (row.type) {
case SectionTypes.SectionTable:
sections.push(
<EntryTableSection key={i} title={row.title} color={color} arrayToIterate={JSON.parse(row.data)} />
)
break;
case SectionTypes.SectionBody:
sections.push(
<EntryBodySection key={i} title={row.title} color={color} content={row.data} encoding={row.encoding} contentType={row.mimeType} selector={row.selector} />
)
break;
default:
break;
}
}
}
return <React.Fragment>{sections}</React.Fragment>;
}
export default SectionsRepresentation

View File

@ -0,0 +1 @@
<?xml version="1.0" ?><svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title/><path d="M16,12a1,1,0,0,1-.49.86l-5,3A1,1,0,0,1,10,16a1,1,0,0,1-.49-.13A1,1,0,0,1,9,15V9a1,1,0,0,1,1.51-.86l5,3A1,1,0,0,1,16,12Z" fill="#464646"/><path d="M21.92,5.09a1,1,0,0,0-1.07.15L19.94,6A9.84,9.84,0,0,0,12,2a10,10,0,1,0,9.42,13.33,1,1,0,0,0-1.89-.66A8,8,0,1,1,12,4a7.87,7.87,0,0,1,6.42,3.32l-1.07.92A1,1,0,0,0,18,10h3.5a1,1,0,0,0,1-1V6A1,1,0,0,0,21.92,5.09Z" fill="#464646"/></svg>

After

Width:  |  Height:  |  Size: 477 B

View File

@ -0,0 +1,4 @@
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="15" cy="15" r="13.5" stroke="#205CF5" stroke-width="3"/>
<path d="M20 15C20 15.3167 19.8392 15.6335 19.5175 15.8189L12.5051 19.8624C11.8427 20.2444 11 19.7858 11 19.0435V10.9565C11 10.2142 11.8427 9.75564 12.5051 10.1376L19.5175 14.1811C19.8392 14.3665 20 14.6833 20 15Z" fill="#205CF5"/>
</svg>

After

Width:  |  Height:  |  Size: 404 B

View File

@ -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

View File

@ -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<TrafficViewerProps> = ({
pauseOnFocusLoss
draggable
pauseOnHover/>
<ReplayRequestModalContainer />
</RecoilRoot>
}

View File

@ -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<any>,
webSocket: {
close: () => void
}

View File

@ -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<CodeEditorProps> = ({
language,
onChange,
code
}) => {
return (
<AceEditor
mode={language}
theme="github"
onChange={onChange}
editorProps={{ $blockScrolling: true }}
setOptions={{
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
enableSnippets: true
}}
showPrintMargin={false}
value={code}
width="100%"
height="100%"
style={{ borderRadius: "inherit" }}
/>
);
}
export default CodeEditor

View File

@ -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<HoverImageProps> = ({
src,
hoverSrc,
style,
disabled,
onClick,
className,
alt = ""
}) => {
const [imageSrc, setImageSrc] = React.useState<string>(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 (
<img
src={imageSrc}
style={style}
onMouseOver={mouseOver}
onMouseOut={mouseOut}
onClick={handleClick}
className={className}
alt={alt}
/>
);
};
export default HoverImage;

View File

@ -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%

View File

@ -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<KeyValueTableProps> = ({ 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 <div className={styles.keyValueTableContainer}>
{keyValueData?.map((row, index) => {
return <div key={index} className={styles.headerRow}>
<div className={styles.roundInputContainer} style={{ width: "30%" }}>
<input
name="key" type="text"
placeholder={keyPlaceholder ? keyPlaceholder : "New key"}
onChange={(event) => setNewVal((row) => { return { key: event.target.value, value: row.value } }, index)}
value={row.key}
autoComplete="off"
spellCheck={false} />
</div>
<div className={styles.roundInputContainer} style={{ width: "65%" }}>
<input
name="value" type="text"
placeholder={valuePlaceholder ? valuePlaceholder : "New Value"}
onChange={(event) => setNewVal((row) => { return { key: row.key, value: event.target.value } }, index)}
value={row.value?.toString()}
autoComplete="off"
spellCheck={false} />
</div>
{(row.key !== "" || row.value !== "") && <HoverImage alt="delete" style={{ marginLeft: "5px", cursor: "pointer" }} className="deleteIcon" src={deleteIcon}
onClick={() => deleteRow(index)} hoverSrc={deleteIconActive} />}
</div>
})}
</div>
}
export default KeyValueTable

View File

@ -0,0 +1,3 @@
<svg width="16" height="18" viewBox="0 0 16 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.9166 4C15.9166 4.20411 15.8416 4.40111 15.706 4.55364C15.5704 4.70617 15.3835 4.80362 15.1808 4.8275L15.0833 4.83333H14.3791L13.3533 15.2667C13.2974 15.8328 13.033 16.3579 12.6114 16.7399C12.1899 17.1219 11.6413 17.3334 11.0724 17.3333H4.92742C4.35854 17.3334 3.80998 17.1219 3.38842 16.7399C2.96686 16.3579 2.70244 15.8328 2.64659 15.2667L1.62075 4.83333H0.916585C0.695572 4.83333 0.48361 4.74554 0.32733 4.58926C0.171049 4.43298 0.083252 4.22101 0.083252 4C0.083252 3.77899 0.171049 3.56702 0.32733 3.41074C0.48361 3.25446 0.695572 3.16667 0.916585 3.16667H5.08325C5.08325 2.78364 5.15869 2.40437 5.30527 2.05051C5.45185 1.69664 5.66669 1.37511 5.93752 1.10427C6.20836 0.833434 6.52989 0.618594 6.88376 0.472018C7.23763 0.325442 7.6169 0.25 7.99992 0.25C8.38294 0.25 8.76221 0.325442 9.11608 0.472018C9.46994 0.618594 9.79148 0.833434 10.0623 1.10427C10.3332 1.37511 10.548 1.69664 10.6946 2.05051C10.8411 2.40437 10.9166 2.78364 10.9166 3.16667H15.0833C15.3043 3.16667 15.5162 3.25446 15.6725 3.41074C15.8288 3.56702 15.9166 3.77899 15.9166 4ZM9.87492 6.70833C9.72389 6.70834 9.57797 6.76304 9.46414 6.86231C9.35032 6.96158 9.27629 7.09871 9.25575 7.24833L9.24992 7.33333V13.1667L9.25575 13.2517C9.27633 13.4013 9.35038 13.5383 9.4642 13.6376C9.57802 13.7368 9.72392 13.7915 9.87492 13.7915C10.0259 13.7915 10.1718 13.7368 10.2856 13.6376C10.3995 13.5383 10.4735 13.4013 10.4941 13.2517L10.4999 13.1667V7.33333L10.4941 7.24833C10.4735 7.09871 10.3995 6.96158 10.2857 6.86231C10.1719 6.76304 10.0259 6.70834 9.87492 6.70833ZM6.12492 6.70833C5.97389 6.70834 5.82797 6.76304 5.71414 6.86231C5.60032 6.96158 5.52629 7.09871 5.50575 7.24833L5.49992 7.33333V13.1667L5.50575 13.2517C5.52633 13.4013 5.60038 13.5383 5.7142 13.6376C5.82802 13.7368 5.97392 13.7915 6.12492 13.7915C6.27592 13.7915 6.42182 13.7368 6.53564 13.6376C6.64946 13.5383 6.7235 13.4013 6.74409 13.2517L6.74992 13.1667V7.33333L6.74409 7.24833C6.72355 7.09871 6.64952 6.96158 6.53569 6.86231C6.42187 6.76304 6.27595 6.70834 6.12492 6.70833ZM7.99992 1.91667C7.6684 1.91667 7.35046 2.04836 7.11603 2.28278C6.88161 2.5172 6.74992 2.83515 6.74992 3.16667H9.24992C9.24992 2.83515 9.11822 2.5172 8.8838 2.28278C8.64938 2.04836 8.33144 1.91667 7.99992 1.91667Z" fill="#DB2156"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1,3 @@
<svg width="16" height="18" viewBox="0 0 16 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.9166 4C15.9166 4.20411 15.8416 4.40111 15.706 4.55364C15.5704 4.70617 15.3835 4.80362 15.1808 4.8275L15.0833 4.83333H14.3791L13.3533 15.2667C13.2974 15.8328 13.033 16.3579 12.6114 16.7399C12.1899 17.1219 11.6413 17.3334 11.0724 17.3333H4.92742C4.35854 17.3334 3.80998 17.1219 3.38842 16.7399C2.96686 16.3579 2.70244 15.8328 2.64659 15.2667L1.62075 4.83333H0.916585C0.695572 4.83333 0.48361 4.74554 0.32733 4.58926C0.171049 4.43298 0.083252 4.22101 0.083252 4C0.083252 3.77899 0.171049 3.56702 0.32733 3.41074C0.48361 3.25446 0.695572 3.16667 0.916585 3.16667H5.08325C5.08325 2.78364 5.15869 2.40437 5.30527 2.05051C5.45185 1.69664 5.66669 1.37511 5.93752 1.10427C6.20836 0.833434 6.52989 0.618594 6.88376 0.472018C7.23763 0.325442 7.6169 0.25 7.99992 0.25C8.38294 0.25 8.76221 0.325442 9.11608 0.472018C9.46994 0.618594 9.79148 0.833434 10.0623 1.10427C10.3332 1.37511 10.548 1.69664 10.6946 2.05051C10.8411 2.40437 10.9166 2.78364 10.9166 3.16667H15.0833C15.3043 3.16667 15.5162 3.25446 15.6725 3.41074C15.8288 3.56702 15.9166 3.77899 15.9166 4ZM9.87492 6.70833C9.72389 6.70834 9.57797 6.76304 9.46414 6.86231C9.35032 6.96158 9.27629 7.09871 9.25575 7.24833L9.24992 7.33333V13.1667L9.25575 13.2517C9.27633 13.4013 9.35038 13.5383 9.4642 13.6376C9.57802 13.7368 9.72392 13.7915 9.87492 13.7915C10.0259 13.7915 10.1718 13.7368 10.2856 13.6376C10.3995 13.5383 10.4735 13.4013 10.4941 13.2517L10.4999 13.1667V7.33333L10.4941 7.24833C10.4735 7.09871 10.3995 6.96158 10.2857 6.86231C10.1719 6.76304 10.0259 6.70834 9.87492 6.70833ZM6.12492 6.70833C5.97389 6.70834 5.82797 6.76304 5.71414 6.86231C5.60032 6.96158 5.52629 7.09871 5.50575 7.24833L5.49992 7.33333V13.1667L5.50575 13.2517C5.52633 13.4013 5.60038 13.5383 5.7142 13.6376C5.82802 13.7368 5.97392 13.7915 6.12492 13.7915C6.27592 13.7915 6.42182 13.7368 6.53564 13.6376C6.64946 13.5383 6.7235 13.4013 6.74409 13.2517L6.74992 13.1667V7.33333L6.74409 7.24833C6.72355 7.09871 6.64952 6.96158 6.53569 6.86231C6.42187 6.76304 6.27595 6.70834 6.12492 6.70833ZM7.99992 1.91667C7.6684 1.91667 7.35046 2.04836 7.11603 2.28278C6.88161 2.5172 6.74992 2.83515 6.74992 3.16667H9.24992C9.24992 2.83515 9.11822 2.5172 8.8838 2.28278C8.64938 2.04836 8.33144 1.91667 7.99992 1.91667Z" fill="#8F9BB2"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -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: {

View File

@ -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

View File

@ -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<ReplayRequestModalProps> = ({ 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<TrafficViewerApi>)
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 = <div className={styles.keyValueContainer}><KeyValueTable data={params} onDataChange={onParamsChange} key={"params"} valuePlaceholder="New Param Value" keyPlaceholder="New param Key" /></div>
break;
case RequestTabs.Headers:
innerComponent = <Fragment>
<div className={styles.keyValueContainer}><KeyValueTable data={headers} onDataChange={(heaedrs) => setHeaders(heaedrs)} key={"Header"} valuePlaceholder="New Headers Value" keyPlaceholder="New Headers Key" />
</div>
<span className={styles.note}><b>* </b> X-Mizu Header added to reuqests</span>
</Fragment>
break;
case RequestTabs.Body:
const formatedCode = formatRequest(postData || "", request?.postData?.mimeType)
innerComponent = <div className={styles.codeEditor}>
<CodeEditor language={request?.postData?.mimeType.split("/")[1]}
code={Utils.isJson(formatedCode) ? JSON.stringify(JSON.parse(formatedCode || "{}"), null, 2) : formatedCode}
onChange={setPostData} />
</div>
break;
default:
innerComponent = null
break;
}
return (
<Modal
aria-labelledby="transition-modal-title"
aria-describedby="transition-modal-description"
open={isOpen}
onClose={onModalClose}
closeAfterTransition
BackdropComponent={Backdrop}
BackdropProps={{ timeout: 500 }}>
<Fade in={isOpen}>
<Box sx={modalStyle}>
<div className={styles.closeIcon}>
<img src={closeIcon} alt="close" onClick={onModalClose} style={{ cursor: "pointer", userSelect: "none" }} />
</div>
<div className={styles.headerContainer}>
<div className={styles.headerSection}>
<span className={styles.title}>Replay Request</span>
</div>
</div>
<div className={styles.modalContainer}>
<Accordion TransitionProps={{ unmountOnExit: true }} expanded={requestExpanded} onChange={() => setRequestExpanded(!requestExpanded)}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls="response-content">
<span className={styles.sectionHeader}>REQUEST</span>
<img src={refreshImg} style={{ marginLeft: "10px" }} title="Refresh Reuqest" alt="Refresh Reuqest" onClick={onRefreshRequest} />
</AccordionSummary>
<AccordionDetails>
<div className={styles.path}>
<select className={styles.select} value={method} onChange={(e) => setMethod(e.target.value)}>
{HTTP_METHODS.map(method => <option value={method} key={method}>{method}</option>)}
</select>
<input placeholder="Host:Port" value={hostPortInput} onChange={(event) => setHostPortInput(event.target.value)} className={`${commonClasses.textField} ${styles.hostPort}`} />
<input className={commonClasses.textField} placeholder="Enter Path" value={pathInput}
onChange={(event) => setPathInput(event.target.value)} />
<Button size="medium"
variant="contained"
className={commonClasses.button + ` ${styles.executeButton}`}
onClick={sendRequest}>
Execute
</Button >
</div>
<Tabs tabs={TABS} currentTab={currentTab} onChange={setCurrentTab} leftAligned classes={{ root: styles.tabs }} />
<div className={styles.tabContent}>
{innerComponent}
</div>
</AccordionDetails>
</Accordion>
{isLoading && <img alt="spinner" src={spinnerImg} style={{ height: 50 }} />}
{response && !isLoading && (<Accordion TransitionProps={{ unmountOnExit: true }} expanded={responseExpanded} onChange={() => setResponseExpanded(!responseExpanded)}>
<AccordionSummary expandIcon={<ExpandMoreIcon />} aria-controls="response-content">
<span className={styles.sectionHeader}>RESPONSE</span>
</AccordionSummary>
<AccordionDetails>
<AutoRepresentation representation={response} color={entryData.protocol.backgroundColor} />
</AccordionDetails>
</Accordion>)}
</div>
</Box>
</Fade>
</Modal>
);
}
const ReplayRequestModalContainer = () => {
const [isOpenRequestModal, setIsOpenRequestModal] = useRecoilState(replayRequestModalOpenAtom)
return isOpenRequestModal && < ReplayRequestModal isOpen={isOpenRequestModal} onClose={() => setIsOpenRequestModal(false)} />
}
export default ReplayRequestModalContainer

View File

@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.591 9.99997C18.591 14.7446 14.7447 18.5909 10.0001 18.5909C5.25546 18.5909 1.40918 14.7446 1.40918 9.99997C1.40918 5.25534 5.25546 1.40906 10.0001 1.40906C14.7447 1.40906 18.591 5.25534 18.591 9.99997Z" fill="#E9EBF8" stroke="#BCCEFD"/>
<path d="M13.1604 8.23038L11.95 7.01994L10.1392 8.83078L8.32832 7.01994L7.11789 8.23038L8.92872 10.0412L7.12046 11.8495L8.33089 13.0599L10.1392 11.2517L11.9474 13.0599L13.1579 11.8495L11.3496 10.0412L13.1604 8.23038Z" fill="#205CF5"/>
</svg>

After

Width:  |  Height:  |  Size: 588 B

View File

@ -0,0 +1,3 @@
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.8337 11.9167H7.69308L7.69416 11.907C7.83561 11.2143 8.11247 10.5564 8.50883 9.97105C9.09865 9.10202 9.92598 8.42097 10.8922 8.00913C11.2193 7.87046 11.5606 7.7643 11.9083 7.69388C12.6297 7.54762 13.3731 7.54762 14.0945 7.69388C15.1312 7.90631 16.0825 8.41908 16.8299 9.1683L18.3639 7.63863C17.6725 6.94707 16.8546 6.39501 15.9546 6.01255C15.4956 5.81823 15.0184 5.67016 14.53 5.57055C13.5223 5.36581 12.4838 5.36581 11.4761 5.57055C10.9873 5.67057 10.5098 5.819 10.0504 6.01363C8.69682 6.58791 7.53808 7.54123 6.71374 8.7588C6.15895 9.5798 5.77099 10.5019 5.57191 11.4725C5.54158 11.6188 5.52533 11.7683 5.50366 11.9167H2.16699L6.50033 16.25L10.8337 11.9167ZM15.167 14.0834H18.3076L18.3065 14.092C18.0234 15.4806 17.205 16.7019 16.0282 17.4915C15.443 17.8882 14.7851 18.1651 14.0923 18.3062C13.3713 18.4525 12.6283 18.4525 11.9072 18.3062C11.2146 18.1648 10.5567 17.8879 9.97133 17.4915C9.68383 17.2971 9.41541 17.0758 9.16966 16.8307L7.63783 18.3625C8.32954 19.0539 9.14791 19.6056 10.0482 19.9875C10.5076 20.1825 10.9875 20.331 11.4728 20.4295C12.4801 20.6344 13.5184 20.6344 14.5257 20.4295C16.4676 20.0265 18.1757 18.8819 19.2869 17.2391C19.8412 16.4187 20.2288 15.4974 20.4277 14.5275C20.4569 14.3813 20.4742 14.2318 20.4959 14.0834H23.8337L19.5003 9.75005L15.167 14.0834Z" fill="#205CF5"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="margin: auto; background: none; display: block; shape-rendering: auto;" width="200px" height="200px" viewBox="0 0 100 100" preserveAspectRatio="xMidYMid">
<circle cx="50" cy="50" fill="none" stroke="#1d3f72" stroke-width="10" r="35" stroke-dasharray="164.93361431346415 56.97787143782138" transform="rotate(275.903 50 50)">
<animateTransform attributeName="transform" type="rotate" repeatCount="indefinite" dur="1s" values="0 50 50;360 50 50" keyTimes="0;1"></animateTransform>
</circle>
<!-- [ldio] generated by https://loading.io/ --></svg>

After

Width:  |  Height:  |  Size: 673 B

View File

@ -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

View File

@ -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];
}
}
}

View File

@ -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

View File

@ -0,0 +1,8 @@
import { atom } from "recoil";
const entryDataAtom = atom({
key: "entryDataAtom",
default: null
});
export default entryDataAtom;

View File

@ -0,0 +1,3 @@
import entryDataAtom from "./atom"
export default entryDataAtom

View File

@ -0,0 +1,8 @@
import { atom } from "recoil"
const replayRequestModalOpenAtom = atom({
key: "replayRequestModalOpenAtom",
default: false
})
export default replayRequestModalOpenAtom;

View File

@ -0,0 +1,2 @@
import replayRequestModalOpenAtom from "./atom";
export default replayRequestModalOpenAtom;

View File

@ -1,8 +0,0 @@
import { atom } from "recoil"
const serviceMapModalOpenAtom = atom({
key: "serviceMapModalOpenAtom",
default: false
})
export default serviceMapModalOpenAtom;

View File

@ -1,2 +0,0 @@
import atom from "./atom";
export default atom;

View File

@ -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;

View File

@ -49,6 +49,14 @@ button
/****
* Select
***/
select
background: url("data:image/svg+xml,<svg height='10px' width='10px' viewBox='0 0 16 16' fill='%23000000' xmlns='http://www.w3.org/2000/svg'><path d='M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z'/></svg>") 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