Files
weave-scope/client/app/scripts/utils/search-utils.js
2019-01-09 13:59:15 +00:00

338 lines
9.1 KiB
JavaScript

import { Map as makeMap, Set as makeSet, List as makeList } from 'immutable';
import { escapeRegExp } from 'lodash';
import { isGenericTable, isPropertyList, genericTableEntryKey } from './node-details-utils';
import { slugify } from './string-utils';
// topolevel search fields
const SEARCH_FIELDS = makeMap({
label: 'label',
labelMinor: 'labelMinor'
});
const COMPARISONS = makeMap({
'<': 'lt',
'=': 'eq',
'>': 'gt'
});
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, truncate) {
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,
{
label, length: firstMatch.length, start: index, text, truncate
}
);
}
}
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 searchNode(node, {
prefix, query, metric, comp, value
}) {
let nodeMatches = makeMap();
if (query) {
// top level fields
SEARCH_FIELDS.forEach((field, label) => {
const keyPath = [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 = ['metadata', field.get('id')];
nodeMatches = findNodeMatch(
nodeMatches, keyPath, field.get('value'),
query, prefix, field.get('label'), field.get('truncate')
);
});
}
// parents and relatives
if (node.get('parents')) {
node.get('parents').forEach((parent) => {
const keyPath = ['parents', parent.get('id')];
nodeMatches = findNodeMatch(
nodeMatches, keyPath, parent.get('label'),
query, prefix, parent.get('topologyId')
);
});
}
// property lists
(node.get('tables') || []).filter(isPropertyList).forEach((propertyList) => {
(propertyList.get('rows') || []).forEach((row) => {
const entries = row.get('entries');
const keyPath = ['property-lists', row.get('id')];
nodeMatches = findNodeMatch(
nodeMatches, keyPath, entries.get('value'),
query, prefix, entries.get('label')
);
});
});
// generic tables
(node.get('tables') || []).filter(isGenericTable).forEach((table) => {
(table.get('rows') || []).forEach((row) => {
table.get('columns').forEach((column) => {
const val = row.get('entries').get(column.get('id'));
const keyPath = ['tables', genericTableEntryKey(row, column)];
nodeMatches = findNodeMatch(nodeMatches, keyPath, val, query);
});
});
});
} else if (metric) {
const metrics = node.get('metrics');
if (metrics) {
metrics.forEach((field) => {
const keyPath = ['metrics', field.get('id')];
nodeMatches = findNodeMatchMetric(
nodeMatches, keyPath, field.get('value'),
field.get('label'), metric, comp, value
);
});
}
}
return nodeMatches;
}
export function searchTopology(nodes, parsedQuery) {
let nodesMatches = makeMap();
nodes.forEach((node, nodeId) => {
const nodeMatches = searchNode(node, parsedQuery);
if (!nodeMatches.isEmpty()) {
nodesMatches = nodesMatches.set(nodeId, nodeMatches);
}
});
return nodesMatches;
}
/**
* 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 {
prefix,
query
};
}
} 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 (!window.isNaN(value) && metric) {
comparison = {
comp,
metric,
value
};
return false; // dont look further
}
}
return true;
});
if (comparison) {
return comparison;
}
} else {
return { query };
}
}
return null;
}
export function getSearchableFields(nodes) {
const get = (node, key) => node.get(key) || makeList();
const baseLabels = makeSet(nodes.size > 0 ? SEARCH_FIELDS.valueSeq() : []);
const metadataLabels = nodes.reduce((labels, node) => (
labels.union(get(node, 'metadata').map(f => f.get('label')))
), makeSet());
const parentLabels = nodes.reduce((labels, node) => (
labels.union(get(node, 'parents').map(p => p.get('topologyId')))
), makeSet());
// Consider only property lists (and not generic tables).
const tableRowLabels = nodes.reduce((labels, node) => (
labels.union(get(node, 'tables').filter(isPropertyList).flatMap(t => (t.get('rows') || makeList)
.map(f => f.getIn(['entries', 'label']))))
), makeSet());
const metricLabels = nodes.reduce((labels, node) => (
labels.union(get(node, 'metrics').map(f => f.get('label')))
), makeSet());
return makeMap({
fields: baseLabels.union(metadataLabels, parentLabels, tableRowLabels)
.map(slugify)
.toList()
.sort(),
metrics: metricLabels.toList().map(slugify).sort()
});
}
/**
* 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,
makeRegExp,
matchPrefix,
parseQuery,
parseValue,
searchTopology,
};