@rozenite/network-activity-plugin 1.0.0-alpha.1

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.
@@ -0,0 +1,145 @@
1
+ import React from 'react';
2
+ import { useVirtualizer } from '@tanstack/react-virtual';
3
+ import { NetworkEntry } from '../types/network';
4
+ import { getStatusColor, getMethodColor, formatDuration, formatFileSize, parseUrl } from './utils';
5
+ import { Badge, Tooltip } from './components';
6
+ import styles from './network-list.module.css';
7
+
8
+ interface NetworkListProps {
9
+ entries: NetworkEntry[];
10
+ selectedRequestId: string | null;
11
+ onSelect: (requestId: string) => void;
12
+ height: number;
13
+ }
14
+
15
+ const ITEM_HEIGHT = 60; // Height of each network list item
16
+
17
+ export const NetworkList: React.FC<NetworkListProps> = ({
18
+ entries,
19
+ selectedRequestId,
20
+ onSelect,
21
+ height,
22
+ }) => {
23
+ const parentRef = React.useRef<HTMLDivElement>(null);
24
+
25
+ const virtualizer = useVirtualizer({
26
+ count: entries.length,
27
+ getScrollElement: () => parentRef.current,
28
+ estimateSize: () => ITEM_HEIGHT,
29
+ overscan: 5,
30
+ });
31
+
32
+ const NetworkListItem: React.FC<{ entry: NetworkEntry; index: number }> = ({ entry, index }) => {
33
+ const status = entry.response?.response.status || 0;
34
+ const method = entry.request.request.method;
35
+ const url = entry.request.request.url;
36
+ const { domain, path } = parseUrl(url);
37
+
38
+ // Get size information
39
+ const encodedSize = entry.response?.response.encodedDataLength || 0;
40
+ const decodedSize = entry.response?.response.decodedBodySize || 0;
41
+ const displaySize = decodedSize > 0 ? decodedSize : encodedSize;
42
+
43
+ const isSelected = selectedRequestId === entry.requestId;
44
+
45
+ return (
46
+ <div
47
+ className={isSelected ? styles.listItemSelected : styles.listItem}
48
+ onClick={() => onSelect(entry.requestId)}
49
+ >
50
+ <div className={styles.statusColumn}>
51
+ <Tooltip
52
+ content={`Status: ${status || 'Pending'}`}
53
+ showOnlyWhenTruncated
54
+ variant={status >= 400 ? 'error' : status >= 300 ? 'warning' : 'info'}
55
+ >
56
+ <Badge color={getStatusColor(status)}>
57
+ {status || '...'}
58
+ </Badge>
59
+ </Tooltip>
60
+ </div>
61
+ <div className={styles.methodColumn}>
62
+ <Tooltip
63
+ content={`Method: ${method}`}
64
+ showOnlyWhenTruncated
65
+ variant="info"
66
+ >
67
+ <Badge color={getMethodColor(method)}>
68
+ {method}
69
+ </Badge>
70
+ </Tooltip>
71
+ </div>
72
+ <div className={styles.urlColumn}>
73
+ <Tooltip content={domain} showOnlyWhenTruncated>
74
+ <div className={styles.domainText}>
75
+ {domain}
76
+ </div>
77
+ </Tooltip>
78
+ <Tooltip content={path} showOnlyWhenTruncated>
79
+ <div className={styles.pathText}>
80
+ {path}
81
+ </div>
82
+ </Tooltip>
83
+ <Tooltip content={url} showOnlyWhenTruncated>
84
+ <div className={styles.fullUrlText}>
85
+ {url}
86
+ </div>
87
+ </Tooltip>
88
+ </div>
89
+ <div className={styles.durationColumn}>
90
+ <Tooltip content={`Duration: ${entry.duration ? formatDuration(entry.duration) : 'Pending'}`} showOnlyWhenTruncated>
91
+ <span className={styles.columnText}>
92
+ {entry.duration ? formatDuration(entry.duration) : '...'}
93
+ </span>
94
+ </Tooltip>
95
+ </div>
96
+ <div className={styles.sizeColumn}>
97
+ <Tooltip content={`Size: ${displaySize > 0 ? formatFileSize(displaySize) : 'Unknown'}`} showOnlyWhenTruncated>
98
+ <span className={styles.columnText}>
99
+ {displaySize > 0 ? formatFileSize(displaySize) : '...'}
100
+ </span>
101
+ </Tooltip>
102
+ </div>
103
+ </div>
104
+ );
105
+ };
106
+
107
+ if (entries.length === 0) {
108
+ return (
109
+ <div className={styles.emptyContainer} style={{ height }}>
110
+ <div className={styles.emptyText}>
111
+ No network requests recorded
112
+ </div>
113
+ </div>
114
+ );
115
+ }
116
+
117
+ return (
118
+ <div
119
+ ref={parentRef}
120
+ className={styles.container}
121
+ style={{ height }}
122
+ >
123
+ <div
124
+ className={styles.virtualContainer}
125
+ style={{ height: `${virtualizer.getTotalSize()}px` }}
126
+ >
127
+ {virtualizer.getVirtualItems().map((virtualItem: any) => (
128
+ <div
129
+ key={virtualItem.key}
130
+ className={styles.virtualItem}
131
+ style={{
132
+ height: `${virtualItem.size}px`,
133
+ transform: `translateY(${virtualItem.start}px)`,
134
+ }}
135
+ >
136
+ <NetworkListItem
137
+ entry={entries[virtualItem.index]}
138
+ index={virtualItem.index}
139
+ />
140
+ </div>
141
+ ))}
142
+ </div>
143
+ </div>
144
+ );
145
+ };
@@ -0,0 +1,9 @@
1
+ .recordingButton {
2
+ margin-right: 8px;
3
+ }
4
+
5
+ .requestCount {
6
+ margin-left: auto;
7
+ font-size: 12px;
8
+ color: #666;
9
+ }
@@ -0,0 +1,40 @@
1
+ import React from 'react';
2
+ import { Button, Toolbar } from './components';
3
+ import styles from './network-toolbar.module.css';
4
+
5
+ interface NetworkToolbarProps {
6
+ isRecording: boolean;
7
+ onToggleRecording: () => void;
8
+ onClear: () => void;
9
+ requestCount: number;
10
+ }
11
+
12
+ export const NetworkToolbar: React.FC<NetworkToolbarProps> = ({
13
+ isRecording,
14
+ onToggleRecording,
15
+ onClear,
16
+ requestCount,
17
+ }) => {
18
+ return (
19
+ <Toolbar>
20
+ <Button
21
+ onClick={onToggleRecording}
22
+ variant={isRecording ? 'danger' : 'success'}
23
+ size="small"
24
+ className={styles.recordingButton}
25
+ >
26
+ {isRecording ? 'Stop' : 'Start'} Recording
27
+ </Button>
28
+ <Button
29
+ onClick={onClear}
30
+ variant="secondary"
31
+ size="small"
32
+ >
33
+ Clear
34
+ </Button>
35
+ <div className={styles.requestCount}>
36
+ {requestCount} requests
37
+ </div>
38
+ </Toolbar>
39
+ );
40
+ };
@@ -0,0 +1,61 @@
1
+ .container {
2
+ height: 100vh;
3
+ display: flex;
4
+ flex-direction: column;
5
+ font-family: system-ui, -apple-system, sans-serif;
6
+ }
7
+
8
+ .mainContent {
9
+ flex: 1;
10
+ display: flex;
11
+ overflow: hidden;
12
+ min-height: 0;
13
+ }
14
+
15
+ .networkListContainer {
16
+ width: 60%;
17
+ border-right: 1px solid #e0e0e0;
18
+ display: flex;
19
+ flex-direction: column;
20
+ min-width: 0;
21
+ }
22
+
23
+ .listContent {
24
+ flex: 1;
25
+ }
26
+
27
+ .detailsContainer {
28
+ width: 40%;
29
+ display: flex;
30
+ flex-direction: column;
31
+ min-width: 0;
32
+ }
33
+
34
+ .headerStatus {
35
+ width: 60px;
36
+ text-align: center;
37
+ flex-shrink: 0;
38
+ }
39
+
40
+ .headerMethod {
41
+ width: 80px;
42
+ text-align: center;
43
+ flex-shrink: 0;
44
+ }
45
+
46
+ .headerName {
47
+ flex: 1;
48
+ min-width: 0;
49
+ }
50
+
51
+ .headerTime {
52
+ width: 80px;
53
+ text-align: right;
54
+ flex-shrink: 0;
55
+ }
56
+
57
+ .headerSize {
58
+ width: 80px;
59
+ text-align: right;
60
+ flex-shrink: 0;
61
+ }
@@ -0,0 +1,201 @@
1
+ import React, { useState, useEffect, useMemo } from 'react';
2
+ import { useRozeniteDevToolsClient } from "@rozenite/plugin-bridge";
3
+ import { NetworkEventMap, NetworkEntry } from '../types/network';
4
+ import styles from './panel.module.css';
5
+ import { NetworkToolbar } from './network-toolbar';
6
+ import { PanelHeader } from './components';
7
+ import { NetworkList } from './network-list';
8
+ import { NetworkDetails } from './network-details';
9
+
10
+ export default function NetworkActivityPanel() {
11
+ const client = useRozeniteDevToolsClient<NetworkEventMap>({
12
+ pluginId: '@rozenite/network-activity-plugin',
13
+ });
14
+
15
+ const [networkEntries, setNetworkEntries] = useState<Map<string, NetworkEntry>>(new Map());
16
+ const [selectedRequestId, setSelectedRequestId] = useState<string | null>(null);
17
+ const [isRecording, setIsRecording] = useState(true);
18
+ const [containerHeight, setContainerHeight] = useState(0);
19
+ const containerRef = React.useRef<HTMLDivElement>(null);
20
+
21
+ // Convert Map to sorted array for rendering
22
+ const sortedEntries = useMemo(() => {
23
+ return Array.from(networkEntries.values())
24
+ .sort((a, b) => b.startTime - a.startTime);
25
+ }, [networkEntries]);
26
+
27
+ const selectedEntry = selectedRequestId ? networkEntries.get(selectedRequestId) || null : null;
28
+
29
+ useEffect(() => {
30
+ if (!client) return;
31
+ if (!isRecording) return;
32
+
33
+ const subscriptions: Array<{ remove: () => void }> = [];
34
+
35
+ // Subscribe to Network events
36
+ subscriptions.push(
37
+ client.onMessage('Network.requestWillBeSent', (payload) => {
38
+ setNetworkEntries(prev => {
39
+ const newMap = new Map(prev);
40
+ newMap.set(payload.requestId, {
41
+ requestId: payload.requestId,
42
+ request: payload,
43
+ status: 'pending',
44
+ startTime: payload.timestamp,
45
+ });
46
+ return newMap;
47
+ });
48
+ })
49
+ );
50
+
51
+ subscriptions.push(
52
+ client.onMessage('Network.requestWillBeSentExtraInfo', (payload) => {
53
+ setNetworkEntries(prev => {
54
+ const newMap = new Map(prev);
55
+ const entry = newMap.get(payload.requestId);
56
+ if (entry) {
57
+ newMap.set(payload.requestId, {
58
+ ...entry,
59
+ extraInfo: payload,
60
+ });
61
+ }
62
+ return newMap;
63
+ });
64
+ })
65
+ );
66
+
67
+ subscriptions.push(
68
+ client.onMessage('Network.responseReceived', (payload) => {
69
+ setNetworkEntries(prev => {
70
+ const newMap = new Map(prev);
71
+ const entry = newMap.get(payload.requestId);
72
+ if (entry) {
73
+ newMap.set(payload.requestId, {
74
+ ...entry,
75
+ response: payload,
76
+ status: 'loading',
77
+ });
78
+ }
79
+ return newMap;
80
+ });
81
+ })
82
+ );
83
+
84
+ subscriptions.push(
85
+ client.onMessage('Network.loadingFinished', (payload) => {
86
+ setNetworkEntries(prev => {
87
+ const newMap = new Map(prev);
88
+ const entry = newMap.get(payload.requestId);
89
+ if (entry) {
90
+ const endTime = payload.timestamp;
91
+ const duration = (endTime - entry.startTime) * 1000; // Convert to milliseconds
92
+ newMap.set(payload.requestId, {
93
+ ...entry,
94
+ loadingFinished: payload,
95
+ status: 'finished',
96
+ endTime,
97
+ duration,
98
+ });
99
+ }
100
+ return newMap;
101
+ });
102
+ })
103
+ );
104
+
105
+ subscriptions.push(
106
+ client.onMessage('Network.loadingFailed', (payload) => {
107
+ setNetworkEntries(prev => {
108
+ const newMap = new Map(prev);
109
+ const entry = newMap.get(payload.requestId);
110
+ if (entry) {
111
+ const endTime = payload.timestamp;
112
+ const duration = (endTime - entry.startTime) * 1000; // Convert to milliseconds
113
+ newMap.set(payload.requestId, {
114
+ ...entry,
115
+ loadingFailed: payload,
116
+ status: 'failed',
117
+ endTime,
118
+ duration,
119
+ });
120
+ }
121
+ return newMap;
122
+ });
123
+ })
124
+ );
125
+
126
+ return () => {
127
+ subscriptions.forEach(sub => sub.remove());
128
+ };
129
+ }, [client, isRecording]);
130
+
131
+ const clearNetworkLog = () => {
132
+ setNetworkEntries(new Map());
133
+ setSelectedRequestId(null);
134
+ };
135
+
136
+ const toggleRecording = () => {
137
+ setIsRecording(!isRecording);
138
+ };
139
+
140
+ const handleSelectRequest = (requestId: string) => {
141
+ setSelectedRequestId(requestId);
142
+ };
143
+
144
+ // Update container height on mount and resize
145
+ useEffect(() => {
146
+ const updateHeight = () => {
147
+ if (containerRef.current) {
148
+ const rect = containerRef.current.getBoundingClientRect();
149
+ setContainerHeight(rect.height - 120); // Subtract toolbar and header height
150
+ }
151
+ };
152
+
153
+ updateHeight();
154
+ window.addEventListener('resize', updateHeight);
155
+ return () => window.removeEventListener('resize', updateHeight);
156
+ }, []);
157
+
158
+ return (
159
+ <div
160
+ ref={containerRef}
161
+ className={styles.container}
162
+ >
163
+ {/* Toolbar */}
164
+ <NetworkToolbar
165
+ isRecording={isRecording}
166
+ onToggleRecording={toggleRecording}
167
+ onClear={clearNetworkLog}
168
+ requestCount={sortedEntries.length}
169
+ />
170
+
171
+ {/* Main Content */}
172
+ <div className={styles.mainContent}>
173
+ {/* Network List */}
174
+ <div className={styles.networkListContainer}>
175
+ {/* List Header */}
176
+ <PanelHeader>
177
+ <div className={styles.headerStatus}>Status</div>
178
+ <div className={styles.headerMethod}>Method</div>
179
+ <div className={styles.headerName}>Name</div>
180
+ <div className={styles.headerTime}>Time</div>
181
+ <div className={styles.headerSize}>Size</div>
182
+ </PanelHeader>
183
+
184
+ <div className={styles.listContent}>
185
+ <NetworkList
186
+ entries={sortedEntries}
187
+ selectedRequestId={selectedRequestId}
188
+ onSelect={handleSelectRequest}
189
+ height={containerHeight}
190
+ />
191
+ </div>
192
+ </div>
193
+
194
+ {/* Details Panel */}
195
+ <div className={styles.detailsContainer}>
196
+ <NetworkDetails entry={selectedEntry} />
197
+ </div>
198
+ </div>
199
+ </div>
200
+ );
201
+ }
@@ -0,0 +1,197 @@
1
+ import { QueryCacheNotifyEvent, MutationCacheNotifyEvent, QueryClient, QueryClientProvider, Query, Mutation } from '@tanstack/react-query';
2
+ import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools';
3
+ import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge';
4
+ import { useEffect, useRef } from 'react';
5
+
6
+ const queryClient = new QueryClient({
7
+ defaultOptions: {
8
+ queries: {
9
+ queryFn: async () => {
10
+ // Prevent refetch from throwing an error
11
+ return Promise.resolve(null);
12
+ },
13
+ },
14
+ },
15
+ });
16
+
17
+ type DevToolsEventMap = {
18
+ "DEVTOOLS_TO_DEVICE": unknown;
19
+ "DEVICE_TO_DEVTOOLS": QueryCacheNotifyEvent | MutationCacheNotifyEvent;
20
+ "DEVICE_TO_DEVTOOLS_ACK": { requestId: string; success: boolean };
21
+ "DEVICE_TO_DEVTOOLS_INITIAL_DATA": { queries: Query[]; mutations: Mutation[] };
22
+ "DEVTOOLS_TO_DEVICE_INITIAL_DATA_REQUEST": unknown;
23
+ }
24
+
25
+ const Wrapped = () => {
26
+ const client = useRozeniteDevToolsClient<DevToolsEventMap>({
27
+ pluginId: '@rozenite/tanstack-query-plugin',
28
+ })
29
+
30
+ // Track pending acknowledgments to prevent feedback loops
31
+ const pendingAcknowledgment = useRef<Set<string>>(new Set());
32
+
33
+ useEffect(() => {
34
+ if (!client) return;
35
+ client.send("DEVTOOLS_TO_DEVICE_INITIAL_DATA_REQUEST", null);
36
+ }, [client]);
37
+
38
+ useEffect(() => {
39
+ if (!client) return;
40
+
41
+ const handleEvent = (event: Event) => {
42
+ const detail = (event as CustomEvent).detail;
43
+
44
+ // Generate a unique request ID for this DevTools action
45
+ const requestId = `devtools-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
46
+
47
+ // Mark that we're waiting for acknowledgment
48
+ pendingAcknowledgment.current.add(requestId);
49
+
50
+ // Add the request ID to the event detail
51
+ const eventWithRequestId = {
52
+ ...detail,
53
+ requestId,
54
+ };
55
+
56
+ client.send("DEVTOOLS_TO_DEVICE", eventWithRequestId);
57
+ };
58
+
59
+ window.addEventListener('@tanstack/query-devtools-event', handleEvent);
60
+ return () => window.removeEventListener('@tanstack/query-devtools-event', handleEvent);
61
+ }, [client])
62
+
63
+ useEffect(() => {
64
+ if (!client) return;
65
+
66
+ const ackSubscription = client.onMessage("DEVICE_TO_DEVTOOLS_ACK", (ack) => {
67
+ // Remove the request from pending acknowledgments
68
+ pendingAcknowledgment.current.delete(ack.requestId);
69
+ });
70
+
71
+ const subscription = client.onMessage("DEVICE_TO_DEVTOOLS", (event) => {
72
+ // Don't reflect events if we're waiting for acknowledgments
73
+ if (pendingAcknowledgment.current.size > 0) {
74
+ return;
75
+ }
76
+
77
+ if ('query' in event) {
78
+ const { query, type } = event as QueryCacheNotifyEvent;
79
+ const queryCache = queryClient.getQueryCache();
80
+
81
+ if (type === 'updated') {
82
+ const existingQuery = queryCache.get(query.queryHash);
83
+ if (existingQuery) {
84
+ existingQuery.setState(query.state);
85
+ } else {
86
+ queryCache.build(
87
+ queryClient,
88
+ {
89
+ queryKey: query.queryKey,
90
+ queryHash: query.queryHash,
91
+ },
92
+ query.state
93
+ );
94
+ }
95
+ } else if (type === 'added') {
96
+ const existingQuery = queryCache.get(query.queryHash);
97
+ if (!existingQuery) {
98
+ // Only add if it doesn't already exist
99
+ queryCache.build(
100
+ queryClient,
101
+ {
102
+ queryKey: query.queryKey,
103
+ queryHash: query.queryHash,
104
+ },
105
+ query.state
106
+ );
107
+ }
108
+ } else if (type === 'removed') {
109
+ const existingQuery = queryCache.get(query.queryHash);
110
+ if (existingQuery) {
111
+ queryCache.remove(existingQuery);
112
+ }
113
+ }
114
+ } else if ('mutation' in event) {
115
+ const { mutation, type } = event as MutationCacheNotifyEvent;
116
+ const mutationCache = queryClient.getMutationCache();
117
+
118
+ if (type === 'added') {
119
+ const existingMutation = mutationCache.find({ mutationKey: mutation.options.mutationKey });
120
+ if (existingMutation) {
121
+ mutationCache.remove(existingMutation);
122
+ }
123
+
124
+ mutationCache.build(
125
+ queryClient,
126
+ mutation.options,
127
+ mutation.state
128
+ );
129
+ } else if (type === 'removed') {
130
+ const existingMutation = mutationCache.find({ mutationKey: mutation.options.mutationKey });
131
+ if (existingMutation) {
132
+ mutationCache.remove(existingMutation);
133
+ }
134
+ } else if (type === 'updated') {
135
+ const existingMutation = mutationCache.find({ mutationKey: mutation.options.mutationKey });
136
+
137
+ if (existingMutation) {
138
+ mutationCache.remove(existingMutation);
139
+ mutationCache.build(
140
+ queryClient,
141
+ mutation.options,
142
+ mutation.state
143
+ );
144
+ }
145
+ }
146
+ }
147
+ })
148
+
149
+ const initialDataSubscription = client.onMessage("DEVICE_TO_DEVTOOLS_INITIAL_DATA", (event) => {
150
+ // Clear existing data first
151
+ queryClient.clear();
152
+ queryClient.getMutationCache().clear();
153
+
154
+ // Restore queries
155
+ const queryCache = queryClient.getQueryCache();
156
+ event.queries.forEach(query => {
157
+ queryCache.build(
158
+ queryClient,
159
+ {
160
+ queryKey: query.queryKey,
161
+ queryHash: query.queryHash,
162
+ },
163
+ query.state
164
+ );
165
+ });
166
+
167
+ // Restore mutations
168
+ const mutationCache = queryClient.getMutationCache();
169
+ event.mutations.forEach(mutation => {
170
+ mutationCache.build(
171
+ queryClient,
172
+ mutation.options,
173
+ mutation.state
174
+ );
175
+ });
176
+ });
177
+
178
+ return () => {
179
+ subscription.remove();
180
+ ackSubscription.remove();
181
+ initialDataSubscription.remove();
182
+ };
183
+ }, [client, queryClient])
184
+
185
+
186
+ return (
187
+ <ReactQueryDevtoolsPanel />
188
+ )
189
+ }
190
+
191
+ export default function TanStackQueryPanel() {
192
+ return (
193
+ <QueryClientProvider client={queryClient}>
194
+ <Wrapped />
195
+ </QueryClientProvider>
196
+ )
197
+ }