Line data Source code
1 : /*
2 : * Copyright (C) 2014 Red Hat, Inc.
3 : * SPDX-License-Identifier: LGPL-2.1-or-later
4 : */
5 :
6 1 : import cockpit from 'cockpit';
7 :
8 : /* The public API here is not general and a bit weird. It can only do
9 : * what Cockpit itself needs right now, and what can be easily
10 : * implemented without touching the older Metrics_series classes.
11 : *
12 : * The basic idea is that you create a global PlotState object and
13 : * keep it alive for the lifetime of the page. Then you instantiate
14 : * ZoomControl and SvgPlot components as needed.
15 : *
16 : * To control what to plot, you call some methods on the PlotState
17 : * object:
18 : *
19 : * - plot_state.plot_single(id, metric_options)
20 : *
21 : * - plot_state.plot_instances(id, metric_options, instances, reset)
22 : *
23 : * You need to figure out the rest of the details from the existing
24 : * users, unfortunately.
25 : */
26 :
27 1 : class Metrics_series {
28 1 : constructor(desc, opts, grid, plot_data, interval) {
29 1 : cockpit.event_target(this);
30 1 : this.desc = desc;
31 1 : this.options = opts;
32 1 : this.grid = grid;
33 1 : this.plot_data = plot_data;
34 1 : this.interval = interval;
35 1 : this.channel = null;
36 1 : this.chanopts_list = [];
37 1 : }
38 :
39 0 : stop() {
40 0 : if (this.channel)
41 0 : this.channel.close();
42 0 : }
43 :
44 0 : remove_series() {
45 0 : const pos = this.plot_data.indexOf(this.options);
46 0 : if (pos >= 0)
47 0 : this.plot_data.splice(pos, 1);
48 0 : }
49 :
50 0 : remove() {
51 0 : this.stop();
52 0 : this.remove_series();
53 0 : this.dispatchEvent("removed");
54 0 : }
55 :
56 1 : build_metric(n) {
57 1 : return { name: n, units: this.desc.units, derive: this.desc.derive };
58 1 : }
59 :
60 1 : check_archives() {
61 1 : if (this.channel.archives)
62 0 : this.dispatchEvent("changed");
63 1 : }
64 1 : }
65 :
66 1 : class Metrics_sum_series extends Metrics_series {
67 0 : constructor(desc, opts, grid, plot_data, interval) {
68 0 : super(desc, opts, grid, plot_data, interval);
69 0 : if (this.desc.direct) {
70 0 : this.chanopts_list.push({
71 0 : source: 'direct',
72 0 : archive_source: 'pcp-archive',
73 0 : metrics: this.desc.direct.map(this.build_metric, this),
74 0 : instances: this.desc.instances,
75 0 : 'omit-instances': this.desc['omit-instances'],
76 0 : host: this.desc.host
77 0 : });
78 0 : }
79 0 : if (this.desc.pmcd) {
80 0 : this.chanopts_list.push({
81 0 : source: 'pmcd',
82 0 : metrics: this.desc.pmcd.map(this.build_metric, this),
83 0 : instances: this.desc.instances,
84 0 : 'omit-instances': this.desc['omit-instances'],
85 0 : host: this.desc.host
86 0 : });
87 0 : }
88 0 : if (this.desc.internal) {
89 0 : this.chanopts_list.push({
90 0 : source: 'internal',
91 0 : metrics: this.desc.internal.map(this.build_metric, this),
92 0 : instances: this.desc.instances,
93 0 : 'omit-instances': this.desc['omit-instances'],
94 0 : host: this.desc.host
95 0 : });
96 0 : }
97 0 : }
98 :
99 0 : flat_sum(val) {
100 0 : if (!val)
101 0 : return 0;
102 0 : if (val.length !== undefined) {
103 0 : let sum = 0;
104 0 : for (let i = 0; i < val.length; i++)
105 0 : sum += this.flat_sum(val[i]);
106 0 : return sum;
107 0 : }
108 0 : return val;
109 0 : }
110 :
111 0 : reset_series() {
112 0 : if (this.channel)
113 0 : this.channel.close();
114 :
115 0 : this.channel = cockpit.metrics(this.interval, this.chanopts_list);
116 :
117 0 : const metrics_row = this.grid.add(this.channel, []);
118 0 : const factor = this.desc.factor || 1;
119 0 : const threshold = this.desc.threshold || null;
120 0 : const offset = this.desc.offset || 0;
121 0 : this.options.data = this.grid.add((row, x, n) => {
122 0 : for (let i = 0; i < n; i++) {
123 0 : const value = offset + this.flat_sum(metrics_row[x + i]) * factor;
124 0 : if (threshold !== null)
125 0 : row[x + i] = [(this.grid.beg + x + i) * this.interval, Math.abs(value) > threshold ? value : null, threshold];
126 : else
127 0 : row[x + i] = [(this.grid.beg + x + i) * this.interval, value];
128 0 : }
129 0 : });
130 :
131 0 : this.channel.addEventListener("changed", this.check_archives.bind(this));
132 0 : this.check_archives();
133 0 : }
134 1 : }
135 :
136 1 : class Metrics_stacked_instances_series extends Metrics_series {
137 1 : constructor(desc, opts, grid, plot_data, interval) {
138 1 : super(desc, opts, grid, plot_data, interval);
139 1 : this.instances = { };
140 1 : this.last_instance = null;
141 1 : if (this.desc.direct) {
142 1 : this.chanopts_list.push({
143 1 : source: 'direct',
144 1 : archive_source: 'pcp-archive',
145 1 : metrics: [this.build_metric(this.desc.direct)],
146 1 : metrics_path_names: ['a'],
147 1 : instances: this.desc.instances,
148 1 : 'omit-instances': this.desc['omit-instances'],
149 1 : host: this.desc.host
150 1 : });
151 1 : }
152 0 : if (this.desc.pmcd) {
153 0 : this.chanopts_list.push({
154 0 : source: 'pmcd',
155 0 : metrics: this.desc.pmcd.map(this.build_metric, this),
156 0 : metrics_path_names: ['a'],
157 0 : instances: this.desc.instances,
158 0 : 'omit-instances': this.desc['omit-instances'],
159 0 : host: this.desc.host
160 0 : });
161 0 : }
162 :
163 1 : if (this.desc.internal) {
164 1 : this.chanopts_list.push({
165 1 : source: 'internal',
166 1 : metrics: [this.build_metric(this.desc.internal)],
167 1 : metrics_path_names: ['a'],
168 1 : instances: this.desc.instances,
169 1 : 'omit-instances': this.desc['omit-instances'],
170 1 : host: this.desc.host
171 1 : });
172 1 : }
173 1 : }
174 :
175 1 : reset_series() {
176 1 : if (this.channel)
177 0 : this.channel.close();
178 1 : this.channel = cockpit.metrics(this.interval, this.chanopts_list);
179 1 : this.channel.addEventListener("changed", this.check_archives.bind(this));
180 1 : this.check_archives();
181 1 : for (const name in this.instances)
182 0 : this.instances[name].reset();
183 1 : }
184 :
185 1 : add_instance(name, selector) {
186 1 : if (this.instances[name])
187 1 : return;
188 :
189 1 : const instance_data = Object.assign({ selector }, this.options);
190 0 : const factor = this.desc.factor || 1;
191 1 : const threshold = this.desc.threshold || 0;
192 1 : const last = this.last_instance;
193 1 : let metrics_row;
194 :
195 1 : function reset() {
196 1 : metrics_row = this.grid.add(this.channel, ['a', name]);
197 1 : instance_data.data = this.grid.add((row, x, n) => {
198 1 : for (let i = 0; i < n; i++) {
199 1 : const value = (metrics_row[x + i] || 0) * factor;
200 1 : const ts = (this.grid.beg + x + i) * this.interval;
201 1 : let floor = 0;
202 :
203 0 : if (last) {
204 0 : if (last.data[x + i][1])
205 0 : floor = last.data[x + i][1];
206 : else
207 0 : floor = last.data[x + i][2];
208 0 : }
209 :
210 0 : if (Math.abs(value) > threshold) {
211 0 : row[x + i] = [ts, floor + value, floor];
212 0 : if (row[x + i - 1] && row[x + i - 1][1] === null)
213 0 : row[x + i - 1][1] = row[x + i - 1][2];
214 0 : } else {
215 1 : row[x + i] = [ts, null, floor];
216 1 : if (row[x + i - 1] && row[x + i - 1][1] !== null)
217 0 : row[x + i - 1][1] = row[x + i - 1][2];
218 1 : }
219 1 : }
220 1 : });
221 1 : }
222 :
223 1 : function remove() {
224 1 : this.grid.remove(metrics_row);
225 1 : this.grid.remove(instance_data.data);
226 1 : const pos = this.plot_data.indexOf(instance_data);
227 1 : if (pos >= 0)
228 1 : this.plot_data.splice(pos, 1);
229 1 : }
230 :
231 1 : instance_data.reset = reset.bind(this);
232 1 : instance_data.remove = remove.bind(this);
233 1 : instance_data.name = name;
234 1 : this.last_instance = instance_data;
235 1 : this.instances[name] = instance_data;
236 1 : instance_data.reset();
237 1 : this.plot_data.push(instance_data);
238 1 : this.grid.sync();
239 1 : }
240 :
241 1 : clear_instances() {
242 1 : for (const i in this.instances)
243 1 : this.instances[i].remove();
244 1 : this.instances = { };
245 1 : this.last_instance = null;
246 1 : }
247 1 : }
248 :
249 1 : class Plot {
250 1 : constructor(element, x_range_seconds, x_stop_seconds) {
251 1 : cockpit.event_target(this);
252 :
253 1 : this.series = [];
254 1 : this.plot_data = [];
255 :
256 1 : this.interval = Math.ceil(x_range_seconds / 1000) * 1000;
257 1 : this.grid = null;
258 :
259 1 : this.sync_suppressed = 0;
260 1 : this.archives = false;
261 :
262 1 : this.reset(x_range_seconds, x_stop_seconds);
263 1 : }
264 :
265 1 : refresh() {
266 1 : this.dispatchEvent("plot", this.plot_data);
267 1 : }
268 :
269 1 : start_walking() {
270 1 : this.grid.walk();
271 1 : }
272 :
273 0 : stop_walking() {
274 0 : this.grid.move(this.grid.beg, this.grid.end);
275 0 : }
276 :
277 1 : reset(x_range_seconds, x_stop_seconds) {
278 : // Fill the plot with about 1000 samples, but don't sample
279 : // faster than once per second.
280 : //
281 : // TODO - do this based on the actual size of the plot.
282 1 : this.interval = Math.ceil(x_range_seconds / 1000) * 1000;
283 :
284 1 : const x_offset = (x_stop_seconds !== undefined)
285 0 : ? (new Date().getTime()) - x_stop_seconds * 1000
286 1 : : 0;
287 :
288 1 : const beg = -Math.ceil((x_range_seconds * 1000 + x_offset) / this.interval);
289 1 : const end = -Math.floor(x_offset / this.interval);
290 :
291 0 : if (this.grid && this.grid.interval == this.interval) {
292 0 : this.grid.move(beg, end);
293 0 : } else {
294 1 : if (this.grid)
295 0 : this.grid.close();
296 1 : this.grid = cockpit.grid(this.interval, beg, end);
297 1 : this.sync_suppressed++;
298 0 : for (let i = 0; i < this.series.length; i++) {
299 0 : this.series[i].stop();
300 0 : this.series[i].interval = this.interval;
301 0 : this.series[i].grid = this.grid;
302 0 : this.series[i].reset_series();
303 0 : }
304 1 : this.sync_suppressed--;
305 1 : this.sync();
306 :
307 1 : this.grid.addEventListener("notify", (event, index, count) => {
308 1 : this.refresh();
309 1 : });
310 1 : }
311 1 : }
312 :
313 1 : sync() {
314 1 : if (this.sync_suppressed === 0)
315 1 : this.grid.sync();
316 1 : }
317 :
318 0 : destroy() {
319 0 : this.grid.close();
320 0 : for (let i = 0; i < this.series.length; i++)
321 0 : this.series[i].stop();
322 :
323 0 : this.options = { };
324 0 : this.series = [];
325 0 : this.plot_data = [];
326 0 : }
327 :
328 0 : check_archives() {
329 0 : if (!this.archives) {
330 0 : this.archives = true;
331 0 : this.dispatchEvent('changed');
332 0 : }
333 0 : }
334 :
335 0 : add_metrics_sum_series(desc, opts) {
336 0 : const sum_series = new Metrics_sum_series(desc, opts, this.grid, this.plot_data, this.interval);
337 :
338 0 : sum_series.addEventListener("removed", this.refresh.bind(this));
339 0 : sum_series.addEventListener("changed", this.check_archives.bind(this));
340 0 : sum_series.reset_series();
341 0 : sum_series.check_archives();
342 :
343 0 : this.series.push(sum_series);
344 0 : this.sync();
345 0 : this.plot_data.push(opts);
346 :
347 0 : return sum_series;
348 0 : }
349 :
350 1 : add_metrics_stacked_instances_series(desc, opts) {
351 1 : const stacked_series = new Metrics_stacked_instances_series(desc, opts, this.grid, this.plot_data, this.interval);
352 :
353 1 : stacked_series.addEventListener("removed", this.refresh.bind(this));
354 1 : stacked_series.addEventListener("changed", this.check_archives.bind(this));
355 1 : stacked_series.reset_series();
356 1 : stacked_series.check_archives();
357 :
358 1 : this.series.push(stacked_series);
359 1 : this.sync_suppressed++;
360 1 : for (const name in stacked_series.instances)
361 0 : stacked_series.instances[name].reset();
362 1 : this.sync_suppressed--;
363 1 : this.sync();
364 :
365 1 : return stacked_series;
366 1 : }
367 1 : }
368 :
369 1 : class ZoomState {
370 0 : constructor(reset_callback) {
371 0 : cockpit.event_target(this);
372 :
373 0 : this.reset_callback = reset_callback;
374 :
375 0 : this.x_range = 5 * 60;
376 0 : this.x_stop = undefined;
377 0 : this.history = [];
378 :
379 0 : this.enable_zoom_in = false;
380 0 : this.enable_zoom_out = true;
381 0 : this.enable_scroll_left = true;
382 0 : this.enable_scroll_right = false;
383 0 : }
384 :
385 0 : reset() {
386 0 : const plot_min_x_range = 5 * 60;
387 :
388 0 : if (this.x_range < plot_min_x_range) {
389 0 : this.x_stop += (plot_min_x_range - this.x_range) / 2;
390 0 : this.x_range = plot_min_x_range;
391 0 : }
392 0 : if (this.x_stop >= (new Date()).getTime() / 1000 - 10)
393 0 : this.x_stop = undefined;
394 :
395 0 : this.reset_callback(this.x_range, this.x_stop);
396 :
397 0 : this.enable_zoom_in = (this.x_range > plot_min_x_range);
398 0 : this.enable_scroll_right = (this.x_stop !== undefined);
399 :
400 0 : this.dispatchEvent("changed");
401 0 : }
402 :
403 0 : set_range(x_range) {
404 0 : this.history = [];
405 0 : this.x_range = x_range;
406 0 : this.reset();
407 0 : }
408 :
409 0 : zoom_in(x_range, x_stop) {
410 0 : this.history.push(this.x_range);
411 0 : this.x_range = x_range;
412 0 : this.x_stop = x_stop;
413 0 : this.reset();
414 0 : }
415 :
416 0 : zoom_out() {
417 0 : const plot_zoom_steps = [
418 0 : 5 * 60,
419 0 : 60 * 60,
420 0 : 6 * 60 * 60,
421 0 : 24 * 60 * 60,
422 0 : 7 * 24 * 60 * 60,
423 0 : 30 * 24 * 60 * 60,
424 0 : 365 * 24 * 60 * 60
425 0 : ];
426 :
427 0 : let r = this.history.pop();
428 0 : if (r === undefined) {
429 0 : let i;
430 0 : for (i = 0; i < plot_zoom_steps.length - 1; i++) {
431 0 : if (plot_zoom_steps[i] > this.x_range)
432 0 : break;
433 0 : }
434 0 : r = plot_zoom_steps[i];
435 0 : }
436 0 : if (this.x_stop !== undefined)
437 0 : this.x_stop += (r - this.x_range) / 2;
438 0 : this.x_range = r;
439 0 : this.reset();
440 0 : }
441 :
442 0 : goto_now() {
443 0 : this.x_stop = undefined;
444 0 : this.reset();
445 0 : }
446 :
447 0 : scroll_left() {
448 0 : const step = this.x_range / 10;
449 0 : if (this.x_stop === undefined)
450 0 : this.x_stop = (new Date()).getTime() / 1000;
451 0 : this.x_stop -= step;
452 0 : this.reset();
453 0 : }
454 :
455 0 : scroll_right() {
456 0 : const step = this.x_range / 10;
457 0 : if (this.x_stop !== undefined) {
458 0 : this.x_stop += step;
459 0 : this.reset();
460 0 : }
461 0 : }
462 1 : }
463 :
464 1 : class SinglePlotState {
465 1 : constructor() {
466 1 : this._plot = new Plot(null, 300);
467 1 : this._plot.start_walking();
468 1 : }
469 :
470 0 : plot_single(metric) {
471 0 : if (this._stacked_instances_series) {
472 0 : this._stacked_instances_series.clear_instances();
473 0 : this._stacked_instances_series.remove();
474 0 : this._stacked_instances_series = null;
475 0 : }
476 0 : if (!this._sum_series) {
477 0 : this._sum_series = this._plot.add_metrics_sum_series(metric, { });
478 0 : }
479 0 : }
480 :
481 1 : plot_instances(metric, insts, reset) {
482 0 : if (this._sum_series) {
483 0 : this._sum_series.remove();
484 0 : this._sum_series = null;
485 0 : }
486 1 : if (!this._stacked_instances_series) {
487 1 : this._stacked_instances_series = this._plot.add_metrics_stacked_instances_series(metric, { });
488 1 : } else if (reset) {
489 : // We can't remove individual instances, only clear the
490 : // whole thing (because of limitations of Metrics_stacked_instances_series above).
491 : // So we do that, but only when there is at least one instance
492 : // that needs to be removed. That avoids a lot of events and React warnings.
493 1 : if (Object.keys(this._stacked_instances_series.instances).some(old => insts.indexOf(old) == -1))
494 1 : this._stacked_instances_series.clear_instances();
495 1 : }
496 :
497 1 : for (let i = 0; i < insts.length; i++) {
498 1 : this._stacked_instances_series.add_instance(insts[i]);
499 1 : }
500 1 : }
501 :
502 0 : destroy() {
503 0 : this._plot.destroy();
504 0 : }
505 1 : }
506 :
507 1 : export class PlotState {
508 1 : constructor() {
509 1 : cockpit.event_target(this);
510 1 : this.plots = { };
511 1 : this.zoom_state = null;
512 1 : }
513 :
514 0 : _reset_plots(x_range, x_stop) {
515 0 : for (const id in this.plots) {
516 0 : const p = this.plots[id]._plot;
517 0 : p.stop_walking();
518 0 : p.reset(x_range, x_stop);
519 0 : p.refresh();
520 0 : if (x_stop === undefined)
521 0 : p.start_walking();
522 0 : }
523 0 : }
524 :
525 1 : _check_archives(plot) {
526 0 : if (!this.zoom_state && plot.archives) {
527 0 : this.zoom_state = new ZoomState(this._reset_plots.bind(this));
528 0 : this.dispatchEvent("changed");
529 0 : }
530 1 : }
531 :
532 1 : _get(id) {
533 1 : if (this.plots[id])
534 1 : return this.plots[id];
535 :
536 1 : const ps = new SinglePlotState();
537 0 : ps._plot.addEventListener("changed", () => this._check_archives(ps._plot));
538 1 : this._check_archives(ps._plot);
539 1 : ps._plot.addEventListener("plot", (event, data) => {
540 1 : ps.data = data;
541 1 : this.dispatchEvent("plot:" + id);
542 1 : });
543 :
544 1 : this.plots[id] = ps;
545 1 : return ps;
546 1 : }
547 :
548 0 : plot_single(id, metric) {
549 0 : const ps = this._get(id);
550 0 : ps.plot_single(metric);
551 0 : }
552 :
553 1 : plot_instances(id, metric, insts, reset) {
554 1 : this._get(id).plot_instances(metric, insts, reset);
555 1 : }
556 :
557 1 : data(id) {
558 1 : return this.plots[id] && this.plots[id].data;
559 1 : }
560 1 : }
|