cockpit/pkg/kubernetes/scripts/nodes.js

899 lines
37 KiB
JavaScript

/*
* This file is part of Cockpit.
*
* Copyright (C) 2016 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/>.
*/
/* This is here to support cockpit.jump
* If this ever needs to be used outsite of cockpit
* then we'll need abstract this away in kube-client-cockpit
*/
/* globals cockpit */
(function() {
var angular = require('angular');
var d3 = require('d3');
require('angular-route');
require('angular-dialog.js');
require('./charts');
require('./date');
require('./kube-client');
require('./listing');
require('./utils');
require('../views/nodes-page.html');
require('../views/node-page.html');
require('../views/node-body.html');
require('../views/node-capacity.html');
require('../views/node-stats.html');
require('../views/node-add.html');
require('../views/node-delete.html');
require('../views/node-alerts.html');
angular.module('kubernetes.nodes', [
'ngRoute',
'kubeClient',
'kubernetes.date',
'kubernetes.listing',
'kubeUtils',
'ui.cockpit',
'ui.charts',
])
.config([
'$routeProvider',
function($routeProvider) {
$routeProvider
.when('/nodes', {
templateUrl: 'views/nodes-page.html',
controller: 'NodeCtrl'
})
.when('/nodes/:target', {
controller: 'NodeCtrl',
templateUrl: 'views/node-page.html'
});
}
])
/*
* The controller for the node view.
*/
.controller('NodeCtrl', [
'$scope',
'kubeLoader',
'kubeSelect',
'ListingState',
'filterService',
'$routeParams',
'$location',
'nodeActions',
'nodeData',
'nodeStatsSummary',
'$timeout',
'KubeBrowserStorage',
function($scope, loader, select, ListingState, filterService,
$routeParams, $location, actions, nodeData, statsSummary,
$timeout, browser) {
var target = $routeParams["target"] || "";
$scope.target = target;
$scope.stats = statsSummary.newNodeStatsSummary();
loader.listen(function() {
var selection;
$scope.nodes = select().kind("Node");
if (target) {
selection = select().kind("Node")
.name(target);
$scope.item = selection.one();
} else {
selection = $scope.nodes;
}
if ($scope.stats)
$scope.stats.trackNodes(selection);
}, $scope);
loader.watch("Node", $scope);
$scope.$on("$destroy", function() {
if ($scope.stats)
$scope.stats.close();
$scope.stats = null;
});
$scope.listing = new ListingState($scope);
/* All the actions available on the $scope */
angular.extend($scope, actions);
angular.extend($scope, nodeData);
$scope.$on("activate", function(ev, id) {
$location.path('/nodes/' + encodeURIComponent(id));
$scope.$applyAsync();
});
$scope.nodePods = function node_pods(item) {
var meta = item.metadata || {};
return select().kind("Pod")
.host(meta.name);
};
$scope.deleteSelectedNodes = function() {
var k;
var selected = [];
for (k in $scope.listing.selected) {
if ($scope.nodes[k] && $scope.listing.selected[k])
selected.push($scope.nodes[k]);
}
if (!selected.length)
return;
return actions.deleteNodes(selected).then(function() {
$scope.listing.selected = {};
});
};
/* Redirect after a delete */
$scope.deleteNode = function(val) {
var promise = actions.deleteNodes(val);
/* If the promise is successful, redirect to another page */
promise.then(function() {
if ($scope.target)
$location.path("/nodes");
});
return promise;
};
$scope.jump = function (node) {
var host, ip;
if (!node || !node.metadata)
return;
host = node.metadata.name;
ip = nodeData.nodeIPAddress(node);
if (ip == "127.0.0.1" || ip == "::1") {
ip = "localhost";
} else {
browser.sessionStorage.setItem(
"v1-session-machine/" + ip,
JSON.stringify({ "address": ip,
"label": host,
visible: true })
);
}
cockpit.jump("/", ip);
};
}
])
.directive('nodeBody',
function() {
return {
restrict: 'A',
templateUrl: 'views/node-body.html',
};
}
)
.directive('nodeCapacity',
function() {
return {
restrict: 'A',
templateUrl: 'views/node-capacity.html',
};
}
)
.directive('nodeStats',
function() {
return {
restrict: 'A',
templateUrl: 'views/node-stats.html',
};
}
)
.factory('nodeActions', [
'$modal',
function($modal) {
function addNode() {
return $modal.open({
animation: false,
controller: 'AddNodeCtrl',
templateUrl: 'views/node-add.html',
resolve: {},
}).result;
}
function deleteNodes(val) {
var nodes;
if (angular.isArray(val))
nodes = val;
else
nodes = [ val ];
return $modal.open({
animation: false,
controller: 'NodeDeleteCtrl',
templateUrl: 'views/node-delete.html',
resolve: {
dialogData: function() {
return { nodes: nodes };
}
},
}).result;
}
return {
addNode: addNode,
deleteNodes: deleteNodes
};
}
])
.controller("NodeDeleteCtrl", [
"$q",
"$scope",
"$modalInstance",
"dialogData",
"kubeMethods",
"kubeSelect",
function($q, $scope, $instance, dialogData, methods, select) {
angular.extend($scope, dialogData);
$scope.performDelete = function performDelete() {
var k;
var errors = [];
var nodes = {};
var promises = [];
function handleError(ex) {
errors.push(ex.message || ex.statusText);
nodes[k] = $scope.nodes[k];
return $q.reject();
}
for (k in $scope.nodes) {
var p = methods.delete($scope.nodes[k])
.catch(handleError);
promises.push(p);
}
return $q.all(promises).catch(function () {
$scope.nodes = select(nodes);
return $q.reject(errors);
});
};
}
])
.factory('nodeData', [
"KubeMapNamedArray",
"KubeTranslate",
function (mapNamedArray, translate) {
const _ = translate.gettext;
function nodeConditions(node) {
var status;
if (!node)
return;
if (!node.conditions) {
status = node.status || { };
node.conditions = mapNamedArray(status.conditions, "type");
}
return node.conditions;
}
function nodeCondition(node, type) {
var conditions = nodeConditions(node) || {};
return conditions[type] || {};
}
function nodeStatus(node) {
var spec = node ? node.spec : {};
if (!nodeCondition(node, "Ready").status)
return _("Unknown");
if (nodeCondition(node, "Ready").status != 'True')
return _("Not Ready");
if (spec && spec.unschedulable)
return _("Scheduling Disabled");
return _("Ready");
}
function nodeStatusIcon(node) {
var state = "";
/* If no status.conditions then it hasn't even started */
if (!nodeCondition(node, "Ready").status) {
state = "wait";
} else {
if (nodeCondition(node, "Ready").status != 'True') {
state = "fail";
} else if (nodeCondition(node, "OutOfDisk").status == "True" ||
nodeCondition(node, "OutOfMemory").status == "True") {
state = "warn";
}
}
return state;
}
function nodeIPAddress(node) {
if (!node || !node.status)
return;
var addresses = node.status.addresses;
var address;
var internal;
/* If no addresses then it hasn't even started */
if (addresses) {
addresses.forEach(function(a) {
if (a.type == "LegacyHostIP" || a.type == "ExternalIP") {
address = a.address;
return false;
} else if (a.type == "InternalIP") {
internal = a.address;
}
});
}
return address || internal;
}
return {
nodeStatusIcon: nodeStatusIcon,
nodeCondition: nodeCondition,
nodeConditions: nodeConditions,
nodeStatus: nodeStatus,
nodeIPAddress: nodeIPAddress
};
}
])
.controller("AddNodeCtrl", [
"$q",
"$scope",
"$modalInstance",
"kubeMethods",
"KubeTranslate",
function($q, $scope, $instance, methods, translate) {
const _ = translate.gettext;
var fields = {
"address" : "",
"name" : "",
};
var dirty = false;
$scope.fields = fields;
function validate() {
var regex = /^[a-z0-9.-]+$/i;
var defer = $q.defer();
var address = fields.address.trim();
var name = fields.name.trim();
var ex;
var failures = [];
var item;
if (!address)
ex = new Error(_("Please type an address"));
else if (!regex.test(address))
ex = new Error(_("The address contains invalid characters"));
if (ex) {
ex.target = "#node-address";
failures.push(ex);
}
if (name && !regex.test(name)) {
ex = new Error(_("The name contains invalid characters"));
ex.target = "#node-name";
failures.push(ex);
}
if (failures.length > 0) {
defer.reject(failures);
} else {
item = {
"kind": "Node",
"apiVersion": "v1",
"metadata": {
"name": name || address,
}
};
defer.resolve(item);
}
return defer.promise;
}
$scope.nameKeyUp = function nameKeyUp(event) {
dirty = true;
if (event.keyCode == 13)
$scope.performAdd();
};
$scope.addressKeyUp = function addressKeyUp(event) {
if (event.keyCode == 13)
$scope.performAdd();
else if (!dirty)
fields.name = event.target.value;
};
$scope.performAdd = function performAdd() {
return validate().then(function(item) {
return methods.create(item);
});
};
}
])
.directive('kubernetesStatusIcon', function() {
return {
restrict: 'A',
link: function($scope, element, attributes) {
$scope.$watch(attributes["status"], function(status) {
element
.toggleClass("spinner spinner-xs", status == "wait")
.toggleClass("pficon pficon-error-circle-o", status == "fail")
.toggleClass("pficon pficon-warning-triangle-o", status == "warn");
});
}
};
})
.directive('nodeAlerts', function() {
return {
restrict: 'A',
templateUrl: 'views/node-alerts.html'
};
})
.factory('nodeStatsSummary', [
"$q",
"$interval",
"$exceptionHandler",
"KubeRequest",
"nodeData",
"KubeStringToBytes",
"KubeFormat",
function ($q, $interval, $exceptionHandler, KubeRequest, nodeData,
kubeStringToBytes, format) {
function NodeStatsSummary() {
var self = this;
var requests = {};
var statData = {};
var callbacks = [];
var interval;
function request(name) {
if (requests[name])
return $q.when([]);
var path = "/api/v1/nodes/" + encodeURIComponent(name) + "/proxy/stats/summary/";
var req = KubeRequest("GET", path);
requests[name] = req;
return req.then(function(data) {
delete requests[name];
statData[name] = data.data;
})
.catch(function(ex) {
delete requests[name];
delete statData[name];
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);
}
}
}
function fetchForNames(names) {
var q = [];
angular.forEach(names, function(name) {
q.push(request(name));
});
$q.all(q).then(invokeCallbacks, invokeCallbacks);
}
interval = $interval(function () {
fetchForNames(Object.keys(statData));
}, 5000);
self.watch = function watch(callback) {
callbacks.push(callback);
return {
cancel: function() {
var i, len;
for (i = 0, len = callbacks.length; i < len; i++) {
if (callbacks[i] === callback)
callbacks[i] = null;
}
}
};
};
self.close = function close() {
var name;
if (interval)
$interval.cancel(interval);
for (name in requests) {
var req = requests[name];
if (req && req.cancel)
req.cancel();
}
};
self.trackNodes = function trackNodes(selection) {
var names = [];
angular.forEach(selection, function(node) {
var ready = nodeData.nodeCondition(node, "Ready");
var meta = node ? node.metadata : {};
var name = meta ? meta.name : "";
if (ready && ready.status === 'True') {
if (!statData[name])
names.push(name);
} else {
// Unfortunally i'm seen some requests
// not error so clean them out.
delete statData[name];
if (request[name]) {
request[name].cancel();
delete request[name];
}
}
});
fetchForNames(names);
};
self.getSimpleUsage = function getSimpleUsage(node, section) {
var meta = node ? node.metadata : {};
var status = node ? node.status : {};
var name = meta ? meta.name : "";
var nodeData = statData[name] ? statData[name].node : {};
var result = nodeData[section];
if (!result)
return;
var allocatable = status ? status.allocatable : {};
if (!allocatable)
return;
switch (section) {
case "cpu":
if (!allocatable.cpu)
return;
return {
used: result.usageNanoCores,
total: allocatable.cpu * 1000000000
};
case "memory":
if (!allocatable.memory)
return;
return {
used: result.usageBytes,
total: kubeStringToBytes(allocatable.memory)
};
case "fs":
return {
used: result.usedBytes,
total: result.capacityBytes
};
default:
}
};
}
return {
newNodeStatsSummary: function(interval) {
return new NodeStatsSummary(interval);
},
};
}
])
.directive('nodeOsGraph', [
"KubeTranslate",
function(translate) {
const _ = translate.gettext;
return {
scope: {
'nodes' : '='
},
template: '<div class="col-xs-12 col-md-6" id="os-counts-graph" donut-pct-chart data="data" bar-size="8" legend="os-counts-legend" large-title="largeTitle"></div><div class="col-xs-12 col-md-6 legend-col"><div id="os-counts-legend"></div></div>',
restrict: 'A',
link: function($scope, element, attributes) {
$scope.data = [];
$scope.largeTitle = 0;
function refresh(items) {
items = items || [];
var data = {};
angular.forEach(items, function(node) {
var os;
var color;
if (node.status && node.status.nodeInfo)
os = node.status.nodeInfo.osImage;
if (!os) {
os = _("Unknown");
color = "#bbbbbb";
}
if (data[os])
data[os].value++;
else
data[os] = { value: 1, label: os, color: color };
});
var arr = Object.keys(data).map(function(k) {
return data[k];
});
arr.sort(function (a, b) {
if (a.label < b.label)
return -1;
if (a.label > b.label)
return 1;
return 0;
});
$scope.data = arr;
$scope.largeTitle = items.length;
}
$scope.$watchCollection('nodes', function(nodes) {
refresh(nodes);
});
}
};
}
])
.directive('nodeHeatMap', [
"KubeTranslate",
"KubeFormat",
function(translate, format) {
const _ = translate.gettext;
return {
restrict: 'A',
scope: {
'nodes' : '=',
'stats' : '='
},
template: '<div class="card-pf-title"><ul class="nav nav-tabs nav-tabs-pf"></ul></div><div threshold-heat-map class="card-pf-body node-heatmap" data="data" clickAction="clickAction()" legend="nodes-heatmap-legend"></div><div id="nodes-heatmap-legend"></div></div>',
link: function($scope, element, attributes) {
var outer = d3.select(element[0]);
var currentTab;
var tabs = {
cpu: {
label: _("CPU"),
tooltip: function(r) { return format.format(_("CPU Utilization: $0%"), Math.round((r.used / r.total) * 100)) }
},
memory: {
label: _("Memory"),
tooltip: function(r) { return format.format(_("Memory Utilization: $0%"), Math.round((r.used / r.total) * 100)) }
},
fs: {
label: _("Disk"),
tooltip: function(r) { return format.format(_("Disk Utilization: $0%"), Math.round((r.used / r.total) * 100)) }
}
};
outer.select("ul.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 changeTab(tab) {
currentTab = tab;
outer.selectAll("ul.nav-tabs li")
.attr("class", function(d) { return tab === d ? "active" : null });
refreshData();
}
outer.selectAll("ul.nav-tabs li")
.on("click", function() {
changeTab(d3.select(this).attr("data-metric"));
});
function refreshData() {
var nodes = $scope.nodes;
var data = [];
if (!nodes)
nodes = [];
angular.forEach(nodes, function(node) {
var result, value, name;
var tooltip = _("Unknown");
if (node && node.metadata)
name = node.metadata.name;
if (!name)
return;
result = $scope.stats.getSimpleUsage(node, currentTab);
if (result)
value = result.used / result.total;
if (value === undefined)
value = -1;
else
tooltip = tabs[currentTab].tooltip(result);
data.push({ value: value, name: name,
tooltip: tooltip });
});
data.sort(function (a, b) {
return b.value - a.value;
});
$scope.$applyAsync(function() {
$scope.data = data;
});
return true;
}
$scope.$on("boxClick", function (ev, name) {
$scope.$emit("activate", name);
});
var sw = $scope.stats.watch(refreshData);
$scope.$on("$destroy", function() {
sw.cancel();
});
changeTab("cpu");
}
};
}
])
.directive('nodeUsageDonutChart', [
"KubeTranslate",
"KubeFormat",
function(translate, format) {
const _ = translate.gettext;
return {
restrict: 'A',
scope: {
'node' : '=',
'stats' : '=',
},
template: '<div ng-if="data" donut-pct-chart data="data" bar-size="8" large-title="largeTitle" small-title="smallTitle"></div><div class="text-center" ng-if="data">{{ title }}</div>',
link: function($scope, element, attributes) {
var colorFunc = d3.scale.threshold()
.domain([0.7, 0.8, 0.9])
.range(['#d4f0fa', '#F9D67A', '#EC7A08', '#CE0000']);
var types = {
cpu: {
label: _("CPU"),
smallTitle: function () {},
largeTitle: function (result) {
var r = result.used / result.total;
var p = Math.round(r * 100);
return format.format("$0%", p);
},
},
memory: {
label: _("Memory"),
smallTitle: function (result) {
return _("Used");
},
largeTitle: function (result) {
return format.formatBytes(result.used);
},
},
fs: {
label: _("Disk"),
smallTitle: function (result) {
return _("Used");
},
largeTitle: function (result) {
return format.formatBytes(result.used);
},
}
};
var type = attributes['type'];
$scope.title = types[type].label;
function clear() {
$scope.$applyAsync(function() {
$scope.data = null;
$scope.smallTitle = null;
$scope.largeTitle = null;
});
}
function refreshData() {
var node = $scope.node;
var result;
if (!node)
return clear();
result = $scope.stats.getSimpleUsage(node, type);
if (result) {
$scope.$applyAsync(function() {
$scope.smallTitle = types[type].smallTitle(result);
$scope.largeTitle = types[type].largeTitle(result);
var u = Math.round((result.used / result.total) * 100);
var l = 100 - u;
var freeText = translate.ngettext("$0% Free",
"$0% Free", u);
var usedText = translate.ngettext("$0% Used",
"$0% Used", u);
$scope.data = [
{ value: result.total - result.used,
tooltip : format.format(freeText, l),
color: "#bbbbbb" },
{ value: result.used,
tooltip : format.format(usedText, u),
color: colorFunc(result.used / result.total) }
];
});
} else {
clear();
}
return true;
}
var sw = $scope.stats.watch(refreshData);
$scope.$on("$destroy", function() {
sw.cancel();
});
}
};
}
]);
}());