@rozenite/network-activity-plugin 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/devtools/App.html +2 -2
- package/dist/devtools/assets/{App-DsimzJvx.js → App-CEESZAW_.js} +985 -259
- package/dist/devtools/assets/{App-CUXU0mup.css → App-xppYUJvX.css} +94 -0
- package/dist/rozenite.json +1 -1
- package/package.json +6 -6
- package/src/ui/components/FilterBar.tsx +13 -45
- package/src/ui/components/NetworkTimeline.tsx +422 -0
- package/src/ui/components/RequestList.tsx +6 -185
- package/src/ui/components/Toolbar.tsx +13 -1
- package/src/ui/hooks/useNetworkActivitySessionExport.ts +39 -0
- package/src/ui/state/__tests__/store.test.ts +77 -0
- package/src/ui/state/derived.ts +2 -0
- package/src/ui/state/filter.ts +49 -0
- package/src/ui/state/hooks.ts +2 -2
- package/src/ui/state/model.ts +1 -0
- package/src/ui/state/store.ts +24 -2
- package/src/ui/utils/__tests__/requestFilters.test.ts +32 -0
- package/src/ui/utils/__tests__/sessionExport.test.ts +174 -0
- package/src/ui/utils/__tests__/timelineModel.test.ts +170 -0
- package/src/ui/utils/download.ts +7 -0
- package/src/ui/utils/requestFilters.ts +183 -0
- package/src/ui/utils/sessionExport.ts +185 -0
- package/src/ui/utils/timelineModel.ts +352 -0
- package/src/ui/views/InspectorView.tsx +40 -8
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import type { FilterState } from '../state/filter';
|
|
2
|
+
import type { ProcessedRequest } from '../state/model';
|
|
3
|
+
import type { HttpMethod } from '../../shared/client';
|
|
4
|
+
|
|
5
|
+
export type RequestFilterOptions = {
|
|
6
|
+
hasOverride?: boolean;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const parseThreshold = (value: string): number | null => {
|
|
10
|
+
const normalizedValue = value.trim();
|
|
11
|
+
if (!normalizedValue) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const parsedValue = Number(normalizedValue);
|
|
16
|
+
return Number.isFinite(parsedValue) ? parsedValue : null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const matchesStatusFilter = (
|
|
20
|
+
statusCode: number | undefined,
|
|
21
|
+
statusFilter: string,
|
|
22
|
+
) => {
|
|
23
|
+
const normalizedFilter = statusFilter.trim().toLowerCase();
|
|
24
|
+
if (!normalizedFilter) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (statusCode === undefined) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const statusRangeMatch = normalizedFilter.match(/^(\d{3})\s*-\s*(\d{3})$/);
|
|
33
|
+
if (statusRangeMatch) {
|
|
34
|
+
const min = Number(statusRangeMatch[1]);
|
|
35
|
+
const max = Number(statusRangeMatch[2]);
|
|
36
|
+
return statusCode >= min && statusCode <= max;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const statusClassMatch = normalizedFilter.match(/^([1-5])xx$/);
|
|
40
|
+
if (statusClassMatch) {
|
|
41
|
+
return Math.floor(statusCode / 100) === Number(statusClassMatch[1]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const comparisonMatch = normalizedFilter.match(/^(>=|<=|>|<)\s*(\d{3})$/);
|
|
45
|
+
if (comparisonMatch) {
|
|
46
|
+
const value = Number(comparisonMatch[2]);
|
|
47
|
+
switch (comparisonMatch[1]) {
|
|
48
|
+
case '>=':
|
|
49
|
+
return statusCode >= value;
|
|
50
|
+
case '<=':
|
|
51
|
+
return statusCode <= value;
|
|
52
|
+
case '>':
|
|
53
|
+
return statusCode > value;
|
|
54
|
+
case '<':
|
|
55
|
+
return statusCode < value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return statusCode === Number(normalizedFilter);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const isInFlightStatus = (status: ProcessedRequest['status']) => {
|
|
63
|
+
return ['pending', 'loading', 'connecting', 'open'].includes(status);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const isFailedStatus = (status: ProcessedRequest['status']) => {
|
|
67
|
+
return ['failed', 'error'].includes(status);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const isHttpMethod = (
|
|
71
|
+
method: ProcessedRequest['method'],
|
|
72
|
+
): method is HttpMethod => method !== 'WS' && method !== 'SSE';
|
|
73
|
+
|
|
74
|
+
const extractDomainAndPath = (url: string) => {
|
|
75
|
+
try {
|
|
76
|
+
const { hostname, pathname, search, hash, port } = new URL(url);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
domain: `${hostname}${port ? `:${port}` : ''}`,
|
|
80
|
+
path: `${pathname}${search}${hash}`,
|
|
81
|
+
};
|
|
82
|
+
} catch {
|
|
83
|
+
return { domain: 'unknown', path: url };
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const matchesRequestFilter = (
|
|
88
|
+
request: ProcessedRequest,
|
|
89
|
+
filter: FilterState,
|
|
90
|
+
options: RequestFilterOptions = {},
|
|
91
|
+
) => {
|
|
92
|
+
if (filter.types.size > 0 && !filter.types.has(request.type)) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (
|
|
97
|
+
filter.advanced.methods.size > 0 &&
|
|
98
|
+
(!isHttpMethod(request.method) ||
|
|
99
|
+
!filter.advanced.methods.has(request.method))
|
|
100
|
+
) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (
|
|
105
|
+
filter.advanced.sources.size > 0 &&
|
|
106
|
+
(!request.source || !filter.advanced.sources.has(request.source))
|
|
107
|
+
) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!matchesStatusFilter(request.httpStatus, filter.advanced.status)) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const { domain, path } = extractDomainAndPath(request.name);
|
|
116
|
+
const domainFilter = filter.advanced.domain.trim().toLowerCase();
|
|
117
|
+
if (domainFilter && !domain.toLowerCase().includes(domainFilter)) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const contentTypeFilter = filter.advanced.contentType.trim().toLowerCase();
|
|
122
|
+
if (
|
|
123
|
+
contentTypeFilter &&
|
|
124
|
+
!request.contentType?.toLowerCase().includes(contentTypeFilter)
|
|
125
|
+
) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (filter.advanced.failedOnly && !isFailedStatus(request.status)) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (filter.advanced.inFlightOnly && !isInFlightStatus(request.status)) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (filter.advanced.overriddenOnly && !options.hasOverride) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const minSize = parseThreshold(filter.advanced.minSize);
|
|
142
|
+
if (minSize !== null && (request.size === null || request.size < minSize)) {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const maxSize = parseThreshold(filter.advanced.maxSize);
|
|
147
|
+
if (maxSize !== null && (request.size === null || request.size > maxSize)) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const duration = request.duration || 0;
|
|
152
|
+
const minDuration = parseThreshold(filter.advanced.minDuration);
|
|
153
|
+
if (minDuration !== null && duration < minDuration) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const maxDuration = parseThreshold(filter.advanced.maxDuration);
|
|
158
|
+
if (maxDuration !== null && duration > maxDuration) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const searchText = filter.text.trim().toLowerCase();
|
|
163
|
+
if (!searchText) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const searchableFields = [
|
|
168
|
+
request.name,
|
|
169
|
+
request.method,
|
|
170
|
+
request.status,
|
|
171
|
+
request.httpStatus,
|
|
172
|
+
request.source,
|
|
173
|
+
request.type,
|
|
174
|
+
request.contentType,
|
|
175
|
+
domain,
|
|
176
|
+
path,
|
|
177
|
+
]
|
|
178
|
+
.filter((value) => value !== undefined && value !== null)
|
|
179
|
+
.join(' ')
|
|
180
|
+
.toLowerCase();
|
|
181
|
+
|
|
182
|
+
return searchableFields.includes(searchText);
|
|
183
|
+
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HttpNetworkEntry,
|
|
3
|
+
NetworkEntry,
|
|
4
|
+
RequestId,
|
|
5
|
+
SSENetworkEntry,
|
|
6
|
+
WebSocketMessage,
|
|
7
|
+
WebSocketNetworkEntry,
|
|
8
|
+
} from '../state/model';
|
|
9
|
+
|
|
10
|
+
const EXPORT_SCHEMA_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
type ExportedHttpEntry = {
|
|
13
|
+
id: RequestId;
|
|
14
|
+
type: 'http';
|
|
15
|
+
source?: NetworkEntry['source'];
|
|
16
|
+
timestamp: number;
|
|
17
|
+
duration: number | null;
|
|
18
|
+
status: HttpNetworkEntry['status'];
|
|
19
|
+
error?: string;
|
|
20
|
+
canceled?: boolean;
|
|
21
|
+
request: HttpNetworkEntry['request'];
|
|
22
|
+
response: HttpNetworkEntry['response'] | null;
|
|
23
|
+
size: number | null;
|
|
24
|
+
ttfb: number | null;
|
|
25
|
+
initiator?: HttpNetworkEntry['initiator'];
|
|
26
|
+
resourceType?: HttpNetworkEntry['resourceType'];
|
|
27
|
+
progress?: HttpNetworkEntry['progress'];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type ExportedWebSocketEntry = {
|
|
31
|
+
id: RequestId;
|
|
32
|
+
type: 'websocket';
|
|
33
|
+
source?: NetworkEntry['source'];
|
|
34
|
+
timestamp: number;
|
|
35
|
+
duration: number | null;
|
|
36
|
+
status: WebSocketNetworkEntry['status'];
|
|
37
|
+
connection: WebSocketNetworkEntry['connection'];
|
|
38
|
+
error?: string;
|
|
39
|
+
closeCode?: number;
|
|
40
|
+
closeReason?: string;
|
|
41
|
+
messages: WebSocketMessage[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type ExportedSSEEntry = {
|
|
45
|
+
id: RequestId;
|
|
46
|
+
type: 'sse';
|
|
47
|
+
source?: NetworkEntry['source'];
|
|
48
|
+
timestamp: number;
|
|
49
|
+
duration: number | null;
|
|
50
|
+
status: SSENetworkEntry['status'];
|
|
51
|
+
error?: string;
|
|
52
|
+
request: SSENetworkEntry['request'];
|
|
53
|
+
response: SSENetworkEntry['response'] | null;
|
|
54
|
+
initiator?: SSENetworkEntry['initiator'];
|
|
55
|
+
resourceType?: SSENetworkEntry['resourceType'];
|
|
56
|
+
messages: SSENetworkEntry['messages'];
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type ExportedNetworkEntry =
|
|
60
|
+
| ExportedHttpEntry
|
|
61
|
+
| ExportedWebSocketEntry
|
|
62
|
+
| ExportedSSEEntry;
|
|
63
|
+
|
|
64
|
+
export type NetworkActivitySessionExport = {
|
|
65
|
+
schemaVersion: typeof EXPORT_SCHEMA_VERSION;
|
|
66
|
+
tool: 'rozenite-network-activity';
|
|
67
|
+
exportedAt: string;
|
|
68
|
+
summary: {
|
|
69
|
+
totalEntries: number;
|
|
70
|
+
httpRequests: number;
|
|
71
|
+
webSocketConnections: number;
|
|
72
|
+
sseConnections: number;
|
|
73
|
+
realtimeMessages: number;
|
|
74
|
+
};
|
|
75
|
+
entries: ExportedNetworkEntry[];
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const getDuration = (duration: number | undefined) => duration ?? null;
|
|
79
|
+
|
|
80
|
+
const serializeHttpEntry = (entry: HttpNetworkEntry): ExportedHttpEntry => ({
|
|
81
|
+
id: entry.id,
|
|
82
|
+
type: 'http',
|
|
83
|
+
source: entry.source,
|
|
84
|
+
timestamp: entry.timestamp,
|
|
85
|
+
duration: getDuration(entry.duration),
|
|
86
|
+
status: entry.status,
|
|
87
|
+
error: entry.error,
|
|
88
|
+
canceled: entry.canceled,
|
|
89
|
+
request: entry.request,
|
|
90
|
+
response: entry.response ?? null,
|
|
91
|
+
size: entry.size ?? null,
|
|
92
|
+
ttfb: entry.ttfb ?? null,
|
|
93
|
+
initiator: entry.initiator,
|
|
94
|
+
resourceType: entry.resourceType,
|
|
95
|
+
progress: entry.progress,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const serializeWebSocketEntry = (
|
|
99
|
+
entry: WebSocketNetworkEntry,
|
|
100
|
+
websocketMessages: Map<RequestId, WebSocketMessage[]>,
|
|
101
|
+
): ExportedWebSocketEntry => ({
|
|
102
|
+
id: entry.id,
|
|
103
|
+
type: 'websocket',
|
|
104
|
+
source: entry.source,
|
|
105
|
+
timestamp: entry.timestamp,
|
|
106
|
+
duration: getDuration(entry.duration),
|
|
107
|
+
status: entry.status,
|
|
108
|
+
connection: entry.connection,
|
|
109
|
+
error: entry.error,
|
|
110
|
+
closeCode: entry.closeCode,
|
|
111
|
+
closeReason: entry.closeReason,
|
|
112
|
+
messages: websocketMessages.get(entry.id) ?? [],
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const serializeSSEEntry = (entry: SSENetworkEntry): ExportedSSEEntry => ({
|
|
116
|
+
id: entry.id,
|
|
117
|
+
type: 'sse',
|
|
118
|
+
source: entry.source,
|
|
119
|
+
timestamp: entry.timestamp,
|
|
120
|
+
duration: getDuration(entry.duration),
|
|
121
|
+
status: entry.status,
|
|
122
|
+
error: entry.error,
|
|
123
|
+
request: entry.request,
|
|
124
|
+
response: entry.response ?? null,
|
|
125
|
+
initiator: entry.initiator,
|
|
126
|
+
resourceType: entry.resourceType,
|
|
127
|
+
messages: entry.messages,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const serializeEntry = (
|
|
131
|
+
entry: NetworkEntry,
|
|
132
|
+
websocketMessages: Map<RequestId, WebSocketMessage[]>,
|
|
133
|
+
): ExportedNetworkEntry => {
|
|
134
|
+
switch (entry.type) {
|
|
135
|
+
case 'http':
|
|
136
|
+
return serializeHttpEntry(entry);
|
|
137
|
+
case 'websocket':
|
|
138
|
+
return serializeWebSocketEntry(entry, websocketMessages);
|
|
139
|
+
case 'sse':
|
|
140
|
+
return serializeSSEEntry(entry);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const createNetworkActivitySessionExport = (
|
|
145
|
+
networkEntries: Map<RequestId, NetworkEntry>,
|
|
146
|
+
websocketMessages: Map<RequestId, WebSocketMessage[]>,
|
|
147
|
+
exportedAt = new Date(),
|
|
148
|
+
): NetworkActivitySessionExport => {
|
|
149
|
+
const entries = Array.from(networkEntries.values())
|
|
150
|
+
.sort((a, b) => a.timestamp - b.timestamp)
|
|
151
|
+
.map((entry) => serializeEntry(entry, websocketMessages));
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
schemaVersion: EXPORT_SCHEMA_VERSION,
|
|
155
|
+
tool: 'rozenite-network-activity',
|
|
156
|
+
exportedAt: exportedAt.toISOString(),
|
|
157
|
+
summary: {
|
|
158
|
+
totalEntries: entries.length,
|
|
159
|
+
httpRequests: entries.filter((entry) => entry.type === 'http').length,
|
|
160
|
+
webSocketConnections: entries.filter(
|
|
161
|
+
(entry) => entry.type === 'websocket',
|
|
162
|
+
).length,
|
|
163
|
+
sseConnections: entries.filter((entry) => entry.type === 'sse').length,
|
|
164
|
+
realtimeMessages: entries.reduce((count, entry) => {
|
|
165
|
+
if (entry.type === 'websocket' || entry.type === 'sse') {
|
|
166
|
+
return count + entry.messages.length;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return count;
|
|
170
|
+
}, 0),
|
|
171
|
+
},
|
|
172
|
+
entries,
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
export const getNetworkActivitySessionExportFileName = (
|
|
177
|
+
exportedAt = new Date(),
|
|
178
|
+
) => {
|
|
179
|
+
const timestamp = exportedAt
|
|
180
|
+
.toISOString()
|
|
181
|
+
.replace(/\.\d{3}Z$/, 'Z')
|
|
182
|
+
.replace(/[:]/g, '-');
|
|
183
|
+
|
|
184
|
+
return `rozenite-network-session-${timestamp}.json`;
|
|
185
|
+
};
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import type { ProcessedRequest } from '../state/model';
|
|
2
|
+
|
|
3
|
+
export const TIMELINE_LAYOUT = {
|
|
4
|
+
minVisibleBarPercent: 0.65,
|
|
5
|
+
minRangeMs: 1000,
|
|
6
|
+
liveRefreshMs: 1000,
|
|
7
|
+
maxRenderedRequests: 1000,
|
|
8
|
+
laneCount: 8,
|
|
9
|
+
laneHeightPx: 2,
|
|
10
|
+
laneGapPx: 6,
|
|
11
|
+
laneHitTargetHeightPx: 8,
|
|
12
|
+
rulerHeightPx: 22,
|
|
13
|
+
laneTopPx: 32,
|
|
14
|
+
laneBottomPaddingPx: 18,
|
|
15
|
+
tickTargetCount: 7,
|
|
16
|
+
minTickLabelGapPercent: 6,
|
|
17
|
+
rangePaddingRatio: 0.025,
|
|
18
|
+
minRangePaddingMs: 25,
|
|
19
|
+
streamingRequestMaxDurationMs: 5000,
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
const NICE_TICK_FACTORS = [1, 2, 2.5, 5, 10] as const;
|
|
23
|
+
|
|
24
|
+
type TimelineLayout = {
|
|
25
|
+
[Key in keyof typeof TIMELINE_LAYOUT]: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type TimelineTick = {
|
|
29
|
+
label: string;
|
|
30
|
+
offsetPercent: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type TimelineRangeSelection = {
|
|
34
|
+
startTime: number;
|
|
35
|
+
endTime: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type TimelineRow = {
|
|
39
|
+
request: ProcessedRequest;
|
|
40
|
+
offsetPercent: number;
|
|
41
|
+
widthPercent: number;
|
|
42
|
+
duration: number;
|
|
43
|
+
ttfbPercent: number;
|
|
44
|
+
receivePercent: number;
|
|
45
|
+
isActive: boolean;
|
|
46
|
+
lane: number;
|
|
47
|
+
isOverflowingLane: boolean;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type TimelineModel = {
|
|
51
|
+
rows: TimelineRow[];
|
|
52
|
+
ticks: TimelineTick[];
|
|
53
|
+
rangeStart: number;
|
|
54
|
+
rangeDuration: number;
|
|
55
|
+
chartHeight: number;
|
|
56
|
+
totalRequestCount: number;
|
|
57
|
+
hiddenRequestCount: number;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const ACTIVE_HTTP_STATUSES = new Set<ProcessedRequest['status']>([
|
|
61
|
+
'pending',
|
|
62
|
+
'loading',
|
|
63
|
+
]);
|
|
64
|
+
const ACTIVE_WEBSOCKET_STATUSES = new Set<ProcessedRequest['status']>([
|
|
65
|
+
'connecting',
|
|
66
|
+
'open',
|
|
67
|
+
'closing',
|
|
68
|
+
]);
|
|
69
|
+
const ACTIVE_SSE_STATUSES = new Set<ProcessedRequest['status']>([
|
|
70
|
+
'connecting',
|
|
71
|
+
'open',
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
const clamp = (value: number, minimum: number, maximum: number) => {
|
|
75
|
+
return Math.min(Math.max(value, minimum), maximum);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const getTimelineChartHeight = (
|
|
79
|
+
layout: TimelineLayout = TIMELINE_LAYOUT,
|
|
80
|
+
) => {
|
|
81
|
+
return (
|
|
82
|
+
layout.laneTopPx +
|
|
83
|
+
layout.laneCount * layout.laneHeightPx +
|
|
84
|
+
(layout.laneCount - 1) * layout.laneGapPx +
|
|
85
|
+
layout.laneBottomPaddingPx
|
|
86
|
+
);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const getTimelineLaneTop = (
|
|
90
|
+
lane: number,
|
|
91
|
+
layout: TimelineLayout = TIMELINE_LAYOUT,
|
|
92
|
+
) => {
|
|
93
|
+
return lane * (layout.laneHeightPx + layout.laneGapPx) + layout.laneTopPx;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const getTimelineTrackTop = (
|
|
97
|
+
lane: number,
|
|
98
|
+
layout: TimelineLayout = TIMELINE_LAYOUT,
|
|
99
|
+
) => {
|
|
100
|
+
const visualBarTop = getTimelineLaneTop(lane, layout);
|
|
101
|
+
return (
|
|
102
|
+
visualBarTop - (layout.laneHitTargetHeightPx - layout.laneHeightPx) / 2
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const getTimelineBarTopOffset = (
|
|
107
|
+
layout: TimelineLayout = TIMELINE_LAYOUT,
|
|
108
|
+
) => {
|
|
109
|
+
return (layout.laneHitTargetHeightPx - layout.laneHeightPx) / 2;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const isRequestActive = (request: ProcessedRequest) => {
|
|
113
|
+
switch (request.type) {
|
|
114
|
+
case 'http':
|
|
115
|
+
return ACTIVE_HTTP_STATUSES.has(request.status);
|
|
116
|
+
case 'websocket':
|
|
117
|
+
return ACTIVE_WEBSOCKET_STATUSES.has(request.status);
|
|
118
|
+
case 'sse':
|
|
119
|
+
return ACTIVE_SSE_STATUSES.has(request.status);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const formatTimelineOffset = (milliseconds: number) => {
|
|
124
|
+
if (milliseconds < 1000) {
|
|
125
|
+
return `${Math.round(milliseconds)} ms`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (milliseconds < 60000) {
|
|
129
|
+
return `${(milliseconds / 1000).toFixed(milliseconds < 10000 ? 1 : 0)} s`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const totalSeconds = Math.round(milliseconds / 1000);
|
|
133
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
134
|
+
const seconds = totalSeconds % 60;
|
|
135
|
+
return `${minutes}m ${seconds.toString().padStart(2, '0')}s`;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
export const getRequestEndTime = (request: ProcessedRequest, now: number) => {
|
|
139
|
+
if (typeof request.duration === 'number') {
|
|
140
|
+
return request.timestamp + Math.max(request.duration, 0);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (isRequestActive(request)) {
|
|
144
|
+
return Math.max(now, request.timestamp);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return request.timestamp;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export const getTimelineRequestEndTime = (
|
|
151
|
+
request: ProcessedRequest,
|
|
152
|
+
now: number,
|
|
153
|
+
layout: TimelineLayout = TIMELINE_LAYOUT,
|
|
154
|
+
) => {
|
|
155
|
+
const endTime = getRequestEndTime(request, now);
|
|
156
|
+
|
|
157
|
+
if (request.type !== 'websocket' && request.type !== 'sse') {
|
|
158
|
+
return endTime;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return Math.min(
|
|
162
|
+
endTime,
|
|
163
|
+
request.timestamp + layout.streamingRequestMaxDurationMs,
|
|
164
|
+
);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export const requestOverlapsTimelineRange = (
|
|
168
|
+
request: ProcessedRequest,
|
|
169
|
+
range: TimelineRangeSelection,
|
|
170
|
+
now: number,
|
|
171
|
+
layout: TimelineLayout = TIMELINE_LAYOUT,
|
|
172
|
+
) => {
|
|
173
|
+
const rangeStart = Math.min(range.startTime, range.endTime);
|
|
174
|
+
const rangeEnd = Math.max(range.startTime, range.endTime);
|
|
175
|
+
const requestStart = request.timestamp;
|
|
176
|
+
const requestEnd = getTimelineRequestEndTime(request, now, layout);
|
|
177
|
+
|
|
178
|
+
return requestStart <= rangeEnd && requestEnd >= rangeStart;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const getNiceTickStep = (rangeDuration: number, targetTickCount: number) => {
|
|
182
|
+
const targetStep = rangeDuration / targetTickCount;
|
|
183
|
+
const exponent = Math.floor(Math.log10(targetStep));
|
|
184
|
+
const magnitude = 10 ** exponent;
|
|
185
|
+
const normalizedStep = targetStep / magnitude;
|
|
186
|
+
const factor =
|
|
187
|
+
NICE_TICK_FACTORS.find((candidate) => candidate >= normalizedStep) ??
|
|
188
|
+
NICE_TICK_FACTORS[NICE_TICK_FACTORS.length - 1];
|
|
189
|
+
|
|
190
|
+
return factor * magnitude;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const getTimelineTicks = (
|
|
194
|
+
rangeDuration: number,
|
|
195
|
+
layout: TimelineLayout = TIMELINE_LAYOUT,
|
|
196
|
+
): TimelineTick[] => {
|
|
197
|
+
const step = getNiceTickStep(rangeDuration, layout.tickTargetCount);
|
|
198
|
+
const ticks: TimelineTick[] = [];
|
|
199
|
+
|
|
200
|
+
for (let value = 0; value <= rangeDuration; value += step) {
|
|
201
|
+
ticks.push({
|
|
202
|
+
label: formatTimelineOffset(value),
|
|
203
|
+
offsetPercent: (value / rangeDuration) * 100,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (
|
|
208
|
+
ticks.length === 0 ||
|
|
209
|
+
ticks[ticks.length - 1].offsetPercent < 100 - Number.EPSILON
|
|
210
|
+
) {
|
|
211
|
+
const finalTick = {
|
|
212
|
+
label: formatTimelineOffset(rangeDuration),
|
|
213
|
+
offsetPercent: 100,
|
|
214
|
+
};
|
|
215
|
+
const previousTick = ticks[ticks.length - 1];
|
|
216
|
+
|
|
217
|
+
if (
|
|
218
|
+
!previousTick ||
|
|
219
|
+
(finalTick.label !== previousTick.label &&
|
|
220
|
+
finalTick.offsetPercent - previousTick.offsetPercent >=
|
|
221
|
+
layout.minTickLabelGapPercent)
|
|
222
|
+
) {
|
|
223
|
+
ticks.push(finalTick);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return ticks;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const getTimelineBounds = (
|
|
231
|
+
requests: ProcessedRequest[],
|
|
232
|
+
now: number,
|
|
233
|
+
layout: TimelineLayout,
|
|
234
|
+
) => {
|
|
235
|
+
return requests.reduce(
|
|
236
|
+
(result, request) => {
|
|
237
|
+
const endTime = getTimelineRequestEndTime(request, now, layout);
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
start: Math.min(result.start, request.timestamp),
|
|
241
|
+
end: Math.max(result.end, endTime),
|
|
242
|
+
};
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
start: Number.POSITIVE_INFINITY,
|
|
246
|
+
end: Number.NEGATIVE_INFINITY,
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const getEarliestLaneIndex = (laneEndTimes: number[]) => {
|
|
252
|
+
return laneEndTimes.reduce((earliestIndex, laneEndTime, index) => {
|
|
253
|
+
return laneEndTime < laneEndTimes[earliestIndex] ? index : earliestIndex;
|
|
254
|
+
}, 0);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const getRenderableRequests = (
|
|
258
|
+
requests: ProcessedRequest[],
|
|
259
|
+
layout: TimelineLayout,
|
|
260
|
+
) => {
|
|
261
|
+
if (requests.length <= layout.maxRenderedRequests) {
|
|
262
|
+
return requests;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return [...requests]
|
|
266
|
+
.sort((a, b) => b.timestamp - a.timestamp)
|
|
267
|
+
.slice(0, layout.maxRenderedRequests);
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
export const getTimelineModel = (
|
|
271
|
+
requests: ProcessedRequest[],
|
|
272
|
+
now: number,
|
|
273
|
+
layout: TimelineLayout = TIMELINE_LAYOUT,
|
|
274
|
+
): TimelineModel => {
|
|
275
|
+
const renderableRequests = getRenderableRequests(requests, layout);
|
|
276
|
+
const hiddenRequestCount = requests.length - renderableRequests.length;
|
|
277
|
+
|
|
278
|
+
if (renderableRequests.length === 0) {
|
|
279
|
+
return {
|
|
280
|
+
rows: [],
|
|
281
|
+
ticks: getTimelineTicks(layout.minRangeMs, layout),
|
|
282
|
+
rangeStart: 0,
|
|
283
|
+
rangeDuration: layout.minRangeMs,
|
|
284
|
+
chartHeight: getTimelineChartHeight(layout),
|
|
285
|
+
totalRequestCount: requests.length,
|
|
286
|
+
hiddenRequestCount,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const bounds = getTimelineBounds(renderableRequests, now, layout);
|
|
291
|
+
const rawRangeDuration = Math.max(
|
|
292
|
+
bounds.end - bounds.start,
|
|
293
|
+
layout.minRangeMs,
|
|
294
|
+
);
|
|
295
|
+
const rangePadding = Math.max(
|
|
296
|
+
rawRangeDuration * layout.rangePaddingRatio,
|
|
297
|
+
layout.minRangePaddingMs,
|
|
298
|
+
);
|
|
299
|
+
const rangeStart = bounds.start - rangePadding;
|
|
300
|
+
const rangeDuration = rawRangeDuration + rangePadding * 2;
|
|
301
|
+
const laneEndTimes = Array.from({ length: layout.laneCount }, () => 0);
|
|
302
|
+
|
|
303
|
+
const rows = [...renderableRequests]
|
|
304
|
+
.sort((a, b) => a.timestamp - b.timestamp)
|
|
305
|
+
.map((request): TimelineRow => {
|
|
306
|
+
const startTime = request.timestamp;
|
|
307
|
+
const endTime = getTimelineRequestEndTime(request, now, layout);
|
|
308
|
+
const duration = Math.max(endTime - startTime, 0);
|
|
309
|
+
const offsetPercent = clamp(
|
|
310
|
+
((startTime - rangeStart) / rangeDuration) * 100,
|
|
311
|
+
0,
|
|
312
|
+
100 - layout.minVisibleBarPercent,
|
|
313
|
+
);
|
|
314
|
+
const widthPercent = Math.min(
|
|
315
|
+
Math.max((duration / rangeDuration) * 100, layout.minVisibleBarPercent),
|
|
316
|
+
100 - offsetPercent,
|
|
317
|
+
);
|
|
318
|
+
const ttfb = clamp(request.ttfb ?? 0, 0, duration);
|
|
319
|
+
const ttfbPercent = duration === 0 ? 0 : (ttfb / duration) * 100;
|
|
320
|
+
const receivePercent = Math.max(100 - ttfbPercent, 0);
|
|
321
|
+
const availableLane = laneEndTimes.findIndex(
|
|
322
|
+
(laneEndTime) => laneEndTime <= startTime,
|
|
323
|
+
);
|
|
324
|
+
const isOverflowingLane = availableLane === -1;
|
|
325
|
+
const lane = isOverflowingLane
|
|
326
|
+
? getEarliestLaneIndex(laneEndTimes)
|
|
327
|
+
: availableLane;
|
|
328
|
+
laneEndTimes[lane] = Math.max(laneEndTimes[lane], endTime);
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
request,
|
|
332
|
+
offsetPercent,
|
|
333
|
+
widthPercent,
|
|
334
|
+
duration,
|
|
335
|
+
ttfbPercent,
|
|
336
|
+
receivePercent,
|
|
337
|
+
isActive: isRequestActive(request),
|
|
338
|
+
lane,
|
|
339
|
+
isOverflowingLane,
|
|
340
|
+
};
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
rows,
|
|
345
|
+
ticks: getTimelineTicks(rangeDuration, layout),
|
|
346
|
+
rangeStart,
|
|
347
|
+
rangeDuration,
|
|
348
|
+
chartHeight: getTimelineChartHeight(layout),
|
|
349
|
+
totalRequestCount: requests.length,
|
|
350
|
+
hiddenRequestCount,
|
|
351
|
+
};
|
|
352
|
+
};
|