From 5a9244786a6ec4096c3aa8405adf35c2d5fe8183 Mon Sep 17 00:00:00 2001 From: Vadim Ogievetsky Date: Thu, 2 May 2024 08:50:27 -0700 Subject: [PATCH 1/3] Web console: surface more info on the supervisor view (#16318) * add rate and stats * better tabs * detail * add recent errors * update tests * don't let people hide the actions column because why * don't sort on actions * better way to agg * add timeouts * show error only once * fix tests and Explain showing up * only consider active tasks * refresh * fix tests * better formatting --- .../src/components/header-bar/header-bar.scss | 5 + .../table-column-selector.spec.tsx | 2 +- .../table-column-selector.tsx | 43 +- .../__snapshots__/timed-button.spec.tsx.snap | 2 +- .../components/timed-button/timed-button.tsx | 4 +- .../compaction-history-dialog.tsx | 4 +- .../kill-datasource-dialog.tsx | 7 +- ...pervisor-table-action-dialog.spec.tsx.snap | 4 +- .../supervisor-statistics-table.spec.tsx.snap | 57 ++- .../supervisor-statistics-table.spec.tsx | 6 +- .../supervisor-statistics-table.tsx | 91 ++-- .../supervisor-table-action-dialog.tsx | 14 +- .../task-table-action-dialog.spec.tsx.snap | 22 +- .../task-table-action-dialog.tsx | 40 +- .../supervisor-status/supervisor-status.ts | 85 +++- web-console/src/utils/druid-query.ts | 9 +- web-console/src/utils/general.tsx | 12 + .../utils/local-storage-backed-visibility.tsx | 4 +- web-console/src/utils/table-helpers.ts | 19 + .../datasources-view.spec.tsx.snap | 3 +- .../datasources-view/datasources-view.tsx | 10 +- .../views/load-data-view/info-messages.tsx | 6 +- .../views/load-data-view/load-data-view.tsx | 10 +- .../__snapshots__/lookups-view.spec.tsx.snap | 3 +- .../src/views/lookups-view/lookups-view.tsx | 3 +- .../__snapshots__/segments-view.spec.tsx.snap | 2 +- .../src/views/segments-view/segments-view.tsx | 46 +- .../__snapshots__/services-view.spec.tsx.snap | 2 +- .../src/views/services-view/services-view.tsx | 5 +- .../supervisors-view.spec.tsx.snap | 148 +++++- .../supervisors-view/supervisors-view.scss | 13 + .../supervisors-view/supervisors-view.tsx | 451 ++++++++++++++---- .../__snapshots__/tasks-view.spec.tsx.snap | 3 +- .../src/views/tasks-view/tasks-view.tsx | 9 +- .../max-tasks-button/max-tasks-button.tsx | 11 +- .../views/workbench-view/workbench-view.tsx | 18 +- 36 files changed, 853 insertions(+), 320 deletions(-) diff --git a/web-console/src/components/header-bar/header-bar.scss b/web-console/src/components/header-bar/header-bar.scss index 062768a22c48..752cc9bf316f 100644 --- a/web-console/src/components/header-bar/header-bar.scss +++ b/web-console/src/components/header-bar/header-bar.scss @@ -89,4 +89,9 @@ } } } + + .#{$bp-ns}-navbar-group.#{$bp-ns}-align-right { + position: absolute; + right: 15px; + } } diff --git a/web-console/src/components/table-column-selector/table-column-selector.spec.tsx b/web-console/src/components/table-column-selector/table-column-selector.spec.tsx index e45fd590e631..e04377c9c5b1 100644 --- a/web-console/src/components/table-column-selector/table-column-selector.spec.tsx +++ b/web-console/src/components/table-column-selector/table-column-selector.spec.tsx @@ -25,7 +25,7 @@ describe('TableColumnSelector', () => { it('matches snapshot', () => { const tableColumn = ( {}} tableColumnsHidden={['b']} /> diff --git a/web-console/src/components/table-column-selector/table-column-selector.tsx b/web-console/src/components/table-column-selector/table-column-selector.tsx index 2a0c2b5a4762..d838e98e04d3 100644 --- a/web-console/src/components/table-column-selector/table-column-selector.tsx +++ b/web-console/src/components/table-column-selector/table-column-selector.tsx @@ -25,9 +25,15 @@ import { MenuCheckbox } from '../menu-checkbox/menu-checkbox'; import './table-column-selector.scss'; +export type TableColumnSelectorColumn = string | { text: string; label: string }; + +function getColumnName(c: TableColumnSelectorColumn) { + return typeof c === 'string' ? c : c.text; +} + interface TableColumnSelectorProps { - columns: string[]; - onChange: (column: string) => void; + columns: TableColumnSelectorColumn[]; + onChange: (columnName: string) => void; onClose?: (added: number) => void; tableColumnsHidden: string[]; } @@ -38,23 +44,28 @@ export const TableColumnSelector = React.memo(function TableColumnSelector( const { columns, onChange, onClose, tableColumnsHidden } = props; const [added, setAdded] = useState(0); - const isColumnShown = (column: string) => !tableColumnsHidden.includes(column); + const isColumnShown = (column: TableColumnSelectorColumn) => + !tableColumnsHidden.includes(getColumnName(column)); const checkboxes = ( - {columns.map(column => ( - { - if (!isColumnShown(column)) { - setAdded(added + 1); - } - onChange(column); - }} - /> - ))} + {columns.map(column => { + const columnName = getColumnName(column); + return ( + { + if (!isColumnShown(column)) { + setAdded(added + 1); + } + onChange(columnName); + }} + /> + ); + })} ); diff --git a/web-console/src/components/timed-button/__snapshots__/timed-button.spec.tsx.snap b/web-console/src/components/timed-button/__snapshots__/timed-button.spec.tsx.snap index b030fdb304bc..52fbee102425 100644 --- a/web-console/src/components/timed-button/__snapshots__/timed-button.spec.tsx.snap +++ b/web-console/src/components/timed-button/__snapshots__/timed-button.spec.tsx.snap @@ -18,7 +18,7 @@ exports[`TimedButton matches snapshot 1`] = ` ( handleSelection(delay)} /> diff --git a/web-console/src/dialogs/compaction-history-dialog/compaction-history-dialog.tsx b/web-console/src/dialogs/compaction-history-dialog/compaction-history-dialog.tsx index 4cdc916ee747..cb886d0483da 100644 --- a/web-console/src/dialogs/compaction-history-dialog/compaction-history-dialog.tsx +++ b/web-console/src/dialogs/compaction-history-dialog/compaction-history-dialog.tsx @@ -16,7 +16,7 @@ * limitations under the License. */ -import { Button, Callout, Classes, Code, Dialog, Tab, Tabs } from '@blueprintjs/core'; +import { Button, Callout, Classes, Dialog, Tab, Tabs, Tag } from '@blueprintjs/core'; import * as JSONBig from 'json-bigint-native'; import React, { useState } from 'react'; @@ -117,7 +117,7 @@ export const CompactionHistoryDialog = React.memo(function CompactionHistoryDial ) : (
- There is no compaction history for {datasource}. + There is no compaction history for {datasource}.
) ) : historyState.loading ? ( diff --git a/web-console/src/dialogs/kill-datasource-dialog/kill-datasource-dialog.tsx b/web-console/src/dialogs/kill-datasource-dialog/kill-datasource-dialog.tsx index f95a5a5d3b89..dba85268d000 100644 --- a/web-console/src/dialogs/kill-datasource-dialog/kill-datasource-dialog.tsx +++ b/web-console/src/dialogs/kill-datasource-dialog/kill-datasource-dialog.tsx @@ -16,7 +16,7 @@ * limitations under the License. */ -import { Code, Intent } from '@blueprintjs/core'; +import { Intent, Tag } from '@blueprintjs/core'; import React, { useState } from 'react'; import { FormGroupWithInfo, PopoverText } from '../../components'; @@ -74,13 +74,14 @@ export const KillDatasourceDialog = function KillDatasourceDialog( warningChecks={[ <> I understand that this operation will delete all metadata about the unused segments of{' '} - {datasource} and removes them from deep storage. + {datasource} and removes them from deep storage. , 'I understand that this operation cannot be undone.', ]} >

- Are you sure you want to permanently delete unused segments in {datasource}? + Are you sure you want to permanently delete unused segments in{' '} + {datasource}?

This action is not reversible and the data deleted will be lost.

- Statistics + Task stats diff --git a/web-console/src/dialogs/task-table-action-dialog/task-table-action-dialog.tsx b/web-console/src/dialogs/task-table-action-dialog/task-table-action-dialog.tsx index a0a5dbbf13fd..9edc5d996f47 100644 --- a/web-console/src/dialogs/task-table-action-dialog/task-table-action-dialog.tsx +++ b/web-console/src/dialogs/task-table-action-dialog/task-table-action-dialog.tsx @@ -25,18 +25,20 @@ import type { BasicAction } from '../../utils/basic-action'; import type { SideButtonMetaData } from '../table-action-dialog/table-action-dialog'; import { TableActionDialog } from '../table-action-dialog/table-action-dialog'; +type TaskTableActionDialogTab = 'status' | 'report' | 'spec' | 'log'; + interface TaskTableActionDialogProps { taskId: string; actions: BasicAction[]; - onClose: () => void; status: string; + onClose(): void; } export const TaskTableActionDialog = React.memo(function TaskTableActionDialog( props: TaskTableActionDialogProps, ) { const { taskId, actions, onClose, status } = props; - const [activeTab, setActiveTab] = useState('status'); + const [activeTab, setActiveTab] = useState('status'); const taskTableSideButtonMetadata: SideButtonMetaData[] = [ { @@ -45,21 +47,21 @@ export const TaskTableActionDialog = React.memo(function TaskTableActionDialog( active: activeTab === 'status', onClick: () => setActiveTab('status'), }, - { - icon: 'align-left', - text: 'Payload', - active: activeTab === 'payload', - onClick: () => setActiveTab('payload'), - }, { icon: 'comparison', text: 'Reports', - active: activeTab === 'reports', - onClick: () => setActiveTab('reports'), + active: activeTab === 'report', + onClick: () => setActiveTab('report'), + }, + { + icon: 'align-left', + text: 'Spec', + active: activeTab === 'spec', + onClick: () => setActiveTab('spec'), }, { icon: 'align-justify', - text: 'Logs', + text: 'Log', active: activeTab === 'log', onClick: () => setActiveTab('log'), }, @@ -80,20 +82,20 @@ export const TaskTableActionDialog = React.memo(function TaskTableActionDialog( downloadFilename={`task-status-${taskId}.json`} /> )} - {activeTab === 'payload' && ( - deepGet(x, 'payload') || x} - downloadFilename={`task-payload-${taskId}.json`} - /> - )} - {activeTab === 'reports' && ( + {activeTab === 'report' && ( deepGet(x, 'ingestionStatsAndErrors.payload') || x} downloadFilename={`task-reports-${taskId}.json`} /> )} + {activeTab === 'spec' && ( + deepGet(x, 'payload') || x} + downloadFilename={`task-payload-${taskId}.json`} + /> + )} {activeTab === 'log' && ( ; @@ -39,16 +42,94 @@ export interface SupervisorStatus { healthy: boolean; state: string; detailedState: string; - recentErrors: any[]; + recentErrors: SupervisorError[]; }; } export interface SupervisorStatusTask { id: string; startingOffsets: SupervisorOffsetMap; - startTime: '2024-04-12T21:35:34.834Z'; + startTime: string; remainingSeconds: number; type: string; currentOffsets: SupervisorOffsetMap; lag: SupervisorOffsetMap; } + +export interface SupervisorError { + timestamp: string; + exceptionClass: string; + message: string; + streamException: boolean; +} + +export type SupervisorStats = Record>; + +export type RowStatsKey = 'totals' | '1m' | '5m' | '15m'; + +export interface RowStats { + movingAverages: { + buildSegments: { + '1m': RowStatsCounter; + '5m': RowStatsCounter; + '15m': RowStatsCounter; + }; + }; + totals: { + buildSegments: RowStatsCounter; + }; +} + +export interface RowStatsCounter { + processed: number; + processedBytes: number; + processedWithError: number; + thrownAway: number; + unparseable: number; +} + +function sumRowStatsCounter(rowStats: RowStatsCounter[]): RowStatsCounter { + return { + processed: sum(rowStats, d => d.processed), + processedBytes: sum(rowStats, d => d.processedBytes), + processedWithError: sum(rowStats, d => d.processedWithError), + thrownAway: sum(rowStats, d => d.thrownAway), + unparseable: sum(rowStats, d => d.unparseable), + }; +} + +function maxRowStatsCounter(rowStats: RowStatsCounter[]): RowStatsCounter { + return { + processed: max(rowStats, d => d.processed) ?? 0, + processedBytes: max(rowStats, d => d.processedBytes) ?? 0, + processedWithError: max(rowStats, d => d.processedWithError) ?? 0, + thrownAway: max(rowStats, d => d.thrownAway) ?? 0, + unparseable: max(rowStats, d => d.unparseable) ?? 0, + }; +} + +function getRowStatsCounter(rowStats: RowStats, key: RowStatsKey): RowStatsCounter | undefined { + if (key === 'totals') { + return deepGet(rowStats, 'totals.buildSegments'); + } else { + return deepGet(rowStats, `movingAverages.buildSegments.${key}`); + } +} + +export function getTotalSupervisorStats( + stats: SupervisorStats, + key: RowStatsKey, + activeTaskIds: string[] | undefined, +): RowStatsCounter { + return sumRowStatsCounter( + Object.values(stats).map(s => + maxRowStatsCounter( + filterMap(Object.entries(s), ([taskId, rs]) => + !activeTaskIds || activeTaskIds.includes(taskId) + ? getRowStatsCounter(rs, key) + : undefined, + ), + ), + ), + ); +} diff --git a/web-console/src/utils/druid-query.ts b/web-console/src/utils/druid-query.ts index c94bfca3d1cd..154103297044 100644 --- a/web-console/src/utils/druid-query.ts +++ b/web-console/src/utils/druid-query.ts @@ -17,7 +17,7 @@ */ import { C } from '@druid-toolkit/query'; -import type { AxiosResponse } from 'axios'; +import type { AxiosResponse, CancelToken } from 'axios'; import axios from 'axios'; import { Api } from '../singletons'; @@ -329,10 +329,13 @@ export async function queryDruidRune(runeQuery: Record): Promise(sqlQueryPayload: Record): Promise { +export async function queryDruidSql( + sqlQueryPayload: Record, + cancelToken?: CancelToken, +): Promise { let sqlResultResp: AxiosResponse; try { - sqlResultResp = await Api.instance.post('/druid/v2/sql', sqlQueryPayload); + sqlResultResp = await Api.instance.post('/druid/v2/sql', sqlQueryPayload, { cancelToken }); } catch (e) { throw new Error(getDruidErrorMessage(e)); } diff --git a/web-console/src/utils/general.tsx b/web-console/src/utils/general.tsx index 3a770c676307..b4537a63e08b 100644 --- a/web-console/src/utils/general.tsx +++ b/web-console/src/utils/general.tsx @@ -239,14 +239,26 @@ export function formatNumber(n: NumberLike): string { return n.toLocaleString('en-US', { maximumFractionDigits: 20 }); } +export function formatRate(n: NumberLike) { + return numeral(n).format('0,0.0') + '/s'; +} + export function formatBytes(n: NumberLike): string { return numeral(n).format('0.00 b'); } +export function formatByteRate(n: NumberLike): string { + return numeral(n).format('0.00 b') + '/s'; +} + export function formatBytesCompact(n: NumberLike): string { return numeral(n).format('0.00b'); } +export function formatByteRateCompact(n: NumberLike): string { + return numeral(n).format('0.00b') + '/s'; +} + export function formatMegabytes(n: NumberLike): string { return numeral(Number(n) / 1048576).format('0,0.0'); } diff --git a/web-console/src/utils/local-storage-backed-visibility.tsx b/web-console/src/utils/local-storage-backed-visibility.tsx index c335180056bb..f20031f2b8df 100644 --- a/web-console/src/utils/local-storage-backed-visibility.tsx +++ b/web-console/src/utils/local-storage-backed-visibility.tsx @@ -65,7 +65,7 @@ export class LocalStorageBackedVisibility { return new LocalStorageBackedVisibility(this.key, defaultHidden, newVisibility); } - public shown(value: string): boolean { - return this.visibility[value] ?? !this.defaultHidden.includes(value); + public shown(...values: string[]): boolean { + return values.some(value => this.visibility[value] ?? !this.defaultHidden.includes(value)); } } diff --git a/web-console/src/utils/table-helpers.ts b/web-console/src/utils/table-helpers.ts index e864aef131f0..7eedd1acaab9 100644 --- a/web-console/src/utils/table-helpers.ts +++ b/web-console/src/utils/table-helpers.ts @@ -17,6 +17,8 @@ */ import type { QueryResult } from '@druid-toolkit/query'; +import { C } from '@druid-toolkit/query'; +import type { Filter } from 'react-table'; import { filterMap, formatNumber, oneOf } from './general'; import { deepSet } from './object-change'; @@ -56,3 +58,20 @@ export function getNumericColumnBraces( return numericColumnBraces; } + +export interface Sorted { + id: string; + desc: boolean; +} + +export interface TableState { + page: number; + pageSize: number; + filtered: Filter[]; + sorted: Sorted[]; +} + +export function sortedToOrderByClause(sorted: Sorted[]): string | undefined { + if (!sorted.length) return; + return 'ORDER BY ' + sorted.map(sort => `${C(sort.id)} ${sort.desc ? 'DESC' : 'ASC'}`).join(', '); +} diff --git a/web-console/src/views/datasources-view/__snapshots__/datasources-view.spec.tsx.snap b/web-console/src/views/datasources-view/__snapshots__/datasources-view.spec.tsx.snap index 2cd926e01f97..b627b2e500ce 100644 --- a/web-console/src/views/datasources-view/__snapshots__/datasources-view.spec.tsx.snap +++ b/web-console/src/views/datasources-view/__snapshots__/datasources-view.spec.tsx.snap @@ -81,7 +81,6 @@ exports[`DatasourcesView matches snapshot 1`] = ` "% Compacted", "Left to be compacted", "Retention", - "Actions", ] } onChange={[Function]} @@ -338,7 +337,7 @@ exports[`DatasourcesView matches snapshot 1`] = ` "accessor": "datasource", "filterable": false, "id": "actions", - "show": true, + "sortable": false, "width": 70, }, ] diff --git a/web-console/src/views/datasources-view/datasources-view.tsx b/web-console/src/views/datasources-view/datasources-view.tsx index 75541b82999c..713df9b18b1c 100644 --- a/web-console/src/views/datasources-view/datasources-view.tsx +++ b/web-console/src/views/datasources-view/datasources-view.tsx @@ -102,7 +102,6 @@ const tableColumns: Record = { '% Compacted', 'Left to be compacted', 'Retention', - ACTION_COLUMN_LABEL, ], 'no-sql': [ 'Datasource name', @@ -114,7 +113,6 @@ const tableColumns: Record = { '% Compacted', 'Left to be compacted', 'Retention', - ACTION_COLUMN_LABEL, ], 'no-proxy': [ 'Datasource name', @@ -128,7 +126,6 @@ const tableColumns: Record = { 'Total rows', 'Avg. row size', 'Replicated size', - ACTION_COLUMN_LABEL, ], }; @@ -338,12 +335,11 @@ export class DatasourcesView extends React.PureComponent< const columns = compact( [ visibleColumns.shown('Datasource name') && `datasource`, - (visibleColumns.shown('Availability') || visibleColumns.shown('Segment granularity')) && [ + visibleColumns.shown('Availability', 'Segment granularity') && [ `COUNT(*) FILTER (WHERE is_active = 1) AS num_segments`, `COUNT(*) FILTER (WHERE is_published = 1 AND is_overshadowed = 0 AND replication_factor = 0) AS num_zero_replica_segments`, ], - (visibleColumns.shown('Availability') || - visibleColumns.shown('Historical load/drop queues')) && [ + visibleColumns.shown('Availability', 'Historical load/drop queues') && [ `COUNT(*) FILTER (WHERE is_published = 1 AND is_overshadowed = 0 AND is_available = 0 AND replication_factor > 0) AS num_segments_to_load`, `COUNT(*) FILTER (WHERE is_available = 1 AND is_active = 0) AS num_segments_to_drop`, ], @@ -1577,11 +1573,11 @@ GROUP BY 1, 2`; }, { Header: ACTION_COLUMN_LABEL, - show: visibleColumns.shown(ACTION_COLUMN_LABEL), accessor: 'datasource', id: ACTION_COLUMN_ID, width: ACTION_COLUMN_WIDTH, filterable: false, + sortable: false, Cell: ({ value: datasource, original }) => { const { unused, rules, compaction } = original as Datasource; const datasourceActions = this.getDatasourceActions( diff --git a/web-console/src/views/load-data-view/info-messages.tsx b/web-console/src/views/load-data-view/info-messages.tsx index b88cf8a70c2b..ad9e96667db5 100644 --- a/web-console/src/views/load-data-view/info-messages.tsx +++ b/web-console/src/views/load-data-view/info-messages.tsx @@ -16,7 +16,7 @@ * limitations under the License. */ -import { Button, Callout, Code, FormGroup, Intent } from '@blueprintjs/core'; +import { Button, Callout, Code, FormGroup, Intent, Tag } from '@blueprintjs/core'; import React from 'react'; import { ExternalLink, LearnMore } from '../../components'; @@ -236,8 +236,8 @@ export const AppendToExistingIssue = React.memo(function AppendToExistingIssue(

- Only dynamic partitioning supports appendToExisting: true. You - have currently selected {partitionsSpecType} partitioning. + Only dynamic partitioning supports appendToExisting: true. + You have currently selected {partitionsSpecType} partitioning.

@@ -252,7 +252,7 @@ exports[`SpecDialog matches snapshot with initSpec 1`] = `
diff --git a/web-console/src/setup-tests.ts b/web-console/src/setup-tests.ts index e75cb3bffb09..518045d6b6da 100644 --- a/web-console/src/setup-tests.ts +++ b/web-console/src/setup-tests.ts @@ -17,6 +17,7 @@ */ import 'core-js/stable'; +import './bootstrap/ace'; import { UrlBaser } from './singletons'; diff --git a/web-console/src/views/workbench-view/explain-dialog/__snapshots__/explain-dialog.spec.tsx.snap b/web-console/src/views/workbench-view/explain-dialog/__snapshots__/explain-dialog.spec.tsx.snap index bbbca4bf4d58..c0332ad0b015 100644 --- a/web-console/src/views/workbench-view/explain-dialog/__snapshots__/explain-dialog.spec.tsx.snap +++ b/web-console/src/views/workbench-view/explain-dialog/__snapshots__/explain-dialog.spec.tsx.snap @@ -122,7 +122,7 @@ exports[`ExplainDialog matches snapshot on some data (many queries) 1`] = ` enableLiveAutocompletion={false} enableSnippets={false} focus={false} - fontSize={13} + fontSize={12} height="100%" highlightActiveLine={true} maxLines={null} @@ -220,7 +220,7 @@ exports[`ExplainDialog matches snapshot on some data (many queries) 1`] = ` enableLiveAutocompletion={false} enableSnippets={false} focus={false} - fontSize={13} + fontSize={12} height="100%" highlightActiveLine={true} maxLines={null} @@ -348,7 +348,7 @@ exports[`ExplainDialog matches snapshot on some data (one query) 1`] = ` enableLiveAutocompletion={false} enableSnippets={false} focus={false} - fontSize={13} + fontSize={12} height="100%" highlightActiveLine={true} maxLines={null} diff --git a/web-console/src/views/workbench-view/explain-dialog/explain-dialog.tsx b/web-console/src/views/workbench-view/explain-dialog/explain-dialog.tsx index 3c535ed5449a..4bab7e7bfb05 100644 --- a/web-console/src/views/workbench-view/explain-dialog/explain-dialog.tsx +++ b/web-console/src/views/workbench-view/explain-dialog/explain-dialog.tsx @@ -131,7 +131,7 @@ export const ExplainDialog = React.memo(function ExplainDialog(props: ExplainDia theme="solarized_dark" className="query-string" name="ace-editor" - fontSize={13} + fontSize={12} width="100%" height="100%" showGutter diff --git a/web-console/src/views/workbench-view/flexible-query-input/__snapshots__/flexible-query-input.spec.tsx.snap b/web-console/src/views/workbench-view/flexible-query-input/__snapshots__/flexible-query-input.spec.tsx.snap index 0efa8f7f7a71..902b465fd23b 100644 --- a/web-console/src/views/workbench-view/flexible-query-input/__snapshots__/flexible-query-input.spec.tsx.snap +++ b/web-console/src/views/workbench-view/flexible-query-input/__snapshots__/flexible-query-input.spec.tsx.snap @@ -1,12 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`FlexibleQueryInput correctly formats helper HTML 1`] = ` -" -
COUNT
-
COUNT(*)
-
Counts the number of things
" -`; - exports[`FlexibleQueryInput matches snapshot 1`] = `