├── .vscode ├── settings.json ├── tasks.json └── launch.json ├── .eslintignore ├── css └── all.css ├── utils ├── emptyObject.ts ├── bufferToDataURL.ts ├── useDarkTheme.ts ├── Columns.ts ├── Filters.ts ├── shortenURL.ts ├── useReactInspectorTheme.ts ├── __tests__ │ ├── shortenURL.spec.ts │ └── IndexedList.spec.ts ├── Headers.tsx ├── LookupManager.ts └── IndexedList.ts ├── .prettierrc.yaml ├── bin └── wirebird.js ├── .babelrc ├── nodemon.json ├── server ├── routes │ ├── status.ts │ ├── updates.ts │ └── wsUpdates.ts ├── PubSub.ts ├── argv.ts ├── configureServer.ts └── index.ts ├── client ├── main.tsx ├── main.html ├── App.tsx └── IndexPage.tsx ├── .storybook ├── preview.js └── main.js ├── components ├── content-view │ ├── viewModes.ts │ ├── ImageView.tsx │ ├── JSONView.tsx │ ├── FormView.tsx │ ├── TextView.tsx │ ├── __tests__ │ │ └── detectType.spec.ts │ ├── XMLView.tsx │ ├── ViewModeSelect.tsx │ └── detectType.ts ├── Theme.tsx ├── toolbar │ └── ToolbarContext.ts ├── KeyValueView.tsx ├── ColumnsSelect.tsx ├── Collapsible.tsx ├── MasterDetailsLayout.tsx ├── ContentView.tsx ├── filter-controls │ ├── LookupFilterField.tsx │ └── TextFilterField.tsx ├── MasterDetailsView.tsx ├── HeadersView.tsx ├── RequestsTable.tsx ├── Toolbar.tsx └── EventDetailsView.tsx ├── tsconfig.server.json ├── redux ├── reducers.ts ├── sagas │ ├── index.ts │ ├── updates.ts │ └── filter-persistence.ts ├── store.ts ├── ducks │ ├── columns.ts │ ├── filters.ts │ └── updates.ts ├── selectors.ts └── selectors │ └── getFilteredLoggerEvents.ts ├── stories ├── 3-EventDetailsView.stories.tsx ├── 1-HeadersView.stories.tsx ├── 6-ColumnsSelect.stories.tsx ├── 8-ViewModeSelect.stories.tsx ├── 6-Toolbar.stories.tsx ├── 5-MasterDetailsLayout.stories.tsx ├── 2-RequestsTable.stories.tsx ├── 4-MasterDetailView.stories.tsx └── data │ └── loggerEvents.ts ├── tsconfig.json ├── .github └── workflows │ ├── npm-publish.yml │ └── npm-test.yml ├── .eslintrc.yml ├── Readme.md ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── tester.js ├── services ├── filters-storage.ts └── updates.ts ├── .gitignore ├── package.json └── jest.config.ts /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .next 2 | server-dist 3 | *.js -------------------------------------------------------------------------------- /css/all.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | } 4 | -------------------------------------------------------------------------------- /utils/emptyObject.ts: -------------------------------------------------------------------------------- 1 | export const emptyObject = {}; 2 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | tabWidth: 4 2 | singleQuote: true 3 | trailingComma: es5 4 | -------------------------------------------------------------------------------- /bin/wirebird.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('../server-dist').main(); 4 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["server", "static"], 3 | "exec": "ts-node --project tsconfig.server.json server/index.ts", 4 | "ext": "js ts" 5 | } 6 | -------------------------------------------------------------------------------- /server/routes/status.ts: -------------------------------------------------------------------------------- 1 | import { RouteHandlerMethod } from 'fastify'; 2 | 3 | export const statusHandler: RouteHandlerMethod = (_, res) => { 4 | res.send({ status: 'ok' }); 5 | }; 6 | -------------------------------------------------------------------------------- /utils/bufferToDataURL.ts: -------------------------------------------------------------------------------- 1 | export const bufferToDataURL = (mimeType: string, buffer: Buffer): string => { 2 | return `data:${mimeType};base64,${buffer.toString('base64')}`; 3 | }; 4 | -------------------------------------------------------------------------------- /utils/useDarkTheme.ts: -------------------------------------------------------------------------------- 1 | import useMediaQuery from '@material-ui/core/useMediaQuery'; 2 | 3 | export const useDarkTheme = (): boolean => 4 | useMediaQuery('(prefers-color-scheme: dark)'); 5 | -------------------------------------------------------------------------------- /client/main.tsx: -------------------------------------------------------------------------------- 1 | import 'regenerator-runtime'; 2 | import React from 'react'; 3 | import { render } from 'react-dom'; 4 | import App from './App'; 5 | 6 | render(, document.getElementById('app')); 7 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | 2 | export const parameters = { 3 | actions: { argTypesRegex: "^on[A-Z].*" }, 4 | controls: { 5 | matchers: { 6 | color: /(background|color)$/i, 7 | date: /Date$/, 8 | }, 9 | }, 10 | } -------------------------------------------------------------------------------- /components/content-view/viewModes.ts: -------------------------------------------------------------------------------- 1 | export const viewModes = { 2 | plain: 'Text', 3 | image: 'Image', 4 | json : 'JSON', 5 | xml : 'XML', 6 | form : 'Form', 7 | }; 8 | export type ViewMode = keyof typeof viewModes; 9 | -------------------------------------------------------------------------------- /server/routes/updates.ts: -------------------------------------------------------------------------------- 1 | import { RouteHandlerMethod } from 'fastify'; 2 | import { MonitorEvent } from 'wirebird-client'; 3 | 4 | export const updatesHandler: RouteHandlerMethod = function (req, res) { 5 | this.updateEvents.pub(req.body as MonitorEvent); 6 | res.status(201).send(null); 7 | }; 8 | -------------------------------------------------------------------------------- /utils/Columns.ts: -------------------------------------------------------------------------------- 1 | export enum Columns { 2 | name = 'Name', 3 | requestMethod = 'Method', 4 | responseStatus = 'Status', 5 | requestURL = 'URL', 6 | } 7 | 8 | export type ColumnName = keyof typeof Columns; 9 | 10 | export type ColumnsSelection = { 11 | [CName in ColumnName]?: boolean; 12 | }; 13 | -------------------------------------------------------------------------------- /utils/Filters.ts: -------------------------------------------------------------------------------- 1 | export interface Filters { 2 | pid?: number; 3 | domain?: string; 4 | search?: string; 5 | method?: string; 6 | } 7 | 8 | export const initialFilters: Filters = { 9 | pid : undefined, 10 | domain: undefined, 11 | search: undefined, 12 | method: undefined, 13 | }; 14 | -------------------------------------------------------------------------------- /tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs", 5 | "outDir": "server-dist", 6 | "sourceMap": true, 7 | "target": "es2017", 8 | "isolatedModules": false, 9 | "noEmit": false 10 | }, 11 | "include": ["server/"] 12 | } 13 | -------------------------------------------------------------------------------- /redux/reducers.ts: -------------------------------------------------------------------------------- 1 | import { slice as updatesSlice } from './ducks/updates'; 2 | import { slice as filtersState } from './ducks/filters'; 3 | import { slice as columnsState } from './ducks/columns'; 4 | 5 | export default { 6 | updates: updatesSlice.reducer, 7 | filters: filtersState.reducer, 8 | columns: columnsState.reducer, 9 | }; 10 | -------------------------------------------------------------------------------- /client/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Wirebird 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /stories/3-EventDetailsView.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { EventDetailsView } from '../components/EventDetailsView'; 3 | import loggerEvents from './data/loggerEvents'; 4 | 5 | export default { 6 | title: 'Event Details View', 7 | }; 8 | 9 | export const main: FC = () => { 10 | return ; 11 | }; 12 | -------------------------------------------------------------------------------- /server/routes/wsUpdates.ts: -------------------------------------------------------------------------------- 1 | import { WebsocketHandler } from 'fastify-websocket'; 2 | 3 | export const wsUpdatesHandler: WebsocketHandler = function (connection) { 4 | const unsub = this.updateEvents.sub((payload) => { 5 | connection.socket.send( 6 | JSON.stringify({ type: 'LOGGER_EVENT', payload }) 7 | ); 8 | }); 9 | connection.socket.on('close', unsub); 10 | }; 11 | -------------------------------------------------------------------------------- /stories/1-HeadersView.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { HeadersView } from '../components/HeadersView'; 3 | import loggerEvents from './data/loggerEvents'; 4 | 5 | export default { 6 | title: 'Headers View', 7 | }; 8 | 9 | export const main: FC = () => ; 10 | export const erraneous: FC = () => ; 11 | -------------------------------------------------------------------------------- /server/PubSub.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'ws'; 2 | 3 | export class PubSub { 4 | private events = new EventEmitter(); 5 | pub(data: T): void { 6 | this.events.emit('data', data); 7 | } 8 | sub(cb: (data: T) => void): () => void { 9 | this.events.on('data', cb); 10 | return () => { 11 | this.events.off('data', cb); 12 | }; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /redux/sagas/index.ts: -------------------------------------------------------------------------------- 1 | import { SagaIterator } from 'redux-saga'; 2 | import { all, AllEffect } from 'redux-saga/effects'; 3 | import { FiltersStorageImpl } from '../../services/filters-storage'; 4 | import filterPersistence from './filter-persistence'; 5 | import updates from './updates'; 6 | 7 | export default function* root(): Generator> { 8 | yield all([updates(), filterPersistence(new FiltersStorageImpl())()]); 9 | } 10 | -------------------------------------------------------------------------------- /stories/6-ColumnsSelect.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState } from 'react'; 2 | import { ColumnsSelect } from '../components/ColumnsSelect'; 3 | import { ColumnsSelection } from '../utils/Columns'; 4 | 5 | export default { 6 | title: 'ColumnsSelect', 7 | }; 8 | 9 | export const main: FC = () => { 10 | const [value, setValue] = useState({ name: true }); 11 | return ; 12 | }; 13 | -------------------------------------------------------------------------------- /stories/8-ViewModeSelect.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState } from 'react'; 2 | import { ViewMode } from '../components/content-view/viewModes'; 3 | import { ViewModeSelect } from '../components/content-view/ViewModeSelect'; 4 | 5 | export default { 6 | title: 'ViewModeSelect', 7 | }; 8 | 9 | export const main: FC = () => { 10 | const [value, setValue] = useState('json'); 11 | return ; 12 | }; 13 | -------------------------------------------------------------------------------- /server/argv.ts: -------------------------------------------------------------------------------- 1 | import yargs from 'yargs'; 2 | 3 | export interface Argv { 4 | headless: boolean; 5 | port: number; 6 | } 7 | 8 | export const argv = (): Argv => 9 | yargs.env().options({ 10 | headless: { 11 | alias : 'H', 12 | type : 'boolean', 13 | default : false, 14 | describe: 'Do not open browser', 15 | }, 16 | port: { 17 | alias : 'p', 18 | type : 'number', 19 | default: 4380, 20 | }, 21 | }).argv; 22 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: [ 3 | '../stories/**/*.stories.mdx', 4 | '../stories/**/*.stories.@(js|jsx|ts|tsx)', 5 | ], 6 | addons: ['@storybook/addon-links', '@storybook/addon-essentials'], 7 | 8 | babel: async (options) => ({ 9 | ...options, 10 | presets: [ 11 | ['@babel/preset-env', { shippedProposals: true }], 12 | '@babel/preset-typescript', 13 | '@babel/preset-react', 14 | ], 15 | plugins: ['@babel/plugin-transform-typescript', ...options.plugins], 16 | }), 17 | }; 18 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "lint", 7 | "problemMatcher": ["$eslint-stylish"], 8 | "label": "npm: lint", 9 | "detail": "eslint ." 10 | }, 11 | { 12 | "type": "npm", 13 | "script": "dev:watch", 14 | "problemMatcher": ["$tsc-watch"], 15 | "label": "npm-dev-watch", 16 | "detail": "Compile everything in a watch mode", 17 | "isBackground": true 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /utils/shortenURL.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'url'; 2 | import { basename } from 'path'; 3 | 4 | export function shortenURL(input: string): string { 5 | const u = parse(input); 6 | const { search, protocol, host, pathname } = u; 7 | 8 | if (protocol === 'data:') { 9 | return input.substr(0, 200); 10 | } 11 | 12 | const pathBaseName = pathname ? basename(pathname) : null; 13 | 14 | if (pathBaseName && pathBaseName !== '') { 15 | return `${pathBaseName}${search ?? ''}`; 16 | } 17 | if (host) { 18 | return host; 19 | } 20 | return 'unknown'; 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve" 16 | }, 17 | "exclude": ["node_modules"], 18 | "include": ["**/*.ts", "**/*.tsx"] 19 | } 20 | -------------------------------------------------------------------------------- /components/content-view/ImageView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Grid from '@material-ui/core/Grid'; 3 | import { FC } from 'react'; 4 | import { bufferToDataURL } from '../../utils/bufferToDataURL'; 5 | 6 | export interface IImageViewProps { 7 | contentType: string; 8 | data: Buffer; 9 | } 10 | 11 | export const ImageView: FC = ({ contentType, data }) => { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | ); 19 | }; 20 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish on Release 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v1 13 | with: 14 | node-version: 14 15 | registry-url: https://registry.npmjs.org/ 16 | - run: yarn 17 | - run: yarn lint 18 | - run: yarn test 19 | - run: yarn publish --access=public 20 | env: 21 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 22 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "program": "${workspaceFolder}/server-dist/index.js", 12 | "preLaunchTask": "npm-dev-watch", 13 | "outFiles": ["${workspaceFolder}/server-dist/**/*.js"], 14 | "console": "integratedTerminal" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /components/Theme.tsx: -------------------------------------------------------------------------------- 1 | import { createTheme } from '@material-ui/core/styles'; 2 | import ThemeProvider from '@material-ui/styles/ThemeProvider'; 3 | import React, { FC, useMemo } from 'react'; 4 | import { useDarkTheme } from '../utils/useDarkTheme'; 5 | 6 | export const Theme: FC = ({ children }) => { 7 | const isDarkTheme = useDarkTheme(); 8 | const theme = useMemo( 9 | () => 10 | createTheme({ 11 | palette: { 12 | type: isDarkTheme ? 'dark' : 'light', 13 | }, 14 | }), 15 | [isDarkTheme] 16 | ); 17 | 18 | return {children}; 19 | }; 20 | -------------------------------------------------------------------------------- /utils/useReactInspectorTheme.ts: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { 3 | chromeDark, 4 | chromeLight, 5 | InspectorThemeDefinition, 6 | } from 'react-inspector'; 7 | import { useDarkTheme } from './useDarkTheme'; 8 | 9 | export const useReactInspectorTheme = (): InspectorThemeDefinition => { 10 | const isDarkTheme = useDarkTheme(); 11 | return useMemo(() => { 12 | const theme = isDarkTheme ? chromeDark : chromeLight; 13 | return { 14 | ...theme, 15 | BASE_FONT_SIZE : '14px', 16 | TREENODE_FONT_SIZE : '14px', 17 | BASE_BACKGROUND_COLOR: 'inherit', 18 | }; 19 | }, [isDarkTheme]); 20 | }; 21 | -------------------------------------------------------------------------------- /stories/6-Toolbar.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState } from 'react'; 2 | import { Toolbar } from '../components/Toolbar'; 3 | import { Filters } from '../utils/Filters'; 4 | 5 | export default { 6 | title: 'Toolbar View', 7 | }; 8 | 9 | export const main: FC = () => { 10 | const [filters, setFilters] = useState({ 11 | pid: 2, 12 | }); 13 | return ( 14 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /.github/workflows/npm-test.yml: -------------------------------------------------------------------------------- 1 | name: Test on Push 2 | 3 | on: [push] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions/setup-node@v1 11 | with: 12 | node-version: 14 13 | registry-url: https://registry.npmjs.org/ 14 | - run: yarn 15 | - run: yarn lint 16 | test: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v2 20 | - uses: actions/setup-node@v1 21 | with: 22 | node-version: 14 23 | registry-url: https://registry.npmjs.org/ 24 | - run: yarn 25 | - run: yarn test 26 | -------------------------------------------------------------------------------- /components/content-view/JSONView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FC } from 'react'; 3 | import { ObjectInspector } from 'react-inspector'; 4 | import { useReactInspectorTheme } from '../../utils/useReactInspectorTheme'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | const tryParseJSON = (buf: Buffer): any => { 8 | const str = buf.toString('utf8'); 9 | try { 10 | return JSON.parse(str); 11 | } catch (e) { 12 | return str; 13 | } 14 | }; 15 | 16 | export interface IJSONViewProps { 17 | data: Buffer; 18 | } 19 | 20 | export const JSONView: FC = ({ data }) => { 21 | const theme = useReactInspectorTheme(); 22 | return ; 23 | }; 24 | -------------------------------------------------------------------------------- /stories/5-MasterDetailsLayout.stories.tsx: -------------------------------------------------------------------------------- 1 | import { LoremIpsum } from 'lorem-ipsum'; 2 | import React, { FC } from 'react'; 3 | import { MasterDetailsLayout } from '../components/MasterDetailsLayout'; 4 | 5 | export default { 6 | title: 'Master-Details Layout', 7 | }; 8 | 9 | const lorem = new LoremIpsum({ 10 | sentencesPerParagraph: { 11 | max: 8, 12 | min: 4, 13 | }, 14 | wordsPerSentence: { 15 | max: 16, 16 | min: 4, 17 | }, 18 | }); 19 | 20 | const left = lorem.generateParagraphs(50); 21 | const right = lorem.generateParagraphs(10); 22 | // const right = null; 23 | const toolbar = lorem.generateSentences(2); 24 | 25 | export const main: FC = () => { 26 | return ; 27 | }; 28 | -------------------------------------------------------------------------------- /utils/__tests__/shortenURL.spec.ts: -------------------------------------------------------------------------------- 1 | import { shortenURL } from '../shortenURL'; 2 | 3 | describe('shortenURL', () => { 4 | it('should shorten URLs', () => { 5 | expect(shortenURL('https://www.fillmurray.com/250/250')).toEqual('250'); 6 | 7 | expect( 8 | shortenURL('data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D') 9 | ).toEqual('data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D'); 10 | 11 | expect(shortenURL('https://example.com/')).toEqual('example.com'); 12 | expect(shortenURL('https://example.com/hello')).toEqual('hello'); 13 | expect(shortenURL('https://example.com/hello.js')).toEqual('hello.js'); 14 | expect(shortenURL('https://example.com/hello.js?a=b')).toEqual( 15 | 'hello.js?a=b' 16 | ); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /components/content-view/FormView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import qs from 'querystring'; 3 | import { FC } from 'react'; 4 | import { ObjectInspector } from 'react-inspector'; 5 | import { useReactInspectorTheme } from '../../utils/useReactInspectorTheme'; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | const tryParseForm = (buf: Buffer): any => { 9 | const str = buf.toString('utf8'); 10 | try { 11 | return qs.parse(str); 12 | } catch (e) { 13 | return str; 14 | } 15 | }; 16 | 17 | export interface IFormViewProps { 18 | data: Buffer; 19 | } 20 | 21 | export const FormView: FC = ({ data }) => { 22 | const theme = useReactInspectorTheme(); 23 | return ; 24 | }; 25 | -------------------------------------------------------------------------------- /components/content-view/TextView.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import makeStyles from '@material-ui/core/styles/makeStyles'; 3 | import { FC } from 'react'; 4 | 5 | const useStyles = makeStyles((theme) => ({ 6 | pre: { 7 | wordBreak : 'break-all', 8 | whiteSpace: 'pre-wrap', 9 | fontFamily: 'monospace', //TODO: add font 10 | fontSize : 13, 11 | color : theme.palette.getContrastText(theme.palette.background.paper), 12 | }, 13 | })); 14 | 15 | export interface ITextViewProps { 16 | data: Buffer | string; 17 | } 18 | 19 | export const TextView: FC = ({ data }) => { 20 | const classes = useStyles(); 21 | const strData = typeof data === 'string' ? data : data.toString('utf8'); 22 | return
{strData}
; 23 | }; 24 | -------------------------------------------------------------------------------- /utils/Headers.tsx: -------------------------------------------------------------------------------- 1 | import { LoggerEvent } from 'wirebird-client'; 2 | 3 | type HeaderDict = LoggerEvent['request']['headers']; 4 | type HeaderVal = HeaderDict[string]; 5 | 6 | export class Headers { 7 | private normalizedHeaders: HeaderDict; 8 | private normalizeName(name: string): string { 9 | return name.toLowerCase(); 10 | } 11 | constructor(private rawHeaders: HeaderDict) { 12 | this.normalizedHeaders = Object.entries( 13 | this.rawHeaders 14 | ).reduce( 15 | (headers, [name, value]) => ( 16 | (headers[this.normalizeName(name)] = value), headers 17 | ), 18 | {} 19 | ); 20 | } 21 | public get(name: string): HeaderVal { 22 | return this.normalizedHeaders[this.normalizeName(name)]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /components/toolbar/ToolbarContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | import { Filters } from 'react-data-grid'; 3 | import { Lookups } from '../../redux/ducks/updates'; 4 | import { ColumnsSelection } from '../../utils/Columns'; 5 | import { emptyObject } from '../../utils/emptyObject'; 6 | 7 | export interface IToolbarContextProps { 8 | lookups?: Partial; 9 | filters?: Filters; 10 | showResetFilters?: boolean; 11 | columnsSelection?: ColumnsSelection; 12 | onResetFilters?: () => void; 13 | onChangeFilters?: (value: Filters) => void; 14 | onChangeColumns?: (value: ColumnsSelection) => void; 15 | } 16 | export const ToolbarContext = createContext(emptyObject); 17 | 18 | export const useToolbarContext = (): IToolbarContextProps => 19 | useContext(ToolbarContext); 20 | -------------------------------------------------------------------------------- /redux/store.ts: -------------------------------------------------------------------------------- 1 | import { 2 | combineReducers, 3 | configureStore, 4 | getDefaultMiddleware, 5 | } from '@reduxjs/toolkit'; 6 | import createSagaMiddleware from 'redux-saga'; 7 | import reducersMap from './reducers'; 8 | import rootSaga from './sagas'; 9 | 10 | const sagaMiddleware = createSagaMiddleware(); 11 | 12 | const rootReducer = combineReducers(reducersMap); 13 | 14 | const store = configureStore({ 15 | reducer : rootReducer, 16 | middleware: [ 17 | ...getDefaultMiddleware({ 18 | serializableCheck: false, 19 | immutableCheck : false, 20 | thunk : false, 21 | }), 22 | sagaMiddleware, 23 | ], 24 | }); 25 | 26 | process.browser && sagaMiddleware.run(rootSaga); 27 | 28 | export default store; 29 | export type State = ReturnType; 30 | -------------------------------------------------------------------------------- /components/content-view/__tests__/detectType.spec.ts: -------------------------------------------------------------------------------- 1 | import { detectType } from '../detectType'; 2 | 3 | describe('detectType', function () { 4 | it('should work', function () { 5 | expect(detectType('application/json')).toEqual({ 6 | pureType: 'application/json', 7 | viewType: 'json', 8 | }); 9 | 10 | expect(detectType('application/json; charset=utf-8')).toEqual({ 11 | pureType: 'application/json', 12 | viewType: 'json', 13 | }); 14 | 15 | expect(detectType('application/json; charset=utf-8; foo=bar')).toEqual({ 16 | pureType: 'application/json', 17 | viewType: 'json', 18 | }); 19 | 20 | expect(detectType(null)).toEqual({ 21 | pureType: '', 22 | viewType: 'plain', 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /components/content-view/XMLView.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { DOMInspector } from 'react-inspector'; 3 | import { useReactInspectorTheme } from '../../utils/useReactInspectorTheme'; 4 | 5 | const tryParseXML = ( 6 | data: Buffer, 7 | contentType: DOMParserSupportedType 8 | ): Document | null => { 9 | try { 10 | const p = new DOMParser(); 11 | return p.parseFromString(data.toString('utf8'), contentType); 12 | } catch (e) { 13 | return null; 14 | } 15 | }; 16 | 17 | export interface IXMLViewProps { 18 | data: Buffer; 19 | contentType: string; 20 | } 21 | 22 | export const XMLView: FC = ({ data, contentType }) => { 23 | const theme = useReactInspectorTheme(); 24 | const doc = tryParseXML(data, contentType as DOMParserSupportedType); 25 | return doc ? : null; 26 | }; 27 | -------------------------------------------------------------------------------- /client/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import store from '../redux/store'; 4 | import { Theme } from '../components/Theme'; 5 | import makeStyles from '@material-ui/core/styles/makeStyles'; 6 | import IndexPage from './IndexPage'; 7 | 8 | const useStyles = makeStyles( 9 | (theme) => ({ 10 | root: { 11 | backgroundColor: theme.palette.background.paper, 12 | }, 13 | }), 14 | { name: 'Background' } 15 | ); 16 | 17 | const Background: FC = ({ children }) => { 18 | const classes = useStyles(); 19 | return
{children}
; 20 | }; 21 | 22 | const App: FC = () => ( 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | 32 | export default App; 33 | -------------------------------------------------------------------------------- /redux/ducks/columns.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | import { ColumnsSelection } from '../../utils/Columns'; 3 | 4 | export interface ColumnsState { 5 | columnsSelection: ColumnsSelection; 6 | } 7 | 8 | const initialState: ColumnsState = { 9 | columnsSelection: { 10 | name : true, 11 | requestMethod : true, 12 | responseStatus: true, 13 | }, 14 | }; 15 | 16 | export const slice = createSlice({ 17 | name : 'columns', 18 | initialState, 19 | reducers: { 20 | setColumnsSelection: ( 21 | state, 22 | { payload }: PayloadAction 23 | ) => ({ 24 | ...state, 25 | columnsSelection: payload, 26 | }), 27 | }, 28 | }); 29 | 30 | export const getColumnsSelection = (state: ColumnsState): ColumnsSelection => { 31 | return state.columnsSelection; 32 | }; 33 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | es6: true 3 | node: true 4 | 5 | parser: '@typescript-eslint/parser' 6 | parserOptions: 7 | project: tsconfig.json 8 | plugins: 9 | - '@typescript-eslint' 10 | extends: 11 | - 'eslint:recommended' 12 | - 'plugin:@typescript-eslint/eslint-recommended' 13 | - 'plugin:@typescript-eslint/recommended' 14 | 15 | rules: 16 | key-spacing: 17 | - warn 18 | - align: colon 19 | indent: 20 | - error 21 | - 4 22 | - { flatTernaryExpressions: true } 23 | linebreak-style: 24 | - error 25 | - unix 26 | quotes: 27 | - error 28 | - single 29 | semi: 30 | - error 31 | - always 32 | prefer-destructuring: 33 | - error 34 | object-shorthand: 35 | - error 36 | '@typescript-eslint/prefer-nullish-coalescing': 37 | - warn 38 | '@typescript-eslint/prefer-optional-chain': 39 | - warn 40 | -------------------------------------------------------------------------------- /server/configureServer.ts: -------------------------------------------------------------------------------- 1 | import fastifyStatic from 'fastify-static'; 2 | import fastifyWebsocket from 'fastify-websocket'; 3 | import { MonitorEvent } from 'wirebird-client'; 4 | import { join, resolve } from 'path'; 5 | import { PubSub } from './PubSub'; 6 | import { statusHandler } from './routes/status'; 7 | import { updatesHandler } from './routes/updates'; 8 | import { wsUpdatesHandler } from './routes/wsUpdates'; 9 | import { FastifyInstance } from 'fastify'; 10 | import { schema } from 'wirebird-client'; 11 | 12 | export const configureServer = (fastify: FastifyInstance): void => { 13 | fastify.decorate('updateEvents', new PubSub()); 14 | 15 | fastify.register(fastifyStatic, { 16 | root : resolve(join(__dirname, '..', 'client-dist')), 17 | index: ['main.html'], 18 | }); 19 | 20 | fastify.register(fastifyWebsocket); 21 | 22 | fastify.get('/status', statusHandler); 23 | fastify.post('/api/updates', { schema: { body: schema } }, updatesHandler); 24 | fastify.get('/api/updates', { websocket: true }, wsUpdatesHandler); 25 | }; 26 | -------------------------------------------------------------------------------- /components/content-view/ViewModeSelect.tsx: -------------------------------------------------------------------------------- 1 | import ToggleButton from '@material-ui/lab/ToggleButton'; 2 | import ToggleButtonGroup from '@material-ui/lab/ToggleButtonGroup'; 3 | import React, { FC, useCallback } from 'react'; 4 | import { ViewMode, viewModes } from './viewModes'; 5 | 6 | interface IViewModeSelectProps { 7 | value: ViewMode; 8 | onChange: (value: ViewMode) => void; 9 | } 10 | 11 | export const ViewModeSelect: FC = ({ 12 | value, 13 | onChange, 14 | }) => { 15 | const handleChange = useCallback((e, value) => onChange(value), [onChange]); 16 | 17 | return ( 18 | 25 | {Object.entries(viewModes).map(([mode, name]) => ( 26 | 27 | {name} 28 | 29 | ))} 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /redux/sagas/updates.ts: -------------------------------------------------------------------------------- 1 | import { MonitorEvent } from 'wirebird-client'; 2 | import { eventChannel, SagaIterator } from 'redux-saga'; 3 | import { call, put, take } from 'redux-saga/effects'; 4 | import UpdatesService from '../../services/updates'; 5 | import { slice as updatesSlice } from '../ducks/updates'; 6 | 7 | function createUpdatesChannel(updatesService: UpdatesService) { 8 | return eventChannel((emitter) => { 9 | updatesService.on('LOGGER_EVENT', (e: MonitorEvent) => { 10 | emitter(e); 11 | }); 12 | // eslint-disable-next-line @typescript-eslint/no-empty-function 13 | return () => {}; 14 | }); 15 | } 16 | 17 | export default function* updates(): SagaIterator { 18 | const updatesService = new UpdatesService(); 19 | updatesService.start(); 20 | const chan = yield call(createUpdatesChannel, updatesService); 21 | while (true) { 22 | const MonitorEvent = yield take(chan); 23 | if (MonitorEvent) { 24 | yield put(updatesSlice.actions.addLoggerEvent(MonitorEvent)); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /utils/__tests__/IndexedList.spec.ts: -------------------------------------------------------------------------------- 1 | import { IndexedList } from '../IndexedList'; 2 | 3 | describe('IndexedList', () => { 4 | interface FooBarBaz { 5 | id: string; 6 | foo?: string; 7 | } 8 | 9 | const simpleList = new IndexedList(_ => _.id); 10 | 11 | it('should init', () => { 12 | const store = simpleList.init(); 13 | expect(store).toEqual({ 14 | itemsByKey: {}, 15 | itemsKeys : [], 16 | }); 17 | }); 18 | 19 | it('should index by key', () => { 20 | let store = simpleList.init(); 21 | store = simpleList.push(store, { id: 'one' }); 22 | expect(store).toEqual({ 23 | itemsByKey: { 24 | one: { 25 | id: 'one', 26 | }, 27 | }, 28 | itemsKeys: ['one'], 29 | }); 30 | 31 | expect(simpleList.getAll(store)).toEqual([{ id: 'one' }]); 32 | expect(simpleList.getByKey(store, 'one')).toEqual({ id: 'one' }); 33 | expect(simpleList.getByKey(store, 'two')).toEqual(null); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /redux/sagas/filter-persistence.ts: -------------------------------------------------------------------------------- 1 | import { call, put, takeLatest } from '@redux-saga/core/effects'; 2 | import { PayloadAction } from '@reduxjs/toolkit'; 3 | import { SagaIterator } from 'redux-saga'; 4 | import { SagaReturnType } from 'redux-saga/effects'; 5 | import { FiltersStorage } from '../../services/filters-storage'; 6 | import { Filters } from '../../utils/Filters'; 7 | import { slice as filtersSlice } from '../ducks/filters'; 8 | 9 | export default (storage: FiltersStorage) => 10 | function* filterPersistence(): SagaIterator { 11 | const restoredFilters: SagaReturnType< 12 | FiltersStorage['load'] 13 | > = yield call([storage, storage.load]); 14 | 15 | yield put(filtersSlice.actions.restoreFilters(restoredFilters)); 16 | 17 | yield takeLatest( 18 | [ 19 | filtersSlice.actions.setFilters, 20 | filtersSlice.actions.resetFilters, 21 | ], 22 | function* (action: PayloadAction) { 23 | yield call([storage, storage.save], action.payload); 24 | } 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /redux/selectors.ts: -------------------------------------------------------------------------------- 1 | import { combineSelectors } from 'comsel'; 2 | import { createSelector } from 'reselect'; 3 | import { getColumnsSelection } from './ducks/columns'; 4 | import { getFilters, isAnyFilterSelected } from './ducks/filters'; 5 | import { 6 | getCurrentLoggerEvent, 7 | getLoggerEvents, 8 | getLookups, 9 | } from './ducks/updates'; 10 | import { getFilteredLoggerEvents } from './selectors/getFilteredLoggerEvents'; 11 | import { State } from './store'; 12 | 13 | const selectorsMap = { 14 | updates: { 15 | getLoggerEvents, 16 | getCurrentLoggerEvent, 17 | getLookups, 18 | }, 19 | filters: { 20 | getFilters, 21 | isAnyFilterSelected, 22 | }, 23 | columns: { 24 | getColumnsSelection, 25 | }, 26 | }; 27 | 28 | export const sliceSelectors = combineSelectors( 29 | selectorsMap 30 | ); 31 | 32 | export const globalSelectors = { 33 | getFilteredLoggerEvents: createSelector( 34 | sliceSelectors.filters.getFilters, 35 | sliceSelectors.updates.getLoggerEvents, 36 | getFilteredLoggerEvents 37 | ), 38 | }; 39 | -------------------------------------------------------------------------------- /stories/2-RequestsTable.stories.tsx: -------------------------------------------------------------------------------- 1 | import { action } from '@storybook/addon-actions'; 2 | import React, { FC, useState } from 'react'; 3 | import RequestsTable from '../components/RequestsTable'; 4 | import { ColumnsSelection } from '../utils/Columns'; 5 | import loggerEvents from './data/loggerEvents'; 6 | 7 | export default { 8 | title: 'Requests Table', 9 | }; 10 | const selectedColumns: ColumnsSelection = { 11 | name : true, 12 | requestMethod : true, 13 | requestURL : true, 14 | responseStatus: true, 15 | }; 16 | 17 | export const main: FC = () => ( 18 | 23 | ); 24 | 25 | export const selectable: FC = () => { 26 | const [currentRowId, setCurrentRowId] = useState(null); 27 | return ( 28 | setCurrentRowId(id)} 32 | selectedColumns={selectedColumns} 33 | /> 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /stories/4-MasterDetailView.stories.tsx: -------------------------------------------------------------------------------- 1 | import { MonitorEvent } from 'http-inspector/lib/src/SharedTypes'; 2 | import React, { FC, useState } from 'react'; 3 | import { MasterDetailsView } from '../components/MasterDetailsView'; 4 | import { ColumnsSelection } from '../utils/Columns'; 5 | import loggerEvents from './data/loggerEvents'; 6 | 7 | export default { 8 | title: 'Master-Detail View', 9 | }; 10 | 11 | const selectedColumns: ColumnsSelection = { 12 | name : true, 13 | requestMethod : true, 14 | requestURL : true, 15 | responseStatus: true, 16 | }; 17 | 18 | export const main: FC = () => { 19 | const [current, setCurrent] = useState(null); 20 | return ( 21 | 25 | setCurrent( 26 | loggerEvents.find((e) => e.request.id === id) ?? null 27 | ) 28 | } 29 | onItemDeselect={() => setCurrent(null)} 30 | selectedColumns={selectedColumns} 31 | /> 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /redux/ducks/filters.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | import { isEqual } from 'lodash'; 3 | import { Filters, initialFilters } from '../../utils/Filters'; 4 | 5 | export interface FiltersState { 6 | filters: Filters; 7 | } 8 | 9 | const initialState: FiltersState = { 10 | filters: initialFilters, 11 | }; 12 | 13 | export const slice = createSlice({ 14 | name : 'filters', 15 | initialState, 16 | reducers: { 17 | resetFilters: (state) => ({ 18 | ...state, 19 | filters: initialFilters, 20 | }), 21 | setFilters: (state, { payload }: PayloadAction) => ({ 22 | ...state, 23 | filters: payload, 24 | }), 25 | restoreFilters: (state, { payload }: PayloadAction) => ({ 26 | ...state, 27 | filters: payload, 28 | }), 29 | }, 30 | }); 31 | 32 | export const getFilters = (state: FiltersState): Filters => { 33 | return state.filters; 34 | }; 35 | 36 | export const isAnyFilterSelected = (state: FiltersState): boolean => { 37 | return !isEqual(state.filters, initialFilters); 38 | }; 39 | -------------------------------------------------------------------------------- /utils/LookupManager.ts: -------------------------------------------------------------------------------- 1 | import { produce } from 'immer'; 2 | 3 | export type LookupStore< 4 | Lookup extends Record 5 | > = { 6 | [LookupName in keyof Lookup]?: { 7 | [LookupKey: string]: Lookup[LookupName]['value']; 8 | }; 9 | }; 10 | 11 | const EMPTY = {}; 12 | 13 | export class LookupManager< 14 | T, 15 | L extends Record 16 | > { 17 | constructor(private lookupExtractor: (item: T) => L) {} 18 | init(): LookupStore { 19 | return {}; 20 | } 21 | push(store: LookupStore, item: T): LookupStore { 22 | const itemLookups = this.lookupExtractor(item); 23 | return produce(store, (store: any) => { 24 | for (const [lookupName, lookup] of Object.entries(itemLookups)) { 25 | if (!store[lookupName]) { 26 | store[lookupName] = {}; 27 | } 28 | store[lookupName][lookup.key] = lookup.value; 29 | } 30 | }); 31 | } 32 | getLookups>( 33 | store: LookupStore, 34 | lookupName: K 35 | ): Required>[K] { 36 | return store[lookupName] ? store[lookupName] : EMPTY; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Wirebird 2 | 3 | HTTP requests debugger for Node.js. 4 | Use Wirebird to inspect requests your Node.js application makes. 5 | 6 | It's very similar to Chrome DevTools, but for Node.js. 7 | 8 | ## Installation 9 | 10 | In order to sniff outgoing HTTP traffic from your Node.js application, 11 | you have to install `wirebird-client` and attach it to the inspected process. 12 | 13 | In order to view the requests you need to install `wirebird` globally and run it. 14 | 15 | ### Install Wirebird globally 16 | 17 | ```sh 18 | npm i -g wirebird 19 | ``` 20 | 21 | ### Add wirebird-client to your project 22 | 23 | ```sh 24 | npm i -D wirebird-client 25 | ``` 26 | 27 | Now it's time to attach `wirebird-client`. 28 | For example, if your project used to be run by the following command: 29 | 30 | ``` 31 | npm run dev 32 | ``` 33 | 34 | , now you need to replace it with: 35 | 36 | ``` 37 | npx wbenv ui npm run dev 38 | ``` 39 | 40 | ## Usage 41 | 42 | To start Wirebird, run: 43 | 44 | ```sh 45 | wirebird 46 | ``` 47 | 48 | You can change the default port: 49 | 50 | ```sh 51 | wirebird --port 3000 52 | # or: 53 | wirebird -p 3000 54 | ``` 55 | 56 | Also if you dont need to start browser automatically, you can disable it: 57 | 58 | ```sh 59 | wirebird --headless 60 | # or: 61 | wirebird -H 62 | ``` 63 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | import Fastify from 'fastify'; 3 | import { MonitorEvent } from 'wirebird-client'; 4 | import { address as getMyIP } from 'ip'; 5 | import openURI from 'opener'; 6 | import { argv } from './argv'; 7 | import { configureServer } from './configureServer'; 8 | import { PubSub } from './PubSub'; 9 | 10 | declare module 'fastify' { 11 | interface FastifyInstance { 12 | updateEvents: PubSub; 13 | } 14 | } 15 | 16 | export async function main(): Promise { 17 | const fastify = Fastify({}); 18 | const { headless, port } = argv(); 19 | const ip = getMyIP('public'); 20 | const listenURL = `http://0.0.0.0:${port}`; 21 | const externalURL = `http://${ip}:${port}`; 22 | const localURL = `http://localhost:${port}`; 23 | 24 | console.log(chalk`Listening at : {bold ${listenURL}}`); 25 | console.log(chalk`Open on your machine: {bold ${localURL}}`); 26 | console.log(chalk`Open from your LAN : {bold ${externalURL}}`); 27 | 28 | configureServer(fastify); 29 | await fastify.listen(port, '0.0.0.0'); 30 | if (!headless) { 31 | openURI(localURL); 32 | } 33 | } 34 | 35 | if (require.main === module) { 36 | main().catch((e) => { 37 | console.error(e.stack || e); 38 | process.exit(1); 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /components/content-view/detectType.ts: -------------------------------------------------------------------------------- 1 | const domParserTypes = [ 2 | 'application/xhtml+xml', 3 | 'application/xml', 4 | 'image/svg+xml', 5 | 'text/html', 6 | 'text/xml', 7 | ]; 8 | 9 | type ViewType = 'plain' | 'image' | 'json' | 'xml' | 'form'; 10 | 11 | const plusSuffixRegex = /\+(\w+)/g; 12 | 13 | export function detectType(contentType: string | null): { 14 | pureType: string; 15 | viewType: ViewType; 16 | } { 17 | const [pureType] = contentType?.split(';') ?? ['']; 18 | 19 | // https://trac.tools.ietf.org/html/draft-ietf-appsawg-media-type-suffix-regs-02 20 | const plusSuffices = new Set( 21 | Array.from(pureType.matchAll(plusSuffixRegex)).map(([, token]) => token) 22 | ); 23 | 24 | if (!pureType || !contentType) { 25 | return { pureType, viewType: 'plain' }; 26 | } 27 | if (contentType.startsWith('image/')) { 28 | return { pureType, viewType: 'image' }; 29 | } 30 | if (pureType === 'application/json' || plusSuffices.has('json')) { 31 | return { pureType, viewType: 'json' }; 32 | } 33 | if (domParserTypes.includes(pureType) || plusSuffices.has('xml')) { 34 | return { pureType, viewType: 'xml' }; 35 | } 36 | if (pureType === 'application/x-www-form-urlencoded') { 37 | return { pureType, viewType: 'form' }; 38 | } 39 | return { pureType, viewType: 'plain' }; 40 | } 41 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.202.5/containers/typescript-node/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Node.js version (use -bullseye variants on local arm64/Apple Silicon): 16, 14, 12, 16-bullseye, 14-bullseye, 12-bullseye, 16-buster, 14-buster, 12-buster 4 | ARG VARIANT="14-bullseye" 5 | FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT} 6 | 7 | # Add repository for GitHub CLI 8 | RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg \ 9 | && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null 10 | 11 | # [Optional] Uncomment this section to install additional OS packages. 12 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 13 | # && apt-get -y install --no-install-recommends mc 14 | 15 | # [Optional] Uncomment if you want to install an additional version of node using nvm 16 | # ARG EXTRA_NODE_VERSION=10 17 | # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 18 | 19 | # [Optional] Uncomment if you want to install more global node packages 20 | # RUN su node -c "npm install -g " 21 | -------------------------------------------------------------------------------- /tester.js: -------------------------------------------------------------------------------- 1 | const Axios = require('axios'); 2 | const qs = require('querystring'); 3 | const sleep = require('sleep-promise'); 4 | 5 | const requests = [ 6 | ['get', 'https://example.com'], 7 | ['get', 'https://httpbin.org/xml'], 8 | [ 9 | 'get', 10 | 'https://httpbin.org/get', 11 | { 12 | headers: { 13 | hello: ['foo', 'bar'], 14 | }, 15 | }, 16 | ], 17 | ['post', 'https://example.com', {}], 18 | [ 19 | 'post', 20 | 'https://example.com/form', 21 | qs.stringify({ foo: 'bar', items: [1, 2, 3] }), 22 | { 23 | headers: { 24 | 'Content-Type': 'application/x-www-form-urlencoded', 25 | }, 26 | }, 27 | ], 28 | ['get', 'https://example.com/does-not-exist'], 29 | ['get', 'https://iueugfroiruthgi-does-not-exist.com'], 30 | ['get', 'https://www.fillmurray.com/250/250'], 31 | ['get', 'https://jsonplaceholder.typicode.com/todos'], 32 | ['post', 'https://httpbin.org/post', { hello: 'world' }], 33 | ]; 34 | 35 | let currentRequest = 0; 36 | 37 | async function ping() { 38 | currentRequest++; 39 | currentRequest = currentRequest % requests.length; 40 | const [method, url, params] = requests[currentRequest]; 41 | try { 42 | await Axios[method](url, params); 43 | } catch (e) { 44 | console.log(`Error: ${e.message}`); 45 | } 46 | } 47 | 48 | async function main() { 49 | for (;;) { 50 | await ping(); 51 | await sleep(1000); 52 | } 53 | } 54 | 55 | main(); 56 | -------------------------------------------------------------------------------- /utils/IndexedList.ts: -------------------------------------------------------------------------------- 1 | import { Draft } from '@reduxjs/toolkit'; 2 | 3 | type KeyExtractorFn = (item: T) => string; 4 | 5 | type MaybeDraft = T | Draft; 6 | 7 | function undraft(value: MaybeDraft): T { 8 | return value as T; 9 | } 10 | 11 | export interface IIndexedListStore { 12 | readonly itemsKeys: string[]; 13 | readonly itemsByKey: { [key: string]: T }; 14 | } 15 | 16 | function isNotNull(v: T | null): v is T { 17 | return v !== null; 18 | } 19 | 20 | export class IndexedList { 21 | constructor(private readonly keyExtractor: KeyExtractorFn) {} 22 | init( 23 | store: MaybeDraft>> = {} 24 | ): IIndexedListStore { 25 | return { 26 | ...store, 27 | itemsKeys : [], 28 | itemsByKey: {}, 29 | }; 30 | } 31 | push( 32 | store: MaybeDraft>, 33 | item: T 34 | ): IIndexedListStore { 35 | const key = this.keyExtractor(item); 36 | store = undraft(store); 37 | return { 38 | ...store, 39 | itemsKeys : [...store.itemsKeys, key], 40 | itemsByKey: { 41 | ...store.itemsByKey, 42 | [key]: item, 43 | }, 44 | }; 45 | } 46 | getByKey(store: IIndexedListStore, key: string): T | null { 47 | return Object.prototype.hasOwnProperty.call(store.itemsByKey, key) 48 | ? store.itemsByKey[key] 49 | : null; 50 | } 51 | getAll(store: IIndexedListStore): T[] { 52 | return store.itemsKeys 53 | .map((k) => this.getByKey(store, k)) 54 | .filter(isNotNull); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /components/KeyValueView.tsx: -------------------------------------------------------------------------------- 1 | import makeStyles from '@material-ui/core/styles/makeStyles'; 2 | import Typography from '@material-ui/core/Typography'; 3 | import React, { FC } from 'react'; 4 | 5 | const useStyles = makeStyles(() => ({ 6 | root: { 7 | flexDirection: 'column', 8 | }, 9 | row: { 10 | wordBreak : 'break-all', 11 | whiteSpace: 'pre-wrap', 12 | }, 13 | rowKey: { 14 | fontSize: 13, 15 | }, 16 | rowValue: { 17 | fontFamily: 'monospace', //TODO: add font 18 | }, 19 | })); 20 | 21 | type Value = string | number | undefined; 22 | 23 | export interface KeyValue { 24 | key: string; 25 | value: Value | Value[]; 26 | } 27 | 28 | export interface IKeyValueViewProps { 29 | items: KeyValue[]; 30 | } 31 | 32 | const Pair: FC<{ k: string; v: Value }> = ({ k, v }) => { 33 | const classes = useStyles(); 34 | return ( 35 | 36 | {k}:{' '} 37 | {v} 38 | 39 | ); 40 | }; 41 | 42 | export const KeyValueView: FC = ({ items }) => { 43 | const classes = useStyles(); 44 | return ( 45 |
46 | {items.map((item, i) => 47 | Array.isArray(item.value) ? ( 48 | item.value.map((v, j) => ( 49 | 50 | )) 51 | ) : ( 52 | 53 | ) 54 | )} 55 |
56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /redux/selectors/getFilteredLoggerEvents.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'url'; 2 | import { MonitorEvent } from 'wirebird-client'; 3 | import { Filters } from '../../utils/Filters'; 4 | 5 | const searchMatch = (event: MonitorEvent, search: string): boolean => { 6 | if (event.request.url.includes(search)) { 7 | return true; 8 | } 9 | return false; 10 | }; 11 | 12 | const filterFns: { 13 | [FName in keyof Required]: ( 14 | filterValue: Filters[FName], 15 | event: MonitorEvent 16 | ) => boolean; 17 | } = { 18 | pid: (pid, event) => { 19 | return pid === undefined || event.processData.pid === pid; 20 | }, 21 | domain: (domain, event) => { 22 | return domain === undefined || parse(event.request.url).host === domain; 23 | }, 24 | search: (search, event) => { 25 | return search === undefined || searchMatch(event, search); 26 | }, 27 | method: (method, event) => { 28 | return ( 29 | method === undefined || 30 | event.request.method.toUpperCase() === method 31 | ); 32 | }, 33 | }; 34 | 35 | export const getFilteredLoggerEvents = ( 36 | filterValues: Filters, 37 | events: MonitorEvent[] 38 | ): MonitorEvent[] => 39 | events.filter((e) => { 40 | for (const filterProp of Object.keys(filterFns)) { 41 | const tsFilterProp = filterProp as keyof typeof filterFns; 42 | const tsValue = 43 | filterValues[filterProp as keyof typeof filterValues]; 44 | if ( 45 | !(filterFns[tsFilterProp] as ( 46 | f: typeof tsValue, 47 | e: MonitorEvent 48 | ) => boolean)(tsValue, e) 49 | ) { 50 | return false; 51 | } 52 | } 53 | return true; 54 | }); 55 | -------------------------------------------------------------------------------- /services/filters-storage.ts: -------------------------------------------------------------------------------- 1 | import { Filters, initialFilters } from '../utils/Filters'; 2 | 3 | const FILTERS_STORAGE_PREFIX = 'wirebird.filters.v1'; 4 | 5 | function sanitizeFilters(input: any) { 6 | if (typeof input !== 'object' && !input) { 7 | return initialFilters; 8 | } 9 | const result = { ...initialFilters }; 10 | const inputAsFilters: Filters = input; 11 | if (typeof inputAsFilters.domain === 'string') { 12 | result.domain = inputAsFilters.domain; 13 | } 14 | if (typeof inputAsFilters.method === 'string') { 15 | result.method = inputAsFilters.method; 16 | } 17 | if (typeof inputAsFilters.search === 'string') { 18 | result.search = inputAsFilters.search; 19 | } 20 | if (typeof inputAsFilters.pid === 'number') { 21 | result.pid = inputAsFilters.pid; 22 | } 23 | return result; 24 | } 25 | 26 | export interface FiltersStorage { 27 | save(filters: Filters): void; 28 | load(): Filters; 29 | } 30 | 31 | export class FiltersStorageImpl implements FiltersStorage { 32 | save(filters?: Filters): void { 33 | if (filters === undefined) { 34 | sessionStorage.removeItem(FILTERS_STORAGE_PREFIX); 35 | return; 36 | } 37 | sessionStorage.setItem(FILTERS_STORAGE_PREFIX, JSON.stringify(filters)); 38 | } 39 | load(): Filters { 40 | const serializedFilters = sessionStorage.getItem( 41 | FILTERS_STORAGE_PREFIX 42 | ); 43 | if (!serializedFilters) { 44 | return initialFilters; 45 | } 46 | let parsedFilters: Filters = initialFilters; 47 | try { 48 | parsedFilters = JSON.parse(serializedFilters); 49 | } catch (e) { 50 | return initialFilters; 51 | } 52 | return sanitizeFilters(parsedFilters); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.202.5/containers/typescript-node 3 | { 4 | "name": "Node.js & TypeScript", 5 | "runArgs": [ 6 | "--init" 7 | ], 8 | "build": { 9 | "dockerfile": "Dockerfile", 10 | // Update 'VARIANT' to pick a Node version: 16, 14, 12. 11 | // Append -bullseye or -buster to pin to an OS version. 12 | // Use -bullseye variants on local on arm64/Apple Silicon. 13 | "args": { 14 | "VARIANT": "14-bullseye" 15 | } 16 | }, 17 | // Set *default* container specific settings.json values on container create. 18 | "settings": {}, 19 | // Add the IDs of extensions you want installed when the container is created. 20 | "extensions": [ 21 | "dbaeumer.vscode-eslint", 22 | "maptz.regionfolder", 23 | "wmaurer.change-case", 24 | "nemesv.copy-file-name", 25 | "ryanluker.vscode-coverage-gutters", 26 | "jpruliere.env-autocomplete", 27 | "waderyan.gitblame", 28 | "github.vscode-pull-request-github", 29 | "eamodio.gitlens", 30 | "orta.vscode-jest", 31 | "cmstead.js-codeformer", 32 | "eg2.vscode-npm-script", 33 | "silvenga.positions", 34 | "esbenp.prettier-vscode", 35 | "2gua.rainbow-brackets", 36 | "unional.vscode-sort-package-json", 37 | "hbenl.vscode-test-explorer" 38 | ], 39 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 40 | "forwardPorts": [ 41 | 4380 42 | ], 43 | // Use 'postCreateCommand' to run commands after the container is created. 44 | // "postCreateCommand": "yarn", 45 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 46 | "remoteUser": "node", 47 | "containerEnv": { 48 | "NPM_TOKEN": "${localEnv:NPM_TOKEN}" 49 | }, 50 | "features": { 51 | "github-cli": "latest" 52 | } 53 | } -------------------------------------------------------------------------------- /components/ColumnsSelect.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MenuItem from '@material-ui/core/MenuItem'; 3 | import TextField from '@material-ui/core/TextField'; 4 | import { FC, ReactNode, useCallback, useMemo } from 'react'; 5 | import { ColumnName, Columns, ColumnsSelection } from '../utils/Columns'; 6 | 7 | interface IColumnsSelectProps { 8 | value: ColumnsSelection; 9 | onChange?: (value: ColumnsSelection) => void; 10 | label?: ReactNode; 11 | } 12 | 13 | export const ColumnsSelect: FC = ({ 14 | onChange, 15 | label, 16 | value = {}, 17 | }) => { 18 | const valueList = useMemo( 19 | () => Object.keys(Columns).filter((key) => value[key as ColumnName]), 20 | [value] 21 | ); 22 | 23 | const handleChange = useCallback( 24 | ({ target: { value } }) => { 25 | const result: ColumnsSelection = value.reduce( 26 | (res: ColumnsSelection, key: ColumnName) => { 27 | res[key] = true; 28 | return res; 29 | }, 30 | {} 31 | ); 32 | onChange?.(result); 33 | }, 34 | [onChange] 35 | ); 36 | 37 | const renderValue = (value: unknown) => { 38 | return Array.isArray(value) ? `${value.length || 'None'} selected` : ''; 39 | }; 40 | 41 | return ( 42 | 50 | {Object.entries(Columns).map(([key, label], i) => ( 51 | 52 | {label} 53 | 54 | ))} 55 | 56 | ); 57 | }; 58 | -------------------------------------------------------------------------------- /components/Collapsible.tsx: -------------------------------------------------------------------------------- 1 | import { alpha } from '@material-ui/core/styles/colorManipulator'; 2 | import makeStyles from '@material-ui/core/styles/makeStyles'; 3 | import Typography from '@material-ui/core/Typography/Typography'; 4 | import Accordion from '@material-ui/core/Accordion'; 5 | import AccordionDetails from '@material-ui/core/AccordionDetails'; 6 | import AccordionSummary from '@material-ui/core/AccordionSummary'; 7 | import React, { FC } from 'react'; 8 | 9 | const useStyles = makeStyles( 10 | () => { 11 | return { 12 | force: {}, 13 | root : { 14 | '&$force': { 15 | margin: 0, 16 | }, 17 | boxShadow: 'none', 18 | }, 19 | summary: { 20 | '$force &': { 21 | minHeight: 33, 22 | }, 23 | backgroundColor: alpha('rgb(0,0,0)', 0.07), 24 | }, 25 | summaryContent: { 26 | '$force &': { 27 | margin: 0, 28 | }, 29 | }, 30 | }; 31 | }, 32 | { name: 'Collapsible' } 33 | ); 34 | 35 | export interface ICollapsibleProps { 36 | title?: string; 37 | } 38 | 39 | export const Collapsible: FC = ({ title, children }) => { 40 | const classes = useStyles(); 41 | return ( 42 | 49 | 55 | {title} 56 | 57 | {children} 58 | 59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/node 3 | # Edit at https://www.gitignore.io/?templates=node 4 | 5 | ### Node ### 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # TypeScript v1 declaration files 50 | typings/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional REPL history 62 | .node_repl_history 63 | 64 | # Output of 'npm pack' 65 | *.tgz 66 | 67 | # Yarn Integrity file 68 | .yarn-integrity 69 | 70 | # dotenv environment variables file 71 | .env 72 | .env.test 73 | 74 | # parcel-bundler cache (https://parceljs.org/) 75 | .cache 76 | 77 | # next.js build output 78 | .next 79 | 80 | # nuxt.js build output 81 | .nuxt 82 | 83 | # vuepress build output 84 | .vuepress/dist 85 | 86 | # Serverless directories 87 | .serverless/ 88 | 89 | # FuseBox cache 90 | .fusebox/ 91 | 92 | # DynamoDB Local files 93 | .dynamodb/ 94 | 95 | # End of https://www.gitignore.io/api/node 96 | 97 | server-dist 98 | client-dist 99 | dist 100 | storybook-static 101 | -------------------------------------------------------------------------------- /components/MasterDetailsLayout.tsx: -------------------------------------------------------------------------------- 1 | import makeStyles from '@material-ui/core/styles/makeStyles'; 2 | import React, { FC } from 'react'; 3 | import SplitterLayout from 'react-splitter-layout'; 4 | import 'react-splitter-layout/lib/index.css'; 5 | 6 | const DEBUG = false; 7 | 8 | const useStyles = makeStyles( 9 | (theme) => { 10 | const toolbarHeight = theme.spacing(8); 11 | return { 12 | root: { 13 | height : 'calc(100vh - 20px)', 14 | display : 'flex', 15 | flexDirection: 'column', 16 | }, 17 | head: { 18 | backgroundColor: DEBUG ? 'rgb(92.1%, 78.2%, 49.2%)' : undefined, 19 | minHeight : toolbarHeight, 20 | }, 21 | content: { 22 | position: 'relative', 23 | flexGrow: 1, 24 | }, 25 | left: { 26 | backgroundColor: DEBUG ? 'rgb(49.2%, 92.1%, 67%)' : undefined, 27 | height : '100%', 28 | }, 29 | right: { 30 | backgroundColor: DEBUG ? 'rgb(90.6%, 49.2%, 92.1%)' : undefined, 31 | // overflow : 'auto', 32 | }, 33 | }; 34 | }, 35 | { name: 'MasterDetailsLayout' } 36 | ); 37 | 38 | export interface IMasterDetailsLayoutProps { 39 | toolbar: React.ReactNode; 40 | left: React.ReactNode; 41 | right: React.ReactNode; 42 | } 43 | 44 | export const MasterDetailsLayout: FC = ({ 45 | left, 46 | right, 47 | toolbar, 48 | }) => { 49 | const classes = useStyles(); 50 | 51 | return ( 52 |
53 |
{toolbar}
54 |
55 | 56 |
{left}
57 | {right &&
{right}
} 58 |
59 |
60 |
61 | ); 62 | }; 63 | -------------------------------------------------------------------------------- /components/ContentView.tsx: -------------------------------------------------------------------------------- 1 | import Grid from '@material-ui/core/Grid'; 2 | import makeStyles from '@material-ui/core/styles/makeStyles'; 3 | import React, { FC, useEffect, useState } from 'react'; 4 | import { detectType } from './content-view/detectType'; 5 | import { FormView } from './content-view/FormView'; 6 | import { ImageView } from './content-view/ImageView'; 7 | import { JSONView } from './content-view/JSONView'; 8 | import { TextView } from './content-view/TextView'; 9 | import { ViewMode } from './content-view/viewModes'; 10 | import { ViewModeSelect } from './content-view/ViewModeSelect'; 11 | import { XMLView } from './content-view/XMLView'; 12 | 13 | const useStyles = makeStyles((theme) => ({ 14 | contentArea: { 15 | padding: theme.spacing(2), 16 | }, 17 | })); 18 | 19 | export interface IContentViewProps { 20 | contentType: string | null; 21 | data: Buffer; 22 | } 23 | 24 | export const ContentView: FC = ({ contentType, data }) => { 25 | const detected = detectType(contentType); 26 | const [viewMode, setViewMode] = useState(detected.viewType); 27 | useEffect(() => { 28 | setViewMode(detected.viewType); 29 | }, [contentType]); 30 | const classes = useStyles(); 31 | 32 | return ( 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | {viewMode === 'plain' && } 41 | {viewMode === 'json' && } 42 | {viewMode === 'image' && contentType && ( 43 | 44 | )} 45 | {viewMode === 'xml' && contentType && ( 46 | 47 | )} 48 | {viewMode === 'form' && contentType && } 49 | 50 | 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /components/filter-controls/LookupFilterField.tsx: -------------------------------------------------------------------------------- 1 | import Box from '@material-ui/core/Box'; 2 | import MenuItem from '@material-ui/core/MenuItem'; 3 | import makeStyles from '@material-ui/core/styles/makeStyles'; 4 | import TextField from '@material-ui/core/TextField'; 5 | import React, { FC, useCallback, useMemo } from 'react'; 6 | 7 | const useStyles = makeStyles((theme) => ({ 8 | root: { 9 | minWidth: theme.spacing(10), 10 | maxWidth: theme.spacing(20), 11 | }, 12 | })); 13 | 14 | type LookupValue = string | number | undefined; 15 | 16 | export interface ILookupFilterFieldProps { 17 | onChange: (value: LookupValue) => void; 18 | value: LookupValue; 19 | label: string; 20 | lookup: { [key: string]: LookupValue }; 21 | } 22 | 23 | export const LookupFilterField: FC = ({ 24 | onChange, 25 | value, 26 | label, 27 | lookup, 28 | }) => { 29 | const handleChange = useCallback( 30 | ({ target: { value } }) => onChange(value), 31 | [onChange] 32 | ); 33 | const classes = useStyles(); 34 | //Sometimes, when filters are restored form local storage, 35 | //there is no item in the lookup which corresponds to the current filter value. 36 | //In this case, we need to add it. 37 | const lookupsWithCurrentValueAdded = useMemo( 38 | () => 39 | value === undefined ? lookup : { ...lookup, [`${value}`]: value }, 40 | [lookup, value] 41 | ); 42 | const lookupIsEfemeric = useMemo( 43 | () => 44 | value === undefined 45 | ? false 46 | : !Object.prototype.hasOwnProperty.call(lookup, value), 47 | [lookup, value] 48 | ); 49 | return ( 50 | 57 | All 58 | 59 | {Object.entries(lookupsWithCurrentValueAdded).map( 60 | ([key, value]) => ( 61 | 62 | 63 | {key} 64 | 65 | 66 | ) 67 | )} 68 | 69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /components/MasterDetailsView.tsx: -------------------------------------------------------------------------------- 1 | import makeStyles from '@material-ui/core/styles/makeStyles'; 2 | import { MonitorEvent } from 'wirebird-client'; 3 | import React, { FC, useCallback } from 'react'; 4 | import { ColumnsSelection } from '../utils/Columns'; 5 | import { emptyObject } from '../utils/emptyObject'; 6 | import { EventDetailsView } from './EventDetailsView'; 7 | import { MasterDetailsLayout } from './MasterDetailsLayout'; 8 | import RequestsTable from './RequestsTable'; 9 | import { Toolbar } from './Toolbar'; 10 | import { useToolbarContext } from './toolbar/ToolbarContext'; 11 | 12 | const useStyles = makeStyles((theme) => ({ 13 | rightPanel: { 14 | height : '100%', 15 | borderLeftWidth: 1, 16 | borderLeftStyle: 'solid', 17 | borderLeftColor: theme.palette.divider, 18 | }, 19 | })); 20 | 21 | export interface IMasterDetailsViewProps { 22 | selectedColumns?: ColumnsSelection; 23 | items: Array; 24 | currentItem?: MonitorEvent | null; 25 | onItemSelect?: (rowId: string) => void; 26 | onItemDeselect?: () => void; 27 | } 28 | 29 | export const MasterDetailsView: FC = ({ 30 | items, 31 | currentItem, 32 | onItemSelect, 33 | onItemDeselect, 34 | selectedColumns = emptyObject, 35 | }) => { 36 | const handleRowClick = useCallback( 37 | (rowId) => { 38 | onItemSelect?.(rowId); 39 | }, 40 | [onItemSelect] 41 | ); 42 | const handleDetailsClose = useCallback(() => { 43 | onItemDeselect?.(); 44 | }, [onItemDeselect]); 45 | 46 | const classes = useStyles(); 47 | const toolbar = useToolbarContext(); 48 | 49 | return ( 50 | } 52 | left={ 53 | 59 | } 60 | right={ 61 | currentItem && ( 62 |
63 | 67 |
68 | ) 69 | } 70 | /> 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /components/filter-controls/TextFilterField.tsx: -------------------------------------------------------------------------------- 1 | import IconButton from '@material-ui/core/IconButton'; 2 | import InputAdornment from '@material-ui/core/InputAdornment'; 3 | import makeStyles from '@material-ui/core/styles/makeStyles'; 4 | import TextField from '@material-ui/core/TextField'; 5 | import ClearIcon from '@material-ui/icons/Clear'; 6 | import debounce from 'lodash/debounce'; 7 | import React, { 8 | ChangeEvent, 9 | FC, 10 | useCallback, 11 | useEffect, 12 | useMemo, 13 | useState, 14 | } from 'react'; 15 | 16 | const useStyles = makeStyles((theme) => ({ 17 | root: { 18 | minWidth: theme.spacing(10), 19 | maxWidth: theme.spacing(20), 20 | }, 21 | })); 22 | 23 | export interface ITextFilterFieldProps { 24 | onChange: (value?: string) => void; 25 | value?: string; 26 | label: string; 27 | } 28 | 29 | export const TextFilterField: FC = ({ 30 | onChange, 31 | value, 32 | label, 33 | }) => { 34 | const classes = useStyles(); 35 | 36 | const [internalValue, setInternalValue] = useState(value); 37 | 38 | const debouncedCommitChange = useMemo( 39 | () => debounce((value) => onChange(value), 400), 40 | [onChange] 41 | ); 42 | 43 | const handleChange = useMemo( 44 | () => ({ target: { value } }: ChangeEvent) => { 45 | setInternalValue(value); 46 | debouncedCommitChange(value); 47 | }, 48 | [onChange] 49 | ); 50 | 51 | const handleClear = useCallback(() => { 52 | setInternalValue(''); 53 | debouncedCommitChange(''); 54 | }, [onChange]); 55 | 56 | useEffect(() => { 57 | setInternalValue(value); 58 | }, [value]); 59 | 60 | const inputProps = useMemo(() => { 61 | if (internalValue?.length) { 62 | return { 63 | endAdornment: ( 64 | 65 | 66 | 67 | 68 | 69 | ), 70 | }; 71 | } 72 | return undefined; 73 | }, [internalValue, handleClear]); 74 | 75 | return ( 76 | 83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /redux/ducks/updates.ts: -------------------------------------------------------------------------------- 1 | import { parse as parseURL } from 'url'; 2 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 3 | import { createSelector } from 'reselect'; 4 | import { MonitorEvent } from 'wirebird-client'; 5 | import { IndexedList, IIndexedListStore } from '../../utils/IndexedList'; 6 | import { LookupManager, LookupStore } from '../../utils/LookupManager'; 7 | 8 | export const indexedList = new IndexedList( 9 | (event): string => event.request.id 10 | ); 11 | 12 | const lookupExtractor = (item: MonitorEvent) => { 13 | const { pid } = item.processData; 14 | const method = item.request.method.toUpperCase(); 15 | const u = parseURL(item.request.url); 16 | const domain = u.host; 17 | 18 | return { 19 | pid: { 20 | key : `${pid}`, 21 | value: pid, 22 | }, 23 | domain: { 24 | key : `${domain}`, 25 | value: domain, 26 | }, 27 | method: { 28 | key : `${method}`, 29 | value: method, 30 | }, 31 | }; 32 | }; 33 | 34 | export type Lookups = Required>>; 35 | 36 | export const lookupManager = new LookupManager(lookupExtractor); 37 | 38 | export interface UpdatesState { 39 | lookups: LookupStore>; 40 | eventsList: IIndexedListStore; 41 | currentEventID: string | null; 42 | } 43 | 44 | const initialState: UpdatesState = { 45 | lookups : lookupManager.init(), 46 | eventsList : indexedList.init(), 47 | currentEventID: null, 48 | }; 49 | 50 | export const slice = createSlice({ 51 | name : 'updates', 52 | initialState, 53 | reducers: { 54 | addLoggerEvent: (state, { payload }: PayloadAction) => ({ 55 | ...state, 56 | eventsList: indexedList.push(state.eventsList, payload), 57 | lookups : lookupManager.push(state.lookups, payload), 58 | }), 59 | setCurrentEventID: ( 60 | state, 61 | { payload }: PayloadAction 62 | ) => ({ 63 | ...state, 64 | currentEventID: payload, 65 | }), 66 | }, 67 | }); 68 | 69 | export const getLoggerEvents = (state: UpdatesState): MonitorEvent[] => 70 | indexedList.getAll(state.eventsList); 71 | 72 | export const getCurrentLoggerEvent = ( 73 | state: UpdatesState 74 | ): MonitorEvent | null => 75 | state.currentEventID 76 | ? indexedList.getByKey(state.eventsList, state.currentEventID) 77 | : null; 78 | 79 | export const getLookups = createSelector( 80 | (state: UpdatesState) => state.lookups, 81 | (lookups: UpdatesState['lookups']): Lookups => ({ 82 | pid : lookupManager.getLookups(lookups, 'pid'), 83 | domain: lookupManager.getLookups(lookups, 'domain'), 84 | method: lookupManager.getLookups(lookups, 'method'), 85 | }) 86 | ); 87 | -------------------------------------------------------------------------------- /services/updates.ts: -------------------------------------------------------------------------------- 1 | import { SerializedLoggerEvent, MonitorEvent, validate } from 'wirebird-client'; 2 | import { EventEmitter } from 'events'; 3 | 4 | export interface UpdatesServiceEvents { 5 | on( 6 | eventName: 'LOGGER_EVENT', 7 | eventHandler: (event: MonitorEvent) => void 8 | ): void; 9 | on(eventName: 'ONLINE', eventHandler: () => void): void; 10 | emit(eventName: 'LOGGER_EVENT', event: MonitorEvent): void; 11 | emit(eventName: 'ONLINE'): void; 12 | } 13 | 14 | interface IncomingSocketMessage { 15 | type: 'LOGGER_EVENT' | 'ONLINE'; 16 | payload: any; 17 | } 18 | 19 | export default class UpdatesService 20 | extends EventEmitter 21 | implements UpdatesServiceEvents { 22 | private sock: WebSocket; 23 | 24 | private unserializeBase64(input: string | null): Buffer | null { 25 | if (!input) { 26 | return null; 27 | } 28 | return Buffer.from(input, 'base64'); 29 | } 30 | private validateLoggerEvent(event: SerializedLoggerEvent): boolean { 31 | return validate(event).valid; 32 | } 33 | private unserialiseLoggerEvent(event: SerializedLoggerEvent): MonitorEvent { 34 | if (event.response) { 35 | return { 36 | request: { 37 | ...event.request, 38 | body: this.unserializeBase64(event.request.body), 39 | }, 40 | response: { 41 | ...event.response, 42 | body: this.unserializeBase64(event.response.body), 43 | }, 44 | error : null, 45 | processData: event.processData, 46 | }; 47 | } 48 | 49 | if (event.error) { 50 | return { 51 | request: { 52 | ...event.request, 53 | body: this.unserializeBase64(event.request.body), 54 | }, 55 | response : null, 56 | error : event.error, 57 | processData: event.processData, 58 | }; 59 | } 60 | throw new Error('Error unserializing MonitorEvent'); 61 | } 62 | 63 | constructor() { 64 | super(); 65 | const { location } = document; 66 | this.sock = new WebSocket(`ws://${location.host}/api/updates`); 67 | } 68 | 69 | start(): void { 70 | this.sock.addEventListener('message', (messageEvent) => { 71 | try { 72 | const messageData = JSON.parse( 73 | messageEvent.data 74 | ) as IncomingSocketMessage; 75 | if (messageData.type === 'ONLINE') { 76 | this.emit('ONLINE'); 77 | } 78 | if (messageData.type === 'LOGGER_EVENT') { 79 | if (!this.validateLoggerEvent(messageData.payload)) { 80 | console.error('Invalid data'); 81 | } 82 | this.emit( 83 | 'LOGGER_EVENT', 84 | this.unserialiseLoggerEvent(messageData.payload) 85 | ); 86 | } 87 | } catch (e) { 88 | console.error('Error parsing socket event:', e); 89 | } 90 | }); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /components/HeadersView.tsx: -------------------------------------------------------------------------------- 1 | import { eventToCurl } from 'wirebird-client'; 2 | import { 3 | MonitorEvent, 4 | LoggerHeaders, 5 | } from 'http-inspector/lib/src/SharedTypes'; 6 | import React, { FC } from 'react'; 7 | import { Collapsible } from './Collapsible'; 8 | import { TextView } from './content-view/TextView'; 9 | import { KeyValue, KeyValueView } from './KeyValueView'; 10 | 11 | const headersToKeyValue = (headers: LoggerHeaders): KeyValue[] => 12 | Object.entries(headers).map(([key, value]) => ({ 13 | key, 14 | value, 15 | })); 16 | 17 | const getGeneralInfo = (event: MonitorEvent): KeyValue[] => { 18 | const info: KeyValue[] = []; 19 | info.push({ 20 | key : 'URL', 21 | value: event.request.url, 22 | }); 23 | info.push({ 24 | key : 'Request Method', 25 | value: event.request.method, 26 | }); 27 | if (event.response) { 28 | info.push({ 29 | key : 'Status Code', 30 | value: `${event.response.status}`, 31 | }); 32 | } 33 | if (event.request.remoteAddress) { 34 | info.push({ 35 | key : 'Remote Address', 36 | value: `${event.request.remoteAddress}`, 37 | }); 38 | } 39 | return info; 40 | }; 41 | 42 | export interface IHeadersViewProps { 43 | event: MonitorEvent; 44 | } 45 | 46 | export const HeadersView: FC = ({ 47 | event, 48 | event: { error, request, response, processData }, 49 | }) => { 50 | return ( 51 |
52 | 53 | 54 | 55 | 56 | 59 | 60 | {response && ( 61 | 62 | 65 | 66 | )} 67 | {error && ( 68 | 69 | 76 | 77 | )} 78 | 79 | 86 | 87 | 88 | 89 | 90 |
91 | ); 92 | }; 93 | -------------------------------------------------------------------------------- /client/IndexPage.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useCallback } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { bindActionCreators } from 'redux'; 4 | import { MonitorEvent } from 'wirebird-client'; 5 | import { MasterDetailsView } from '../components/MasterDetailsView'; 6 | import { 7 | IToolbarContextProps, 8 | ToolbarContext, 9 | } from '../components/toolbar/ToolbarContext'; 10 | import { slice as columnsSlice } from '../redux/ducks/columns'; 11 | import { slice as filtersSlice } from '../redux/ducks/filters'; 12 | import { Lookups, slice as updatesSlice } from '../redux/ducks/updates'; 13 | import { globalSelectors, sliceSelectors } from '../redux/selectors'; 14 | import { State } from '../redux/store'; 15 | import { ColumnsSelection } from '../utils/Columns'; 16 | import { Filters } from '../utils/Filters'; 17 | 18 | interface Props { 19 | loggerEvents: MonitorEvent[]; 20 | setCurrentEventID: typeof updatesSlice.actions.setCurrentEventID; 21 | setFilters: typeof filtersSlice.actions.setFilters; 22 | resetFilters: typeof filtersSlice.actions.resetFilters; 23 | setColumnsSelection: typeof columnsSlice.actions.setColumnsSelection; 24 | filters: Filters; 25 | isAnyFilterSelected: boolean; 26 | currentEvent: MonitorEvent | null; 27 | lookups: Lookups; 28 | columnsSelection: ColumnsSelection; 29 | } 30 | 31 | const IndexPage: FC = ({ 32 | loggerEvents, 33 | setCurrentEventID, 34 | setFilters, 35 | currentEvent, 36 | lookups, 37 | filters, 38 | isAnyFilterSelected, 39 | columnsSelection, 40 | setColumnsSelection, 41 | resetFilters, 42 | }) => { 43 | const handleItemSelect = useCallback((id) => setCurrentEventID(id), []); 44 | const handleItemDeselect = useCallback(() => setCurrentEventID(null), []); 45 | const handleChangeFilters = useCallback( 46 | (filters) => setFilters(filters), 47 | [] 48 | ); 49 | const toolbarContextProps: IToolbarContextProps = { 50 | lookups, 51 | filters, 52 | showResetFilters: isAnyFilterSelected, 53 | columnsSelection, 54 | onChangeFilters : handleChangeFilters, 55 | onChangeColumns : setColumnsSelection, 56 | onResetFilters : resetFilters, 57 | }; 58 | 59 | return ( 60 | 61 | 68 | 69 | ); 70 | }; 71 | 72 | export default connect( 73 | (state: State) => ({ 74 | loggerEvents : globalSelectors.getFilteredLoggerEvents(state), 75 | currentEvent : sliceSelectors.updates.getCurrentLoggerEvent(state), 76 | filters : sliceSelectors.filters.getFilters(state), 77 | isAnyFilterSelected: sliceSelectors.filters.isAnyFilterSelected(state), 78 | lookups : sliceSelectors.updates.getLookups(state), 79 | columnsSelection : sliceSelectors.columns.getColumnsSelection(state), 80 | }), 81 | (dispatch) => 82 | bindActionCreators( 83 | { 84 | setCurrentEventID : updatesSlice.actions.setCurrentEventID, 85 | setFilters : filtersSlice.actions.setFilters, 86 | resetFilters : filtersSlice.actions.resetFilters, 87 | setColumnsSelection: columnsSlice.actions.setColumnsSelection, 88 | }, 89 | dispatch 90 | ) 91 | )(IndexPage); 92 | -------------------------------------------------------------------------------- /components/RequestsTable.tsx: -------------------------------------------------------------------------------- 1 | import makeStyles from '@material-ui/core/styles/makeStyles'; 2 | import classnames from 'classnames'; 3 | import { MonitorEvent } from 'wirebird-client'; 4 | import React, { FC, useCallback, useMemo } from 'react'; 5 | import DataGrid, { Column } from 'react-data-grid'; 6 | import { ColumnsSelection } from '../utils/Columns'; 7 | import { emptyObject } from '../utils/emptyObject'; 8 | import { shortenURL } from '../utils/shortenURL'; 9 | 10 | const useStyles = makeStyles( 11 | (theme) => ({ 12 | table: { 13 | height : '100%', 14 | minHeight : 350, 15 | fontFamily: theme.typography.fontFamily, 16 | }, 17 | rowError: { 18 | color: theme.palette.error.main, 19 | }, 20 | }), 21 | { name: 'RequestsTable' } 22 | ); 23 | 24 | interface IRequestsTableProps { 25 | items: Array; 26 | current?: string | null; 27 | onRowClick?: (rowId: string) => void; 28 | selectedColumns?: ColumnsSelection; 29 | } 30 | 31 | interface RTRow { 32 | id: string; 33 | name: string; 34 | requestURL: string; 35 | requestMethod: string; 36 | responseStatus?: number; 37 | kind: 'normal' | 'error'; 38 | } 39 | 40 | const monitorEventToRow = (e: MonitorEvent): RTRow => { 41 | let kind: RTRow['kind'] = 'normal'; 42 | if (e.error || e.response.status >= 400) { 43 | kind = 'error'; 44 | } 45 | 46 | return { 47 | id : e.request.id, 48 | name : shortenURL(e.request.url), 49 | requestURL : e.request.url, 50 | requestMethod : e.request.method, 51 | responseStatus: e.response?.status, 52 | kind, 53 | }; 54 | }; 55 | 56 | const rowKeyGetter = (row: RTRow): React.Key => { 57 | return row.id; 58 | }; 59 | 60 | const useColumns = (selectedColumns: ColumnsSelection): Column[] => { 61 | const columns: Column[] = [ 62 | { 63 | key : 'name', 64 | name : 'Name', 65 | resizable: true, 66 | formatter: ({ row, column: { key } }) => { 67 | return ( 68 | 69 | {row[key as keyof RTRow]} 70 | 71 | ); 72 | }, 73 | }, 74 | { 75 | key : 'requestURL', 76 | name : 'URL', 77 | resizable: true, 78 | }, 79 | { 80 | key : 'requestMethod', 81 | name : 'Method', 82 | resizable: true, 83 | }, 84 | { 85 | key : 'responseStatus', 86 | name : 'Status', 87 | resizable: true, 88 | }, 89 | ]; 90 | 91 | return useMemo( 92 | () => 93 | columns.filter( 94 | ({ key }) => selectedColumns[key as keyof ColumnsSelection] 95 | ), 96 | [selectedColumns] 97 | ); 98 | }; 99 | 100 | const RequestsTable: FC = ({ 101 | items, 102 | onRowClick, 103 | current, 104 | selectedColumns = emptyObject, 105 | }) => { 106 | const classes = useStyles(); 107 | 108 | const rows = items.map(monitorEventToRow); 109 | const handleRowClick = useCallback( 110 | (idx: number, row: RTRow) => { 111 | onRowClick?.(row.id); 112 | }, 113 | [onRowClick] 114 | ); 115 | 116 | const selectedRows: ReadonlySet = useMemo( 117 | () => (current ? new Set([current]) : new Set()), 118 | [current] 119 | ); 120 | 121 | const rowClass = useCallback( 122 | (row) => classnames({ [classes.rowError]: row.kind === 'error' }), 123 | [] 124 | ); 125 | 126 | const columns = useColumns(selectedColumns); 127 | 128 | return ( 129 | 138 | ); 139 | }; 140 | 141 | export default RequestsTable; 142 | -------------------------------------------------------------------------------- /components/Toolbar.tsx: -------------------------------------------------------------------------------- 1 | import Box from '@material-ui/core/Box'; 2 | import Grid from '@material-ui/core/Grid'; 3 | import IconButton from '@material-ui/core/IconButton'; 4 | import BlockIcon from '@material-ui/icons/Block'; 5 | import React, { FC, useCallback, useMemo } from 'react'; 6 | import { Lookups } from '../redux/ducks/updates'; 7 | import { emptyObject } from '../utils/emptyObject'; 8 | import { Filters, initialFilters } from '../utils/Filters'; 9 | import { ColumnsSelect } from './ColumnsSelect'; 10 | import { LookupFilterField } from './filter-controls/LookupFilterField'; 11 | import { TextFilterField } from './filter-controls/TextFilterField'; 12 | import { IToolbarContextProps } from './toolbar/ToolbarContext'; 13 | 14 | const createFieldUpdater = ( 15 | fieldName: keyof Filters, 16 | value: IToolbarContextProps['filters'], 17 | onChange: IToolbarContextProps['onChangeFilters'] 18 | ) => (fieldValue: string | number | undefined) => { 19 | if (fieldValue === '') { 20 | fieldValue = undefined; 21 | } 22 | onChange?.({ ...value, [fieldName]: fieldValue }); 23 | }; 24 | 25 | export const Toolbar: FC = React.memo( 26 | ({ 27 | lookups = emptyObject as Partial, 28 | filters: value = initialFilters, 29 | showResetFilters, 30 | columnsSelection = emptyObject, 31 | onResetFilters, 32 | onChangeFilters, 33 | onChangeColumns, 34 | }) => { 35 | const handlePIDChange = useMemo( 36 | () => createFieldUpdater('pid', value, onChangeFilters), 37 | [onChangeFilters, value] 38 | ); 39 | const handleSearchChange = useMemo( 40 | () => createFieldUpdater('search', value, onChangeFilters), 41 | [onChangeFilters, value] 42 | ); 43 | const handleDomainChange = useMemo( 44 | () => createFieldUpdater('domain', value, onChangeFilters), 45 | [onChangeFilters, value] 46 | ); 47 | const handleMethodChange = useMemo( 48 | () => createFieldUpdater('method', value, onChangeFilters), 49 | [onChangeFilters, value] 50 | ); 51 | const handleReset = useCallback(() => { 52 | onResetFilters?.(); 53 | }, [onResetFilters]); 54 | return ( 55 | 56 | 57 | 63 | 64 | 65 | 71 | 72 | 73 | 79 | 80 | 81 | 86 | 87 | 88 | 93 | 94 | {showResetFilters && ( 95 | 96 | 97 | 102 | 103 | 104 | 105 | 106 | )} 107 | 108 | ); 109 | } 110 | ); 111 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wirebird", 3 | "version": "0.2.5", 4 | "description": "DevTools / Network for Node.js", 5 | "keywords": [ 6 | "http-inspector", 7 | "mitm", 8 | "devtools", 9 | "development", 10 | "network", 11 | "http", 12 | "https", 13 | "sniffer", 14 | "logger", 15 | "debugger", 16 | "network debugger" 17 | ], 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/wirebird-js/wirebird" 21 | }, 22 | "license": "WTFPL", 23 | "author": "corporateanon ", 24 | "main": "index.js", 25 | "bin": { 26 | "http-inspector-ui": "./bin/wirebird.js", 27 | "wirebird": "./bin/wirebird.js" 28 | }, 29 | "files": [ 30 | "server-dist", 31 | "client-dist", 32 | "bin" 33 | ], 34 | "scripts": { 35 | "build": "NODE_ENV=production concurrently npm:build:client npm:build:server", 36 | "build-storybook": "build-storybook", 37 | "build:client": "parcel build client/main.html -d client-dist", 38 | "build:server": "tsc -d -p tsconfig.server.json", 39 | "clean": "rm -Rf client-dist server-dist", 40 | "dev": "concurrently npm:watch:client npm:watch:server npm:run:server", 41 | "dev:watch": "concurrently npm:watch:client npm:watch:server", 42 | "lint": "eslint .", 43 | "prepublishOnly": "npm run build", 44 | "run:server": "nodemon ./server-dist/index.js", 45 | "start": "node bin/wirebird.js", 46 | "storybook": "start-storybook -p 6006", 47 | "test": "jest", 48 | "tester": "wbenv node tester.js", 49 | "watch:client": "parcel watch client/main.html -d client-dist", 50 | "watch:server": "tsc -d -watch -p tsconfig.server.json" 51 | }, 52 | "dependencies": { 53 | "@types/ws": "^6.0.3", 54 | "chalk": "^4.1.0", 55 | "fastify": "^3.10.1", 56 | "fastify-static": "^3.4.0", 57 | "fastify-websocket": "^2.1.0", 58 | "ip": "^1.1.5", 59 | "lodash": "^4.17.20", 60 | "opener": "^1.5.2", 61 | "regenerator-runtime": "^0.13.7", 62 | "wirebird-client": "^0.2.4", 63 | "ws": "^7.1.2", 64 | "yargs": "^16.2.0" 65 | }, 66 | "devDependencies": { 67 | "@babel/core": "^7.6.4", 68 | "@babel/plugin-proposal-optional-chaining": "^7.12.7", 69 | "@babel/plugin-transform-typescript": "^7.13.0", 70 | "@babel/preset-react": "^7.13.13", 71 | "@babel/preset-typescript": "^7.13.0", 72 | "@material-ui/core": "^4.11.1", 73 | "@material-ui/icons": "^4.9.1", 74 | "@material-ui/lab": "^4.0.0-alpha.56", 75 | "@material-ui/styles": "^4.11.1", 76 | "@reduxjs/toolkit": "^1.5.0", 77 | "@storybook/addon-actions": "^6.2.7", 78 | "@storybook/addon-essentials": "^6.2.7", 79 | "@storybook/addon-links": "^6.2.7", 80 | "@storybook/react": "^6.2.7", 81 | "@types/classnames": "^2.2.11", 82 | "@types/ip": "^1.1.0", 83 | "@types/jest": "^26.0.16", 84 | "@types/lodash": "^4.14.165", 85 | "@types/opener": "^1.4.0", 86 | "@types/react": "^17.0.0", 87 | "@types/react-dom": "^17.0.0", 88 | "@types/react-inspector": "^4.0.1", 89 | "@types/react-redux": "^7.1.2", 90 | "@types/react-splitter-layout": "^3.0.1", 91 | "@typescript-eslint/eslint-plugin": "^4.10.0", 92 | "@typescript-eslint/parser": "^4.10.0", 93 | "axios": "^0.21.0", 94 | "babel-jest": "^26.6.3", 95 | "babel-loader": "^8.0.6", 96 | "classnames": "^2.2.6", 97 | "comsel": "0.0.5", 98 | "concurrently": "^5.3.0", 99 | "eslint": "^7.16.0", 100 | "immer": "^8.0.0", 101 | "jest": "^26.6.3", 102 | "lorem-ipsum": "^2.0.3", 103 | "nodemon": "^1.19.4", 104 | "parcel-bundler": "^1.12.4", 105 | "parcel-plugin-bundle-visualiser": "^1.2.0", 106 | "prettier": "^2.2.1", 107 | "react": "^16.9.0", 108 | "react-data-grid": "^7.0.0-canary.30", 109 | "react-dom": "^16.9.0", 110 | "react-inspector": "^5.1.0", 111 | "react-redux": "^7.1.1", 112 | "react-splitter-layout": "^4.0.0", 113 | "redux": "^4.0.4", 114 | "redux-saga": "^1.1.3", 115 | "reselect": "^4.0.0", 116 | "serve": "^11.3.2", 117 | "sleep-promise": "^9.0.0", 118 | "ts-node": "^9.1.0", 119 | "typescript": "^4.2.3" 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /stories/data/loggerEvents.ts: -------------------------------------------------------------------------------- 1 | import { MonitorEvent } from 'wirebird-client'; 2 | 3 | const items: Array = [ 4 | { 5 | processData: { 6 | mainModule: '/app/index.js', 7 | pid : 100, 8 | title : 'node', 9 | }, 10 | request: { 11 | remoteAddress: '1.2.3.4', 12 | body : null, 13 | headers : { 14 | Connection : 'keep-alive', 15 | Pragma : 'no-cache', 16 | 'Cache-Control' : 'no-cache', 17 | 'Upgrade-Insecure-Requests': '1', 18 | 'User-Agent' : 19 | 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.193 Safari/537.36', 20 | Accept: 21 | 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9', 22 | 'Accept-Encoding': 'gzip, deflate', 23 | 'Accept-Language': 24 | 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7,uk;q=0.6,pt;q=0.5', 25 | }, 26 | id : 'id1', 27 | method : 'GET', 28 | timeStart: 1000, 29 | url : 'https://example.com', 30 | }, 31 | response: { 32 | body : new Buffer('Hello world', 'utf8'), 33 | rawHeaders: [ 34 | 'Content-Encoding', 35 | 'gzip', 36 | 'Age', 37 | '592103', 38 | 'Cache-Control', 39 | 'max-age=604800', 40 | 'Content-Type', 41 | 'text/html; charset=UTF-8', 42 | 'Date', 43 | 'Sat, 28 Nov 2020 13:10:29 GMT', 44 | 'Etag', 45 | '"3147526947+gzip"', 46 | 'Expires', 47 | 'Sat, 05 Dec 2020 13:10:29 GMT', 48 | 'Last-Modified', 49 | 'Thu, 17 Oct 2019 07:18:26 GMT', 50 | 'Server', 51 | 'ECS (dcb/7F18)', 52 | 'Vary', 53 | 'Accept-Encoding', 54 | 'X-Cache', 55 | 'HIT', 56 | 'Content-Length', 57 | '648', 58 | ], 59 | headers: { 60 | 'Content-Encoding': 'gzip', 61 | Age : '592103', 62 | 'Cache-Control' : 'max-age=604800', 63 | 'Content-Type' : 'text/html; charset=UTF-8', 64 | Date : 'Sat, 28 Nov 2020 13:10:29 GMT', 65 | Etag : '"3147526947+gzip"', 66 | Expires : 'Sat, 05 Dec 2020 13:10:29 GMT', 67 | 'Last-Modified' : 'Thu, 17 Oct 2019 07:18:26 GMT', 68 | Server : 'ECS (dcb/7F18)', 69 | Vary : 'Accept-Encoding', 70 | 'X-Cache' : 'HIT', 71 | 'Content-Length' : '648', 72 | }, 73 | status : 200, 74 | timeStart: 1001, 75 | }, 76 | error: null, 77 | }, 78 | { 79 | processData: { 80 | mainModule: '/app/index.js', 81 | pid : 100, 82 | title : 'node', 83 | }, 84 | request: { 85 | remoteAddress: '1.2.3.4', 86 | body : new Buffer('{"hello":"world"}', 'utf8'), 87 | headers : { 88 | 'content-type': 'application/json', 89 | }, 90 | id : 'id2', 91 | method : 'POST', 92 | timeStart: 2000, 93 | url : 'https://example.com', 94 | }, 95 | response: { 96 | body : new Buffer('Internal server error', 'utf8'), 97 | rawHeaders: [], 98 | headers : {}, 99 | status : 500, 100 | timeStart : 2001, 101 | }, 102 | error: null, 103 | }, 104 | { 105 | processData: { 106 | mainModule: '/app/index.js', 107 | pid : 100, 108 | title : 'node', 109 | }, 110 | request: { 111 | remoteAddress: '1.2.3.4', 112 | body : new Buffer('{"hello":"world"}', 'utf8'), 113 | headers : { 114 | 'content-type': 'application/json', 115 | }, 116 | id : 'id3', 117 | method : 'POST', 118 | timeStart: 2000, 119 | url : 'https://non-existing-asdhfsdiufhsd.com', 120 | }, 121 | error: { 122 | code : 'E_LOOKUP', 123 | message: 'Could not find address non-existing-asdhfsdiufhsd.com', 124 | stack : 'Lorem ipsum', 125 | }, 126 | response: null, 127 | }, 128 | ]; 129 | 130 | for (let i = 0; i < 100; i++) { 131 | items.push({ 132 | ...items[0], 133 | request: { ...items[0].request, id: `additional_${i}` }, 134 | }); 135 | } 136 | 137 | export default items; 138 | -------------------------------------------------------------------------------- /components/EventDetailsView.tsx: -------------------------------------------------------------------------------- 1 | import Grid from '@material-ui/core/Grid'; 2 | import IconButton from '@material-ui/core/IconButton'; 3 | import makeStyles from '@material-ui/core/styles/makeStyles'; 4 | import Tab from '@material-ui/core/Tab'; 5 | import CloseIcon from '@material-ui/icons/Close'; 6 | import TabContext from '@material-ui/lab/TabContext'; 7 | import TabList from '@material-ui/lab/TabList'; 8 | import TabPanel from '@material-ui/lab/TabPanel'; 9 | import { MonitorEvent } from 'wirebird-client'; 10 | import React, { FC, useCallback, useState } from 'react'; 11 | import { Headers } from '../utils/Headers'; 12 | import { ContentView } from './ContentView'; 13 | import { HeadersView } from './HeadersView'; 14 | 15 | const useStyles = makeStyles((theme) => ({ 16 | tabs: { 17 | minHeight: 'auto', 18 | color : theme.palette.getContrastText(theme.palette.background.default), 19 | }, 20 | tab: { 21 | minWidth : 'auto', 22 | minHeight : 35, 23 | paddingTop : 0, 24 | paddingBottom: 0, 25 | }, 26 | tabPanel: { 27 | padding: 0, 28 | }, 29 | header: { 30 | position : 'sticky', 31 | top : 0, 32 | zIndex : 2, 33 | backgroundColor: theme.palette.background.default, 34 | }, 35 | })); 36 | 37 | type TabID = 'headers' | 'request' | 'response'; 38 | 39 | const createTabs = ( 40 | event: MonitorEvent, 41 | classes: ReturnType, 42 | currentTab: TabID 43 | ) => { 44 | const tabDefs = { 45 | headers: { 46 | condition: true, 47 | tab : ( 48 | 54 | ), 55 | }, 56 | request: { 57 | condition: !!event.request?.body, 58 | tab : ( 59 | 65 | ), 66 | }, 67 | response: { 68 | condition: !!event.response?.body, 69 | tab : ( 70 | 76 | ), 77 | }, 78 | }; 79 | 80 | const tabs = []; 81 | 82 | for (const [tabID, tabDef] of Object.entries(tabDefs)) { 83 | if (tabDef.condition) { 84 | tabs.push(tabDef.tab); 85 | } else if (currentTab === tabID) { 86 | currentTab = 'headers'; 87 | } 88 | } 89 | 90 | return { tabs, currentTab }; 91 | }; 92 | 93 | export interface IEventDetailsViewProps { 94 | event: MonitorEvent; 95 | onClose?: () => void; 96 | } 97 | 98 | export const EventDetailsView: FC = ({ 99 | event, 100 | onClose, 101 | }) => { 102 | const handleCloseClick = useCallback(() => onClose?.(), [onClose]); 103 | const classes = useStyles(); 104 | const [currentTab, setCurrentTab] = useState('headers'); 105 | const handleTabsChange = useCallback( 106 | (event, tabValue) => setCurrentTab(tabValue), 107 | [] 108 | ); 109 | const normalizedRequestHeaders = new Headers(event.request.headers); 110 | const normalizedResponseHeaders = event.response 111 | ? new Headers(event.response.headers) 112 | : null; 113 | const responseContentType = 114 | normalizedResponseHeaders?.get('content-type')?.toString() ?? null; 115 | const requestContentType = 116 | normalizedRequestHeaders?.get('content-type')?.toString() ?? null; 117 | 118 | const tabs = createTabs(event, classes, currentTab); 119 | 120 | return ( 121 | 122 |
123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 135 | {tabs.tabs} 136 | 137 | 138 | 139 |
140 | 141 | 142 | 143 | 144 | 145 | {event.request?.body && ( 146 | 150 | )} 151 | 152 | 153 | {event.response?.body && ( 154 | 158 | )} 159 | 160 |
161 | ); 162 | }; 163 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/en/configuration.html 4 | */ 5 | 6 | export default { 7 | // All imported modules in your tests should be mocked automatically 8 | // automock: false, 9 | 10 | // Stop running tests after `n` failures 11 | // bail: 0, 12 | 13 | // The directory where Jest should store its cached dependency information 14 | // cacheDirectory: "/tmp/jest_rs", 15 | 16 | // Automatically clear mock calls and instances between every test 17 | clearMocks: true, 18 | 19 | // Indicates whether the coverage information should be collected while executing the test 20 | // collectCoverage: false, 21 | 22 | // An array of glob patterns indicating a set of files for which coverage information should be collected 23 | // collectCoverageFrom: undefined, 24 | 25 | // The directory where Jest should output its coverage files 26 | coverageDirectory: 'coverage', 27 | 28 | // An array of regexp pattern strings used to skip coverage collection 29 | // coveragePathIgnorePatterns: [ 30 | // "/node_modules/" 31 | // ], 32 | 33 | // Indicates which provider should be used to instrument code for coverage 34 | coverageProvider: 'v8', 35 | 36 | // A list of reporter names that Jest uses when writing coverage reports 37 | // coverageReporters: [ 38 | // "json", 39 | // "text", 40 | // "lcov", 41 | // "clover" 42 | // ], 43 | 44 | // An object that configures minimum threshold enforcement for coverage results 45 | // coverageThreshold: undefined, 46 | 47 | // A path to a custom dependency extractor 48 | // dependencyExtractor: undefined, 49 | 50 | // Make calling deprecated APIs throw helpful error messages 51 | // errorOnDeprecated: false, 52 | 53 | // Force coverage collection from ignored files using an array of glob patterns 54 | // forceCoverageMatch: [], 55 | 56 | // A path to a module which exports an async function that is triggered once before all test suites 57 | // globalSetup: undefined, 58 | 59 | // A path to a module which exports an async function that is triggered once after all test suites 60 | // globalTeardown: undefined, 61 | 62 | // A set of global variables that need to be available in all test environments 63 | // globals: {}, 64 | 65 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 66 | // maxWorkers: "50%", 67 | 68 | // An array of directory names to be searched recursively up from the requiring module's location 69 | // moduleDirectories: [ 70 | // "node_modules" 71 | // ], 72 | 73 | // An array of file extensions your modules use 74 | // moduleFileExtensions: [ 75 | // "js", 76 | // "json", 77 | // "jsx", 78 | // "ts", 79 | // "tsx", 80 | // "node" 81 | // ], 82 | 83 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 84 | // moduleNameMapper: {}, 85 | 86 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 87 | // modulePathIgnorePatterns: [], 88 | 89 | // Activates notifications for test results 90 | // notify: false, 91 | 92 | // An enum that specifies notification mode. Requires { notify: true } 93 | // notifyMode: "failure-change", 94 | 95 | // A preset that is used as a base for Jest's configuration 96 | // preset: undefined, 97 | 98 | // Run tests from one or more projects 99 | // projects: undefined, 100 | 101 | // Use this configuration option to add custom reporters to Jest 102 | // reporters: undefined, 103 | 104 | // Automatically reset mock state between every test 105 | // resetMocks: false, 106 | 107 | // Reset the module registry before running each individual test 108 | // resetModules: false, 109 | 110 | // A path to a custom resolver 111 | // resolver: undefined, 112 | 113 | // Automatically restore mock state between every test 114 | // restoreMocks: false, 115 | 116 | // The root directory that Jest should scan for tests and modules within 117 | // rootDir: undefined, 118 | 119 | // A list of paths to directories that Jest should use to search for files in 120 | // roots: [ 121 | // "" 122 | // ], 123 | 124 | // Allows you to use a custom runner instead of Jest's default test runner 125 | // runner: "jest-runner", 126 | 127 | // The paths to modules that run some code to configure or set up the testing environment before each test 128 | // setupFiles: [], 129 | 130 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 131 | // setupFilesAfterEnv: [], 132 | 133 | // The number of seconds after which a test is considered as slow and reported as such in the results. 134 | // slowTestThreshold: 5, 135 | 136 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 137 | // snapshotSerializers: [], 138 | 139 | // The test environment that will be used for testing 140 | testEnvironment: 'node', 141 | 142 | // Options that will be passed to the testEnvironment 143 | // testEnvironmentOptions: {}, 144 | 145 | // Adds a location field to test results 146 | // testLocationInResults: false, 147 | 148 | // The glob patterns Jest uses to detect test files 149 | // testMatch: [ 150 | // "**/__tests__/**/*.[jt]s?(x)", 151 | // "**/?(*.)+(spec|test).[tj]s?(x)" 152 | // ], 153 | 154 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 155 | // testPathIgnorePatterns: [ 156 | // "/node_modules/" 157 | // ], 158 | 159 | // The regexp pattern or array of patterns that Jest uses to detect test files 160 | // testRegex: [], 161 | 162 | // This option allows the use of a custom results processor 163 | // testResultsProcessor: undefined, 164 | 165 | // This option allows use of a custom test runner 166 | // testRunner: "jasmine2", 167 | 168 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href 169 | // testURL: "http://localhost", 170 | 171 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout" 172 | // timers: "real", 173 | 174 | // A map from regular expressions to paths to transformers 175 | // transform: undefined, 176 | 177 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 178 | // transformIgnorePatterns: [ 179 | // "/node_modules/", 180 | // "\\.pnp\\.[^\\/]+$" 181 | // ], 182 | 183 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 184 | // unmockedModulePathPatterns: undefined, 185 | 186 | // Indicates whether each individual test should be reported during the run 187 | // verbose: undefined, 188 | 189 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 190 | // watchPathIgnorePatterns: [], 191 | 192 | // Whether to use watchman for file crawling 193 | // watchman: true, 194 | }; 195 | --------------------------------------------------------------------------------