diff --git a/.changeset/network-activity-rozenite-ui.md b/.changeset/network-activity-rozenite-ui.md new file mode 100644 index 000000000..2f70db64a --- /dev/null +++ b/.changeset/network-activity-rozenite-ui.md @@ -0,0 +1,12 @@ +--- +'@rozenite/network-activity-plugin': minor +--- + +The Network Activity panel is rebuilt on `@rozenite/ui`, so it follows the DevTools +light and dark themes instead of always rendering dark. Every feature stays: the +request list with filters, sorting and selection, the timeline, the side panel with +headers, request and response bodies, cookies, initiator, timing and WebSocket and +SSE messages, response overrides, and copying a request as cURL or `fetch`. The +side panel and the columns of the request list are now resizable, the list shows an +empty state, rows can be picked with the keyboard, and failed requests read as +errors. The Timing tab now shows the request's real start and end times. diff --git a/packages/network-activity-plugin/package.json b/packages/network-activity-plugin/package.json index 046107a9d..a0b727836 100644 --- a/packages/network-activity-plugin/package.json +++ b/packages/network-activity-plugin/package.json @@ -42,18 +42,13 @@ "@rozenite/agent-bridge": "workspace:*", "@rozenite/agent-shared": "workspace:*", "@rozenite/plugin-bridge": "workspace:*", + "@rozenite/ui": "workspace:*", "base64-js": "^1.5.1", "nanoevents": "^9.1.0" }, "devDependencies": { "@floating-ui/react": "^0.26.0", - "@radix-ui/react-dropdown-menu": "^2.1.15", - "@radix-ui/react-scroll-area": "^1.2.9", - "@radix-ui/react-separator": "^1.1.7", - "@radix-ui/react-slot": "^1.2.3", - "@radix-ui/react-tabs": "^1.1.12", "@rozenite/test-utils": "workspace:*", - "@rozenite/ui": "workspace:*", "@rozenite/vite-plugin": "workspace:*", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.0.0", @@ -62,8 +57,6 @@ "@testing-library/react": "^16.1.0", "@types/react": "catalog:", "@types/react-dom": "catalog:", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", "lucide-react": "^0.263.1", "proxy-memoize": "^3.0.1", "react": "catalog:", @@ -74,8 +67,6 @@ "react-native-web": "^0.21.2", "react-virtuoso": "^4.6.0", "rozenite": "workspace:*", - "tailwind-merge": "^3.3.1", - "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", "vite": "catalog:", "zustand": "^5.0.6" diff --git a/packages/network-activity-plugin/rozenite.config.ts b/packages/network-activity-plugin/rozenite.config.ts index 863b0460f..2f1327aeb 100644 --- a/packages/network-activity-plugin/rozenite.config.ts +++ b/packages/network-activity-plugin/rozenite.config.ts @@ -1,3 +1,937 @@ +// Dev host fixtures for `rozenite dev`. They replay the messages the device +// side (`src/react-native/**`) sends, so the panel renders a realistic mix of +// traffic without a running app. Kept inline, like `packages/storage-plugin`'s +// dev fixtures: this file is evaluated without `require`, so only type-only +// imports are allowed. + +type DevFlowMessage = { + id: string; + direction: 'in' | 'out'; + date: string; + type: string; + payload: unknown; +}; + +type DevFlowMessageMatcher = + | string + | ((message: DevFlowMessage) => boolean) + | { + type?: string; + direction?: DevFlowMessage['direction']; + predicate?: (message: DevFlowMessage) => boolean; + }; + +type DevFlowContext = { + signal: AbortSignal; + send: (type: string, payload: unknown) => void; + onMessage: ( + matcher: DevFlowMessageMatcher, + listener: (message: DevFlowMessage) => void, + ) => { remove: () => void }; + waitForMessage: ( + matcher: DevFlowMessageMatcher, + options?: { timeoutMs?: number }, + ) => Promise; +}; + +type Headers = Record; + +type ResponseBody = + | string + | { kind: 'binary'; base64: string } + | { kind: 'binary-too-large'; size: number } + | null; + +type HttpFixture = { + id: string; + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD'; + url: string; + /** Milliseconds after the start of the capture window. */ + at: number; + type?: 'Fetch' | 'XHR' | 'Other'; + source?: 'builtin' | 'expo' | 'nitro'; + requestHeaders?: Headers; + postData?: unknown; + outcome: 'finished' | 'failed' | 'pending' | 'loading'; + status?: number; + statusText?: string; + responseHeaders?: Headers; + contentType?: string; + body?: ResponseBody; + ttfb?: number; + duration?: number; + error?: string; + /** Percentage downloaded for `loading` requests. */ + progress?: number; + initiator?: unknown; +}; + +const API = 'https://api.shop.dev'; +const CAPTURE_WINDOW_MS = 14000; + +const initiator = ( + functionName: string, + file: string, + lineNumber: number, + columnNumber: number, + extraFrames: Array<{ functionName: string; file: string; line: number; column: number }> = [], +) => ({ + type: 'script', + symbolicationStatus: 'complete', + functionName, + url: `file:///Users/dev/shop/${file}`, + lineNumber, + columnNumber, + generatedUrl: 'http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false', + generatedLineNumber: 120000 + lineNumber * 7, + generatedColumnNumber: columnNumber + 4, + codeFrame: { + fileName: file, + location: { row: lineNumber, column: columnNumber }, + content: [ + ` ${lineNumber - 2} | export async function ${functionName}() {`, + ` ${lineNumber - 1} | const headers = await getAuthHeaders();`, + `> ${lineNumber} | const response = await fetch(url, { headers });`, + ` | ${' '.repeat(columnNumber)}^`, + ` ${lineNumber + 1} | return response.json();`, + ` ${lineNumber + 2} | }`, + ].join('\n'), + }, + stack: [ + { + functionName, + url: `file:///Users/dev/shop/${file}`, + lineNumber, + columnNumber, + generatedUrl: 'http://localhost:8081/index.bundle?platform=ios&dev=true&minify=false', + generatedLineNumber: 120000 + lineNumber * 7, + generatedColumnNumber: columnNumber + 4, + }, + ...extraFrames.map((frame) => ({ + functionName: frame.functionName, + url: `file:///Users/dev/shop/${frame.file}`, + lineNumber: frame.line, + columnNumber: frame.column, + })), + ], +}); + +const json = (value: unknown) => JSON.stringify(value); + +const products = Array.from({ length: 6 }, (_, index) => ({ + id: `sku_${1001 + index}`, + name: [ + 'Trail Runner 2', + 'Ridge Shell Jacket', + 'Summit Pack 28L', + 'Merino Base Layer', + 'Gaiters', + 'Headlamp 400', + ][index], + price: { amount: [149, 229, 119, 79, 34, 59][index], currency: 'USD' }, + tags: ['outdoor', index % 2 ? 'sale' : 'new'], + inStock: index !== 3, + rating: [4.7, 4.5, 4.8, 4.2, 4.0, 4.6][index], +})); + +const longSearchUrl = + `${API}/v2/search/suggestions?q=trail+running+shoes+waterproof+gore-tex` + + '&filters%5Bbrand%5D=salomon,hoka,altra,la-sportiva,brooks,saucony' + + '&filters%5Bsize%5D=42&filters%5Bwidth%5D=regular&sort=relevance&locale=en-US' + + '&session=9f2c1ab4-7d1e-4e7b-9a53-0c1d2b6f84aa&utm_source=app&utm_medium=search&utm_campaign=spring'; + +const multipartPostData = { + type: 'form-data', + value: { + caption: { type: 'text', value: 'Morning trail run' }, + visibility: { type: 'text', value: 'friends' }, + photo: { type: 'binary', value: { size: 48211, type: 'image/png', name: 'trail.png' } }, + }, +}; + +const httpFixtures: HttpFixture[] = [ + { + id: 'req-products', + method: 'GET', + url: `${API}/v2/products?category=shoes&page=1&limit=24`, + at: 0, + type: 'Fetch', + requestHeaders: { + Accept: 'application/json', + Authorization: 'Bearer eyJhbGciOiJIUzI1NiJ9.demo.signature', + Cookie: 'session_id=7f3a2c19d0e4; theme=dark; ab_bucket=B', + 'User-Agent': 'ShopApp/4.2.0 CFNetwork/1494 Darwin/23.4.0', + }, + outcome: 'finished', + status: 200, + statusText: 'OK', + responseHeaders: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'private, max-age=60', + etag: 'W/"1a2b3c"', + 'set-cookie': [ + 'session_id=7f3a2c19d0e4; Path=/; Domain=.shop.dev; Max-Age=3600; HttpOnly; Secure; SameSite=Lax', + 'csrf_token=Zm9vYmFy; Path=/; Secure; SameSite=Strict', + ], + 'x-request-id': 'a9c4e1d2-55f0-4b7e', + }, + contentType: 'application/json', + body: json({ page: 1, total: 128, products }), + ttfb: 96, + duration: 142, + initiator: initiator('fetchProducts', 'src/api/products.ts', 42, 18, [ + { functionName: 'useProducts', file: 'src/hooks/useProducts.ts', line: 17, column: 9 }, + { functionName: 'ProductList', file: 'src/screens/ProductList.tsx', line: 31, column: 5 }, + ]), + }, + { + id: 'req-profile', + method: 'GET', + url: `${API}/v2/user/profile`, + at: 180, + type: 'Fetch', + requestHeaders: { + Accept: 'application/json', + Authorization: 'Bearer eyJhbGciOiJIUzI1NiJ9.demo.signature', + }, + outcome: 'finished', + status: 200, + statusText: 'OK', + responseHeaders: { 'content-type': 'application/json' }, + contentType: 'application/json', + body: json({ + id: 'usr_8841', + name: 'Ada Lovelace', + email: 'ada@example.com', + plan: 'pro', + flags: { beta: true }, + }), + ttfb: 54, + duration: 88, + initiator: initiator('fetchProfile', 'src/api/user.ts', 21, 12), + }, + { + id: 'req-image', + method: 'GET', + url: 'https://cdn.shop.dev/images/products/trail-runner-2/hero@2x.png', + at: 420, + type: 'Other', + requestHeaders: { Accept: 'image/png,image/*;q=0.8' }, + outcome: 'finished', + status: 200, + statusText: 'OK', + responseHeaders: { + 'content-type': 'image/png', + 'content-length': '13453', + 'cache-control': 'public, max-age=31536000, immutable', + }, + contentType: 'image/png', + body: { kind: 'binary', base64: '__CANVAS_PNG__' }, + ttfb: 120, + duration: 231, + initiator: { type: 'other' }, + }, + { + id: 'req-svg', + method: 'GET', + url: 'https://cdn.shop.dev/brand/logo.svg', + at: 460, + type: 'Other', + outcome: 'finished', + status: 200, + statusText: 'OK', + responseHeaders: { 'content-type': 'image/svg+xml' }, + contentType: 'image/svg+xml', + body: + '' + + '' + + '', + ttfb: 30, + duration: 47, + initiator: { type: 'other' }, + }, + { + id: 'req-order', + method: 'POST', + url: `${API}/v2/orders`, + at: 900, + type: 'Fetch', + requestHeaders: { + 'Content-Type': 'application/json', + Authorization: 'Bearer eyJhbGciOiJIUzI1NiJ9.demo.signature', + 'Idempotency-Key': '5d1c0e8e-3f0b-4c61-b0fa-2f7f8d3a9c11', + }, + postData: { + type: 'text', + value: json({ + items: [ + { sku: 'sku_1001', qty: 1 }, + { sku: 'sku_1005', qty: 2 }, + ], + shipping: { method: 'express', address: { city: 'Wroclaw', country: 'PL' } }, + coupon: null, + }), + }, + outcome: 'finished', + status: 201, + statusText: 'Created', + responseHeaders: { + 'content-type': 'application/json', + location: '/v2/orders/ord_4471', + }, + contentType: 'application/json', + body: json({ id: 'ord_4471', status: 'confirmed', total: { amount: 217, currency: 'USD' } }), + ttfb: 188, + duration: 241, + initiator: initiator('placeOrder', 'src/api/orders.ts', 58, 22, [ + { functionName: 'onCheckoutPress', file: 'src/screens/Checkout.tsx', line: 88, column: 11 }, + ]), + }, + { + id: 'req-upload', + method: 'POST', + url: `${API}/v2/uploads`, + at: 1500, + type: 'XHR', + requestHeaders: { Accept: 'application/json' }, + postData: multipartPostData, + outcome: 'finished', + status: 200, + statusText: 'OK', + responseHeaders: { 'content-type': 'application/json' }, + contentType: 'application/json', + body: json({ uploadId: 'upl_92', url: 'https://cdn.shop.dev/u/upl_92.png' }), + ttfb: 410, + duration: 622, + initiator: initiator('uploadPhoto', 'src/api/uploads.ts', 33, 16), + }, + { + id: 'req-catalog-xml', + method: 'GET', + url: `${API}/v2/catalog.xml`, + at: 1900, + type: 'Fetch', + outcome: 'finished', + status: 200, + statusText: 'OK', + responseHeaders: { 'content-type': 'application/xml' }, + contentType: 'application/xml', + body: + '\n\n' + + ' Trail Runner 2149\n' + + ' Ridge Shell Jacket229\n' + + ' \n', + ttfb: 70, + duration: 133, + initiator: initiator('fetchCatalog', 'src/api/catalog.ts', 14, 10), + }, + { + id: 'req-recommendations', + method: 'GET', + url: `${API}/v2/recommendations?user=usr_8841`, + at: 2300, + type: 'Fetch', + requestHeaders: { 'If-None-Match': 'W/"9f8e7d"' }, + outcome: 'finished', + status: 304, + statusText: 'Not Modified', + responseHeaders: { etag: 'W/"9f8e7d"', 'cache-control': 'private, max-age=300' }, + contentType: 'application/json', + body: null, + ttfb: 28, + duration: 41, + initiator: initiator('fetchRecommendations', 'src/api/recommendations.ts', 9, 14), + }, + { + id: 'req-redirect', + method: 'GET', + url: `${API}/v2/go/spring-sale`, + at: 2500, + type: 'Fetch', + outcome: 'finished', + status: 302, + statusText: 'Found', + responseHeaders: { location: '/v2/promotions/spring-sale', 'content-length': '0' }, + contentType: 'text/plain', + body: null, + ttfb: 22, + duration: 30, + initiator: initiator('openPromotion', 'src/api/promotions.ts', 27, 8), + }, + { + id: 'req-html', + method: 'GET', + url: `${API}/v2/promotions/spring-sale`, + at: 2600, + type: 'Fetch', + outcome: 'finished', + status: 200, + statusText: 'OK', + responseHeaders: { 'content-type': 'text/html; charset=utf-8' }, + contentType: 'text/html', + body: + '

