console: fix operation details modal layout

[DSF-424]: https://hasurahq.atlassian.net/browse/DSF-424?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9495
GitOrigin-RevId: 561bb3060c7b31bafa7f9a5be8e3fe08f7462f16
This commit is contained in:
Nicolas Inchauspe 2023-06-19 17:47:34 +02:00 committed by hasura-bot
parent 3124c93673
commit 92f244f9cc
4 changed files with 301 additions and 180 deletions

View File

@ -1,3 +1,7 @@
import CommonScss from './lib/components/Common/Common.module.scss';
import filterQueryScss from './lib/components/Common/FilterQuery/FilterQuery.module.scss';
import tableScss from './lib/components/Common/TableCommon/Table.module.scss';
import DragFoldTable from './lib/components/Common/TableCommon/DragFoldTable'; import DragFoldTable from './lib/components/Common/TableCommon/DragFoldTable';
import Editor from './lib/components/Common/Layout/ExpandableEditor/Editor'; import Editor from './lib/components/Common/Layout/ExpandableEditor/Editor';
@ -13,9 +17,6 @@ import * as EndpointNamedExps from './lib/Endpoints';
import * as ControlPlane from './lib/features/ControlPlane'; import * as ControlPlane from './lib/features/ControlPlane';
export * from './lib/utils/console-dev-tools'; export * from './lib/utils/console-dev-tools';
const CommonScss = require('./lib/components/Common/Common.module.scss');
const filterQueryScss = require('./lib/components/Common/FilterQuery/FilterQuery.module.scss');
const tableScss = require('./lib/components/Common/TableCommon/Table.module.scss');
export { ControlPlane }; export { ControlPlane };
@ -85,6 +86,7 @@ export * from './lib/new-components/Button/';
export * from './lib/new-components/Tooltip/'; export * from './lib/new-components/Tooltip/';
export * from './lib/new-components/Badge/'; export * from './lib/new-components/Badge/';
export * from './lib/new-components/Dialog'; export * from './lib/new-components/Dialog';
export * from './lib/new-components/Toasts';
export { default as dataHeaders } from './lib/components/Services/Data/Common/Headers'; export { default as dataHeaders } from './lib/components/Services/Data/Common/Headers';
export { handleMigrationErrors } from './lib/components/Services/Data/TableModify/ModifyActions'; export { handleMigrationErrors } from './lib/components/Services/Data/TableModify/ModifyActions';
export { loadMigrationStatus } from './lib/components/Main/Actions'; export { loadMigrationStatus } from './lib/components/Main/Actions';

View File

