mirror of
https://github.com/weaveworks/scope.git
synced 2026-03-03 18:20:27 +00:00
@@ -4,9 +4,10 @@ import ActionTypes from '../constants/action-types';
|
||||
import { saveGraph } from '../utils/file-utils';
|
||||
import { modulo } from '../utils/math-utils';
|
||||
import { updateRoute } from '../utils/router-utils';
|
||||
import { parseQuery } from '../utils/search-utils';
|
||||
import { bufferDeltaUpdate, resumeUpdate,
|
||||
resetUpdateBuffer } from '../utils/update-buffer-utils';
|
||||
import { doControlRequest, getNodesDelta, getNodeDetails,
|
||||
import { doControlRequest, getAllNodes, getNodesDelta, getNodeDetails,
|
||||
getTopologies, deletePipe } from '../utils/web-api-utils';
|
||||
import { getActiveTopologyOptions,
|
||||
getCurrentTopologyUrl } from '../utils/topology-utils';
|
||||
@@ -69,6 +70,20 @@ export function pinNextMetric(delta) {
|
||||
};
|
||||
}
|
||||
|
||||
export function unpinSearch(query) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: ActionTypes.UNPIN_SEARCH,
|
||||
query
|
||||
});
|
||||
updateRoute(getState);
|
||||
};
|
||||
}
|
||||
|
||||
export function blurSearch() {
|
||||
return { type: ActionTypes.BLUR_SEARCH };
|
||||
}
|
||||
|
||||
export function changeTopologyOption(option, value, topologyId) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({
|
||||
@@ -128,7 +143,11 @@ export function clickCloseTerminal(pipeId, closePipe) {
|
||||
}
|
||||
|
||||
export function clickDownloadGraph() {
|
||||
saveGraph();
|
||||
return (dispatch) => {
|
||||
dispatch({ type: ActionTypes.SET_EXPORTING_GRAPH, exporting: true });
|
||||
saveGraph();
|
||||
dispatch({ type: ActionTypes.SET_EXPORTING_GRAPH, exporting: false });
|
||||
};
|
||||
}
|
||||
|
||||
export function clickForceRelayout() {
|
||||
@@ -265,6 +284,16 @@ export function doControl(nodeId, control) {
|
||||
};
|
||||
}
|
||||
|
||||
export function doSearch(searchQuery) {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({
|
||||
type: ActionTypes.DO_SEARCH,
|
||||
searchQuery
|
||||
});
|
||||
updateRoute(getState);
|
||||
};
|
||||
}
|
||||
|
||||
export function enterEdge(edgeId) {
|
||||
return {
|
||||
type: ActionTypes.ENTER_EDGE,
|
||||
@@ -279,12 +308,61 @@ export function enterNode(nodeId) {
|
||||
};
|
||||
}
|
||||
|
||||
export function focusSearch() {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({ type: ActionTypes.FOCUS_SEARCH });
|
||||
// update nodes cache to allow search across all topologies,
|
||||
// wait a second until animation is over
|
||||
setTimeout(() => {
|
||||
getAllNodes(getState, dispatch);
|
||||
}, 1200);
|
||||
};
|
||||
}
|
||||
|
||||
export function hitBackspace() {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
// remove last pinned query if search query is empty
|
||||
if (state.get('searchFocused') && !state.get('searchQuery')) {
|
||||
const query = state.get('pinnedSearches').last();
|
||||
if (query) {
|
||||
dispatch({
|
||||
type: ActionTypes.UNPIN_SEARCH,
|
||||
query
|
||||
});
|
||||
updateRoute(getState);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function hitEnter() {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
// pin query based on current search field
|
||||
if (state.get('searchFocused')) {
|
||||
const query = state.get('searchQuery');
|
||||
if (query && parseQuery(query)) {
|
||||
dispatch({
|
||||
type: ActionTypes.PIN_SEARCH,
|
||||
query
|
||||
});
|
||||
updateRoute(getState);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function hitEsc() {
|
||||
return (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const controlPipe = state.get('controlPipes').last();
|
||||
if (state.get('showingHelp')) {
|
||||
dispatch(hideHelp());
|
||||
} else if (state.get('searchQuery')) {
|
||||
dispatch(doSearch(''));
|
||||
} else if (state.get('searchFocused')) {
|
||||
dispatch(blurSearch());
|
||||
} else if (controlPipe && controlPipe.get('status') === 'PIPE_DELETED') {
|
||||
dispatch({
|
||||
type: ActionTypes.CLICK_CLOSE_TERMINAL,
|
||||
@@ -351,9 +429,17 @@ export function receiveNodesDelta(delta) {
|
||||
};
|
||||
}
|
||||
|
||||
export function receiveNodesForTopology(nodes, topologyId) {
|
||||
return {
|
||||
type: ActionTypes.RECEIVE_NODES_FOR_TOPOLOGY,
|
||||
nodes,
|
||||
topologyId
|
||||
};
|
||||
}
|
||||
|
||||
export function receiveTopologies(topologies) {
|
||||
return (dispatch, getState) => {
|
||||
const firstLoad = !getState().get('topologiesLoaded');
|
||||
dispatch({
|
||||
type: ActionTypes.RECEIVE_TOPOLOGIES,
|
||||
topologies
|
||||
@@ -369,6 +455,10 @@ export function receiveTopologies(topologies) {
|
||||
state.get('nodeDetails'),
|
||||
dispatch
|
||||
);
|
||||
// populate search matches on first load
|
||||
if (firstLoad && state.get('searchQuery')) {
|
||||
dispatch(focusSearch());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { connect } from 'react-redux';
|
||||
import classNames from 'classnames';
|
||||
import classnames from 'classnames';
|
||||
import { Map as makeMap } from 'immutable';
|
||||
|
||||
import { clickNode, enterNode, leaveNode } from '../actions/app-actions';
|
||||
import { getNodeColor } from '../utils/color-utils';
|
||||
import MatchedText from '../components/matched-text';
|
||||
import MatchedResults from '../components/matched-results';
|
||||
|
||||
import NodeShapeCircle from './node-shape-circle';
|
||||
import NodeShapeStack from './node-shape-stack';
|
||||
@@ -34,14 +37,15 @@ function getNodeShape({ shape, stack }) {
|
||||
return stack ? stackedShape(nodeShape) : nodeShape;
|
||||
}
|
||||
|
||||
function ellipsis(text, fontSize, maxWidth) {
|
||||
const averageCharLength = fontSize / 1.5;
|
||||
const allowedChars = maxWidth / averageCharLength;
|
||||
let truncatedText = text;
|
||||
if (text && text.length > allowedChars) {
|
||||
truncatedText = `${text.slice(0, allowedChars)}...`;
|
||||
}
|
||||
return truncatedText;
|
||||
function svgLabels(label, subLabel, labelClassName, subLabelClassName, labelOffsetY) {
|
||||
return (
|
||||
<g className="node-label-svg">
|
||||
<text className={labelClassName} y={labelOffsetY + 18} textAnchor="middle">{label}</text>
|
||||
<text className={subLabelClassName} y={labelOffsetY + 35} textAnchor="middle">
|
||||
{subLabel}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
class Node extends React.Component {
|
||||
@@ -51,64 +55,79 @@ class Node extends React.Component {
|
||||
this.handleMouseClick = this.handleMouseClick.bind(this);
|
||||
this.handleMouseEnter = this.handleMouseEnter.bind(this);
|
||||
this.handleMouseLeave = this.handleMouseLeave.bind(this);
|
||||
this.state = { hovered: false };
|
||||
this.state = {
|
||||
hovered: false,
|
||||
matched: false
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
// marks as matched only when search query changes
|
||||
if (nextProps.searchQuery !== this.props.searchQuery) {
|
||||
this.setState({
|
||||
matched: nextProps.matched
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
matched: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { blurred, focused, highlighted, label, pseudo, rank,
|
||||
subLabel, scaleFactor, transform, zoomScale } = this.props;
|
||||
const { hovered } = this.state;
|
||||
const { blurred, focused, highlighted, label, matches = makeMap(),
|
||||
pseudo, rank, subLabel, scaleFactor, transform, zoomScale, exportingGraph } = this.props;
|
||||
const { hovered, matched } = this.state;
|
||||
const nodeScale = focused ? this.props.selectedNodeScale : this.props.nodeScale;
|
||||
|
||||
const color = getNodeColor(rank, label, pseudo);
|
||||
const truncate = !focused && !hovered;
|
||||
const labelText = truncate ? ellipsis(label, 14, nodeScale(4 * scaleFactor)) : label;
|
||||
const subLabelText = truncate ? ellipsis(subLabel, 12, nodeScale(4 * scaleFactor)) : subLabel;
|
||||
const labelTransform = focused ? `scale(${1 / zoomScale})` : '';
|
||||
const labelWidth = nodeScale(scaleFactor * 4);
|
||||
const labelOffsetX = -labelWidth / 2;
|
||||
const labelOffsetY = focused ? nodeScale(0.5) : nodeScale(0.5 * scaleFactor);
|
||||
|
||||
let labelOffsetY = 18;
|
||||
let subLabelOffsetY = 35;
|
||||
let labelFontSize = 14;
|
||||
let subLabelFontSize = 12;
|
||||
|
||||
// render focused nodes in normal size
|
||||
if (focused) {
|
||||
labelFontSize /= zoomScale;
|
||||
subLabelFontSize /= zoomScale;
|
||||
labelOffsetY /= zoomScale;
|
||||
subLabelOffsetY /= zoomScale;
|
||||
}
|
||||
|
||||
const className = classNames({
|
||||
node: true,
|
||||
const nodeClassName = classnames('node', {
|
||||
highlighted,
|
||||
blurred,
|
||||
blurred: blurred && !focused,
|
||||
hovered,
|
||||
matched,
|
||||
pseudo
|
||||
});
|
||||
|
||||
const labelClassName = classnames('node-label', { truncate });
|
||||
const subLabelClassName = classnames('node-sublabel', { truncate });
|
||||
|
||||
const NodeShapeType = getNodeShape(this.props);
|
||||
const useSvgLabels = exportingGraph;
|
||||
|
||||
return (
|
||||
<g className={className} transform={transform} onClick={this.handleMouseClick}
|
||||
<g className={nodeClassName} transform={transform}
|
||||
onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
|
||||
<rect className="hover-box"
|
||||
x={-nodeScale(scaleFactor * 0.5)}
|
||||
y={-nodeScale(scaleFactor * 0.5)}
|
||||
width={nodeScale(scaleFactor)}
|
||||
height={nodeScale(scaleFactor) + subLabelOffsetY}
|
||||
/>
|
||||
<text className="node-label" textAnchor="middle" style={{fontSize: labelFontSize}}
|
||||
x="0" y={labelOffsetY + nodeScale(0.5 * scaleFactor)}>
|
||||
{labelText}
|
||||
</text>
|
||||
<text className="node-sublabel" textAnchor="middle" style={{fontSize: subLabelFontSize}}
|
||||
x="0" y={subLabelOffsetY + nodeScale(0.5 * scaleFactor)}>
|
||||
{subLabelText}
|
||||
</text>
|
||||
<NodeShapeType
|
||||
size={nodeScale(scaleFactor)}
|
||||
color={color}
|
||||
{...this.props} />
|
||||
|
||||
{useSvgLabels ?
|
||||
|
||||
svgLabels(label, subLabel, labelClassName, subLabelClassName, labelOffsetY) :
|
||||
|
||||
<foreignObject x={labelOffsetX} y={labelOffsetY} width={labelWidth}
|
||||
height="10em" transform={labelTransform}>
|
||||
<div className="node-label-wrapper" onClick={this.handleMouseClick}>
|
||||
<div className={labelClassName}>
|
||||
<MatchedText text={label} match={matches.get('label')} />
|
||||
</div>
|
||||
<div className={subLabelClassName}>
|
||||
<MatchedText text={subLabel} match={matches.get('sublabel')} />
|
||||
</div>
|
||||
{!blurred && <MatchedResults matches={matches.get('metadata')} />}
|
||||
</div>
|
||||
</foreignObject>}
|
||||
|
||||
<g onClick={this.handleMouseClick}>
|
||||
<NodeShapeType
|
||||
size={nodeScale(scaleFactor)}
|
||||
color={color}
|
||||
{...this.props} />
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -131,6 +150,9 @@ class Node extends React.Component {
|
||||
}
|
||||
|
||||
export default connect(
|
||||
null,
|
||||
state => ({
|
||||
searchQuery: state.get('searchQuery'),
|
||||
exportingGraph: state.get('exportingGraph')
|
||||
}),
|
||||
{ clickNode, enterNode, leaveNode }
|
||||
)(Node);
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Map as makeMap } from 'immutable';
|
||||
|
||||
import { hasSelectedNode as hasSelectedNodeFn } from '../utils/topology-utils';
|
||||
import EdgeContainer from './edge-container';
|
||||
|
||||
class NodesChartEdges extends React.Component {
|
||||
render() {
|
||||
const {hasSelectedNode, highlightedEdgeIds, layoutEdges, layoutPrecision,
|
||||
selectedNodeId} = this.props;
|
||||
const { hasSelectedNode, highlightedEdgeIds, layoutEdges,
|
||||
layoutPrecision, searchNodeMatches = makeMap(), searchQuery,
|
||||
selectedNodeId } = this.props;
|
||||
|
||||
return (
|
||||
<g className="nodes-chart-edges">
|
||||
{layoutEdges.toIndexedSeq().map(edge => {
|
||||
const sourceSelected = selectedNodeId === edge.get('source');
|
||||
const targetSelected = selectedNodeId === edge.get('target');
|
||||
const blurred = hasSelectedNode && !sourceSelected && !targetSelected;
|
||||
const highlighted = highlightedEdgeIds.has(edge.get('id'));
|
||||
const focused = hasSelectedNode && (sourceSelected || targetSelected);
|
||||
const blurred = !(highlightedEdgeIds.size > 0 && highlighted)
|
||||
&& ((hasSelectedNode && !sourceSelected && !targetSelected)
|
||||
|| !focused && searchQuery && !(searchNodeMatches.has(edge.get('source'))
|
||||
&& searchNodeMatches.has(edge.get('target'))));
|
||||
|
||||
return (
|
||||
<EdgeContainer
|
||||
@@ -27,7 +33,7 @@ class NodesChartEdges extends React.Component {
|
||||
blurred={blurred}
|
||||
focused={focused}
|
||||
layoutPrecision={layoutPrecision}
|
||||
highlighted={highlightedEdgeIds.has(edge.get('id'))}
|
||||
highlighted={highlighted}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -37,10 +43,13 @@ class NodesChartEdges extends React.Component {
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const currentTopologyId = state.get('currentTopologyId');
|
||||
return {
|
||||
hasSelectedNode: hasSelectedNodeFn(state),
|
||||
selectedNodeId: state.get('selectedNodeId'),
|
||||
highlightedEdgeIds: state.get('highlightedEdgeIds')
|
||||
highlightedEdgeIds: state.get('highlightedEdgeIds'),
|
||||
searchNodeMatches: state.getIn(['searchNodeMatches', currentTopologyId]),
|
||||
searchQuery: state.get('searchQuery'),
|
||||
selectedNodeId: state.get('selectedNodeId')
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { fromJS } from 'immutable';
|
||||
import { fromJS, Map as makeMap } from 'immutable';
|
||||
|
||||
import { getAdjacentNodes } from '../utils/topology-utils';
|
||||
import NodeContainer from './node-container';
|
||||
@@ -8,8 +8,9 @@ import NodeContainer from './node-container';
|
||||
class NodesChartNodes extends React.Component {
|
||||
render() {
|
||||
const { adjacentNodes, highlightedNodeIds, layoutNodes, layoutPrecision,
|
||||
nodeScale, scale, selectedMetric, selectedNodeScale, selectedNodeId,
|
||||
topologyId, topCardNode } = this.props;
|
||||
mouseOverNodeId, nodeScale, scale, searchNodeMatches = makeMap(),
|
||||
searchQuery, selectedMetric, selectedNodeScale, selectedNodeId,
|
||||
topCardNode } = this.props;
|
||||
|
||||
const zoomScale = scale;
|
||||
|
||||
@@ -19,11 +20,17 @@ class NodesChartNodes extends React.Component {
|
||||
const setFocused = node => node.set('focused', selectedNodeId
|
||||
&& (selectedNodeId === node.get('id')
|
||||
|| (adjacentNodes && adjacentNodes.includes(node.get('id')))));
|
||||
const setBlurred = node => node.set('blurred', selectedNodeId && !node.get('focused'));
|
||||
const setBlurred = node => node.set('blurred',
|
||||
selectedNodeId && !node.get('focused')
|
||||
|| searchQuery && !searchNodeMatches.has(node.get('id'))
|
||||
&& !node.get('highlighted'));
|
||||
|
||||
// make sure blurred nodes are in the background
|
||||
const sortNodes = node => {
|
||||
if (node.get('blurred')) {
|
||||
if (node.get('id') === mouseOverNodeId) {
|
||||
return 3;
|
||||
}
|
||||
if (node.get('blurred') && !node.get('focused')) {
|
||||
return 0;
|
||||
}
|
||||
if (node.get('highlighted')) {
|
||||
@@ -52,8 +59,9 @@ class NodesChartNodes extends React.Component {
|
||||
{nodesToRender.map(node => <NodeContainer
|
||||
blurred={node.get('blurred')}
|
||||
focused={node.get('focused')}
|
||||
matched={searchNodeMatches.has(node.get('id'))}
|
||||
matches={searchNodeMatches.get(node.get('id'))}
|
||||
highlighted={node.get('highlighted')}
|
||||
topologyId={topologyId}
|
||||
shape={node.get('shape')}
|
||||
stack={node.get('stack')}
|
||||
key={node.get('id')}
|
||||
@@ -76,12 +84,15 @@ class NodesChartNodes extends React.Component {
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
const currentTopologyId = state.get('currentTopologyId');
|
||||
return {
|
||||
adjacentNodes: getAdjacentNodes(state),
|
||||
highlightedNodeIds: state.get('highlightedNodeIds'),
|
||||
mouseOverNodeId: state.get('mouseOverNodeId'),
|
||||
selectedMetric: state.get('selectedMetric'),
|
||||
selectedNodeId: state.get('selectedNodeId'),
|
||||
topologyId: state.get('topologyId'),
|
||||
searchNodeMatches: state.getIn(['searchNodeMatches', currentTopologyId]),
|
||||
searchQuery: state.get('searchQuery'),
|
||||
topCardNode: state.get('nodeDetails').last()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -198,17 +198,14 @@ class NodesChart extends React.Component {
|
||||
if (!edges.has(edgeId)) {
|
||||
const source = edge[0];
|
||||
const target = edge[1];
|
||||
|
||||
if (!stateNodes.has(source) || !stateNodes.has(target)) {
|
||||
log('Missing edge node', edge[0], edge[1]);
|
||||
if (stateNodes.has(source) && stateNodes.has(target)) {
|
||||
edges = edges.set(edgeId, makeMap({
|
||||
id: edgeId,
|
||||
value: 1,
|
||||
source,
|
||||
target
|
||||
}));
|
||||
}
|
||||
|
||||
edges = edges.set(edgeId, makeMap({
|
||||
id: edgeId,
|
||||
value: 1,
|
||||
source,
|
||||
target
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -404,9 +401,9 @@ function mapStateToProps(state) {
|
||||
return {
|
||||
adjacentNodes: getAdjacentNodes(state),
|
||||
forceRelayout: state.get('forceRelayout'),
|
||||
nodes: state.get('nodes'),
|
||||
nodes: state.get('nodes').filter(node => !node.get('filtered')),
|
||||
selectedNodeId: state.get('selectedNodeId'),
|
||||
topologyId: state.get('topologyId'),
|
||||
topologyId: state.get('currentTopologyId'),
|
||||
topologyOptions: getActiveTopologyOptions(state)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const DEFAULT_WIDTH = 800;
|
||||
const DEFAULT_MARGINS = {top: 0, left: 0};
|
||||
const DEFAULT_SCALE = val => val * 2;
|
||||
const NODE_SIZE_FACTOR = 1;
|
||||
const NODE_SEPARATION_FACTOR = 2.5;
|
||||
const NODE_SEPARATION_FACTOR = 3.0;
|
||||
const RANK_SEPARATION_FACTOR = 2.5;
|
||||
let layoutRuns = 0;
|
||||
let layoutRunsTrivial = 0;
|
||||
|
||||
@@ -39,6 +39,6 @@ describe('NodeDetails', () => {
|
||||
nodeId={nodeId} details={details} />);
|
||||
|
||||
const title = TestUtils.findRenderedDOMComponentWithClass(c, 'node-details-header-label');
|
||||
expect(title.textContent).toBe('Node 1');
|
||||
expect(title.title).toBe('Node 1');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,11 +6,12 @@ import Logo from './logo';
|
||||
import Footer from './footer.js';
|
||||
import Sidebar from './sidebar.js';
|
||||
import HelpPanel from './help-panel';
|
||||
import Search from './search';
|
||||
import Status from './status.js';
|
||||
import Topologies from './topologies.js';
|
||||
import TopologyOptions from './topology-options.js';
|
||||
import { getApiDetails, getTopologies } from '../utils/web-api-utils';
|
||||
import { pinNextMetric, hitEsc, unpinMetric,
|
||||
import { focusSearch, pinNextMetric, hitBackspace, hitEnter, hitEsc, unpinMetric,
|
||||
selectMetric, toggleHelp } from '../actions/app-actions';
|
||||
import Details from './details';
|
||||
import Nodes from './nodes';
|
||||
@@ -22,6 +23,8 @@ import DebugToolbar, { showingDebugToolbar,
|
||||
import { getUrlState } from '../utils/router-utils';
|
||||
import { getActiveTopologyOptions } from '../utils/topology-utils';
|
||||
|
||||
const BACKSPACE_KEY_CODE = 8;
|
||||
const ENTER_KEY_CODE = 13;
|
||||
const ESC_KEY_CODE = 27;
|
||||
const keyPressLog = debug('scope:app-key-press');
|
||||
|
||||
@@ -54,31 +57,40 @@ class App extends React.Component {
|
||||
// don't get esc in onKeyPress
|
||||
if (ev.keyCode === ESC_KEY_CODE) {
|
||||
this.props.dispatch(hitEsc());
|
||||
} else if (ev.keyCode === ENTER_KEY_CODE) {
|
||||
this.props.dispatch(hitEnter());
|
||||
} else if (ev.keyCode === BACKSPACE_KEY_CODE) {
|
||||
this.props.dispatch(hitBackspace());
|
||||
} else if (ev.code === 'KeyD' && ev.ctrlKey) {
|
||||
toggleDebugToolbar();
|
||||
this.forceUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
onKeyPress(ev) {
|
||||
const { dispatch } = this.props;
|
||||
const { dispatch, searchFocused } = this.props;
|
||||
//
|
||||
// keyup gives 'key'
|
||||
// keypress gives 'char'
|
||||
// Distinction is important for international keyboard layouts where there
|
||||
// is often a different {key: char} mapping.
|
||||
//
|
||||
keyPressLog('onKeyPress', 'keyCode', ev.keyCode, ev);
|
||||
const char = String.fromCharCode(ev.charCode);
|
||||
if (char === '<') {
|
||||
dispatch(pinNextMetric(-1));
|
||||
} else if (char === '>') {
|
||||
dispatch(pinNextMetric(1));
|
||||
} else if (char === 'q') {
|
||||
dispatch(unpinMetric());
|
||||
dispatch(selectMetric(null));
|
||||
} else if (char === 'd') {
|
||||
toggleDebugToolbar();
|
||||
this.forceUpdate();
|
||||
} else if (char === '?') {
|
||||
dispatch(toggleHelp());
|
||||
if (!searchFocused) {
|
||||
keyPressLog('onKeyPress', 'keyCode', ev.keyCode, ev);
|
||||
const char = String.fromCharCode(ev.charCode);
|
||||
if (char === '<') {
|
||||
dispatch(pinNextMetric(-1));
|
||||
} else if (char === '>') {
|
||||
dispatch(pinNextMetric(1));
|
||||
} else if (char === 'q') {
|
||||
dispatch(unpinMetric());
|
||||
dispatch(selectMetric(null));
|
||||
} else if (char === '/') {
|
||||
ev.preventDefault();
|
||||
dispatch(focusSearch());
|
||||
} else if (char === '?') {
|
||||
dispatch(toggleHelp());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +116,7 @@ class App extends React.Component {
|
||||
<Logo />
|
||||
</svg>
|
||||
</div>
|
||||
<Search />
|
||||
<Topologies />
|
||||
</div>
|
||||
|
||||
@@ -128,6 +141,8 @@ function mapStateToProps(state) {
|
||||
controlPipes: state.get('controlPipes'),
|
||||
nodeDetails: state.get('nodeDetails'),
|
||||
routeSet: state.get('routeSet'),
|
||||
searchFocused: state.get('searchFocused'),
|
||||
searchQuery: state.get('searchQuery'),
|
||||
showingHelp: state.get('showingHelp'),
|
||||
urlState: getUrlState(state)
|
||||
};
|
||||
|
||||
@@ -72,7 +72,7 @@ class Footer extends React.Component {
|
||||
<span className="fa fa-refresh" />
|
||||
</a>
|
||||
<a className="footer-icon" onClick={this.props.clickDownloadGraph}
|
||||
title="Save canvas as SVG">
|
||||
title="Save canvas as SVG (does not include search highlighting)">
|
||||
<span className="fa fa-download" />
|
||||
</a>
|
||||
<a className="footer-icon" href="api/report" download title="Save raw data as JSON">
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
|
||||
const GENERAL_SHORTCUTS = [
|
||||
{key: 'esc', label: 'Close active panel'},
|
||||
{key: '/', label: 'Activate search field'},
|
||||
{key: '?', label: 'Toggle shortcut menu'},
|
||||
];
|
||||
|
||||
@@ -41,4 +42,3 @@ export default class HelpPanel extends React.Component {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
55
client/app/scripts/components/matched-results.js
Normal file
55
client/app/scripts/components/matched-results.js
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import MatchedText from './matched-text';
|
||||
|
||||
const SHOW_ROW_COUNT = 2;
|
||||
const MAX_MATCH_LENGTH = 24;
|
||||
|
||||
class MatchedResults extends React.Component {
|
||||
|
||||
renderMatch(matches, field) {
|
||||
const match = matches.get(field);
|
||||
const text = match.text;
|
||||
|
||||
return (
|
||||
<div className="matched-results-match" key={match.label}>
|
||||
<div className="matched-results-match-wrapper">
|
||||
<span className="matched-results-match-label">
|
||||
{match.label}:
|
||||
</span>
|
||||
<MatchedText text={text} match={match} maxLength={MAX_MATCH_LENGTH} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { matches } = this.props;
|
||||
|
||||
if (!matches) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let moreFieldMatches;
|
||||
let moreFieldMatchesTitle;
|
||||
if (matches.size > SHOW_ROW_COUNT) {
|
||||
moreFieldMatches = matches
|
||||
.valueSeq()
|
||||
.skip(SHOW_ROW_COUNT)
|
||||
.map(field => field.label);
|
||||
moreFieldMatchesTitle = `More matches:\n${moreFieldMatches.join(',\n')}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="matched-results">
|
||||
{matches.keySeq().take(SHOW_ROW_COUNT).map(fieldId => this.renderMatch(matches, fieldId))}
|
||||
{moreFieldMatches && <div className="matched-results-more" title={moreFieldMatchesTitle}>
|
||||
{`${moreFieldMatches.size} more matches`}
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect()(MatchedResults);
|
||||
108
client/app/scripts/components/matched-text.js
Normal file
108
client/app/scripts/components/matched-text.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
const TRUNCATE_CONTEXT = 6;
|
||||
const TRUNCATE_ELLIPSIS = '…';
|
||||
|
||||
/**
|
||||
* Returns an array with chunks that cover the whole text via {start, length}
|
||||
* objects.
|
||||
*
|
||||
* `('text', {start: 2, length: 1}) => [{text: 'te'}, {text: 'x', match: true}, {text: 't'}]`
|
||||
*/
|
||||
function chunkText(text, { start, length }) {
|
||||
if (text && !isNaN(start) && !isNaN(length)) {
|
||||
const chunks = [];
|
||||
// text chunk before match
|
||||
if (start > 0) {
|
||||
chunks.push({text: text.substr(0, start)});
|
||||
}
|
||||
// matching chunk
|
||||
chunks.push({match: true, text: text.substr(start, length)});
|
||||
// text after match
|
||||
const remaining = start + length;
|
||||
if (remaining < text.length) {
|
||||
chunks.push({text: text.substr(remaining)});
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
return [{ text }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncates chunks with ellipsis
|
||||
*
|
||||
* First chunk is truncated from left, second chunk (match) is truncated in the
|
||||
* middle, last chunk is truncated at the end, e.g.
|
||||
* `[{text: "...cation is a "}, {text: "useful...or not"}, {text: "tool..."}]`
|
||||
*/
|
||||
function truncateChunks(chunks, text, maxLength) {
|
||||
if (chunks && chunks.length === 3 && maxLength && text && text.length > maxLength) {
|
||||
const res = chunks.map(c => Object.assign({}, c));
|
||||
let needToCut = text.length - maxLength;
|
||||
// trucate end
|
||||
const end = res[2];
|
||||
if (end.text.length > TRUNCATE_CONTEXT) {
|
||||
needToCut -= end.text.length - TRUNCATE_CONTEXT;
|
||||
end.text = `${end.text.substr(0, TRUNCATE_CONTEXT)}${TRUNCATE_ELLIPSIS}`;
|
||||
}
|
||||
|
||||
if (needToCut) {
|
||||
// truncate front
|
||||
const start = res[0];
|
||||
if (start.text.length > TRUNCATE_CONTEXT) {
|
||||
needToCut -= start.text.length - TRUNCATE_CONTEXT;
|
||||
start.text = `${TRUNCATE_ELLIPSIS}`
|
||||
+ `${start.text.substr(start.text.length - TRUNCATE_CONTEXT)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (needToCut) {
|
||||
// truncate match
|
||||
const middle = res[1];
|
||||
if (middle.text.length > 2 * TRUNCATE_CONTEXT) {
|
||||
middle.text = `${middle.text.substr(0, TRUNCATE_CONTEXT)}`
|
||||
+ `${TRUNCATE_ELLIPSIS}`
|
||||
+ `${middle.text.substr(middle.text.length - TRUNCATE_CONTEXT)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders text with highlighted search match.
|
||||
*
|
||||
* A match object is of shape `{text, label, match}`.
|
||||
* `match` is a text match object of shape `{start, length}`
|
||||
* that delimit text matches in `text`. `label` shows the origin of the text.
|
||||
*/
|
||||
class MatchedText extends React.Component {
|
||||
|
||||
render() {
|
||||
const { match, text, maxLength } = this.props;
|
||||
|
||||
if (!match) {
|
||||
return <span>{text}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="matched-text" title={text}>
|
||||
{truncateChunks(chunkText(text, match), text, maxLength).map((chunk, index) => {
|
||||
if (chunk.match) {
|
||||
return (
|
||||
<span className="match" key={index}>
|
||||
{chunk.text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return chunk.text;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect()(MatchedText);
|
||||
@@ -1,11 +1,13 @@
|
||||
import _ from 'lodash';
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Map as makeMap } from 'immutable';
|
||||
|
||||
import { clickCloseDetails, clickShowTopologyForNode } from '../actions/app-actions';
|
||||
import { brightenColor, getNeutralColor, getNodeColorDark } from '../utils/color-utils';
|
||||
import { resetDocumentTitle, setDocumentTitle } from '../utils/title-utils';
|
||||
|
||||
import MatchedText from './matched-text';
|
||||
import NodeDetailsControls from './node-details/node-details-controls';
|
||||
import NodeDetailsHealth from './node-details/node-details-health';
|
||||
import NodeDetailsInfo from './node-details/node-details-info';
|
||||
@@ -140,11 +142,10 @@ export class NodeDetails extends React.Component {
|
||||
}
|
||||
|
||||
renderDetails() {
|
||||
const details = this.props.details;
|
||||
const { details, nodeControlStatus, nodeMatches = makeMap() } = this.props;
|
||||
const showControls = details.controls && details.controls.length > 0;
|
||||
const nodeColor = getNodeColorDark(details.rank, details.label, details.pseudo);
|
||||
const {error, pending} = this.props.nodeControlStatus
|
||||
? this.props.nodeControlStatus.toJS() : {};
|
||||
const {error, pending} = nodeControlStatus ? nodeControlStatus.toJS() : {};
|
||||
const tools = this.renderTools();
|
||||
const styles = {
|
||||
controls: {
|
||||
@@ -161,7 +162,7 @@ export class NodeDetails extends React.Component {
|
||||
<div className="node-details-header" style={styles.header}>
|
||||
<div className="node-details-header-wrapper">
|
||||
<h2 className="node-details-header-label truncate" title={details.label}>
|
||||
{details.label}
|
||||
<MatchedText text={details.label} match={nodeMatches.get('label')} />
|
||||
</h2>
|
||||
<div className="node-details-header-relatives">
|
||||
{details.parents && <NodeDetailsRelatives relatives={details.parents} />}
|
||||
@@ -183,7 +184,7 @@ export class NodeDetails extends React.Component {
|
||||
</div>}
|
||||
{details.metadata && <div className="node-details-content-section">
|
||||
<div className="node-details-content-section-header">Info</div>
|
||||
<NodeDetailsInfo rows={details.metadata} />
|
||||
<NodeDetailsInfo rows={details.metadata} matches={nodeMatches.get('metadata')} />
|
||||
</div>}
|
||||
|
||||
{details.connections && details.connections.map(connections => <div
|
||||
@@ -210,7 +211,8 @@ export class NodeDetails extends React.Component {
|
||||
<Warning text={getTruncationText(table.truncationCount)} />
|
||||
</span>}
|
||||
</div>
|
||||
<NodeDetailsLabels rows={table.rows} />
|
||||
<NodeDetailsLabels rows={table.rows}
|
||||
matches={nodeMatches.get('tables')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -230,8 +232,10 @@ export class NodeDetails extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const currentTopologyId = state.get('currentTopologyId');
|
||||
return {
|
||||
nodeMatches: state.getIn(['searchNodeMatches', currentTopologyId, ownProps.id]),
|
||||
nodes: state.get('nodes')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Map as makeMap } from 'immutable';
|
||||
|
||||
import MatchedText from '../matched-text';
|
||||
import ShowMore from '../show-more';
|
||||
|
||||
export default class NodeDetailsInfo extends React.Component {
|
||||
@@ -18,13 +20,21 @@ export default class NodeDetailsInfo extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { matches = makeMap() } = this.props;
|
||||
let rows = (this.props.rows || []);
|
||||
const prime = rows.filter(row => row.priority < 10);
|
||||
let notShown = 0;
|
||||
|
||||
const prime = rows.filter(row => row.priority < 10);
|
||||
if (!this.state.expanded && prime.length < rows.length) {
|
||||
notShown = rows.length - prime.length;
|
||||
rows = prime;
|
||||
// check if there is a search match in non-prime fields
|
||||
const hasNonPrimeMatch = matches && rows.filter(row => row.priority >= 10
|
||||
&& matches.has(row.id)).length > 0;
|
||||
if (!hasNonPrimeMatch) {
|
||||
notShown = rows.length - prime.length;
|
||||
rows = prime;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="node-details-info">
|
||||
{rows.map(field => (<div className="node-details-info-field" key={field.id}>
|
||||
@@ -32,7 +42,7 @@ export default class NodeDetailsInfo extends React.Component {
|
||||
{field.label}
|
||||
</div>
|
||||
<div className="node-details-info-field-value truncate" title={field.value}>
|
||||
{field.value}
|
||||
<MatchedText text={field.value} match={matches.get(field.id)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Map as makeMap } from 'immutable';
|
||||
|
||||
import MatchedText from '../matched-text';
|
||||
import ShowMore from '../show-more';
|
||||
|
||||
export default class NodeDetailsLabels extends React.Component {
|
||||
@@ -19,12 +21,18 @@ export default class NodeDetailsLabels extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { matches = makeMap() } = this.props;
|
||||
let rows = this.props.rows;
|
||||
let notShown = 0;
|
||||
const limited = rows && this.state.limit > 0 && rows.length > this.state.limit;
|
||||
const expanded = this.state.limit === 0;
|
||||
const notShown = rows.length - this.DEFAULT_LIMIT;
|
||||
if (rows && limited) {
|
||||
rows = rows.slice(0, this.state.limit);
|
||||
const hasNotShownMatch = rows.filter((row, index) => index >= this.state.limit
|
||||
&& matches.has(row.id)).length > 0;
|
||||
if (!hasNotShownMatch) {
|
||||
notShown = rows.length - this.DEFAULT_LIMIT;
|
||||
rows = rows.slice(0, this.state.limit);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -35,7 +43,7 @@ export default class NodeDetailsLabels extends React.Component {
|
||||
{field.label}
|
||||
</div>
|
||||
<div className="node-details-labels-field-value truncate" title={field.value}>
|
||||
{field.value}
|
||||
<MatchedText text={field.value} match={matches.get(field.id)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
28
client/app/scripts/components/search-item.js
Normal file
28
client/app/scripts/components/search-item.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { unpinSearch } from '../actions/app-actions';
|
||||
|
||||
class SearchItem extends React.Component {
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.handleClick = this.handleClick.bind(this);
|
||||
}
|
||||
|
||||
handleClick(ev) {
|
||||
ev.preventDefault();
|
||||
this.props.unpinSearch(this.props.query);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<span className="search-item">
|
||||
<span className="search-item-label">{this.props.query}</span>
|
||||
<span className="search-item-icon fa fa-close" onClick={this.handleClick} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(null, { unpinSearch })(SearchItem);
|
||||
145
client/app/scripts/components/search.js
Normal file
145
client/app/scripts/components/search.js
Normal file
@@ -0,0 +1,145 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { connect } from 'react-redux';
|
||||
import classnames from 'classnames';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { blurSearch, doSearch, focusSearch } from '../actions/app-actions';
|
||||
import { slugify } from '../utils/string-utils';
|
||||
import { isTopologyEmpty } from '../utils/topology-utils';
|
||||
import SearchItem from './search-item';
|
||||
|
||||
function shortenHintLabel(text) {
|
||||
return text
|
||||
.split(' ')[0]
|
||||
.toLowerCase()
|
||||
.substr(0, 12);
|
||||
}
|
||||
|
||||
// dynamic hint based on node names
|
||||
function getHint(nodes) {
|
||||
let label = 'mycontainer';
|
||||
let metadataLabel = 'ip';
|
||||
let metadataValue = '10.1.0.1';
|
||||
|
||||
const node = nodes.filter(n => !n.get('pseudo') && n.has('metadata')).last();
|
||||
if (node) {
|
||||
label = shortenHintLabel(node.get('label'))
|
||||
.split('.')[0];
|
||||
if (node.get('metadata')) {
|
||||
const metadataField = node.get('metadata').first();
|
||||
metadataLabel = shortenHintLabel(slugify(metadataField.get('label')))
|
||||
.split('.').pop();
|
||||
metadataValue = shortenHintLabel(metadataField.get('value'));
|
||||
}
|
||||
}
|
||||
|
||||
return `Try "${label}", "${metadataLabel}:${metadataValue}", or "cpu > 2%".
|
||||
Hit enter to apply the search as a filter.`;
|
||||
}
|
||||
|
||||
class Search extends React.Component {
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleFocus = this.handleFocus.bind(this);
|
||||
this.doSearch = _.debounce(this.doSearch.bind(this), 200);
|
||||
this.state = {
|
||||
value: ''
|
||||
};
|
||||
}
|
||||
|
||||
handleChange(ev) {
|
||||
const inputValue = ev.target.value;
|
||||
let value = inputValue;
|
||||
// In render() props.searchQuery can be set from the outside, but state.value
|
||||
// must have precedence for quick feedback. Now when the user backspaces
|
||||
// quickly enough from `text`, a previouse doSearch(`text`) will come back
|
||||
// via props and override the empty state.value. To detect this edge case
|
||||
// we instead set value to null when backspacing.
|
||||
if (this.state.value && value === '') {
|
||||
value = null;
|
||||
}
|
||||
this.setState({value});
|
||||
this.doSearch(inputValue);
|
||||
}
|
||||
|
||||
handleFocus() {
|
||||
this.props.focusSearch();
|
||||
}
|
||||
|
||||
doSearch(value) {
|
||||
this.props.doSearch(value);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
// when cleared from the outside, reset internal state
|
||||
if (this.props.searchQuery !== nextProps.searchQuery && nextProps.searchQuery === '') {
|
||||
this.setState({value: ''});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.props.searchFocused) {
|
||||
ReactDOM.findDOMNode(this.refs.queryInput).focus();
|
||||
} else if (!this.state.value) {
|
||||
ReactDOM.findDOMNode(this.refs.queryInput).blur();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { inputId = 'search', nodes, pinnedSearches, searchFocused,
|
||||
searchNodeMatches, searchQuery, topologiesLoaded } = this.props;
|
||||
const disabled = this.props.isTopologyEmpty;
|
||||
const matchCount = searchNodeMatches
|
||||
.reduce((count, topologyMatches) => count + topologyMatches.size, 0);
|
||||
const showPinnedSearches = pinnedSearches.size > 0;
|
||||
// manual clear (null) has priority, then props, then state
|
||||
const value = this.state.value === null ? '' : this.state.value || searchQuery || '';
|
||||
const classNames = classnames('search', 'hideable', {
|
||||
hide: !topologiesLoaded,
|
||||
'search-pinned': showPinnedSearches,
|
||||
'search-matched': matchCount,
|
||||
'search-filled': value,
|
||||
'search-focused': searchFocused,
|
||||
'search-disabled': disabled
|
||||
});
|
||||
const title = matchCount ? `${matchCount} matches` : null;
|
||||
|
||||
return (
|
||||
<div className="search-wrapper">
|
||||
<div className={classNames} title={title}>
|
||||
<div className="search-input">
|
||||
<i className="fa fa-search search-input-icon"></i>
|
||||
<label className="search-input-label" htmlFor={inputId}>
|
||||
Search
|
||||
</label>
|
||||
{showPinnedSearches && pinnedSearches.toIndexedSeq()
|
||||
.map(query => <SearchItem query={query} key={query} />)}
|
||||
<input className="search-input-field" type="text" id={inputId}
|
||||
value={value} onChange={this.handleChange}
|
||||
onFocus={this.handleFocus}
|
||||
disabled={disabled} ref="queryInput" />
|
||||
</div>
|
||||
{!showPinnedSearches && <div className="search-hint">
|
||||
{getHint(nodes)}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(
|
||||
state => ({
|
||||
nodes: state.get('nodes'),
|
||||
isTopologyEmpty: isTopologyEmpty(state),
|
||||
pinnedSearches: state.get('pinnedSearches'),
|
||||
searchFocused: state.get('searchFocused'),
|
||||
searchQuery: state.get('searchQuery'),
|
||||
searchNodeMatches: state.get('searchNodeMatches'),
|
||||
topologiesLoaded: state.get('topologiesLoaded')
|
||||
}),
|
||||
{ blurSearch, doSearch, focusSearch }
|
||||
)(Search);
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import classnames from 'classnames';
|
||||
|
||||
import { clickTopology } from '../actions/app-actions';
|
||||
|
||||
@@ -18,9 +19,13 @@ class Topologies extends React.Component {
|
||||
renderSubTopology(subTopology) {
|
||||
const isActive = subTopology === this.props.currentTopology;
|
||||
const topologyId = subTopology.get('id');
|
||||
const title = this.renderTitle(subTopology);
|
||||
const className = isActive
|
||||
? 'topologies-sub-item topologies-sub-item-active' : 'topologies-sub-item';
|
||||
const searchMatches = this.props.searchNodeMatches.get(subTopology.get('id'));
|
||||
const searchMatchCount = searchMatches ? searchMatches.size : 0;
|
||||
const title = this.renderTitle(subTopology, searchMatchCount);
|
||||
const className = classnames('topologies-sub-item', {
|
||||
'topologies-sub-item-active': isActive,
|
||||
'topologies-sub-item-matched': searchMatchCount
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={className} title={title} key={topologyId} rel={topologyId}
|
||||
@@ -32,17 +37,25 @@ class Topologies extends React.Component {
|
||||
);
|
||||
}
|
||||
|
||||
renderTitle(topology) {
|
||||
return `Nodes: ${topology.getIn(['stats', 'node_count'])}\n`
|
||||
renderTitle(topology, searchMatchCount) {
|
||||
let title = `Nodes: ${topology.getIn(['stats', 'node_count'])}\n`
|
||||
+ `Connections: ${topology.getIn(['stats', 'node_count'])}`;
|
||||
if (searchMatchCount) {
|
||||
title = `${title}\nSearch Matches: ${searchMatchCount}`;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
renderTopology(topology) {
|
||||
const isActive = topology === this.props.currentTopology;
|
||||
const className = isActive
|
||||
? 'topologies-item-main topologies-item-main-active' : 'topologies-item-main';
|
||||
const searchMatches = this.props.searchNodeMatches.get(topology.get('id'));
|
||||
const searchMatchCount = searchMatches ? searchMatches.size : 0;
|
||||
const className = classnames('topologies-item-main', {
|
||||
'topologies-item-main-active': isActive,
|
||||
'topologies-item-main-matched': searchMatchCount
|
||||
});
|
||||
const topologyId = topology.get('id');
|
||||
const title = this.renderTitle(topology);
|
||||
const title = this.renderTitle(topology, searchMatchCount);
|
||||
|
||||
return (
|
||||
<div className="topologies-item" key={topologyId}>
|
||||
@@ -73,6 +86,7 @@ class Topologies extends React.Component {
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
topologies: state.get('topologies'),
|
||||
searchNodeMatches: state.get('searchNodeMatches'),
|
||||
currentTopology: state.get('currentTopology')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
const ACTION_TYPES = [
|
||||
'ADD_QUERY_FILTER',
|
||||
'BLUR_SEARCH',
|
||||
'CHANGE_TOPOLOGY_OPTION',
|
||||
'CLEAR_CONTROL_ERROR',
|
||||
'CLICK_BACKGROUND',
|
||||
@@ -19,13 +21,17 @@ const ACTION_TYPES = [
|
||||
'DO_CONTROL',
|
||||
'DO_CONTROL_ERROR',
|
||||
'DO_CONTROL_SUCCESS',
|
||||
'DO_SEARCH',
|
||||
'ENTER_EDGE',
|
||||
'ENTER_NODE',
|
||||
'FOCUS_SEARCH',
|
||||
'HIDE_HELP',
|
||||
'LEAVE_EDGE',
|
||||
'LEAVE_NODE',
|
||||
'PIN_METRIC',
|
||||
'PIN_SEARCH',
|
||||
'UNPIN_METRIC',
|
||||
'UNPIN_SEARCH',
|
||||
'OPEN_WEBSOCKET',
|
||||
'RECEIVE_CONTROL_NODE_REMOVED',
|
||||
'RECEIVE_CONTROL_PIPE',
|
||||
@@ -33,13 +39,15 @@ const ACTION_TYPES = [
|
||||
'RECEIVE_NODE_DETAILS',
|
||||
'RECEIVE_NODES',
|
||||
'RECEIVE_NODES_DELTA',
|
||||
'RECEIVE_NODES_FOR_TOPOLOGY',
|
||||
'RECEIVE_NOT_FOUND',
|
||||
'RECEIVE_TOPOLOGIES',
|
||||
'RECEIVE_API_DETAILS',
|
||||
'RECEIVE_ERROR',
|
||||
'ROUTE_TOPOLOGY',
|
||||
'SELECT_METRIC',
|
||||
'SHOW_HELP'
|
||||
'SHOW_HELP',
|
||||
'SET_EXPORTING_GRAPH'
|
||||
];
|
||||
|
||||
export default _.zipObject(ACTION_TYPES, ACTION_TYPES);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
jest.dontMock('../../utils/router-utils');
|
||||
jest.dontMock('../../utils/search-utils');
|
||||
jest.dontMock('../../utils/string-utils');
|
||||
jest.dontMock('../../utils/topology-utils');
|
||||
jest.dontMock('../../constants/action-types');
|
||||
jest.dontMock('../root');
|
||||
@@ -27,7 +29,12 @@ describe('RootReducer', () => {
|
||||
adjacency: ['n1', 'n2'],
|
||||
pseudo: undefined,
|
||||
label: undefined,
|
||||
label_minor: undefined
|
||||
label_minor: undefined,
|
||||
filtered: false,
|
||||
metrics: undefined,
|
||||
node_count: undefined,
|
||||
shape: undefined,
|
||||
stack: undefined
|
||||
},
|
||||
n2: {
|
||||
id: 'n2',
|
||||
@@ -35,7 +42,12 @@ describe('RootReducer', () => {
|
||||
adjacency: undefined,
|
||||
pseudo: undefined,
|
||||
label: undefined,
|
||||
label_minor: undefined
|
||||
label_minor: undefined,
|
||||
filtered: false,
|
||||
metrics: undefined,
|
||||
node_count: undefined,
|
||||
shape: undefined,
|
||||
stack: undefined
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { fromJS, is as isDeepEqual, List as makeList, Map as makeMap,
|
||||
|
||||
import ActionTypes from '../constants/action-types';
|
||||
import { EDGE_ID_SEPARATOR } from '../constants/naming';
|
||||
import { applyPinnedSearches, updateNodeMatches } from '../utils/search-utils';
|
||||
import { findTopologyById, getAdjacentNodes, setTopologyUrlsById,
|
||||
updateTopologyIds, filterHiddenTopologies } from '../utils/topology-utils';
|
||||
|
||||
@@ -33,7 +34,8 @@ const topologySorter = topology => topology.get('rank');
|
||||
// Initial values
|
||||
|
||||
export const initialState = makeMap({
|
||||
topologyOptions: makeOrderedMap(), // topologyId -> options
|
||||
availableCanvasMetrics: makeList(),
|
||||
controlPipes: makeOrderedMap(), // pipeId -> controlPipe
|
||||
controlStatus: makeMap(),
|
||||
currentTopology: null,
|
||||
currentTopologyId: 'containers',
|
||||
@@ -42,29 +44,34 @@ export const initialState = makeMap({
|
||||
highlightedEdgeIds: makeSet(),
|
||||
highlightedNodeIds: makeSet(),
|
||||
hostname: '...',
|
||||
version: '...',
|
||||
versionUpdate: null,
|
||||
plugins: makeList(),
|
||||
mouseOverEdgeId: null,
|
||||
mouseOverNodeId: null,
|
||||
nodeDetails: makeOrderedMap(), // nodeId -> details
|
||||
nodes: makeOrderedMap(), // nodeId -> node
|
||||
selectedNodeId: null,
|
||||
topologies: makeList(),
|
||||
topologiesLoaded: false,
|
||||
topologyUrlsById: makeOrderedMap(), // topologyId -> topologyUrl
|
||||
routeSet: false,
|
||||
controlPipes: makeOrderedMap(), // pipeId -> controlPipe
|
||||
updatePausedAt: null, // Date
|
||||
websocketClosed: true,
|
||||
showingHelp: false,
|
||||
|
||||
selectedMetric: null,
|
||||
// nodes cache, infrequently updated, used for search
|
||||
nodesByTopology: makeMap(), // topologyId -> nodes
|
||||
pinnedMetric: null,
|
||||
// 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.
|
||||
pinnedMetricType: null,
|
||||
availableCanvasMetrics: makeList()
|
||||
plugins: makeList(),
|
||||
pinnedSearches: makeList(), // list of node filters
|
||||
routeSet: false,
|
||||
searchFocused: false,
|
||||
searchNodeMatches: makeMap(),
|
||||
searchQuery: null,
|
||||
selectedMetric: null,
|
||||
selectedNodeId: null,
|
||||
showingHelp: false,
|
||||
topologies: makeList(),
|
||||
topologiesLoaded: false,
|
||||
topologyOptions: makeOrderedMap(), // topologyId -> options
|
||||
topologyUrlsById: makeOrderedMap(), // topologyId -> topologyUrl
|
||||
updatePausedAt: null, // Date
|
||||
version: '...',
|
||||
versionUpdate: null,
|
||||
websocketClosed: true,
|
||||
exportingGraph: false
|
||||
});
|
||||
|
||||
// adds ID field to topology (based on last part of URL path) and save urls in
|
||||
@@ -142,6 +149,10 @@ export function rootReducer(state = initialState, action) {
|
||||
}
|
||||
|
||||
switch (action.type) {
|
||||
case ActionTypes.BLUR_SEARCH: {
|
||||
return state.set('searchFocused', false);
|
||||
}
|
||||
|
||||
case ActionTypes.CHANGE_TOPOLOGY_OPTION: {
|
||||
state = resumeUpdate(state);
|
||||
// set option on parent topology
|
||||
@@ -159,6 +170,10 @@ export function rootReducer(state = initialState, action) {
|
||||
return state;
|
||||
}
|
||||
|
||||
case ActionTypes.SET_EXPORTING_GRAPH: {
|
||||
return state.set('exportingGraph', action.exporting);
|
||||
}
|
||||
|
||||
case ActionTypes.CLEAR_CONTROL_ERROR: {
|
||||
return state.removeIn(['controlStatus', action.nodeId, 'error']);
|
||||
}
|
||||
@@ -305,6 +320,11 @@ export function rootReducer(state = initialState, action) {
|
||||
}));
|
||||
}
|
||||
|
||||
case ActionTypes.DO_SEARCH: {
|
||||
state = state.set('searchQuery', action.searchQuery);
|
||||
return updateNodeMatches(state);
|
||||
}
|
||||
|
||||
case ActionTypes.ENTER_EDGE: {
|
||||
// highlight adjacent nodes
|
||||
state = state.update('highlightedNodeIds', highlightedNodeIds => {
|
||||
@@ -325,6 +345,8 @@ export function rootReducer(state = initialState, action) {
|
||||
const nodeId = action.nodeId;
|
||||
const adjacentNodes = getAdjacentNodes(state, nodeId);
|
||||
|
||||
state = state.set('mouseOverNodeId', nodeId);
|
||||
|
||||
// highlight adjacent nodes
|
||||
state = state.update('highlightedNodeIds', highlightedNodeIds => {
|
||||
highlightedNodeIds = highlightedNodeIds.clear();
|
||||
@@ -355,6 +377,7 @@ export function rootReducer(state = initialState, action) {
|
||||
}
|
||||
|
||||
case ActionTypes.LEAVE_NODE: {
|
||||
state = state.set('mouseOverNodeId', null);
|
||||
state = state.update('highlightedEdgeIds', highlightedEdgeIds => highlightedEdgeIds.clear());
|
||||
state = state.update('highlightedNodeIds', highlightedNodeIds => highlightedNodeIds.clear());
|
||||
return state;
|
||||
@@ -380,6 +403,18 @@ export function rootReducer(state = initialState, action) {
|
||||
}));
|
||||
}
|
||||
|
||||
case ActionTypes.FOCUS_SEARCH: {
|
||||
return state.set('searchFocused', true);
|
||||
}
|
||||
|
||||
case ActionTypes.PIN_SEARCH: {
|
||||
state = state.set('searchQuery', '');
|
||||
state = updateNodeMatches(state);
|
||||
const pinnedSearches = state.get('pinnedSearches');
|
||||
state = state.setIn(['pinnedSearches', pinnedSearches.size], action.query);
|
||||
return applyPinnedSearches(state);
|
||||
}
|
||||
|
||||
case ActionTypes.RECEIVE_CONTROL_NODE_REMOVED: {
|
||||
return closeNodeDetails(state, action.nodeId);
|
||||
}
|
||||
@@ -458,6 +493,9 @@ export function rootReducer(state = initialState, action) {
|
||||
state = state.setIn(['nodes', node.id], fromJS(makeNode(node)));
|
||||
});
|
||||
|
||||
// apply pinned searches, filters nodes that dont match
|
||||
state = applyPinnedSearches(state);
|
||||
|
||||
state = state.set('availableCanvasMetrics', state.get('nodes')
|
||||
.valueSeq()
|
||||
.flatMap(n => (n.get('metrics') || makeList()).map(m => (
|
||||
@@ -478,6 +516,17 @@ export function rootReducer(state = initialState, action) {
|
||||
state = state.set('selectedMetric', state.get('pinnedMetric'));
|
||||
}
|
||||
|
||||
// update nodes cache and search results
|
||||
state = state.setIn(['nodesByTopology', state.get('currentTopologyId')], state.get('nodes'));
|
||||
state = updateNodeMatches(state);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
case ActionTypes.RECEIVE_NODES_FOR_TOPOLOGY: {
|
||||
// not sure if mergeDeep() brings any benefit here
|
||||
state = state.setIn(['nodesByTopology', action.topologyId], fromJS(action.nodes));
|
||||
state = updateNodeMatches(state);
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -519,6 +568,8 @@ export function rootReducer(state = initialState, action) {
|
||||
|
||||
case ActionTypes.ROUTE_TOPOLOGY: {
|
||||
state = state.set('routeSet', true);
|
||||
state = state.set('pinnedSearches', makeList(action.state.pinnedSearches));
|
||||
state = state.set('searchQuery', action.state.searchQuery || '');
|
||||
if (state.get('currentTopologyId') !== action.state.topologyId) {
|
||||
state = state.update('nodes', nodes => nodes.clear());
|
||||
}
|
||||
@@ -551,6 +602,12 @@ export function rootReducer(state = initialState, action) {
|
||||
return state;
|
||||
}
|
||||
|
||||
case ActionTypes.UNPIN_SEARCH: {
|
||||
const pinnedSearches = state.get('pinnedSearches').filter(query => query !== action.query);
|
||||
state = state.set('pinnedSearches', pinnedSearches);
|
||||
return applyPinnedSearches(state);
|
||||
}
|
||||
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
|
||||
300
client/app/scripts/utils/__tests__/search-utils-test.js
Normal file
300
client/app/scripts/utils/__tests__/search-utils-test.js
Normal file
@@ -0,0 +1,300 @@
|
||||
jest.dontMock('../search-utils');
|
||||
jest.dontMock('../string-utils');
|
||||
jest.dontMock('../../constants/naming'); // edge naming: 'source-target'
|
||||
|
||||
import { fromJS } from 'immutable';
|
||||
|
||||
const SearchUtils = require('../search-utils').testable;
|
||||
|
||||
describe('SearchUtils', () => {
|
||||
const nodeSets = {
|
||||
someNodes: fromJS({
|
||||
n1: {
|
||||
id: 'n1',
|
||||
label: 'node label 1',
|
||||
metadata: [{
|
||||
id: 'fieldId1',
|
||||
label: 'Label 1',
|
||||
value: 'value 1'
|
||||
}],
|
||||
metrics: [{
|
||||
id: 'metric1',
|
||||
label: 'Metric 1',
|
||||
value: 1
|
||||
}]
|
||||
},
|
||||
n2: {
|
||||
id: 'n2',
|
||||
label: 'node label 2',
|
||||
metadata: [{
|
||||
id: 'fieldId2',
|
||||
label: 'Label 2',
|
||||
value: 'value 2'
|
||||
}],
|
||||
tables: [{
|
||||
id: 'metric1',
|
||||
rows: [{
|
||||
id: 'row1',
|
||||
label: 'Row 1',
|
||||
value: 'Row Value 1'
|
||||
}]
|
||||
}],
|
||||
},
|
||||
})
|
||||
};
|
||||
|
||||
describe('applyPinnedSearches', () => {
|
||||
const fun = SearchUtils.applyPinnedSearches;
|
||||
|
||||
it('should not filter anything when no pinned searches present', () => {
|
||||
let nextState = fromJS({
|
||||
nodes: nodeSets.someNodes,
|
||||
pinnedSearches: []
|
||||
});
|
||||
nextState = fun(nextState);
|
||||
expect(nextState.get('nodes').filter(node => node.get('filtered')).size).toEqual(0);
|
||||
});
|
||||
|
||||
it('should filter nodes if nothing matches a pinned search', () => {
|
||||
let nextState = fromJS({
|
||||
nodes: nodeSets.someNodes,
|
||||
pinnedSearches: ['cantmatch']
|
||||
});
|
||||
nextState = fun(nextState);
|
||||
expect(nextState.get('nodes').filterNot(node => node.get('filtered')).size).toEqual(0);
|
||||
});
|
||||
|
||||
it('should filter nodes if nothing matches a combination of pinned searches', () => {
|
||||
let nextState = fromJS({
|
||||
nodes: nodeSets.someNodes,
|
||||
pinnedSearches: ['node label 1', 'node label 2']
|
||||
});
|
||||
nextState = fun(nextState);
|
||||
expect(nextState.get('nodes').filterNot(node => node.get('filtered')).size).toEqual(0);
|
||||
});
|
||||
|
||||
it('should filter nodes that do not match a pinned searches', () => {
|
||||
let nextState = fromJS({
|
||||
nodes: nodeSets.someNodes,
|
||||
pinnedSearches: ['row']
|
||||
});
|
||||
nextState = fun(nextState);
|
||||
expect(nextState.get('nodes').filter(node => node.get('filtered')).size).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNodeMatch', () => {
|
||||
const fun = SearchUtils.findNodeMatch;
|
||||
|
||||
it('does not add a non-matching field', () => {
|
||||
let matches = fromJS({});
|
||||
matches = fun(matches, ['node1', 'field1'],
|
||||
'some value', 'some query', null, 'some label');
|
||||
expect(matches.size).toBe(0);
|
||||
});
|
||||
|
||||
it('adds a matching field', () => {
|
||||
let matches = fromJS({});
|
||||
matches = fun(matches, ['node1', 'field1'],
|
||||
'samevalue', 'samevalue', null, 'some label');
|
||||
expect(matches.size).toBe(1);
|
||||
expect(matches.getIn(['node1', 'field1'])).toBeDefined();
|
||||
const {text, label, start, length} = matches.getIn(['node1', 'field1']);
|
||||
expect(text).toBe('samevalue');
|
||||
expect(label).toBe('some label');
|
||||
expect(start).toBe(0);
|
||||
expect(length).toBe(9);
|
||||
});
|
||||
|
||||
it('does not add a field when the prefix does not match the label', () => {
|
||||
let matches = fromJS({});
|
||||
matches = fun(matches, ['node1', 'field1'],
|
||||
'samevalue', 'samevalue', 'some prefix', 'some label');
|
||||
expect(matches.size).toBe(0);
|
||||
});
|
||||
|
||||
it('adds a field when the prefix matches the label', () => {
|
||||
let matches = fromJS({});
|
||||
matches = fun(matches, ['node1', 'field1'],
|
||||
'samevalue', 'samevalue', 'prefix', 'prefixed label');
|
||||
expect(matches.size).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findNodeMatchMetric', () => {
|
||||
const fun = SearchUtils.findNodeMatchMetric;
|
||||
|
||||
it('does not add a non-matching field', () => {
|
||||
let matches = fromJS({});
|
||||
matches = fun(matches, ['node1', 'field1'],
|
||||
1, 'metric1', 'metric2', 'lt', 2);
|
||||
expect(matches.size).toBe(0);
|
||||
});
|
||||
|
||||
it('adds a matching field', () => {
|
||||
let matches = fromJS({});
|
||||
matches = fun(matches, ['node1', 'field1'],
|
||||
1, 'metric1', 'metric1', 'lt', 2);
|
||||
expect(matches.size).toBe(1);
|
||||
expect(matches.getIn(['node1', 'field1'])).toBeDefined();
|
||||
const { metric } = matches.getIn(['node1', 'field1']);
|
||||
expect(metric).toBeTruthy();
|
||||
|
||||
matches = fun(matches, ['node2', 'field1'],
|
||||
1, 'metric1', 'metric1', 'gt', 0);
|
||||
expect(matches.size).toBe(2);
|
||||
|
||||
matches = fun(matches, ['node3', 'field1'],
|
||||
1, 'metric1', 'metric1', 'eq', 1);
|
||||
expect(matches.size).toBe(3);
|
||||
|
||||
matches = fun(matches, ['node3', 'field1'],
|
||||
1, 'metric1', 'metric1', 'other', 1);
|
||||
expect(matches.size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeRegExp', () => {
|
||||
const fun = SearchUtils.makeRegExp;
|
||||
|
||||
it('should make a regexp from any string', () => {
|
||||
expect(fun().source).toEqual((new RegExp).source);
|
||||
expect(fun('que').source).toEqual((new RegExp('que')).source);
|
||||
// invalid string
|
||||
expect(fun('que[').source).toEqual((new RegExp('que\\[')).source);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchPrefix', () => {
|
||||
const fun = SearchUtils.matchPrefix;
|
||||
|
||||
it('returns true if the prefix matches the label', () => {
|
||||
expect(fun('label', 'prefix')).toBeFalsy();
|
||||
expect(fun('memory', 'mem')).toBeTruthy();
|
||||
expect(fun('mem', 'memory')).toBeFalsy();
|
||||
expect(fun('com.domain.label', 'label')).toBeTruthy();
|
||||
expect(fun('com.domain.Label', 'domainlabel')).toBeTruthy();
|
||||
expect(fun('com-Domain-label', 'domainlabel')).toBeTruthy();
|
||||
expect(fun('memory', 'mem.ry')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseQuery', () => {
|
||||
const fun = SearchUtils.parseQuery;
|
||||
|
||||
it('should parse a metric value from a string', () => {
|
||||
expect(fun('')).toEqual(null);
|
||||
expect(fun('text')).toEqual({query: 'text'});
|
||||
expect(fun('prefix:text')).toEqual({prefix: 'prefix', query: 'text'});
|
||||
expect(fun(':text')).toEqual(null);
|
||||
expect(fun('text:')).toEqual(null);
|
||||
expect(fun('cpu > 1')).toEqual({metric: 'cpu', value: 1, comp: 'gt'});
|
||||
expect(fun('cpu >')).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseValue', () => {
|
||||
const fun = SearchUtils.parseValue;
|
||||
|
||||
it('should parse a metric value from a string', () => {
|
||||
expect(fun('1')).toEqual(1);
|
||||
expect(fun('1.34%')).toEqual(1.34);
|
||||
expect(fun('10kB')).toEqual(1024 * 10);
|
||||
expect(fun('1K')).toEqual(1024);
|
||||
expect(fun('2KB')).toEqual(2048);
|
||||
expect(fun('1MB')).toEqual(Math.pow(1024, 2));
|
||||
expect(fun('1m')).toEqual(Math.pow(1024, 2));
|
||||
expect(fun('1GB')).toEqual(Math.pow(1024, 3));
|
||||
expect(fun('1TB')).toEqual(Math.pow(1024, 4));
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchTopology', () => {
|
||||
const fun = SearchUtils.searchTopology;
|
||||
|
||||
it('should return no matches on an empty topology', () => {
|
||||
const nodes = fromJS({});
|
||||
const matches = fun(nodes, {query: 'value'});
|
||||
expect(matches.size).toEqual(0);
|
||||
});
|
||||
|
||||
it('should match on a node label', () => {
|
||||
const nodes = nodeSets.someNodes;
|
||||
let matches = fun(nodes, {query: 'node label 1'});
|
||||
expect(matches.size).toEqual(1);
|
||||
matches = fun(nodes, {query: 'node label'});
|
||||
expect(matches.size).toEqual(2);
|
||||
});
|
||||
|
||||
it('should match on a metadata field', () => {
|
||||
const nodes = nodeSets.someNodes;
|
||||
const matches = fun(nodes, {query: 'value'});
|
||||
expect(matches.size).toEqual(2);
|
||||
expect(matches.getIn(['n1', 'metadata', 'fieldId1']).text).toEqual('value 1');
|
||||
});
|
||||
|
||||
it('should match on a metric field', () => {
|
||||
const nodes = nodeSets.someNodes;
|
||||
const matches = fun(nodes, {metric: 'metric1', value: 1, comp: 'eq'});
|
||||
expect(matches.size).toEqual(1);
|
||||
expect(matches.getIn(['n1', 'metrics', 'metric1']).metric).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should match on a tables field', () => {
|
||||
const nodes = nodeSets.someNodes;
|
||||
const matches = fun(nodes, {query: 'Row Value 1'});
|
||||
expect(matches.size).toEqual(1);
|
||||
expect(matches.getIn(['n2', 'tables', 'row1']).text).toBe('Row Value 1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateNodeMatches', () => {
|
||||
const fun = SearchUtils.updateNodeMatches;
|
||||
|
||||
it('should return no matches on an empty topology', () => {
|
||||
let nextState = fromJS({
|
||||
nodesByTopology: {},
|
||||
searchNodeMatches: {},
|
||||
searchQuery: ''
|
||||
});
|
||||
nextState = fun(nextState);
|
||||
expect(nextState.get('searchNodeMatches').size).toEqual(0);
|
||||
});
|
||||
|
||||
it('should return no matches when no query is present', () => {
|
||||
let nextState = fromJS({
|
||||
nodesByTopology: {topo1: nodeSets.someNodes},
|
||||
searchNodeMatches: {},
|
||||
searchQuery: ''
|
||||
});
|
||||
nextState = fun(nextState);
|
||||
expect(nextState.get('searchNodeMatches').size).toEqual(0);
|
||||
});
|
||||
|
||||
it('should return no matches when query matches nothing', () => {
|
||||
let nextState = fromJS({
|
||||
nodesByTopology: {topo1: nodeSets.someNodes},
|
||||
searchNodeMatches: {},
|
||||
searchQuery: 'cantmatch'
|
||||
});
|
||||
nextState = fun(nextState);
|
||||
expect(nextState.get('searchNodeMatches').size).toEqual(0);
|
||||
});
|
||||
|
||||
it('should return a matches when a query matches something', () => {
|
||||
let nextState = fromJS({
|
||||
nodesByTopology: {topo1: nodeSets.someNodes},
|
||||
searchNodeMatches: {},
|
||||
searchQuery: 'value 2'
|
||||
});
|
||||
nextState = fun(nextState);
|
||||
expect(nextState.get('searchNodeMatches').size).toEqual(1);
|
||||
expect(nextState.get('searchNodeMatches').get('topo1').size).toEqual(1);
|
||||
|
||||
// then clear up again
|
||||
nextState = nextState.set('searchQuery', '');
|
||||
nextState = fun(nextState);
|
||||
expect(nextState.get('searchNodeMatches').size).toEqual(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,13 +8,18 @@ import { route } from '../actions/app-actions';
|
||||
//
|
||||
const SLASH = '/';
|
||||
const SLASH_REPLACEMENT = '<SLASH>';
|
||||
const PERCENT = '%';
|
||||
const PERCENT_REPLACEMENT = '<PERCENT>';
|
||||
|
||||
function encodeURL(url) {
|
||||
return url.replace(new RegExp(SLASH, 'g'), SLASH_REPLACEMENT);
|
||||
return url
|
||||
.replace(new RegExp(PERCENT, 'g'), PERCENT_REPLACEMENT)
|
||||
.replace(new RegExp(SLASH, 'g'), SLASH_REPLACEMENT);
|
||||
}
|
||||
|
||||
function decodeURL(url) {
|
||||
return decodeURIComponent(url.replace(new RegExp(SLASH_REPLACEMENT, 'g'), SLASH));
|
||||
return decodeURIComponent(url.replace(new RegExp(SLASH_REPLACEMENT, 'g'), SLASH))
|
||||
.replace(new RegExp(PERCENT_REPLACEMENT, 'g'), PERCENT);
|
||||
}
|
||||
|
||||
function shouldReplaceState(prevState, nextState) {
|
||||
@@ -35,8 +40,10 @@ export function getUrlState(state) {
|
||||
return {
|
||||
controlPipe: cp ? cp.toJS() : null,
|
||||
nodeDetails: nodeDetails.toJS(),
|
||||
selectedNodeId: state.get('selectedNodeId'),
|
||||
pinnedMetricType: state.get('pinnedMetricType'),
|
||||
pinnedSearches: state.get('pinnedSearches').toJS(),
|
||||
searchQuery: state.get('searchQuery'),
|
||||
selectedNodeId: state.get('selectedNodeId'),
|
||||
topologyId: state.get('currentTopologyId'),
|
||||
topologyOptions: state.get('topologyOptions').toJS() // all options
|
||||
};
|
||||
@@ -69,7 +76,7 @@ export function getRouter(dispatch, initialState) {
|
||||
});
|
||||
|
||||
page('/state/:state', (ctx) => {
|
||||
const state = JSON.parse(ctx.params.state);
|
||||
const state = JSON.parse(decodeURL(ctx.params.state));
|
||||
dispatch(route(state));
|
||||
});
|
||||
|
||||
|
||||
278
client/app/scripts/utils/search-utils.js
Normal file
278
client/app/scripts/utils/search-utils.js
Normal file
@@ -0,0 +1,278 @@
|
||||
import { Map as makeMap } from 'immutable';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { slugify } from './string-utils';
|
||||
|
||||
// topolevel search fields
|
||||
const SEARCH_FIELDS = makeMap({
|
||||
label: 'label',
|
||||
sublabel: 'label_minor'
|
||||
});
|
||||
|
||||
const COMPARISONS = makeMap({
|
||||
'<': 'lt',
|
||||
'>': 'gt',
|
||||
'=': 'eq'
|
||||
});
|
||||
const COMPARISONS_REGEX = new RegExp(`[${COMPARISONS.keySeq().toJS().join('')}]`);
|
||||
|
||||
const PREFIX_DELIMITER = ':';
|
||||
|
||||
/**
|
||||
* Returns a RegExp from a given string. If the string is not a valid regexp,
|
||||
* it is escaped. Returned regexp is case-insensitive.
|
||||
*/
|
||||
function makeRegExp(expression, options = 'i') {
|
||||
try {
|
||||
return new RegExp(expression, options);
|
||||
} catch (e) {
|
||||
return new RegExp(_.escapeRegExp(expression), options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the float of a metric value string, e.g. 2 KB -> 2048
|
||||
*/
|
||||
function parseValue(value) {
|
||||
let parsed = parseFloat(value);
|
||||
if ((/k/i).test(value)) {
|
||||
parsed *= 1024;
|
||||
} else if ((/m/i).test(value)) {
|
||||
parsed *= 1024 * 1024;
|
||||
} else if ((/g/i).test(value)) {
|
||||
parsed *= 1024 * 1024 * 1024;
|
||||
} else if ((/t/i).test(value)) {
|
||||
parsed *= 1024 * 1024 * 1024 * 1024;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* True if a prefix matches a field label
|
||||
* Slugifies the label (removes all non-alphanumerical chars).
|
||||
*/
|
||||
function matchPrefix(label, prefix) {
|
||||
if (label && prefix) {
|
||||
return (makeRegExp(prefix)).test(slugify(label));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a match to nodeMatches under the keyPath. The text is matched against
|
||||
* the query. If a prefix is given, it is matched against the label (skip on
|
||||
* no match).
|
||||
* Returns a new instance of nodeMatches.
|
||||
*/
|
||||
function findNodeMatch(nodeMatches, keyPath, text, query, prefix, label) {
|
||||
if (!prefix || matchPrefix(label, prefix)) {
|
||||
const queryRe = makeRegExp(query);
|
||||
const matches = text.match(queryRe);
|
||||
if (matches) {
|
||||
const firstMatch = matches[0];
|
||||
const index = text.search(queryRe);
|
||||
nodeMatches = nodeMatches.setIn(keyPath,
|
||||
{text, label, start: index, length: firstMatch.length});
|
||||
}
|
||||
}
|
||||
return nodeMatches;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the metric matches the field's label and the value compares positively
|
||||
* with the comp operator, a nodeMatch is added
|
||||
*/
|
||||
function findNodeMatchMetric(nodeMatches, keyPath, fieldValue, fieldLabel, metric, comp, value) {
|
||||
if (slugify(metric) === slugify(fieldLabel)) {
|
||||
let matched = false;
|
||||
switch (comp) {
|
||||
case 'gt': {
|
||||
if (fieldValue > value) {
|
||||
matched = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'lt': {
|
||||
if (fieldValue < value) {
|
||||
matched = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'eq': {
|
||||
if (fieldValue === value) {
|
||||
matched = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) {
|
||||
nodeMatches = nodeMatches.setIn(keyPath,
|
||||
{fieldLabel, metric: true});
|
||||
}
|
||||
}
|
||||
return nodeMatches;
|
||||
}
|
||||
|
||||
|
||||
export function searchTopology(nodes, { prefix, query, metric, comp, value }) {
|
||||
let nodeMatches = makeMap();
|
||||
nodes.forEach((node, nodeId) => {
|
||||
if (query) {
|
||||
// top level fields
|
||||
SEARCH_FIELDS.forEach((field, label) => {
|
||||
const keyPath = [nodeId, label];
|
||||
if (node.has(field)) {
|
||||
nodeMatches = findNodeMatch(nodeMatches, keyPath, node.get(field),
|
||||
query, prefix, label);
|
||||
}
|
||||
});
|
||||
|
||||
// metadata
|
||||
if (node.get('metadata')) {
|
||||
node.get('metadata').forEach(field => {
|
||||
const keyPath = [nodeId, 'metadata', field.get('id')];
|
||||
nodeMatches = findNodeMatch(nodeMatches, keyPath, field.get('value'),
|
||||
query, prefix, field.get('label'));
|
||||
});
|
||||
}
|
||||
|
||||
// tables (envvars and labels)
|
||||
const tables = node.get('tables');
|
||||
if (tables) {
|
||||
tables.forEach((table) => {
|
||||
if (table.get('rows')) {
|
||||
table.get('rows').forEach(field => {
|
||||
const keyPath = [nodeId, 'tables', field.get('id')];
|
||||
nodeMatches = findNodeMatch(nodeMatches, keyPath, field.get('value'),
|
||||
query, prefix, field.get('label'));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (metric) {
|
||||
const metrics = node.get('metrics');
|
||||
if (metrics) {
|
||||
metrics.forEach(field => {
|
||||
const keyPath = [nodeId, 'metrics', field.get('id')];
|
||||
nodeMatches = findNodeMatchMetric(nodeMatches, keyPath, field.get('value'),
|
||||
field.get('label'), metric, comp, value);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return nodeMatches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an object with fields depending on the query:
|
||||
* parseQuery('text') -> {query: 'text'}
|
||||
* parseQuery('p:text') -> {query: 'text', prefix: 'p'}
|
||||
* parseQuery('cpu > 1') -> {metric: 'cpu', value: '1', comp: 'gt'}
|
||||
*/
|
||||
export function parseQuery(query) {
|
||||
if (query) {
|
||||
const prefixQuery = query.split(PREFIX_DELIMITER);
|
||||
const isPrefixQuery = prefixQuery && prefixQuery.length === 2;
|
||||
|
||||
if (isPrefixQuery) {
|
||||
const prefix = prefixQuery[0].trim();
|
||||
query = prefixQuery[1].trim();
|
||||
if (prefix && query) {
|
||||
return {
|
||||
query,
|
||||
prefix
|
||||
};
|
||||
}
|
||||
} else if (COMPARISONS_REGEX.test(query)) {
|
||||
// check for comparisons
|
||||
let comparison;
|
||||
COMPARISONS.forEach((comp, delim) => {
|
||||
const comparisonQuery = query.split(delim);
|
||||
if (comparisonQuery && comparisonQuery.length === 2) {
|
||||
const value = parseValue(comparisonQuery[1]);
|
||||
const metric = comparisonQuery[0].trim();
|
||||
if (!isNaN(value) && metric) {
|
||||
comparison = {
|
||||
metric,
|
||||
value,
|
||||
comp
|
||||
};
|
||||
return false; // dont look further
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (comparison) {
|
||||
return comparison;
|
||||
}
|
||||
} else {
|
||||
return { query };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {topologyId: {nodeId: matches}}
|
||||
*/
|
||||
export function updateNodeMatches(state) {
|
||||
const parsed = parseQuery(state.get('searchQuery'));
|
||||
if (parsed) {
|
||||
if (state.has('nodesByTopology')) {
|
||||
state.get('nodesByTopology').forEach((nodes, topologyId) => {
|
||||
const nodeMatches = searchTopology(nodes, parsed);
|
||||
if (nodeMatches.size > 0) {
|
||||
state = state.setIn(['searchNodeMatches', topologyId], nodeMatches);
|
||||
} else {
|
||||
state = state.deleteIn(['searchNodeMatches', topologyId]);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (state.has('searchNodeMatches')) {
|
||||
state = state.update('searchNodeMatches', snm => snm.clear());
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set `filtered:true` in state's nodes if a pinned search matches
|
||||
*/
|
||||
export function applyPinnedSearches(state) {
|
||||
// clear old filter state
|
||||
state = state.update('nodes',
|
||||
nodes => nodes.map(node => node.set('filtered', false)));
|
||||
|
||||
const pinnedSearches = state.get('pinnedSearches');
|
||||
if (pinnedSearches.size > 0) {
|
||||
state.get('pinnedSearches').forEach(query => {
|
||||
const parsed = parseQuery(query);
|
||||
if (parsed) {
|
||||
const nodeMatches = searchTopology(state.get('nodes'), parsed);
|
||||
const filteredNodes = state.get('nodes')
|
||||
.map(node => node.set('filtered',
|
||||
node.get('filtered') // matched by previous pinned search
|
||||
|| nodeMatches.size === 0 // no match, filter all nodes
|
||||
|| !nodeMatches.has(node.get('id')))); // filter matches
|
||||
state = state.set('nodes', filteredNodes);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export const testable = {
|
||||
applyPinnedSearches,
|
||||
findNodeMatch,
|
||||
findNodeMatchMetric,
|
||||
matchPrefix,
|
||||
makeRegExp,
|
||||
parseQuery,
|
||||
parseValue,
|
||||
searchTopology,
|
||||
updateNodeMatches
|
||||
};
|
||||
@@ -64,3 +64,8 @@ function makeFormatMetric(renderFn) {
|
||||
export const formatMetric = makeFormatMetric(renderHtml);
|
||||
export const formatMetricSvg = makeFormatMetric(renderSvg);
|
||||
export const formatDate = d3.time.format.iso;
|
||||
|
||||
const CLEAN_LABEL_REGEX = /\W/g;
|
||||
export function slugify(label) {
|
||||
return label.replace(CLEAN_LABEL_REGEX, '').toLowerCase();
|
||||
}
|
||||
|
||||
@@ -137,3 +137,7 @@ export function isSameTopology(nodes, nextNodes) {
|
||||
const nextTopology = nextNodes.map(mapper);
|
||||
return isDeepEqual(topology, nextTopology);
|
||||
}
|
||||
|
||||
export function isNodeMatchingQuery(node, query) {
|
||||
return node.get('label').includes(query) || node.get('subLabel').includes(query);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ import reqwest from 'reqwest';
|
||||
import { clearControlError, closeWebsocket, openWebsocket, receiveError,
|
||||
receiveApiDetails, receiveNodesDelta, receiveNodeDetails, receiveControlError,
|
||||
receiveControlNodeRemoved, receiveControlPipe, receiveControlPipeStatus,
|
||||
receiveControlSuccess, receiveTopologies, receiveNotFound } from '../actions/app-actions';
|
||||
receiveControlSuccess, receiveTopologies, receiveNotFound,
|
||||
receiveNodesForTopology } from '../actions/app-actions';
|
||||
|
||||
import { API_INTERVAL, TOPOLOGY_INTERVAL } from '../constants/timer';
|
||||
|
||||
@@ -95,6 +96,23 @@ function createWebsocket(topologyUrl, optionsQuery, dispatch) {
|
||||
|
||||
/* keep URLs relative */
|
||||
|
||||
/**
|
||||
* Gets nodes for all topologies (for search)
|
||||
*/
|
||||
export function getAllNodes(getState, dispatch) {
|
||||
const state = getState();
|
||||
const topologyOptions = state.get('topologyOptions');
|
||||
// fetch sequentially
|
||||
state.get('topologyUrlsById')
|
||||
.reduce((sequence, topologyUrl, topologyId) => sequence.then(() => {
|
||||
const optionsQuery = buildOptionsQuery(topologyOptions.get(topologyId));
|
||||
return fetch(`${topologyUrl}?${optionsQuery}`);
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(json => dispatch(receiveNodesForTopology(json.nodes, topologyId))),
|
||||
Promise.resolve());
|
||||
}
|
||||
|
||||
export function getTopologies(options, dispatch) {
|
||||
clearTimeout(topologyTimer);
|
||||
const optionsQuery = buildOptionsQuery(options);
|
||||
|
||||
@@ -28,3 +28,6 @@
|
||||
@btn-opacity-selected: 1;
|
||||
|
||||
@link-opacity-default: 1;
|
||||
|
||||
@search-border-color: @background-darker-color;
|
||||
@search-border-width: 2px;
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
|
||||
@link-opacity-default: 0.8;
|
||||
|
||||
@search-border-color: transparent;
|
||||
@search-border-width: 1px;
|
||||
|
||||
/* add this class to truncate text with ellipsis, container needs width */
|
||||
.truncate {
|
||||
white-space: nowrap;
|
||||
@@ -170,7 +173,7 @@ h2 {
|
||||
display: flex;
|
||||
|
||||
.logo {
|
||||
margin: -8px 0 0 64px;
|
||||
margin: -10px 0 0 64px;
|
||||
height: 64px;
|
||||
width: 250px;
|
||||
}
|
||||
@@ -236,7 +239,7 @@ h2 {
|
||||
}
|
||||
|
||||
.topologies {
|
||||
margin: 4px 0 0 128px;
|
||||
margin: 8px 4px;
|
||||
display: flex;
|
||||
|
||||
.topologies-item {
|
||||
@@ -268,6 +271,7 @@ h2 {
|
||||
border-radius: @border-radius;
|
||||
opacity: 0.8;
|
||||
margin-bottom: 3px;
|
||||
border: 1px solid transparent;
|
||||
|
||||
&-active, &:hover {
|
||||
color: @text-color;
|
||||
@@ -277,6 +281,11 @@ h2 {
|
||||
&-active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&-matched {
|
||||
border-color: @weave-blue;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.topologies-sub-item {
|
||||
@@ -326,29 +335,41 @@ h2 {
|
||||
text {
|
||||
font-family: @base-font;
|
||||
fill: @text-secondary-color;
|
||||
|
||||
&.node-label {
|
||||
fill: @text-color;
|
||||
}
|
||||
|
||||
&.node-sublabel {
|
||||
fill: @text-secondary-color;
|
||||
}
|
||||
}
|
||||
|
||||
.nodes-chart-nodes > .node {
|
||||
cursor: pointer;
|
||||
transition: opacity .5s @base-ease;
|
||||
text-align: center;
|
||||
|
||||
.hover-box {
|
||||
fill-opacity: 0;
|
||||
.node-label,
|
||||
.node-sublabel {
|
||||
line-height: 125%;
|
||||
}
|
||||
|
||||
&.hovered .node-label, &.hovered .node-sublabel {
|
||||
stroke: @background-average-color;
|
||||
stroke-width: 8px;
|
||||
stroke-opacity: 0.7;
|
||||
paint-order: stroke;
|
||||
.node-label {
|
||||
color: @text-color;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.node-label-wrapper {
|
||||
cursor: pointer;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.node-sublabel {
|
||||
color: @text-secondary-color;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
&.hovered {
|
||||
.node-label, .node-sublabel {
|
||||
span:not(.match) {
|
||||
background-color: fade(@background-average-color, 70%);
|
||||
}
|
||||
}
|
||||
.matched-results {
|
||||
background-color: fade(@background-average-color, 70%);
|
||||
}
|
||||
}
|
||||
|
||||
&.pseudo {
|
||||
@@ -375,6 +396,19 @@ h2 {
|
||||
&.blurred {
|
||||
opacity: @node-opacity-blurred;
|
||||
}
|
||||
|
||||
&.matched .shape {
|
||||
animation: throb 0.5s @base-ease;
|
||||
}
|
||||
|
||||
.node-label, .node-sublabel {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.match {
|
||||
background-color: lighten(rgba(0, 210, 255, 0.5), 30%);
|
||||
border: 1px solid @weave-blue;
|
||||
}
|
||||
}
|
||||
|
||||
.edge {
|
||||
@@ -423,6 +457,9 @@ h2 {
|
||||
}
|
||||
|
||||
.shape {
|
||||
transform: scale(1);
|
||||
cursor: pointer;
|
||||
|
||||
/* cloud paths have stroke-width set dynamically */
|
||||
&:not(.shape-cloud) .border {
|
||||
stroke-width: @node-border-stroke-width;
|
||||
@@ -474,6 +511,33 @@ h2 {
|
||||
|
||||
}
|
||||
|
||||
.matched-results {
|
||||
text-align: center;
|
||||
|
||||
&-match {
|
||||
font-size: 0.7rem;
|
||||
|
||||
&-wrapper {
|
||||
display: inline-block;
|
||||
margin: 1px;
|
||||
padding: 2px 4px;
|
||||
background-color: fade(@weave-blue, 10%);
|
||||
}
|
||||
|
||||
&-label {
|
||||
color: @text-secondary-color;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
}
|
||||
|
||||
&-more {
|
||||
text-transform: uppercase;
|
||||
font-size: 0.6rem;
|
||||
color: darken(@weave-blue, 10%);
|
||||
margin-top: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
.details {
|
||||
&-wrapper {
|
||||
position: fixed;
|
||||
@@ -526,6 +590,11 @@ h2 {
|
||||
}
|
||||
}
|
||||
|
||||
.match {
|
||||
background-color: fade(@weave-blue, 30%);
|
||||
border: 1px solid @weave-blue;
|
||||
}
|
||||
|
||||
&-header {
|
||||
.colorable;
|
||||
|
||||
@@ -1141,6 +1210,141 @@ h2 {
|
||||
font-size: .7rem;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 10em;
|
||||
transition: width 0.3s 0s @base-ease;
|
||||
|
||||
&-wrapper {
|
||||
flex: 0 1 25%;
|
||||
margin: 8px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: disabled;
|
||||
}
|
||||
|
||||
&-hint {
|
||||
font-size: 0.7rem;
|
||||
position: absolute;
|
||||
padding: 0 1em;
|
||||
color: @text-tertiary-color;
|
||||
top: 0;
|
||||
opacity: 0;
|
||||
transition: transform 0.3s 0s @base-ease, opacity 0.3s 0s @base-ease;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&-input {
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
border-radius: @border-radius;
|
||||
width: 100%;
|
||||
border: @search-border-width solid @search-border-color;
|
||||
padding: 2px 4px;
|
||||
text-align: left;
|
||||
flex-wrap: wrap;
|
||||
|
||||
&-field {
|
||||
font-size: 0.8rem;
|
||||
line-height: 150%;
|
||||
position: relative;
|
||||
padding: 1px 4px 1px 0.75em;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: @text-color;
|
||||
flex: 1;
|
||||
width: 60px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
&-icon {
|
||||
position: relative;
|
||||
width: 1.285em;
|
||||
text-align: center;
|
||||
color: @text-secondary-color;
|
||||
position: relative;
|
||||
top: 2px;
|
||||
left: 4px;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
&-label {
|
||||
user-select: none;
|
||||
display: inline-block;
|
||||
padding: 2px 0.75em;
|
||||
font-size: 0.8rem;
|
||||
position: absolute;
|
||||
text-align: left;
|
||||
pointer-events: none;
|
||||
color: @text-secondary-color;
|
||||
text-transform: uppercase;
|
||||
transition: opacity 0.3s 0.5s @base-ease;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&-focused &-input-label,
|
||||
&-pinned &-input-label,
|
||||
&-filled &-input-label {
|
||||
transition: opacity 0.1s 0s @base-ease;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&-focused &-hint,
|
||||
&-filled &-hint,
|
||||
&-pinned &-hint {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 2.75em, 0);
|
||||
transition: transform 0.3s 0.3s @base-ease, opacity 0.3s 0.3s @base-ease;
|
||||
}
|
||||
|
||||
&-focused,
|
||||
&-filled,
|
||||
&-pinned {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&-matched &-input {
|
||||
border-color: @weave-blue;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.search-item {
|
||||
background-color: fade(@weave-blue, 20%);
|
||||
border-radius: @border-radius / 2;
|
||||
margin: 1px 0 1px 8px;
|
||||
display: inline-block;
|
||||
|
||||
& + .search-item {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
&-label {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
&-icon {
|
||||
.btn-opacity;
|
||||
padding: 2px 4px 2px 2px;
|
||||
cursor: pointer;
|
||||
font-size: 80%;
|
||||
position: relative;
|
||||
top: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes focusing {
|
||||
0% {
|
||||
opacity: 0;
|
||||
@@ -1159,6 +1363,14 @@ h2 {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes throb {
|
||||
0%, 50%, 100% {
|
||||
transform: scale(1);
|
||||
} 25%, 75% {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Help panel!
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user