mirror of
https://github.com/weaveworks/scope.git
synced 2026-05-15 21:58:33 +00:00
Node grid view
More graphs! Rank is not support by dagre any longer.. Quick go at using facebook's fixed-data-table Kind of working, kind of interesting. Hack on the details-panel table, supports sorting etc already! No, this one! Hacks on the details panel's table. Hovering on the table works! (highlights nodes) wip get sorting going Working on sorting, not behaving! Pulling out methods to fns Kind of demoable More hacks to make it demoable
This commit is contained in:
@@ -78,6 +78,13 @@ export function unpinNetwork(networkId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function sortOrderChanged(newOrder) {
|
||||
AppDispatcher.dispatch({
|
||||
type: ActionTypes.SORT_ORDER_CHANGED,
|
||||
newOrder
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
//
|
||||
// Metrics
|
||||
|
||||
@@ -5,6 +5,7 @@ import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Map as makeMap, fromJS, is as isDeepEqual } from 'immutable';
|
||||
import timely from 'timely';
|
||||
import { Set as makeSet } from 'immutable';
|
||||
|
||||
import { clickBackground } from '../actions/app-actions';
|
||||
import { EDGE_ID_SEPARATOR } from '../constants/naming';
|
||||
@@ -17,18 +18,12 @@ import { getActiveTopologyOptions, getAdjacentNodes,
|
||||
|
||||
const log = debug('scope:nodes-chart');
|
||||
|
||||
const MARGINS = {
|
||||
top: 130,
|
||||
left: 40,
|
||||
right: 40,
|
||||
bottom: 0
|
||||
};
|
||||
|
||||
const ZOOM_CACHE_FIELDS = ['scale', 'panTranslateX', 'panTranslateY'];
|
||||
|
||||
// make sure circular layouts a bit denser with 3-6 nodes
|
||||
const radiusDensity = d3.scale.threshold()
|
||||
.domain([3, 6]).range([2.5, 3.5, 3]);
|
||||
.domain([3, 6])
|
||||
.range([2.5, 3.5, 3]);
|
||||
|
||||
class NodesChart extends React.Component {
|
||||
|
||||
@@ -47,8 +42,8 @@ class NodesChart extends React.Component {
|
||||
scale: 1,
|
||||
selectedNodeScale: d3.scale.linear(),
|
||||
hasZoomed: false,
|
||||
height: 0,
|
||||
width: 0,
|
||||
height: props.height || 0,
|
||||
width: props.width || 0,
|
||||
zoomCache: {}
|
||||
};
|
||||
}
|
||||
@@ -67,7 +62,7 @@ class NodesChart extends React.Component {
|
||||
// re-apply cached canvas zoom/pan to d3 behavior (or set defaul values)
|
||||
const defaultZoom = { scale: 1, panTranslateX: 0, panTranslateY: 0, hasZoomed: false };
|
||||
const nextZoom = this.state.zoomCache[nextProps.topologyId] || defaultZoom;
|
||||
if (nextZoom) {
|
||||
if (nextZoom && this.zoom) {
|
||||
this.zoom.scale(nextZoom.scale);
|
||||
this.zoom.translate([nextZoom.panTranslateX, nextZoom.panTranslateY]);
|
||||
}
|
||||
@@ -85,13 +80,13 @@ class NodesChart extends React.Component {
|
||||
}
|
||||
|
||||
// reset layout dimensions only when forced
|
||||
state.height = nextProps.forceRelayout ? nextProps.height : (state.height || nextProps.height);
|
||||
state.width = nextProps.forceRelayout ? nextProps.width : (state.width || nextProps.width);
|
||||
state.height = nextProps.height;
|
||||
state.width = nextProps.width;
|
||||
|
||||
// _.assign(state, this.updateGraphState(nextProps, state));
|
||||
if (nextProps.forceRelayout || !isSameTopology(nextProps.nodes, this.props.nodes)) {
|
||||
_.assign(state, this.updateGraphState(nextProps, state));
|
||||
}
|
||||
_.assign(state, this.updateGraphState(nextProps, state));
|
||||
// if (nextProps.forceRelayout || nextProps.nodes !== this.props.nodes) {
|
||||
// _.assign(state, this.updateGraphState(nextProps, state));
|
||||
// }
|
||||
|
||||
if (this.props.selectedNodeId !== nextProps.selectedNodeId) {
|
||||
_.assign(state, this.restoreLayout(state));
|
||||
@@ -105,6 +100,10 @@ class NodesChart extends React.Component {
|
||||
|
||||
componentDidMount() {
|
||||
// distinguish pan/zoom from click
|
||||
if (this.props.noZoom) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isZooming = false;
|
||||
|
||||
this.zoom = d3.behavior.zoom()
|
||||
@@ -116,6 +115,10 @@ class NodesChart extends React.Component {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.props.noZoom) {
|
||||
return;
|
||||
}
|
||||
|
||||
// undoing .call(zoom)
|
||||
d3.select('.nodes-chart svg')
|
||||
.on('mousedown.zoom', null)
|
||||
@@ -237,9 +240,9 @@ class NodesChart extends React.Component {
|
||||
// move origin node to center of viewport
|
||||
const zoomScale = state.scale;
|
||||
const translate = [state.panTranslateX, state.panTranslateY];
|
||||
const centerX = (-translate[0] + (state.width + MARGINS.left
|
||||
const centerX = (-translate[0] + (state.width + props.margins.left
|
||||
- DETAILS_PANEL_WIDTH) / 2) / zoomScale;
|
||||
const centerY = (-translate[1] + (state.height + MARGINS.top) / 2) / zoomScale;
|
||||
const centerY = (-translate[1] + (state.height + props.margins.top) / 2) / zoomScale;
|
||||
stateNodes = stateNodes.mergeIn([props.selectedNodeId], {
|
||||
x: centerX,
|
||||
y: centerY
|
||||
@@ -291,8 +294,10 @@ class NodesChart extends React.Component {
|
||||
|
||||
restoreLayout(state) {
|
||||
// undo any pan/zooming that might have happened
|
||||
this.zoom.scale(state.scale);
|
||||
this.zoom.translate([state.panTranslateX, state.panTranslateY]);
|
||||
if (this.zoom) {
|
||||
this.zoom.scale(state.scale);
|
||||
this.zoom.translate([state.panTranslateX, state.panTranslateY]);
|
||||
}
|
||||
|
||||
const nodes = state.nodes.map(node => node.merge({
|
||||
x: node.get('px'),
|
||||
@@ -310,9 +315,7 @@ class NodesChart extends React.Component {
|
||||
}
|
||||
|
||||
updateGraphState(props, state) {
|
||||
const n = props.nodes.size;
|
||||
|
||||
if (n === 0) {
|
||||
if (props.nodes.size === 0) {
|
||||
return {
|
||||
nodes: makeMap(),
|
||||
edges: makeMap()
|
||||
@@ -323,17 +326,24 @@ class NodesChart extends React.Component {
|
||||
const stateEdges = this.initEdges(props.nodes, stateNodes);
|
||||
const nodeScale = this.getNodeScale(props.nodes, state.width, state.height);
|
||||
const nextState = { nodeScale };
|
||||
console.log(props.nodeOrder);
|
||||
const nodeOrder = props.nodeOrder || makeMap(stateNodes
|
||||
.toList()
|
||||
.sortBy(n => n.get('label'))
|
||||
.map((n, i) => [n.get('id'), i]));
|
||||
|
||||
const options = {
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
scale: nodeScale,
|
||||
margins: MARGINS,
|
||||
margins: props.margins,
|
||||
forceRelayout: props.forceRelayout,
|
||||
topologyId: this.props.topologyId,
|
||||
topologyOptions: this.props.topologyOptions
|
||||
topologyOptions: this.props.topologyOptions,
|
||||
nodeOrder
|
||||
};
|
||||
|
||||
console.log('nodes-chart', state.height);
|
||||
const timedLayouter = timely(doLayout);
|
||||
const graph = timedLayouter(stateNodes, stateEdges, options);
|
||||
|
||||
@@ -353,15 +363,17 @@ class NodesChart extends React.Component {
|
||||
.map(edge => edge.set('ppoints', edge.get('points')));
|
||||
|
||||
// adjust layout based on viewport
|
||||
const xFactor = (state.width - MARGINS.left - MARGINS.right) / graph.width;
|
||||
const xFactor = (state.width - props.margins.left - props.margins.right) / graph.width;
|
||||
const yFactor = state.height / graph.height;
|
||||
const zoomFactor = Math.min(xFactor, yFactor);
|
||||
let zoomScale = this.state.scale;
|
||||
|
||||
if (!state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) {
|
||||
if (!this.props.noZoom && !state.hasZoomed && zoomFactor > 0 && zoomFactor < 1) {
|
||||
zoomScale = zoomFactor;
|
||||
// saving in d3's behavior cache
|
||||
this.zoom.scale(zoomFactor);
|
||||
if (this.zoom) {
|
||||
this.zoom.scale(zoomFactor);
|
||||
}
|
||||
}
|
||||
|
||||
nextState.scale = zoomScale;
|
||||
@@ -380,7 +392,7 @@ class NodesChart extends React.Component {
|
||||
const nodeSize = expanse / 3; // single node should fill a third of the screen
|
||||
const maxNodeSize = expanse / 10;
|
||||
const normalizedNodeSize = Math.min(nodeSize / Math.sqrt(nodes.size), maxNodeSize);
|
||||
return this.state.nodeScale.copy().range([0, normalizedNodeSize]);
|
||||
return this.state.nodeScale.copy().range([0, this.props.nodeSize || normalizedNodeSize]);
|
||||
}
|
||||
|
||||
zoomed() {
|
||||
|
||||
86
client/app/scripts/charts/nodes-grid.js
Normal file
86
client/app/scripts/charts/nodes-grid.js
Normal file
@@ -0,0 +1,86 @@
|
||||
/* eslint react/jsx-no-bind: "off", no-multi-comp: "off" */
|
||||
|
||||
import React from 'react';
|
||||
import { Set as makeSet, List as makeList, Map as makeMap } from 'immutable';
|
||||
import NodesChart from './nodes-chart';
|
||||
import NodeDetailsTable from '../components/node-details/node-details-table';
|
||||
import { enterNode, leaveNode } from '../actions/app-actions';
|
||||
|
||||
|
||||
function MiniChart(props) {
|
||||
const {width, height} = props;
|
||||
return (
|
||||
<div style={{height, width}} className="nodes-grid-graph">
|
||||
<NodesChart {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const IGNORED_COLUMNS = ['docker_container_ports'];
|
||||
|
||||
|
||||
function getColumns(nodes) {
|
||||
const allColumns = nodes.toList().flatMap(n => {
|
||||
const metrics = (n.get('metrics') || makeList())
|
||||
.map(m => makeMap({ id: m.get('id'), label: m.get('label') }));
|
||||
const metadata = (n.get('metadata') || makeList())
|
||||
.map(m => makeMap({ id: m.get('id'), label: m.get('label') }));
|
||||
return metadata.concat(metrics);
|
||||
});
|
||||
return makeSet(allColumns).filter(n => !IGNORED_COLUMNS.includes(n.get('id'))).toJS();
|
||||
}
|
||||
|
||||
|
||||
export default class NodesGrid extends React.Component {
|
||||
|
||||
onMouseOverRow(node) {
|
||||
enterNode(node.id);
|
||||
}
|
||||
|
||||
onMouseOut() {
|
||||
leaveNode();
|
||||
}
|
||||
|
||||
render() {
|
||||
const {margins, nodes, height, nodeSize} = this.props;
|
||||
const rowStyle = { height: nodeSize };
|
||||
const tableHeight = nodes.size * rowStyle.height;
|
||||
const graphProps = Object.assign({}, this.props, {
|
||||
height: tableHeight,
|
||||
width: 400,
|
||||
noZoom: true,
|
||||
nodeSize: nodeSize - 4,
|
||||
margins: {top: 0, left: 0, right: 0, bottom: 0},
|
||||
nodes: nodes.map(node => node.remove('label').remove('label_minor'))
|
||||
});
|
||||
const cmpStyle = {
|
||||
height,
|
||||
paddingTop: margins.top,
|
||||
paddingBottom: margins.bottom,
|
||||
paddingLeft: margins.left,
|
||||
paddingRight: margins.right,
|
||||
};
|
||||
|
||||
const detailsData = {
|
||||
label: 'procs',
|
||||
id: '',
|
||||
nodes: nodes.toList().toJS(),
|
||||
columns: getColumns(nodes)
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="nodes-grid">
|
||||
<NodeDetailsTable
|
||||
style={cmpStyle}
|
||||
onMouseOut={this.onMouseOut}
|
||||
onMouseOverRow={this.onMouseOverRow}
|
||||
{...detailsData}
|
||||
highlightedNodeIds={this.props.highlightedNodeIds}
|
||||
limit={1000}>
|
||||
<MiniChart {...graphProps} />
|
||||
</NodeDetailsTable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import dagre from 'dagre';
|
||||
import debug from 'debug';
|
||||
import d3 from 'd3';
|
||||
import { fromJS, Map as makeMap, Set as ImmSet } from 'immutable';
|
||||
|
||||
import { EDGE_ID_SEPARATOR } from '../constants/naming';
|
||||
@@ -49,7 +50,9 @@ function runLayoutEngine(graph, imNodes, imEdges, opts) {
|
||||
// configure node margins
|
||||
graph.setGraph({
|
||||
nodesep,
|
||||
ranksep
|
||||
ranksep,
|
||||
rankdir: 'LR',
|
||||
align: 'UL'
|
||||
});
|
||||
|
||||
// add nodes to the graph if not already there
|
||||
@@ -176,6 +179,7 @@ function layoutSingleNodes(layout, opts) {
|
||||
offsetY = offsetY || margins.top + nodeHeight / 2;
|
||||
|
||||
const columns = Math.ceil(Math.sqrt(singleNodes.size));
|
||||
const rows = Math.ceil(singleNodes.size / columns);
|
||||
let row = 0;
|
||||
let col = 0;
|
||||
let singleX;
|
||||
@@ -197,9 +201,11 @@ function layoutSingleNodes(layout, opts) {
|
||||
return node;
|
||||
});
|
||||
|
||||
console.log(singleX, singleY);
|
||||
|
||||
// adjust layout dimensions if graph is now bigger
|
||||
result.width = Math.max(layout.width, singleX + nodeWidth / 2 + nodesep);
|
||||
result.height = Math.max(layout.height, singleY + nodeHeight / 2 + ranksep);
|
||||
result.width = Math.max(layout.width, columns * nodeWidth + (columns - 1) * nodesep);
|
||||
result.height = Math.max(layout.height, rows * nodeHeight + (rows - 1) * ranksep);
|
||||
result.nodes = nodes;
|
||||
}
|
||||
|
||||
@@ -259,6 +265,40 @@ function setSimpleEdgePoints(edge, nodeCache) {
|
||||
]));
|
||||
}
|
||||
|
||||
|
||||
function uniqueRowConstraint(layout, options) {
|
||||
const result = Object.assign({}, layout);
|
||||
const scale = options.scale || DEFAULT_SCALE;
|
||||
const nodeHeight = scale(NODE_SIZE_FACTOR);
|
||||
const nodeWidth = scale(NODE_SIZE_FACTOR);
|
||||
const margins = options.margins || DEFAULT_MARGINS;
|
||||
|
||||
const rowHeight = options.height / layout.nodes.size;
|
||||
const nodeOrder = options.nodeOrder || makeMap(layout.nodes
|
||||
.toList()
|
||||
.sortBy(n => n.get('y'))
|
||||
.map((n, i) => [n.get('id'), i]));
|
||||
|
||||
const nodeXs = layout.nodes.map(n => n.get('x')).toList().toJS();
|
||||
const xScale = d3.scale.linear()
|
||||
.domain(d3.extent(nodeXs))
|
||||
.range([nodeWidth, options.width - nodeWidth])
|
||||
.clamp(false);
|
||||
|
||||
console.log('uniqueRowConstraint', options.height);
|
||||
result.nodes = layout.nodes.map(node => node.merge({
|
||||
x: xScale(node.get('x')),
|
||||
y: nodeOrder.get(node.get('id')) * rowHeight + nodeHeight * 0.5 + margins.top + 2
|
||||
}));
|
||||
|
||||
result.edges = layout.edges.map(edge => (
|
||||
setSimpleEdgePoints(edge, result.nodes)
|
||||
));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Determine if nodes were added between node sets
|
||||
* @param {Map} nodes new Map of nodes
|
||||
@@ -355,7 +395,7 @@ export function doLayout(immNodes, immEdges, opts) {
|
||||
let layout;
|
||||
|
||||
++layoutRuns;
|
||||
if (!options.forceRelayout && cachedLayout && nodeCache && edgeCache
|
||||
if (false && !options.forceRelayout && cachedLayout && nodeCache && edgeCache
|
||||
&& !hasUnseenNodes(immNodes, nodeCache)) {
|
||||
log('skip layout, trivial adjustment', ++layoutRunsTrivial, layoutRuns);
|
||||
layout = cloneLayout(cachedLayout, immNodes, immEdges);
|
||||
@@ -370,6 +410,7 @@ export function doLayout(immNodes, immEdges, opts) {
|
||||
}
|
||||
layout = layoutSingleNodes(layout, opts);
|
||||
layout = shiftLayoutToCenter(layout, opts);
|
||||
layout = uniqueRowConstraint(layout, opts);
|
||||
}
|
||||
|
||||
// cache results
|
||||
|
||||
150
client/app/scripts/components/examples.js
Normal file
150
client/app/scripts/components/examples.js
Normal file
@@ -0,0 +1,150 @@
|
||||
/* eslint no-unused-vars: "off" */
|
||||
import React from 'react';
|
||||
import _ from 'lodash';
|
||||
import NodesChart from '../charts/nodes-chart';
|
||||
import NodesGrid from '../charts/nodes-grid';
|
||||
import { deltaAdd, makeNodes } from './debug-toolbar';
|
||||
import { fromJS, Map as makeMap, Set as makeSet } from 'immutable';
|
||||
|
||||
|
||||
function clog(v) {
|
||||
console.log(v);
|
||||
return v;
|
||||
}
|
||||
|
||||
function randomGraph(n) {
|
||||
return makeMap(makeNodes(n, 'ewq', 4, 'hexagon').map(d => [d.id, fromJS(d)]));
|
||||
}
|
||||
|
||||
function deltaAddSimple(name, adjacency = []) {
|
||||
return deltaAdd(name, adjacency, 'circle', false, 1, '');
|
||||
}
|
||||
|
||||
|
||||
function makeIds(n) {
|
||||
return _.range(n).map(i => `n${i}`);
|
||||
}
|
||||
|
||||
|
||||
function disconnectedGraph(n) {
|
||||
return makeMap(makeIds(n)
|
||||
.map((id) => deltaAddSimple(id))
|
||||
.map(d => [d.id, fromJS(d)]));
|
||||
}
|
||||
|
||||
|
||||
function completeGraph(n) {
|
||||
const ids = makeIds(n);
|
||||
const allEdges = _.flatMap(ids, i => ids.filter(ii => i !== ii).map(ii => [i, ii]));
|
||||
const oneWayEdges = allEdges.filter(edge => _.isEqual(edge, _.sortBy(edge)));
|
||||
const adjacencyMap = _(oneWayEdges)
|
||||
.groupBy(e => e[0])
|
||||
.mapValues(edges => edges.map(e => e[1]))
|
||||
.value();
|
||||
return makeMap(ids
|
||||
.map((id) => deltaAddSimple(id, adjacencyMap[id] || []))
|
||||
.map(d => [d.id, fromJS(d)]));
|
||||
}
|
||||
|
||||
|
||||
function completeGraphBi(n) {
|
||||
const ids = makeIds(n);
|
||||
const adjacency = (id) => ids.filter(_id => _id !== id);
|
||||
return makeMap(ids
|
||||
.map((id) => deltaAddSimple(id, adjacency(id)))
|
||||
.map(d => [d.id, fromJS(d)]));
|
||||
}
|
||||
|
||||
|
||||
function flatTree(n) {
|
||||
const ids = makeIds(n + 1);
|
||||
const p = ids.pop();
|
||||
const adjacency = id => id === p ? ids : [];
|
||||
return makeMap(ids.concat([p])
|
||||
.map((id) => deltaAddSimple(id, adjacency(id)))
|
||||
.map(d => [d.id, fromJS(d)]));
|
||||
}
|
||||
|
||||
function proxyGraph(n) {
|
||||
const ids = makeIds(n * 2 + 1);
|
||||
const p = ids.pop();
|
||||
const topIds = _.take(ids, n);
|
||||
const bottomIds = _.drop(ids, n);
|
||||
const adjacencyMap = Object.assign({
|
||||
[p]: bottomIds
|
||||
}, _.fromPairs(topIds.map(id => [id, [p]])));
|
||||
|
||||
return makeMap(ids.concat([p])
|
||||
.map((id) => deltaAddSimple(id, adjacencyMap[id] || []))
|
||||
.map(d => [d.id, fromJS(d)]));
|
||||
}
|
||||
|
||||
|
||||
function chart(nodes,
|
||||
n,
|
||||
style = { width: 250, height: 250 },
|
||||
margins = { top: 0, left: 0, right: 0, bottom: 0 },
|
||||
nodeSize = null) {
|
||||
return (
|
||||
<div key={n} className="example-chart" style={style}>
|
||||
<NodesChart
|
||||
nodes={nodes}
|
||||
width={style.width}
|
||||
height={style.height}
|
||||
margins={margins}
|
||||
highlightedNodeIds={makeSet()}
|
||||
highlightedEdgeIds={makeSet()}
|
||||
layoutPrecision="3"
|
||||
topologyId={Math.random()}
|
||||
noZoom="true"
|
||||
nodeSize={nodeSize}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function variants() {
|
||||
const nCharts = 5;
|
||||
const width = 250;
|
||||
const style = {width: nCharts * (width + 16)};
|
||||
const generators = [
|
||||
{ id: 'disconnectedGraph', fn: disconnectedGraph },
|
||||
{ id: 'completeGraphBi', fn: completeGraphBi },
|
||||
{ id: 'completeGraph', fn: completeGraph },
|
||||
{ id: 'flatTree', fn: flatTree },
|
||||
{ id: 'proxyGraph', fn: proxyGraph }
|
||||
];
|
||||
return (
|
||||
<div>
|
||||
{_.reverse(generators).map(({id, fn}) => (
|
||||
<div key={id} className="nodes-chart-examples" style={style}>
|
||||
{_.range(1, nCharts + 1).map(i => (
|
||||
chart(fn(i), i)
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
function gridView() {
|
||||
const nodes = randomGraph(50).map(node => node.remove('label').remove('label_minor'));
|
||||
const nodeSize = 24;
|
||||
return (
|
||||
<NodesGrid width="500" height="500" nodeSize={nodeSize} nodes={nodes} />
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export class Examples extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<div className="examples">
|
||||
{gridView()}
|
||||
{false && variants()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import _ from 'lodash';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Map as makeMap } from 'immutable';
|
||||
@@ -123,12 +122,6 @@ export class NodeDetails extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
renderTable(table) {
|
||||
const key = _.snakeCase(table.title);
|
||||
return (<NodeDetailsTable title={table.title} key={key} rows={table.rows}
|
||||
isNumeric={table.numeric} />);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.notFound) {
|
||||
return this.renderNotAvailable();
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import NodeDetailsTableNodeLink from './node-details-table-node-link';
|
||||
import NodeDetailsTableNodeMetric from './node-details-table-node-metric';
|
||||
|
||||
|
||||
function getValuesForNode(node) {
|
||||
const values = {};
|
||||
['metrics', 'metadata'].forEach(collection => {
|
||||
if (node[collection]) {
|
||||
node[collection].forEach(field => {
|
||||
const result = Object.assign({}, field);
|
||||
result.valueType = collection;
|
||||
values[field.id] = result;
|
||||
});
|
||||
}
|
||||
});
|
||||
return values;
|
||||
}
|
||||
|
||||
|
||||
function renderValues(node, columns = []) {
|
||||
const fields = getValuesForNode(node);
|
||||
return columns.map(({id}) => {
|
||||
const field = fields[id];
|
||||
if (field) {
|
||||
if (field.valueType === 'metadata') {
|
||||
return (
|
||||
<td className="node-details-table-node-value truncate" title={field.value}
|
||||
key={field.id}>
|
||||
{field.value}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
return <NodeDetailsTableNodeMetric key={field.id} {...field} />;
|
||||
}
|
||||
// empty cell to complete the row for proper hover
|
||||
return <td className="node-details-table-node-value" key={id} />;
|
||||
});
|
||||
}
|
||||
|
||||
export default class NodeDetailsTableRow extends React.Component {
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.onMouseOver = this.onMouseOver.bind(this);
|
||||
}
|
||||
|
||||
onMouseOver() {
|
||||
const { node, onMouseOverRow } = this.props;
|
||||
onMouseOverRow(node);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { node, nodeIdKey, topologyId, columns, onMouseOverRow, selected } = this.props;
|
||||
const values = renderValues(node, columns);
|
||||
const nodeId = node[nodeIdKey];
|
||||
const className = classNames('node-details-table-node', { selected });
|
||||
return (
|
||||
<tr onMouseOver={onMouseOverRow && this.onMouseOver} className={className}>
|
||||
<td className="node-details-table-node-label truncate">
|
||||
<NodeDetailsTableNodeLink
|
||||
topologyId={topologyId}
|
||||
nodeId={nodeId}
|
||||
{...node} />
|
||||
</td>
|
||||
{values}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import _ from 'lodash';
|
||||
import React from 'react';
|
||||
import { Map as makeMap } from 'immutable';
|
||||
|
||||
import ShowMore from '../show-more';
|
||||
import NodeDetailsTableNodeLink from './node-details-table-node-link';
|
||||
import NodeDetailsTableNodeMetric from './node-details-table-node-metric';
|
||||
import NodeDetailsTableRow from './node-details-table-row';
|
||||
import { sortOrderChanged } from '../../actions/app-actions';
|
||||
|
||||
|
||||
function isNumberField(field) {
|
||||
@@ -16,61 +17,20 @@ const COLUMN_WIDTHS = {
|
||||
count: '70px'
|
||||
};
|
||||
|
||||
|
||||
export default class NodeDetailsTable extends React.Component {
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.DEFAULT_LIMIT = 5;
|
||||
this.state = {
|
||||
limit: this.DEFAULT_LIMIT,
|
||||
sortedDesc: true,
|
||||
sortBy: null
|
||||
};
|
||||
this.handleLimitClick = this.handleLimitClick.bind(this);
|
||||
this.getValueForSortBy = this.getValueForSortBy.bind(this);
|
||||
function getDefaultSortBy(columns, nodes) {
|
||||
// default sorter specified by columns
|
||||
const defaultSortColumn = _.find(columns, {defaultSort: true});
|
||||
if (defaultSortColumn) {
|
||||
return defaultSortColumn.id;
|
||||
}
|
||||
// otherwise choose first metric
|
||||
return _.get(nodes, [0, 'metrics', 0, 'id']);
|
||||
}
|
||||
|
||||
handleHeaderClick(ev, headerId) {
|
||||
ev.preventDefault();
|
||||
const sortedDesc = headerId === this.state.sortBy
|
||||
? !this.state.sortedDesc : this.state.sortedDesc;
|
||||
const sortBy = headerId;
|
||||
this.setState({sortBy, sortedDesc});
|
||||
}
|
||||
|
||||
handleLimitClick() {
|
||||
const limit = this.state.limit ? 0 : this.DEFAULT_LIMIT;
|
||||
this.setState({limit});
|
||||
}
|
||||
|
||||
getDefaultSortBy() {
|
||||
// default sorter specified by columns
|
||||
const defaultSortColumn = _.find(this.props.columns, {defaultSort: true});
|
||||
if (defaultSortColumn) {
|
||||
return defaultSortColumn.id;
|
||||
}
|
||||
// otherwise choose first metric
|
||||
return _.get(this.props.nodes, [0, 'metrics', 0, 'id']);
|
||||
}
|
||||
|
||||
getMetaDataSorters() {
|
||||
// returns an array of sorters that will take a node
|
||||
return _.get(this.props.nodes, [0, 'metadata'], []).map((field, index) => node => {
|
||||
const nodeMetadataField = node.metadata[index];
|
||||
if (nodeMetadataField) {
|
||||
if (isNumberField(nodeMetadataField)) {
|
||||
return parseFloat(nodeMetadataField.value);
|
||||
}
|
||||
return nodeMetadataField.value;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
getValueForSortBy(node) {
|
||||
// return the node's value based on the sortBy field
|
||||
const sortBy = this.state.sortBy || this.getDefaultSortBy();
|
||||
function getValueForSortBy(sortBy) {
|
||||
// return the node's value based on the sortBy field
|
||||
return (node) => {
|
||||
if (sortBy !== null) {
|
||||
const field = _.union(node.metrics, node.metadata).find(f => f.id === sortBy);
|
||||
if (field) {
|
||||
@@ -81,27 +41,73 @@ export default class NodeDetailsTable extends React.Component {
|
||||
}
|
||||
}
|
||||
return -1e-10; // just under 0 to treat missing values differently from 0
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
function getMetaDataSorters(nodes) {
|
||||
// returns an array of sorters that will take a node
|
||||
return _.get(nodes, [0, 'metadata'], []).map((field, index) => node => {
|
||||
const nodeMetadataField = node.metadata[index];
|
||||
if (nodeMetadataField) {
|
||||
if (isNumberField(nodeMetadataField)) {
|
||||
return parseFloat(nodeMetadataField.value);
|
||||
}
|
||||
return nodeMetadataField.value;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
*/
|
||||
|
||||
|
||||
function getSortedNodes(nodes, columns, sortBy, sortedDesc) {
|
||||
const sortedNodes = _.sortBy(
|
||||
nodes,
|
||||
getValueForSortBy(sortBy || getDefaultSortBy(columns, nodes)),
|
||||
'label'
|
||||
// getMetaDataSorters(nodes)
|
||||
);
|
||||
if (sortedDesc) {
|
||||
sortedNodes.reverse();
|
||||
}
|
||||
return sortedNodes;
|
||||
}
|
||||
|
||||
|
||||
export default class NodeDetailsTable extends React.Component {
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.DEFAULT_LIMIT = 5;
|
||||
this.state = {
|
||||
limit: props.limit || this.DEFAULT_LIMIT,
|
||||
sortedDesc: true,
|
||||
sortBy: null
|
||||
};
|
||||
this.handleLimitClick = this.handleLimitClick.bind(this);
|
||||
}
|
||||
|
||||
getValuesForNode(node) {
|
||||
const values = {};
|
||||
['metrics', 'metadata'].forEach(collection => {
|
||||
if (node[collection]) {
|
||||
node[collection].forEach(field => {
|
||||
const result = Object.assign({}, field);
|
||||
result.valueType = collection;
|
||||
values[field.id] = result;
|
||||
});
|
||||
}
|
||||
});
|
||||
return values;
|
||||
handleHeaderClick(ev, headerId) {
|
||||
ev.preventDefault();
|
||||
const sortedDesc = headerId === this.state.sortBy
|
||||
? !this.state.sortedDesc : this.state.sortedDesc;
|
||||
const sortBy = headerId;
|
||||
this.setState({sortBy, sortedDesc});
|
||||
sortOrderChanged({sortBy, sortedDesc});
|
||||
}
|
||||
|
||||
handleLimitClick() {
|
||||
const limit = this.state.limit ? 0 : this.DEFAULT_LIMIT;
|
||||
this.setState({limit});
|
||||
}
|
||||
|
||||
renderHeaders() {
|
||||
if (this.props.nodes && this.props.nodes.length > 0) {
|
||||
const columns = this.props.columns || [];
|
||||
const headers = [{id: 'label', label: this.props.label}].concat(columns);
|
||||
const defaultSortBy = this.getDefaultSortBy();
|
||||
const defaultSortBy = getDefaultSortBy(this.props);
|
||||
|
||||
// Beauty hack: adjust first column width if there are only few columns;
|
||||
// this assumes the other columns are narrow metric columns of 20% table width
|
||||
@@ -109,7 +115,7 @@ export default class NodeDetailsTable extends React.Component {
|
||||
headers[0].width = '66%';
|
||||
} else if (headers.length === 3) {
|
||||
headers[0].width = '50%';
|
||||
} else if (headers.length >= 3) {
|
||||
} else if (headers.length >= 3 && headers.length < 5) {
|
||||
headers[0].width = '33%';
|
||||
}
|
||||
|
||||
@@ -161,66 +167,53 @@ export default class NodeDetailsTable extends React.Component {
|
||||
return '';
|
||||
}
|
||||
|
||||
renderValues(node) {
|
||||
const fields = this.getValuesForNode(node);
|
||||
const columns = this.props.columns || [];
|
||||
return columns.map(({id}) => {
|
||||
const field = fields[id];
|
||||
if (field) {
|
||||
if (field.valueType === 'metadata') {
|
||||
return (
|
||||
<td className="node-details-table-node-value truncate" title={field.value}
|
||||
key={field.id}>
|
||||
{field.value}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
return <NodeDetailsTableNodeMetric key={field.id} {...field} />;
|
||||
}
|
||||
// empty cell to complete the row for proper hover
|
||||
return <td className="node-details-table-node-value" key={id} />;
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const headers = this.renderHeaders();
|
||||
const { nodeIdKey } = this.props;
|
||||
let nodes = _.sortBy(this.props.nodes, this.getValueForSortBy, 'label',
|
||||
this.getMetaDataSorters());
|
||||
const { nodeIdKey, columns, topologyId, onMouseOverRow } = this.props;
|
||||
let nodes = getSortedNodes(this.props.nodes, this.props.columns, this.state.sortBy,
|
||||
this.state.sortedDesc);
|
||||
const limited = nodes && this.state.limit > 0 && nodes.length > this.state.limit;
|
||||
const expanded = this.state.limit === 0;
|
||||
const notShown = nodes.length - this.DEFAULT_LIMIT;
|
||||
if (this.state.sortedDesc) {
|
||||
nodes.reverse();
|
||||
}
|
||||
const notShown = nodes.length - this.state.limit;
|
||||
if (nodes && limited) {
|
||||
nodes = nodes.slice(0, this.state.limit);
|
||||
}
|
||||
|
||||
const nodeOrderJS = (nodes || []).map((n, i) => [n.id, i]);
|
||||
const nodeOrder = makeMap(nodeOrderJS);
|
||||
const childrenWithProps = React.Children.map(this.props.children, (child) => (
|
||||
React.cloneElement(child, { nodeOrder })
|
||||
));
|
||||
|
||||
console.log(this.props.selectedRowId);
|
||||
|
||||
return (
|
||||
<div className="node-details-table-wrapper">
|
||||
<table className="node-details-table">
|
||||
<thead>
|
||||
{headers}
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodes && nodes.map(node => {
|
||||
const values = this.renderValues(node);
|
||||
const nodeId = node[nodeIdKey];
|
||||
return (
|
||||
<tr className="node-details-table-node" key={node.id}>
|
||||
<td className="node-details-table-node-label truncate">
|
||||
<NodeDetailsTableNodeLink {...node} topologyId={this.props.topologyId}
|
||||
nodeId={nodeId} />
|
||||
</td>
|
||||
{values}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<ShowMore handleClick={this.handleLimitClick} collection={this.props.nodes}
|
||||
expanded={expanded} notShown={notShown} />
|
||||
<div className="node-details-table-wrapper-wrapper" style={this.props.style}>
|
||||
<div className="node-details-table-wrapper" onMouseOut={this.props.onMouseOut}>
|
||||
<table className="node-details-table">
|
||||
<thead>
|
||||
{headers}
|
||||
</thead>
|
||||
<tbody>
|
||||
{nodes && nodes.map(node => (
|
||||
<NodeDetailsTableRow
|
||||
key={node.id}
|
||||
selected={this.props.highlightedNodeIds.has(node.id)}
|
||||
node={node}
|
||||
nodeIdKey={nodeIdKey}
|
||||
columns={columns}
|
||||
onMouseOverRow={onMouseOverRow}
|
||||
topologyId={topologyId} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<ShowMore
|
||||
handleClick={this.handleLimitClick}
|
||||
collection={this.props.nodes}
|
||||
expanded={expanded}
|
||||
notShown={notShown} />
|
||||
</div>
|
||||
{childrenWithProps}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import NodesChart from '../charts/nodes-chart';
|
||||
// import NodesChart from '../charts/nodes-chart';
|
||||
import NodesGrid from '../charts/nodes-grid';
|
||||
import NodesError from '../charts/nodes-error';
|
||||
import { DelayedShow } from '../utils/delayed-show';
|
||||
import { Loading, getNodeType } from './loading';
|
||||
import { isTopologyEmpty } from '../utils/topology-utils';
|
||||
import { CANVAS_MARGINS } from '../constants/styles';
|
||||
|
||||
const navbarHeight = 160;
|
||||
const marginTop = 0;
|
||||
@@ -80,7 +82,11 @@ class Nodes extends React.Component {
|
||||
show={topologiesLoaded && !nodesLoaded} />
|
||||
</DelayedShow>
|
||||
{this.renderEmptyTopologyError(topologiesLoaded && nodesLoaded && topologyEmpty)}
|
||||
<NodesChart {...this.state}
|
||||
<NodesGrid {...this.state}
|
||||
nodeSize="24"
|
||||
width={1300}
|
||||
height={780}
|
||||
margins={CANVAS_MARGINS}
|
||||
detailsWidth={detailsWidth}
|
||||
layoutPrecision={layoutPrecision}
|
||||
hasSelectedNode={hasSelectedNode}
|
||||
|
||||
@@ -55,6 +55,7 @@ const ACTION_TYPES = [
|
||||
'UNPIN_NETWORK',
|
||||
'SHOW_NETWORKS',
|
||||
'SET_RECEIVED_NODES_DELTA',
|
||||
'SORT_ORDER_CHANGED'
|
||||
];
|
||||
|
||||
export default _.zipObject(ACTION_TYPES, ACTION_TYPES);
|
||||
|
||||
@@ -10,3 +10,10 @@ export const DETAILS_PANEL_MARGINS = {
|
||||
export const DETAILS_PANEL_OFFSET = 8;
|
||||
|
||||
export const CANVAS_METRIC_FONT_SIZE = 0.19;
|
||||
|
||||
export const CANVAS_MARGINS = {
|
||||
top: 130,
|
||||
left: 40,
|
||||
right: 40,
|
||||
bottom: 100,
|
||||
};
|
||||
|
||||
10
client/app/scripts/examples-main.js
Normal file
10
client/app/scripts/examples-main.js
Normal file
@@ -0,0 +1,10 @@
|
||||
require('../styles/main.less');
|
||||
require('../../node_modules/fixed-data-table/dist/fixed-data-table.css');
|
||||
require('../images/favicon.ico');
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import { Examples } from './components/examples.js';
|
||||
|
||||
ReactDOM.render(<Examples />, document.getElementById('app'));
|
||||
749
client/app/scripts/stores/app-store.js
Normal file
749
client/app/scripts/stores/app-store.js
Normal file
@@ -0,0 +1,749 @@
|
||||
import _ from 'lodash';
|
||||
import debug from 'debug';
|
||||
import { fromJS, is as isDeepEqual, List, Map, OrderedMap, Set } from 'immutable';
|
||||
import { Store } from 'flux/utils';
|
||||
|
||||
import AppDispatcher from '../dispatcher/app-dispatcher';
|
||||
import ActionTypes from '../constants/action-types';
|
||||
import { EDGE_ID_SEPARATOR } from '../constants/naming';
|
||||
import { findTopologyById, setTopologyUrlsById, updateTopologyIds,
|
||||
filterHiddenTopologies } from '../utils/topology-utils';
|
||||
|
||||
const makeList = List;
|
||||
const makeMap = Map;
|
||||
const makeOrderedMap = OrderedMap;
|
||||
const makeSet = Set;
|
||||
const log = debug('scope:app-store');
|
||||
|
||||
const error = debug('scope:error');
|
||||
|
||||
// Helpers
|
||||
|
||||
function makeNode(node) {
|
||||
return {
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
label_minor: node.label_minor,
|
||||
node_count: node.node_count,
|
||||
rank: node.rank,
|
||||
pseudo: node.pseudo,
|
||||
stack: node.stack,
|
||||
shape: node.shape,
|
||||
adjacency: node.adjacency,
|
||||
metrics: node.metrics
|
||||
};
|
||||
}
|
||||
|
||||
// Initial values
|
||||
|
||||
let topologyOptions = makeOrderedMap(); // topologyId -> options
|
||||
let controlStatus = makeMap();
|
||||
let currentTopology = null;
|
||||
let currentTopologyId = 'containers';
|
||||
let errorUrl = null;
|
||||
let forceRelayout = false;
|
||||
let highlightedEdgeIds = makeSet();
|
||||
let highlightedNodeIds = makeSet();
|
||||
let hostname = '...';
|
||||
let version = '...';
|
||||
let versionUpdate = null;
|
||||
let plugins = [];
|
||||
let mouseOverEdgeId = null;
|
||||
let mouseOverNodeId = null;
|
||||
let nodeDetails = makeOrderedMap(); // nodeId -> details
|
||||
let nodes = makeOrderedMap(); // nodeId -> node
|
||||
let selectedNodeId = null;
|
||||
let topologies = makeList();
|
||||
let topologiesLoaded = false;
|
||||
let topologyUrlsById = makeOrderedMap(); // topologyId -> topologyUrl
|
||||
let routeSet = false;
|
||||
let controlPipes = makeOrderedMap(); // pipeId -> controlPipe
|
||||
let updatePausedAt = null; // Date
|
||||
let websocketClosed = true;
|
||||
let showingHelp = false;
|
||||
let tableSortOrder = null;
|
||||
|
||||
let selectedMetric = null;
|
||||
let pinnedMetric = selectedMetric;
|
||||
// class of metric, e.g. 'cpu', rather than 'host_cpu' or 'process_cpu'.
|
||||
// allows us to keep the same metric "type" selected when the topology changes.
|
||||
let pinnedMetricType = null;
|
||||
let availableCanvasMetrics = makeList();
|
||||
|
||||
|
||||
const topologySorter = topology => topology.get('rank');
|
||||
|
||||
// adds ID field to topology (based on last part of URL path) and save urls in
|
||||
// map for easy lookup
|
||||
function processTopologies(nextTopologies) {
|
||||
// filter out hidden topos
|
||||
const visibleTopologies = filterHiddenTopologies(nextTopologies);
|
||||
|
||||
// add IDs to topology objects in-place
|
||||
const topologiesWithId = updateTopologyIds(visibleTopologies);
|
||||
|
||||
// cache URLs by ID
|
||||
topologyUrlsById = setTopologyUrlsById(topologyUrlsById, topologiesWithId);
|
||||
|
||||
const immNextTopologies = fromJS(topologiesWithId).sortBy(topologySorter);
|
||||
topologies = topologies.mergeDeep(immNextTopologies);
|
||||
}
|
||||
|
||||
function setTopology(topologyId) {
|
||||
currentTopology = findTopologyById(topologies, topologyId);
|
||||
currentTopologyId = topologyId;
|
||||
}
|
||||
|
||||
function setDefaultTopologyOptions(topologyList) {
|
||||
topologyList.forEach(topology => {
|
||||
let defaultOptions = makeOrderedMap();
|
||||
if (topology.has('options') && topology.get('options')) {
|
||||
topology.get('options').forEach((option) => {
|
||||
const optionId = option.get('id');
|
||||
const defaultValue = option.get('defaultValue');
|
||||
defaultOptions = defaultOptions.set(optionId, defaultValue);
|
||||
});
|
||||
}
|
||||
|
||||
if (defaultOptions.size) {
|
||||
topologyOptions = topologyOptions.set(
|
||||
topology.get('id'),
|
||||
defaultOptions
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function closeNodeDetails(nodeId) {
|
||||
if (nodeDetails.size > 0) {
|
||||
const popNodeId = nodeId || nodeDetails.keySeq().last();
|
||||
// remove pipe if it belongs to the node being closed
|
||||
controlPipes = controlPipes.filter(pipe => pipe.get('nodeId') !== popNodeId);
|
||||
nodeDetails = nodeDetails.delete(popNodeId);
|
||||
}
|
||||
if (nodeDetails.size === 0 || selectedNodeId === nodeId) {
|
||||
selectedNodeId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closeAllNodeDetails() {
|
||||
while (nodeDetails.size) {
|
||||
closeNodeDetails();
|
||||
}
|
||||
}
|
||||
|
||||
function resumeUpdate() {
|
||||
updatePausedAt = null;
|
||||
}
|
||||
|
||||
// Store API
|
||||
|
||||
export class AppStore extends Store {
|
||||
|
||||
// keep at the top
|
||||
getAppState() {
|
||||
const cp = this.getControlPipe();
|
||||
return {
|
||||
controlPipe: cp ? cp.toJS() : null,
|
||||
nodeDetails: this.getNodeDetailsState().toJS(),
|
||||
selectedNodeId,
|
||||
pinnedMetricType,
|
||||
topologyId: currentTopologyId,
|
||||
topologyOptions: topologyOptions.toJS() // all options
|
||||
};
|
||||
}
|
||||
|
||||
getTableSortOrder() {
|
||||
return tableSortOrder;
|
||||
}
|
||||
|
||||
getShowingHelp() {
|
||||
return showingHelp;
|
||||
}
|
||||
|
||||
getActiveTopologyOptions() {
|
||||
// options for current topology, sub-topologies share options with parent
|
||||
if (currentTopology && currentTopology.get('parentId')) {
|
||||
return topologyOptions.get(currentTopology.get('parentId'));
|
||||
}
|
||||
return topologyOptions.get(currentTopologyId);
|
||||
}
|
||||
|
||||
getAdjacentNodes(nodeId) {
|
||||
let adjacentNodes = makeSet();
|
||||
|
||||
if (nodes.has(nodeId)) {
|
||||
adjacentNodes = makeSet(nodes.getIn([nodeId, 'adjacency']));
|
||||
// fill up set with reverse edges
|
||||
nodes.forEach((node, id) => {
|
||||
if (node.get('adjacency') && node.get('adjacency').includes(nodeId)) {
|
||||
adjacentNodes = adjacentNodes.add(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return adjacentNodes;
|
||||
}
|
||||
|
||||
getPinnedMetric() {
|
||||
return pinnedMetric;
|
||||
}
|
||||
|
||||
getSelectedMetric() {
|
||||
return selectedMetric;
|
||||
}
|
||||
|
||||
getAvailableCanvasMetrics() {
|
||||
return availableCanvasMetrics;
|
||||
}
|
||||
|
||||
getAvailableCanvasMetricsTypes() {
|
||||
return makeMap(this.getAvailableCanvasMetrics().map(m => [m.get('id'), m.get('label')]));
|
||||
}
|
||||
|
||||
getControlStatus() {
|
||||
return controlStatus;
|
||||
}
|
||||
|
||||
getControlPipe() {
|
||||
return controlPipes.last();
|
||||
}
|
||||
|
||||
getCurrentTopology() {
|
||||
if (!currentTopology) {
|
||||
currentTopology = setTopology(currentTopologyId);
|
||||
}
|
||||
return currentTopology;
|
||||
}
|
||||
|
||||
getCurrentTopologyId() {
|
||||
return currentTopologyId;
|
||||
}
|
||||
|
||||
getCurrentTopologyOptions() {
|
||||
return currentTopology && currentTopology.get('options') || makeOrderedMap();
|
||||
}
|
||||
|
||||
getCurrentTopologyUrl() {
|
||||
return currentTopology && currentTopology.get('url');
|
||||
}
|
||||
|
||||
getErrorUrl() {
|
||||
return errorUrl;
|
||||
}
|
||||
|
||||
getHighlightedEdgeIds() {
|
||||
return highlightedEdgeIds;
|
||||
}
|
||||
|
||||
getHighlightedNodeIds() {
|
||||
return highlightedNodeIds;
|
||||
}
|
||||
|
||||
getHostname() {
|
||||
return hostname;
|
||||
}
|
||||
|
||||
getNodeDetails() {
|
||||
return nodeDetails;
|
||||
}
|
||||
|
||||
getNodeDetailsState() {
|
||||
return nodeDetails.toIndexedSeq().map(details => ({
|
||||
id: details.id, label: details.label, topologyId: details.topologyId
|
||||
}));
|
||||
}
|
||||
|
||||
getTopCardNodeId() {
|
||||
return nodeDetails.last() && nodeDetails.last().id;
|
||||
}
|
||||
|
||||
getNodes() {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
getSelectedNodeId() {
|
||||
return selectedNodeId;
|
||||
}
|
||||
|
||||
getTopologies() {
|
||||
return topologies;
|
||||
}
|
||||
|
||||
getTopologyUrlsById() {
|
||||
return topologyUrlsById;
|
||||
}
|
||||
|
||||
getUpdatePausedAt() {
|
||||
return updatePausedAt;
|
||||
}
|
||||
|
||||
getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
getVersionUpdate() {
|
||||
return versionUpdate;
|
||||
}
|
||||
|
||||
getPlugins() {
|
||||
return plugins;
|
||||
}
|
||||
|
||||
isForceRelayout() {
|
||||
return forceRelayout;
|
||||
}
|
||||
|
||||
isRouteSet() {
|
||||
return routeSet;
|
||||
}
|
||||
|
||||
isTopologiesLoaded() {
|
||||
return topologiesLoaded;
|
||||
}
|
||||
|
||||
isTopologyEmpty() {
|
||||
return currentTopology && currentTopology.get('stats')
|
||||
&& currentTopology.get('stats').get('node_count') === 0 && nodes.size === 0;
|
||||
}
|
||||
|
||||
isUpdatePaused() {
|
||||
return updatePausedAt !== null;
|
||||
}
|
||||
|
||||
isWebsocketClosed() {
|
||||
return websocketClosed;
|
||||
}
|
||||
|
||||
__onDispatch(payload) {
|
||||
if (!payload.type) {
|
||||
error('Payload missing a type!', payload);
|
||||
}
|
||||
|
||||
switch (payload.type) {
|
||||
case ActionTypes.CHANGE_TOPOLOGY_OPTION: {
|
||||
resumeUpdate();
|
||||
// set option on parent topology
|
||||
const topology = findTopologyById(topologies, payload.topologyId);
|
||||
if (topology) {
|
||||
const topologyId = topology.get('parentId') || topology.get('id');
|
||||
if (topologyOptions.getIn([topologyId, payload.option]) !== payload.value) {
|
||||
nodes = nodes.clear();
|
||||
}
|
||||
topologyOptions = topologyOptions.setIn(
|
||||
[topologyId, payload.option],
|
||||
payload.value
|
||||
);
|
||||
this.__emitChange();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ActionTypes.CLEAR_CONTROL_ERROR: {
|
||||
controlStatus = controlStatus.removeIn([payload.nodeId, 'error']);
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.CLICK_BACKGROUND: {
|
||||
closeAllNodeDetails();
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.CLICK_CLOSE_DETAILS: {
|
||||
closeNodeDetails(payload.nodeId);
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.SORT_ORDER_CHANGED: {
|
||||
tableSortOrder = makeMap((payload.newOrder || []).map((n, i) => [n.id, i]));
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.CLICK_CLOSE_TERMINAL: {
|
||||
controlPipes = controlPipes.clear();
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.CLICK_FORCE_RELAYOUT: {
|
||||
forceRelayout = true;
|
||||
// fire only once, reset after emitChange
|
||||
setTimeout(() => {
|
||||
forceRelayout = false;
|
||||
}, 0);
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.CLICK_NODE: {
|
||||
const prevSelectedNodeId = selectedNodeId;
|
||||
const prevDetailsStackSize = nodeDetails.size;
|
||||
// click on sibling closes all
|
||||
closeAllNodeDetails();
|
||||
// select new node if it's not the same (in that case just delesect)
|
||||
if (prevDetailsStackSize > 1 || prevSelectedNodeId !== payload.nodeId) {
|
||||
// dont set origin if a node was already selected, suppresses animation
|
||||
const origin = prevSelectedNodeId === null ? payload.origin : null;
|
||||
nodeDetails = nodeDetails.set(
|
||||
payload.nodeId,
|
||||
{
|
||||
id: payload.nodeId,
|
||||
label: payload.label,
|
||||
origin,
|
||||
topologyId: currentTopologyId
|
||||
}
|
||||
);
|
||||
selectedNodeId = payload.nodeId;
|
||||
}
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.CLICK_PAUSE_UPDATE: {
|
||||
updatePausedAt = new Date;
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.CLICK_RELATIVE: {
|
||||
if (nodeDetails.has(payload.nodeId)) {
|
||||
// bring to front
|
||||
const details = nodeDetails.get(payload.nodeId);
|
||||
nodeDetails = nodeDetails.delete(payload.nodeId);
|
||||
nodeDetails = nodeDetails.set(payload.nodeId, details);
|
||||
} else {
|
||||
nodeDetails = nodeDetails.set(
|
||||
payload.nodeId,
|
||||
{
|
||||
id: payload.nodeId,
|
||||
label: payload.label,
|
||||
origin: payload.origin,
|
||||
topologyId: payload.topologyId
|
||||
}
|
||||
);
|
||||
}
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.CLICK_RESUME_UPDATE: {
|
||||
resumeUpdate();
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.CLICK_SHOW_TOPOLOGY_FOR_NODE: {
|
||||
resumeUpdate();
|
||||
nodeDetails = nodeDetails.filter((v, k) => k === payload.nodeId);
|
||||
controlPipes = controlPipes.clear();
|
||||
selectedNodeId = payload.nodeId;
|
||||
if (payload.topologyId !== currentTopologyId) {
|
||||
setTopology(payload.topologyId);
|
||||
nodes = nodes.clear();
|
||||
}
|
||||
availableCanvasMetrics = makeList();
|
||||
tableSortOrder = null;
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.CLICK_TOPOLOGY: {
|
||||
resumeUpdate();
|
||||
closeAllNodeDetails();
|
||||
if (payload.topologyId !== currentTopologyId) {
|
||||
setTopology(payload.topologyId);
|
||||
nodes = nodes.clear();
|
||||
}
|
||||
availableCanvasMetrics = makeList();
|
||||
tableSortOrder = null;
|
||||
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.CLOSE_WEBSOCKET: {
|
||||
if (!websocketClosed) {
|
||||
websocketClosed = true;
|
||||
this.__emitChange();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ActionTypes.SELECT_METRIC: {
|
||||
selectedMetric = payload.metricId;
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.PIN_METRIC: {
|
||||
pinnedMetric = payload.metricId;
|
||||
pinnedMetricType = this.getAvailableCanvasMetricsTypes().get(payload.metricId);
|
||||
selectedMetric = payload.metricId;
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.UNPIN_METRIC: {
|
||||
pinnedMetric = null;
|
||||
pinnedMetricType = null;
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.SHOW_HELP: {
|
||||
showingHelp = true;
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.HIDE_HELP: {
|
||||
showingHelp = false;
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.DESELECT_NODE: {
|
||||
closeNodeDetails();
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.DO_CONTROL: {
|
||||
controlStatus = controlStatus.set(payload.nodeId, makeMap({
|
||||
pending: true,
|
||||
error: null
|
||||
}));
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.ENTER_EDGE: {
|
||||
// clear old highlights
|
||||
highlightedNodeIds = highlightedNodeIds.clear();
|
||||
highlightedEdgeIds = highlightedEdgeIds.clear();
|
||||
|
||||
// highlight edge
|
||||
highlightedEdgeIds = highlightedEdgeIds.add(payload.edgeId);
|
||||
|
||||
// highlight adjacent nodes
|
||||
highlightedNodeIds = highlightedNodeIds.union(payload.edgeId.split(EDGE_ID_SEPARATOR));
|
||||
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.ENTER_NODE: {
|
||||
const nodeId = payload.nodeId;
|
||||
const adjacentNodes = this.getAdjacentNodes(nodeId);
|
||||
|
||||
// clear old highlights
|
||||
highlightedNodeIds = highlightedNodeIds.clear();
|
||||
highlightedEdgeIds = highlightedEdgeIds.clear();
|
||||
|
||||
// highlight nodes
|
||||
highlightedNodeIds = highlightedNodeIds.add(nodeId);
|
||||
highlightedNodeIds = highlightedNodeIds.union(adjacentNodes);
|
||||
|
||||
// highlight edges
|
||||
if (adjacentNodes.size > 0) {
|
||||
// all neighbour combinations because we dont know which direction exists
|
||||
highlightedEdgeIds = highlightedEdgeIds.union(adjacentNodes.flatMap((adjacentId) => [
|
||||
[adjacentId, nodeId].join(EDGE_ID_SEPARATOR),
|
||||
[nodeId, adjacentId].join(EDGE_ID_SEPARATOR)
|
||||
]));
|
||||
}
|
||||
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.LEAVE_EDGE: {
|
||||
highlightedEdgeIds = highlightedEdgeIds.clear();
|
||||
highlightedNodeIds = highlightedNodeIds.clear();
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.LEAVE_NODE: {
|
||||
highlightedEdgeIds = highlightedEdgeIds.clear();
|
||||
highlightedNodeIds = highlightedNodeIds.clear();
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.OPEN_WEBSOCKET: {
|
||||
// flush nodes cache after re-connect
|
||||
nodes = nodes.clear();
|
||||
websocketClosed = false;
|
||||
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.DO_CONTROL_ERROR: {
|
||||
controlStatus = controlStatus.set(payload.nodeId, makeMap({
|
||||
pending: false,
|
||||
error: payload.error
|
||||
}));
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.DO_CONTROL_SUCCESS: {
|
||||
controlStatus = controlStatus.set(payload.nodeId, makeMap({
|
||||
pending: false,
|
||||
error: null
|
||||
}));
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.RECEIVE_CONTROL_PIPE: {
|
||||
controlPipes = controlPipes.set(payload.pipeId, makeOrderedMap({
|
||||
id: payload.pipeId,
|
||||
nodeId: payload.nodeId,
|
||||
raw: payload.rawTty
|
||||
}));
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.RECEIVE_CONTROL_PIPE_STATUS: {
|
||||
if (controlPipes.has(payload.pipeId)) {
|
||||
controlPipes = controlPipes.setIn([payload.pipeId, 'status'], payload.status);
|
||||
this.__emitChange();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ActionTypes.RECEIVE_ERROR: {
|
||||
if (errorUrl !== null) {
|
||||
errorUrl = payload.errorUrl;
|
||||
this.__emitChange();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ActionTypes.RECEIVE_NODE_DETAILS: {
|
||||
errorUrl = null;
|
||||
|
||||
// disregard if node is not selected anymore
|
||||
if (nodeDetails.has(payload.details.id)) {
|
||||
nodeDetails = nodeDetails.update(payload.details.id, obj => {
|
||||
const result = Object.assign({}, obj);
|
||||
result.notFound = false;
|
||||
result.details = payload.details;
|
||||
return result;
|
||||
});
|
||||
}
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.RECEIVE_NODES_DELTA: {
|
||||
const emptyMessage = !payload.delta.add && !payload.delta.remove
|
||||
&& !payload.delta.update;
|
||||
// this action is called frequently, good to check if something changed
|
||||
const emitChange = !emptyMessage || errorUrl !== null;
|
||||
|
||||
if (!emptyMessage) {
|
||||
log('RECEIVE_NODES_DELTA',
|
||||
'remove', _.size(payload.delta.remove),
|
||||
'update', _.size(payload.delta.update),
|
||||
'add', _.size(payload.delta.add));
|
||||
}
|
||||
|
||||
errorUrl = null;
|
||||
|
||||
// nodes that no longer exist
|
||||
_.each(payload.delta.remove, (nodeId) => {
|
||||
// in case node disappears before mouseleave event
|
||||
if (mouseOverNodeId === nodeId) {
|
||||
mouseOverNodeId = null;
|
||||
}
|
||||
if (nodes.has(nodeId) && _.includes(mouseOverEdgeId, nodeId)) {
|
||||
mouseOverEdgeId = null;
|
||||
}
|
||||
nodes = nodes.delete(nodeId);
|
||||
});
|
||||
|
||||
// update existing nodes
|
||||
_.each(payload.delta.update, (node) => {
|
||||
if (nodes.has(node.id)) {
|
||||
nodes = nodes.set(node.id, nodes.get(node.id).merge(fromJS(node)));
|
||||
}
|
||||
});
|
||||
|
||||
// add new nodes
|
||||
_.each(payload.delta.add, (node) => {
|
||||
nodes = nodes.set(node.id, fromJS(makeNode(node)));
|
||||
});
|
||||
|
||||
availableCanvasMetrics = nodes
|
||||
.valueSeq()
|
||||
.flatMap(n => (n.get('metrics') || makeList()).map(m => (
|
||||
makeMap({id: m.get('id'), label: m.get('label')})
|
||||
)))
|
||||
.toSet()
|
||||
.toList()
|
||||
.sortBy(m => m.get('label'));
|
||||
|
||||
const similarTypeMetric = availableCanvasMetrics
|
||||
.find(m => m.get('label') === pinnedMetricType);
|
||||
pinnedMetric = similarTypeMetric && similarTypeMetric.get('id');
|
||||
// if something in the current topo is not already selected, select it.
|
||||
if (!availableCanvasMetrics.map(m => m.get('id')).toSet().has(selectedMetric)) {
|
||||
selectedMetric = pinnedMetric;
|
||||
}
|
||||
|
||||
if (emitChange) {
|
||||
this.__emitChange();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ActionTypes.RECEIVE_NOT_FOUND: {
|
||||
if (nodeDetails.has(payload.nodeId)) {
|
||||
nodeDetails = nodeDetails.update(payload.nodeId, obj => {
|
||||
const result = Object.assign({}, obj);
|
||||
result.notFound = true;
|
||||
return result;
|
||||
});
|
||||
this.__emitChange();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ActionTypes.RECEIVE_TOPOLOGIES: {
|
||||
errorUrl = null;
|
||||
topologyUrlsById = topologyUrlsById.clear();
|
||||
processTopologies(payload.topologies);
|
||||
setTopology(currentTopologyId);
|
||||
// only set on first load, if options are not already set via route
|
||||
if (!topologiesLoaded && topologyOptions.size === 0) {
|
||||
setDefaultTopologyOptions(topologies);
|
||||
}
|
||||
topologiesLoaded = true;
|
||||
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.RECEIVE_API_DETAILS: {
|
||||
errorUrl = null;
|
||||
hostname = payload.hostname;
|
||||
version = payload.version;
|
||||
plugins = payload.plugins;
|
||||
versionUpdate = payload.newVersion;
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
case ActionTypes.ROUTE_TOPOLOGY: {
|
||||
routeSet = true;
|
||||
if (currentTopologyId !== payload.state.topologyId) {
|
||||
nodes = nodes.clear();
|
||||
}
|
||||
setTopology(payload.state.topologyId);
|
||||
setDefaultTopologyOptions(topologies);
|
||||
selectedNodeId = payload.state.selectedNodeId;
|
||||
pinnedMetricType = payload.state.pinnedMetricType;
|
||||
if (payload.state.controlPipe) {
|
||||
controlPipes = makeOrderedMap({
|
||||
[payload.state.controlPipe.id]:
|
||||
makeOrderedMap(payload.state.controlPipe)
|
||||
});
|
||||
} else {
|
||||
controlPipes = controlPipes.clear();
|
||||
}
|
||||
if (payload.state.nodeDetails) {
|
||||
const payloadNodeDetails = makeOrderedMap(
|
||||
payload.state.nodeDetails.map(obj => [obj.id, obj]));
|
||||
// check if detail IDs have changed
|
||||
if (!isDeepEqual(nodeDetails.keySeq(), payloadNodeDetails.keySeq())) {
|
||||
nodeDetails = payloadNodeDetails;
|
||||
}
|
||||
} else {
|
||||
nodeDetails = nodeDetails.clear();
|
||||
}
|
||||
topologyOptions = fromJS(payload.state.topologyOptions)
|
||||
|| topologyOptions;
|
||||
this.__emitChange();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new AppStore(AppDispatcher);
|
||||
@@ -889,7 +889,7 @@ h2 {
|
||||
font-size: 105%;
|
||||
line-height: 1.5;
|
||||
|
||||
&:hover {
|
||||
&:hover, &.selected {
|
||||
background-color: lighten(@background-color, 5%);
|
||||
}
|
||||
|
||||
@@ -1490,3 +1490,57 @@ h2 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Examples
|
||||
//
|
||||
|
||||
.examples {
|
||||
background-color: @background-average-color;
|
||||
.example-chart {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.nodes-chart-examples {
|
||||
.example-chart {
|
||||
margin: 8px;
|
||||
display: inline-block;
|
||||
border: 1px solid steelBlue;
|
||||
}
|
||||
}
|
||||
|
||||
.nodes-grid {
|
||||
.node-details-table-wrapper-wrapper {
|
||||
flex: 1;
|
||||
|
||||
overflow: scroll;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin: 8px 16px;
|
||||
width: 100%;
|
||||
// border: 1px solid @background-darker-color;
|
||||
padding-bottom: 36px;
|
||||
|
||||
.node-details-table-wrapper {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nodes-grid-graph {
|
||||
position: relative;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.node-details-table-node, thead tr {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.node-details-table-node {
|
||||
&:hover, &.selected {
|
||||
background-color: @background-darker-color;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,13 @@ var HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
*/
|
||||
|
||||
// Inject websocket url to dev backend
|
||||
var WEBPACK_SERVER_HOST = process.env.WEBPACK_SERVER_HOST || 'localhost';
|
||||
|
||||
var WEBPACK_SERVER_HOST = process.env.WEBPACK_SERVER_HOST || 'localhost';
|
||||
var COMMON_DEPS = [
|
||||
'webpack-dev-server/client?http://' + WEBPACK_SERVER_HOST + ':4041',
|
||||
'webpack/hot/only-dev-server',
|
||||
'./app/scripts/debug'
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -63,6 +69,11 @@ module.exports = {
|
||||
new webpack.HotModuleReplacementPlugin(),
|
||||
new webpack.NoErrorsPlugin(),
|
||||
new webpack.IgnorePlugin(/^\.\/locale$/, [/moment$/]),
|
||||
new HtmlWebpackPlugin({
|
||||
chunks: ['vendors', 'examples-app'],
|
||||
template: 'app/html/index.html',
|
||||
filename: 'examples.html'
|
||||
}),
|
||||
new HtmlWebpackPlugin({
|
||||
chunks: ['vendors', 'contrast-app'],
|
||||
template: 'app/html/index.html',
|
||||
@@ -104,7 +115,7 @@ module.exports = {
|
||||
loader: 'json-loader'
|
||||
},
|
||||
{
|
||||
test: /\.less$/,
|
||||
test: /(\.css|\.less)$/,
|
||||
loader: 'style-loader!css-loader!postcss-loader!less-loader'
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user