-
Notifications
You must be signed in to change notification settings - Fork 235
feat(explain-aggregation): explain aggregation COMPASS-5788 #3102
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
dbf40ab
08c0ac1
4eabca7
6346d21
71b2751
f1fd5b3
89d0ca2
c59cb92
1e2e044
be6f5b4
719fa9e
e35fa26
b91eb1b
1941956
8adaaba
15663f4
0f4532a
b42c24b
aa62431
e4216f6
2900fe9
f4122d5
40d6680
e35201a
f37e9c4
c463a70
19e7d97
2e1c598
54431a6
3845394
6071aaf
78b7448
8481f21
3932ed4
dd83d1c
dccc229
4e34ab2
f407c7a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
import React from 'react'; | ||
import { Badge, BadgeVariant, Body } from '@mongodb-js/compass-components'; | ||
import type { IndexInformation } from '@mongodb-js/explain-plan-helper'; | ||
|
||
type ExplainIndexesProps = { | ||
indexes: IndexInformation[]; | ||
}; | ||
|
||
export const ExplainIndexes: React.FunctionComponent<ExplainIndexesProps> = ({ | ||
indexes, | ||
}) => { | ||
if (indexes.filter(({ index }) => index).length === 0) { | ||
return <Body weight="medium">No index available for this query.</Body>; | ||
} | ||
|
||
return ( | ||
<div> | ||
{indexes.map((info, idx) => ( | ||
<Badge key={idx} variant={BadgeVariant.LightGray}> | ||
{info.index} {info.shard && <>({info.shard})</>} | ||
</Badge> | ||
))} | ||
</div> | ||
); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
import React from 'react'; | ||
import { Body, Subtitle, css, spacing } from '@mongodb-js/compass-components'; | ||
import type { IndexInformation } from '@mongodb-js/explain-plan-helper'; | ||
|
||
import { ExplainIndexes } from './explain-indexes'; | ||
|
||
type ExplainQueryPerformanceProps = { | ||
executionTimeMillis: number; | ||
nReturned: number; | ||
usedIndexes: IndexInformation[]; | ||
}; | ||
|
||
const containerStyles = css({ | ||
display: 'flex', | ||
gap: spacing[3], | ||
flexDirection: 'column', | ||
}); | ||
|
||
const statsStyles = css({ | ||
gap: spacing[1], | ||
display: 'flex', | ||
flexDirection: 'column', | ||
}); | ||
|
||
const statItemStyles = css({ | ||
display: 'flex', | ||
gap: spacing[1], | ||
}); | ||
|
||
const statTitleStyles = css({ | ||
whiteSpace: 'nowrap', | ||
}); | ||
|
||
export const ExplainQueryPerformance: React.FunctionComponent<ExplainQueryPerformanceProps> = | ||
({ nReturned, executionTimeMillis, usedIndexes }) => { | ||
return ( | ||
<div | ||
className={containerStyles} | ||
data-testid="pipeline-explain-results-summary" | ||
> | ||
<Subtitle>Query Performance Summary</Subtitle> | ||
<div className={statsStyles}> | ||
{typeof nReturned === 'number' && ( | ||
<div className={statItemStyles}> | ||
<Body>Documents returned:</Body> | ||
<Body weight="medium">{nReturned}</Body> | ||
</div> | ||
)} | ||
{executionTimeMillis > 0 && ( | ||
<div className={statItemStyles}> | ||
<Body>Actual query execution time(ms):</Body> | ||
<Body weight="medium">{executionTimeMillis}</Body> | ||
</div> | ||
)} | ||
<div className={statItemStyles}> | ||
<Body className={statTitleStyles}> | ||
Query used the following indexes: | ||
</Body> | ||
<ExplainIndexes indexes={usedIndexes} /> | ||
</div> | ||
</div> | ||
</div> | ||
); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import React from 'react'; | ||
import { css, spacing, Card } from '@mongodb-js/compass-components'; | ||
import { Document } from '@mongodb-js/compass-crud'; | ||
import HadronDocument from 'hadron-document'; | ||
|
||
import type { ExplainData } from '../../modules/explain'; | ||
import { ExplainQueryPerformance } from './explain-query-performance'; | ||
|
||
type ExplainResultsProps = { | ||
plan: ExplainData['plan']; | ||
stats?: ExplainData['stats']; | ||
}; | ||
|
||
const containerStyles = css({ | ||
display: 'flex', | ||
flexDirection: 'column', | ||
gap: spacing[4], | ||
}); | ||
|
||
const cardStyles = css({ | ||
// 170px works with minimum-height of compass | ||
// todo: handle height for bigger sized compass | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As an addition to this TODO, currently because "copy to clipboard" button is part of the Document component and not the card, the whole thing scrolls including the button: I think we might want to set the height here on the document fields container and not the wrapper card as a way to prevent it, but there is no way to do it now, we would need to change the component interface a bit, and it's a very small thing so totally can be addressed later There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. sure :) |
||
height: '170px', | ||
overflowY: 'scroll', | ||
}); | ||
|
||
export const ExplainResults: React.FunctionComponent<ExplainResultsProps> = ({ | ||
plan, | ||
stats, | ||
}) => { | ||
return ( | ||
<div className={containerStyles} data-testid="pipeline-explain-results"> | ||
{stats && ( | ||
<ExplainQueryPerformance | ||
nReturned={stats.nReturned} | ||
executionTimeMillis={stats.executionTimeMillis} | ||
usedIndexes={stats.usedIndexes} | ||
/> | ||
)} | ||
<Card className={cardStyles} data-testid="pipeline-explain-results-json"> | ||
<Document | ||
doc={plan} | ||
editable={false} | ||
copyToClipboard={() => { | ||
void navigator.clipboard.writeText( | ||
new HadronDocument(plan).toEJSON() | ||
); | ||
}} | ||
/> | ||
</Card> | ||
</div> | ||
); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
import React from 'react'; | ||
import type { ComponentProps } from 'react'; | ||
import { render, screen, within } from '@testing-library/react'; | ||
import userEvent from '@testing-library/user-event'; | ||
import { spy } from 'sinon'; | ||
import { expect } from 'chai'; | ||
|
||
import { PipelineExplain } from './index'; | ||
|
||
const renderPipelineExplain = ( | ||
props: Partial<ComponentProps<typeof PipelineExplain>> = {} | ||
) => { | ||
render( | ||
<PipelineExplain | ||
isLoading={false} | ||
isModalOpen={true} | ||
onCancelExplain={() => {}} | ||
onCloseModal={() => {}} | ||
onRunExplain={() => {}} | ||
{...props} | ||
/> | ||
); | ||
}; | ||
|
||
describe('PipelineExplain', function () { | ||
it('renders loading state', function () { | ||
const onCancelExplainSpy = spy(); | ||
renderPipelineExplain({ | ||
isLoading: true, | ||
onCancelExplain: onCancelExplainSpy, | ||
}); | ||
const modal = screen.getByTestId('pipeline-explain-modal'); | ||
expect(within(modal).getByTestId('pipeline-explain-cancel')).to.exist; | ||
expect(onCancelExplainSpy.callCount).to.equal(0); | ||
|
||
userEvent.click(within(modal).getByText(/cancel/gi), null, { | ||
skipPointerEventsCheck: true, | ||
}); | ||
expect(onCancelExplainSpy.callCount).to.equal(1); | ||
|
||
expect(() => { | ||
within(modal).getByTestId('pipeline-explain-footer-close-button'); | ||
}, 'does not show footer in loading state').to.throw; | ||
}); | ||
|
||
it('renders error state', function () { | ||
renderPipelineExplain({ | ||
error: 'Error occurred', | ||
}); | ||
const modal = screen.getByTestId('pipeline-explain-modal'); | ||
expect(within(modal).getByTestId('pipeline-explain-error')).to.exist; | ||
expect(within(modal).findByText('Error occurred')).to.exist; | ||
expect(() => { | ||
within(modal).getByTestId('pipeline-explain-retry-button'); | ||
}).to.throw; | ||
|
||
expect(within(modal).getByTestId('pipeline-explain-footer-close-button')).to | ||
.exist; | ||
}); | ||
|
||
it('renders explain results - without stats', function () { | ||
renderPipelineExplain({ | ||
explain: { | ||
plan: { | ||
stages: [], | ||
}, | ||
}, | ||
}); | ||
const results = screen.getByTestId('pipeline-explain-results'); | ||
expect(within(results).getByTestId('pipeline-explain-results-json')).to | ||
.exist; | ||
expect(() => { | ||
within(results).getByTestId('pipeline-explain-results-summary'); | ||
}).to.throw; | ||
|
||
expect(screen.getByTestId('pipeline-explain-footer-close-button')).to.exist; | ||
}); | ||
|
||
it('renders explain results - with stats', function () { | ||
renderPipelineExplain({ | ||
explain: { | ||
stats: { | ||
executionTimeMillis: 20, | ||
nReturned: 100, | ||
usedIndexes: [{ index: 'name', shard: 'shard1' }], | ||
}, | ||
plan: { | ||
stages: [], | ||
}, | ||
}, | ||
}); | ||
const results = screen.getByTestId('pipeline-explain-results'); | ||
expect(results).to.exist; | ||
expect(within(results).getByTestId('pipeline-explain-results-json')).to | ||
.exist; | ||
|
||
const summary = within(results).getByTestId( | ||
'pipeline-explain-results-summary' | ||
); | ||
expect(summary).to.exist; | ||
|
||
expect(within(summary).getByText(/documents returned/gi)).to.exist; | ||
expect(within(summary).getByText(/actual query execution time/gi)).to.exist; | ||
expect(within(summary).getByText(/query used the following indexes/gi)).to | ||
.exist; | ||
|
||
expect(screen.getByTestId('pipeline-explain-footer-close-button')).to.exist; | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,36 +1,103 @@ | ||
import React from 'react'; | ||
import { Modal, H3, Body } from '@mongodb-js/compass-components'; | ||
import { | ||
css, | ||
spacing, | ||
Modal, | ||
CancelLoader, | ||
H3, | ||
ModalFooter, | ||
Button, | ||
ErrorSummary, | ||
} from '@mongodb-js/compass-components'; | ||
import { connect } from 'react-redux'; | ||
|
||
import type { RootState } from '../../modules'; | ||
import { closeExplainModal } from '../../modules/explain'; | ||
import type { ExplainData } from '../../modules/explain'; | ||
import { closeExplainModal, cancelExplain } from '../../modules/explain'; | ||
import { ExplainResults } from './explain-results'; | ||
|
||
type PipelineExplainProps = { | ||
isModalOpen: boolean; | ||
isLoading: boolean; | ||
error?: string; | ||
explain?: ExplainData; | ||
onCloseModal: () => void; | ||
onCancelExplain: () => void; | ||
}; | ||
|
||
const contentStyles = css({ | ||
marginTop: spacing[3], | ||
marginBottom: spacing[3], | ||
}); | ||
|
||
const footerStyles = css({ | ||
paddingRight: 0, | ||
paddingBottom: 0, | ||
}); | ||
|
||
export const PipelineExplain: React.FunctionComponent<PipelineExplainProps> = ({ | ||
isModalOpen, | ||
isLoading, | ||
error, | ||
explain, | ||
onCloseModal, | ||
onCancelExplain, | ||
}) => { | ||
let content = null; | ||
if (isLoading) { | ||
content = ( | ||
<CancelLoader | ||
data-testid="pipeline-explain-cancel" | ||
cancelText="Cancel" | ||
onCancel={() => onCancelExplain()} | ||
progressText="Running explain" | ||
/> | ||
); | ||
} else if (error) { | ||
content = ( | ||
<ErrorSummary data-testid="pipeline-explain-error" errors={error} /> | ||
); | ||
} else if (explain) { | ||
content = <ExplainResults plan={explain.plan} stats={explain.stats} />; | ||
} | ||
|
||
if (!content) { | ||
return null; | ||
} | ||
|
||
return ( | ||
<Modal | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
setOpen={onCloseModal} | ||
open={isModalOpen} | ||
data-testid="pipeline-explain-modal" | ||
> | ||
<H3>Explain</H3> | ||
<Body>Implementation in progress ...</Body> | ||
<div className={contentStyles}>{content}</div> | ||
{!isLoading && ( | ||
<ModalFooter className={footerStyles}> | ||
<Button | ||
onClick={onCloseModal} | ||
data-testid="pipeline-explain-footer-close-button" | ||
> | ||
Close | ||
</Button> | ||
</ModalFooter> | ||
)} | ||
</Modal> | ||
); | ||
}; | ||
|
||
const mapState = ({ explain: { isModalOpen } }: RootState) => ({ | ||
isModalOpen: isModalOpen, | ||
const mapState = ({ | ||
explain: { isModalOpen, isLoading, error, explain }, | ||
}: RootState) => ({ | ||
isModalOpen, | ||
isLoading, | ||
error, | ||
explain, | ||
}); | ||
|
||
const mapDispatch = { | ||
onCloseModal: closeExplainModal, | ||
onCancelExplain: cancelExplain, | ||
}; | ||
export default connect(mapState, mapDispatch)(PipelineExplain); |
Uh oh!
There was an error while loading. Please reload this page.