cockpit/pkg/kubernetes/scripts/graphs.js

864 lines
37 KiB
JavaScript

/*
* This file is part of Cockpit.
*
* Copyright (C) 2015 Red Hat, Inc.
*
* Cockpit is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation; either version 2.1 of the License, or
* (at your option) any later version.
*
* Cockpit is distributed in the hope that it will be useful, but
* WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
*/
(function() {
var angular = require('angular');
var d3 = require('d3');
require('./kube-client');
require('./kube-client-cockpit');
require('./utils');
angular.module('kubernetes.graph', [
'kubeClient',
'kubeClient.cockpit',
'kubeUtils',
])
.factory('CAdvisorSeries', [
"KubeRequest",
"CockpitMetrics",
"$exceptionHandler",
"$timeout",
function (KubeRequest, CockpitMetrics, $exceptionHandler, $timeout) {
function CAdvisor(node) {
var self = this;
/* called when containers changed */
var callbacks = [];
/* cAdvisor has 10 second intervals */
var interval = 10000;
var last = { };
var requests = { };
/* Holds the container specs */
self.specs = { };
function feed(containers) {
var x, y, ylen, i, len;
var item, offset, timestamp, container, stat;
/*
* The cAdvisor data doesn't seem to have inherent guarantees of
* continuity or regularity. In theory each stats object can have
* it's own arbitrary timestamp ... although in practice they do
* generally follow the interval to within a few milliseconds.
*
* So we first look for the lowest timestamp, treat that as our
* base index, and then batch the data based on that.
*/
var first = null;
for (x in containers) {
container = containers[x];
if (container.stats) {
len = container.stats.length;
for (i = 0; i < len; i++) {
timestamp = container.stats[i].timestamp;
if (timestamp) {
if (first === null || timestamp < first)
first = timestamp;
}
}
}
}
if (first === null)
return;
var base = Math.floor(new Date(first).getTime() / interval);
var items = [];
var name;
var mapping = { };
var new_ids = [];
var id;
var names = { };
for (x in containers) {
container = containers[x];
/*
* This builds the correct type of object graph for the
* paths seen in grid.add() to operate on
*/
name = container.name;
if (!name)
continue;
names[name] = name;
mapping[name] = { "": name };
id = name;
if (container.aliases) {
ylen = container.aliases.length;
for (y = 0; y < ylen; y++) {
mapping[container.aliases[y]] = { "": name };
/* Try to use the real docker container id as our id */
if (container.aliases[y].length === 64)
id = container.aliases[y];
}
}
if (id && container.spec) {
if (!self.specs[id]) {
self.specs[id] = container.spec;
new_ids.push(id);
}
}
if (container.stats) {
len = container.stats.length;
for (i = 0; i < len; i++) {
stat = container.stats[i];
if (!stat.timestamp)
continue;
/* Convert the timestamp into an index */
offset = Math.floor(new Date(stat.timestamp).getTime() / interval);
item = items[offset - base];
if (!item)
item = items[offset - base] = { };
item[name] = stat;
}
}
}
if (new_ids.length > 0)
invokeCallbacks(new_ids);
/* Make sure each offset has something */
len = items.length;
for (i = 0; i < len; i++) {
if (items[i] === undefined)
items[i] = { };
}
/* Now for each offset, if it's a duplicate, put in a copy */
for (name in names) {
len = items.length;
for (i = 0; i < len; i++) {
if (items[i][name] === undefined)
items[i][name] = last[name];
else
last[name] = items[i][name];
}
}
self.series.input(base, items, mapping);
}
function request(query) {
var body = JSON.stringify(query);
/* Only one request active at a time for any given body */
if (body in requests)
return;
var path = "/api/v1/proxy/nodes/" + encodeURIComponent(node) + ":4194/api/v1.2/docker";
var req = KubeRequest("POST", path, query);
requests[body] = req;
req.then(function(data) {
delete requests[body];
feed(data.data);
})
.catch(function(ex) {
delete requests[body];
if (ex.status != 503)
console.warn(ex);
});
}
function invokeCallbacks(/* ... */) {
var i, len, func;
for (i = 0, len = callbacks.length; i < len; i++) {
func = callbacks[i];
try {
if (func)
func.apply(self, arguments);
} catch (e) {
$exceptionHandler(e);
}
}
}
self.fetch = function fetch(beg, end) {
var query;
if (!beg || !end) {
query = { num_stats: 60 };
} else {
query = {
start: new Date((beg - 1) * interval).toISOString(),
end: new Date(end * interval).toISOString()
};
}
request(query);
};
self.close = function close() {
var req, body;
for (body in requests) {
req = requests[body];
if (req && req.cancel)
req.cancel();
}
};
self.watch = function watch(callback) {
var ids;
var timeout;
callbacks.push(callback);
if (self.specs) {
ids = Object.keys(self.specs);
if (ids.length > 0) {
timeout = $timeout(function() {
timeout = null;
callback.call(self, ids);
}, 0);
}
}
return {
cancel: function() {
var i, len;
$timeout.cancel(timeout);
timeout = null;
for (i = 0, len = callbacks.length; i < len; i++) {
if (callbacks[i] === callback)
callbacks[i] = null;
}
}
};
};
var cache = "cadv1-" + (node || null);
self.series = CockpitMetrics.series(interval, cache, self.fetch);
}
return {
new_cadvisor: function(node) {
return new CAdvisor(node);
},
};
}
])
.factory('ServiceGrid', [
"CAdvisorSeries",
"CockpitMetrics",
"kubeSelect",
"kubeLoader",
function ServiceGrid(CAdvisorSeries, CockpitMetrics, select, loader) {
function CockpitServiceGrid(until) {
var self = CockpitMetrics.grid(10000, 0, 0);
/* All the cadvisors that have been opened, one per host */
var cadvisors = { };
/* Service uids */
var services = { };
/* The various rows being shown, override */
self.rows = [ ];
self.events = [ ];
var change_queued = false;
var current_metric = null;
var rows = {
cpu: { },
memory: { },
network: { }
};
var container_cpu = { };
var container_mem = { };
var container_rx = { };
var container_tx = { };
/* Track Pods and Services */
loader.listen(function() {
var changed = false;
var seen_services = {};
var seen_hosts = {};
/* Lookup all the services */
angular.forEach(select().kind("Service"), function(service) {
var spec = service.spec;
var meta = service.metadata;
var name = meta.name;
if (!spec || !spec.selector || name === "kubernetes" || name === "kubernetes-ro")
return;
var uid = meta.uid;
var pods = select().kind("Pod")
.namespace(meta.namespace || "")
.label(spec.selector || {});
seen_services[uid] = true;
if (!services[uid])
add_service(uid);
/* Lookup all the pods for each service */
angular.forEach(pods, function(pod) {
var status = pod.status || { };
var spec = pod.spec || { };
var host = spec.nodeName;
var container_ids = {};
var containers = status.containerStatuses || [];
var i;
var mapped = services[uid];
seen_hosts[host] = true;
if (host && !cadvisors[host]) {
add_cadvisor(host);
}
/* Note all the containers for that pod */
for (i = 0; i < containers.length; i++) {
var container = containers[i];
var id = container.containerID;
if (id && id.indexOf("docker://") === 0) {
container_ids[id] = id;
id = id.substring(9);
container_ids[id] = id;
if (!mapped || !mapped[id])
changed = true;
}
}
services[uid] = container_ids;
});
});
var k;
for (k in services) {
if (!seen_services[k]) {
remove_service(k);
changed = true;
}
}
for (k in cadvisors) {
if (!seen_hosts[k]) {
remove_host(k);
changed = true;
}
}
/* Notify for all rows */
if (changed)
self.sync();
}, until);
loader.watch("Pod", until);
loader.watch("Service", until);
function add_container(cadvisor, id) {
var cpu = self.add(cadvisor, [ id, "cpu", "usage", "total" ]);
container_cpu[id] = self.add(function(row, x, n) {
row_delta(cpu, row, x, n);
}, true);
container_mem[id] = self.add(cadvisor, [ id, "memory", "usage" ]);
var rx = self.add(cadvisor, [ id, "network", "rx_bytes" ]);
container_rx[id] = self.add(function(row, x, n) {
row_delta(rx, row, x, n);
}, true);
var tx = self.add(cadvisor, [ id, "network", "tx_bytes" ]);
container_tx[id] = self.add(function(row, x, n) {
row_delta(tx, row, x, n);
}, true);
self.sync();
}
function add_cadvisor(host) {
var cadvisor = CAdvisorSeries.new_cadvisor(host);
cadvisor.watch(function (ids) {
var i;
for (i = 0; i < ids.length; i++)
add_container(cadvisor, ids[i]);
});
/* A dummy row to force fetching data from the cadvisor */
self.add(cadvisor, [ "unused-dummy" ]);
/* TODO: Handle cadvisor failure somehow */
cadvisors[host] = cadvisor;
}
function remove_host(host) {
var cadvisor = cadvisors[host];
if (cadvisor) {
delete cadvisors[host];
cadvisor.close();
cadvisor = null;
change_queued = true;
}
}
function add_service(uid) {
/* CPU needs summing of containers, and then delta between them */
rows.cpu[uid] = self.add(function(row, x, n) {
containers_sum(uid, container_cpu, row, x, n);
});
/* Memory row is pretty simple, just sum containers */
rows.memory[uid] = self.add(function(row, x, n) {
containers_sum(uid, container_mem, row, x, n);
});
/* Network sums containers, then sum tx and rx, and then delta */
var tx = self.add(function(row, x, n) {
containers_sum(uid, container_tx, row, x, n);
});
var rx = self.add(function(row, x, n) {
containers_sum(uid, container_rx, row, x, n);
});
rows.network[uid] = self.add(function(row, x, n) {
rows_sum([tx, rx], row, x, n);
});
change_queued = true;
}
function remove_service(uid) {
delete services[uid];
delete rows.network[uid];
delete rows.cpu[uid];
delete rows.memory[uid];
change_queued = true;
}
function rows_sum(input, row, x, n) {
var max = row.maximum || 0;
var value, i, v, j;
var len = input.length;
/* Calculate the sum of the rows */
for (i = 0; i < n; i++) {
value = undefined;
for (j = 0; j < len; j++) {
v = input[j][x + i];
if (v !== undefined) {
if (value === undefined)
value = v;
else
value += v;
}
}
if (value !== undefined && value > max) {
row.maximum = max = value;
change_queued = true;
}
row[x + i] = value;
}
}
function row_delta(input, row, x, n) {
var i, last, res, value;
if (x > 0)
last = input[x - 1];
var max = row.maximum || 1;
for (i = 0; i < n; i++) {
value = input[x + i];
if (last === undefined || value === undefined) {
res = undefined;
} else {
res = (value - last);
if (res < 0) {
res = undefined;
} else if (res > max) {
row.maximum = max = res;
change_queued = true;
}
}
row[x + i] = res;
last = value;
}
}
function containers_sum(service, input, row, x, n) {
var id, rowc;
var subset = [];
var mapped = services[service];
if (mapped) {
for (id in mapped) {
rowc = input[id];
if (rowc)
subset.push(rowc);
}
}
rows_sum(subset, row, x, n);
}
self.metric = function metric(type) {
if (type === undefined)
return current_metric;
if (rows[type] === undefined)
throw Error("unsupported metric type");
self.rows = [];
current_metric = type;
var service_uids = Object.keys(services);
var row, i;
var len = service_uids.length;
for (i = 0; i < len; i++) {
row = rows[type][service_uids[i]];
if (row !== undefined) {
self.rows.push(row);
row.uid = service_uids[i];
}
}
var event = new CustomEvent("changed", {
bubbles: false,
cancelable: false,
detail: null
});
self.dispatchEvent(event, null);
};
var base_close = self.close;
self.close = function close() {
var hosts = Object.keys(cadvisors);
var i;
for (i = 0; i < hosts.length; i++) {
var k = hosts[i];
var cadvisor = cadvisors[k];
if (cadvisor) {
delete cadvisors[k];
cadvisor.close();
cadvisor = null;
}
}
base_close.apply(self);
};
self.addEventListener("notify", function () {
if (change_queued) {
change_queued = false;
self.metric(current_metric);
}
});
return self;
}
return {
new_grid: function (until) {
return new CockpitServiceGrid(until);
}
};
}
])
.directive('kubernetesServiceGraph', [
"ServiceGrid",
"KubeTranslate",
"KubeFormat",
function kubernetesServiceGraph(ServiceGrid, KubeTranslate, KubeFormat) {
const _ = KubeTranslate.gettext;
function service_graph($scope, selector, highlighter) {
var grid = ServiceGrid.new_grid($scope);
var outer = d3.select(selector);
var highlighted = null;
/* Various tabs */
var tabs = {
cpu: {
label: _("CPU"),
step: 1000 * 1000 * 1000 * 10,
formatter: function(v) { return (v / (100 * 1000 * 1000)) + "%" }
},
memory: {
label: _("Memory"),
step: 1024 * 1024 * 64,
formatter: function(v) { return KubeFormat.formatBytes(v) }
},
network: {
label: _("Network"),
step: 1000 * 1000 * 10,
formatter: function(v) { return KubeFormat.formatBitsPerSec((v / 10), "Mbps") }
}
};
outer.append("ul")
.attr("class", "nav nav-tabs")
.selectAll("li")
.data(Object.keys(tabs))
.enter()
.append("li")
.attr("data-metric", function(d) { return d })
.append("a")
.text(function(d) { return tabs[d].label });
function metric_tab(tab) {
outer.selectAll("ul li")
.attr("class", function(d) { return tab === d ? "active" : null });
grid.metric(tab);
}
outer.selectAll("ul li")
.on("click", function() {
metric_tab(d3.select(this).attr("data-metric"));
});
metric_tab("cpu");
/* The main svg graph stars here */
var margins = {
top: 12,
right: 15,
bottom: 40,
left: 60
};
var colors = d3.scale.category20();
var element = d3.select(selector).append("svg");
var stage = element.append("g")
.attr("transform", "translate(" + margins.left + "," + margins.top + ")");
var y = d3.scale.linear();
var y_axis = d3.svg.axis()
.scale(y)
.ticks(5)
.orient("left");
var y_group = stage.append("g")
.attr("class", "y axis");
var x = d3.scale.linear();
var x_axis = d3.svg.axis()
.scale(x)
.orient("bottom");
var x_group = stage.append("g")
.attr("class", "x axis");
var offset = 0;
var line = d3.svg.line()
.defined(function(d) { return d !== undefined })
.x(function(d, i) { return x((grid.beg + i) - offset) })
.y(function(d, i) { return y(d) });
/* Initial display: 1024 px, 5 minutes of data */
var factor = 300000 / 1024;
var width = 300;
var height = 300;
var rendered = false;
window.setTimeout(function() {
rendered = true;
adjust();
}, 1);
function ceil(value, step) {
var d = value % step;
if (value === 0 || d !== 0)
value += (step - d);
return value;
}
function jump() {
var interval = grid.interval;
var w = (width - margins.right) - margins.left;
/* This doesn't yet work for an arbitary ponit in time */
var now = new Date().getTime();
var end = Math.floor(now / interval);
var beg = end - Math.floor((factor * w) / interval);
offset = beg;
grid.move(beg, end);
}
function adjust() {
if (!rendered)
return;
element
.attr("width", width)
.attr("height", height);
var w = (width - margins.right) - margins.left;
var h = (height - margins.top) - margins.bottom;
var metric = grid.metric();
var interval = grid.interval;
/* Calculate our maximum value, hopefully rows are tracking this for us */
var rows = grid.rows;
var maximum = 0;
var i, max;
var len = rows.length;
for (i = 0; i < len; i++) {
if (rows[i].maximum !== undefined)
max = rows[i].maximum;
else
max = d3.max(rows[i]);
if (max > maximum)
maximum = Math.ceil(max);
}
/* This doesn't yet work for an arbitary ponit in time */
var end = Math.floor((factor * w) / interval);
x.domain([0, end]).range([0, w]);
y.domain([0, ceil(maximum, tabs[metric].step)]).range([h, 0]);
/* The ticks are inverted backwards */
var tsc = d3.scale.linear().domain([0, end])
.range([end, 0]);
/* Calculate ticks every 60 seconds in past */
var ticks = [];
for (i = 6; i < end; i += 6)
ticks.push(Math.round(tsc(i)));
/* Make x-axis ticks into grid of right width */
x_axis
.tickValues(ticks)
.tickSize(-h, -h)
.tickFormat(function(d) {
d = Math.round(tsc.invert(d));
return (d / 6) + " min";
});
/* Re-render the X axis. Note that we also
* bump down the labels a bit. */
x_group
.attr("transform", "translate(0," + h + ")")
.call(x_axis)
.selectAll("text")
.attr("y", "10px");
/* Turn the Y axis ticks into a grid */
y_axis
.tickSize(-w, -w)
.tickFormat(tabs[metric].formatter);
y_group
.call(y_axis)
.selectAll("text")
.attr("x", "-10px");
jump();
}
function notified() {
var rows = grid.rows;
var series = stage.selectAll("path.line")
.data(rows, function(d, i) { return i });
series
.style("stroke", function(d, i) { return colors(i) })
.attr("d", function(d) { return line(d) })
.classed("highlight", function(d) { return d.uid === highlighted });
series.enter().append("path")
.attr("class", "line")
.on("mouseover", function() {
highlighter(d3.select(this).datum().uid);
})
.on("mouseout", function() {
highlighter(null);
});
series.exit().remove();
}
grid.addEventListener('notify', notified);
function changed() {
adjust();
notified();
}
grid.addEventListener('changed', changed);
function resized() {
width = selector.offsetWidth - 10;
if (width < 0)
width = 0;
adjust();
}
window.addEventListener('resize', resized);
resized();
var timer = window.setInterval(function () {
if (!width)
resized();
else
jump();
}, grid.interval);
return {
highlight: function highlight(uid) {
highlighted = uid;
notified();
},
close: function close() {
if (timer)
window.clearInterval(timer);
timer = null;
window.removeEventListener('resize', resized);
grid.removeEventListener('notify', notified);
grid.removeEventListener('changed', changed);
grid.close();
}
};
}
return {
restrict: 'E',
link: function($scope, element, attributes) {
var graph = service_graph($scope, element[0], function(uid) {
$scope.$broadcast('highlight', uid);
$scope.$digest();
});
$scope.$on("highlight", function(ev, uid) {
graph.highlight(uid);
});
element.on('$destroy', function() {
graph.close();
graph = null;
});
}
};
}
]);
}());