Skip to content

Commit 13706d2

Browse files
committed
fix: add fallback to numeric comparator to respect natural sorting
Signed-off-by: Gabriel Bernal <gbernal@redhat.com>
1 parent 81c3a0f commit 13706d2

6 files changed

Lines changed: 294 additions & 13 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { TestIds } from '../../../src/test-ids';
2+
3+
const LOGS_PAGE_URL = '/monitoring/logs';
4+
const QUERY_RANGE_STREAMS_URL_MATCH =
5+
'/api/proxy/plugin/logging-view-plugin/backend/api/logs/v1/application/loki/api/v1/query_range?query=%7B*';
6+
const QUERY_RANGE_MATRIX_URL_MATCH =
7+
'/api/proxy/plugin/logging-view-plugin/backend/api/logs/v1/application/loki/api/v1/query_range?query=sum*';
8+
9+
const sameTimestampStreamsResponse = () => {
10+
const msTimestamp = BigInt(Date.now());
11+
const nanosTimestamp = msTimestamp * 1000000n;
12+
const nanosString = String(nanosTimestamp);
13+
14+
return {
15+
status: 'success',
16+
data: {
17+
resultType: 'streams',
18+
result: [
19+
{
20+
stream: {
21+
filename: '/var/log/out.log',
22+
job: 'varlogs',
23+
level: 'info',
24+
observedTimestamp: String(nanosTimestamp + 200n),
25+
},
26+
values: [[nanosString, 'log-line-B']],
27+
},
28+
{
29+
stream: {
30+
filename: '/var/log/out.log',
31+
job: 'varlogs',
32+
level: 'info',
33+
observedTimestamp: String(nanosTimestamp + 300n),
34+
},
35+
values: [[nanosString, 'log-line-C']],
36+
},
37+
{
38+
stream: {
39+
filename: '/var/log/out.log',
40+
job: 'varlogs',
41+
level: 'info',
42+
observedTimestamp: String(nanosTimestamp + 100n),
43+
},
44+
values: [[nanosString, 'log-line-A']],
45+
},
46+
],
47+
stats: {
48+
summary: {
49+
bytesProcessedPerSecond: 0,
50+
linesProcessedPerSecond: 0,
51+
totalBytesProcessed: 0,
52+
totalLinesProcessed: 0,
53+
execTime: 0,
54+
queueTime: 0,
55+
subqueries: 0,
56+
},
57+
querier: {
58+
store: {
59+
totalChunksRef: 0,
60+
totalChunksDownloaded: 0,
61+
chunksDownloadTime: 0,
62+
chunk: {
63+
headChunkBytes: 0,
64+
headChunkLines: 0,
65+
decompressedBytes: 0,
66+
decompressedLines: 0,
67+
compressedBytes: 0,
68+
totalDuplicates: 0,
69+
},
70+
},
71+
},
72+
ingester: {
73+
totalReached: 0,
74+
totalChunksMatched: 0,
75+
totalBatches: 0,
76+
totalLinesSent: 0,
77+
store: {
78+
totalChunksRef: 0,
79+
totalChunksDownloaded: 0,
80+
chunksDownloadTime: 0,
81+
chunk: {
82+
headChunkBytes: 0,
83+
headChunkLines: 0,
84+
decompressedBytes: 0,
85+
decompressedLines: 0,
86+
compressedBytes: 0,
87+
totalDuplicates: 0,
88+
},
89+
},
90+
},
91+
},
92+
},
93+
};
94+
};
95+
96+
describe('Logs Table Sorting', () => {
97+
it('sorts same-timestamp logs by observedTimestamp', () => {
98+
cy.intercept(QUERY_RANGE_STREAMS_URL_MATCH, sameTimestampStreamsResponse()).as(
99+
'queryRangeStreams',
100+
);
101+
cy.intercept(QUERY_RANGE_MATRIX_URL_MATCH, { statusCode: 200, body: {} });
102+
103+
cy.visit(LOGS_PAGE_URL);
104+
105+
cy.wait('@queryRangeStreams');
106+
107+
// Default direction is 'backward' (desc) — largest observedTimestamp first
108+
// observedTimestamp order: C(+300) > B(+200) > A(+100)
109+
cy.byTestID(TestIds.LogsTable)
110+
.should('exist')
111+
.within(() => {
112+
cy.get('td[data-label="message"]').should('have.length', 3);
113+
cy.get('td[data-label="message"]').eq(0).should('contain', 'log-line-C');
114+
cy.get('td[data-label="message"]').eq(1).should('contain', 'log-line-B');
115+
cy.get('td[data-label="message"]').eq(2).should('contain', 'log-line-A');
116+
});
117+
});
118+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { numericComparator } from '../sort-utils';
2+
3+
describe('numericComparator', () => {
4+
it('should return -1 when a < b with positive multiplier', () => {
5+
expect(numericComparator(1, 2, 1)).toBe(-1);
6+
});
7+
8+
it('should return 1 when a > b with positive multiplier', () => {
9+
expect(numericComparator(2, 1, 1)).toBe(1);
10+
});
11+
12+
it('should return 0 when a === b', () => {
13+
expect(numericComparator(1, 1, 1)).toBe(0);
14+
});
15+
16+
it('should invert result with negative multiplier (descending sort)', () => {
17+
expect(numericComparator(1, 2, -1)).toBe(1);
18+
expect(numericComparator(2, 1, -1)).toBe(-1);
19+
});
20+
21+
it('should use fallback comparison when values are equal', () => {
22+
expect(numericComparator(5, 5, 1, 3)).toBe(3);
23+
expect(numericComparator(5, 5, 1, -2)).toBe(-2);
24+
});
25+
26+
it('should apply direction multiplier to fallback comparison', () => {
27+
expect(numericComparator(5, 5, -1, 3)).toBe(-3);
28+
expect(numericComparator(5, 5, -1, -2)).toBe(2);
29+
});
30+
31+
it('should ignore fallback when values are not equal', () => {
32+
expect(numericComparator(1, 2, 1, 100)).toBe(-1);
33+
expect(numericComparator(2, 1, 1, 100)).toBe(1);
34+
});
35+
36+
it('should work with timestamps', () => {
37+
const timestamp1 = 1679000000000;
38+
const timestamp2 = 1679000000001;
39+
expect(numericComparator(timestamp1, timestamp2, 1)).toBe(-1);
40+
expect(numericComparator(timestamp2, timestamp1, 1)).toBe(1);
41+
expect(numericComparator(timestamp1, timestamp1, 1)).toBe(0);
42+
});
43+
44+
it('should use logIndex as tiebreaker for equal timestamps in ascending sort', () => {
45+
const timestamp = 1679000000000;
46+
const logs = [
47+
{ timestamp, logIndex: 0 },
48+
{ timestamp, logIndex: 1 },
49+
{ timestamp, logIndex: 2 },
50+
];
51+
52+
const sortedAsc = [...logs].sort((a, b) =>
53+
numericComparator(a.timestamp, b.timestamp, 1, a.logIndex - b.logIndex),
54+
);
55+
expect(sortedAsc.map((l) => l.logIndex)).toEqual([0, 1, 2]);
56+
});
57+
58+
it('should use logIndex as tiebreaker for equal timestamps in descending sort', () => {
59+
const timestamp = 1679000000000;
60+
const logs = [
61+
{ timestamp, logIndex: 0 },
62+
{ timestamp, logIndex: 1 },
63+
{ timestamp, logIndex: 2 },
64+
];
65+
66+
const sortedDesc = [...logs].sort((a, b) =>
67+
numericComparator(a.timestamp, b.timestamp, -1, a.logIndex - b.logIndex),
68+
);
69+
// Direction multiplier is applied to fallback, so order is reversed
70+
expect(sortedDesc.map((l) => l.logIndex)).toEqual([2, 1, 0]);
71+
});
72+
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { observedTimestampDifference } from '../sort-utils';
2+
3+
describe('observedTimestampDifference', () => {
4+
it('should return the numeric difference for small bigints', () => {
5+
expect(observedTimestampDifference(10n, 3n)).toBe(7);
6+
expect(observedTimestampDifference(3n, 10n)).toBe(-7);
7+
});
8+
9+
it('should return 0 when both values are equal', () => {
10+
expect(observedTimestampDifference(42n, 42n)).toBe(0);
11+
});
12+
13+
it('should clamp to MIN_SAFE_INTEGER when difference is too negative', () => {
14+
const a = 0n;
15+
const b = BigInt(Number.MAX_SAFE_INTEGER) + 1n;
16+
expect(observedTimestampDifference(a, b)).toBe(Number.MIN_SAFE_INTEGER);
17+
});
18+
19+
it('should clamp to MAX_SAFE_INTEGER when difference is too positive', () => {
20+
const a = BigInt(Number.MAX_SAFE_INTEGER) + 1n;
21+
const b = 0n;
22+
expect(observedTimestampDifference(a, b)).toBe(Number.MAX_SAFE_INTEGER);
23+
});
24+
25+
it('should not clamp when difference is exactly MAX_SAFE_INTEGER', () => {
26+
const maxSafe = BigInt(Number.MAX_SAFE_INTEGER);
27+
expect(observedTimestampDifference(maxSafe, 0n)).toBe(Number.MAX_SAFE_INTEGER);
28+
});
29+
30+
it('should not clamp when difference is exactly MIN_SAFE_INTEGER', () => {
31+
const minSafe = BigInt(Number.MIN_SAFE_INTEGER);
32+
expect(observedTimestampDifference(minSafe, 0n)).toBe(Number.MIN_SAFE_INTEGER);
33+
});
34+
35+
it('should clamp when difference is one past MAX_SAFE_INTEGER', () => {
36+
const pastMax = BigInt(Number.MAX_SAFE_INTEGER) + 1n;
37+
expect(observedTimestampDifference(pastMax, 0n)).toBe(Number.MAX_SAFE_INTEGER);
38+
});
39+
40+
it('should clamp when difference is one past MIN_SAFE_INTEGER', () => {
41+
const pastMin = BigInt(Number.MIN_SAFE_INTEGER) - 1n;
42+
expect(observedTimestampDifference(pastMin, 0n)).toBe(Number.MIN_SAFE_INTEGER);
43+
});
44+
45+
it('should handle realistic nanosecond timestamp differences', () => {
46+
const ts1 = 1679000000000000000n;
47+
const ts2 = 1679000000000000001n;
48+
expect(observedTimestampDifference(ts2, ts1)).toBe(1);
49+
expect(observedTimestampDifference(ts1, ts2)).toBe(-1);
50+
});
51+
52+
it('should clamp when nanosecond timestamps are far apart', () => {
53+
const ts1 = 1679000000000000000n;
54+
const ts2 = 1579000000000000000n;
55+
expect(observedTimestampDifference(ts1, ts2)).toBe(Number.MAX_SAFE_INTEGER);
56+
expect(observedTimestampDifference(ts2, ts1)).toBe(Number.MIN_SAFE_INTEGER);
57+
});
58+
});

web/src/components/logs-table.tsx

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from '../logs.types';
2828
import { parseName, parseResources, ResourceLabel } from '../parse-resources';
2929
import { severityFromString } from '../severity';
30+
import { numericComparator, observedTimestampDifference } from '../sort-utils';
3031
import { TestIds } from '../test-ids';
3132
import { LogDetail } from './log-detail';
3233
import './logs-table.css';
@@ -50,8 +51,6 @@ interface LogsTableProps {
5051
schema: Schema;
5152
}
5253

53-
type TableCellValue = string | number | Resource | Array<Resource>;
54-
5554
const isJSONObject = (value: string): boolean => {
5655
const trimmedValue = value.trim();
5756

@@ -64,7 +63,9 @@ const streamToTableData = (stream: StreamLogData, timezone?: string): Array<LogT
6463
return values.map((value) => {
6564
const logValue = String(value[1]);
6665
const message = isJSONObject(logValue) ? stream.stream['message'] || logValue : logValue;
67-
const timestamp = parseFloat(String(value[0]));
66+
const rawTimestamp = String(value[0]);
67+
const timestamp = parseFloat(rawTimestamp);
68+
const observedTimestamp = BigInt(stream.stream.observedTimestamp ?? rawTimestamp);
6869
const time = timestamp / 1e6;
6970
const formattedTime = dateToFormat(time, DateFormat.Full, timezone);
7071
const severity = parseName(stream.stream, ResourceLabel.Severity);
@@ -81,6 +82,7 @@ const streamToTableData = (stream: StreamLogData, timezone?: string): Array<LogT
8182
namespace,
8283
podName,
8384
type: 'log',
85+
observedTimestamp,
8486
// index is 0 here to match the type, but it will be recalculated when flattening the array
8587
logIndex: 0,
8688
};
@@ -108,13 +110,6 @@ const getSeverityClass = (severity: string) => {
108110
return severity ? `lv-plugin__table__severity-${severity}` : '';
109111
};
110112

111-
// sort with an appropriate numeric comparator for big floats
112-
const numericComparator = <T extends TableCellValue>(
113-
a: T,
114-
b: T,
115-
directionMultiplier: number,
116-
): number => (a < b ? -1 : a > b ? 1 : 0) * directionMultiplier;
117-
118113
const columns: Array<TableColumn<LogTableData>> = [
119114
{
120115
id: 'expand',
@@ -131,7 +126,12 @@ const columns: Array<TableColumn<LogTableData>> = [
131126
},
132127
sort: (data, sortDirection) =>
133128
data.sort((a, b) =>
134-
numericComparator(a.timestamp, b.timestamp, sortDirection === 'asc' ? 1 : -1),
129+
numericComparator(
130+
a.timestamp,
131+
b.timestamp,
132+
sortDirection === 'asc' ? 1 : -1,
133+
observedTimestampDifference(a.observedTimestamp, b.observedTimestamp),
134+
),
135135
),
136136
},
137137
{
@@ -338,8 +338,15 @@ export const LogsTable: FC<PropsWithChildren<LogsTableProps>> = ({
338338
}
339339
}
340340

341-
return dataCopy.sort((a, b) => numericComparator(a.timestamp, b.timestamp, -1));
342-
}, [tableData, sortBy]);
341+
return dataCopy.sort((a, b) =>
342+
numericComparator(
343+
a.timestamp,
344+
b.timestamp,
345+
direction === 'backward' ? -1 : 1,
346+
observedTimestampDifference(a.observedTimestamp, b.observedTimestamp),
347+
),
348+
);
349+
}, [tableData, sortBy, direction]);
343350

344351
const dataIsEmpty = sortedData.length === 0;
345352

web/src/logs.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,4 +119,5 @@ export type LogTableData = {
119119
data: Record<string, string>;
120120
type: 'log' | 'expand';
121121
logIndex: number;
122+
observedTimestamp: bigint;
122123
};

web/src/sort-utils.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
type SortableValue = string | number;
2+
3+
// sort with an appropriate numeric comparator for big floats
4+
export const numericComparator = <T extends SortableValue>(
5+
a: T,
6+
b: T,
7+
directionMultiplier: number,
8+
fallbackComparison?: number,
9+
): number => {
10+
const result = a < b ? -1 : a > b ? 1 : 0;
11+
if (result === 0 && fallbackComparison !== undefined) {
12+
return fallbackComparison * directionMultiplier;
13+
}
14+
return result * directionMultiplier;
15+
};
16+
17+
const MIN_SAFE_BIGINT = BigInt(Number.MIN_SAFE_INTEGER);
18+
const MAX_SAFE_BIGINT = BigInt(Number.MAX_SAFE_INTEGER);
19+
20+
export const observedTimestampDifference = (a: bigint, b: bigint): number => {
21+
const difference = a - b;
22+
if (difference < MIN_SAFE_BIGINT) return Number.MIN_SAFE_INTEGER;
23+
if (difference > MAX_SAFE_BIGINT) return Number.MAX_SAFE_INTEGER;
24+
return Number(difference);
25+
};

0 commit comments

Comments
 (0)