diff --git a/app/router.go b/app/router.go index ccdcadee4..a1c866173 100644 --- a/app/router.go +++ b/app/router.go @@ -128,6 +128,8 @@ func apiHandler(w http.ResponseWriter, r *http.Request) { respondWith(w, http.StatusOK, APIDetails{Version: version}) } +// Topology option labels should tell the current state. The first item must +// be the verb to get to that state var topologyRegistry = map[string]topologyView{ "applications": { human: "Applications", @@ -144,8 +146,8 @@ var topologyRegistry = map[string]topologyView{ parent: "", renderer: render.ContainerWithImageNameRenderer{}, options: optionParams{"system": { - {"show", "Show system containers", false, nop}, - {"hide", "Hide system containers", true, render.FilterSystem}, + {"show", "System containers shown", false, nop}, + {"hide", "System containers hidden", true, render.FilterSystem}, }}, }, "containers-by-image": { @@ -153,8 +155,8 @@ var topologyRegistry = map[string]topologyView{ parent: "containers", renderer: render.ContainerImageRenderer, options: optionParams{"system": { - {"show", "Show system containers", false, nop}, - {"hide", "Hide system containers", true, render.FilterSystem}, + {"show", "System containers shown", false, nop}, + {"hide", "System containers hidden", true, render.FilterSystem}, }}, }, "hosts": { diff --git a/client/app/scripts/charts/nodes-chart.js b/client/app/scripts/charts/nodes-chart.js index 039ac5f21..318b29fbe 100644 --- a/client/app/scripts/charts/nodes-chart.js +++ b/client/app/scripts/charts/nodes-chart.js @@ -5,6 +5,7 @@ const React = require('react'); const timely = require('timely'); const Spring = require('react-motion').Spring; +const AppActions = require('../actions/app-actions'); const AppStore = require('../stores/app-store'); const Edge = require('./edge'); const Naming = require('../constants/naming'); @@ -28,7 +29,7 @@ const NodesChart = React.createClass({ return { nodes: {}, edges: {}, - nodeScale: 1, + nodeScale: d3.scale.linear(), translate: [0, 0], panTranslate: [0, 0], scale: 1, @@ -47,6 +48,7 @@ const NodesChart = React.createClass({ .on('zoom', this.zoomed); d3.select('.nodes-chart') + .on('click', this.handleBackgroundClick) .call(this.zoom); }, @@ -79,6 +81,7 @@ const NodesChart = React.createClass({ // undoing .call(zoom) d3.select('.nodes-chart') + .on('click', null) .on('mousedown.zoom', null) .on('onwheel', null) .on('onmousewheel', null) @@ -288,25 +291,29 @@ const NodesChart = React.createClass({ const visibleWidth = Math.max(props.width - props.detailsWidth, 0); const translate = state.translate; const offsetX = translate[0]; - if (offsetX + centerX + radius > visibleWidth) { + // normalize graph coordinates by zoomScale + const zoomScale = state.scale; + const outerRadius = radius + this.state.nodeScale(2); + if (offsetX + (centerX + outerRadius) * zoomScale > visibleWidth) { // shift left if blocked by details - const shift = centerX + radius - visibleWidth; + const shift = (centerX + outerRadius) * zoomScale - visibleWidth; translate[0] = -shift; - } else if (offsetX + centerX - radius < 0) { + } else if (offsetX + (centerX - outerRadius) * zoomScale < 0) { // shift right if off canvas - const shift = offsetX - offsetX + centerX - radius; + const shift = offsetX - offsetX + (centerX - outerRadius) * zoomScale; translate[0] = -shift; } const offsetY = translate[1]; - if (offsetY + centerY + radius > props.height) { + if (offsetY + (centerY + outerRadius) * zoomScale > props.height) { // shift up if past bottom - const shift = centerY + radius - props.height; + const shift = (centerY + outerRadius) * zoomScale - props.height; translate[1] = -shift; - } else if (offsetY + centerY - radius - props.topMargin < 0) { + } else if (offsetY + (centerY - outerRadius) * zoomScale - props.topMargin < 0) { // shift down if off canvas - const shift = offsetY - offsetY + centerY - radius - props.topMargin; + const shift = offsetY - offsetY + (centerY - outerRadius) * zoomScale - props.topMargin; translate[1] = -shift; } + // debug('shift', centerX, centerY, outerRadius, translate); // saving translate in d3's panning cache this.zoom.translate(translate); @@ -318,6 +325,10 @@ const NodesChart = React.createClass({ }; }, + handleBackgroundClick: function() { + AppActions.clickCloseDetails(); + }, + restoreLayout: function(state) { const edges = state.edges; const nodes = state.nodes; @@ -348,7 +359,7 @@ const NodesChart = React.createClass({ const expanse = Math.min(props.height, props.width); const nodeSize = expanse / 2; - const nodeScale = d3.scale.linear().range([0, nodeSize / Math.pow(n, 0.7)]); + const nodeScale = this.state.nodeScale.range([0, nodeSize / Math.pow(n, 0.7)]); const timedLayouter = timely(NodesLayout.doLayout); const graph = timedLayouter( @@ -398,6 +409,7 @@ const NodesChart = React.createClass({ }, zoomed: function() { + // debug('zoomed', d3.event.scale, d3.event.translate); this.setState({ hasZoomed: true, panTranslate: d3.event.translate.slice(), diff --git a/client/app/scripts/components/app.js b/client/app/scripts/components/app.js index b02e79768..9f1fa120b 100644 --- a/client/app/scripts/components/app.js +++ b/client/app/scripts/components/app.js @@ -1,7 +1,9 @@ const React = require('react'); +const mui = require('material-ui'); const Logo = require('./logo'); const AppStore = require('../stores/app-store'); +const Sidebar = require('./sidebar.js'); const Status = require('./status.js'); const Topologies = require('./topologies.js'); const TopologyOptions = require('./topology-options.js'); @@ -11,6 +13,8 @@ const Details = require('./details'); const Nodes = require('./nodes'); const RouterUtils = require('../utils/router-utils'); +const ThemeManager = new mui.Styles.ThemeManager(); + const ESC_KEY_CODE = 27; function getStateFromStores() { @@ -26,6 +30,7 @@ function getStateFromStores() { nodeDetails: AppStore.getNodeDetails(), nodes: AppStore.getNodes(), topologies: AppStore.getTopologies(), + topologiesLoaded: AppStore.isTopologiesLoaded(), version: AppStore.getVersion(), websocketClosed: AppStore.isWebsocketClosed() }; @@ -57,11 +62,17 @@ const App = React.createClass({ } }, + getChildContext: function() { + return { + muiTheme: ThemeManager.getCurrentTheme() + }; + }, + render: function() { const showingDetails = this.state.selectedNodeId; const versionString = this.state.version ? 'Version ' + this.state.version : ''; // width of details panel blocking a view - const detailsWidth = showingDetails ? 420 : 0; + const detailsWidth = showingDetails ? 450 : 0; const topMargin = 100; return ( @@ -73,9 +84,6 @@ const App = React.createClass({
- -
+ + + + +
{versionString}   Report an issue
); - } + }, + childContextTypes: { + muiTheme: React.PropTypes.object + } }); module.exports = App; diff --git a/client/app/scripts/components/details.js b/client/app/scripts/components/details.js index bccab8f1a..811b7b642 100644 --- a/client/app/scripts/components/details.js +++ b/client/app/scripts/components/details.js @@ -10,7 +10,7 @@ const Details = React.createClass({ render: function() { return (
- +
diff --git a/client/app/scripts/components/sidebar.js b/client/app/scripts/components/sidebar.js new file mode 100644 index 000000000..659b7226e --- /dev/null +++ b/client/app/scripts/components/sidebar.js @@ -0,0 +1,15 @@ +const React = require('react'); + +const Sidebar = React.createClass({ + + render: function() { + return ( +
+ {this.props.children} +
+ ); + } + +}); + +module.exports = Sidebar; diff --git a/client/app/scripts/components/status.js b/client/app/scripts/components/status.js index d5b726f58..43a698d6b 100644 --- a/client/app/scripts/components/status.js +++ b/client/app/scripts/components/status.js @@ -2,22 +2,34 @@ const React = require('react'); const Status = React.createClass({ - renderConnectionState: function(errorUrl, websocketClosed) { - if (errorUrl || websocketClosed) { - const title = errorUrl ? 'Cannot reach Scope. Make sure the following URL is reachable: ' + errorUrl : ''; - return ( -
- - Trying to reconnect... -
- ); - } - }, - render: function() { + let title = ''; + let text = 'Trying to reconnect...'; + let showWarningIcon = false; + let classNames = 'status sidebar-item'; + + if (this.props.errorUrl) { + title = `Cannot reach Scope. Make sure the following URL is reachable: ${this.props.errorUrl}`; + classNames += ' status-loading'; + showWarningIcon = true; + } else if (!this.props.topologiesLoaded) { + text = 'Loading topologies...'; + classNames += ' status-loading'; + showWarningIcon = false; + } else if (this.props.websocketClosed) { + classNames += ' status-loading'; + showWarningIcon = true; + } else if (this.props.topology) { + const stats = this.props.topology.stats; + text = `${stats.node_count} nodes, ${stats.edge_count} connections`; + classNames += ' status-stats'; + showWarningIcon = false; + } + return ( -
- {this.renderConnectionState(this.props.errorUrl, this.props.websocketClosed)} +
+ {showWarningIcon && } + {text}
); } diff --git a/client/app/scripts/components/topology-option-action.js b/client/app/scripts/components/topology-option-action.js new file mode 100644 index 000000000..2101bc307 --- /dev/null +++ b/client/app/scripts/components/topology-option-action.js @@ -0,0 +1,22 @@ +const React = require('react'); + +const AppActions = require('../actions/app-actions'); + +const TopologyOptionAction = React.createClass({ + + onClick: function(ev) { + ev.preventDefault(); + AppActions.changeTopologyOption(this.props.option, this.props.value); + }, + + render: function() { + return ( + + {this.props.value} + + ); + } + +}); + +module.exports = TopologyOptionAction; diff --git a/client/app/scripts/components/topology-options.js b/client/app/scripts/components/topology-options.js index 38855a486..435ad62a7 100644 --- a/client/app/scripts/components/topology-options.js +++ b/client/app/scripts/components/topology-options.js @@ -1,40 +1,35 @@ const React = require('react'); const _ = require('lodash'); -const mui = require('material-ui'); -const DropDownMenu = mui.DropDownMenu; -const AppActions = require('../actions/app-actions'); +const TopologyOptionAction = require('./topology-option-action'); const TopologyOptions = React.createClass({ - componentDidMount: function() { - this.fixWidth(); - }, - - onChange: function(ev, index, item) { - ev.preventDefault(); - AppActions.changeTopologyOption(item.option, item.payload); + renderAction: function(action, option) { + return ( + + ); }, renderOption: function(items) { - let selected = 0; - let key; + let activeText; + const actions = []; const activeOptions = this.props.activeOptions; - const menuItems = items.map(function(item, index) { + items.forEach(function(item) { if (activeOptions[item.option] && activeOptions[item.option] === item.value) { - selected = index; + activeText = item.display; + } else { + actions.push(this.renderAction(item.value, item.option)); } - key = item.option; - return { - option: item.option, - payload: item.value, - text: item.display - }; - }); + }, this); return ( - +
+ {activeText} + + {actions} + +
); }, @@ -51,27 +46,14 @@ const TopologyOptions = React.createClass({ ); return ( -
+
{options.map(function(items) { return this.renderOption(items); }, this)}
); - }, - - componentDidUpdate: function() { - this.fixWidth(); - }, - - fixWidth: function() { - const containerNode = this.refs.container.getDOMNode(); - _.each(containerNode.childNodes, function(child) { - // set drop down width to length of current label - const label = child.getElementsByClassName('mui-menu-label')[0]; - const width = label.getBoundingClientRect().width + 40; - child.style.width = width + 'px'; - }); } + }); module.exports = TopologyOptions; diff --git a/client/app/scripts/stores/app-store.js b/client/app/scripts/stores/app-store.js index 09aa0aac4..aa689c7cd 100644 --- a/client/app/scripts/stores/app-store.js +++ b/client/app/scripts/stores/app-store.js @@ -56,6 +56,7 @@ let nodes = makeOrderedMap(); let nodeDetails = null; let selectedNodeId = null; let topologies = []; +let topologiesLoaded = false; let websocketClosed = true; function setTopology(topologyId) { @@ -192,6 +193,10 @@ const AppStore = assign({}, EventEmitter.prototype, { return version; }, + isTopologiesLoaded: function() { + return topologiesLoaded; + }, + isWebsocketClosed: function() { return websocketClosed; } @@ -316,6 +321,7 @@ AppStore.registeredCallback = function(payload) { case ActionTypes.RECEIVE_TOPOLOGIES: errorUrl = null; + topologiesLoaded = true; topologies = payload.topologies; if (!currentTopology) { setTopology(currentTopologyId); diff --git a/client/app/styles/main.less b/client/app/styles/main.less index 85d063a05..f7d843f60 100644 --- a/client/app/styles/main.less +++ b/client/app/styles/main.less @@ -1,6 +1,3 @@ -@import "~material-ui/src/less/scaffolding.less"; -@import "~material-ui/src/less/components.less"; - @font-face { font-family: "Roboto"; src: url("../../node_modules/materialize-css/font/roboto/Roboto-Regular.woff2"), @@ -8,7 +5,6 @@ url("../../node_modules/materialize-css/font/roboto/Roboto-Regular.ttf"); } - .browsehappy { margin: 0.2em 0; background: #ccc; @@ -32,12 +28,6 @@ @text-darker-color: @primary-color; @white: @background-secondary-color; -html, body { -} - -.wrap { -} - /* add this class to truncate text with ellipsis, container needs width */ .truncate { white-space: nowrap; @@ -45,11 +35,49 @@ html, body { text-overflow: ellipsis; } +* { + box-sizing: border-box; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); +} +*:before, +*:after { + box-sizing: border-box; +} + +html { + -webkit-font-smoothing: antialiased; +} + +html, +body { + height: 100%; + width: 100%; +} + /* Space out content a bit */ body { background: linear-gradient(30deg, @background-color 0%, @background-secondary-color 100%); color: @text-color; line-height: 150%; + font-family: "Roboto", sans-serif; + font-size: 13px; +} + +p { + line-height: 20px; + padding-top: 6px; + margin-bottom: 14px; + letter-spacing: 0; + font-weight: 400; + color: @text-color; +} + +h2 { + font-size: 34px; + line-height: 40px; + padding-top: 8px; + margin-bottom: 12px; + font-weight: 400; } #app { @@ -135,15 +163,10 @@ body { } .status { - float: right; - margin-top: 14px; - margin-right: 64px; - &-icon { font-size: 16px; position: relative; top: 1px; - color: @text-secondary-color; } &-label { @@ -280,26 +303,6 @@ body { } } -.mui-paper, .mui-paper-container { - height: 100%; -} - -.mui-drop-down-menu { - .mui-menu-control { - .mui-menu-label { - font-size: 12px; - } - - .mui-menu-control-underline { - border-top: none; - } - - .mui-menu-control-bg { - background-color: transparent; - } - } -} - .node-details { height: 100%; width: 100%; @@ -311,7 +314,7 @@ body { &-label { color: white; - margin-bottom: 0; + margin: 0; width: 348px; &-minor { @@ -330,7 +333,6 @@ body { position: absolute; top: 115px; bottom: 0; - width: 100%; padding: 0 36px 0 36px; overflow-y: scroll; @@ -383,3 +385,45 @@ body { } +.sidebar { + position: fixed; + bottom: 16px; + left: 20px; + width: 18em; + font-size: 85%; + + &-item { + background-color: darken(@background-color, 8%); + border-radius: 2px; + padding: 4px 8px; + width: 100%; + margin-top: 2px; + + &.status { + background-color: darken(@background-color, 4%); + color: @text-secondary-color; + } + &.status-loading { + animation: status-loading 2.0s infinite ease-in-out; + } + + &-action { + float: right; + text-transform: uppercase; + font-weight: bold; + color: darken(@weave-orange, 25%); + cursor: pointer; + font-size: 90%; + } + } +} + +@keyframes status-loading { + 0%, 100% { + background-color: darken(@background-color, 4%); + color: @text-secondary-color; + } 50% { + background-color: darken(@background-color, 8%); + color: @text-color; + } +} diff --git a/client/package.json b/client/package.json index 0d38001c6..841e743ab 100644 --- a/client/package.json +++ b/client/package.json @@ -15,7 +15,7 @@ "immutable": "^3.7.4", "keymirror": "^0.1.1", "lodash": "~3.9.3", - "material-ui": "~0.7.5", + "material-ui": "^0.11.0", "materialize-css": "^0.96.1", "object-assign": "^2.0.0", "page": "^1.6.3",