1import React, { Component } from 'react';
2
3import { Alert, Button, Col, Nav, NavItem, NavLink, Row, TabContent, TabPane } from 'reactstrap';
4
5import moment from 'moment-timezone';
6
7import ExpressionInput from './ExpressionInput';
8import CMExpressionInput from './CMExpressionInput';
9import GraphControls from './GraphControls';
10import { GraphTabContent } from './GraphTabContent';
11import DataTable from './DataTable';
12import TimeInput from './TimeInput';
13import QueryStatsView, { QueryStats } from './QueryStatsView';
14import { QueryParams } from '../../types/types';
15import { API_PATH } from '../../constants/constants';
16
17interface PanelProps {
18  options: PanelOptions;
19  onOptionsChanged: (opts: PanelOptions) => void;
20  useLocalTime: boolean;
21  pastQueries: string[];
22  metricNames: string[];
23  removePanel: () => void;
24  onExecuteQuery: (query: string) => void;
25  pathPrefix: string;
26  useExperimentalEditor: boolean;
27  enableAutocomplete: boolean;
28  enableHighlighting: boolean;
29  enableLinter: boolean;
30}
31
32interface PanelState {
33  data: any; // TODO: Type data.
34  lastQueryParams: QueryParams | null;
35  loading: boolean;
36  warnings: string[] | null;
37  error: string | null;
38  stats: QueryStats | null;
39  exprInputValue: string;
40}
41
42export interface PanelOptions {
43  expr: string;
44  type: PanelType;
45  range: number; // Range in milliseconds.
46  endTime: number | null; // Timestamp in milliseconds.
47  resolution: number | null; // Resolution in seconds.
48  stacked: boolean;
49}
50
51export enum PanelType {
52  Graph = 'graph',
53  Table = 'table',
54}
55
56export const PanelDefaultOptions: PanelOptions = {
57  type: PanelType.Table,
58  expr: '',
59  range: 60 * 60 * 1000,
60  endTime: null,
61  resolution: null,
62  stacked: false,
63};
64
65class Panel extends Component<PanelProps, PanelState> {
66  private abortInFlightFetch: (() => void) | null = null;
67
68  constructor(props: PanelProps) {
69    super(props);
70
71    this.state = {
72      data: null,
73      lastQueryParams: null,
74      loading: false,
75      warnings: null,
76      error: null,
77      stats: null,
78      exprInputValue: props.options.expr,
79    };
80  }
81
82  componentDidUpdate({ options: prevOpts }: PanelProps) {
83    const { endTime, range, resolution, type } = this.props.options;
84    if (
85      prevOpts.endTime !== endTime ||
86      prevOpts.range !== range ||
87      prevOpts.resolution !== resolution ||
88      prevOpts.type !== type
89    ) {
90      this.executeQuery();
91    }
92  }
93
94  componentDidMount() {
95    this.executeQuery();
96  }
97
98  executeQuery = (): void => {
99    const { exprInputValue: expr } = this.state;
100    const queryStart = Date.now();
101    this.props.onExecuteQuery(expr);
102    if (this.props.options.expr !== expr) {
103      this.setOptions({ expr });
104    }
105    if (expr === '') {
106      return;
107    }
108
109    if (this.abortInFlightFetch) {
110      this.abortInFlightFetch();
111      this.abortInFlightFetch = null;
112    }
113
114    const abortController = new AbortController();
115    this.abortInFlightFetch = () => abortController.abort();
116    this.setState({ loading: true });
117
118    const endTime = this.getEndTime().valueOf() / 1000; // TODO: shouldn't valueof only work when it's a moment?
119    const startTime = endTime - this.props.options.range / 1000;
120    const resolution = this.props.options.resolution || Math.max(Math.floor(this.props.options.range / 250000), 1);
121    const params: URLSearchParams = new URLSearchParams({
122      query: expr,
123    });
124
125    let path: string;
126    switch (this.props.options.type) {
127      case 'graph':
128        path = 'query_range';
129        params.append('start', startTime.toString());
130        params.append('end', endTime.toString());
131        params.append('step', resolution.toString());
132        break;
133      case 'table':
134        path = 'query';
135        params.append('time', endTime.toString());
136        break;
137      default:
138        throw new Error('Invalid panel type "' + this.props.options.type + '"');
139    }
140
141    fetch(`${this.props.pathPrefix}/${API_PATH}/${path}?${params}`, {
142      cache: 'no-store',
143      credentials: 'same-origin',
144      signal: abortController.signal,
145    })
146      .then(resp => resp.json())
147      .then(json => {
148        if (json.status !== 'success') {
149          throw new Error(json.error || 'invalid response JSON');
150        }
151
152        let resultSeries = 0;
153        if (json.data) {
154          const { resultType, result } = json.data;
155          if (resultType === 'scalar') {
156            resultSeries = 1;
157          } else if (result && result.length > 0) {
158            resultSeries = result.length;
159          }
160        }
161
162        this.setState({
163          error: null,
164          data: json.data,
165          warnings: json.warnings,
166          lastQueryParams: {
167            startTime,
168            endTime,
169            resolution,
170          },
171          stats: {
172            loadTime: Date.now() - queryStart,
173            resolution,
174            resultSeries,
175          },
176          loading: false,
177        });
178        this.abortInFlightFetch = null;
179      })
180      .catch(error => {
181        if (error.name === 'AbortError') {
182          // Aborts are expected, don't show an error for them.
183          return;
184        }
185        this.setState({
186          error: 'Error executing query: ' + error.message,
187          loading: false,
188        });
189      });
190  };
191
192  setOptions(opts: object): void {
193    const newOpts = { ...this.props.options, ...opts };
194    this.props.onOptionsChanged(newOpts);
195  }
196
197  handleExpressionChange = (expr: string): void => {
198    this.setState({ exprInputValue: expr });
199  };
200
201  handleChangeRange = (range: number): void => {
202    this.setOptions({ range: range });
203  };
204
205  getEndTime = (): number | moment.Moment => {
206    if (this.props.options.endTime === null) {
207      return moment();
208    }
209    return this.props.options.endTime;
210  };
211
212  handleChangeEndTime = (endTime: number | null) => {
213    this.setOptions({ endTime: endTime });
214  };
215
216  handleChangeResolution = (resolution: number | null) => {
217    this.setOptions({ resolution: resolution });
218  };
219
220  handleChangeType = (type: PanelType) => {
221    if (this.props.options.type === type) {
222      return;
223    }
224
225    this.setState({ data: null });
226    this.setOptions({ type: type });
227  };
228
229  handleChangeStacking = (stacked: boolean) => {
230    this.setOptions({ stacked: stacked });
231  };
232
233  render() {
234    const { pastQueries, metricNames, options } = this.props;
235    return (
236      <div className="panel">
237        <Row>
238          <Col>
239            {this.props.useExperimentalEditor ? (
240              <CMExpressionInput
241                value={this.state.exprInputValue}
242                onExpressionChange={this.handleExpressionChange}
243                executeQuery={this.executeQuery}
244                loading={this.state.loading}
245                enableAutocomplete={this.props.enableAutocomplete}
246                enableHighlighting={this.props.enableHighlighting}
247                enableLinter={this.props.enableLinter}
248                queryHistory={pastQueries}
249                metricNames={metricNames}
250              />
251            ) : (
252              <ExpressionInput
253                value={this.state.exprInputValue}
254                onExpressionChange={this.handleExpressionChange}
255                executeQuery={this.executeQuery}
256                loading={this.state.loading}
257                enableAutocomplete={this.props.enableAutocomplete}
258                queryHistory={pastQueries}
259                metricNames={metricNames}
260              />
261            )}
262          </Col>
263        </Row>
264        <Row>
265          <Col>{this.state.error && <Alert color="danger">{this.state.error}</Alert>}</Col>
266        </Row>
267        {this.state.warnings?.map((warning, index) => (
268          <Row key={index}>
269            <Col>{warning && <Alert color="warning">{warning}</Alert>}</Col>
270          </Row>
271        ))}
272        <Row>
273          <Col>
274            <Nav tabs>
275              <NavItem>
276                <NavLink
277                  className={options.type === 'table' ? 'active' : ''}
278                  onClick={() => this.handleChangeType(PanelType.Table)}
279                >
280                  Table
281                </NavLink>
282              </NavItem>
283              <NavItem>
284                <NavLink
285                  className={options.type === 'graph' ? 'active' : ''}
286                  onClick={() => this.handleChangeType(PanelType.Graph)}
287                >
288                  Graph
289                </NavLink>
290              </NavItem>
291              {!this.state.loading && !this.state.error && this.state.stats && <QueryStatsView {...this.state.stats} />}
292            </Nav>
293            <TabContent activeTab={options.type}>
294              <TabPane tabId="table">
295                {options.type === 'table' && (
296                  <>
297                    <div className="table-controls">
298                      <TimeInput
299                        time={options.endTime}
300                        useLocalTime={this.props.useLocalTime}
301                        range={options.range}
302                        placeholder="Evaluation time"
303                        onChangeTime={this.handleChangeEndTime}
304                      />
305                    </div>
306                    <DataTable data={this.state.data} />
307                  </>
308                )}
309              </TabPane>
310              <TabPane tabId="graph">
311                {this.props.options.type === 'graph' && (
312                  <>
313                    <GraphControls
314                      range={options.range}
315                      endTime={options.endTime}
316                      useLocalTime={this.props.useLocalTime}
317                      resolution={options.resolution}
318                      stacked={options.stacked}
319                      onChangeRange={this.handleChangeRange}
320                      onChangeEndTime={this.handleChangeEndTime}
321                      onChangeResolution={this.handleChangeResolution}
322                      onChangeStacking={this.handleChangeStacking}
323                    />
324                    <GraphTabContent
325                      data={this.state.data}
326                      stacked={options.stacked}
327                      useLocalTime={this.props.useLocalTime}
328                      lastQueryParams={this.state.lastQueryParams}
329                    />
330                  </>
331                )}
332              </TabPane>
333            </TabContent>
334          </Col>
335        </Row>
336        <Row>
337          <Col>
338            <Button className="float-right" color="link" onClick={this.props.removePanel} size="sm">
339              Remove Panel
340            </Button>
341          </Col>
342        </Row>
343      </div>
344    );
345  }
346}
347
348export default Panel;
349