Read about our upcoming Code of Conduct on this issue

time_series.vue 10.4 KB
Newer Older
1
<script>
2
import { s__, __ } from '~/locale';
3
import _ from 'underscore';
4
import { GlLink, GlButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/ui';
5
6
import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
7
import { roundOffFloat } from '~/lib/utils/common_utils';
8
9
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import Icon from '~/vue_shared/components/icon.vue';
10
11
12
13
14
15
16
17
import {
  chartHeight,
  graphTypes,
  lineTypes,
  lineWidths,
  symbolSizes,
  dateFormats,
} from '../../constants';
18
19
20
21
22
23
24
import { makeDataSeries } from '~/helpers/monitor_helper';
import { graphDataValidatorForValues } from '../../utils';

export default {
  components: {
    GlAreaChart,
    GlLineChart,
25
    GlTooltip,
26
27
28
29
30
    GlButton,
    GlChartSeriesLabel,
    GlLink,
    Icon,
  },
31
32
33
  directives: {
    GlResizeObserverDirective,
  },
34
35
36
37
38
39
40
  inheritAttrs: false,
  props: {
    graphData: {
      type: Object,
      required: true,
      validator: graphDataValidatorForValues.bind(null, false),
    },
41
42
43
44
45
46
47
48
49
50
    option: {
      type: Object,
      required: false,
      default: () => ({}),
    },
    seriesConfig: {
      type: Object,
      required: false,
      default: () => ({}),
    },
51
52
53
54
55
56
57
58
59
60
    deploymentData: {
      type: Array,
      required: false,
      default: () => [],
    },
    projectPath: {
      type: String,
      required: false,
      default: '',
    },
61
62
63
64
65
    singleEmbed: {
      type: Boolean,
      required: false,
      default: false,
    },
66
67
68
69
70
    thresholds: {
      type: Array,
      required: false,
      default: () => [],
    },
71
72
73
74
75
76
77
78
79
80
    legendAverageText: {
      type: String,
      required: false,
      default: s__('Metrics|Avg'),
    },
    legendMaxText: {
      type: String,
      required: false,
      default: s__('Metrics|Max'),
    },
81
82
83
84
85
    groupId: {
      type: String,
      required: false,
      default: '',
    },
86
87
88
89
90
91
92
93
94
95
  },
  data() {
    return {
      tooltip: {
        title: '',
        content: [],
        commitUrl: '',
        isDeployment: false,
        sha: '',
      },
96
      showTitleTooltip: false,
97
98
99
100
101
102
103
104
105
106
107
      width: 0,
      height: chartHeight,
      svgs: {},
      primaryColor: null,
    };
  },
  computed: {
    chartData() {
      // Transforms & supplements query data to render appropriate labels & styles
      // Input: [{ queryAttributes1 }, { queryAttributes2 }]
      // Output: [{ seriesAttributes1 }, { seriesAttributes2 }]
108
      return this.graphData.metrics.reduce((acc, query) => {
109
110
111
112
113
114
115
116
        const { appearance } = query;
        const lineType =
          appearance && appearance.line && appearance.line.type
            ? appearance.line.type
            : lineTypes.default;
        const lineWidth =
          appearance && appearance.line && appearance.line.width
            ? appearance.line.width
117
            : lineWidths.default;
118
119
120
121
122
123
        const areaStyle = {
          opacity:
            appearance && appearance.area && typeof appearance.area.opacity === 'number'
              ? appearance.area.opacity
              : undefined,
        };
124
        const series = makeDataSeries(query.result || [], {
125
126
127
128
          name: this.formatLegendLabel(query),
          lineStyle: {
            type: lineType,
            width: lineWidth,
129
            color: this.primaryColor,
130
131
132
          },
          showSymbol: false,
          areaStyle: this.graphData.type === 'area-chart' ? areaStyle : undefined,
133
          ...this.seriesConfig,
134
135
136
137
138
        });

        return acc.concat(series);
      }, []);
    },
139
140
141
    chartOptionSeries() {
      return (this.option.series || []).concat(this.scatterSeries ? [this.scatterSeries] : []);
    },
142
    chartOptions() {
143
      const option = _.omit(this.option, 'series');
144
      return {
145
        series: this.chartOptionSeries,
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
        xAxis: {
          name: __('Time'),
          type: 'time',
          axisLabel: {
            formatter: date => dateFormat(date, dateFormats.timeOfDay),
          },
          axisPointer: {
            snap: true,
          },
        },
        yAxis: {
          name: this.yAxisLabel,
          axisLabel: {
            formatter: num => roundOffFloat(num, 3).toString(),
          },
        },
162
        dataZoom: [this.dataZoomConfig],
163
        ...option,
164
165
166
167
168
169
170
      };
    },
    dataZoomConfig() {
      const handleIcon = this.svgs['scroll-handle'];

      return handleIcon ? { handleIcon } : {};
    },
171
172
173
174
175
176
177
178
    /**
     * This method returns the earliest time value in all series of a chart.
     * Takes a chart data with data to populate a timeseries.
     * data should be an array of data points [t, y] where t is a ISO formatted date,
     * and is sorted by t (time).
     * @returns {(String|null)} earliest x value from all series, or null when the
     * chart series data is empty.
     */
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
    earliestDatapoint() {
      return this.chartData.reduce((acc, series) => {
        const { data } = series;
        const { length } = data;
        if (!length) {
          return acc;
        }

        const [first] = data[0];
        const [last] = data[length - 1];
        const seriesEarliest = first < last ? first : last;

        return seriesEarliest < acc || acc === null ? seriesEarliest : acc;
      }, null);
    },
    glChartComponent() {
      const chartTypes = {
        'area-chart': GlAreaChart,
        'line-chart': GlLineChart,
      };
      return chartTypes[this.graphData.type] || GlAreaChart;
    },
    isMultiSeries() {
      return this.tooltip.content.length > 1;
    },
    recentDeployments() {
      return this.deploymentData.reduce((acc, deployment) => {
        if (deployment.created_at >= this.earliestDatapoint) {
          const { id, created_at, sha, ref, tag } = deployment;
          acc.push({
            id,
            createdAt: created_at,
            sha,
            commitUrl: `${this.projectPath}/commit/${sha}`,
            tag,
            tagUrl: tag ? `${this.tagsPath}/${ref.name}` : null,
            ref: ref.name,
            showDeploymentFlag: false,
          });
        }

        return acc;
      }, []);
    },
    scatterSeries() {
      return {
        type: graphTypes.deploymentData,
        data: this.recentDeployments.map(deployment => [deployment.createdAt, 0]),
        symbol: this.svgs.rocket,
        symbolSize: symbolSizes.default,
        itemStyle: {
          color: this.primaryColor,
        },
      };
    },
    yAxisLabel() {
      return `${this.graphData.y_label}`;
    },
  },
238
239
240
241
242
243
  mounted() {
    const graphTitleEl = this.$refs.graphTitle;
    if (graphTitleEl && graphTitleEl.scrollWidth > graphTitleEl.offsetWidth) {
      this.showTitleTooltip = true;
    }
  },
244
245
246
247
248
249
250
251
252
253
254
255
  created() {
    this.setSvg('rocket');
    this.setSvg('scroll-handle');
  },
  methods: {
    formatLegendLabel(query) {
      return `${query.label}`;
    },
    formatTooltipText(params) {
      this.tooltip.title = dateFormat(params.value, dateFormats.default);
      this.tooltip.content = [];
      params.seriesData.forEach(dataPoint => {
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
        if (dataPoint.value) {
          const [xVal, yVal] = dataPoint.value;
          this.tooltip.isDeployment = dataPoint.componentSubType === graphTypes.deploymentData;
          if (this.tooltip.isDeployment) {
            const [deploy] = this.recentDeployments.filter(
              deployment => deployment.createdAt === xVal,
            );
            this.tooltip.sha = deploy.sha.substring(0, 8);
            this.tooltip.commitUrl = deploy.commitUrl;
          } else {
            const { seriesName, color, dataIndex } = dataPoint;
            const value = yVal.toFixed(3);
            this.tooltip.content.push({
              name: seriesName,
              dataIndex,
              value,
              color,
            });
          }
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
        }
      });
    },
    setSvg(name) {
      getSvgIconPathContent(name)
        .then(path => {
          if (path) {
            this.$set(this.svgs, name, `path://${path}`);
          }
        })
        .catch(e => {
          // eslint-disable-next-line no-console, @gitlab/i18n/no-non-i18n-strings
          console.error('SVG could not be rendered correctly: ', e);
        });
    },
    onChartUpdated(chart) {
      [this.primaryColor] = chart.getOption().color;
    },
    onResize() {
      if (!this.$refs.chart) return;
      const { width } = this.$refs.chart.$el.getBoundingClientRect();
      this.width = width;
    },
  },
};
</script>

<template>
303
  <div v-gl-resize-observer-directive="onResize" class="prometheus-graph">
304
    <div class="prometheus-graph-header">
305
306
307
308
309
310
311
312
313
314
      <h5
        ref="graphTitle"
        class="prometheus-graph-title js-graph-title text-truncate append-right-8"
      >
        {{ graphData.title }}
      </h5>
      <gl-tooltip :target="() => $refs.graphTitle" :disabled="!showTitleTooltip">
        {{ graphData.title }}
      </gl-tooltip>
      <div class="prometheus-graph-widgets js-graph-widgets flex-fill">
315
        <slot></slot>
316
      </div>
317
318
319
320
321
    </div>
    <component
      :is="glChartComponent"
      ref="chart"
      v-bind="$attrs"
322
      :group-id="groupId"
323
324
325
326
327
328
      :data="chartData"
      :option="chartOptions"
      :format-tooltip-text="formatTooltipText"
      :thresholds="thresholds"
      :width="width"
      :height="height"
329
330
      :average-text="legendAverageText"
      :max-text="legendMaxText"
331
332
333
334
335
336
337
338
339
340
341
342
343
      @updated="onChartUpdated"
    >
      <template v-if="tooltip.isDeployment">
        <template slot="tooltipTitle">
          {{ __('Deployed') }}
        </template>
        <div slot="tooltipContent" class="d-flex align-items-center">
          <icon name="commit" class="mr-2" />
          <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link>
        </div>
      </template>
      <template v-else>
        <template slot="tooltipTitle">
344
345
346
347
348
          <slot name="tooltipTitle">
            <div class="text-nowrap">
              {{ tooltip.title }}
            </div>
          </slot>
349
        </template>
350
        <template slot="tooltipContent">
351
352
353
354
355
356
357
358
359
360
361
362
          <slot name="tooltipContent" :tooltip="tooltip">
            <div
              v-for="(content, key) in tooltip.content"
              :key="key"
              class="d-flex justify-content-between"
            >
              <gl-chart-series-label :color="isMultiSeries ? content.color : ''">
                {{ content.name }}
              </gl-chart-series-label>
              <div class="prepend-left-32">
                {{ content.value }}
              </div>
363
            </div>
364
          </slot>
365
        </template>
366
367
      </template>
    </component>
368
369
  </div>
</template>