github.com/cockroachdb/cockroach@v20.2.0-alpha.1+incompatible/pkg/ui/src/views/statements/statementsPage.tsx (about) 1 // Copyright 2018 The Cockroach Authors. 2 // 3 // Use of this software is governed by the Business Source License 4 // included in the file licenses/BSL.txt. 5 // 6 // As of the Change Date specified in that file, in accordance with 7 // the Business Source License, use of this software will be governed 8 // by the Apache License, Version 2.0, included in the file 9 // licenses/APL.txt. 10 11 import { Icon, Pagination } from "antd"; 12 import { isNil, merge, forIn } from "lodash"; 13 import moment from "moment"; 14 import { DATE_FORMAT } from "src/util/format"; 15 import React from "react"; 16 import Helmet from "react-helmet"; 17 import { connect } from "react-redux"; 18 import { createSelector } from "reselect"; 19 import { RouteComponentProps, withRouter } from "react-router-dom"; 20 import { paginationPageCount } from "src/components/pagination/pagination"; 21 import * as protos from "src/js/protos"; 22 import { refreshStatementDiagnosticsRequests, refreshStatements } from "src/redux/apiReducers"; 23 import { CachedDataReducerState } from "src/redux/cachedDataReducer"; 24 import { AdminUIState } from "src/redux/state"; 25 import { StatementsResponseMessage } from "src/util/api"; 26 import { aggregateStatementStats, combineStatementStats, ExecutionStatistics, flattenStatementStats, StatementStatistics } from "src/util/appStats"; 27 import { appAttr } from "src/util/constants"; 28 import { TimestampToMoment } from "src/util/convert"; 29 import { Pick } from "src/util/pick"; 30 import { PrintTime } from "src/views/reports/containers/range/print"; 31 import Dropdown, { DropdownOption } from "src/views/shared/components/dropdown"; 32 import Loading from "src/views/shared/components/loading"; 33 import { PageConfig, PageConfigItem } from "src/views/shared/components/pageconfig"; 34 import { SortSetting } from "src/views/shared/components/sortabletable"; 35 import { Search } from "../app/components/Search"; 36 import { AggregateStatistics, makeStatementsColumns, StatementsSortedTable } from "./statementsTable"; 37 import ActivateDiagnosticsModal, { ActivateDiagnosticsModalRef } from "src/views/statements/diagnostics/activateDiagnosticsModal"; 38 import "./statements.styl"; 39 import { 40 selectLastDiagnosticsReportPerStatement, 41 } from "src/redux/statements/statementsSelectors"; 42 import { createStatementDiagnosticsAlertLocalSetting } from "src/redux/alerts"; 43 import { getMatchParamByName } from "src/util/query"; 44 import { trackPaginate, trackSearch } from "src/util/analytics"; 45 46 import "./statements.styl"; 47 import { ISortedTablePagination } from "../shared/components/sortedtable"; 48 import { statementsTable } from "src/util/docs"; 49 50 type ICollectedStatementStatistics = protos.cockroach.server.serverpb.StatementsResponse.ICollectedStatementStatistics; 51 52 interface OwnProps { 53 statements: AggregateStatistics[]; 54 statementsError: Error | null; 55 apps: string[]; 56 totalFingerprints: number; 57 lastReset: string; 58 refreshStatements: typeof refreshStatements; 59 refreshStatementDiagnosticsRequests: typeof refreshStatementDiagnosticsRequests; 60 dismissAlertMessage: () => void; 61 } 62 63 export interface StatementsPageState { 64 sortSetting: SortSetting; 65 search?: string; 66 pagination: ISortedTablePagination; 67 } 68 69 export type StatementsPageProps = OwnProps & RouteComponentProps<any>; 70 71 export class StatementsPage extends React.Component<StatementsPageProps, StatementsPageState> { 72 activateDiagnosticsRef: React.RefObject<ActivateDiagnosticsModalRef>; 73 74 constructor(props: StatementsPageProps) { 75 super(props); 76 const defaultState = { 77 sortSetting: { 78 sortKey: 3, // Sort by Execution Count column as default option 79 ascending: false, 80 }, 81 pagination: { 82 pageSize: 20, 83 current: 1, 84 }, 85 search: "", 86 }; 87 88 const stateFromHistory = this.getStateFromHistory(); 89 this.state = merge(defaultState, stateFromHistory); 90 this.activateDiagnosticsRef = React.createRef(); 91 } 92 93 getStateFromHistory = (): Partial<StatementsPageState> => { 94 const { history } = this.props; 95 const searchParams = new URLSearchParams(history.location.search); 96 const sortKey = searchParams.get("sortKey") || undefined; 97 const ascending = searchParams.get("ascending") || undefined; 98 const searchQuery = searchParams.get("q") || undefined; 99 100 return { 101 sortSetting: { 102 sortKey, 103 ascending: Boolean(ascending), 104 }, 105 search: searchQuery, 106 }; 107 } 108 109 syncHistory = (params: Record<string, string | undefined>) => { 110 const { history } = this.props; 111 const currentSearchParams = new URLSearchParams(history.location.search); 112 // const nextSearchParams = new URLSearchParams(params); 113 114 forIn(params, (value, key) => { 115 if (!value) { 116 currentSearchParams.delete(key); 117 } else { 118 currentSearchParams.set(key, value); 119 } 120 }); 121 122 history.location.search = currentSearchParams.toString(); 123 history.replace(history.location); 124 } 125 126 changeSortSetting = (ss: SortSetting) => { 127 this.setState({ 128 sortSetting: ss, 129 }); 130 131 this.syncHistory({ 132 "sortKey": ss.sortKey, 133 "ascending": Boolean(ss.ascending).toString(), 134 }); 135 } 136 137 selectApp = (app: DropdownOption) => { 138 const { history } = this.props; 139 history.location.pathname = `/statements/${app.value}`; 140 history.replace(history.location); 141 this.resetPagination(); 142 } 143 144 resetPagination = () => { 145 this.setState((prevState) => { 146 return { 147 pagination: { 148 current: 1, 149 pageSize: prevState.pagination.pageSize, 150 }, 151 }; 152 }); 153 } 154 155 componentDidMount() { 156 this.props.refreshStatements(); 157 this.props.refreshStatementDiagnosticsRequests(); 158 } 159 160 componentDidUpdate = (__: StatementsPageProps, prevState: StatementsPageState) => { 161 if (this.state.search && this.state.search !== prevState.search) { 162 trackSearch(this.filteredStatementsData().length); 163 } 164 this.props.refreshStatements(); 165 this.props.refreshStatementDiagnosticsRequests(); 166 } 167 168 componentWillUnmount() { 169 this.props.dismissAlertMessage(); 170 } 171 172 onChangePage = (current: number) => { 173 const { pagination } = this.state; 174 this.setState({ pagination: { ...pagination, current } }); 175 trackPaginate(current); 176 } 177 onSubmitSearchField = (search: string) => { 178 this.setState({ search }); 179 this.resetPagination(); 180 this.syncHistory({ 181 "q": search, 182 }); 183 } 184 185 onClearSearchField = () => { 186 this.setState({ search: "" }); 187 this.syncHistory({ 188 "q": undefined, 189 }); 190 } 191 192 filteredStatementsData = () => { 193 const { search } = this.state; 194 const { statements } = this.props; 195 return statements.filter(statement => search.split(" ").every(val => statement.label.toLowerCase().includes(val.toLowerCase()))); 196 } 197 198 renderPage = (_page: number, type: "page" | "prev" | "next" | "jump-prev" | "jump-next", originalElement: React.ReactNode) => { 199 switch (type) { 200 case "jump-prev": 201 return ( 202 <div className="_pg-jump"> 203 <Icon type="left" /> 204 <span className="_jump-dots">•••</span> 205 </div> 206 ); 207 case "jump-next": 208 return ( 209 <div className="_pg-jump"> 210 <Icon type="right" /> 211 <span className="_jump-dots">•••</span> 212 </div> 213 ); 214 default: 215 return originalElement; 216 } 217 } 218 219 renderLastCleared = () => { 220 const { lastReset } = this.props; 221 return `Last cleared ${moment.utc(lastReset).format(DATE_FORMAT)}`; 222 } 223 224 noStatementResult = () => ( 225 <> 226 <h3 className="table__no-results--title">There are no SQL statements that match your search or filter since this page was last cleared.</h3> 227 <p className="table__no-results--description"> 228 <span>Statements are cleared every hour by default, or according to your configuration.</span> 229 <a href={statementsTable} target="_blank">Learn more</a> 230 </p> 231 </> 232 ) 233 234 renderStatements = () => { 235 const { pagination, search } = this.state; 236 const { statements, match } = this.props; 237 const appAttrValue = getMatchParamByName(match, appAttr); 238 const selectedApp = appAttrValue || ""; 239 const appOptions = [{ value: "", label: "All" }]; 240 this.props.apps.forEach(app => appOptions.push({ value: app, label: app })); 241 const data = this.filteredStatementsData(); 242 return ( 243 <div> 244 <PageConfig> 245 <PageConfigItem> 246 <Search 247 onSubmit={this.onSubmitSearchField as any} 248 onClear={this.onClearSearchField} 249 defaultValue={search} 250 /> 251 </PageConfigItem> 252 <PageConfigItem> 253 <Dropdown 254 title="App" 255 options={appOptions} 256 selected={selectedApp} 257 onChange={this.selectApp} 258 /> 259 </PageConfigItem> 260 </PageConfig> 261 <section className="cl-table-container"> 262 <div className="cl-table-statistic"> 263 <h4 className="cl-count-title"> 264 {paginationPageCount({ ...pagination, total: this.filteredStatementsData().length }, "statements", match, appAttr, search)} 265 </h4> 266 <h4 className="last-cleared-title"> 267 {this.renderLastCleared()} 268 </h4> 269 </div> 270 <StatementsSortedTable 271 className="statements-table" 272 data={data} 273 columns={ 274 makeStatementsColumns( 275 statements, 276 selectedApp, 277 search, 278 this.activateDiagnosticsRef, 279 ) 280 } 281 empty={data.length === 0 && search.length === 0} 282 emptyProps={{ 283 title: "There are no statements since this page was last cleared.", 284 description: "Statements help you identify frequently executed or high latency SQL statements. Statements are cleared every hour by default, or according to your configuration.", 285 label: "Learn more", 286 onClick: () => window.open(statementsTable), 287 }} 288 sortSetting={this.state.sortSetting} 289 onChangeSortSetting={this.changeSortSetting} 290 renderNoResult={this.noStatementResult()} 291 pagination={pagination} 292 /> 293 </section> 294 <Pagination 295 size="small" 296 itemRender={this.renderPage as (page: number, type: "page" | "prev" | "next" | "jump-prev" | "jump-next") => React.ReactNode} 297 pageSize={pagination.pageSize} 298 current={pagination.current} 299 total={data.length} 300 onChange={this.onChangePage} 301 hideOnSinglePage 302 /> 303 </div> 304 ); 305 } 306 307 render() { 308 const { match } = this.props; 309 const app = getMatchParamByName(match, appAttr); 310 return ( 311 <React.Fragment> 312 <Helmet title={app ? `${app} App | Statements` : "Statements"} /> 313 314 <section className="section"> 315 <h1 className="base-heading">Statements</h1> 316 </section> 317 318 <Loading 319 loading={isNil(this.props.statements)} 320 error={this.props.statementsError} 321 render={this.renderStatements} 322 /> 323 <ActivateDiagnosticsModal ref={this.activateDiagnosticsRef} /> 324 </React.Fragment> 325 ); 326 } 327 } 328 329 type StatementsState = Pick<AdminUIState, "cachedData", "statements">; 330 331 interface StatementsSummaryData { 332 statement: string; 333 implicitTxn: boolean; 334 stats: StatementStatistics[]; 335 } 336 337 function keyByStatementAndImplicitTxn(stmt: ExecutionStatistics): string { 338 return stmt.statement + stmt.implicit_txn; 339 } 340 341 // selectStatements returns the array of AggregateStatistics to show on the 342 // StatementsPage, based on if the appAttr route parameter is set. 343 export const selectStatements = createSelector( 344 (state: StatementsState) => state.cachedData.statements, 345 (_state: StatementsState, props: RouteComponentProps) => props, 346 selectLastDiagnosticsReportPerStatement, 347 ( 348 state: CachedDataReducerState<StatementsResponseMessage>, 349 props: RouteComponentProps<any>, 350 lastDiagnosticsReportPerStatement, 351 ) => { 352 if (!state.data) { 353 return null; 354 } 355 let statements = flattenStatementStats(state.data.statements); 356 const app = getMatchParamByName(props.match, appAttr); 357 const isInternal = (statement: ExecutionStatistics) => statement.app.startsWith(state.data.internal_app_name_prefix); 358 359 if (app) { 360 let criteria = app; 361 let showInternal = false; 362 if (criteria === "(unset)") { 363 criteria = ""; 364 } else if (criteria === "(internal)") { 365 showInternal = true; 366 } 367 368 statements = statements.filter( 369 (statement: ExecutionStatistics) => (showInternal && isInternal(statement)) || statement.app === criteria, 370 ); 371 } else { 372 statements = statements.filter((statement: ExecutionStatistics) => !isInternal(statement)); 373 } 374 375 const statsByStatementAndImplicitTxn: { [statement: string]: StatementsSummaryData } = {}; 376 statements.forEach(stmt => { 377 const key = keyByStatementAndImplicitTxn(stmt); 378 if (!(key in statsByStatementAndImplicitTxn)) { 379 statsByStatementAndImplicitTxn[key] = { 380 statement: stmt.statement, 381 implicitTxn: stmt.implicit_txn, 382 stats: [], 383 }; 384 } 385 statsByStatementAndImplicitTxn[key].stats.push(stmt.stats); 386 }); 387 388 return Object.keys(statsByStatementAndImplicitTxn).map(key => { 389 const stmt = statsByStatementAndImplicitTxn[key]; 390 return { 391 label: stmt.statement, 392 implicitTxn: stmt.implicitTxn, 393 stats: combineStatementStats(stmt.stats), 394 diagnosticsReport: lastDiagnosticsReportPerStatement[stmt.statement], 395 }; 396 }); 397 }, 398 ); 399 400 // selectApps returns the array of all apps with statement statistics present 401 // in the data. 402 export const selectApps = createSelector( 403 (state: StatementsState) => state.cachedData.statements, 404 (state: CachedDataReducerState<StatementsResponseMessage>) => { 405 if (!state.data) { 406 return []; 407 } 408 409 let sawBlank = false; 410 let sawInternal = false; 411 const apps: { [app: string]: boolean } = {}; 412 state.data.statements.forEach( 413 (statement: ICollectedStatementStatistics) => { 414 if (state.data.internal_app_name_prefix && statement.key.key_data.app.startsWith(state.data.internal_app_name_prefix)) { 415 sawInternal = true; 416 } else if (statement.key.key_data.app) { 417 apps[statement.key.key_data.app] = true; 418 } else { 419 sawBlank = true; 420 } 421 }, 422 ); 423 return [].concat(sawInternal ? ["(internal)"] : []).concat(sawBlank ? ["(unset)"] : []).concat(Object.keys(apps)); 424 }, 425 ); 426 427 // selectTotalFingerprints returns the count of distinct statement fingerprints 428 // present in the data. 429 export const selectTotalFingerprints = createSelector( 430 (state: StatementsState) => state.cachedData.statements, 431 (state: CachedDataReducerState<StatementsResponseMessage>) => { 432 if (!state.data) { 433 return 0; 434 } 435 const aggregated = aggregateStatementStats(state.data.statements); 436 return aggregated.length; 437 }, 438 ); 439 440 // selectLastReset returns a string displaying the last time the statement 441 // statistics were reset. 442 export const selectLastReset = createSelector( 443 (state: StatementsState) => state.cachedData.statements, 444 (state: CachedDataReducerState<StatementsResponseMessage>) => { 445 if (!state.data) { 446 return "unknown"; 447 } 448 449 return PrintTime(TimestampToMoment(state.data.last_reset)); 450 }, 451 ); 452 453 // tslint:disable-next-line:variable-name 454 const StatementsPageConnected = withRouter(connect( 455 (state: AdminUIState, props: RouteComponentProps) => ({ 456 statements: selectStatements(state, props), 457 statementsError: state.cachedData.statements.lastError, 458 apps: selectApps(state), 459 totalFingerprints: selectTotalFingerprints(state), 460 lastReset: selectLastReset(state), 461 }), 462 { 463 refreshStatements, 464 refreshStatementDiagnosticsRequests, 465 dismissAlertMessage: () => createStatementDiagnosticsAlertLocalSetting.set({ show: false }), 466 }, 467 )(StatementsPage)); 468 469 export default StatementsPageConnected;