Spring sale

Up to 40% off trail gear.

' + + '', + ttfb: 66, + duration: 98, + initiator: initiator('openPromotion', 'src/api/promotions.ts', 27, 8), + }, + { + id: 'req-search-long', + method: 'GET', + url: longSearchUrl, + at: 3100, + type: 'Fetch', + source: 'nitro', + requestHeaders: { Accept: 'application/json' }, + outcome: 'finished', + status: 200, + statusText: 'OK', + responseHeaders: { 'content-type': 'application/json' }, + contentType: 'application/json', + body: json({ suggestions: ['trail runner 2', 'trail running vest', 'trail running socks'] }), + ttfb: 140, + duration: 205, + initiator: initiator('fetchSuggestions', 'src/api/search.ts', 19, 20), + }, + { + id: 'req-session-401', + method: 'GET', + url: `${API}/v2/session/refresh`, + at: 3600, + type: 'Fetch', + source: 'expo', + requestHeaders: { Authorization: 'Bearer expired.token.value' }, + outcome: 'finished', + status: 401, + statusText: 'Unauthorized', + responseHeaders: { + 'content-type': 'application/json', + 'www-authenticate': 'Bearer realm="shop"', + }, + contentType: 'application/json', + body: json({ error: { code: 'token_expired', message: 'The access token expired' } }), + ttfb: 64, + duration: 79, + initiator: initiator('refreshSession', 'src/auth/session.ts', 64, 15), + }, + { + id: 'req-cart-500', + method: 'GET', + url: `${API}/v2/cart`, + at: 4400, + type: 'Fetch', + requestHeaders: { Accept: 'application/json', Cookie: 'session_id=7f3a2c19d0e4' }, + outcome: 'finished', + status: 500, + statusText: 'Internal Server Error', + responseHeaders: { 'content-type': 'application/json', 'x-request-id': '7f3a2c19-d0e4' }, + contentType: 'application/json', + body: json({ error: 'upstream_timeout', requestId: '7f3a2c19-d0e4', retryable: true }), + ttfb: 186, + duration: 212, + initiator: initiator('fetchCart', 'src/api/cart.ts', 12, 18), + }, + { + id: 'req-cart-put', + method: 'PUT', + url: `${API}/v2/cart/items/sku_1001`, + at: 5200, + type: 'Fetch', + requestHeaders: { 'Content-Type': 'application/json' }, + postData: { type: 'text', value: json({ qty: 3 }) }, + outcome: 'finished', + status: 200, + statusText: 'OK', + responseHeaders: { 'content-type': 'application/json' }, + contentType: 'application/json', + body: json({ sku: 'sku_1001', qty: 3 }), + ttfb: 80, + duration: 119, + initiator: initiator('updateCartItem', 'src/api/cart.ts', 40, 14), + }, + { + id: 'req-wishlist-patch', + method: 'PATCH', + url: `${API}/v2/wishlist/lists/lst_12`, + at: 5600, + type: 'XHR', + requestHeaders: { 'Content-Type': 'application/json' }, + postData: { type: 'text', value: json({ name: 'Spring run' }) }, + outcome: 'finished', + status: 204, + statusText: 'No Content', + responseHeaders: {}, + contentType: 'application/json', + body: null, + ttfb: 40, + duration: 55, + initiator: initiator('renameList', 'src/api/wishlist.ts', 25, 12), + }, + { + id: 'req-delete', + method: 'DELETE', + url: `${API}/v2/cart/items/sku_1005`, + at: 6400, + type: 'Fetch', + outcome: 'finished', + status: 404, + statusText: 'Not Found', + responseHeaders: { 'content-type': 'application/json' }, + contentType: 'application/json', + body: json({ error: 'not_found' }), + ttfb: 48, + duration: 61, + initiator: initiator('removeCartItem', 'src/api/cart.ts', 52, 14), + }, + { + id: 'req-pdf', + method: 'GET', + url: `${API}/v2/orders/ord_4471/invoice.pdf`, + at: 7000, + type: 'Other', + outcome: 'finished', + status: 200, + statusText: 'OK', + responseHeaders: { + 'content-type': 'application/pdf', + 'content-disposition': 'attachment; filename="invoice-4471.pdf"', + 'content-length': '288', + }, + contentType: 'application/pdf', + body: { + kind: 'binary', + base64: + 'JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSA+PgplbmRvYmoKdHJhaWxlcgo8PCAvUm9vdCAxIDAgUiA+PgolJUVPRgo=', + }, + ttfb: 150, + duration: 380, + initiator: initiator('downloadInvoice', 'src/api/orders.ts', 91, 10), + }, + { + id: 'req-video', + method: 'GET', + url: 'https://cdn.shop.dev/media/promo-spring.mp4', + at: 7600, + type: 'Other', + outcome: 'finished', + status: 200, + statusText: 'OK', + responseHeaders: { 'content-type': 'video/mp4', 'content-length': '9437184' }, + contentType: 'video/mp4', + body: { kind: 'binary-too-large', size: 9437184 }, + ttfb: 220, + duration: 1840, + initiator: { type: 'other' }, + }, + { + id: 'req-analytics-failed', + method: 'POST', + url: 'https://tracking.analytics.io/v1/collect', + at: 8200, + type: 'Fetch', + requestHeaders: { 'Content-Type': 'application/json' }, + postData: { type: 'text', value: json({ event: 'screen_view', screen: 'ProductList' }) }, + outcome: 'failed', + error: 'Network request failed', + duration: 3100, + initiator: initiator('track', 'src/analytics/track.ts', 11, 9), + }, + { + id: 'req-text', + method: 'GET', + url: `${API}/v2/health`, + at: 9000, + type: 'Fetch', + outcome: 'finished', + status: 200, + statusText: 'OK', + responseHeaders: { 'content-type': 'text/plain' }, + contentType: 'text/plain', + body: 'ok\nuptime=18d 3h\nregion=eu-central-1', + ttfb: 18, + duration: 26, + initiator: initiator('ping', 'src/api/health.ts', 6, 12), + }, + { + id: 'req-reviews-loading', + method: 'GET', + url: `${API}/v2/products/sku_1001/reviews?limit=500`, + at: 11800, + type: 'Fetch', + outcome: 'loading', + progress: 45, + status: 200, + statusText: 'OK', + responseHeaders: { 'content-type': 'application/json', 'content-length': '482113' }, + contentType: 'application/json', + ttfb: 110, + initiator: initiator('fetchReviews', 'src/api/reviews.ts', 18, 16), + }, + { + id: 'req-pending', + method: 'GET', + url: `${API}/v2/inventory/stores?lat=51.1079&lng=17.0385&radius=25`, + at: 12900, + type: 'XHR', + outcome: 'pending', + initiator: initiator('fetchStores', 'src/api/stores.ts', 30, 14), + }, +]; + +const sseFixture = { + id: 'req-sse-prices', + url: `${API}/v2/stream/prices?symbols=SKU1001,SKU1002`, + at: 6000, + messages: [ + { type: 'price', data: json({ sku: 'sku_1001', amount: 149 }), at: 6400 }, + { type: 'price', data: json({ sku: 'sku_1002', amount: 229 }), at: 7600 }, + { type: 'heartbeat', data: 'ping', at: 9000 }, + { + type: 'price', + data: json({ sku: 'sku_1001', amount: 139, reason: 'flash-sale' }), + at: 10800, + }, + { + type: 'message', + data: json({ notice: 'Stream stays open', nested: { retryMs: 3000 } }), + at: 12200, + }, + ], +}; + +const webSocketFixtures = [ + { + socketId: '1', + url: 'wss://realtime.shop.dev/socket?token=demo', + at: 800, + closedAt: null as number | null, + messages: [ + { + direction: 'sent', + data: json({ type: 'subscribe', channels: ['inventory', 'orders'] }), + at: 1000, + }, + { + direction: 'received', + data: json({ type: 'subscribed', channels: ['inventory', 'orders'] }), + at: 1090, + }, + { + direction: 'received', + data: json({ type: 'inventory.update', sku: 'sku_1001', stock: 14 }), + at: 3000, + }, + { direction: 'sent', data: 'ping', at: 6000 }, + { direction: 'received', data: 'pong', at: 6040 }, + { + direction: 'received', + data: json({ type: 'order.status', id: 'ord_4471', status: 'packed' }), + at: 10400, + }, + ], + }, + { + socketId: '2', + url: 'wss://chat.shop.dev/support', + at: 2800, + closedAt: 9400 as number | null, + messages: [ + { direction: 'sent', data: 'Hi, where is my order?', at: 3300 }, + { direction: 'received', data: 'Hello! Let me check order ord_4471 for you.', at: 3900 }, + ], + }, +]; + +// Enough traffic on the busy streams that the message tables scroll. The panel keeps the latest +// 32 messages of a connection, so these are what its tables show. +for (let index = 0; index < 70; index += 1) { + const at = 10500 + index * 45; + sseFixture.messages.push({ + type: index % 9 === 0 ? 'heartbeat' : 'price', + data: + index % 9 === 0 + ? 'ping' + : json({ sku: `sku_${1001 + (index % 6)}`, amount: 100 + index, tick: index }), + at, + }); + webSocketFixtures[0].messages.push({ + direction: index % 4 === 0 ? 'sent' : 'received', + data: + index % 4 === 0 + ? json({ type: 'ack', seq: index }) + : json({ type: 'inventory.update', sku: `sku_${1001 + (index % 6)}`, stock: 40 - index }), + at, + }); +} + +const buildPngBase64 = () => { + const canvas = document.createElement('canvas'); + canvas.width = 160; + canvas.height = 100; + const ctx = canvas.getContext('2d'); + if (!ctx) { + return ''; + } + const gradient = ctx.createLinearGradient(0, 0, 160, 100); + gradient.addColorStop(0, '#0ea5e9'); + gradient.addColorStop(1, '#f43f5e'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, 160, 100); + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + for (let x = 0; x < 160; x += 20) { + ctx.fillRect(x, 70 - (x % 40), 10, 30 + (x % 40)); + } + return canvas.toDataURL('image/png').replace(/^data:image\/png;base64,/, ''); +}; + +const runNetworkFixtures = async ({ send, onMessage, waitForMessage, signal }: DevFlowContext) => { + await waitForMessage({ type: 'get-client-ui-settings', direction: 'out' }, { timeoutMs: 10000 }); + + const start = Date.now() - CAPTURE_WINDOW_MS; + const at = (offset: number) => start + offset; + const png = buildPngBase64(); + const bodies = new Map(); + + httpFixtures.forEach((fixture) => { + const sentAt = at(fixture.at); + const body = + fixture.body && typeof fixture.body === 'object' && fixture.body.kind === 'binary' + ? { + kind: 'binary' as const, + base64: fixture.body.base64 === '__CANVAS_PNG__' ? png : fixture.body.base64, + } + : (fixture.body ?? null); + bodies.set(fixture.id, body); + + send('request-sent', { + requestId: fixture.id, + timestamp: sentAt, + type: fixture.type ?? 'Fetch', + source: fixture.source, + initiator: fixture.initiator ?? { type: 'other' }, + request: { + url: fixture.url, + method: fixture.method, + headers: fixture.requestHeaders ?? {}, + postData: fixture.postData, + }, + }); + + if (fixture.outcome === 'failed') { + send('request-failed', { + requestId: fixture.id, + timestamp: sentAt + (fixture.duration ?? 0), + type: fixture.type ?? 'Fetch', + error: fixture.error ?? 'Network request failed', + canceled: false, + source: fixture.source, + }); + return; + } + + if (fixture.outcome === 'pending') { + return; + } + + const size = typeof body === 'string' ? body.length : body && 'size' in body ? body.size : null; + const sentResponse = { + url: fixture.url, + status: fixture.status ?? 200, + statusText: fixture.statusText ?? 'OK', + headers: fixture.responseHeaders ?? {}, + contentType: fixture.contentType ?? 'application/json', + size: fixture.outcome === 'loading' ? 482113 : size, + responseTime: sentAt + (fixture.ttfb ?? 0), + }; + + send('response-received', { + requestId: fixture.id, + timestamp: sentAt + (fixture.ttfb ?? 0), + type: fixture.type ?? 'Fetch', + source: fixture.source, + response: sentResponse, + }); + + if (fixture.outcome === 'loading') { + send('request-progress', { + requestId: fixture.id, + timestamp: Date.now(), + loaded: Math.round((482113 * (fixture.progress ?? 0)) / 100), + total: 482113, + lengthComputable: true, + source: fixture.source, + }); + return; + } + + send('request-completed', { + requestId: fixture.id, + timestamp: sentAt + (fixture.duration ?? 0), + duration: fixture.duration ?? 0, + size, + ttfb: fixture.ttfb ?? 0, + source: fixture.source, + }); + }); + + // Server-sent events. The device side first records the request, then + // reports the stream as open. + send('request-sent', { + requestId: sseFixture.id, + timestamp: at(sseFixture.at), + type: 'Other', + initiator: initiator('subscribePrices', 'src/api/prices.ts', 22, 14), + request: { + url: sseFixture.url, + method: 'GET', + headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache' }, + }, + }); + send('sse-open', { + type: 'sse-open', + requestId: sseFixture.id, + timestamp: at(sseFixture.at + 90), + response: { + url: sseFixture.url, + status: 200, + statusText: 'OK', + headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }, + contentType: 'text/event-stream', + size: 0, + responseTime: at(sseFixture.at + 90), + }, + }); + sseFixture.messages.forEach((message) => { + send('sse-message', { + type: 'sse-message', + requestId: sseFixture.id, + timestamp: at(message.at), + payload: { type: message.type, data: message.data }, + }); + }); + + // WebSockets. + webSocketFixtures.forEach((socket) => { + send('websocket-connect', { + type: 'websocket-connect', + url: socket.url, + socketId: socket.socketId, + timestamp: at(socket.at), + protocols: ['v1.shop'], + options: [], + }); + send('websocket-open', { + type: 'websocket-open', + url: socket.url, + socketId: socket.socketId, + timestamp: at(socket.at + 120), + }); + socket.messages.forEach((message) => { + send(message.direction === 'sent' ? 'websocket-message-sent' : 'websocket-message-received', { + type: + message.direction === 'sent' ? 'websocket-message-sent' : 'websocket-message-received', + url: socket.url, + socketId: socket.socketId, + timestamp: at(message.at), + data: message.data, + messageType: 'text', + }); + }); + if (socket.closedAt !== null) { + send('websocket-close', { + type: 'websocket-close', + url: socket.url, + socketId: socket.socketId, + timestamp: at(socket.closedAt), + code: 1000, + reason: 'Normal closure', + }); + } + }); + + send('websocket-connect', { + type: 'websocket-connect', + url: 'wss://legacy.shop.dev/feed', + socketId: '3', + timestamp: at(8800), + protocols: null, + options: [], + }); + send('websocket-error', { + type: 'websocket-error', + url: 'wss://legacy.shop.dev/feed', + socketId: '3', + timestamp: at(9300), + error: 'Connection refused', + }); + + // Serve response bodies on demand, like the device does. + onMessage({ type: 'get-response-body', direction: 'out' }, (message) => { + const { requestId } = message.payload as { requestId: string }; + send('response-body', { requestId, body: bodies.get(requestId) ?? null }); + }); + + await new Promise((resolve) => { + signal.addEventListener('abort', () => resolve(), { once: true }); + }); + + return { requests: httpFixtures.length + 1, webSockets: webSocketFixtures.length + 1 }; +}; + +// A burst of requests, for measuring how the panel copes with a long session. The device keeps +// sending while the panel renders, so the requests go out in batches. +const runBurst = async ({ send, signal }: DevFlowContext) => { + const total = 600; + const batch = 50; + const start = Date.now() - 120000; + + for (let first = 0; first < total; first += batch) { + for (let index = first; index < Math.min(first + batch, total); index += 1) { + const requestId = `burst-${index}`; + const url = `${API}/v2/items/${index}?page=${index % 7}&sort=name`; + const sentAt = start + index * 200; + + send('request-sent', { + requestId, + timestamp: sentAt, + type: 'Fetch', + initiator: { type: 'other' }, + request: { url, method: 'GET', headers: { Accept: 'application/json' } }, + }); + send('response-received', { + requestId, + timestamp: sentAt + 40, + type: 'Fetch', + response: { + url, + status: index % 17 === 0 ? 500 : 200, + statusText: 'OK', + headers: { 'content-type': 'application/json' }, + contentType: 'application/json', + size: 128, + responseTime: sentAt + 40, + }, + }); + send('request-completed', { + requestId, + timestamp: sentAt + 90, + duration: 90, + size: 128, + ttfb: 40, + }); + } + + if (signal.aborted) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + return { requests: total }; +}; + export default { integrations: ['react-native'], panels: [ @@ -6,4 +940,29 @@ export default { source: './src/ui/App.tsx', }, ], + dev: { + presets: [ + { + name: 'Show URL as request name', + type: 'client-ui-settings', + payload: { settings: { showUrlAsName: true } }, + }, + { + name: 'Device reports recording stopped', + type: 'recording-state', + payload: { isRecording: false }, + }, + ], + flows: [ + { + name: 'Populate Network Activity', + autoRun: true, + run: runNetworkFixtures, + }, + { + name: 'Burst 600 requests', + run: runBurst, + }, + ], + }, }; diff --git a/packages/network-activity-plugin/src/ui/components/Badge.tsx b/packages/network-activity-plugin/src/ui/components/Badge.tsx deleted file mode 100644 index 56aa4cde7..000000000 --- a/packages/network-activity-plugin/src/ui/components/Badge.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import * as React from 'react'; -import { cva, type VariantProps } from 'class-variance-authority'; - -import { cn } from '../utils/cn'; - -const badgeVariants = cva( - 'inline-flex items-center rounded-sm border px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', - { - variants: { - variant: { - default: 'border-transparent bg-primary text-primary-foreground hover:bg-primary/80', - secondary: - 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', - destructive: - 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', - outline: 'text-foreground', - }, - }, - defaultVariants: { - variant: 'default', - }, - }, -); - -export interface BadgeProps - extends React.HTMLAttributes, VariantProps {} - -function Badge({ className, variant, ...props }: BadgeProps) { - return
; -} - -export { Badge, badgeVariants }; diff --git a/packages/network-activity-plugin/src/ui/components/Button.tsx b/packages/network-activity-plugin/src/ui/components/Button.tsx deleted file mode 100644 index 1a84fce97..000000000 --- a/packages/network-activity-plugin/src/ui/components/Button.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import * as React from 'react'; -import { Slot } from '@radix-ui/react-slot'; -import { cva, type VariantProps } from 'class-variance-authority'; - -import { cn } from '../utils/cn'; - -const buttonVariants = cva( - 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', - { - variants: { - variant: { - default: 'bg-primary text-primary-foreground hover:bg-primary/90', - destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', - outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground', - secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', - ghost: 'hover:bg-gray-700 hover:text-gray-100', - link: 'text-primary underline-offset-4 hover:underline', - }, - size: { - default: 'h-10 px-4 py-2', - xs: 'h-7 rounded-md px-2 text-xs', - sm: 'h-9 rounded-md px-3', - lg: 'h-11 rounded-md px-8', - icon: 'h-10 w-10', - }, - }, - defaultVariants: { - variant: 'default', - size: 'default', - }, - }, -); - -export interface ButtonProps - extends React.ButtonHTMLAttributes, VariantProps { - asChild?: boolean; -} - -const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : 'button'; - return ( - - ); - }, -); -Button.displayName = 'Button'; - -export { Button, buttonVariants }; diff --git a/packages/network-activity-plugin/src/ui/components/CodeBlock.tsx b/packages/network-activity-plugin/src/ui/components/CodeBlock.tsx index 388f55ce4..3ed94a6fc 100644 --- a/packages/network-activity-plugin/src/ui/components/CodeBlock.tsx +++ b/packages/network-activity-plugin/src/ui/components/CodeBlock.tsx @@ -1,6 +1,6 @@ import { HTMLProps, useMemo } from 'react'; import { Virtuoso } from 'react-virtuoso'; -import { cn } from '../utils/cn'; +import { cn } from '@rozenite/ui'; export type CodeBlockProps = HTMLProps; @@ -11,7 +11,7 @@ export type CodeBlockProps = HTMLProps; const VIRTUALIZATION_THRESHOLD = 50_000; const codeBlockClassNames = - 'text-sm font-mono text-gray-300 whitespace-pre-wrap bg-gray-800 p-3 rounded-md border border-gray-700 overflow-x-auto wrap-anywhere'; + 'overflow-x-auto border border-border bg-muted p-3 font-mono text-xs whitespace-pre-wrap text-foreground wrap-anywhere'; export const CodeBlock = ({ children, className, ...props }: CodeBlockProps) => { // Only string children are eligible for virtualization. Component diff --git a/packages/network-activity-plugin/src/ui/components/CodeEditor.tsx b/packages/network-activity-plugin/src/ui/components/CodeEditor.tsx index 3a5abf037..9c0e8467b 100644 --- a/packages/network-activity-plugin/src/ui/components/CodeEditor.tsx +++ b/packages/network-activity-plugin/src/ui/components/CodeEditor.tsx @@ -12,7 +12,7 @@ export const CodeEditor = forwardRef(({ data, o contentEditable suppressContentEditableWarning className={ - 'w-full text-sm font-mono text-gray-300 whitespace-pre-wrap bg-gray-800 p-3 rounded-md border border-gray-700 overflow-x-auto wrap-anywhere ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2' + 'w-full overflow-x-auto border border-input bg-muted p-3 font-mono text-xs whitespace-pre-wrap text-foreground wrap-anywhere transition-colors duration-[120ms] outline-none hover:border-faint focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring' } onInput={onInput} > diff --git a/packages/network-activity-plugin/src/ui/components/CookieCard.tsx b/packages/network-activity-plugin/src/ui/components/CookieCard.tsx index 767f1e32f..de42c9be1 100644 --- a/packages/network-activity-plugin/src/ui/components/CookieCard.tsx +++ b/packages/network-activity-plugin/src/ui/components/CookieCard.tsx @@ -1,56 +1,49 @@ -import { Badge } from './Badge'; +import { Badge, Text } from '@rozenite/ui'; import { Cookie } from '../../shared/client'; -import { cn } from '../utils/cn'; type CookieCardProps = { cookie: Cookie; - keyClassName?: string; }; -export const CookieCard = ({ cookie, keyClassName }: CookieCardProps) => ( -
-
- {cookie.name} -
+const CookieAttribute = ({ label, value }: { label: string; value: string }) => ( +
+ + {label} + + + {value} + +
+); + +export const CookieCard = ({ cookie }: CookieCardProps) => ( +
+
+ + {cookie.name} + +
{cookie.secure && ( - + Secure )} {cookie.httpOnly && ( - + HttpOnly )}
-
{cookie.value}
-
- {cookie.domain && ( -
- Domain: {cookie.domain} -
- )} - {cookie.path && ( -
- Path: {cookie.path} -
- )} - {cookie.expires && ( -
- Expires: {cookie.expires} -
- )} - {cookie.maxAge && ( -
- Max-Age: {cookie.maxAge} -
- )} - {cookie.sameSite && ( -
- SameSite: {cookie.sameSite} -
- )} + + {cookie.value} + +
+ {cookie.domain && } + {cookie.path && } + {cookie.expires && } + {cookie.maxAge && } + {cookie.sameSite && }
); diff --git a/packages/network-activity-plugin/src/ui/components/CopyRequestDropdown.tsx b/packages/network-activity-plugin/src/ui/components/CopyRequestDropdown.tsx index fdaca84ee..363862974 100644 --- a/packages/network-activity-plugin/src/ui/components/CopyRequestDropdown.tsx +++ b/packages/network-activity-plugin/src/ui/components/CopyRequestDropdown.tsx @@ -1,16 +1,10 @@ import { useCallback } from 'react'; import { Copy, Check, ChevronDown } from 'lucide-react'; -import { Button } from './Button'; +import { Button, Menu } from '@rozenite/ui'; import { generateFetchCall } from '../utils/generateFetchCall'; import { generateCurlCommand } from '../utils/generateCurlCommand'; import { useCopyToClipboard } from '../hooks/useCopyToClipboard'; import { HttpNetworkEntry, SSENetworkEntry } from '../state/model'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from './DropdownMenu'; import { checkRequestBodyBinary } from '../utils/checkRequestBodyBinary'; type NetworkEntry = HttpNetworkEntry | SSENetworkEntry; @@ -66,27 +60,21 @@ export const CopyRequestDropdown = ({ selectedRequest }: CopyDropdownProps) => { } return ( - - - - - - {filteredCopyOptions.map((option) => { - return ( - handleCopy(option)} - className="cursor-pointer" - key={option.id} - > - {option.label} - - ); - })} - - + + } + > + {isCopied ? : } + Copy as ... + + + + {filteredCopyOptions.map((option) => ( + handleCopy(option)} key={option.id}> + {option.label} + + ))} + + ); }; diff --git a/packages/network-activity-plugin/src/ui/components/DropdownMenu.tsx b/packages/network-activity-plugin/src/ui/components/DropdownMenu.tsx deleted file mode 100644 index bc1d26f9e..000000000 --- a/packages/network-activity-plugin/src/ui/components/DropdownMenu.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import * as React from 'react'; -import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; - -import { cn } from '../utils/cn'; - -const DropdownMenu = DropdownMenuPrimitive.Root; - -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; - -const DropdownMenuGroup = DropdownMenuPrimitive.Group; - -const DropdownMenuPortal = DropdownMenuPrimitive.Portal; - -const DropdownMenuSub = DropdownMenuPrimitive.Sub; - -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; - -const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, children, ...props }, ref) => ( - - {children} - -)); -DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; - -const DropdownMenuSubContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName; - -const DropdownMenuContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - - - -)); -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; - -const DropdownMenuItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, ...props }, ref) => ( - -)); -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; - -const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, checked, ...props }, ref) => ( - - - - - - - - - {children} - -)); -DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName; - -const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - - - - - - - {children} - -)); -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; - -const DropdownMenuLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, ...props }, ref) => ( - -)); -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; - -const DropdownMenuSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; - -const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => { - return ( - - ); -}; -DropdownMenuShortcut.displayName = 'DropdownMenuShortcut'; - -export { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup, -}; diff --git a/packages/network-activity-plugin/src/ui/components/FilterBar.tsx b/packages/network-activity-plugin/src/ui/components/FilterBar.tsx index 54baf319d..d167a8695 100644 --- a/packages/network-activity-plugin/src/ui/components/FilterBar.tsx +++ b/packages/network-activity-plugin/src/ui/components/FilterBar.tsx @@ -2,7 +2,6 @@ import { useState } from 'react'; import { autoUpdate, flip, - FloatingPortal, offset, shift, size, @@ -12,9 +11,18 @@ import { useInteractions, useRole, } from '@floating-ui/react'; -import { Input } from './Input'; -import { Button } from './Button'; -import { X, Filter, ChevronDown, Check } from 'lucide-react'; +import { Check, ChevronDown, Filter } from 'lucide-react'; +import { + Badge, + Button, + cn, + IconButton, + Input, + SearchField, + Separator, + Text, + X, +} from '@rozenite/ui'; import type { HttpMethod, NetworkEventSource } from '../../shared/client'; import { createDefaultFilter, DEFAULT_REQUEST_TYPES } from '../state/filter'; import type { AdvancedFilterState, FilterState, RequestTypeFilter } from '../state/filter'; @@ -83,24 +91,29 @@ const FilterField = ({ placeholder: string; onChange: (value: string) => void; }) => ( -