@ -1,4 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { hasuraToast } from '@hasura/console-legacy-ce';
import { CopyToClipboard } from 'react-copy-to-clipboard'; import { CopyToClipboard } from 'react-copy-to-clipboard';
import { EditIcon } from './EditIcon'; import { EditIcon } from './EditIcon';
@ -6,7 +7,14 @@ import { EditIcon } from './EditIcon';
import styles from '../Metrics.module.scss'; import styles from '../Metrics.module.scss';
import copyImg from '../images/copy.svg'; import copyImg from '../images/copy.svg';
const CustomCopy = ({ label, copy, onEdit }) => { const CustomCopy = ({
label,
copy,
onEdit,
displayColon = true,
displayAcknowledgement = true,
contentMaxHeight,
}) => {
const [isCopied, toggle] = useState(false); const [isCopied, toggle] = useState(false);
const onCopy = () => { const onCopy = () => {
toggle(true); toggle(true);
@ -14,17 +22,24 @@ const CustomCopy = ({ label, copy, onEdit }) => {
}; };
const renderCopyIcon = () => { const renderCopyIcon = () => {
if (isCopied) { if (isCopied) {
// To suri modify it to have some kind of tooltip saying copied if (displayAcknowledgement) {
return ( // To suri modify it to have some kind of tooltip saying copied
<div className={styles.copyIcon + ' ' + styles.copiedIcon}> return (
<img <div className={styles.copyIcon + ' ' + styles.copiedIcon}>
className={styles.copyIcon + ' ' + styles.copiedIcon} <img
src={copyImg} className={styles.copyIcon + ' ' + styles.copiedIcon}
alt={'Copy icon'} src={copyImg}
/> alt={'Copy icon'}
<div className={styles.copiedWrapper}>Copied</div> />
</div> <div className={styles.copiedWrapper}>Copied</div>
); </div>
);
} else {
hasuraToast({
type: 'success',
title: 'Copied!',
});
}
} }
return <img className={styles.copyIcon} src={copyImg} alt={'Copy icon'} />; return <img className={styles.copyIcon} src={copyImg} alt={'Copy icon'} />;
}; };
@ -32,7 +47,10 @@ const CustomCopy = ({ label, copy, onEdit }) => {
<React.Fragment> <React.Fragment>
<div className={styles.infoWrapper}> <div className={styles.infoWrapper}>
<div className={styles.information}> <div className={styles.information}>
<span>{label}:</span> <span>
{label}
{displayColon ? ':' : ''}
</span>
<span> <span>
{onEdit && ( {onEdit && (
<EditIcon <EditIcon
@ -47,7 +65,12 @@ const CustomCopy = ({ label, copy, onEdit }) => {
</div> </div>
</div> </div>
<div className={styles.boxwrapper + ' ' + styles.errorBox}> <div className={styles.boxwrapper + ' ' + styles.errorBox}>
<div className={`p-xs overflow-auto ${styles.box}`}> <div
className={`p-xs overflow-auto ${styles.box}`}
style={{
...(contentMaxHeight ? { maxHeight: contentMaxHeight } : {}),
}}
>
<code className={styles.queryCode}> <code className={styles.queryCode}>
<pre style={{ whitespace: 'pre-wrap' }}>{copy}</pre> <pre style={{ whitespace: 'pre-wrap' }}>{copy}</pre>
</code> </code>

View File

@ -1,3 +1,4 @@
import clsx from 'clsx';
import CustomCopy from './CustomCopy'; import CustomCopy from './CustomCopy';
import { Dialog } from '@hasura/console-legacy-ce'; import { Dialog } from '@hasura/console-legacy-ce';
@ -55,6 +56,22 @@ const TraceGraph = props => {
return root ? <FlameGraph data={root} height={200} width={375} /> : null; return root ? <FlameGraph data={root} height={200} width={375} /> : null;
}; };
const LabelValue = props => {
const { label, value, className } = props;
return (
<div className={className}>
<div className={clsx('bg-white rounded p-2', className)}>
<div className="text-slate-500 text-base mr-1 whitespace-nowrap">
{label}
</div>
<div>
<strong>{value}</strong>
</div>
</div>
</div>
);
};
const Modal = props => { const Modal = props => {
const { onHide, data, nullData, configData } = props; const { onHide, data, nullData, configData } = props;
@ -66,7 +83,7 @@ const Modal = props => {
analyzeVariables = configData.analyze_query_variables; analyzeVariables = configData.analyze_query_variables;
} }
const renderSessonVars = () => { const renderSessionVars = () => {
const { user_vars: userVars } = data; const { user_vars: userVars } = data;
const userVarKeys = Object.keys(userVars); const userVarKeys = Object.keys(userVars);
const sessionVariables = {}; const sessionVariables = {};
@ -76,12 +93,15 @@ const Modal = props => {
}); });
} }
return ( return (
<div <div className="rounded bg-white text-sm">
className={`${styles.resetInfoWrapperPadd} ${styles.alignedCustomCopy} ${styles.paddingTop}`}
>
<CustomCopy <CustomCopy
label="SESSION VARIABLES" label={
<LabelValue className="inline-block" label="Session variables" />
}
copy={JSON.stringify(sessionVariables, null, 2)} copy={JSON.stringify(sessionVariables, null, 2)}
displayColon={false}
displayAcknowledgement={false}
contentMaxHeight="200px"
/> />
</div> </div>
); );
@ -134,11 +154,19 @@ const Modal = props => {
d = 'Enable response body analysis'; d = 'Enable response body analysis';
} }
return ( return (
<div <div className="rounded bg-white text-sm">
className={`${styles.resetInfoWrapperPadd} ${styles.alignedCustomCopy} <CustomCopy
}`} label={
> <LabelValue
<CustomCopy label="EMPTY ARRAYS & NULLS IN RESPONSE" copy={d} /> className="inline-block"
label="Empty arrays & nulls in response"
/>
}
copy={d}
displayColon={false}
displayAcknowledgement={false}
contentMaxHeight="200px"
/>
</div> </div>
); );
} }
@ -150,7 +178,7 @@ const Modal = props => {
const formattedError = JSON.stringify(requestError, null, 2); const formattedError = JSON.stringify(requestError, null, 2);
return ( return (
<div className={styles.boxwrapper + ' ' + styles.errorBox}> <div className={styles.boxwrapper + ' ' + styles.errorBox}>
<div className={styles.errorMessage}>ERROR:</div> <LabelValue label="Error:" />
<div className={styles.errorBox}> <div className={styles.errorBox}>
<code className={styles.queryCode}> <code className={styles.queryCode}>
<pre style={{ whitespace: 'pre-wrap' }}>{formattedError}</pre> <pre style={{ whitespace: 'pre-wrap' }}>{formattedError}</pre>
@ -166,14 +194,37 @@ const Modal = props => {
if (query) { if (query) {
const { query: graphQLQuery, variables } = query; const { query: graphQLQuery, variables } = query;
const queryElement = ( const queryElement = (
<CustomCopy label={'OPERATION STRING'} copy={graphQLQuery} /> <div className="rounded bg-white text-sm">
<CustomCopy
label={
<LabelValue className="inline-block" label="Operation string" />
}
copy={graphQLQuery}
displayColon={false}
displayAcknowledgement={false}
contentMaxHeight="200px"
/>
</div>
); );
renderItem.push(queryElement); renderItem.push(queryElement);
if (variables && analyzeVariables) { if (variables && analyzeVariables) {
try { try {
const formattedVar = JSON.stringify(variables, null, 2); const formattedVar = JSON.stringify(variables, null, 2);
const variablesElement = [ const variablesElement = [
<CustomCopy label={'QUERY VARIABLES'} copy={formattedVar} />, <div className="rounded bg-white text-sm">
<CustomCopy
label={
<LabelValue
className="inline-block"
label="Query variables"
/>
}
copy={formattedVar}
displayColon={false}
displayAcknowledgement={false}
contentMaxHeight="200px"
/>
</div>,
]; ];
renderItem.push(variablesElement); renderItem.push(variablesElement);
} catch (e) { } catch (e) {
@ -189,10 +240,17 @@ const Modal = props => {
if (generatedSql) { if (generatedSql) {
try { try {
return ( return (
<CustomCopy <div className="rounded bg-white text-sm">
label={'GENERATED SQL'} <CustomCopy
copy={JSON.stringify(generatedSql, null, 2)} label={
/> <LabelValue className="inline-block" label="Generated SQL" />
}
copy={JSON.stringify(generatedSql, null, 2)}
displayColon={false}
displayAcknowledgement={false}
contentMaxHeight="200px"
/>
</div>
); );
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@ -205,7 +263,19 @@ const Modal = props => {
if (!requestHeaders) return null; if (!requestHeaders) return null;
try { try {
const stringified = JSON.stringify(requestHeaders, null, 2); const stringified = JSON.stringify(requestHeaders, null, 2);
return <CustomCopy label="REQUEST HEADERS" copy={stringified} />; return (
<div className="rounded bg-white text-sm">
<CustomCopy
label={
<LabelValue className="inline-block" label="Request headers:" />
}
copy={stringified}
displayColon={false}
displayAcknowledgement={false}
contentMaxHeight="200px"
/>
</div>
);
} catch (e) { } catch (e) {
console.log(e); console.log(e);
} }
@ -217,154 +287,86 @@ const Modal = props => {
onClose={onHide} onClose={onHide}
hasBackdrop hasBackdrop
size="xxxl" size="xxxl"
title="Inspect" title={
<div className="font-normal text-slate-900 flex gap-2 text-left w-full">
<div>
<div>
<span className="text-slate-500">Operation </span>
<strong>{operationName || 'N/A'}</strong>
</div>
<div className="text-sm">
<span className="text-slate-500">Id </span>
{operationId || 'N/A'}
</div>
</div>
<div className="text-base flex-grow text-right pr-6 pt-2">
<LabelValue
label="Timestamp:"
value={new Date(time).toLocaleString()}
className="bg-transparent flex justify-end"
/>
</div>
</div>
}
> >
<div className={styles.modalWrapper}> <div
<div className={`border border-top overflow-y-auto flex w-full text-left bg-slate-100`}
className={`mt-2 border border-top overflow-auto ${styles.modalContainer}`} >
> <div className="flex flex-col flex-shrink p-4 pr-2 gap-4 w-1/2">
<div <LabelValue label="Request Id" value={requestId} />
className={ <LabelValue label="Transport" value={transport} />
styles.noPadd + {transport === 'ws' ? (
' col-md-6 ' + <>
styles.borderRight + <LabelValue label="Websocket Id" value={websocketId} />
' ' + <LabelValue
styles.flexColumn + label="Websocket operation Id"
' ' + value={websocketOperationId}
'overflow-auto' />
{operationType === 'subscription' ? (
<LabelValue label="Subscription status" value={status} />
) : null}
</>
) : null}
<LabelValue label="Client name" value={role || 'N/A'} />
<LabelValue label="Role" value={client_name || 'N/A'} />
<LabelValue
label="Request size"
value={
((transformedVals.hasOwnProperty('request_size') &&
transformedVals.request_size(requestSize)) ||
requestSize) + ' kB'
} }
> />
<div className={styles.infoWrapper}> <LabelValue
<div className={`${styles.infoField} ${styles.paddingBottom}`}> label="Response size"
<div className={styles.information}> value={
TIMESTAMP: <span>{new Date(time).toLocaleString()}</span> (('response_size' in transformedVals &&
</div> transformedVals.response_size(responseSize)) ||
{/* responseSize) + ' kB'
<div className={styles.information}> }
ID: <span>{id}</span> />
</div> {renderSessionVars()}
*/} <div>
<div className={styles.information}> <LabelValue
OPERATION NAME: <span>{operationName || 'N/A'}</span> label="Execution time"
</div> value={
<div className={styles.information}> (('execution_time' in transformedVals &&
OPERATION ID: <span>{operationId || 'N/A'}</span> transformedVals.execution_time(executionTime)) ||
</div> executionTime) + ' ms'
<div className={styles.information}> }
REQUEST ID: <span>{requestId}</span> />
</div> <LabelValue
<div label="Timing"
className={ value={trace && trace?.length && <TraceGraph trace={trace} />}
styles.information + />
' ' +
styles.borderBottom +
' ' +
styles.addPaddBottom
}
>
TRANSPORT: <span>{transport}</span>
</div>
{transport === 'ws' ? (
<div
className={styles.borderBottom + ' ' + styles.paddingTop}
>
<div className={styles.information}>
WEBSOCKET ID: <span>{websocketId}</span>
</div>
<div className={styles.information}>
WEBSOCKET OPERATION ID:{' '}
<span>{websocketOperationId}</span>
</div>
{operationType === 'subscription' ? (
<div className={styles.information}>
SUBSCRIPTION STATUS <span>{status}</span>
</div>
) : null}
</div>
) : null}
</div>
<div className={`${styles.infoField} ${styles.noPaddingBottom}`}>
{/*
<div className={styles.information}>
CLIENT ID: <span>{clientId}</span>
</div>
*/}
<div className={styles.information}>
CLIENT NAME: <span>{client_name || 'N/A'}</span>
</div>
<div
className={`${styles.information} ${styles.noPaddingBottom}`}
>
ROLE: <span>{role || 'N/A'}</span>
</div>
</div>
</div>
{renderSessonVars()}
<div className={`${styles.infoWrapper} ${styles.noPaddingTop}`}>
<div className={`${styles.infoField} ${styles.noPaddingBottom}`}>
<div
className={`
${styles.information} ${styles.borderTop} ${styles.paddingTop}
`}
>
EXECUTION TIME:{' '}
<span>
{('execution_time' in transformedVals &&
transformedVals.execution_time(executionTime)) ||
executionTime}{' '}
ms
</span>
</div>
<div
className={`
${styles.information} ${styles.paddingTop}
`}
>
TIMING
<div className={styles.paddingTop}>
{trace && trace?.length && <TraceGraph trace={trace} />}
</div>
</div>
<div
className={`${styles.information} ${styles.addPaddBottom}`}
>
REQUEST SIZE:{' '}
<span>
{(transformedVals.hasOwnProperty('request_size') &&
transformedVals.request_size(requestSize)) ||
requestSize}{' '}
kB
</span>
<br />
RESPONSE SIZE:{' '}
<span>
{('response_size' in transformedVals &&
transformedVals.response_size(responseSize)) ||
responseSize}{' '}
kB
</span>
</div>
</div>
</div>
{renderError()}
{renderResponseAnalysis()}
</div>
<div className={`overflow-auto ${styles.noPadd} col-md-6`}>
{renderOperationQuery()}
{renderGeneratedSql()}
{renderRequestHeaders()}
{/*
<div className={styles.infoWrapper}>
<div className={styles.information}>
GENERATED SQL:{' '}
<button className={styles.analyzeBtn}>ANALYZE</button>
</div>
</div>
<div className={styles.boxwrapper + ' ' + styles.errorBox}>
<div className={styles.box} />
</div>
*/}
</div> </div>
{renderError()}
{renderResponseAnalysis()}
</div>
<div className="flex flex-col flex-shrink p-4 gap-4 w-1/2">
{renderOperationQuery()}
{renderGeneratedSql()}
{renderRequestHeaders()}
</div> </div>
</div> </div>
</Dialog> </Dialog>

View File

@ -0,0 +1,94 @@
import React from 'react';
import { StoryObj, Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import Modal from './Modal';
const DATA = {
operation: {
time: '2023-06-07T12:11:01.134+00:00',
request_id: 'f09cde5e9616177f77d61c78479ed633',
operation_id: '7116865cef017c3b09e5c9271b0e182a6dcf4c01',
operation_name: 'IntrospectionQuery',
client_name: null,
user_role: 'admin',
execution_time: 0.004819542,
request_size: 1728,
response_size: 1152,
error: null,
query: {
query:
'\n query IntrospectionQuery {\n __schema {\n queryType { name }\n mutationType { name }\n subscriptionType { name }\n types {\n ...FullType\n }\n directives {\n name\n description\n locations\n args {\n ...InputValue\n }\n }\n }\n }\n\n fragment FullType on __Type {\n kind\n name\n description\n fields(includeDeprecated: true) {\n name\n description\n args {\n ...InputValue\n }\n type {\n ...TypeRef\n }\n isDeprecated\n deprecationReason\n }\n inputFields {\n ...InputValue\n }\n interfaces {\n ...TypeRef\n }\n enumValues(includeDeprecated: true) {\n name\n description\n isDeprecated\n deprecationReason\n }\n possibleTypes {\n ...TypeRef\n }\n }\n\n fragment InputValue on __InputValue {\n name\n description\n type { ...TypeRef }\n defaultValue\n }\n\n fragment TypeRef on __Type {\n kind\n name\n ofType {\n kind\n name\n ofType {\n kind\n name\n ofType {\n kind\n name\n ofType {\n kind\n name\n ofType {\n kind\n name\n ofType {\n kind\n name\n ofType {\n kind\n name\n }\n }\n }\n }\n }\n }\n }\n }\n ',
variables: {
review: 4,
},
},
user_vars: {
'x-hasura-role': 'admin',
},
transport: 'ws',
request_headers: {
Accept: '*/*',
'Accept-Encoding': 'gzip, deflate',
'Accept-Language': 'en',
'Cache-Control': 'no-cache',
Connection: 'close',
'Content-Length': '1728',
'Content-Type': 'application/json',
Host: 'tenant1.nginx.hasura.me',
Origin: 'http://cloud.lux-dev.hasura.me',
Pragma: 'no-cache',
Referer: 'http://cloud.lux-dev.hasura.me/',
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
'X-Forwarded-Host': 'tenant1.nginx.hasura.me',
'X-Forwarded-Port': '80',
'X-Forwarded-Proto': 'http',
'X-Forwarded-Server': 'ccced495ab5e',
'X-NginX-Proxy': 'true',
'X-Request-Id': 'f09cde5e9616177f77d61c78479ed633',
},
websocket_id: 'c0cjsdf09cde5e9616177f77d61c78479ed3',
ws_operation_id: 'c1984fgb9cde5616177f77d61c78479ed3',
kind: null,
request_mode: 'single',
generated_sql: 'SELECT * FROM public.author',
trace: [
{
id: '429793da-9c7d-450e-9a9d-d50283b446ca',
name: '/v1/graphql',
parent_id: null,
span_id: '7903618144220886803',
time: '2023-06-07T15:14:02.774+00:00',
duration: 3398250,
start: '2023-06-07T15:14:02.878461+00:00',
meta: {
request_id: '05e83aff90e604b68cd6daa311105f78',
},
},
{
id: 'a1c38f1d-bd74-4660-a849-0a1e4924ebe9',
name: 'Query',
parent_id: '7903618144220886803',
span_id: '2425353977187282560',
time: '2023-06-07T15:14:02.774+00:00',
duration: 49791,
start: '2023-06-07T15:14:02.881081+00:00',
meta: {},
},
],
},
};
export default {
component: Modal,
} as Meta<typeof Modal>;
export const Basic: StoryObj<typeof Modal> = {
args: {
data: DATA.operation,
configData: {},
onHide: action('onHide'),
},
};