search-utils.js 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. import { Map as makeMap, Set as makeSet, List as makeList } from 'immutable';
  2. import { escapeRegExp } from 'lodash';
  3. import { isGenericTable, isPropertyList, genericTableEntryKey } from './node-details-utils';
  4. import { slugify } from './string-utils';
  5. // topolevel search fields
  6. const SEARCH_FIELDS = makeMap({
  7. label: 'label',
  8. labelMinor: 'labelMinor'
  9. });
  10. const COMPARISONS = makeMap({
  11. '<': 'lt',
  12. '=': 'eq',
  13. '>': 'gt'
  14. });
  15. const COMPARISONS_REGEX = new RegExp(`[${COMPARISONS.keySeq().toJS().join('')}]`);
  16. const PREFIX_DELIMITER = ':';
  17. /**
  18. * Returns a RegExp from a given string. If the string is not a valid regexp,
  19. * it is escaped. Returned regexp is case-insensitive.
  20. */
  21. function makeRegExp(expression, options = 'i') {
  22. try {
  23. return new RegExp(expression, options);
  24. } catch (e) {
  25. return new RegExp(escapeRegExp(expression), options);
  26. }
  27. }
  28. /**
  29. * Returns the float of a metric value string, e.g. 2 KB -> 2048
  30. */
  31. function parseValue(value) {
  32. let parsed = parseFloat(value);
  33. if ((/k/i).test(value)) {
  34. parsed *= 1024;
  35. } else if ((/m/i).test(value)) {
  36. parsed *= 1024 * 1024;
  37. } else if ((/g/i).test(value)) {
  38. parsed *= 1024 * 1024 * 1024;
  39. } else if ((/t/i).test(value)) {
  40. parsed *= 1024 * 1024 * 1024 * 1024;
  41. }
  42. return parsed;
  43. }
  44. /**
  45. * True if a prefix matches a field label
  46. * Slugifies the label (removes all non-alphanumerical chars).
  47. */
  48. function matchPrefix(label, prefix) {
  49. if (label && prefix) {
  50. return (makeRegExp(prefix)).test(slugify(label));
  51. }
  52. return false;
  53. }
  54. /**
  55. * Adds a match to nodeMatches under the keyPath. The text is matched against
  56. * the query. If a prefix is given, it is matched against the label (skip on
  57. * no match).
  58. * Returns a new instance of nodeMatches.
  59. */
  60. function findNodeMatch(nodeMatches, keyPath, text, query, prefix, label, truncate) {
  61. if (!prefix || matchPrefix(label, prefix)) {
  62. const queryRe = makeRegExp(query);
  63. const matches = text.match(queryRe);
  64. if (matches) {
  65. const firstMatch = matches[0];
  66. const index = text.search(queryRe);
  67. nodeMatches = nodeMatches.setIn(
  68. keyPath,
  69. {
  70. label, length: firstMatch.length, start: index, text, truncate
  71. }
  72. );
  73. }
  74. }
  75. return nodeMatches;
  76. }
  77. /**
  78. * If the metric matches the field's label and the value compares positively
  79. * with the comp operator, a nodeMatch is added
  80. */
  81. function findNodeMatchMetric(nodeMatches, keyPath, fieldValue, fieldLabel, metric, comp, value) {
  82. if (slugify(metric) === slugify(fieldLabel)) {
  83. let matched = false;
  84. switch (comp) {
  85. case 'gt': {
  86. if (fieldValue > value) {
  87. matched = true;
  88. }
  89. break;
  90. }
  91. case 'lt': {
  92. if (fieldValue < value) {
  93. matched = true;
  94. }
  95. break;
  96. }
  97. case 'eq': {
  98. if (fieldValue === value) {
  99. matched = true;
  100. }
  101. break;
  102. }
  103. default: {
  104. break;
  105. }
  106. }
  107. if (matched) {
  108. nodeMatches = nodeMatches.setIn(
  109. keyPath,
  110. {fieldLabel, metric: true}
  111. );
  112. }
  113. }
  114. return nodeMatches;
  115. }
  116. export function searchNode(node, {
  117. prefix, query, metric, comp, value
  118. }) {
  119. let nodeMatches = makeMap();
  120. if (query) {
  121. // top level fields
  122. SEARCH_FIELDS.forEach((field, label) => {
  123. const keyPath = [label];
  124. if (node.has(field)) {
  125. nodeMatches = findNodeMatch(
  126. nodeMatches, keyPath, node.get(field),
  127. query, prefix, label
  128. );
  129. }
  130. });
  131. // metadata
  132. if (node.get('metadata')) {
  133. node.get('metadata').forEach((field) => {
  134. const keyPath = ['metadata', field.get('id')];
  135. nodeMatches = findNodeMatch(
  136. nodeMatches, keyPath, field.get('value'),
  137. query, prefix, field.get('label'), field.get('truncate')
  138. );
  139. });
  140. }
  141. // parents and relatives
  142. if (node.get('parents')) {
  143. node.get('parents').forEach((parent) => {
  144. const keyPath = ['parents', parent.get('id')];
  145. nodeMatches = findNodeMatch(
  146. nodeMatches, keyPath, parent.get('label'),
  147. query, prefix, parent.get('topologyId')
  148. );
  149. });
  150. }
  151. // property lists
  152. (node.get('tables') || []).filter(isPropertyList).forEach((propertyList) => {
  153. (propertyList.get('rows') || []).forEach((row) => {
  154. const entries = row.get('entries');
  155. const keyPath = ['property-lists', row.get('id')];
  156. nodeMatches = findNodeMatch(
  157. nodeMatches, keyPath, entries.get('value'),
  158. query, prefix, entries.get('label')
  159. );
  160. });
  161. });
  162. // generic tables
  163. (node.get('tables') || []).filter(isGenericTable).forEach((table) => {
  164. (table.get('rows') || []).forEach((row) => {
  165. table.get('columns').forEach((column) => {
  166. const val = row.get('entries').get(column.get('id'));
  167. const keyPath = ['tables', genericTableEntryKey(row, column)];
  168. nodeMatches = findNodeMatch(nodeMatches, keyPath, val, query);
  169. });
  170. });
  171. });
  172. } else if (metric) {
  173. const metrics = node.get('metrics');
  174. if (metrics) {
  175. metrics.forEach((field) => {
  176. const keyPath = ['metrics', field.get('id')];
  177. nodeMatches = findNodeMatchMetric(
  178. nodeMatches, keyPath, field.get('value'),
  179. field.get('label'), metric, comp, value
  180. );
  181. });
  182. }
  183. }
  184. return nodeMatches;
  185. }
  186. export function searchTopology(nodes, parsedQuery) {
  187. let nodesMatches = makeMap();
  188. nodes.forEach((node, nodeId) => {
  189. const nodeMatches = searchNode(node, parsedQuery);
  190. if (!nodeMatches.isEmpty()) {
  191. nodesMatches = nodesMatches.set(nodeId, nodeMatches);
  192. }
  193. });
  194. return nodesMatches;
  195. }
  196. /**
  197. * Returns an object with fields depending on the query:
  198. * parseQuery('text') -> {query: 'text'}
  199. * parseQuery('p:text') -> {query: 'text', prefix: 'p'}
  200. * parseQuery('cpu > 1') -> {metric: 'cpu', value: '1', comp: 'gt'}
  201. */
  202. export function parseQuery(query) {
  203. if (query) {
  204. const prefixQuery = query.split(PREFIX_DELIMITER);
  205. const isPrefixQuery = prefixQuery && prefixQuery.length === 2;
  206. if (isPrefixQuery) {
  207. const prefix = prefixQuery[0].trim();
  208. query = prefixQuery[1].trim();
  209. if (prefix && query) {
  210. return {
  211. prefix,
  212. query
  213. };
  214. }
  215. } else if (COMPARISONS_REGEX.test(query)) {
  216. // check for comparisons
  217. let comparison;
  218. COMPARISONS.forEach((comp, delim) => {
  219. const comparisonQuery = query.split(delim);
  220. if (comparisonQuery && comparisonQuery.length === 2) {
  221. const value = parseValue(comparisonQuery[1]);
  222. const metric = comparisonQuery[0].trim();
  223. if (!window.isNaN(value) && metric) {
  224. comparison = {
  225. comp,
  226. metric,
  227. value
  228. };
  229. return false; // dont look further
  230. }
  231. }
  232. return true;
  233. });
  234. if (comparison) {
  235. return comparison;
  236. }
  237. } else {
  238. return { query };
  239. }
  240. }
  241. return null;
  242. }
  243. export function getSearchableFields(nodes) {
  244. const get = (node, key) => node.get(key) || makeList();
  245. const baseLabels = makeSet(nodes.size > 0 ? SEARCH_FIELDS.valueSeq() : []);
  246. const metadataLabels = nodes.reduce((labels, node) => (
  247. labels.union(get(node, 'metadata').map(f => f.get('label')))
  248. ), makeSet());
  249. const parentLabels = nodes.reduce((labels, node) => (
  250. labels.union(get(node, 'parents').map(p => p.get('topologyId')))
  251. ), makeSet());
  252. // Consider only property lists (and not generic tables).
  253. const tableRowLabels = nodes.reduce((labels, node) => (
  254. labels.union(get(node, 'tables').filter(isPropertyList).flatMap(t => (t.get('rows') || makeList)
  255. .map(f => f.getIn(['entries', 'label']))))
  256. ), makeSet());
  257. const metricLabels = nodes.reduce((labels, node) => (
  258. labels.union(get(node, 'metrics').map(f => f.get('label')))
  259. ), makeSet());
  260. return makeMap({
  261. fields: baseLabels.union(metadataLabels, parentLabels, tableRowLabels)
  262. .map(slugify)
  263. .toList()
  264. .sort(),
  265. metrics: metricLabels.toList().map(slugify).sort()
  266. });
  267. }
  268. /**
  269. * Set `filtered:true` in state's nodes if a pinned search matches
  270. */
  271. export function applyPinnedSearches(state) {
  272. // clear old filter state
  273. state = state.update(
  274. 'nodes',
  275. nodes => nodes.map(node => node.set('filtered', false))
  276. );
  277. const pinnedSearches = state.get('pinnedSearches');
  278. if (pinnedSearches.size > 0) {
  279. state.get('pinnedSearches').forEach((query) => {
  280. const parsed = parseQuery(query);
  281. if (parsed) {
  282. const nodeMatches = searchTopology(state.get('nodes'), parsed);
  283. const filteredNodes = state.get('nodes')
  284. .map(node => node.set(
  285. 'filtered',
  286. node.get('filtered') // matched by previous pinned search
  287. || nodeMatches.size === 0 // no match, filter all nodes
  288. || !nodeMatches.has(node.get('id'))
  289. )); // filter matches
  290. state = state.set('nodes', filteredNodes);
  291. }
  292. });
  293. }
  294. return state;
  295. }
  296. export const testable = {
  297. applyPinnedSearches,
  298. findNodeMatch,
  299. findNodeMatchMetric,
  300. makeRegExp,
  301. matchPrefix,
  302. parseQuery,
  303. parseValue,
  304. searchTopology,
  305. };