diff --git a/SingularityService/src/main/java/com/hubspot/singularity/config/UIConfiguration.java b/SingularityService/src/main/java/com/hubspot/singularity/config/UIConfiguration.java
index 28d430f7c5..e75f68a6ca 100644
--- a/SingularityService/src/main/java/com/hubspot/singularity/config/UIConfiguration.java
+++ b/SingularityService/src/main/java/com/hubspot/singularity/config/UIConfiguration.java
@@ -66,6 +66,8 @@ public static RootUrlMode parse(String value) {
private boolean hideNewDeployButton = false;
private boolean hideNewRequestButton = false;
+ private boolean shortenSlaveUsageHostname = false;
+
/**
* If true, the root of the server (http://.../singularity/) will open the UI. Otherwise,
* the UI URI (http://.../singularity/ui/) must be used.
@@ -109,6 +111,14 @@ public void setHideNewRequestButton(boolean hideNewRequestButton) {
this.hideNewRequestButton = hideNewRequestButton;
}
+ public boolean isShortenSlaveUsageHostname() {
+ return shortenSlaveUsageHostname;
+ }
+
+ public void setShortenSlaveUsageHostname(boolean shortenSlaveUsageHostname) {
+ this.shortenSlaveUsageHostname = shortenSlaveUsageHostname;
+ }
+
public String getTitle() {
return title;
}
diff --git a/SingularityService/src/main/java/com/hubspot/singularity/views/IndexView.java b/SingularityService/src/main/java/com/hubspot/singularity/views/IndexView.java
index a28b869d60..2fe01b7736 100644
--- a/SingularityService/src/main/java/com/hubspot/singularity/views/IndexView.java
+++ b/SingularityService/src/main/java/com/hubspot/singularity/views/IndexView.java
@@ -49,6 +49,8 @@ public class IndexView extends View {
private final String shellCommands;
+ private final boolean shortenSlaveUsageHostname;
+
private final String timestampFormat;
private final boolean showTaskDiskResource;
@@ -111,6 +113,8 @@ public IndexView(String singularityUriBase, String appRoot, SingularityConfigura
throw Throwables.propagate(e);
}
+ this.shortenSlaveUsageHostname = configuration.getUiConfiguration().isShortenSlaveUsageHostname();
+
this.timestampFormat = configuration.getUiConfiguration().getTimestampFormat();
this.timestampWithSecondsFormat = configuration.getUiConfiguration().getTimestampWithSecondsFormat();
@@ -234,6 +238,10 @@ public String getRedirectOnUnauthorizedUrl() {
return redirectOnUnauthorizedUrl;
}
+ public boolean isShortenSlaveUsageHostname() {
+ return shortenSlaveUsageHostname;
+ }
+
@Override
public String toString() {
return "IndexView{" +
@@ -261,6 +269,7 @@ public String toString() {
", taskS3LogOmitPrefix='" + taskS3LogOmitPrefix + '\'' +
", warnIfScheduledJobIsRunningPastNextRunPct=" + warnIfScheduledJobIsRunningPastNextRunPct +
", shellCommands='" + shellCommands + '\'' +
+ ", shortenSlaveUsageHostname=" + shortenSlaveUsageHostname +
", timestampFormat='" + timestampFormat + '\'' +
", showTaskDiskResource=" + showTaskDiskResource +
", timestampWithSecondsFormat='" + timestampWithSecondsFormat + '\'' +
diff --git a/SingularityUI/app/actions/api/slaves.es6 b/SingularityUI/app/actions/api/slaves.es6
index 6d93de76ff..8a2e803398 100644
--- a/SingularityUI/app/actions/api/slaves.es6
+++ b/SingularityUI/app/actions/api/slaves.es6
@@ -54,4 +54,7 @@ export const RemoveExpiringSlaveState = buildJsonApiAction(
})
);
-
+export const FetchSlaveUsages = buildApiAction(
+ 'FETCH_SLAVE_USAGES',
+ {url : '/usage/slaves'}
+);
diff --git a/SingularityUI/app/assets/index.mustache b/SingularityUI/app/assets/index.mustache
index aa45a2b9b2..364b080e4f 100644
--- a/SingularityUI/app/assets/index.mustache
+++ b/SingularityUI/app/assets/index.mustache
@@ -44,6 +44,7 @@
slaveHttpsPort: {{{slaveHttpsPort}}},
{{/slaveHttpsPort}}
shellCommands: {{{shellCommands}}},
+ shortenSlaveUsageHostname : {{{shortenSlaveUsageHostname}}},
redirectOnUnauthorizedUrl: "{{{redirectOnUnauthorizedUrl}}}",
globalRefreshInterval: 60000,
sentryDsn: "{{{sentryDsn}}}"
diff --git a/SingularityUI/app/components/common/Navigation.jsx b/SingularityUI/app/components/common/Navigation.jsx
index 94b5f3c969..c472790843 100644
--- a/SingularityUI/app/components/common/Navigation.jsx
+++ b/SingularityUI/app/components/common/Navigation.jsx
@@ -62,6 +62,7 @@ const Navigation = (props) => {
- Racks
- Slaves
+ - Slave Usage
- Webhooks
- Disasters
diff --git a/SingularityUI/app/components/machines/Constants.jsx b/SingularityUI/app/components/machines/Constants.jsx
new file mode 100644
index 0000000000..8257169df8
--- /dev/null
+++ b/SingularityUI/app/components/machines/Constants.jsx
@@ -0,0 +1,23 @@
+import chroma from 'chroma-js';
+
+export const HEALTH_SCALE_MAX = 10000;
+export const WHOLE_NUMBER = 0;
+export const HUNDREDTHS_PLACE = 2;
+
+export const STAT_NAMES = {
+ cpusUsedStat : 'cpusUsed',
+ memoryBytesUsedStat : 'memoryBytesUsed',
+ numTasksStat : 'numTasks',
+ slaveIdStat : 'slaveId',
+ timestampStat : 'timestamp'
+};
+
+export const SLAVE_HEALTH_MENU_ITEM_ORDER = [
+ 'host',
+ STAT_NAMES.cpusUsedStat,
+ STAT_NAMES.memoryBytesUsedStat,
+ STAT_NAMES.numTasksStat,
+ STAT_NAMES.timestampStat
+];
+
+export const HEALTH_SCALE = chroma.scale(['3182bd','9ecae1','deebf7','fee0d2','fc9272','de2d26']).colors(HEALTH_SCALE_MAX);
diff --git a/SingularityUI/app/components/machines/ResourceHealthData.jsx b/SingularityUI/app/components/machines/ResourceHealthData.jsx
new file mode 100644
index 0000000000..3860cae046
--- /dev/null
+++ b/SingularityUI/app/components/machines/ResourceHealthData.jsx
@@ -0,0 +1,86 @@
+import React, { PropTypes } from 'react';
+import Utils from '../../utils';
+import SlaveResourceHealth from './SlaveResourceHealth';
+import { OverlayTrigger, Popover } from 'react-bootstrap';
+import { STAT_NAMES, HUNDREDTHS_PLACE, HEALTH_SCALE, HEALTH_SCALE_MAX } from './Constants';
+
+const overlayTriggers = ['hover', 'focus'];
+const overlayPlacement = 'bottom';
+
+const getTotalForStat = (statName, data) => {
+ switch (statName) {
+ case STAT_NAMES.memoryBytesUsedStat:
+ return data.totalMemoryResource;
+ case STAT_NAMES.cpusUsedStat:
+ return data.totalCpuResource;
+ default:
+ throw new Error(`${name} is an unsupported statistic`);
+ }
+};
+
+const getUtilizationForStat = (statName, data) => {
+ switch (statName) {
+ case STAT_NAMES.memoryBytesUsedStat:
+ return data.memoryUtilized;
+ case STAT_NAMES.cpusUsedStat:
+ return data.cpuUtilized;
+ default:
+ throw new Error(`${name} is an unsupported statistic`);
+ }
+};
+
+const slaveQuickStats = (data) => {
+ const shortenHostName = true;
+ const largeBlackCircle = '⬤';
+ return (
+
+
+
+ {Utils.humanizeSlaveHostName(data.slaveInfo.host, shortenHostName)}
+
+
+
+ {Utils.roundTo(data.memoryUtilized / HEALTH_SCALE_MAX * 100, HUNDREDTHS_PLACE)}%
+
+
+ Mem {largeBlackCircle}
+
+
+
+
+ {Utils.roundTo(data.cpuUtilized / HEALTH_SCALE_MAX * 100, HUNDREDTHS_PLACE)}%
+
+
+ Cpu {largeBlackCircle}
+
+
+
+
+ );
+};
+
+const ResourceHealthData = ({utilizationData, statName}) => {
+ return (
+
+
+
+
+
+ );
+};
+
+ResourceHealthData.propTypes = {
+ utilizationData : PropTypes.shape({
+ slaveUsage : PropTypes.object.isRequired,
+ slaveInfo : PropTypes.object.isRequired
+ }),
+ statName : PropTypes.string
+};
+
+export default ResourceHealthData;
diff --git a/SingularityUI/app/components/machines/SlaveAggregates.jsx b/SingularityUI/app/components/machines/SlaveAggregates.jsx
new file mode 100644
index 0000000000..574c45fadd
--- /dev/null
+++ b/SingularityUI/app/components/machines/SlaveAggregates.jsx
@@ -0,0 +1,71 @@
+import React, { PropTypes } from 'react';
+import CircularProgressbar from 'react-circular-progressbar';
+import Utils from '../../utils';
+import { STAT_NAMES, HUNDREDTHS_PLACE } from './Constants';
+
+const getPctSlaveUsage = (slaves, slaveUsages, usageCallback, resourceCallback) => {
+ const totalUsage = slaveUsages.map(usageCallback)
+ .reduce((acc, val) => acc + parseFloat(val), 0);
+
+ const totalResource = slaves.map(resourceCallback)
+ .reduce((acc, val) => acc + parseFloat(val), 0);
+
+ return Utils.roundTo((totalUsage / totalResource) * 100, HUNDREDTHS_PLACE);
+};
+
+const getCpuUtilizationPct = (slaves, slaveUsages) => {
+ return getPctSlaveUsage(slaves,
+ slaveUsages,
+ usage => usage.cpusUsed,
+ slave => Utils.getMaxAvailableResource(slave, STAT_NAMES.cpusUsedStat));
+};
+
+const getMemUtilizationPct = (slaves, slaveUsages) => {
+ return getPctSlaveUsage(slaves,
+ slaveUsages,
+ usage => usage.memoryBytesUsed,
+ slave => Utils.getMaxAvailableResource(slave, STAT_NAMES.memoryBytesUsedStat));
+};
+
+const SlaveAggregates = ({slaves, slaveUsages, activeTasks}) => {
+ return (
+
+
+
+ {slaves.length}
+
+
+ Active Slaves
+
+
+
+
+ {activeTasks}
+
+
+ Tasks Running
+
+
+
+
`${pct}%`} />
+
+ Cpu
+
+
+
+
`${pct}%`} />
+
+ Memory
+
+
+
+ );
+};
+
+SlaveAggregates.propTypes = {
+ slaves : PropTypes.array,
+ slaveUsages : PropTypes.array,
+ activeTasks : PropTypes.number.isRequired
+};
+
+export default SlaveAggregates;
diff --git a/SingularityUI/app/components/machines/SlaveResourceHealth.jsx b/SingularityUI/app/components/machines/SlaveResourceHealth.jsx
new file mode 100644
index 0000000000..395feb0d18
--- /dev/null
+++ b/SingularityUI/app/components/machines/SlaveResourceHealth.jsx
@@ -0,0 +1,55 @@
+import React, { PropTypes } from 'react';
+import Utils from '../../utils';
+import SlaveResourceHealthMenuItems from './SlaveResourceHealthMenuItems';
+import { Dropdown } from 'react-bootstrap';
+import { HEALTH_SCALE, STAT_NAMES } from './Constants';
+
+const SlaveResourceHealth = ({slaveInfo, slaveUsage, resource, totalResource, utilization}) => {
+ const checkStats = (val, stat) => {
+ if (Utils.isResourceStat(stat) && stat !== resource) {
+ return null;
+ }
+
+ const newStat = {
+ name : stat,
+ value : (stat === STAT_NAMES.slaveIdStat ? slaveInfo.host : val),
+ };
+
+ if (Utils.isResourceStat(stat)) {
+ newStat.maybeTotalResource = totalResource;
+ }
+
+ return newStat;
+ };
+
+ const checkedStats = _.map(slaveUsage, checkStats).filter(obj => obj);
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+SlaveResourceHealth.propTypes = {
+ slaveUsage : PropTypes.shape({
+ slaveId : PropTypes.string.isRequired,
+ cpusUsed : PropTypes.number.isRequired,
+ memoryBytesUsed : PropTypes.number.isRequired,
+ numTasks : PropTypes.number.isRequired,
+ timestamp : PropTypes.number.isRequired
+ }),
+ slaveInfo : PropTypes.shape({
+ host : PropTypes.string.isRequired,
+ attributes : PropTypes.object.isRequired,
+ resources : PropTypes.object.isRequired
+ }),
+ resource : PropTypes.string.isRequired,
+ totalResource : PropTypes.number.isRequired,
+ utilization : PropTypes.number.isRequired
+};
+
+export default SlaveResourceHealth;
diff --git a/SingularityUI/app/components/machines/SlaveResourceHealthMenuItems.js b/SingularityUI/app/components/machines/SlaveResourceHealthMenuItems.js
new file mode 100644
index 0000000000..28118111cd
--- /dev/null
+++ b/SingularityUI/app/components/machines/SlaveResourceHealthMenuItems.js
@@ -0,0 +1,97 @@
+import React, { PropTypes } from 'react';
+import StatItem from './StatItem';
+import Utils from '../../utils';
+import { STAT_NAMES, SLAVE_HEALTH_MENU_ITEM_ORDER, HUNDREDTHS_PLACE } from './Constants';
+
+const compareStats = (a, b) => {
+ return SLAVE_HEALTH_MENU_ITEM_ORDER.indexOf(a.name) - SLAVE_HEALTH_MENU_ITEM_ORDER.indexOf(b.name);
+};
+
+const humanizeStatName = (name) => {
+ switch (name) {
+ case STAT_NAMES.slaveIdStat:
+ return 'HOST';
+ case STAT_NAMES.cpusUsedStat:
+ return 'CPU';
+ case STAT_NAMES.memoryBytesUsedStat:
+ return 'MEM';
+ case STAT_NAMES.numTasksStat:
+ return 'TASKS';
+ case STAT_NAMES.timestampStat:
+ return '';
+ default:
+ throw new Error(`${name} is an unsupported statistic`);
+ }
+};
+
+const humanizeStatValue = (name, value, maybeTotalResource) => {
+ switch (name) {
+ case STAT_NAMES.slaveIdStat:
+ return Utils.humanizeSlaveHostName(value);
+ case STAT_NAMES.cpusUsedStat:
+ return `${Utils.roundTo(value, HUNDREDTHS_PLACE)} / ${maybeTotalResource}`;
+ case STAT_NAMES.memoryBytesUsedStat:
+ return `${Utils.humanizeFileSize(value)} / ${Utils.humanizeFileSize(maybeTotalResource)}`;
+ case STAT_NAMES.numTasksStat:
+ return value.toString();
+ case STAT_NAMES.timestampStat:
+ return '';
+ default:
+ throw new Error(`${name} is an unsupported statistic`);
+ }
+};
+
+const humanizeStatPct = (name, value, maybeTotalResource) => {
+ if (Utils.isResourceStat(name)) {
+ return Utils.roundTo((value / maybeTotalResource) * 100, HUNDREDTHS_PLACE);
+ }
+
+ return null;
+};
+
+const maybeLink = (name, value) => {
+ if (name === STAT_NAMES.slaveIdStat) {
+ return { href : `tasks/active/all/${value}`,
+ title : `All tasks running on host ${value}`
+ };
+ }
+
+ return null;
+};
+
+const SlaveResourceHealthMenuItems = ({stats}) => {
+ const renderSlaveStats = _.map(stats.sort(compareStats), ({name, value, maybeTotalResource}) => {
+ return ;
+ });
+
+ return (
+
+ {renderSlaveStats}
+
-
+
+
+ Last updated {Utils.timestampFromNow(stats.find((stat) => stat.name === STAT_NAMES.timestampStat).value)}
+
+
+
+
+ );
+};
+
+SlaveResourceHealthMenuItems.propTypes = {
+ stats : PropTypes.arrayOf(
+ PropTypes.shape({
+ name : PropTypes.string.isRequired,
+ value : PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.number
+ ]).isRequired,
+ maybeTotalResource : PropTypes.oneOfType([
+ PropTypes.string,
+ PropTypes.number
+ ])
+ })
+ )
+};
+
+export default SlaveResourceHealthMenuItems;
diff --git a/SingularityUI/app/components/machines/SlaveUsage.jsx b/SingularityUI/app/components/machines/SlaveUsage.jsx
new file mode 100644
index 0000000000..158708c2db
--- /dev/null
+++ b/SingularityUI/app/components/machines/SlaveUsage.jsx
@@ -0,0 +1,92 @@
+import React, { PropTypes } from 'react';
+import { connect } from 'react-redux';
+import rootComponent from '../../rootComponent';
+import { FetchSlaveUsages, FetchSlaves } from '../../actions/api/slaves';
+import { FetchSingularityStatus } from '../../actions/api/state';
+import { STAT_NAMES, WHOLE_NUMBER, HEALTH_SCALE_MAX } from './Constants';
+import Utils from '../../utils';
+import ResourceHealthData from './ResourceHealthData';
+import SlaveAggregates from './SlaveAggregates';
+
+const getSlaveInfo = (slaves, slaveUsage) => {
+ return _.findWhere(slaves, {'id' : slaveUsage.slaveId});
+};
+
+const getUtilizationData = (slaves, slaveUsages) => {
+ return slaveUsages.map((slaveUsage) => {
+ const slaveInfo = getSlaveInfo(slaves, slaveUsage);
+
+ const totalCpuResource = Utils.getMaxAvailableResource(slaveInfo, STAT_NAMES.cpusUsedStat);
+ const cpuUtilized = Utils.roundTo((slaveUsage[STAT_NAMES.cpusUsedStat] / totalCpuResource) * HEALTH_SCALE_MAX, WHOLE_NUMBER);
+
+ const totalMemoryResource = Utils.getMaxAvailableResource(slaveInfo, STAT_NAMES.memoryBytesUsedStat);
+ const memoryUtilized = Utils.roundTo((slaveUsage[STAT_NAMES.memoryBytesUsedStat] / totalMemoryResource) * HEALTH_SCALE_MAX, WHOLE_NUMBER);
+
+ return {slaveInfo, slaveUsage, totalCpuResource, cpuUtilized, totalMemoryResource, memoryUtilized};
+ });
+};
+
+const SlaveUsage = ({slaves, slaveUsages, activeTasks}) => {
+ const activeSlaves = slaves.filter(Utils.isActiveSlave);
+ const utilizationData = getUtilizationData(activeSlaves, slaveUsages);
+
+ const cpuHealthData = utilizationData.sort((a, b) => a.cpuUtilized - b.cpuUtilized).map((data, index) => {
+ return ;
+ });
+
+ const memoryHealthData = utilizationData.sort((a, b) => a.memoryUtilized - b.memoryUtilized).map((data, index) => {
+ return ;
+ });
+
+ return (
+
+
Slave Usage
+
+
+
+
+
+
Slave health
+
Cpu
+
+ {cpuHealthData}
+
+
Memory
+
+ {memoryHealthData}
+
+
+
+ );
+};
+
+SlaveUsage.propTypes = {
+ slaveUsages : PropTypes.arrayOf(PropTypes.object),
+ slaves : PropTypes.arrayOf(PropTypes.object),
+ activeTasks : PropTypes.number
+};
+
+function mapStateToProps(state) {
+ return {
+ slaveUsages : state.api.slaveUsages.data,
+ slaves : state.api.slaves.data,
+ activeTasks : state.api.status.data.activeTasks
+ };
+}
+
+function mapDispatchToProps(dispatch) {
+ return {
+ fetchSlaves : () => dispatch(FetchSlaves.trigger()),
+ fetchSlaveUsages : () => dispatch(FetchSlaveUsages.trigger()),
+ fetchSingularityStatus : () => dispatch(FetchSingularityStatus.trigger())
+ };
+}
+
+const refresh = () => (dispatch) =>
+ Promise.all([
+ dispatch(FetchSlaves.trigger()),
+ dispatch(FetchSlaveUsages.trigger()),
+ dispatch(FetchSingularityStatus.trigger())
+ ]);
+
+export default connect(mapStateToProps, mapDispatchToProps)(rootComponent(SlaveUsage, refresh, true, true));
diff --git a/SingularityUI/app/components/machines/StatItem.jsx b/SingularityUI/app/components/machines/StatItem.jsx
new file mode 100644
index 0000000000..4b49c925d0
--- /dev/null
+++ b/SingularityUI/app/components/machines/StatItem.jsx
@@ -0,0 +1,49 @@
+import React, { PropTypes } from 'react';
+import { Link } from 'react-router';
+import CopyToClipboard from 'react-copy-to-clipboard';
+
+const valueWithPotentialLink = (value, maybeLink) => {
+ if (maybeLink) {
+ return {value};
+ }
+
+ return value;
+};
+
+const StatItem = ({name, value, maybeLink, percentage}) => {
+ return (
+ -
+
+
+
+ {name}
+
+
+
+
+ {valueWithPotentialLink(value, maybeLink)}
+
+
+ {percentage != null &&
+
+
+ {percentage}%
+
+
+ }
+
+
+ );
+};
+
+StatItem.propTypes = {
+ name : PropTypes.string,
+ value : PropTypes.string,
+ maybeLink : PropTypes.shape({
+ href : PropTypes.string,
+ title : PropTypes.string
+ }),
+ percentage : PropTypes.number
+};
+
+export default StatItem;
diff --git a/SingularityUI/app/reducers/api/index.es6 b/SingularityUI/app/reducers/api/index.es6
index 9d790c922b..94f0ae1546 100644
--- a/SingularityUI/app/reducers/api/index.es6
+++ b/SingularityUI/app/reducers/api/index.es6
@@ -58,7 +58,8 @@ import {
RemoveSlave,
ReactivateSlave,
FetchExpiringSlaveStates,
- RemoveExpiringSlaveState
+ RemoveExpiringSlaveState,
+ FetchSlaveUsages
} from '../../actions/api/slaves';
import {
@@ -101,6 +102,7 @@ const removeSlave = buildApiActionReducer(RemoveSlave, []);
const reactivateSlave = buildApiActionReducer(ReactivateSlave, []);
const expiringSlaveStates = buildApiActionReducer(FetchExpiringSlaveStates, []);
const removeExpiringSlaveState = buildApiActionReducer(RemoveExpiringSlaveState, []);
+const slaveUsages = buildApiActionReducer(FetchSlaveUsages, []);
const racks = buildApiActionReducer(FetchRacks, []);
const freezeRack = buildApiActionReducer(FreezeRack, []);
const decommissionRack = buildApiActionReducer(DecommissionRack, []);
@@ -154,6 +156,7 @@ export default combineReducers({
reactivateSlave,
expiringSlaveStates,
removeExpiringSlaveState,
+ slaveUsages,
racks,
freezeRack,
decommissionRack,
diff --git a/SingularityUI/app/router.jsx b/SingularityUI/app/router.jsx
index cb54b41885..10b3320420 100644
--- a/SingularityUI/app/router.jsx
+++ b/SingularityUI/app/router.jsx
@@ -12,6 +12,7 @@ import RequestsPage from './components/requests/RequestsPage';
import TasksPage from './components/tasks/TasksPage';
import Racks from './components/machines/Racks';
import Slaves from './components/machines/Slaves';
+import SlaveUsage from './components/machines/SlaveUsage';
import Webhooks from './components/webhooks/Webhooks';
import TaskDetail from './components/taskDetail/TaskDetail';
import TaskSearch from './components/taskSearch/TaskSearch';
@@ -54,6 +55,7 @@ const routes = (
+
diff --git a/SingularityUI/app/styles/stylus/circular-progress-bar.styl b/SingularityUI/app/styles/stylus/circular-progress-bar.styl
new file mode 100644
index 0000000000..072492e1f7
--- /dev/null
+++ b/SingularityUI/app/styles/stylus/circular-progress-bar.styl
@@ -0,0 +1,16 @@
+.CircularProgressbar .CircularProgressbar-path {
+ stroke: #3e98c7;
+ stroke-linecap: round;
+ transition: stroke-dashoffset 0.5s ease 0s;
+}
+
+.CircularProgressbar .CircularProgressbar-trail {
+ stroke: #d6d6d6;
+}
+
+.CircularProgressbar .CircularProgressbar-text {
+ fill: #3e98c7;
+ font-size: 20px;
+ dominant-baseline: middle;
+ text-anchor: middle;
+}
\ No newline at end of file
diff --git a/SingularityUI/app/styles/stylus/colors.styl b/SingularityUI/app/styles/stylus/colors.styl
index a0e162a453..64ed479b1f 100644
--- a/SingularityUI/app/styles/stylus/colors.styl
+++ b/SingularityUI/app/styles/stylus/colors.styl
@@ -19,6 +19,10 @@ $purple = #b93c96
$dark-yellow = #e6e65c
$removeRed = #df6f6f
+$alert-danger = #f04b51
+$alert-warning = #fdf3e1
+$alert-success = #5967bc
+
$nav-yellow-start = #fcf8e3
$nav-yellow-end = #faf2cc
$nav-yellow-dark = #F8EDB4
diff --git a/SingularityUI/app/styles/stylus/slaveUsage.styl b/SingularityUI/app/styles/stylus/slaveUsage.styl
new file mode 100644
index 0000000000..f344cef005
--- /dev/null
+++ b/SingularityUI/app/styles/stylus/slaveUsage.styl
@@ -0,0 +1,100 @@
+#slave-usage-page
+ margin-left : 85px
+ margin-right : 85px
+
+#slave-usage-page h1
+ height: 43px;
+ font-size: 36px;
+ font-weight: 500;
+ line-height: 44px;
+ color: #425B76;
+
+#slave-usage-page hr
+ margin-left : -85px
+
+// SlaveAggregates
+.total-slaves, .total-tasks
+ padding-top : 20px
+
+.total-slaves #value, .total-tasks #value
+ font-size : 60px
+ text-align : center
+ color: #7C98B6;
+
+.total-slaves #label, .total-tasks #label, .avg-cpu #label, .avg-memory #label
+ font-size : 20px
+ text-align : center
+ font-weight: 300;
+ color: #7C98B6;
+
+.avg-cpu
+ margin-left : 10px
+ width : 160px
+
+.avg-memory
+ margin-left : 10px
+ width : 160px
+
+// SlaveHealth
+.single-slave-btn
+ padding : 15px 15px
+ border-radius : 0px
+ display : inline-block
+ opacity : .8
+
+.single-slave-btn:hover
+ opacity : 1
+
+#slave-usage-quick-stats
+ color : #7C98B6
+ margin-bottom : 0
+ font-size : 14px
+ height : 34px
+
+#slave-usage-quick-stats #slave-name
+ padding-top : 10px
+ padding-right : 0px
+
+#slave-usage-quick-stats #pct-utilized
+ color : #425B76
+ text-align : center
+
+#slave-usage-quick-stats #status
+ font-size : 12px
+ padding-left : 3px
+ white-space : nowrap
+
+#slave-usage-quick-stats #memory-stats
+ padding-left : 5px
+
+
+// SlaveHealthMenuItems
+.dropdown-menu
+ border-radius : 0px
+
+.stat-item-detail
+ white-space : nowrap
+ padding-left : 30px
+ padding-right : 5px
+ width : 350px
+
+.timestamp-stat
+ color : #666
+ font-size : 13px
+ padding-right : 5px
+ text-align : right
+
+
+.stat-item-detail .row
+ margin-bottom : .5em
+
+#stat-name
+ color : #666
+ font-size : 13px
+ display : inline
+
+#stat-value
+ display : inline
+
+#stat-percentage
+ display : inline
diff --git a/SingularityUI/app/utils.es6 b/SingularityUI/app/utils.es6
index 6113d38800..8748042022 100644
--- a/SingularityUI/app/utils.es6
+++ b/SingularityUI/app/utils.es6
@@ -1,4 +1,5 @@
import moment from 'moment';
+import { STAT_NAMES } from './components/machines/Constants';
const Utils = {
TERMINAL_TASK_STATES: ['TASK_KILLED', 'TASK_LOST', 'TASK_FAILED', 'TASK_FINISHED', 'TASK_ERROR'],
@@ -45,6 +46,10 @@ const Utils = {
));
},
+ humanizeSlaveHostName(longHostName, override=false) {
+ return (config.shortenSlaveUsageHostname || override ? longHostName.split('.')[0] : longHostName);
+ },
+
timestampFromNow(millis) {
const timeObject = moment(millis);
return `${timeObject.fromNow()} (${timeObject.format(window.config.timestampFormat)})`;
@@ -169,6 +174,29 @@ const Utils = {
};
},
+ getMaxAvailableResource(slaveInfo, statName) {
+ switch (statName) {
+ case STAT_NAMES.cpusUsedStat:
+ try {
+ return parseFloat(slaveInfo.attributes.real_cpus || slaveInfo.resources.cpus);
+ } catch (e) {
+ throw new Error(`Could not find resource (cpus) for slave ${slaveInfo.host} (${slaveInfo.id})`);
+ }
+ case STAT_NAMES.memoryBytesUsedStat:
+ try {
+ return parseFloat(slaveInfo.attributes.real_memory_mb || slaveInfo.resources.mem) * Math.pow(1024, 2);
+ } catch (e) {
+ throw new Error(`Could not find resource (memory) for slave ${slaveInfo.host} (${slaveInfo.id})`);
+ }
+ default:
+ throw new Error(`${statName} is an unsupported statistic'`);
+ }
+ },
+
+ isResourceStat(stat) {
+ return stat === STAT_NAMES.cpusUsedStat || stat === STAT_NAMES.memoryBytesUsedStat;
+ },
+
getRequestIdFromTaskId(taskId) {
const splits = taskId.split('-');
return splits.slice(0, splits.length - 5).join('-');
@@ -211,6 +239,10 @@ const Utils = {
return filename.replace(new RegExp(finalRegex), '');
},
+ roundTo(value, place) {
+ return +(Math.round(parseFloat(value) + 'e+' + place) + 'e-' + place);
+ },
+
millisecondsToSecondsRoundToTenth(millis) {
return Math.round(millis / 100) / 10;
},
@@ -389,8 +421,6 @@ const Utils = {
},
},
-
-
isImmediateCleanup: (cleanupType, longRunning) => {
if (longRunning) {
return _.contains(Utils.LONG_RUNNING_IMMEDIATE_CLEANUPS, cleanupType)
@@ -399,6 +429,10 @@ const Utils = {
}
},
+ isActiveSlave(slaveInfo) {
+ return !Utils.isIn(slaveInfo.currentState.state, ['DEAD', 'MISSING_ON_STARTUP']);
+ },
+
enums: {
SingularityRequestTypes: ['SERVICE', 'WORKER', 'SCHEDULED', 'ON_DEMAND', 'RUN_ONCE'],
SingularityEmailDestination: ['OWNERS', 'ACTION_TAKER', 'ADMINS'],
@@ -432,7 +466,6 @@ const Utils = {
}
return array.join('&');
}
-
};
export default Utils;
diff --git a/SingularityUI/gulpfile.js b/SingularityUI/gulpfile.js
index 18d682face..2d320475cd 100644
--- a/SingularityUI/gulpfile.js
+++ b/SingularityUI/gulpfile.js
@@ -39,6 +39,7 @@ var templateData = {
taskS3LogOmitPrefix: process.env.SINGULARITY_TASK_S3_LOG_OMIT_PREFIX || '',
warnIfScheduledJobIsRunningPastNextRunPct: process.env.SINGULARITY_WARN_IF_SCHEDULED_JOB_IS_RUNNING_PAST_NEXT_RUN_PCT || 200,
shellCommands: process.env.SINGULARITY_SHELL_COMMANDS || '[]',
+ shortenSlaveUsageHostname: process.env.SINGULARITY_SHORTEN_SLAVE_USAGE_HOSTNAME || 'false',
timestampFormat: process.env.SINGULARITY_TIMESTAMP_FORMAT || 'lll',
timestampWithSecondsFormat: process.env.SINGULARITY_TIMESTAMP_WITH_SECONDS_FORMAT || 'lll:ss',
redirectOnUnauthorizedUrl: process.env.SINGULARITY_REDIRECT_ON_UNAUTHORIZED_URL || '',
diff --git a/SingularityUI/package.json b/SingularityUI/package.json
index b1e5e4d9fb..701e8100e3 100644
--- a/SingularityUI/package.json
+++ b/SingularityUI/package.json
@@ -18,6 +18,8 @@
"test:watch": "npm test -- --watch"
},
"dependencies": {
+ "bootstrap": "~3.3.0",
+ "chroma-js": "^1.2.2",
"ansi-style-parser": "^1.0.1",
"babel-runtime": "^6.9.2",
"bootstrap-sass": "^3.3.6",
@@ -54,6 +56,8 @@
"react-tagsinput": "^3.13.0",
"react-typeahead": "git://github.com/HubSpot/react-typeahead.git#hubspot-3",
"react-waypoint": "^2.0.3",
+ "react-copy-to-clipboard": "^4.2.3",
+ "react-circular-progressbar": "^0.1.3",
"redux": "^3.5.2",
"redux-form": "^5.3.0",
"redux-logger": "^2.6.1",
@@ -77,6 +81,7 @@
"babel-plugin-transform-react-jsx": "^6.8.0",
"babel-plugin-transform-runtime": "^6.9.0",
"babel-preset-es2015": "^6.9.0",
+ "chroma-js": "^1.2.2",
"babel-register": "^6.9.0",
"browser-sync": "^2.13.0",
"case-sensitive-paths-webpack-plugin": "^1.1.3",
@@ -85,7 +90,7 @@
"eslint": "^2.11.1",
"eslint-config-hubspot": "^6.2.0",
"eslint-plugin-babel": "^3.2.0",
- "eslint-plugin-react": "^4.2.3",
+ "eslint-plugin-react": "^4.3.0",
"expect": "^1.20.2",
"exports-loader": "^0.6.3",
"extend": "^3.0.0",
@@ -98,6 +103,8 @@
"mocha": "^3.1.2",
"nib": "^1.1.0",
"node-sass": "^3.8.0",
+ "react-copy-to-clipboard": "^4.2.3",
+ "react-circular-progressbar": "^0.1.3",
"react-hot-loader": "^3.0.0-beta.2",
"resolve-url-loader": "^1.6.0",
"sass-loader": "^4.0.0",