├── .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 |
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 |
58 |
59 | {Object.entries(lookupsWithCurrentValueAdded).map(
60 | ([key, value]) => (
61 |
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 |
--------------------------------------------------------------------------------