Skip to content

Commit 8fd8848

Browse files
[7.x] [Lens] Add specific IP and Range/Interval sorting to datatable (#87006) (#87975)
Co-authored-by: Kibana Machine <[email protected]> Co-authored-by: Kibana Machine <[email protected]>
1 parent c8bdc7a commit 8fd8848

File tree

5 files changed

+301
-11
lines changed

5 files changed

+301
-11
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@
229229
"intl-messageformat": "^2.2.0",
230230
"intl-relativeformat": "^2.1.0",
231231
"io-ts": "^2.0.5",
232+
"ipaddr.js": "2.0.0",
232233
"isbinaryfile": "4.0.2",
233234
"joi": "^13.5.2",
234235
"jquery": "^3.5.0",

x-pack/plugins/lens/public/datatable_visualization/expression.tsx

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
EuiBasicTableColumn,
2323
EuiTableActionsColumnType,
2424
} from '@elastic/eui';
25-
import { orderBy } from 'lodash';
25+
2626
import { IAggType } from 'src/plugins/data/public';
2727
import { Datatable, DatatableColumnMeta, RenderMode } from 'src/plugins/expressions';
2828
import {
@@ -41,6 +41,7 @@ import { VisualizationContainer } from '../visualization_container';
4141
import { EmptyPlaceholder } from '../shared_components';
4242
import { desanitizeFilterContext } from '../utils';
4343
import { LensIconChartDatatable } from '../assets/chart_datatable';
44+
import { getSortingCriteria } from './sorting';
4445

4546
export const LENS_EDIT_SORT_ACTION = 'sort';
4647

@@ -92,6 +93,10 @@ export interface DatatableRender {
9293
value: DatatableProps;
9394
}
9495

96+
function isRange(meta: { params?: { id?: string } } | undefined) {
97+
return meta?.params?.id === 'range';
98+
}
99+
95100
export const getDatatable = ({
96101
formatFactory,
97102
}: {
@@ -139,17 +144,18 @@ export const getDatatable = ({
139144

140145
if (sortBy && sortDirection !== 'none') {
141146
// Sort on raw values for these types, while use the formatted value for the rest
142-
const sortingCriteria = ['number', 'date'].includes(
143-
columnsReverseLookup[sortBy]?.meta?.type || ''
144-
)
145-
? sortBy
146-
: (row: Record<string, unknown>) => formatters[sortBy]?.convert(row[sortBy]);
147-
// replace the table here
148-
context.inspectorAdapters.tables[layerId].rows = orderBy(
149-
firstTable.rows || [],
150-
[sortingCriteria],
151-
sortDirection as Direction
147+
const sortingCriteria = getSortingCriteria(
148+
isRange(columnsReverseLookup[sortBy]?.meta)
149+
? 'range'
150+
: columnsReverseLookup[sortBy]?.meta?.type,
151+
sortBy,
152+
formatters[sortBy],
153+
sortDirection
152154
);
155+
// replace the table here
156+
context.inspectorAdapters.tables[layerId].rows = (firstTable.rows || [])
157+
.slice()
158+
.sort(sortingCriteria);
153159
// replace also the local copy
154160
firstTable.rows = context.inspectorAdapters.tables[layerId].rows;
155161
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import { getSortingCriteria } from './sorting';
8+
import { FieldFormat } from 'src/plugins/data/public';
9+
import { DatatableColumnType } from 'src/plugins/expressions';
10+
11+
function getMockFormatter() {
12+
return { convert: (v: unknown) => `${v as string}` } as FieldFormat;
13+
}
14+
15+
function testSorting({
16+
input,
17+
output,
18+
direction,
19+
type,
20+
keepLast,
21+
}: {
22+
input: unknown[];
23+
output: unknown[];
24+
direction: 'asc' | 'desc';
25+
type: DatatableColumnType | 'range';
26+
keepLast?: boolean; // special flag to handle values that should always be last no matter the direction
27+
}) {
28+
const datatable = input.map((v) => ({
29+
a: v,
30+
}));
31+
const sorted = output.map((v) => ({ a: v }));
32+
if (direction === 'desc') {
33+
sorted.reverse();
34+
if (keepLast) {
35+
// Cycle shift of the first element
36+
const firstEl = sorted.shift()!;
37+
sorted.push(firstEl);
38+
}
39+
}
40+
const criteria = getSortingCriteria(type, 'a', getMockFormatter(), direction);
41+
expect(datatable.sort(criteria)).toEqual(sorted);
42+
}
43+
44+
describe('Data sorting criteria', () => {
45+
describe('Numeric values', () => {
46+
for (const direction of ['asc', 'desc'] as const) {
47+
it(`should provide the number criteria of numeric values (${direction})`, () => {
48+
testSorting({
49+
input: [7, 6, 5, -Infinity, Infinity],
50+
output: [-Infinity, 5, 6, 7, Infinity],
51+
direction,
52+
type: 'number',
53+
});
54+
});
55+
56+
it(`should provide the number criteria for date values (${direction})`, () => {
57+
const now = Date.now();
58+
testSorting({
59+
input: [now, 0, now - 150000],
60+
output: [0, now - 150000, now],
61+
direction,
62+
type: 'date',
63+
});
64+
});
65+
}
66+
});
67+
68+
describe('String or anything else as string', () => {
69+
for (const direction of ['asc', 'desc'] as const) {
70+
it(`should provide the string criteria for terms values (${direction})`, () => {
71+
testSorting({
72+
input: ['a', 'b', 'c', 'd', '12'],
73+
output: ['12', 'a', 'b', 'c', 'd'],
74+
direction,
75+
type: 'string',
76+
});
77+
});
78+
79+
it(`should provide the string criteria for other types of values (${direction})`, () => {
80+
testSorting({
81+
input: [true, false, false],
82+
output: [false, false, true],
83+
direction,
84+
type: 'boolean',
85+
});
86+
});
87+
}
88+
});
89+
90+
describe('IP sorting', () => {
91+
for (const direction of ['asc', 'desc'] as const) {
92+
it(`should provide the IP criteria for IP values (IPv4 only values) - ${direction}`, () => {
93+
testSorting({
94+
input: ['127.0.0.1', '192.168.1.50', '200.100.100.10', '10.0.1.76', '8.8.8.8'],
95+
output: ['8.8.8.8', '10.0.1.76', '127.0.0.1', '192.168.1.50', '200.100.100.10'],
96+
direction,
97+
type: 'ip',
98+
});
99+
});
100+
101+
it(`should provide the IP criteria for IP values (IPv6 only values) - ${direction}`, () => {
102+
testSorting({
103+
input: [
104+
'fc00::123',
105+
'::1',
106+
'2001:0db8:85a3:0000:0000:8a2e:0370:7334',
107+
'2001:db8:1234:0000:0000:0000:0000:0000',
108+
'2001:db8:1234::', // equivalent to the above
109+
],
110+
output: [
111+
'::1',
112+
'2001:db8:1234::',
113+
'2001:db8:1234:0000:0000:0000:0000:0000',
114+
'2001:0db8:85a3:0000:0000:8a2e:0370:7334',
115+
'fc00::123',
116+
],
117+
direction,
118+
type: 'ip',
119+
});
120+
});
121+
122+
it(`should provide the IP criteria for IP values (mixed values) - ${direction}`, () => {
123+
// A mix of IPv4, IPv6, IPv4 mapped to IPv6
124+
testSorting({
125+
input: [
126+
'fc00::123',
127+
'192.168.1.50',
128+
'::FFFF:192.168.1.50', // equivalent to the above with the IPv6 mapping
129+
'10.0.1.76',
130+
'8.8.8.8',
131+
'::1',
132+
],
133+
output: [
134+
'::1',
135+
'8.8.8.8',
136+
'10.0.1.76',
137+
'192.168.1.50',
138+
'::FFFF:192.168.1.50',
139+
'fc00::123',
140+
],
141+
direction,
142+
type: 'ip',
143+
});
144+
});
145+
146+
it(`should provide the IP criteria for IP values (mixed values with invalid "Other" field) - ${direction}`, () => {
147+
testSorting({
148+
input: ['fc00::123', '192.168.1.50', 'Other', '10.0.1.76', '8.8.8.8', '::1'],
149+
output: ['::1', '8.8.8.8', '10.0.1.76', '192.168.1.50', 'fc00::123', 'Other'],
150+
direction,
151+
type: 'ip',
152+
keepLast: true,
153+
});
154+
});
155+
}
156+
});
157+
158+
describe('Range sorting', () => {
159+
for (const direction of ['asc', 'desc'] as const) {
160+
it(`should sort closed ranges - ${direction}`, () => {
161+
testSorting({
162+
input: [
163+
{ gte: 1, lt: 5 },
164+
{ gte: 0, lt: 5 },
165+
{ gte: 0, lt: 1 },
166+
],
167+
output: [
168+
{ gte: 0, lt: 1 },
169+
{ gte: 0, lt: 5 },
170+
{ gte: 1, lt: 5 },
171+
],
172+
direction,
173+
type: 'range',
174+
});
175+
});
176+
177+
it(`should sort open ranges - ${direction}`, () => {
178+
testSorting({
179+
input: [{ gte: 1, lt: 5 }, { gte: 0, lt: 5 }, { gte: 0 }],
180+
output: [{ gte: 0, lt: 5 }, { gte: 0 }, { gte: 1, lt: 5 }],
181+
direction,
182+
type: 'range',
183+
});
184+
});
185+
}
186+
});
187+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import ipaddr from 'ipaddr.js';
8+
import type { IPv4, IPv6 } from 'ipaddr.js';
9+
import { FieldFormat } from 'src/plugins/data/public';
10+
11+
function isIPv6Address(ip: IPv4 | IPv6): ip is IPv6 {
12+
return ip.kind() === 'ipv6';
13+
}
14+
15+
function getSafeIpAddress(ip: string, directionFactor: number) {
16+
if (!ipaddr.isValid(ip)) {
17+
// for non valid IPs have the same behaviour as for now (we assume it's only the "Other" string)
18+
// create a mock object which has all a special value to keep them always at the bottom of the list
19+
return { parts: Array(8).fill(directionFactor * Infinity) };
20+
}
21+
const parsedIp = ipaddr.parse(ip);
22+
return isIPv6Address(parsedIp) ? parsedIp : parsedIp.toIPv4MappedAddress();
23+
}
24+
25+
function getIPCriteria(sortBy: string, directionFactor: number) {
26+
// Create a set of 8 function to sort based on the 8 IPv6 slots of an address
27+
// For IPv4 bring them to the IPv6 "mapped" format and then sort
28+
return (rowA: Record<string, unknown>, rowB: Record<string, unknown>) => {
29+
const ipAString = rowA[sortBy] as string;
30+
const ipBString = rowB[sortBy] as string;
31+
const ipA = getSafeIpAddress(ipAString, directionFactor);
32+
const ipB = getSafeIpAddress(ipBString, directionFactor);
33+
34+
// Now compare each part of the IPv6 address and exit when a value != 0 is found
35+
let i = 0;
36+
let diff = ipA.parts[i] - ipB.parts[i];
37+
while (!diff && i < 7) {
38+
i++;
39+
diff = ipA.parts[i] - ipB.parts[i];
40+
}
41+
42+
// in case of same address but written in different styles, sort by string length
43+
if (diff === 0) {
44+
return directionFactor * (ipAString.length - ipBString.length);
45+
}
46+
return directionFactor * diff;
47+
};
48+
}
49+
50+
function getRangeCriteria(sortBy: string, directionFactor: number) {
51+
// fill missing fields with these open bounds to perform number sorting
52+
const openRange = { gte: -Infinity, lt: Infinity };
53+
return (rowA: Record<string, unknown>, rowB: Record<string, unknown>) => {
54+
const rangeA = { ...openRange, ...(rowA[sortBy] as Omit<Range, 'type'>) };
55+
const rangeB = { ...openRange, ...(rowB[sortBy] as Omit<Range, 'type'>) };
56+
57+
const fromComparison = rangeA.gte - rangeB.gte;
58+
const toComparison = rangeA.lt - rangeB.lt;
59+
60+
return directionFactor * (fromComparison || toComparison);
61+
};
62+
}
63+
64+
export function getSortingCriteria(
65+
type: string | undefined,
66+
sortBy: string,
67+
formatter: FieldFormat,
68+
direction: string
69+
) {
70+
// handle the direction with a multiply factor.
71+
const directionFactor = direction === 'asc' ? 1 : -1;
72+
73+
if (['number', 'date'].includes(type || '')) {
74+
return (rowA: Record<string, unknown>, rowB: Record<string, unknown>) =>
75+
directionFactor * ((rowA[sortBy] as number) - (rowB[sortBy] as number));
76+
}
77+
// this is a custom type, and can safely assume the gte and lt fields are all numbers or undefined
78+
if (type === 'range') {
79+
return getRangeCriteria(sortBy, directionFactor);
80+
}
81+
// IP have a special sorting
82+
if (type === 'ip') {
83+
return getIPCriteria(sortBy, directionFactor);
84+
}
85+
// use a string sorter for the rest
86+
return (rowA: Record<string, unknown>, rowB: Record<string, unknown>) => {
87+
const aString = formatter.convert(rowA[sortBy]);
88+
const bString = formatter.convert(rowB[sortBy]);
89+
return directionFactor * aString.localeCompare(bString);
90+
};
91+
}

yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16565,6 +16565,11 @@ [email protected], ipaddr.js@^1.9.0:
1656516565
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.0.tgz#37df74e430a0e47550fe54a2defe30d8acd95f65"
1656616566
integrity sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA==
1656716567

16568+
16569+
version "2.0.0"
16570+
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.0.0.tgz#77ccccc8063ae71ab65c55f21b090698e763fc6e"
16571+
integrity sha512-S54H9mIj0rbxRIyrDMEuuER86LdlgUg9FSeZ8duQb6CUG2iRrA36MYVQBSprTF/ZeAwvyQ5mDGuNvIPM0BIl3w==
16572+
1656816573
1656916574
version "5.0.6"
1657016575
resolved "https://registry.yarnpkg.com/iron/-/iron-5.0.6.tgz#7121d4a6e3ac2f65e4d02971646fea1995434744"

0 commit comments

Comments
 (0)