github.com/thanos-io/thanos@v0.32.5/pkg/ui/react-app/src/pages/graph/Panel.tsx (about) 1 import React, { Component } from 'react'; 2 3 import { 4 UncontrolledAlert, 5 Alert, 6 Button, 7 Col, 8 Nav, 9 NavItem, 10 NavLink, 11 Row, 12 TabContent, 13 TabPane, 14 Input, 15 Label, 16 } from 'reactstrap'; 17 import Select from 'react-select'; 18 19 import moment from 'moment-timezone'; 20 21 import Checkbox from '../../components/Checkbox'; 22 import ListTree, { QueryTree } from '../../components/ListTree'; 23 import ExpressionInput from './ExpressionInput'; 24 import GraphControls from './GraphControls'; 25 import { GraphTabContent } from './GraphTabContent'; 26 import DataTable from './DataTable'; 27 import TimeInput from './TimeInput'; 28 import QueryStatsView, { QueryStats } from './QueryStatsView'; 29 import { Store } from '../../thanos/pages/stores/store'; 30 import PathPrefixProps from '../../types/PathPrefixProps'; 31 import { QueryParams } from '../../types/types'; 32 import { parseDuration } from '../../utils'; 33 34 export interface PanelProps { 35 id: string; 36 options: PanelOptions; 37 onOptionsChanged: (opts: PanelOptions) => void; 38 useLocalTime: boolean; 39 pastQueries: string[]; 40 metricNames: string[]; 41 removePanel: () => void; 42 onExecuteQuery: (query: string) => void; 43 stores: Store[]; 44 enableAutocomplete: boolean; 45 enableHighlighting: boolean; 46 enableLinter: boolean; 47 defaultStep: string; 48 defaultEngine: string; 49 } 50 51 interface PanelState { 52 data: any; // TODO: Type data. 53 lastQueryParams: QueryParams | null; 54 loading: boolean; 55 error: string | null; 56 warnings: string[] | null; 57 stats: QueryStats | null; 58 exprInputValue: string; 59 explanation: QueryTree | null; 60 } 61 62 export interface PanelOptions { 63 expr: string; 64 type: PanelType; 65 range: number; // Range in milliseconds. 66 endTime: number | null; // Timestamp in milliseconds. 67 resolution: number | null; // Resolution in seconds. 68 stacked: boolean; 69 maxSourceResolution: string; 70 useDeduplication: boolean; 71 usePartialResponse: boolean; 72 storeMatches: Store[]; 73 engine: string; 74 explain: boolean; 75 disableExplainCheckbox: boolean; 76 } 77 78 export enum PanelType { 79 Graph = 'graph', 80 Table = 'table', 81 } 82 83 export const PanelDefaultOptions: PanelOptions = { 84 type: PanelType.Table, 85 expr: '', 86 range: 60 * 60 * 1000, 87 endTime: null, 88 resolution: null, 89 stacked: false, 90 maxSourceResolution: '0s', 91 useDeduplication: true, 92 usePartialResponse: false, 93 storeMatches: [], 94 engine: '', 95 explain: false, 96 disableExplainCheckbox: false, 97 }; 98 99 class Panel extends Component<PanelProps & PathPrefixProps, PanelState> { 100 private abortInFlightFetch: (() => void) | null = null; 101 102 constructor(props: PanelProps) { 103 super(props); 104 105 this.state = { 106 data: null, 107 lastQueryParams: null, 108 loading: false, 109 warnings: null, 110 error: null, 111 stats: null, 112 exprInputValue: props.options.expr, 113 explanation: null, 114 }; 115 116 if (this.props.options.engine === '') { 117 this.props.options.engine = this.props.defaultEngine; 118 } 119 this.handleEngine(this.props.options.engine); 120 121 this.handleChangeDeduplication = this.handleChangeDeduplication.bind(this); 122 this.handleChangePartialResponse = this.handleChangePartialResponse.bind(this); 123 this.handleStoreMatchChange = this.handleStoreMatchChange.bind(this); 124 this.handleChangeEngine = this.handleChangeEngine.bind(this); 125 this.handleChangeExplain = this.handleChangeExplain.bind(this); 126 } 127 128 componentDidUpdate({ options: prevOpts }: PanelProps): void { 129 const { 130 endTime, 131 range, 132 resolution, 133 type, 134 maxSourceResolution, 135 useDeduplication, 136 usePartialResponse, 137 engine, 138 explain, 139 // TODO: Add support for Store Matches 140 } = this.props.options; 141 if ( 142 prevOpts.endTime !== endTime || 143 prevOpts.range !== range || 144 prevOpts.resolution !== resolution || 145 prevOpts.type !== type || 146 prevOpts.maxSourceResolution !== maxSourceResolution || 147 prevOpts.useDeduplication !== useDeduplication || 148 prevOpts.usePartialResponse !== usePartialResponse || 149 prevOpts.engine !== engine || 150 prevOpts.explain !== explain 151 // Check store matches 152 ) { 153 this.executeQuery(); 154 } 155 } 156 157 componentDidMount(): void { 158 this.executeQuery(); 159 } 160 161 executeQuery = (): void => { 162 const { exprInputValue: expr } = this.state; 163 const queryStart = Date.now(); 164 this.props.onExecuteQuery(expr); 165 if (this.props.options.expr !== expr) { 166 this.setOptions({ expr }); 167 } 168 if (expr === '') { 169 return; 170 } 171 172 if (this.abortInFlightFetch) { 173 this.abortInFlightFetch(); 174 this.abortInFlightFetch = null; 175 } 176 177 const abortController = new AbortController(); 178 this.abortInFlightFetch = () => abortController.abort(); 179 this.setState({ loading: true }); 180 181 const endTime = this.getEndTime().valueOf() / 1000; // TODO: shouldn't valueof only work when it's a moment? 182 const startTime = endTime - this.props.options.range / 1000; 183 const resolution = 184 this.props.options.resolution || 185 Math.max(Math.floor(this.props.options.range / 250000), (parseDuration(this.props.defaultStep) || 0) / 1000); 186 const params: URLSearchParams = new URLSearchParams({ 187 query: expr, 188 dedup: this.props.options.useDeduplication.toString(), 189 partial_response: this.props.options.usePartialResponse.toString(), 190 }); 191 192 // Add storeMatches to query params. 193 // eslint-disable-next-line @typescript-eslint/no-unused-expressions 194 this.props.options.storeMatches?.forEach((store: Store) => 195 params.append('storeMatch[]', `{__address__="${store.name}"}`) 196 ); 197 198 let path: string; 199 switch (this.props.options.type) { 200 case 'graph': 201 path = '/api/v1/query_range'; 202 params.append('start', startTime.toString()); 203 params.append('end', endTime.toString()); 204 params.append('step', resolution.toString()); 205 params.append('max_source_resolution', this.props.options.maxSourceResolution); 206 params.append('engine', this.props.options.engine); 207 params.append('explain', this.props.options.explain.toString()); 208 // TODO path prefix here and elsewhere. 209 break; 210 case 'table': 211 path = '/api/v1/query'; 212 params.append('time', endTime.toString()); 213 params.append('engine', this.props.options.engine); 214 params.append('explain', this.props.options.explain.toString()); 215 break; 216 default: 217 throw new Error('Invalid panel type "' + this.props.options.type + '"'); 218 } 219 220 fetch(`${this.props.pathPrefix}${path}?${params}`, { 221 cache: 'no-store', 222 credentials: 'same-origin', 223 signal: abortController.signal, 224 }) 225 .then((resp) => resp.json()) 226 .then((json) => { 227 if (json.status !== 'success') { 228 throw new Error(json.error || 'invalid response JSON'); 229 } 230 231 let resultSeries = 0; 232 let explanation = null; 233 if (json.data) { 234 const { resultType, result } = json.data; 235 if (resultType === 'scalar') { 236 resultSeries = 1; 237 } else if (result && result.length > 0) { 238 resultSeries = result.length; 239 } 240 explanation = json.data.explanation; 241 } 242 243 this.setState({ 244 error: null, 245 data: json.data, 246 lastQueryParams: { 247 startTime, 248 endTime, 249 resolution, 250 }, 251 warnings: json.warnings, 252 stats: { 253 loadTime: Date.now() - queryStart, 254 resolution, 255 resultSeries, 256 }, 257 loading: false, 258 explanation: explanation, 259 }); 260 this.abortInFlightFetch = null; 261 }) 262 .catch((error) => { 263 if (error.name === 'AbortError') { 264 // Aborts are expected, don't show an error for them. 265 return; 266 } 267 this.setState({ 268 error: 'Error executing query: ' + error.message, 269 loading: false, 270 }); 271 }); 272 }; 273 274 setOptions(opts: any): void { 275 const newOpts = { ...this.props.options, ...opts }; 276 this.props.onOptionsChanged(newOpts); 277 } 278 279 handleExpressionChange = (expr: string): void => { 280 this.setState({ exprInputValue: expr }); 281 }; 282 283 handleChangeRange = (range: number): void => { 284 this.setOptions({ range: range }); 285 }; 286 287 getEndTime = (): number | moment.Moment => { 288 if (this.props.options.endTime === null) { 289 return moment(); 290 } 291 return this.props.options.endTime; 292 }; 293 294 handleChangeEndTime = (endTime: number | null): void => { 295 this.setOptions({ endTime: endTime }); 296 }; 297 298 handleChangeResolution = (resolution: number | null): void => { 299 this.setOptions({ resolution: resolution }); 300 }; 301 302 handleChangeMaxSourceResolution = (maxSourceResolution: string): void => { 303 this.setOptions({ maxSourceResolution }); 304 }; 305 306 handleChangeType = (type: PanelType): void => { 307 this.setState({ data: null }); 308 this.setOptions({ type: type }); 309 }; 310 311 handleChangeStacking = (stacked: boolean): void => { 312 this.setOptions({ stacked: stacked }); 313 }; 314 315 handleChangeDeduplication = (event: React.ChangeEvent<HTMLInputElement>): void => { 316 this.setOptions({ useDeduplication: event.target.checked }); 317 }; 318 319 handleChangePartialResponse = (event: React.ChangeEvent<HTMLInputElement>): void => { 320 this.setOptions({ usePartialResponse: event.target.checked }); 321 }; 322 323 handleStoreMatchChange = (selectedStores: any): void => { 324 this.setOptions({ storeMatches: selectedStores || [] }); 325 }; 326 327 handleToggleAlert = (): void => { 328 this.setState({ error: null }); 329 }; 330 331 handleToggleWarn = (): void => { 332 this.setState({ warnings: null }); 333 }; 334 335 handleChangeEngine = (event: React.ChangeEvent<HTMLInputElement>): void => { 336 this.handleEngine(event.target.value); 337 }; 338 339 handleChangeExplain = (event: React.ChangeEvent<HTMLInputElement>): void => { 340 this.setOptions({ explain: event.target.checked }); 341 }; 342 343 handleEngine = (engine: string): void => { 344 if (engine === 'prometheus') { 345 this.setOptions({ engine: engine, explain: false, disableExplainCheckbox: true }); 346 } else { 347 this.setOptions({ engine: engine, disableExplainCheckbox: false }); 348 } 349 }; 350 351 render(): JSX.Element { 352 const { pastQueries, metricNames, options, id, stores } = this.props; 353 return ( 354 <div className="panel"> 355 <Row> 356 <Col> 357 <ExpressionInput 358 pathPrefix={this.props.pathPrefix} 359 value={this.state.exprInputValue} 360 onExpressionChange={this.handleExpressionChange} 361 executeQuery={this.executeQuery} 362 loading={this.state.loading} 363 enableAutocomplete={this.props.enableAutocomplete} 364 enableHighlighting={this.props.enableHighlighting} 365 enableLinter={this.props.enableLinter} 366 queryHistory={pastQueries} 367 metricNames={metricNames} 368 /> 369 </Col> 370 </Row> 371 <Row> 372 <Col> 373 <UncontrolledAlert isOpen={this.state.error || false} toggle={this.handleToggleAlert} color="danger"> 374 {this.state.error} 375 </UncontrolledAlert> 376 </Col> 377 </Row> 378 <Row> 379 <Col> 380 <UncontrolledAlert 381 isOpen={this.state.warnings || false} 382 toggle={this.handleToggleWarn} 383 color="info" 384 style={{ whiteSpace: 'break-spaces' }} 385 > 386 {this.state.warnings} 387 </UncontrolledAlert> 388 </Col> 389 </Row> 390 <Row> 391 <Col> 392 <div className="float-left"> 393 <Checkbox 394 wrapperStyles={{ marginLeft: 20, display: 'inline-block' }} 395 id={`use-deduplication-checkbox-${id}`} 396 onChange={this.handleChangeDeduplication} 397 defaultChecked={options.useDeduplication} 398 > 399 Use Deduplication 400 </Checkbox> 401 <Checkbox 402 wrapperStyles={{ marginLeft: 20, display: 'inline-block' }} 403 id={`use-partial-resp-checkbox-${id}`} 404 onChange={this.handleChangePartialResponse} 405 defaultChecked={options.usePartialResponse} 406 > 407 Use Partial Response 408 </Checkbox> 409 <Label 410 style={{ marginLeft: '10px', display: 'inline-block' }} 411 for={`select-engine=${id}`} 412 className="control-label" 413 > 414 Engine 415 </Label> 416 <Input 417 style={{ 418 width: 'auto', 419 marginLeft: '10px', 420 display: 'inline-block', 421 }} 422 id={`select-engine=${id}`} 423 type="select" 424 value={options.engine} 425 onChange={this.handleChangeEngine} 426 bsSize="sm" 427 > 428 <option value="prometheus">Prometheus</option> 429 <option value="thanos">Thanos</option> 430 </Input> 431 </div> 432 <div className="float-right"> 433 <Checkbox 434 wrapperStyles={{ marginRight: 20, display: 'inline-block' }} 435 id={`explain-${id}`} 436 onChange={this.handleChangeExplain} 437 checked={options.explain} 438 disabled={options.disableExplainCheckbox} 439 > 440 Explain 441 </Checkbox> 442 </div> 443 </Col> 444 </Row> 445 <Row hidden={!(options.explain && this.state.explanation)}> 446 <Col> 447 <Alert color="info" style={{ overflowX: 'auto', whiteSpace: 'nowrap', width: '100%' }}> 448 <ListTree id={`explain-tree-${id}`} node={this.state.explanation} /> 449 </Alert> 450 </Col> 451 </Row> 452 {stores?.length > 0 && ( 453 <Row> 454 <Col> 455 <div className="store-filter-wrapper"> 456 <label className="store-filter-label">Store Filter:</label> 457 <Select 458 defaultValue={options.storeMatches} 459 options={stores} 460 isMulti 461 getOptionLabel={(option: Store) => option.name} 462 getOptionValue={(option: Store) => option.name} 463 closeMenuOnSelect={false} 464 styles={{ 465 container: (provided, state) => ({ 466 ...provided, 467 marginBottom: 20, 468 zIndex: 3, 469 width: '100%', 470 color: '#000', 471 }), 472 }} 473 onChange={this.handleStoreMatchChange} 474 /> 475 </div> 476 </Col> 477 </Row> 478 )} 479 <Row> 480 <Col> 481 <Nav tabs> 482 <NavItem> 483 <NavLink 484 className={options.type === 'table' ? 'active' : ''} 485 onClick={() => this.handleChangeType(PanelType.Table)} 486 > 487 Table 488 </NavLink> 489 </NavItem> 490 <NavItem> 491 <NavLink 492 className={options.type === 'graph' ? 'active' : ''} 493 onClick={() => this.handleChangeType(PanelType.Graph)} 494 > 495 Graph 496 </NavLink> 497 </NavItem> 498 {!this.state.loading && !this.state.error && this.state.stats && <QueryStatsView {...this.state.stats} />} 499 </Nav> 500 <TabContent activeTab={options.type}> 501 <TabPane tabId="table"> 502 {options.type === 'table' && ( 503 <> 504 <div className="table-controls"> 505 <TimeInput 506 time={options.endTime} 507 useLocalTime={this.props.useLocalTime} 508 range={options.range} 509 placeholder="Evaluation time" 510 onChangeTime={this.handleChangeEndTime} 511 /> 512 </div> 513 <DataTable data={this.state.data} /> 514 </> 515 )} 516 </TabPane> 517 <TabPane tabId="graph"> 518 {this.props.options.type === 'graph' && ( 519 <> 520 <GraphControls 521 range={options.range} 522 endTime={options.endTime} 523 useLocalTime={this.props.useLocalTime} 524 resolution={options.resolution} 525 stacked={options.stacked} 526 maxSourceResolution={options.maxSourceResolution} 527 onChangeRange={this.handleChangeRange} 528 onChangeEndTime={this.handleChangeEndTime} 529 onChangeResolution={this.handleChangeResolution} 530 onChangeStacking={this.handleChangeStacking} 531 onChangeMaxSourceResolution={this.handleChangeMaxSourceResolution} 532 /> 533 <GraphTabContent 534 data={this.state.data} 535 stacked={options.stacked} 536 useLocalTime={this.props.useLocalTime} 537 lastQueryParams={this.state.lastQueryParams} 538 /> 539 </> 540 )} 541 </TabPane> 542 </TabContent> 543 </Col> 544 </Row> 545 <Row> 546 <Col> 547 <Button className="float-right" color="link" onClick={this.props.removePanel} size="sm"> 548 Remove Panel 549 </Button> 550 </Col> 551 </Row> 552 </div> 553 ); 554 } 555 } 556 557 export default Panel;