Compare commits

..

31 Commits

Author SHA1 Message Date
b41fe29850 Add "Copy to Sender" table action 2022-02-26 08:51:36 +01:00
7e43479b54 Reuse components across Proxy and Sender modules 2022-02-25 21:08:15 +01:00
11f70282d7 Tidy up admin structure 2022-02-23 15:20:23 +01:00
efc20564c1 Add Sender module 2022-02-22 14:10:39 +01:00
afa211d0ec Add lint Github Action 2022-02-01 18:14:14 +01:00
44193cd723 Use Node v16 in CI/CD 2022-02-01 18:13:34 +01:00
e07163fef3 Add prettier lint config 2022-02-01 18:13:14 +01:00
ed394507d3 Fix default make target 2022-02-01 18:12:44 +01:00
cd5403e353 Fix README 2022-01-31 16:17:19 +01:00
565c370bb8 Fix GoReleaser config, update moq dep 2022-01-31 15:48:44 +01:00
2dc6538a3b Add build-test Github Action 2022-01-31 14:37:56 +01:00
aa8ddf4122 Update Next.js, Material UI 2022-01-28 20:20:15 +01:00
73ebb89863 Update Dockerfile 2022-01-26 11:35:47 +01:00
1489cb16bf Update GoReleaser config 2022-01-25 13:20:16 +01:00
d84d2d0905 Replace SQLite with BadgerDB 2022-01-21 11:45:54 +01:00
8a3b3cbf02 Bump copyright year 2022-01-01 16:15:03 +01:00
b3225bfb99 Add initial tests for reqlog package 2022-01-01 16:11:49 +01:00
4e2eaea499 Add sponsor 2021-12-30 11:40:14 +01:00
8122b2552d Remove dead code 2021-05-13 13:35:11 +02:00
569f7bc76f Use embed instead of rice 2021-04-26 09:24:45 +02:00
ca3a729c36 Add linter, fix linting issue 2021-04-25 16:23:53 +02:00
ad3dc0da70 Bump ini from 1.3.5 to 1.3.8 in /docs (#57)
Bumps [ini](https://github.com/isaacs/ini) from 1.3.5 to 1.3.8.
- [Release notes](https://github.com/isaacs/ini/releases)
- [Commits](https://github.com/isaacs/ini/compare/v1.3.5...v1.3.8)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:18:36 +02:00
49547f535f Bump prismjs from 1.22.0 to 1.23.0 in /docs (#64)
Bumps [prismjs](https://github.com/PrismJS/prism) from 1.22.0 to 1.23.0.
- [Release notes](https://github.com/PrismJS/prism/releases)
- [Changelog](https://github.com/PrismJS/prism/blob/master/CHANGELOG.md)
- [Commits](https://github.com/PrismJS/prism/compare/v1.22.0...v1.23.0)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:18:23 +02:00
e42e1c212b Bump elliptic from 6.5.3 to 6.5.4 in /admin (#65)
Bumps [elliptic](https://github.com/indutny/elliptic) from 6.5.3 to 6.5.4.
- [Release notes](https://github.com/indutny/elliptic/releases)
- [Commits](https://github.com/indutny/elliptic/compare/v6.5.3...v6.5.4)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:18:17 +02:00
6e38b16cf2 Bump elliptic from 6.5.3 to 6.5.4 in /docs (#66)
Bumps [elliptic](https://github.com/indutny/elliptic) from 6.5.3 to 6.5.4.
- [Release notes](https://github.com/indutny/elliptic/releases)
- [Commits](https://github.com/indutny/elliptic/compare/v6.5.3...v6.5.4)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:18:10 +02:00
078bf303be Bump ssri from 6.0.1 to 6.0.2 in /docs (#71)
Bumps [ssri](https://github.com/npm/ssri) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/npm/ssri/releases)
- [Changelog](https://github.com/npm/ssri/blob/v6.0.2/CHANGELOG.md)
- [Commits](https://github.com/npm/ssri/compare/v6.0.1...v6.0.2)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:17:30 +02:00
a42f003919 Bump y18n from 4.0.0 to 4.0.1 in /admin (#68)
Bumps [y18n](https://github.com/yargs/y18n) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/yargs/y18n/releases)
- [Changelog](https://github.com/yargs/y18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/yargs/y18n/commits)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:16:52 +02:00
50c2eac42d Bump y18n from 4.0.0 to 4.0.1 in /docs (#69)
Bumps [y18n](https://github.com/yargs/y18n) from 4.0.0 to 4.0.1.
- [Release notes](https://github.com/yargs/y18n/releases)
- [Changelog](https://github.com/yargs/y18n/blob/master/CHANGELOG.md)
- [Commits](https://github.com/yargs/y18n/commits)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:16:39 +02:00
4ead501f53 Bump ssri from 6.0.1 to 6.0.2 in /admin (#70)
Bumps [ssri](https://github.com/npm/ssri) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/npm/ssri/releases)
- [Changelog](https://github.com/npm/ssri/blob/v6.0.2/CHANGELOG.md)
- [Commits](https://github.com/npm/ssri/compare/v6.0.1...v6.0.2)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-23 20:16:11 +02:00
d2e97f2acc Fix typo "is" > "if" in CLI cert help text (#63)
* Fix typo "is" > "if" in main.go

* Fix typo "is" > "if" in documentation

Update to match the code.
2021-04-23 20:01:50 +02:00
ad3fa7d379 Handle req log filter I/O when no project is set 2020-12-29 20:46:42 +01:00
158 changed files with 15942 additions and 8954 deletions

View File

@ -1,4 +1,8 @@
/admin/.env
/admin/.next
/admin/dist
/admin/node_modules
/admin/node_modules
/dist
/docs
/hetty
/cmd/hetty/admin

52
.github/workflows/build-test.yml vendored Normal file
View File

@ -0,0 +1,52 @@
name: Build and Test
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
go: ["1.17", "1.16"]
name: Go ${{ matrix.go }} - Build
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go }}
- uses: actions/setup-node@v2
with:
node-version: "16"
- uses: actions/cache@v2
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- uses: actions/cache@v2
with:
path: ${{ github.workspace }}/admin/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- run: make build
test:
runs-on: ubuntu-latest
strategy:
matrix:
go: ["1.17", "1.16"]
name: Go ${{ matrix.go }} - Test
steps:
- uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go }}
- uses: actions/cache@v2
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- run: go test ./pkg/...

16
.github/workflows/lint.yml vendored Normal file
View File

@ -0,0 +1,16 @@
name: Lint
on: [push, pull_request]
defaults:
run:
working-directory: ./admin
jobs:
lint-admin:
runs-on: ubuntu-latest
name: Admin (Next.js)
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: "16"
- run: yarn install
- run: yarn run lint

11
.gitignore vendored
View File

@ -1,7 +1,6 @@
.release-env
.vscode
**/rice-box.go
dist
hetty
*.vscode
/dist
/hetty
/cmd/hetty/admin
*.pem
*.test
*.test

48
.golangci.yml Normal file
View File

@ -0,0 +1,48 @@
linters:
presets:
- bugs
- comment
- error
- format
- import
- metalinter
- module
- performance
- style
- test
- unused
disable:
- exhaustive
- exhaustivestruct
- gochecknoglobals
- gochecknoinits
- godox
- goerr113
- gomnd
- interfacer
- maligned
- nlreturn
- scopelint
- testpackage
- wrapcheck
linters-settings:
gci:
local-prefixes: github.com/dstotijn/hetty
godot:
capital: true
issues:
exclude-rules:
- linters:
- gosec
# Ignore SHA1 usage.
text: "G(401|505):"
- linters:
- wsl
# Ignore cuddled defer statements.
text: "only one cuddle assignment allowed before defer statement"
- linters:
- nlreturn
# Ignore `break` without leading blank line.
text: "break with no blank line before"

View File

@ -1,60 +1,38 @@
env:
- GO111MODULE=on
- CGO_ENABLED=1
before:
hooks:
- make clean
- sh -c "NEXT_PUBLIC_VERSION={{ .Version}} make build-admin"
- go mod tidy
builds:
- id: hetty-darwin-amd64
- env:
- CGO_ENABLED=0
main: ./cmd/hetty
goarch:
- amd64
goos:
- darwin
env:
- CC=o64-clang
- CXX=o64-clang++
flags:
- -mod=readonly
- id: hetty-linux-amd64
main: ./cmd/hetty
goarch:
- amd64
ldflags:
- -s -w -X main.version={{.Version}}
goos:
- linux
flags:
- -mod=readonly
- id: hetty-windows-amd64
main: ./cmd/hetty
- windows
- darwin
goarch:
- amd64
goos:
- windows
env:
- CC=x86_64-w64-mingw32-gcc
- CXX=x86_64-w64-mingw32-g++
flags:
- -mod=readonly
ldflags:
- -buildmode=exe
- arm64
archives:
-
replacements:
darwin: macOS
linux: Linux
windows: Windows
386: i386
amd64: x86_64
- replacements:
darwin: macOS
linux: Linux
windows: Windows
amd64: x86_64
format_overrides:
- goos: windows
format: zip
- goos: windows
format: zip
checksum:
name_template: "checksums.txt"
snapshot:
name_template: "{{ .Tag }}-next"
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc

View File

@ -1,16 +1,6 @@
ARG GO_VERSION=1.15
ARG CGO_ENABLED=1
ARG NODE_VERSION=14.11
FROM golang:${GO_VERSION}-alpine AS go-builder
WORKDIR /app
RUN apk add --no-cache build-base
COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY pkg ./pkg
RUN rm -f cmd/hetty/rice-box.go
RUN go build ./cmd/hetty
ARG GO_VERSION=1.17
ARG NODE_VERSION=16.13
ARG ALPINE_VERSION=3.15
FROM node:${NODE_VERSION}-alpine AS node-builder
WORKDIR /app
@ -20,11 +10,21 @@ COPY admin/ .
ENV NEXT_TELEMETRY_DISABLED=1
RUN yarn run export
FROM alpine:3.12
FROM golang:${GO_VERSION}-alpine AS go-builder
ARG HETTY_VERSION=0.0.0
ENV CGO_ENABLED=0
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY pkg ./pkg
COPY --from=node-builder /app/dist ./cmd/hetty/admin
RUN go build -ldflags="-s -w -X main.version=${HETTY_VERSION}" ./cmd/hetty
FROM alpine:${ALPINE_VERSION}
WORKDIR /app
COPY --from=go-builder /app/hetty .
COPY --from=node-builder /app/dist admin
ENTRYPOINT ["./hetty", "-adminPath=./admin"]
ENTRYPOINT ["./hetty"]
EXPOSE 8080

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020 David Stotijn
Copyright (c) 2021 David Stotijn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.

View File

@ -1,34 +1,20 @@
PACKAGE_NAME := github.com/dstotijn/hetty
GOLANG_CROSS_VERSION ?= v1.15.2
.PHONY: embed
embed:
NEXT_TELEMETRY_DISABLED=1 cd admin && yarn install && yarn run export
cd cmd/hetty && rice embed-go
export CGO_ENABLED = 0
export NEXT_TELEMETRY_DISABLED = 1
.PHONY: build
build: embed
CGO_ENABLED=1 go build ./cmd/hetty
build: build-admin
go build ./cmd/hetty
.PHONY: release-dry-run
release-dry-run: embed
@docker run \
--rm \
-v `pwd`:/go/src/$(PACKAGE_NAME) \
-w /go/src/$(PACKAGE_NAME) \
troian/golang-cross:${GOLANG_CROSS_VERSION} \
--rm-dist --skip-validate --skip-publish
.PHONY: build-admin
build-admin:
cd admin && \
yarn install --frozen-lockfile && \
yarn run export && \
mv dist ../cmd/hetty/admin
.PHONY: release
release: embed
@if [ ! -f ".release-env" ]; then \
echo "\033[91mFile \`.release-env\` is missing.\033[0m";\
exit 1;\
fi
@docker run \
--rm \
-v `pwd`:/go/src/$(PACKAGE_NAME) \
-w /go/src/$(PACKAGE_NAME) \
--env-file .release-env \
troian/golang-cross:${GOLANG_CROSS_VERSION} \
release --rm-dist
.PHONY: clean
clean:
rm -f hetty
rm -rf ./cmd/hetty/admin
rm -rf ./admin/dist
rm -rf ./admin/.next

View File

@ -5,6 +5,7 @@
</h1>
[![Latest GitHub release](https://img.shields.io/github/v/release/dstotijn/hetty?color=18BA91&style=flat-square)](https://github.com/dstotijn/hetty/releases/latest)
[![Build Status](https://img.shields.io/endpoint.svg?url=https://actions-badge.atrox.dev/dstotijn/hetty/badge&style=flat-square&label=build+%26+test&logo=none&color=18BA91)](https://github.com/dstotijn/hetty/actions/workflows/build-test.yml)
![GitHub download count](https://img.shields.io/github/downloads/dstotijn/hetty/total?color=18BA91&style=flat-square)
[![GitHub](https://img.shields.io/github/license/dstotijn/hetty?color=18BA91&style=flat-square)](https://github.com/dstotijn/hetty/blob/master/LICENSE)
[![Documentation](https://img.shields.io/badge/hetty-docs-18BA91?style=flat-square)](https://hetty.xyz/)
@ -18,7 +19,7 @@ features tailored to the needs of the infosec and bug bounty community.
## Features
- Man-in-the-middle (MITM) HTTP/1.1 proxy with logs
- Project based database storage (SQLite)
- Project based database storage (BadgerDB)
- Scope support
- Headless management API using GraphQL
- Embedded web interface (Next.js)
@ -33,7 +34,7 @@ for details.
## Installation
Hetty compiles to a self-contained binary, with an embedded SQLite database
Hetty compiles to a self-contained binary, with an embedded BadgerDB database
and web based admin interface.
### Install pre-built release (recommended)
@ -44,14 +45,13 @@ and web based admin interface.
#### Prerequisites
- [Go](https://golang.org/)
- [Go 1.16](https://golang.org/)
- [Yarn](https://yarnpkg.com/)
- [go.rice](https://github.com/GeertJohan/go.rice)
Hetty depends on SQLite (via [mattn/go-sqlite3](https://github.com/mattn/go-sqlite3))
and needs `cgo` to compile. Additionally, the static resources for the admin interface
(Next.js) need to be generated via [Yarn](https://yarnpkg.com/) and embedded in
a `.go` file with [go.rice](https://github.com/GeertJohan/go.rice) beforehand.
When building from source, the static resources for the admin interface
(Next.js) need to be generated via [Yarn](https://yarnpkg.com/). The generated
files will be embedded (using the [embed](https://golang.org/pkg/embed/)
package) when you use the `build` Makefile target.
Clone the repository and use the `build` make target to create a binary:
@ -64,7 +64,7 @@ $ make build
### Docker
A Docker image is available on Docker Hub: [`dstotijn/hetty`](https://hub.docker.com/r/dstotijn/hetty).
For persistent storage of CA certificates and project databases, mount a volume:
For persistent storage of CA certificates and projects database, mount a volume:
```
$ mkdir -p $HOME/.hetty
@ -77,7 +77,7 @@ When Hetty is run, by default it listens on `:8080` and is accessible via
http://localhost:8080. Depending on incoming HTTP requests, it either acts as a
MITM proxy, or it serves the API and web interface.
By default, project database files and CA certificates are stored in a `.hetty`
By default, the projects database files and CA certificates are stored in a `.hetty`
directory under the user's home directory (`$HOME` on Linux/macOS, `%USERPROFILE%`
on Windows).
@ -98,17 +98,17 @@ Usage of ./hetty:
-adminPath string
File path to admin build
-cert string
CA certificate filepath. Creates a new CA certificate is file doesn't exist (default "~/.hetty/hetty_cert.pem")
CA certificate filepath. Creates a new CA certificate if file doesn't exist (default "~/.hetty/hetty_cert.pem")
-key string
CA private key filepath. Creates a new CA private key if file doesn't exist (default "~/.hetty/hetty_key.pem")
-projects string
Projects directory path (default "~/.hetty/projects")
-db string
Database directory path (default "~/.hetty/db")
```
You should see:
```
2020/11/01 14:47:10 [INFO] Running server on :8080 ...
2022/01/26 10:34:24 [INFO] Hetty (v0.3.2) is running on :8080 ...
```
Then, visit [http://localhost:8080](http://localhost:8080) to get started.
@ -228,10 +228,16 @@ for details.
for all the encouragement and feedback.
- The font used in the logo and admin interface is [JetBrains Mono](https://www.jetbrains.com/lp/mono/).
## Sponsors
<a href="https://www.tines.com/?utm_source=oss&utm_medium=sponsorship&utm_campaign=hetty">
<img src="https://hetty.xyz/assets/tines-sponsorship-badge.png" width="140" alt="Sponsored by Tines">
</a>
## License
[MIT License](LICENSE)
---
© 2020 David Stotijn — [Twitter](https://twitter.com/dstotijn), [Email](mailto:dstotijn@gmail.com)
© 2021 David Stotijn — [Twitter](https://twitter.com/dstotijn), [Email](mailto:dstotijn@gmail.com)

51
admin/.eslintrc.json Normal file
View File

@ -0,0 +1,51 @@
{
"root": true,
"extends": ["next/core-web-vitals", "prettier", "plugin:@typescript-eslint/recommended", "plugin:import/typescript"],
"plugins": ["prettier", "@typescript-eslint", "import"],
"ignorePatterns": ["next*", "src/lib/graphql/generated.tsx"],
"settings": {
"import/parsers": {
"@typescript-eslint/parser": [".ts", ".tsx"]
},
"import/resolver": {
"typescript": {
"alwaysTryTypes": true
}
}
},
"rules": {
"prettier/prettier": ["error"],
"@next/next/no-css-tags": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": "error",
"import/default": "off",
"import/no-unresolved": "error",
"import/named": "error",
"import/namespace": "error",
"import/export": "error",
"import/no-deprecated": "error",
"import/no-cycle": "error",
"import/no-named-as-default": "warn",
"import/no-named-as-default-member": "warn",
"import/no-duplicates": "warn",
"import/newline-after-import": "warn",
"import/order": [
"warn",
{
"alphabetize": { "order": "asc", "caseInsensitive": false },
"newlines-between": "always",
"groups": ["builtin", "external", "parent", "sibling", "index"]
}
],
"import/no-unused-modules": [
"error",
{
"missingExports": true,
"ignoreExports": ["./src/pages"]
}
]
}
}

4
admin/.prettierignore Normal file
View File

@ -0,0 +1,4 @@
/.next/
/out/
/build
/coverage

3
admin/.prettierrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"printWidth": 120
}

9
admin/gqlcodegen.yml Normal file
View File

@ -0,0 +1,9 @@
overwrite: true
schema: "../pkg/api/schema.graphql"
documents: "src/**/*.graphql"
generates:
src/lib/graphql/generated.tsx:
plugins:
- "typescript"
- "typescript-operations"
- "typescript-react-apollo"

5
admin/next-env.d.ts vendored
View File

@ -1,2 +1,5 @@
/// <reference types="next" />
/// <reference types="next/types/global" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.

View File

@ -1,7 +1,10 @@
const withCSS = require("@zeit/next-css");
const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin");
// @ts-check
module.exports = withCSS({
/**
* @type {import('next').NextConfig}
**/
const nextConfig = {
reactStrictMode: true,
trailingSlash: true,
async rewrites() {
return [
@ -11,24 +14,6 @@ module.exports = withCSS({
},
];
},
webpack: (config) => {
config.module.rules.push({
test: /\.(png|jpg|gif|svg|eot|ttf|woff|woff2)$/,
use: {
loader: "url-loader",
options: {
limit: 100000,
},
},
});
};
config.plugins.push(
new MonacoWebpackPlugin({
languages: ["html", "json", "javascript"],
filename: "static/[name].worker.js",
})
);
return config;
},
});
module.exports = nextConfig;

View File

@ -6,31 +6,51 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"export": "rm -rf .next && next build && next export -o dist"
"lint": "next lint",
"export": "next build && next export -o dist",
"generate": "graphql-codegen --config gqlcodegen.yml"
},
"dependencies": {
"@apollo/client": "^3.2.0",
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.56",
"@zeit/next-css": "^1.0.1",
"graphql": "^15.3.0",
"monaco-editor": "^0.20.0",
"monaco-editor-webpack-plugin": "^1.9.0",
"next": "^9.5.4",
"@emotion/react": "^11.7.1",
"@emotion/server": "^11.4.0",
"@emotion/styled": "^11.6.0",
"@monaco-editor/react": "^4.3.1",
"@mui/icons-material": "^5.3.1",
"@mui/lab": "^5.0.0-alpha.66",
"@mui/material": "^5.3.1",
"@mui/styles": "^5.4.2",
"allotment": "^1.9.0",
"deepmerge": "^4.2.2",
"graphql": "^16.2.0",
"lodash": "^4.17.21",
"monaco-editor": "^0.31.1",
"next": "^12.0.8",
"next-fonts": "^1.0.3",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-monaco-editor": "^0.34.0",
"react-syntax-highlighter": "^13.5.3",
"typescript": "^4.0.3"
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-split-pane": "^0.1.92"
},
"devDependencies": {
"@types/node": "^14.11.1",
"@types/react": "^16.9.49",
"eslint": "^7.9.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-prettier": "^3.1.4",
"prettier": "^2.1.2"
"@babel/core": "^7.0.0",
"@graphql-codegen/cli": "2.6.1",
"@graphql-codegen/introspection": "2.1.1",
"@graphql-codegen/typescript": "2.4.3",
"@graphql-codegen/typescript-operations": "2.3.0",
"@graphql-codegen/typescript-react-apollo": "3.2.6",
"@types/lodash": "^4.14.178",
"@types/node": "^17.0.12",
"@types/react": "^17.0.38",
"@typescript-eslint/eslint-plugin": "^5.12.0",
"@typescript-eslint/parser": "^5.12.0",
"eslint": "^8.7.0",
"eslint-config-next": "12.0.8",
"eslint-config-prettier": "^8.3.0",
"eslint-import-resolver-typescript": "^2.5.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-prettier": "^4.0.0",
"prettier": "^2.1.2",
"typescript": "^4.0.3",
"webpack": "^5.67.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@ -1,25 +0,0 @@
import { Paper } from "@material-ui/core";
function CenteredPaper({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return (
<div>
<Paper
elevation={0}
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: 36,
}}
>
{children}
</Paper>
</div>
);
}
export default CenteredPaper;

View File

@ -1,283 +0,0 @@
import React from "react";
import {
makeStyles,
Theme,
createStyles,
useTheme,
AppBar,
Toolbar,
IconButton,
Typography,
Drawer,
Divider,
List,
ListItem,
ListItemIcon,
ListItemText,
Tooltip,
} from "@material-ui/core";
import Link from "next/link";
import MenuIcon from "@material-ui/icons/Menu";
import HomeIcon from "@material-ui/icons/Home";
import SettingsEthernetIcon from "@material-ui/icons/SettingsEthernet";
import SendIcon from "@material-ui/icons/Send";
import FolderIcon from "@material-ui/icons/Folder";
import LocationSearchingIcon from "@material-ui/icons/LocationSearching";
import ChevronLeftIcon from "@material-ui/icons/ChevronLeft";
import ChevronRightIcon from "@material-ui/icons/ChevronRight";
import clsx from "clsx";
export enum Page {
Home,
GetStarted,
Projects,
ProxySetup,
ProxyLogs,
Sender,
Scope,
}
const drawerWidth = 240;
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
display: "flex",
width: "100%",
},
appBar: {
zIndex: theme.zIndex.drawer + 1,
transition: theme.transitions.create(["width", "margin"], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
},
appBarShift: {
marginLeft: drawerWidth,
width: `calc(100% - ${drawerWidth}px)`,
transition: theme.transitions.create(["width", "margin"], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
},
menuButton: {
marginRight: 28,
},
hide: {
display: "none",
},
drawer: {
width: drawerWidth,
flexShrink: 0,
whiteSpace: "nowrap",
},
drawerOpen: {
width: drawerWidth,
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
},
drawerClose: {
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
overflowX: "hidden",
width: theme.spacing(7) + 1,
[theme.breakpoints.up("sm")]: {
width: theme.spacing(7) + 8,
},
},
toolbar: {
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
padding: theme.spacing(0, 1),
// necessary for content to be below app bar
...theme.mixins.toolbar,
},
content: {
flexGrow: 1,
padding: theme.spacing(3),
},
listItem: {
paddingLeft: 16,
paddingRight: 16,
[theme.breakpoints.up("sm")]: {
paddingLeft: 20,
paddingRight: 20,
},
},
listItemIcon: {
minWidth: 42,
},
titleHighlight: {
color: theme.palette.secondary.main,
marginRight: 4,
},
})
);
interface Props {
children: React.ReactNode;
title: string;
page: Page;
}
export function Layout({ title, page, children }: Props): JSX.Element {
const classes = useStyles();
const theme = useTheme();
const [open, setOpen] = React.useState(false);
const handleDrawerOpen = () => {
setOpen(true);
};
const handleDrawerClose = () => {
setOpen(false);
};
return (
<div className={classes.root}>
<AppBar
position="fixed"
className={clsx(classes.appBar, {
[classes.appBarShift]: open,
})}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
onClick={handleDrawerOpen}
edge="start"
className={clsx(classes.menuButton, {
[classes.hide]: open,
})}
>
<MenuIcon />
</IconButton>
<Typography variant="h5" noWrap>
<span className={title !== "" ? classes.titleHighlight : ""}>
Hetty://
</span>
{title}
</Typography>
</Toolbar>
</AppBar>
<Drawer
variant="permanent"
className={clsx(classes.drawer, {
[classes.drawerOpen]: open,
[classes.drawerClose]: !open,
})}
classes={{
paper: clsx({
[classes.drawerOpen]: open,
[classes.drawerClose]: !open,
}),
}}
>
<div className={classes.toolbar}>
<IconButton onClick={handleDrawerClose}>
{theme.direction === "rtl" ? (
<ChevronRightIcon />
) : (
<ChevronLeftIcon />
)}
</IconButton>
</div>
<Divider />
<List>
<Link href="/" passHref>
<ListItem
button
component="a"
key="home"
selected={page === Page.Home}
className={classes.listItem}
>
<Tooltip title="Home">
<ListItemIcon className={classes.listItemIcon}>
<HomeIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Home" />
</ListItem>
</Link>
<Link href="/proxy/logs" passHref>
<ListItem
button
component="a"
key="proxyLogs"
selected={page === Page.ProxyLogs}
className={classes.listItem}
>
<Tooltip title="Proxy">
<ListItemIcon className={classes.listItemIcon}>
<SettingsEthernetIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Proxy" />
</ListItem>
</Link>
<Link href="/sender" passHref>
<ListItem
button
component="a"
key="sender"
selected={page === Page.Sender}
className={classes.listItem}
>
<Tooltip title="Sender">
<ListItemIcon className={classes.listItemIcon}>
<SendIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Sender" />
</ListItem>
</Link>
<Link href="/scope" passHref>
<ListItem
button
component="a"
key="scope"
selected={page === Page.Scope}
className={classes.listItem}
>
<Tooltip title="Scope">
<ListItemIcon className={classes.listItemIcon}>
<LocationSearchingIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Scope" />
</ListItem>
</Link>
<Link href="/projects" passHref>
<ListItem
button
component="a"
key="projects"
selected={page === Page.Projects}
className={classes.listItem}
>
<Tooltip title="Projects">
<ListItemIcon className={classes.listItemIcon}>
<FolderIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Projects" />
</ListItem>
</Link>
</List>
</Drawer>
<main className={classes.content}>
<div className={classes.toolbar} />
{children}
</main>
</div>
);
}
export default Layout;

View File

@ -1,122 +0,0 @@
import { gql, useMutation } from "@apollo/client";
import {
Box,
Button,
CircularProgress,
createStyles,
makeStyles,
TextField,
Theme,
Typography,
} from "@material-ui/core";
import AddIcon from "@material-ui/icons/Add";
import React, { useState } from "react";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
projectName: {
marginTop: -6,
marginRight: theme.spacing(2),
},
button: {
marginRight: theme.spacing(2),
},
})
);
const OPEN_PROJECT = gql`
mutation OpenProject($name: String!) {
openProject(name: $name) {
name
isActive
}
}
`;
function NewProject(): JSX.Element {
const classes = useStyles();
const [input, setInput] = useState(null);
const [openProject, { error, loading }] = useMutation(OPEN_PROJECT, {
onError: () => {},
onCompleted() {
input.value = "";
},
update(cache, { data: { openProject } }) {
cache.modify({
fields: {
activeProject() {
const activeProjRef = cache.writeFragment({
id: openProject.name,
data: openProject,
fragment: gql`
fragment ActiveProject on Project {
name
isActive
type
}
`,
});
return activeProjRef;
},
projects(_, { DELETE }) {
cache.writeFragment({
id: openProject.name,
data: openProject,
fragment: gql`
fragment OpenProject on Project {
name
isActive
type
}
`,
});
return DELETE;
},
},
});
},
});
const handleNewProjectForm = (e: React.SyntheticEvent) => {
e.preventDefault();
openProject({ variables: { name: input.value } });
};
return (
<div>
<Box mb={3}>
<Typography variant="h6">New project</Typography>
</Box>
<form onSubmit={handleNewProjectForm} autoComplete="off">
<TextField
className={classes.projectName}
color="secondary"
inputProps={{
id: "projectName",
ref: (node) => {
setInput(node);
},
}}
label="Project name"
placeholder="Project name…"
error={Boolean(error)}
helperText={error && error.message}
/>
<Button
className={classes.button}
type="submit"
variant="contained"
color="secondary"
size="large"
disabled={loading}
startIcon={loading ? <CircularProgress size={22} /> : <AddIcon />}
>
Create & open project
</Button>
</form>
</div>
);
}
export default NewProject;

View File

@ -1,317 +0,0 @@
import { gql, useMutation, useQuery } from "@apollo/client";
import {
Avatar,
Box,
Button,
CircularProgress,
createStyles,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
makeStyles,
Snackbar,
Theme,
Tooltip,
Typography,
} from "@material-ui/core";
import CloseIcon from "@material-ui/icons/Close";
import DescriptionIcon from "@material-ui/icons/Description";
import DeleteIcon from "@material-ui/icons/Delete";
import LaunchIcon from "@material-ui/icons/Launch";
import { Alert } from "@material-ui/lab";
import React, { useState } from "react";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
projectsList: {
backgroundColor: theme.palette.background.paper,
},
activeProject: {
color: theme.palette.getContrastText(theme.palette.secondary.main),
backgroundColor: theme.palette.secondary.main,
},
deleteProjectButton: {
color: theme.palette.error.main,
},
})
);
const PROJECTS = gql`
query Projects {
projects {
name
isActive
}
}
`;
const OPEN_PROJECT = gql`
mutation OpenProject($name: String!) {
openProject(name: $name) {
name
isActive
}
}
`;
const CLOSE_PROJECT = gql`
mutation CloseProject {
closeProject {
success
}
}
`;
const DELETE_PROJECT = gql`
mutation DeleteProject($name: String!) {
deleteProject(name: $name) {
success
}
}
`;
function ProjectList(): JSX.Element {
const classes = useStyles();
const { loading: projLoading, error: projErr, data: projData } = useQuery(
PROJECTS
);
const [
openProject,
{ error: openProjErr, loading: openProjLoading },
] = useMutation(OPEN_PROJECT, {
errorPolicy: "all",
onError: () => {},
update(cache, { data: { openProject } }) {
cache.modify({
fields: {
activeProject() {
const activeProjRef = cache.writeFragment({
data: openProject,
fragment: gql`
fragment ActiveProject on Project {
name
isActive
type
}
`,
});
return activeProjRef;
},
projects(_, { DELETE }) {
cache.writeFragment({
id: openProject.name,
data: openProject,
fragment: gql`
fragment OpenProject on Project {
name
isActive
type
}
`,
});
return DELETE;
},
httpRequestLogFilter(_, { DELETE }) {
return DELETE;
},
},
});
},
});
const [closeProject, { error: closeProjErr }] = useMutation(CLOSE_PROJECT, {
errorPolicy: "all",
onError: () => {},
update(cache) {
cache.modify({
fields: {
activeProject() {
return null;
},
projects(_, { DELETE }) {
return DELETE;
},
httpRequestLogFilter(_, { DELETE }) {
return DELETE;
},
},
});
},
});
const [
deleteProject,
{ loading: deleteProjLoading, error: deleteProjErr },
] = useMutation(DELETE_PROJECT, {
errorPolicy: "all",
onError: () => {},
update(cache) {
cache.modify({
fields: {
projects(_, { DELETE }) {
return DELETE;
},
},
});
setDeleteDiagOpen(false);
setDeleteNotifOpen(true);
},
});
const [deleteProjName, setDeleteProjName] = useState(null);
const [deleteDiagOpen, setDeleteDiagOpen] = useState(false);
const handleDeleteButtonClick = (name: string) => {
setDeleteProjName(name);
setDeleteDiagOpen(true);
};
const handleDeleteConfirm = () => {
deleteProject({ variables: { name: deleteProjName } });
};
const handleDeleteCancel = () => {
setDeleteDiagOpen(false);
};
const [deleteNotifOpen, setDeleteNotifOpen] = useState(false);
const handleCloseDeleteNotif = (_, reason?: string) => {
if (reason === "clickaway") {
return;
}
setDeleteNotifOpen(false);
};
return (
<div>
<Dialog open={deleteDiagOpen} onClose={handleDeleteCancel}>
<DialogTitle>
Delete project <strong>{deleteProjName}</strong>?
</DialogTitle>
<DialogContent>
<DialogContentText>
Deleting a project permanently removes its database file from disk.
This action is irreversible.
</DialogContentText>
{deleteProjErr && (
<Alert severity="error">
Error closing project: {deleteProjErr.message}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteCancel} autoFocus>
Cancel
</Button>
<Button
className={classes.deleteProjectButton}
onClick={handleDeleteConfirm}
disabled={deleteProjLoading}
>
Delete
</Button>
</DialogActions>
</Dialog>
<Snackbar
open={deleteNotifOpen}
autoHideDuration={3000}
onClose={handleCloseDeleteNotif}
>
<Alert onClose={handleCloseDeleteNotif} severity="info">
Project <strong>{deleteProjName}</strong> was deleted.
</Alert>
</Snackbar>
<Box mb={3}>
<Typography variant="h6">Manage projects</Typography>
</Box>
<Box mb={4}>
{projLoading && <CircularProgress />}
{projErr && (
<Alert severity="error">
Error fetching projects: {projErr.message}
</Alert>
)}
{openProjErr && (
<Alert severity="error">
Error opening project: {openProjErr.message}
</Alert>
)}
{closeProjErr && (
<Alert severity="error">
Error closing project: {closeProjErr.message}
</Alert>
)}
</Box>
{projData?.projects.length > 0 && (
<List className={classes.projectsList}>
{projData.projects.map((project) => (
<ListItem key={project.name}>
<ListItemAvatar>
<Avatar
className={
project.isActive ? classes.activeProject : undefined
}
>
<DescriptionIcon />
</Avatar>
</ListItemAvatar>
<ListItemText>
{project.name} {project.isActive && <em>(Active)</em>}
</ListItemText>
<ListItemSecondaryAction>
{project.isActive && (
<Tooltip title="Close project">
<IconButton onClick={() => closeProject()}>
<CloseIcon />
</IconButton>
</Tooltip>
)}
{!project.isActive && (
<Tooltip title="Open project">
<span>
<IconButton
disabled={openProjLoading || projLoading}
onClick={() =>
openProject({
variables: { name: project.name },
})
}
>
<LaunchIcon />
</IconButton>
</span>
</Tooltip>
)}
<Tooltip title="Delete project">
<span>
<IconButton
onClick={() => handleDeleteButtonClick(project.name)}
disabled={project.isActive}
>
<DeleteIcon />
</IconButton>
</span>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
{projData?.projects.length === 0 && (
<Alert severity="info">
There are no projects. Create one to get started.
</Alert>
)}
</div>
);
}
export default ProjectList;

View File

@ -1,60 +0,0 @@
import dynamic from "next/dynamic";
const MonacoEditor = dynamic(import("react-monaco-editor"), { ssr: false });
const monacoOptions = {
readOnly: true,
wordWrap: "on",
minimap: {
enabled: false,
},
};
type language = "html" | "typescript" | "json";
function editorDidMount() {
return ((window as any).MonacoEnvironment.getWorkerUrl = (
moduleId,
label
) => {
if (label === "json") return "/_next/static/json.worker.js";
if (label === "html") return "/_next/static/html.worker.js";
if (label === "javascript") return "/_next/static/ts.worker.js";
return "/_next/static/editor.worker.js";
});
}
function languageForContentType(contentType: string): language {
switch (contentType) {
case "text/html":
return "html";
case "application/json":
case "application/json; charset=utf-8":
return "json";
case "application/javascript":
case "application/javascript; charset=utf-8":
return "typescript";
default:
return;
}
}
interface Props {
content: string;
contentType: string;
}
function Editor({ content, contentType }: Props): JSX.Element {
return (
<MonacoEditor
height={"600px"}
language={languageForContentType(contentType)}
theme="vs-dark"
editorDidMount={editorDidMount}
options={monacoOptions as any}
value={content}
/>
);
}
export default Editor;

View File

@ -1,120 +0,0 @@
import {
makeStyles,
Theme,
createStyles,
Table,
TableBody,
TableCell,
TableContainer,
TableRow,
Snackbar,
} from "@material-ui/core";
import { Alert } from "@material-ui/lab";
import React, { useState } from "react";
const useStyles = makeStyles((theme: Theme) => {
const paddingX = 0;
const paddingY = theme.spacing(1) / 3;
const tableCell = {
paddingLeft: paddingX,
paddingRight: paddingX,
paddingTop: paddingY,
paddingBottom: paddingY,
verticalAlign: "top",
border: "none",
whiteSpace: "nowrap" as any,
overflow: "hidden",
textOverflow: "ellipsis",
"&:hover": {
color: theme.palette.secondary.main,
whiteSpace: "inherit" as any,
overflow: "inherit",
textOverflow: "inherit",
cursor: "copy",
},
};
return createStyles({
root: {},
table: {
tableLayout: "fixed",
width: "100%",
},
keyCell: {
...tableCell,
paddingRight: theme.spacing(1),
width: "40%",
fontWeight: "bold",
fontSize: ".75rem",
},
valueCell: {
...tableCell,
width: "60%",
border: "none",
fontSize: ".75rem",
},
});
});
interface Props {
headers: Array<{ key: string; value: string }>;
}
function HttpHeadersTable({ headers }: Props): JSX.Element {
const classes = useStyles();
const [open, setOpen] = useState(false);
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
const r = document.createRange();
r.selectNode(e.currentTarget);
window.getSelection().removeAllRanges();
window.getSelection().addRange(r);
document.execCommand("copy");
window.getSelection().removeAllRanges();
setOpen(true);
};
const handleClose = (event?: React.SyntheticEvent, reason?: string) => {
if (reason === "clickaway") {
return;
}
setOpen(false);
};
return (
<div>
<Snackbar open={open} autoHideDuration={3000} onClose={handleClose}>
<Alert onClose={handleClose} severity="info">
Copied to clipboard.
</Alert>
</Snackbar>
<TableContainer className={classes.root}>
<Table className={classes.table} size="small">
<TableBody>
{headers.map(({ key, value }, index) => (
<TableRow key={index}>
<TableCell
component="th"
scope="row"
className={classes.keyCell}
onClick={handleClick}
>
<code>{key}:</code>
</TableCell>
<TableCell className={classes.valueCell} onClick={handleClick}>
<code>{value}</code>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</div>
);
}
export default HttpHeadersTable;

View File

@ -1,31 +0,0 @@
import { Theme, withTheme } from "@material-ui/core";
import { orange, red } from "@material-ui/core/colors";
import FiberManualRecordIcon from "@material-ui/icons/FiberManualRecord";
interface Props {
status: number;
theme: Theme;
}
function HttpStatusIcon({ status, theme }: Props): JSX.Element {
const style = { marginTop: "-.25rem", verticalAlign: "middle" };
switch (Math.floor(status / 100)) {
case 2:
case 3:
return (
<FiberManualRecordIcon
style={{ ...style, color: theme.palette.secondary.main }}
/>
);
case 4:
return (
<FiberManualRecordIcon style={{ ...style, color: orange["A400"] }} />
);
case 5:
return <FiberManualRecordIcon style={{ ...style, color: red["A400"] }} />;
default:
return <FiberManualRecordIcon style={style} />;
}
}
export default withTheme(HttpStatusIcon);

View File

@ -1,84 +0,0 @@
import { gql, useQuery } from "@apollo/client";
import { Box, Grid, Paper, CircularProgress } from "@material-ui/core";
import ResponseDetail from "./ResponseDetail";
import RequestDetail from "./RequestDetail";
import Alert from "@material-ui/lab/Alert";
const HTTP_REQUEST_LOG = gql`
query HttpRequestLog($id: ID!) {
httpRequestLog(id: $id) {
id
method
url
proto
headers {
key
value
}
body
response {
proto
headers {
key
value
}
statusCode
statusReason
body
}
}
}
`;
interface Props {
requestId: number;
}
function LogDetail({ requestId: id }: Props): JSX.Element {
const { loading, error, data } = useQuery(HTTP_REQUEST_LOG, {
variables: { id },
});
if (loading) {
return <CircularProgress />;
}
if (error) {
return (
<Alert severity="error">
Error fetching logs details: {error.message}
</Alert>
);
}
if (!data.httpRequestLog) {
return (
<Alert severity="warning">
Request <strong>{id}</strong> was not found.
</Alert>
);
}
const { method, url, proto, headers, body, response } = data.httpRequestLog;
return (
<div>
<Grid container item spacing={2}>
<Grid item xs={6}>
<Box component={Paper}>
<RequestDetail request={{ method, url, proto, headers, body }} />
</Box>
</Grid>
<Grid item xs={6}>
{response && (
<Box component={Paper}>
<ResponseDetail response={response} />
</Box>
)}
</Grid>
</Grid>
</div>
);
}
export default LogDetail;

View File

@ -1,70 +0,0 @@
import { useRouter } from "next/router";
import Link from "next/link";
import {
Box,
CircularProgress,
Link as MaterialLink,
Typography,
} from "@material-ui/core";
import Alert from "@material-ui/lab/Alert";
import RequestList from "./RequestList";
import LogDetail from "./LogDetail";
import CenteredPaper from "../CenteredPaper";
import { useHttpRequestLogs } from "./hooks/useHttpRequestLogs";
function LogsOverview(): JSX.Element {
const router = useRouter();
const detailReqLogId =
router.query.id && parseInt(router.query.id as string, 10);
const { loading, error, data } = useHttpRequestLogs();
const handleLogClick = (reqId: number) => {
router.push("/proxy/logs?id=" + reqId, undefined, {
shallow: false,
});
};
if (loading) {
return <CircularProgress />;
}
if (error) {
if (error.graphQLErrors[0]?.extensions?.code === "no_active_project") {
return (
<Alert severity="info">
There is no project active.{" "}
<Link href="/projects" passHref>
<MaterialLink color="secondary">Create or open</MaterialLink>
</Link>{" "}
one first.
</Alert>
);
}
return <Alert severity="error">Error fetching logs: {error.message}</Alert>;
}
const { httpRequestLogs: logs } = data;
return (
<div>
<Box mb={2}>
<RequestList
logs={logs}
selectedReqLogId={detailReqLogId}
onLogClick={handleLogClick}
/>
</Box>
<Box>
{detailReqLogId && <LogDetail requestId={detailReqLogId} />}
{logs.length !== 0 && !detailReqLogId && (
<CenteredPaper>
<Typography>Select a log entry</Typography>
</CenteredPaper>
)}
</Box>
</div>
);
}
export default LogsOverview;

View File

@ -1,91 +0,0 @@
import React from "react";
import {
Typography,
Box,
createStyles,
makeStyles,
Theme,
Divider,
} from "@material-ui/core";
import HttpHeadersTable from "./HttpHeadersTable";
import Editor from "./Editor";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
requestTitle: {
width: "calc(100% - 80px)",
fontSize: "1rem",
wordBreak: "break-all",
whiteSpace: "pre-wrap",
},
headersTable: {
tableLayout: "fixed",
width: "100%",
},
headerKeyCell: {
verticalAlign: "top",
width: "30%",
fontWeight: "bold",
},
headerValueCell: {
width: "70%",
verticalAlign: "top",
wordBreak: "break-all",
whiteSpace: "pre-wrap",
},
})
);
interface Props {
request: {
method: string;
url: string;
proto: string;
headers: Array<{ key: string; value: string }>;
body?: string;
};
}
function RequestDetail({ request }: Props): JSX.Element {
const { method, url, proto, headers, body } = request;
const classes = useStyles();
const contentType = headers.find((header) => header.key === "Content-Type")
?.value;
const parsedUrl = new URL(url);
return (
<div>
<Box p={2}>
<Typography
variant="overline"
color="textSecondary"
style={{ float: "right" }}
>
Request
</Typography>
<Typography className={classes.requestTitle} variant="h6">
{method} {decodeURIComponent(parsedUrl.pathname + parsedUrl.search)}{" "}
<Typography
component="span"
color="textSecondary"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
{proto}
</Typography>
</Typography>
</Box>
<Divider />
<Box p={2}>
<HttpHeadersTable headers={headers} />
</Box>
{body && <Editor content={body} contentType={contentType} />}
</div>
);
}
export default RequestDetail;

View File

@ -1,146 +0,0 @@
import {
TableContainer,
Paper,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
Typography,
Box,
createStyles,
makeStyles,
Theme,
withTheme,
} from "@material-ui/core";
import HttpStatusIcon from "./HttpStatusCode";
import CenteredPaper from "../CenteredPaper";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
row: {
"&:hover": {
cursor: "pointer",
},
},
/* Pseudo-class applied to the root element if `hover={true}`. */
hover: {},
})
);
interface Props {
logs: Array<any>;
selectedReqLogId?: number;
onLogClick(requestId: number): void;
theme: Theme;
}
function RequestList({
logs,
onLogClick,
selectedReqLogId,
theme,
}: Props): JSX.Element {
return (
<div>
<RequestListTable
onLogClick={onLogClick}
logs={logs}
selectedReqLogId={selectedReqLogId}
theme={theme}
/>
{logs.length === 0 && (
<Box my={1}>
<CenteredPaper>
<Typography>No logs found.</Typography>
</CenteredPaper>
</Box>
)}
</div>
);
}
interface RequestListTableProps {
logs?: any;
selectedReqLogId?: number;
onLogClick(requestId: number): void;
theme: Theme;
}
function RequestListTable({
logs,
selectedReqLogId,
onLogClick,
theme,
}: RequestListTableProps): JSX.Element {
const classes = useStyles();
return (
<TableContainer
component={Paper}
style={{
minHeight: logs.length ? 200 : 0,
height: logs.length ? "24vh" : "inherit",
}}
>
<Table stickyHeader size="small">
<TableHead>
<TableRow>
<TableCell>Method</TableCell>
<TableCell>Origin</TableCell>
<TableCell>Path</TableCell>
<TableCell>Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{logs.map(({ id, method, url, response }) => {
const { origin, pathname, search, hash } = new URL(url);
const cellStyle = {
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
} as any;
const rowStyle = {
backgroundColor:
id === selectedReqLogId && theme.palette.action.selected,
};
return (
<TableRow
key={id}
className={classes.row}
style={rowStyle}
hover
onClick={() => onLogClick(id)}
>
<TableCell style={{ ...cellStyle, width: "100px" }}>
<code>{method}</code>
</TableCell>
<TableCell style={{ ...cellStyle, maxWidth: "100px" }}>
{origin}
</TableCell>
<TableCell style={{ ...cellStyle, maxWidth: "200px" }}>
{decodeURIComponent(pathname + search + hash)}
</TableCell>
<TableCell style={{ maxWidth: "100px" }}>
{response && (
<div>
<HttpStatusIcon status={response.statusCode} />{" "}
<code>
{response.statusCode} {response.statusReason}
</code>
</div>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);
}
export default withTheme(RequestList);

View File

@ -1,62 +0,0 @@
import { Typography, Box, Divider } from "@material-ui/core";
import HttpStatusIcon from "./HttpStatusCode";
import Editor from "./Editor";
import HttpHeadersTable from "./HttpHeadersTable";
interface Props {
response: {
proto: string;
statusCode: number;
statusReason: string;
headers: Array<{ key: string; value: string }>;
body?: string;
};
}
function ResponseDetail({ response }: Props): JSX.Element {
const contentType = response.headers.find(
(header) => header.key === "Content-Type"
)?.value;
return (
<div>
<Box p={2}>
<Typography
variant="overline"
color="textSecondary"
style={{ float: "right" }}
>
Response
</Typography>
<Typography
variant="h6"
style={{ fontSize: "1rem", whiteSpace: "nowrap" }}
>
<HttpStatusIcon status={response.statusCode} />{" "}
<Typography component="span" color="textSecondary">
<Typography
component="span"
color="textSecondary"
style={{ fontFamily: "'JetBrains Mono', monospace" }}
>
{response.proto}
</Typography>
</Typography>{" "}
{response.statusCode} {response.statusReason}
</Typography>
</Box>
<Divider />
<Box p={2}>
<HttpHeadersTable headers={response.headers} />
</Box>
{response.body && (
<Editor content={response.body} contentType={contentType} />
)}
</div>
);
}
export default ResponseDetail;

View File

@ -1,253 +0,0 @@
import {
Box,
Checkbox,
CircularProgress,
ClickAwayListener,
createStyles,
FormControlLabel,
InputBase,
makeStyles,
Paper,
Popper,
Theme,
Tooltip,
useTheme,
} from "@material-ui/core";
import IconButton from "@material-ui/core/IconButton";
import SearchIcon from "@material-ui/icons/Search";
import FilterListIcon from "@material-ui/icons/FilterList";
import DeleteIcon from "@material-ui/icons/Delete";
import React, { useRef, useState } from "react";
import { gql, useMutation, useQuery } from "@apollo/client";
import { withoutTypename } from "../../lib/omitTypename";
import { Alert } from "@material-ui/lab";
import { useClearHTTPRequestLog } from "./hooks/useClearHTTPRequestLog";
import {
ConfirmationDialog,
useConfirmationDialog,
} from "./ConfirmationDialog";
const FILTER = gql`
query HttpRequestLogFilter {
httpRequestLogFilter {
onlyInScope
searchExpression
}
}
`;
const SET_FILTER = gql`
mutation SetHttpRequestLogFilter($filter: HttpRequestLogFilterInput) {
setHttpRequestLogFilter(filter: $filter) {
onlyInScope
searchExpression
}
}
`;
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
padding: "2px 4px",
display: "flex",
alignItems: "center",
width: 400,
},
input: {
marginLeft: theme.spacing(1),
flex: 1,
},
iconButton: {
padding: 10,
},
filterPopper: {
width: 400,
marginTop: 6,
zIndex: 99,
},
filterOptions: {
padding: theme.spacing(2),
},
filterLoading: {
marginRight: 1,
color: theme.palette.text.primary,
},
})
);
export interface SearchFilter {
onlyInScope: boolean;
searchExpression: string;
}
function Search(): JSX.Element {
const classes = useStyles();
const theme = useTheme();
const [searchExpr, setSearchExpr] = useState("");
const { loading: filterLoading, error: filterErr, data: filter } = useQuery(
FILTER,
{
onCompleted: (data) => {
setSearchExpr(data.httpRequestLogFilter.searchExpression || "");
},
}
);
const [
setFilterMutate,
{ error: setFilterErr, loading: setFilterLoading },
] = useMutation<{
setHttpRequestLogFilter: SearchFilter | null;
}>(SET_FILTER, {
update(cache, { data: { setHttpRequestLogFilter } }) {
cache.writeQuery({
query: FILTER,
data: {
httpRequestLogFilter: setHttpRequestLogFilter,
},
});
},
});
const [
clearHTTPRequestLog,
clearHTTPRequestLogResult,
] = useClearHTTPRequestLog();
const clearHTTPConfirmationDialog = useConfirmationDialog();
const filterRef = useRef<HTMLElement | null>();
const [filterOpen, setFilterOpen] = useState(false);
const handleSubmit = (e: React.SyntheticEvent) => {
setFilterMutate({
variables: {
filter: {
...withoutTypename(filter?.httpRequestLogFilter),
searchExpression: searchExpr,
},
},
});
setFilterOpen(false);
e.preventDefault();
};
const handleClickAway = (event: React.MouseEvent<EventTarget>) => {
if (filterRef.current.contains(event.target as HTMLElement)) {
return;
}
setFilterOpen(false);
};
return (
<Box>
<Error prefix="Error fetching filter" error={filterErr} />
<Error prefix="Error setting filter" error={setFilterErr} />
<Error
prefix="Error clearing all HTTP logs"
error={clearHTTPRequestLogResult.error}
/>
<Box style={{ display: "flex", flex: 1 }}>
<ClickAwayListener onClickAway={handleClickAway}>
<Paper
component="form"
onSubmit={handleSubmit}
ref={filterRef}
className={classes.root}
>
<Tooltip title="Toggle filter options">
<IconButton
className={classes.iconButton}
onClick={() => setFilterOpen(!filterOpen)}
style={{
color: filter?.httpRequestLogFilter?.onlyInScope
? theme.palette.secondary.main
: "inherit",
}}
>
{filterLoading || setFilterLoading ? (
<CircularProgress
className={classes.filterLoading}
size={23}
/>
) : (
<FilterListIcon />
)}
</IconButton>
</Tooltip>
<InputBase
className={classes.input}
placeholder="Search proxy logs…"
value={searchExpr}
onChange={(e) => setSearchExpr(e.target.value)}
onFocus={() => setFilterOpen(true)}
/>
<Tooltip title="Search">
<IconButton type="submit" className={classes.iconButton}>
<SearchIcon />
</IconButton>
</Tooltip>
<Popper
className={classes.filterPopper}
open={filterOpen}
anchorEl={filterRef.current}
placement="bottom-start"
>
<Paper className={classes.filterOptions}>
<FormControlLabel
control={
<Checkbox
checked={
filter?.httpRequestLogFilter?.onlyInScope ? true : false
}
disabled={filterLoading || setFilterLoading}
onChange={(e) =>
setFilterMutate({
variables: {
filter: {
...withoutTypename(filter?.httpRequestLogFilter),
onlyInScope: e.target.checked,
},
},
})
}
/>
}
label="Only show in-scope requests"
/>
</Paper>
</Popper>
</Paper>
</ClickAwayListener>
<Box style={{ marginLeft: "auto" }}>
<Tooltip title="Clear all">
<IconButton onClick={clearHTTPConfirmationDialog.open}>
<DeleteIcon />
</IconButton>
</Tooltip>
</Box>
</Box>
<ConfirmationDialog
isOpen={clearHTTPConfirmationDialog.isOpen}
onClose={clearHTTPConfirmationDialog.close}
onConfirm={clearHTTPRequestLog}
>
All proxy logs are going to be removed. This action cannot be undone.
</ConfirmationDialog>
</Box>
);
}
function Error(props: { prefix: string; error?: Error }) {
if (!props.error) return null;
return (
<Box mb={4}>
<Alert severity="error">
{props.prefix}: {props.error.message}
</Alert>
</Box>
);
}
export default Search;

View File

@ -1,16 +0,0 @@
import { gql, useMutation } from "@apollo/client";
import { HTTP_REQUEST_LOGS } from "./useHttpRequestLogs";
const CLEAR_HTTP_REQUEST_LOG = gql`
mutation ClearHTTPRequestLog {
clearHTTPRequestLog {
success
}
}
`;
export function useClearHTTPRequestLog() {
return useMutation(CLEAR_HTTP_REQUEST_LOG, {
refetchQueries: [{ query: HTTP_REQUEST_LOGS }],
});
}

View File

@ -1,22 +0,0 @@
import { gql, useQuery } from "@apollo/client";
export const HTTP_REQUEST_LOGS = gql`
query HttpRequestLogs {
httpRequestLogs {
id
method
url
timestamp
response {
statusCode
statusReason
}
}
}
`;
export function useHttpRequestLogs() {
return useQuery(HTTP_REQUEST_LOGS, {
pollInterval: 1000,
});
}

View File

@ -1,55 +0,0 @@
import { gql, useQuery } from "@apollo/client";
import {
CircularProgress,
createStyles,
List,
makeStyles,
Theme,
} from "@material-ui/core";
import { Alert } from "@material-ui/lab";
import React from "react";
import RuleListItem from "./RuleListItem";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
rulesList: {
backgroundColor: theme.palette.background.paper,
},
})
);
export const SCOPE = gql`
query Scope {
scope {
url
}
}
`;
function Rules(): JSX.Element {
const classes = useStyles();
const { loading, error, data } = useQuery(SCOPE);
return (
<div>
{loading && <CircularProgress />}
{error && (
<Alert severity="error">Error fetching scope: {error.message}</Alert>
)}
{data?.scope.length > 0 && (
<List className={classes.rulesList}>
{data.scope.map((rule, index) => (
<RuleListItem
key={index}
rule={rule}
scope={data.scope}
index={index}
/>
))}
</List>
)}
</div>
);
}
export default Rules;

View File

@ -0,0 +1,252 @@
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import FolderIcon from "@mui/icons-material/Folder";
import HomeIcon from "@mui/icons-material/Home";
import LocationSearchingIcon from "@mui/icons-material/LocationSearching";
import MenuIcon from "@mui/icons-material/Menu";
import SendIcon from "@mui/icons-material/Send";
import SettingsEthernetIcon from "@mui/icons-material/SettingsEthernet";
import {
Theme,
useTheme,
Toolbar,
IconButton,
Typography,
Divider,
List,
Tooltip,
styled,
CSSObject,
Box,
ListItemText,
} from "@mui/material";
import MuiAppBar, { AppBarProps as MuiAppBarProps } from "@mui/material/AppBar";
import MuiDrawer from "@mui/material/Drawer";
import MuiListItemButton, { ListItemButtonProps } from "@mui/material/ListItemButton";
import MuiListItemIcon, { ListItemIconProps } from "@mui/material/ListItemIcon";
import Link from "next/link";
import React, { useState } from "react";
import { useActiveProject } from "lib/ActiveProjectContext";
export enum Page {
Home,
GetStarted,
Projects,
ProxySetup,
ProxyLogs,
Sender,
Scope,
}
const drawerWidth = 240;
const openedMixin = (theme: Theme): CSSObject => ({
width: drawerWidth,
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
overflowX: "hidden",
});
const closedMixin = (theme: Theme): CSSObject => ({
transition: theme.transitions.create("width", {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
overflowX: "hidden",
width: 56,
});
const DrawerHeader = styled("div")(({ theme }) => ({
display: "flex",
alignItems: "center",
justifyContent: "flex-start",
padding: theme.spacing(0, 1),
// necessary for content to be below app bar
...theme.mixins.toolbar,
}));
interface AppBarProps extends MuiAppBarProps {
open?: boolean;
}
const AppBar = styled(MuiAppBar, {
shouldForwardProp: (prop) => prop !== "open",
})<AppBarProps>(({ theme, open }) => ({
backgroundColor: theme.palette.secondary.dark,
zIndex: theme.zIndex.drawer + 1,
transition: theme.transitions.create(["width", "margin"], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
...(open && {
marginLeft: drawerWidth,
width: `calc(100% - ${drawerWidth}px)`,
transition: theme.transitions.create(["width", "margin"], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.enteringScreen,
}),
}),
}));
const Drawer = styled(MuiDrawer, { shouldForwardProp: (prop) => prop !== "open" })(({ theme, open }) => ({
width: drawerWidth,
flexShrink: 0,
whiteSpace: "nowrap",
boxSizing: "border-box",
...(open && {
...openedMixin(theme),
"& .MuiDrawer-paper": openedMixin(theme),
}),
...(!open && {
...closedMixin(theme),
"& .MuiDrawer-paper": closedMixin(theme),
}),
}));
const ListItemButton = styled(MuiListItemButton)<ListItemButtonProps>(({ theme }) => ({
[theme.breakpoints.up("sm")]: {
px: 1,
},
"&.MuiListItemButton-root": {
"&.Mui-selected": {
backgroundColor: theme.palette.primary.main,
"& .MuiListItemIcon-root": {
color: theme.palette.secondary.dark,
},
"& .MuiListItemText-root": {
color: theme.palette.secondary.dark,
},
},
},
}));
const ListItemIcon = styled(MuiListItemIcon)<ListItemIconProps>(() => ({
minWidth: 42,
}));
interface Props {
children: React.ReactNode;
title: string;
page: Page;
}
export function Layout({ title, page, children }: Props): JSX.Element {
const activeProject = useActiveProject();
const theme = useTheme();
const [open, setOpen] = useState(false);
const handleDrawerOpen = () => {
setOpen(true);
};
const handleDrawerClose = () => {
setOpen(false);
};
const SiteTitle = styled("span")({
...(title !== "" && {
color: theme.palette.primary.main,
marginRight: 4,
}),
});
return (
<Box sx={{ display: "flex", height: "100%" }}>
<AppBar position="fixed" open={open}>
<Toolbar>
<IconButton
color="inherit"
aria-label="Open drawer"
onClick={handleDrawerOpen}
edge="start"
sx={{
mr: 5,
...(open && { display: "none" }),
}}
>
<MenuIcon />
</IconButton>
<Box
sx={{
display: "flex",
justifyContent: "space-around",
width: "100%",
}}
>
<Typography variant="h5" noWrap sx={{ width: "100%" }}>
<SiteTitle>Hetty://</SiteTitle>
{title}
</Typography>
<Box sx={{ flexShrink: 0, pt: 0.75 }}>v{process.env.NEXT_PUBLIC_VERSION || "0.0"}</Box>
</Box>
</Toolbar>
</AppBar>
<Drawer variant="permanent" open={open}>
<DrawerHeader>
<IconButton onClick={handleDrawerClose}>
{theme.direction === "rtl" ? <ChevronRightIcon /> : <ChevronLeftIcon />}
</IconButton>
</DrawerHeader>
<Divider />
<List sx={{ p: 0 }}>
<Link href="/" passHref>
<ListItemButton key="home" selected={page === Page.Home}>
<Tooltip title="Home">
<ListItemIcon>
<HomeIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Home" />
</ListItemButton>
</Link>
<Link href="/proxy/logs" passHref>
<ListItemButton key="proxyLogs" disabled={!activeProject} selected={page === Page.ProxyLogs}>
<Tooltip title="Proxy">
<ListItemIcon>
<SettingsEthernetIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Proxy" />
</ListItemButton>
</Link>
<Link href="/sender" passHref>
<ListItemButton key="sender" disabled={!activeProject} selected={page === Page.Sender}>
<Tooltip title="Sender">
<ListItemIcon>
<SendIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Sender" />
</ListItemButton>
</Link>
<Link href="/scope" passHref>
<ListItemButton key="scope" disabled={!activeProject} selected={page === Page.Scope}>
<Tooltip title="Scope">
<ListItemIcon>
<LocationSearchingIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Scope" />
</ListItemButton>
</Link>
<Link href="/projects" passHref>
<ListItemButton key="projects" selected={page === Page.Projects}>
<Tooltip title="Projects">
<ListItemIcon>
<FolderIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Projects" />
</ListItemButton>
</Link>
</List>
</Drawer>
<Box component="main" sx={{ flexGrow: 1, mx: 3, mt: 11 }}>
{children}
</Box>
</Box>
);
}

View File

@ -0,0 +1,67 @@
import AddIcon from "@mui/icons-material/Add";
import { Box, Button, CircularProgress, TextField, Typography } from "@mui/material";
import React, { useState } from "react";
import useOpenProjectMutation from "../hooks/useOpenProjectMutation";
import { useCreateProjectMutation } from "lib/graphql/generated";
function NewProject(): JSX.Element {
const [name, setName] = useState("");
const [createProject, createProjResult] = useCreateProjectMutation({
onCompleted(data) {
setName("");
if (data?.createProject) {
openProject({ variables: { id: data.createProject?.id } });
}
},
});
const [openProject, openProjResult] = useOpenProjectMutation();
const handleCreateAndOpenProjectForm = (e: React.SyntheticEvent) => {
e.preventDefault();
createProject({ variables: { name } });
};
return (
<div>
<Box mb={3}>
<Typography variant="h6">New project</Typography>
</Box>
<form onSubmit={handleCreateAndOpenProjectForm} autoComplete="off">
<TextField
sx={{
mr: 2,
}}
color="primary"
size="small"
label="Project name"
placeholder="Project name…"
onChange={(e) => setName(e.target.value)}
error={Boolean(createProjResult.error || openProjResult.error)}
helperText={
(createProjResult.error && createProjResult.error.message) ||
(openProjResult.error && openProjResult.error.message)
}
/>
<Button
type="submit"
variant="contained"
color="primary"
size="large"
sx={{
pt: 0.9,
pb: 0.7,
}}
disabled={createProjResult.loading || openProjResult.loading}
startIcon={createProjResult.loading || openProjResult.loading ? <CircularProgress size={22} /> : <AddIcon />}
>
Create & open project
</Button>
</form>
</div>
);
}
export default NewProject;

View File

@ -0,0 +1,225 @@
import CloseIcon from "@mui/icons-material/Close";
import DeleteIcon from "@mui/icons-material/Delete";
import DescriptionIcon from "@mui/icons-material/Description";
import LaunchIcon from "@mui/icons-material/Launch";
import { Alert } from "@mui/lab";
import {
Avatar,
Box,
Button,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
Paper,
Snackbar,
Tooltip,
Typography,
useTheme,
} from "@mui/material";
import React, { useState } from "react";
import useOpenProjectMutation from "../hooks/useOpenProjectMutation";
import {
ProjectsQuery,
useCloseProjectMutation,
useDeleteProjectMutation,
useProjectsQuery,
} from "lib/graphql/generated";
function ProjectList(): JSX.Element {
const theme = useTheme();
const projResult = useProjectsQuery({ fetchPolicy: "network-only" });
const [openProject, openProjResult] = useOpenProjectMutation();
const [closeProject, closeProjResult] = useCloseProjectMutation({
errorPolicy: "all",
onCompleted() {
closeProjResult.client.resetStore();
},
update(cache) {
cache.modify({
fields: {
activeProject() {
return null;
},
projects(_, { DELETE }) {
return DELETE;
},
httpRequestLogFilter(_, { DELETE }) {
return DELETE;
},
},
});
},
});
const [deleteProject, deleteProjResult] = useDeleteProjectMutation({
errorPolicy: "all",
update(cache) {
cache.modify({
fields: {
projects(_, { DELETE }) {
return DELETE;
},
},
});
setDeleteDiagOpen(false);
setDeleteNotifOpen(true);
},
});
const [deleteProj, setDeleteProj] = useState<ProjectsQuery["projects"][number]>();
const [deleteDiagOpen, setDeleteDiagOpen] = useState(false);
const handleDeleteButtonClick = (project: ProjectsQuery["projects"][number]) => {
setDeleteProj(project);
setDeleteDiagOpen(true);
};
const handleDeleteConfirm = () => {
if (deleteProj) {
deleteProject({ variables: { id: deleteProj.id } });
}
};
const handleDeleteCancel = () => {
setDeleteDiagOpen(false);
};
const [deleteNotifOpen, setDeleteNotifOpen] = useState(false);
const handleCloseDeleteNotif = (_: Event | React.SyntheticEvent, reason?: string) => {
if (reason === "clickaway") {
return;
}
setDeleteNotifOpen(false);
};
return (
<div>
<Dialog open={deleteDiagOpen} onClose={handleDeleteCancel}>
<DialogTitle>
Delete project <strong>{deleteProj?.name}</strong>?
</DialogTitle>
<DialogContent>
<DialogContentText>
Deleting a project permanently removes all its data from the database. This action is irreversible.
</DialogContentText>
{deleteProjResult.error && (
<Alert severity="error">Error closing project: {deleteProjResult.error.message}</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleDeleteCancel} autoFocus color="secondary" variant="contained">
Cancel
</Button>
<Button
sx={{
color: "white",
backgroundColor: "error.main",
"&:hover": {
backgroundColor: "error.dark",
},
}}
onClick={handleDeleteConfirm}
disabled={deleteProjResult.loading}
variant="contained"
>
Delete
</Button>
</DialogActions>
</Dialog>
<Snackbar
open={deleteNotifOpen}
autoHideDuration={3000}
onClose={handleCloseDeleteNotif}
anchorOrigin={{ horizontal: "center", vertical: "bottom" }}
>
<Alert onClose={handleCloseDeleteNotif} severity="info">
Project <strong>{deleteProj?.name}</strong> was deleted.
</Alert>
</Snackbar>
<Box mb={3}>
<Typography variant="h6">Manage projects</Typography>
</Box>
<Box mb={4}>
{projResult.loading && <CircularProgress />}
{projResult.error && <Alert severity="error">Error fetching projects: {projResult.error.message}</Alert>}
{openProjResult.error && <Alert severity="error">Error opening project: {openProjResult.error.message}</Alert>}
{closeProjResult.error && (
<Alert severity="error">Error closing project: {closeProjResult.error.message}</Alert>
)}
</Box>
{projResult.data && projResult.data.projects.length > 0 && (
<Paper>
<List>
{projResult.data.projects.map((project) => (
<ListItem key={project.id}>
<ListItemAvatar>
<Avatar
sx={{
...(project.isActive && {
color: theme.palette.secondary.dark,
backgroundColor: theme.palette.primary.main,
}),
}}
>
<DescriptionIcon />
</Avatar>
</ListItemAvatar>
<ListItemText>
{project.name} {project.isActive && <em>(Active)</em>}
</ListItemText>
<ListItemSecondaryAction>
{project.isActive && (
<Tooltip title="Close project">
<IconButton onClick={() => closeProject()}>
<CloseIcon />
</IconButton>
</Tooltip>
)}
{!project.isActive && (
<Tooltip title="Open project">
<span>
<IconButton
disabled={openProjResult.loading || projResult.loading}
onClick={() =>
openProject({
variables: { id: project.id },
})
}
>
<LaunchIcon />
</IconButton>
</span>
</Tooltip>
)}
<Tooltip title="Delete project">
<span>
<IconButton onClick={() => handleDeleteButtonClick(project)} disabled={project.isActive}>
<DeleteIcon />
</IconButton>
</span>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
</Paper>
)}
{projResult.data?.projects.length === 0 && (
<Alert severity="info">There are no projects. Create one to get started.</Alert>
)}
</div>
);
}
export default ProjectList;

View File

@ -0,0 +1,5 @@
mutation CloseProject {
closeProject {
success
}
}

View File

@ -0,0 +1,6 @@
mutation CreateProject($name: String!) {
createProject(name: $name) {
id
name
}
}

View File

@ -0,0 +1,5 @@
mutation DeleteProject($id: ID!) {
deleteProject(id: $id) {
success
}
}

View File

@ -0,0 +1,7 @@
mutation OpenProject($id: ID!) {
openProject(id: $id) {
id
name
isActive
}
}

View File

@ -0,0 +1,7 @@
query Projects {
projects {
id
name
isActive
}
}

View File

@ -0,0 +1,47 @@
import { gql } from "@apollo/client";
import { useOpenProjectMutation as _useOpenProjectMutation } from "lib/graphql/generated";
export default function useOpenProjectMutation() {
return _useOpenProjectMutation({
errorPolicy: "all",
update(cache, { data }) {
cache.modify({
fields: {
activeProject() {
const activeProjRef = cache.writeFragment({
data: data?.openProject,
fragment: gql`
fragment ActiveProject on Project {
id
name
isActive
type
}
`,
});
return activeProjRef;
},
projects(_, { DELETE }) {
cache.writeFragment({
id: data?.openProject?.id,
data: data?.openProject,
fragment: gql`
fragment OpenProject on Project {
id
name
isActive
type
}
`,
});
return DELETE;
},
httpRequestLogFilter(_, { DELETE }) {
return DELETE;
},
},
});
},
});
}

View File

@ -0,0 +1,57 @@
import Alert from "@mui/lab/Alert";
import { Box, CircularProgress, Paper, Typography } from "@mui/material";
import RequestDetail from "./RequestDetail";
import Response from "lib/components/Response";
import SplitPane from "lib/components/SplitPane";
import { useHttpRequestLogQuery } from "lib/graphql/generated";
interface Props {
id?: string;
}
function LogDetail({ id }: Props): JSX.Element {
const { loading, error, data } = useHttpRequestLogQuery({
variables: { id: id as string },
skip: id === undefined,
});
if (loading) {
return <CircularProgress />;
}
if (error) {
return <Alert severity="error">Error fetching logs details: {error.message}</Alert>;
}
if (data && !data.httpRequestLog) {
return (
<Alert severity="warning">
Request <strong>{id}</strong> was not found.
</Alert>
);
}
if (!data?.httpRequestLog) {
return (
<Paper variant="centered" sx={{ mt: 2 }}>
<Typography>Select a log entry</Typography>
</Paper>
);
}
const reqLog = data.httpRequestLog;
return (
<SplitPane split="vertical" size={"50%"}>
<RequestDetail request={reqLog} />
{reqLog.response && (
<Box sx={{ height: "100%", pt: 1, pl: 2, pb: 2 }}>
<Response response={reqLog.response} />
</Box>
)}
</SplitPane>
);
}
export default LogDetail;

View File

@ -0,0 +1,47 @@
import { Typography, Box } from "@mui/material";
import React from "react";
import RequestTabs from "lib/components/RequestTabs";
import { HttpRequestLogQuery } from "lib/graphql/generated";
import { queryParamsFromURL } from "lib/queryParamsFromURL";
interface Props {
request: NonNullable<HttpRequestLogQuery["httpRequestLog"]>;
}
function RequestDetail({ request }: Props): JSX.Element {
const { method, url, headers, body } = request;
const parsedUrl = new URL(url);
return (
<Box sx={{ height: "100%", display: "flex", flexDirection: "column", pr: 2, pb: 2 }}>
<Box sx={{ p: 2, pb: 0 }}>
<Typography variant="overline" color="textSecondary" style={{ float: "right" }}>
Request
</Typography>
<Typography
variant="h6"
component="h2"
sx={{
fontSize: "1rem",
fontFamily: "'JetBrains Mono', monospace",
display: "block",
overflow: "hidden",
whiteSpace: "nowrap",
textOverflow: "ellipsis",
pr: 2,
}}
>
{method} {decodeURIComponent(parsedUrl.pathname + parsedUrl.search)}
</Typography>
</Box>
<Box flex="1 auto" overflow="scroll">
<RequestTabs headers={headers} queryParams={queryParamsFromURL(url)} body={body} />
</Box>
</Box>
);
}
export default RequestDetail;

View File

@ -0,0 +1,110 @@
import { ContentCopy } from "@mui/icons-material";
import { Alert, Box, IconButton, Link, MenuItem, Snackbar, Tooltip } from "@mui/material";
import { useRouter } from "next/router";
import { useState } from "react";
import LogDetail from "./LogDetail";
import Search from "./Search";
import RequestsTable from "lib/components/RequestsTable";
import SplitPane from "lib/components/SplitPane";
import useContextMenu from "lib/components/useContextMenu";
import { useCreateSenderRequestFromHttpRequestLogMutation, useHttpRequestLogsQuery } from "lib/graphql/generated";
export function RequestLogs(): JSX.Element {
const router = useRouter();
const id = router.query.id as string | undefined;
const { data } = useHttpRequestLogsQuery({
pollInterval: 1000,
});
const [createSenderReqFromLog] = useCreateSenderRequestFromHttpRequestLogMutation({
onCompleted({ createSenderRequestFromHttpRequestLog }) {
const { id } = createSenderRequestFromHttpRequestLog;
setNewSenderReqId(id);
setCopiedReqNotifOpen(true);
},
});
const [copyToSenderId, setCopyToSenderId] = useState("");
const [Menu, handleContextMenu, handleContextMenuClose] = useContextMenu();
const handleCopyToSenderClick = () => {
createSenderReqFromLog({
variables: {
id: copyToSenderId,
},
});
handleContextMenuClose();
};
const [newSenderReqId, setNewSenderReqId] = useState("");
const [copiedReqNotifOpen, setCopiedReqNotifOpen] = useState(false);
const handleCloseCopiedNotif = (_: Event | React.SyntheticEvent, reason?: string) => {
if (reason === "clickaway") {
return;
}
setCopiedReqNotifOpen(false);
};
const handleRowClick = (id: string) => {
router.push(`/proxy/logs?id=${id}`);
};
const handleRowContextClick = (e: React.MouseEvent, id: string) => {
setCopyToSenderId(id);
handleContextMenu(e);
};
const handleCopyToSenderActionClick = (id: string) => {
setCopyToSenderId(id);
createSenderReqFromLog({
variables: {
id,
},
});
};
const rowActions = (id: string): JSX.Element => (
<Tooltip title="Copy to Sender">
<IconButton size="small" onClick={() => handleCopyToSenderActionClick(id)}>
<ContentCopy fontSize="inherit" />
</IconButton>
</Tooltip>
);
return (
<Box display="flex" flexDirection="column" height="100%">
<Search />
<Box sx={{ display: "flex", flex: "1 auto", position: "relative" }}>
<SplitPane split="horizontal" size={"40%"}>
<Box sx={{ width: "100%", height: "100%", pb: 2 }}>
<Box sx={{ width: "100%", height: "100%", overflow: "scroll" }}>
<Menu>
<MenuItem onClick={handleCopyToSenderClick}>Copy request to Sender</MenuItem>
</Menu>
<Snackbar
open={copiedReqNotifOpen}
autoHideDuration={3000}
onClose={handleCloseCopiedNotif}
anchorOrigin={{ horizontal: "center", vertical: "bottom" }}
>
<Alert onClose={handleCloseCopiedNotif} severity="info">
Request was copied. <Link href={`/sender?id=${newSenderReqId}`}>Edit in Sender.</Link>
</Alert>
</Snackbar>
<RequestsTable
requests={data?.httpRequestLogs || []}
activeRowId={id}
onRowClick={handleRowClick}
onContextMenu={handleRowContextClick}
rowActions={rowActions}
/>
</Box>
</Box>
<LogDetail id={id} />
</SplitPane>
</Box>
</Box>
);
}

View File

@ -0,0 +1,195 @@
import DeleteIcon from "@mui/icons-material/Delete";
import FilterListIcon from "@mui/icons-material/FilterList";
import SearchIcon from "@mui/icons-material/Search";
import { Alert } from "@mui/lab";
import {
Box,
Checkbox,
CircularProgress,
ClickAwayListener,
FormControlLabel,
InputBase,
Paper,
Popper,
Tooltip,
useTheme,
} from "@mui/material";
import IconButton from "@mui/material/IconButton";
import React, { useRef, useState } from "react";
import { ConfirmationDialog, useConfirmationDialog } from "lib/components/ConfirmationDialog";
import {
HttpRequestLogFilterDocument,
HttpRequestLogsDocument,
useClearHttpRequestLogMutation,
useHttpRequestLogFilterQuery,
useSetHttpRequestLogFilterMutation,
} from "lib/graphql/generated";
import { withoutTypename } from "lib/graphql/omitTypename";
function Search(): JSX.Element {
const theme = useTheme();
const [searchExpr, setSearchExpr] = useState("");
const filterResult = useHttpRequestLogFilterQuery({
onCompleted: (data) => {
setSearchExpr(data.httpRequestLogFilter?.searchExpression || "");
},
});
const filter = filterResult.data?.httpRequestLogFilter;
const [setFilterMutate, setFilterResult] = useSetHttpRequestLogFilterMutation({
update(cache, { data }) {
cache.writeQuery({
query: HttpRequestLogFilterDocument,
data: {
httpRequestLogFilter: data?.setHttpRequestLogFilter,
},
});
},
});
const [clearHTTPRequestLog, clearHTTPRequestLogResult] = useClearHttpRequestLogMutation({
refetchQueries: [{ query: HttpRequestLogsDocument }],
});
const clearHTTPConfirmationDialog = useConfirmationDialog();
const filterRef = useRef<HTMLFormElement>(null);
const [filterOpen, setFilterOpen] = useState(false);
const handleSubmit = (e: React.SyntheticEvent) => {
setFilterMutate({
variables: {
filter: {
...withoutTypename(filter),
searchExpression: searchExpr,
},
},
});
setFilterOpen(false);
e.preventDefault();
};
const handleClickAway = (event: MouseEvent | TouchEvent) => {
if (filterRef?.current && filterRef.current.contains(event.target as HTMLElement)) {
return;
}
setFilterOpen(false);
};
return (
<Box>
<Error prefix="Error fetching filter" error={filterResult.error} />
<Error prefix="Error setting filter" error={setFilterResult.error} />
<Error prefix="Error clearing all HTTP logs" error={clearHTTPRequestLogResult.error} />
<Box style={{ display: "flex", flex: 1 }}>
<ClickAwayListener onClickAway={handleClickAway}>
<Paper
component="form"
onSubmit={handleSubmit}
ref={filterRef}
sx={{
padding: "2px 4px",
display: "flex",
alignItems: "center",
width: 400,
}}
>
<Tooltip title="Toggle filter options">
<IconButton
onClick={() => setFilterOpen(!filterOpen)}
sx={{
p: 1,
color: filter?.onlyInScope ? "primary.main" : "inherit",
}}
>
{filterResult.loading || setFilterResult.loading ? (
<CircularProgress sx={{ color: theme.palette.text.primary }} size={23} />
) : (
<FilterListIcon />
)}
</IconButton>
</Tooltip>
<InputBase
sx={{
ml: 1,
flex: 1,
}}
placeholder="Search proxy logs…"
value={searchExpr}
onChange={(e) => setSearchExpr(e.target.value)}
onFocus={() => setFilterOpen(true)}
/>
<Tooltip title="Search">
<IconButton type="submit" sx={{ padding: 1.25 }}>
<SearchIcon />
</IconButton>
</Tooltip>
<Popper
open={filterOpen}
anchorEl={filterRef.current}
placement="bottom"
style={{ zIndex: theme.zIndex.appBar }}
>
<Paper
sx={{
width: 400,
marginTop: 0.5,
p: 1.5,
}}
>
<FormControlLabel
control={
<Checkbox
checked={filter?.onlyInScope ? true : false}
disabled={filterResult.loading || setFilterResult.loading}
onChange={(e) =>
setFilterMutate({
variables: {
filter: {
...withoutTypename(filter),
onlyInScope: e.target.checked,
},
},
})
}
/>
}
label="Only show in-scope requests"
/>
</Paper>
</Popper>
</Paper>
</ClickAwayListener>
<Box style={{ marginLeft: "auto" }}>
<Tooltip title="Clear all">
<IconButton onClick={clearHTTPConfirmationDialog.open}>
<DeleteIcon />
</IconButton>
</Tooltip>
</Box>
</Box>
<ConfirmationDialog
isOpen={clearHTTPConfirmationDialog.isOpen}
onClose={clearHTTPConfirmationDialog.close}
onConfirm={clearHTTPRequestLog}
>
All proxy logs are going to be removed. This action cannot be undone.
</ConfirmationDialog>
</Box>
);
}
function Error(props: { prefix: string; error?: Error }) {
if (!props.error) return null;
return (
<Box mb={4}>
<Alert severity="error">
{props.prefix}: {props.error.message}
</Alert>
</Box>
);
}
export default Search;

View File

@ -0,0 +1,5 @@
mutation ClearHTTPRequestLog {
clearHTTPRequestLog {
success
}
}

View File

@ -0,0 +1,24 @@
query HttpRequestLog($id: ID!) {
httpRequestLog(id: $id) {
id
method
url
proto
headers {
key
value
}
body
response {
id
proto
headers {
key
value
}
statusCode
statusReason
body
}
}
}

View File

@ -0,0 +1,6 @@
query HttpRequestLogFilter {
httpRequestLogFilter {
onlyInScope
searchExpression
}
}

View File

@ -0,0 +1,12 @@
query HttpRequestLogs {
httpRequestLogs {
id
method
url
timestamp
response {
statusCode
statusReason
}
}
}

View File

@ -0,0 +1,6 @@
mutation SetHttpRequestLogFilter($filter: HttpRequestLogFilterInput) {
setHttpRequestLogFilter(filter: $filter) {
onlyInScope
searchExpression
}
}

View File

@ -0,0 +1,3 @@
import { RequestLogs } from "./components/RequestLogs";
export default RequestLogs;

View File

@ -1,56 +1,33 @@
import { gql, useApolloClient, useMutation } from "@apollo/client";
import { useApolloClient } from "@apollo/client";
import AddIcon from "@mui/icons-material/Add";
import { Alert } from "@mui/lab";
import {
Box,
Button,
CircularProgress,
createStyles,
FormControl,
FormControlLabel,
FormLabel,
makeStyles,
Radio,
RadioGroup,
TextField,
Theme,
} from "@material-ui/core";
import AddIcon from "@material-ui/icons/Add";
import { Alert } from "@material-ui/lab";
import React from "react";
import { SCOPE } from "./Rules";
} from "@mui/material";
import React, { useState } from "react";
const SET_SCOPE = gql`
mutation SetScope($scope: [ScopeRuleInput!]!) {
setScope(scope: $scope) {
url
}
}
`;
const useStyles = makeStyles((theme: Theme) =>
createStyles({
ruleExpression: {
fontFamily: "'JetBrains Mono', monospace",
},
})
);
import { ScopeDocument, ScopeQuery, ScopeRule, useSetScopeMutation } from "lib/graphql/generated";
function AddRule(): JSX.Element {
const classes = useStyles();
const [ruleType, setRuleType] = React.useState("url");
const [expression, setExpression] = React.useState(null);
const [ruleType, setRuleType] = useState("url");
const [expression, setExpression] = useState("");
const client = useApolloClient();
const [setScope, { error, loading }] = useMutation(SET_SCOPE, {
onError() {},
onCompleted() {
expression.value = "";
},
update(_, { data: { setScope } }) {
const [setScope, { error, loading }] = useSetScopeMutation({
onCompleted({ setScope }) {
client.writeQuery({
query: SCOPE,
query: ScopeDocument,
data: { scope: setScope },
});
setExpression("");
},
});
@ -59,21 +36,20 @@ function AddRule(): JSX.Element {
};
const handleSubmit = (e: React.SyntheticEvent) => {
e.preventDefault();
let scope = [];
let scope: ScopeRule[] = [];
try {
const data = client.readQuery({
query: SCOPE,
const data = client.readQuery<ScopeQuery>({
query: ScopeDocument,
});
scope = data.scope;
if (data) {
scope = data.scope;
}
} catch (e) {}
setScope({
variables: {
scope: [
...scope.map(({ url }) => ({ url })),
{ url: expression.value },
],
scope: [...scope.map(({ url }) => ({ url })), { url: expression }],
},
});
};
@ -87,15 +63,10 @@ function AddRule(): JSX.Element {
)}
<form onSubmit={handleSubmit} autoComplete="off">
<FormControl fullWidth>
<FormLabel color="secondary" component="legend">
<FormLabel color="primary" component="legend">
Rule Type
</FormLabel>
<RadioGroup
row
name="ruleType"
value={ruleType}
onChange={handleTypeChange}
>
<RadioGroup row name="ruleType" value={ruleType} onChange={handleTypeChange}>
<FormControlLabel value="url" control={<Radio />} label="URL" />
</RadioGroup>
</FormControl>
@ -104,20 +75,17 @@ function AddRule(): JSX.Element {
label="Expression"
placeholder="^https:\/\/(.*)example.com(.*)"
helperText="Regular expression to match on."
color="secondary"
color="primary"
variant="outlined"
required
value={expression}
onChange={(e) => setExpression(e.target.value)}
InputProps={{
className: classes.ruleExpression,
sx: { fontFamily: "'JetBrains Mono', monospace" },
}}
InputLabelProps={{
shrink: true,
}}
inputProps={{
ref: (node) => {
setExpression(node);
},
}}
margin="normal"
/>
</FormControl>
@ -125,7 +93,7 @@ function AddRule(): JSX.Element {
<Button
type="submit"
variant="contained"
color="secondary"
color="primary"
disabled={loading}
startIcon={loading ? <CircularProgress size={22} /> : <AddIcon />}
>

View File

@ -1,4 +1,6 @@
import { gql, useApolloClient, useMutation, useQuery } from "@apollo/client";
import { useApolloClient } from "@apollo/client";
import CodeIcon from "@mui/icons-material/Code";
import DeleteIcon from "@mui/icons-material/Delete";
import {
Avatar,
Chip,
@ -8,26 +10,25 @@ import {
ListItemSecondaryAction,
ListItemText,
Tooltip,
} from "@material-ui/core";
import CodeIcon from "@material-ui/icons/Code";
import DeleteIcon from "@material-ui/icons/Delete";
} from "@mui/material";
import React from "react";
import { SCOPE } from "./Rules";
const SET_SCOPE = gql`
mutation SetScope($scope: [ScopeRuleInput!]!) {
setScope(scope: $scope) {
url
}
}
`;
import { ScopeDocument, ScopeQuery, useSetScopeMutation } from "lib/graphql/generated";
function RuleListItem({ scope, rule, index }): JSX.Element {
type ScopeRule = ScopeQuery["scope"][number];
type RuleListItemProps = {
scope: ScopeQuery["scope"];
rule: ScopeRule;
index: number;
};
function RuleListItem({ scope, rule, index }: RuleListItemProps): JSX.Element {
const client = useApolloClient();
const [setScope, { loading }] = useMutation(SET_SCOPE, {
update(_, { data: { setScope } }) {
const [setScope, { loading }] = useSetScopeMutation({
onCompleted({ setScope }) {
client.writeQuery({
query: SCOPE,
query: ScopeDocument,
data: { scope: setScope },
});
},
@ -65,8 +66,8 @@ function RuleListItem({ scope, rule, index }): JSX.Element {
);
}
function RuleListItemText({ rule }): JSX.Element {
let text: JSX.Element;
function RuleListItemText({ rule }: { rule: ScopeRule }): JSX.Element {
let text: JSX.Element = <div></div>;
if (rule.url) {
text = <code>{rule.url}</code>;
@ -77,10 +78,14 @@ function RuleListItemText({ rule }): JSX.Element {
return <ListItemText>{text}</ListItemText>;
}
function RuleTypeChip({ rule }): JSX.Element {
function RuleTypeChip({ rule }: { rule: ScopeRule }): JSX.Element {
let label = "Unknown";
if (rule.url) {
return <Chip label="URL" variant="outlined" />;
label = "URL";
}
return <Chip label={label} variant="outlined" />;
}
export default RuleListItem;

View File

@ -0,0 +1,31 @@
import { Alert } from "@mui/lab";
import { CircularProgress, List } from "@mui/material";
import React from "react";
import RuleListItem from "./RuleListItem";
import { useScopeQuery } from "lib/graphql/generated";
function Rules(): JSX.Element {
const { loading, error, data } = useScopeQuery();
return (
<div>
{loading && <CircularProgress />}
{error && <Alert severity="error">Error fetching scope: {error.message}</Alert>}
{data && data.scope.length > 0 && (
<List
sx={{
bgcolor: "background.paper",
}}
>
{data.scope.map((rule, index) => (
<RuleListItem key={index} rule={rule} scope={data.scope} index={index} />
))}
</List>
)}
</div>
);
}
export default Rules;

View File

@ -0,0 +1,5 @@
query Scope {
scope {
url
}
}

View File

@ -0,0 +1,5 @@
mutation SetScope($scope: [ScopeRuleInput!]!) {
setScope(scope: $scope) {
url
}
}

View File

@ -0,0 +1,354 @@
import {
Alert,
Box,
BoxProps,
Button,
InputLabel,
FormControl,
MenuItem,
Select,
TextField,
Typography,
} from "@mui/material";
import { useRouter } from "next/router";
import React, { useState } from "react";
import { KeyValuePair, sortKeyValuePairs } from "lib/components/KeyValuePair";
import RequestTabs from "lib/components/RequestTabs";
import Response from "lib/components/Response";
import SplitPane from "lib/components/SplitPane";
import {
GetSenderRequestQuery,
useCreateOrUpdateSenderRequestMutation,
HttpProtocol,
useGetSenderRequestQuery,
useSendRequestMutation,
} from "lib/graphql/generated";
import { queryParamsFromURL } from "lib/queryParamsFromURL";
enum HttpMethod {
Get = "GET",
Post = "POST",
Put = "PUT",
Patch = "PATCH",
Delete = "DELETE",
Head = "HEAD",
Options = "OPTIONS",
Connect = "CONNECT",
Trace = "TRACE",
}
enum HttpProto {
Http1 = "HTTP/1.1",
Http2 = "HTTP/2.0",
}
const httpProtoMap = new Map([
[HttpProto.Http1, HttpProtocol.Http1],
[HttpProto.Http2, HttpProtocol.Http2],
]);
function updateKeyPairItem(key: string, value: string, idx: number, items: KeyValuePair[]): KeyValuePair[] {
const updated = [...items];
updated[idx] = { key, value };
// Append an empty key-value pair if the last item in the array isn't blank
// anymore.
if (items.length - 1 === idx && items[idx].key === "" && items[idx].value === "") {
updated.push({ key: "", value: "" });
}
return updated;
}
function updateURLQueryParams(url: string, queryParams: KeyValuePair[]) {
// Note: We don't use the `URL` interface, because we're potentially dealing
// with malformed/incorrect URLs, which would yield TypeErrors when constructed
// via `URL`.
let newURL = url;
const questionMarkIndex = url.indexOf("?");
if (questionMarkIndex !== -1) {
newURL = newURL.slice(0, questionMarkIndex);
}
const searchParams = new URLSearchParams();
for (const { key, value } of queryParams.filter(({ key }) => key !== "")) {
searchParams.append(key, value);
}
const rawQueryParams = decodeURI(searchParams.toString());
if (rawQueryParams == "") {
return newURL;
}
return newURL + "?" + rawQueryParams;
}
function EditRequest(): JSX.Element {
const router = useRouter();
const reqId = router.query.id as string | undefined;
const [method, setMethod] = useState(HttpMethod.Get);
const [url, setURL] = useState("");
const [proto, setProto] = useState(HttpProto.Http2);
const [queryParams, setQueryParams] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
const [headers, setHeaders] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
const [body, setBody] = useState("");
const handleQueryParamChange = (key: string, value: string, idx: number) => {
setQueryParams((prev) => {
const updated = updateKeyPairItem(key, value, idx, prev);
setURL((prev) => updateURLQueryParams(prev, updated));
return updated;
});
};
const handleQueryParamDelete = (idx: number) => {
setQueryParams((prev) => {
const updated = prev.slice(0, idx).concat(prev.slice(idx + 1, prev.length));
setURL((prev) => updateURLQueryParams(prev, updated));
return updated;
});
};
const handleHeaderChange = (key: string, value: string, idx: number) => {
setHeaders((prev) => updateKeyPairItem(key, value, idx, prev));
};
const handleHeaderDelete = (idx: number) => {
setHeaders((prev) => prev.slice(0, idx).concat(prev.slice(idx + 1, prev.length)));
};
const handleURLChange = (url: string) => {
setURL(url);
const questionMarkIndex = url.indexOf("?");
if (questionMarkIndex === -1) {
setQueryParams([{ key: "", value: "" }]);
return;
}
const newQueryParams = queryParamsFromURL(url);
// Push empty row.
newQueryParams.push({ key: "", value: "" });
setQueryParams(newQueryParams);
};
const [response, setResponse] = useState<NonNullable<GetSenderRequestQuery["senderRequest"]>["response"]>(null);
const getReqResult = useGetSenderRequestQuery({
variables: { id: reqId as string },
skip: reqId === undefined,
onCompleted: ({ senderRequest }) => {
if (!senderRequest) {
return;
}
setURL(senderRequest.url);
setMethod(senderRequest.method);
setBody(senderRequest.body || "");
const newQueryParams = queryParamsFromURL(senderRequest.url);
// Push empty row.
newQueryParams.push({ key: "", value: "" });
setQueryParams(newQueryParams);
const newHeaders = sortKeyValuePairs(senderRequest.headers || []);
setHeaders([...newHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]);
setResponse(senderRequest.response);
},
});
const [createOrUpdateRequest, createResult] = useCreateOrUpdateSenderRequestMutation();
const [sendRequest, sendResult] = useSendRequestMutation();
const createOrUpdateRequestAndSend = () => {
const senderReq = getReqResult?.data?.senderRequest;
createOrUpdateRequest({
variables: {
request: {
// Update existing sender request if it was cloned from a request log
// and it doesn't have a response body yet (e.g. not sent yet).
...(senderReq && senderReq.sourceRequestLogID && !senderReq.response && { id: senderReq.id }),
url,
method,
proto: httpProtoMap.get(proto),
headers: headers.filter((kv) => kv.key !== ""),
body: body || undefined,
},
},
onCompleted: ({ createOrUpdateSenderRequest }) => {
const { id } = createOrUpdateSenderRequest;
sendRequestAndPushRoute(id);
},
});
};
const sendRequestAndPushRoute = (id: string) => {
sendRequest({
errorPolicy: "all",
onCompleted: () => {
router.push(`/sender?id=${id}`);
},
variables: {
id,
},
});
};
const handleFormSubmit: React.FormEventHandler = (e) => {
e.preventDefault();
createOrUpdateRequestAndSend();
};
return (
<Box display="flex" flexDirection="column" height="100%" gap={2}>
<Box component="form" autoComplete="off" onSubmit={handleFormSubmit}>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<UrlBar
method={method}
onMethodChange={setMethod}
url={url.toString()}
onUrlChange={handleURLChange}
proto={proto}
onProtoChange={setProto}
sx={{ flex: "1 auto" }}
/>
<Button
variant="contained"
disableElevation
sx={{ width: "8rem" }}
type="submit"
disabled={createResult.loading || sendResult.loading}
>
Send
</Button>
</Box>
{createResult.error && (
<Alert severity="error" sx={{ mt: 1 }}>
{createResult.error.message}
</Alert>
)}
{sendResult.error && (
<Alert severity="error" sx={{ mt: 1 }}>
{sendResult.error.message}
</Alert>
)}
</Box>
<Box flex="1 auto" position="relative">
<SplitPane split="vertical" size={"50%"}>
<Box sx={{ height: "100%", mr: 2, pb: 2, position: "relative" }}>
<Typography variant="overline" color="textSecondary" sx={{ position: "absolute", right: 0, mt: 1.2 }}>
Request
</Typography>
<RequestTabs
queryParams={queryParams}
headers={headers}
body={body}
onQueryParamChange={handleQueryParamChange}
onQueryParamDelete={handleQueryParamDelete}
onHeaderChange={handleHeaderChange}
onHeaderDelete={handleHeaderDelete}
onBodyChange={setBody}
/>
</Box>
<Box sx={{ height: "100%", position: "relative", ml: 2, pb: 2 }}>
<Response response={response} />
</Box>
</SplitPane>
</Box>
</Box>
);
}
interface UrlBarProps extends BoxProps {
method: HttpMethod;
onMethodChange: (method: HttpMethod) => void;
url: string;
onUrlChange: (url: string) => void;
proto: HttpProto;
onProtoChange: (proto: HttpProto) => void;
}
function UrlBar(props: UrlBarProps) {
const { method, onMethodChange, url, onUrlChange, proto, onProtoChange, ...other } = props;
return (
<Box {...other} sx={{ ...other.sx, display: "flex" }}>
<FormControl>
<InputLabel id="req-method-label">Method</InputLabel>
<Select
labelId="req-method-label"
id="req-method"
value={method}
label="Method"
onChange={(e) => onMethodChange(e.target.value as HttpMethod)}
sx={{
width: "8rem",
".MuiOutlinedInput-notchedOutline": {
borderRightWidth: 0,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderRightWidth: 1,
},
}}
>
{Object.values(HttpMethod).map((method) => (
<MenuItem key={method} value={method}>
{method}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="URL"
placeholder="E.g. “https://example.com/foobar”"
value={url}
onChange={(e) => onUrlChange(e.target.value)}
required
variant="outlined"
InputLabelProps={{
shrink: true,
}}
InputProps={{
sx: {
".MuiOutlinedInput-notchedOutline": {
borderRadius: 0,
},
},
}}
sx={{ flexGrow: 1 }}
/>
<FormControl>
<InputLabel id="req-proto-label">Protocol</InputLabel>
<Select
labelId="req-proto-label"
id="req-proto"
value={proto}
label="Protocol"
onChange={(e) => onProtoChange(e.target.value as HttpProto)}
sx={{
".MuiOutlinedInput-notchedOutline": {
borderLeftWidth: 0,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
},
"&:hover .MuiOutlinedInput-notchedOutline": {
borderLeftWidth: 1,
},
}}
>
{Object.values(HttpProto).map((proto) => (
<MenuItem key={proto} value={proto}>
{proto}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
);
}
export default EditRequest;

View File

@ -0,0 +1,35 @@
import { Box, Paper, Typography } from "@mui/material";
import { useRouter } from "next/router";
import RequestsTable from "lib/components/RequestsTable";
import { useGetSenderRequestsQuery } from "lib/graphql/generated";
function History(): JSX.Element {
const { data, loading } = useGetSenderRequestsQuery({
pollInterval: 1000,
});
const router = useRouter();
const activeId = router.query.id as string | undefined;
const handleRowClick = (id: string) => {
router.push(`/sender?id=${id}`);
};
return (
<Box>
{!loading && data?.senderRequests && data?.senderRequests.length > 0 && (
<RequestsTable requests={data.senderRequests} onRowClick={handleRowClick} activeRowId={activeId} />
)}
<Box sx={{ mt: 2, height: "100%" }}>
{!loading && data?.senderRequests.length === 0 && (
<Paper variant="centered">
<Typography>No requests created yet.</Typography>
</Paper>
)}
</Box>
</Box>
);
}
export default History;

View File

@ -0,0 +1,21 @@
import { Box } from "@mui/material";
import EditRequest from "./EditRequest";
import History from "./History";
import SplitPane from "lib/components/SplitPane";
export default function Sender(): JSX.Element {
return (
<Box sx={{ height: "100%", position: "relative" }}>
<SplitPane split="horizontal" size="70%">
<Box sx={{ width: "100%", pt: "0.75rem" }}>
<EditRequest />
</Box>
<Box sx={{ height: "100%", overflow: "scroll" }}>
<History />
</Box>
</SplitPane>
</Box>
);
}

View File

@ -0,0 +1,5 @@
mutation CreateOrUpdateSenderRequest($request: SenderRequestInput!) {
createOrUpdateSenderRequest(request: $request) {
id
}
}

View File

@ -0,0 +1,5 @@
mutation CreateSenderRequestFromHttpRequestLog($id: ID!) {
createSenderRequestFromHttpRequestLog(id: $id) {
id
}
}

View File

@ -0,0 +1,5 @@
mutation SendRequest($id: ID!) {
sendRequest(id: $id) {
id
}
}

View File

@ -0,0 +1,26 @@
query GetSenderRequest($id: ID!) {
senderRequest(id: $id) {
id
sourceRequestLogID
url
method
proto
headers {
key
value
}
body
timestamp
response {
id
proto
statusCode
statusReason
body
headers {
key
value
}
}
}
}

View File

@ -0,0 +1,12 @@
query GetSenderRequests {
senderRequests {
id
url
method
response {
id
statusCode
statusReason
}
}
}

View File

@ -0,0 +1,3 @@
import Sender from "./components/Sender";
export default Sender;

View File

@ -0,0 +1,20 @@
import React, { createContext, useContext } from "react";
import { Project, useProjectsQuery } from "./graphql/generated";
const ActiveProjectContext = createContext<Project | null>(null);
interface Props {
children?: React.ReactNode | undefined;
}
export function ActiveProjectProvider({ children }: Props): JSX.Element {
const { data } = useProjectsQuery();
const project = data?.projects.find((project) => project.isActive) || null;
return <ActiveProjectContext.Provider value={project}>{children}</ActiveProjectContext.Provider>;
}
export function useActiveProject() {
return useContext(ActiveProjectContext);
}

View File

@ -1,10 +1,10 @@
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import React, { useState } from "react";
import Button from "@material-ui/core/Button";
import Dialog from "@material-ui/core/Dialog";
import DialogActions from "@material-ui/core/DialogActions";
import DialogContent from "@material-ui/core/DialogContent";
import DialogContentText from "@material-ui/core/DialogContentText";
import DialogTitle from "@material-ui/core/DialogTitle";
export function useConfirmationDialog() {
const [isOpen, setIsOpen] = useState(false);
@ -38,12 +38,10 @@ export function ConfirmationDialog(props: ConfirmationDialog) {
>
<DialogTitle id="alert-dialog-title">Are you sure?</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">
{children}
</DialogContentText>
<DialogContentText id="alert-dialog-description">{children}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Abort</Button>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={confirm} autoFocus>
Confirm
</Button>

View File

@ -0,0 +1,48 @@
import MonacoEditor, { EditorProps } from "@monaco-editor/react";
const defaultMonacoOptions: EditorProps["options"] = {
readOnly: true,
wordWrap: "on",
minimap: {
enabled: false,
},
};
type language = "html" | "typescript" | "json";
function languageForContentType(contentType?: string): language | undefined {
switch (contentType?.toLowerCase()) {
case "text/html":
case "text/html; charset=utf-8":
return "html";
case "application/json":
case "application/json; charset=utf-8":
return "json";
case "application/javascript":
case "application/javascript; charset=utf-8":
return "typescript";
default:
return;
}
}
interface Props {
content: string;
contentType?: string;
monacoOptions?: EditorProps["options"];
onChange?: EditorProps["onChange"];
}
function Editor({ content, contentType, monacoOptions, onChange }: Props): JSX.Element {
return (
<MonacoEditor
language={languageForContentType(contentType)}
theme="vs-dark"
options={{ ...defaultMonacoOptions, ...monacoOptions }}
value={content}
onChange={onChange}
/>
);
}
export default Editor;

View File

@ -0,0 +1,25 @@
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
import { SvgIconTypeMap } from "@mui/material";
interface Props {
status: number;
}
export default function HttpStatusIcon({ status }: Props): JSX.Element {
let color: SvgIconTypeMap["props"]["color"] = "inherit";
switch (Math.floor(status / 100)) {
case 2:
case 3:
color = "primary";
break;
case 4:
color = "warning";
break;
case 5:
color = "error";
break;
}
return <FiberManualRecordIcon sx={{ marginTop: "-.25rem", verticalAlign: "middle" }} color={color} />;
}

View File

@ -0,0 +1,201 @@
import ClearIcon from "@mui/icons-material/Clear";
import {
Alert,
IconButton,
InputBase,
InputBaseProps,
Snackbar,
styled,
Table,
TableBody,
TableCell,
TableCellProps,
TableContainer,
TableHead,
TableRow,
TableRowProps,
} from "@mui/material";
import { useState } from "react";
const StyledInputBase = styled(InputBase)<InputBaseProps>(() => ({
fontSize: "0.875rem",
"&.MuiInputBase-root input": {
p: 0,
},
}));
const StyledTableRow = styled(TableRow)<TableRowProps>(() => ({
"& .delete-button": {
visibility: "hidden",
},
"&:hover .delete-button": {
visibility: "inherit",
},
}));
export interface KeyValuePair {
key: string;
value: string;
}
export interface KeyValuePairTableProps {
items: KeyValuePair[];
onChange?: (key: string, value: string, index: number) => void;
onDelete?: (index: number) => void;
}
export function KeyValuePairTable({ items, onChange, onDelete }: KeyValuePairTableProps): JSX.Element {
const [copyConfOpen, setCopyConfOpen] = useState(false);
const handleCellClick = (e: React.MouseEvent) => {
e.preventDefault();
const windowSel = window.getSelection();
if (!windowSel || !document) {
return;
}
const r = document.createRange();
r.selectNode(e.currentTarget);
windowSel.removeAllRanges();
windowSel.addRange(r);
document.execCommand("copy");
windowSel.removeAllRanges();
setCopyConfOpen(true);
};
const handleCopyConfClose = (_: Event | React.SyntheticEvent, reason?: string) => {
if (reason === "clickaway") {
return;
}
setCopyConfOpen(false);
};
const baseCellStyle = {
"&:hover": {
cursor: "copy",
},
};
const KeyTableCell = styled(TableCell)<TableCellProps>(() => (!onChange ? baseCellStyle : {}));
const ValueTableCell = styled(TableCell)<TableCellProps>(() => ({
...(!onChange && baseCellStyle),
width: "60%",
wordBreak: "break-all",
}));
return (
<div>
<Snackbar open={copyConfOpen} autoHideDuration={3000} onClose={handleCopyConfClose}>
<Alert onClose={handleCopyConfClose} severity="info">
Copied to clipboard.
</Alert>
</Snackbar>
<TableContainer sx={{ overflowX: "initial" }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Key</TableCell>
<TableCell>Value</TableCell>
{onDelete && <TableCell padding="checkbox"></TableCell>}
</TableRow>
</TableHead>
<TableBody
sx={{
"td, th, input": {
fontFamily: "'JetBrains Mono', monospace",
fontSize: "0.75rem",
py: 0.2,
},
"td span, th span": {
display: "block",
py: 0.7,
},
}}
>
{items.map(({ key, value }, idx) => (
<StyledTableRow key={idx} hover>
<KeyTableCell
component="th"
scope="row"
onClick={(e) => {
!onChange && handleCellClick(e);
}}
>
{!onChange && <span>{key}</span>}
{onChange && (
<StyledInputBase
size="small"
fullWidth
placeholder="Key"
value={key}
onChange={(e) => {
onChange && onChange(e.target.value, value, idx);
}}
/>
)}
</KeyTableCell>
<ValueTableCell
onClick={(e) => {
!onChange && handleCellClick(e);
}}
>
{!onChange && value}
{onChange && (
<StyledInputBase
size="small"
fullWidth
placeholder="Value"
value={value}
onChange={(e) => {
onChange && onChange(key, e.target.value, idx);
}}
/>
)}
</ValueTableCell>
{onDelete && (
<TableCell>
<div className="delete-button">
<IconButton
size="small"
onClick={() => {
onDelete && onDelete(idx);
}}
sx={{
visibility: onDelete === undefined || items.length === idx + 1 ? "hidden" : "inherit",
}}
>
<ClearIcon fontSize="inherit" />
</IconButton>
</div>
</TableCell>
)}
</StyledTableRow>
))}
</TableBody>
</Table>
</TableContainer>
</div>
);
}
export function sortKeyValuePairs(items: KeyValuePair[]): KeyValuePair[] {
const sorted = [...items];
sorted.sort((a, b) => {
if (a.key < b.key) {
return -1;
}
if (a.key > b.key) {
return 1;
}
return 0;
});
return sorted;
}
export default KeyValuePairTable;

View File

@ -0,0 +1,91 @@
import { TabContext, TabList, TabPanel } from "@mui/lab";
import { Box, Tab } from "@mui/material";
import React, { useState } from "react";
import { KeyValuePairTable, KeyValuePair, KeyValuePairTableProps } from "./KeyValuePair";
import Editor from "lib/components/Editor";
enum TabValue {
QueryParams = "queryParams",
Headers = "headers",
Body = "body",
}
interface RequestTabsProps {
queryParams: KeyValuePair[];
headers: KeyValuePair[];
onQueryParamChange?: KeyValuePairTableProps["onChange"];
onQueryParamDelete?: KeyValuePairTableProps["onDelete"];
onHeaderChange?: KeyValuePairTableProps["onChange"];
onHeaderDelete?: KeyValuePairTableProps["onDelete"];
body?: string | null;
onBodyChange?: (value: string) => void;
}
function RequestTabs(props: RequestTabsProps): JSX.Element {
const {
queryParams,
onQueryParamChange,
onQueryParamDelete,
headers,
onHeaderChange,
onHeaderDelete,
body,
onBodyChange,
} = props;
const [tabValue, setTabValue] = useState(TabValue.QueryParams);
const tabSx = {
textTransform: "none",
};
const queryParamsLength = onQueryParamChange ? queryParams.length - 1 : queryParams.length;
const headersLength = onHeaderChange ? headers.length - 1 : headers.length;
return (
<Box sx={{ display: "flex", flexDirection: "column", height: "100%" }}>
<TabContext value={tabValue}>
<Box sx={{ borderBottom: 1, borderColor: "divider", mb: 1 }}>
<TabList onChange={(_, value) => setTabValue(value)}>
<Tab
value={TabValue.QueryParams}
label={"Query Params" + (queryParamsLength ? ` (${queryParamsLength})` : "")}
sx={tabSx}
/>
<Tab value={TabValue.Headers} label={"Headers" + (headersLength ? ` (${headersLength})` : "")} sx={tabSx} />
<Tab
value={TabValue.Body}
label={"Body" + (body?.length ? ` (${body.length} byte` + (body.length > 1 ? "s" : "") + ")" : "")}
sx={tabSx}
/>
</TabList>
</Box>
<Box flex="1 auto" overflow="scroll" height="100%">
<TabPanel value={TabValue.QueryParams} sx={{ p: 0, height: "100%" }}>
<Box>
<KeyValuePairTable items={queryParams} onChange={onQueryParamChange} onDelete={onQueryParamDelete} />
</Box>
</TabPanel>
<TabPanel value={TabValue.Headers} sx={{ p: 0, height: "100%" }}>
<Box>
<KeyValuePairTable items={headers} onChange={onHeaderChange} onDelete={onHeaderDelete} />
</Box>
</TabPanel>
<TabPanel value={TabValue.Body} sx={{ p: 0, height: "100%" }}>
<Editor
content={body || ""}
onChange={(value) => {
onBodyChange && onBodyChange(value || "");
}}
monacoOptions={{ readOnly: onBodyChange === undefined }}
contentType={headers.find(({ key }) => key.toLowerCase() === "content-type")?.value}
/>
</TabPanel>
</Box>
</TabContext>
</Box>
);
}
export default RequestTabs;

View File

@ -0,0 +1,128 @@
import {
TableContainer,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
styled,
TableCellProps,
TableRowProps,
} from "@mui/material";
import HttpStatusIcon from "./HttpStatusIcon";
import { HttpMethod } from "lib/graphql/generated";
const baseCellStyle = {
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
} as const;
const MethodTableCell = styled(TableCell)<TableCellProps>(() => ({
...baseCellStyle,
width: "100px",
}));
const OriginTableCell = styled(TableCell)<TableCellProps>(() => ({
...baseCellStyle,
maxWidth: "100px",
}));
const PathTableCell = styled(TableCell)<TableCellProps>(() => ({
...baseCellStyle,
maxWidth: "200px",
}));
const StatusTableCell = styled(TableCell)<TableCellProps>(() => ({
...baseCellStyle,
width: "100px",
}));
const RequestTableRow = styled(TableRow)<TableRowProps>(() => ({
"&:hover": {
cursor: "pointer",
},
}));
interface HttpRequest {
id: string;
url: string;
method: HttpMethod;
response?: HttpResponse | null;
}
interface HttpResponse {
statusCode: number;
statusReason: string;
body?: string;
}
interface Props {
requests: HttpRequest[];
activeRowId?: string;
onRowClick?: (id: string) => void;
onContextMenu?: (e: React.MouseEvent, id: string) => void;
rowActions?: (id: string) => JSX.Element;
}
export default function RequestsTable(props: Props): JSX.Element {
const { requests, activeRowId, onRowClick, onContextMenu, rowActions } = props;
return (
<TableContainer sx={{ overflowX: "initial" }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Method</TableCell>
<TableCell>Origin</TableCell>
<TableCell>Path</TableCell>
<TableCell>Status</TableCell>
{rowActions && <TableCell padding="checkbox" />}
</TableRow>
</TableHead>
<TableBody>
{requests.map(({ id, method, url, response }) => {
const { origin, pathname, search, hash } = new URL(url);
return (
<RequestTableRow
key={id}
hover
selected={id === activeRowId}
onClick={() => {
onRowClick && onRowClick(id);
}}
onContextMenu={(e) => {
onContextMenu && onContextMenu(e, id);
}}
>
<MethodTableCell>
<code>{method}</code>
</MethodTableCell>
<OriginTableCell>{origin}</OriginTableCell>
<PathTableCell>{decodeURIComponent(pathname + search + hash)}</PathTableCell>
<StatusTableCell>
{response && <Status code={response.statusCode} reason={response.statusReason} />}
</StatusTableCell>
{rowActions && <TableCell sx={{ py: 0 }}>{rowActions(id)}</TableCell>}
</RequestTableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);
}
function Status({ code, reason }: { code: number; reason: string }): JSX.Element {
return (
<div>
<HttpStatusIcon status={code} />{" "}
<code>
{code} {reason}
</code>
</div>
);
}

View File

@ -0,0 +1,39 @@
import { Box, Typography } from "@mui/material";
import { sortKeyValuePairs } from "./KeyValuePair";
import ResponseTabs from "./ResponseTabs";
import ResponseStatus from "lib/components/ResponseStatus";
import { HttpResponseLog } from "lib/graphql/generated";
interface ResponseProps {
response?: HttpResponseLog | null;
}
function Response({ response }: ResponseProps): JSX.Element {
return (
<Box height="100%">
<Box sx={{ position: "absolute", right: 0, mt: 1.4 }}>
<Typography variant="overline" color="textSecondary" sx={{ float: "right", ml: 3 }}>
Response
</Typography>
{response && (
<Box sx={{ float: "right", mt: 0.2 }}>
<ResponseStatus
proto={response.proto}
statusCode={response.statusCode}
statusReason={response.statusReason}
/>
</Box>
)}
</Box>
<ResponseTabs
body={response?.body}
headers={sortKeyValuePairs(response?.headers || [])}
hasResponse={response !== undefined && response !== null}
/>
</Box>
);
}
export default Response;

View File

@ -0,0 +1,36 @@
import { Typography } from "@mui/material";
import HttpStatusIcon from "./HttpStatusIcon";
import { HttpProtocol } from "lib/graphql/generated";
type ResponseStatusProps = {
proto: HttpProtocol;
statusCode: number;
statusReason: string;
};
function mapProto(proto: HttpProtocol): string {
switch (proto) {
case HttpProtocol.Http1:
return "HTTP/1.1";
case HttpProtocol.Http2:
return "HTTP/2.0";
default:
return proto;
}
}
export default function ResponseStatus({ proto, statusCode, statusReason }: ResponseStatusProps): JSX.Element {
return (
<Typography variant="h6" style={{ fontSize: "1rem", whiteSpace: "nowrap" }}>
<HttpStatusIcon status={statusCode} />{" "}
<Typography component="span" color="textSecondary">
<Typography component="span" color="textSecondary" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
{mapProto(proto)}
</Typography>
</Typography>{" "}
{statusCode} {statusReason}
</Typography>
);
}

View File

@ -0,0 +1,68 @@
import { TabContext, TabList, TabPanel } from "@mui/lab";
import { Box, Paper, Tab, Typography } from "@mui/material";
import React, { useState } from "react";
import Editor from "lib/components/Editor";
import { KeyValuePairTable } from "lib/components/KeyValuePair";
import { HttpResponseLog } from "lib/graphql/generated";
interface ResponseTabsProps {
headers: HttpResponseLog["headers"];
body: HttpResponseLog["body"];
hasResponse: boolean;
}
enum TabValue {
Body = "body",
Headers = "headers",
}
const reqNotSent = (
<Paper variant="centered">
<Typography>Response not received yet.</Typography>
</Paper>
);
function ResponseTabs(props: ResponseTabsProps): JSX.Element {
const { headers, body, hasResponse } = props;
const [tabValue, setTabValue] = useState(TabValue.Body);
const contentType = headers.find((header) => header.key.toLowerCase() === "content-type")?.value;
const tabSx = {
textTransform: "none",
};
return (
<Box height="100%" sx={{ display: "flex", flexDirection: "column" }}>
<TabContext value={tabValue}>
<Box sx={{ borderBottom: 1, borderColor: "divider", mb: 1 }}>
<TabList onChange={(_, value) => setTabValue(value)}>
<Tab
value={TabValue.Body}
label={"Body" + (body?.length ? ` (${body.length} byte` + (body.length > 1 ? "s" : "") + ")" : "")}
sx={tabSx}
/>
<Tab
value={TabValue.Headers}
label={"Headers" + (headers.length ? ` (${headers.length})` : "")}
sx={tabSx}
/>
</TabList>
</Box>
<Box flex="1 auto" overflow="hidden">
<TabPanel value={TabValue.Body} sx={{ p: 0, height: "100%" }}>
{body && <Editor content={body} contentType={contentType} />}
{!hasResponse && reqNotSent}
</TabPanel>
<TabPanel value={TabValue.Headers} sx={{ p: 0, height: "100%", overflow: "scroll" }}>
{headers.length > 0 && <KeyValuePairTable items={headers} />}
{!hasResponse && reqNotSent}
</TabPanel>
</Box>
</TabContext>
</Box>
);
}
export default ResponseTabs;

View File

@ -0,0 +1,53 @@
import { alpha, styled } from "@mui/material/styles";
import ReactSplitPane, { SplitPaneProps } from "react-split-pane";
const BORDER_WIDTH_FACTOR = 1.75;
const SIZE_FACTOR = 4;
const MARGIN_FACTOR = -1.75;
const SplitPane = styled(ReactSplitPane)<SplitPaneProps>(({ theme }) => ({
".Resizer": {
zIndex: theme.zIndex.mobileStepper,
boxSizing: "border-box",
backgroundClip: "padding-box",
backgroundColor: alpha(theme.palette.grey[400], 0.05),
},
".Resizer:hover": {
transition: "all 0.5s ease",
backgroundColor: alpha(theme.palette.primary.main, 1),
},
".Resizer.horizontal": {
height: theme.spacing(SIZE_FACTOR),
marginTop: theme.spacing(MARGIN_FACTOR),
marginBottom: theme.spacing(MARGIN_FACTOR),
borderTop: `${theme.spacing(BORDER_WIDTH_FACTOR)} solid rgba(255, 255, 255, 0)`,
borderBottom: `${theme.spacing(BORDER_WIDTH_FACTOR)} solid rgba(255, 255, 255, 0)`,
borderBottomColor: "rgba(255, 255, 255, 0)",
cursor: "row-resize",
width: "100%",
},
".Resizer.vertical": {
width: theme.spacing(SIZE_FACTOR),
marginLeft: theme.spacing(MARGIN_FACTOR),
marginRight: theme.spacing(MARGIN_FACTOR),
borderLeft: `${theme.spacing(BORDER_WIDTH_FACTOR)} solid rgba(255, 255, 255, 0)`,
borderRight: `${theme.spacing(BORDER_WIDTH_FACTOR)} solid rgba(255, 255, 255, 0)`,
cursor: "col-resize",
},
".Resizer.disabled": {
cursor: "not-allowed",
},
".Resizer.disabled:hover": {
borderColor: "transparent",
},
".Pane": {
overflow: "hidden",
},
}));
export default SplitPane;

View File

@ -0,0 +1,49 @@
import { Menu } from "@mui/material";
import React, { useState } from "react";
interface ContextMenuProps {
children?: React.ReactNode;
}
export default function useContextMenu(): [
(props: ContextMenuProps) => JSX.Element,
(e: React.MouseEvent) => void,
() => void
] {
const [contextMenu, setContextMenu] = useState<{
mouseX: number;
mouseY: number;
} | null>(null);
const handleContextMenu = (event: React.MouseEvent) => {
event.preventDefault();
setContextMenu(
contextMenu === null
? {
mouseX: event.clientX - 2,
mouseY: event.clientY - 4,
}
: // repeated contextmenu when it is already open closes it with Chrome 84 on Ubuntu
// Other native context menus might behave different.
// With this behavior we prevent contextmenu from the backdrop to re-locale existing context menus.
null
);
};
const handleClose = () => {
setContextMenu(null);
};
const menu = ({ children }: ContextMenuProps): JSX.Element => (
<Menu
open={contextMenu !== null}
onClose={handleClose}
anchorReference="anchorPosition"
anchorPosition={contextMenu !== null ? { top: contextMenu.mouseY, left: contextMenu.mouseX } : undefined}
>
{children}
</Menu>
);
return [menu, handleContextMenu, handleClose];
}

View File

@ -1,45 +0,0 @@
import { useMemo } from "react";
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
let apolloClient;
function createApolloClient() {
return new ApolloClient({
ssrMode: typeof window === "undefined",
link: new HttpLink({
uri: "/api/graphql/",
}),
cache: new InMemoryCache({
typePolicies: {
Project: {
keyFields: ["name"],
},
},
}),
});
}
export function initializeApollo(initialState = null) {
const _apolloClient = apolloClient ?? createApolloClient();
// If your page has Next.js data fetching methods that use Apollo Client, the initial state
// gets hydrated here
if (initialState) {
// Get existing cache, loaded during client side data fetching
const existingCache = _apolloClient.extract();
// Restore the cache using the data passed from getStaticProps/getServerSideProps
// combined with the existing cached data
_apolloClient.cache.restore({ ...existingCache, ...initialState });
}
// For SSG and SSR always create a new Apollo Client
if (typeof window === "undefined") return _apolloClient;
// Create the Apollo Client once in the client
if (!apolloClient) apolloClient = _apolloClient;
return _apolloClient;
}
export function useApollo(initialState) {
const store = useMemo(() => initializeApollo(initialState), [initialState]);
return store;
}

View File

@ -0,0 +1,984 @@
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
const defaultOptions = {} as const;
/** All built-in and custom scalars, mapped to their actual values */
export type Scalars = {
ID: string;
String: string;
Boolean: boolean;
Int: number;
Float: number;
Regexp: any;
Time: any;
URL: any;
};
export type ClearHttpRequestLogResult = {
__typename?: 'ClearHTTPRequestLogResult';
success: Scalars['Boolean'];
};
export type CloseProjectResult = {
__typename?: 'CloseProjectResult';
success: Scalars['Boolean'];
};
export type DeleteProjectResult = {
__typename?: 'DeleteProjectResult';
success: Scalars['Boolean'];
};
export type DeleteSenderRequestsResult = {
__typename?: 'DeleteSenderRequestsResult';
success: Scalars['Boolean'];
};
export type HttpHeader = {
__typename?: 'HttpHeader';
key: Scalars['String'];
value: Scalars['String'];
};
export type HttpHeaderInput = {
key: Scalars['String'];
value: Scalars['String'];
};
export enum HttpMethod {
Connect = 'CONNECT',
Delete = 'DELETE',
Get = 'GET',
Head = 'HEAD',
Options = 'OPTIONS',
Patch = 'PATCH',
Post = 'POST',
Put = 'PUT',
Trace = 'TRACE'
}
export enum HttpProtocol {
Http1 = 'HTTP1',
Http2 = 'HTTP2'
}
export type HttpRequestLog = {
__typename?: 'HttpRequestLog';
body?: Maybe<Scalars['String']>;
headers: Array<HttpHeader>;
id: Scalars['ID'];
method: HttpMethod;
proto: Scalars['String'];
response?: Maybe<HttpResponseLog>;
timestamp: Scalars['Time'];
url: Scalars['String'];
};
export type HttpRequestLogFilter = {
__typename?: 'HttpRequestLogFilter';
onlyInScope: Scalars['Boolean'];
searchExpression?: Maybe<Scalars['String']>;
};
export type HttpRequestLogFilterInput = {
onlyInScope?: InputMaybe<Scalars['Boolean']>;
searchExpression?: InputMaybe<Scalars['String']>;
};
export type HttpResponseLog = {
__typename?: 'HttpResponseLog';
body?: Maybe<Scalars['String']>;
headers: Array<HttpHeader>;
/** Will be the same ID as its related request ID. */
id: Scalars['ID'];
proto: HttpProtocol;
statusCode: Scalars['Int'];
statusReason: Scalars['String'];
};
export type Mutation = {
__typename?: 'Mutation';
clearHTTPRequestLog: ClearHttpRequestLogResult;
closeProject: CloseProjectResult;
createOrUpdateSenderRequest: SenderRequest;
createProject?: Maybe<Project>;
createSenderRequestFromHttpRequestLog: SenderRequest;
deleteProject: DeleteProjectResult;
deleteSenderRequests: DeleteSenderRequestsResult;
openProject?: Maybe<Project>;
sendRequest: SenderRequest;
setHttpRequestLogFilter?: Maybe<HttpRequestLogFilter>;
setScope: Array<ScopeRule>;
setSenderRequestFilter?: Maybe<SenderRequestFilter>;
};
export type MutationCreateOrUpdateSenderRequestArgs = {
request: SenderRequestInput;
};
export type MutationCreateProjectArgs = {
name: Scalars['String'];
};
export type MutationCreateSenderRequestFromHttpRequestLogArgs = {
id: Scalars['ID'];
};
export type MutationDeleteProjectArgs = {
id: Scalars['ID'];
};
export type MutationOpenProjectArgs = {
id: Scalars['ID'];
};
export type MutationSendRequestArgs = {
id: Scalars['ID'];
};
export type MutationSetHttpRequestLogFilterArgs = {
filter?: InputMaybe<HttpRequestLogFilterInput>;
};
export type MutationSetScopeArgs = {
scope: Array<ScopeRuleInput>;
};
export type MutationSetSenderRequestFilterArgs = {
filter?: InputMaybe<SenderRequestFilterInput>;
};
export type Project = {
__typename?: 'Project';
id: Scalars['ID'];
isActive: Scalars['Boolean'];
name: Scalars['String'];
};
export type Query = {
__typename?: 'Query';
activeProject?: Maybe<Project>;
httpRequestLog?: Maybe<HttpRequestLog>;
httpRequestLogFilter?: Maybe<HttpRequestLogFilter>;
httpRequestLogs: Array<HttpRequestLog>;
projects: Array<Project>;
scope: Array<ScopeRule>;
senderRequest?: Maybe<SenderRequest>;
senderRequests: Array<SenderRequest>;
};
export type QueryHttpRequestLogArgs = {
id: Scalars['ID'];
};
export type QuerySenderRequestArgs = {
id: Scalars['ID'];
};
export type ScopeHeader = {
__typename?: 'ScopeHeader';
key?: Maybe<Scalars['Regexp']>;
value?: Maybe<Scalars['Regexp']>;
};
export type ScopeHeaderInput = {
key?: InputMaybe<Scalars['Regexp']>;
value?: InputMaybe<Scalars['Regexp']>;
};
export type ScopeRule = {
__typename?: 'ScopeRule';
body?: Maybe<Scalars['Regexp']>;
header?: Maybe<ScopeHeader>;
url?: Maybe<Scalars['Regexp']>;
};
export type ScopeRuleInput = {
body?: InputMaybe<Scalars['Regexp']>;
header?: InputMaybe<ScopeHeaderInput>;
url?: InputMaybe<Scalars['Regexp']>;
};
export type SenderRequest = {
__typename?: 'SenderRequest';
body?: Maybe<Scalars['String']>;
headers?: Maybe<Array<HttpHeader>>;
id: Scalars['ID'];
method: HttpMethod;
proto: HttpProtocol;
response?: Maybe<HttpResponseLog>;
sourceRequestLogID?: Maybe<Scalars['ID']>;
timestamp: Scalars['Time'];
url: Scalars['URL'];
};
export type SenderRequestFilter = {
__typename?: 'SenderRequestFilter';
onlyInScope: Scalars['Boolean'];
searchExpression?: Maybe<Scalars['String']>;
};
export type SenderRequestFilterInput = {
onlyInScope?: InputMaybe<Scalars['Boolean']>;
searchExpression?: InputMaybe<Scalars['String']>;
};
export type SenderRequestInput = {
body?: InputMaybe<Scalars['String']>;
headers?: InputMaybe<Array<HttpHeaderInput>>;
id?: InputMaybe<Scalars['ID']>;
method?: InputMaybe<HttpMethod>;
proto?: InputMaybe<HttpProtocol>;
url: Scalars['URL'];
};
export type CloseProjectMutationVariables = Exact<{ [key: string]: never; }>;
export type CloseProjectMutation = { __typename?: 'Mutation', closeProject: { __typename?: 'CloseProjectResult', success: boolean } };
export type CreateProjectMutationVariables = Exact<{
name: Scalars['String'];
}>;
export type CreateProjectMutation = { __typename?: 'Mutation', createProject?: { __typename?: 'Project', id: string, name: string } | null };
export type DeleteProjectMutationVariables = Exact<{
id: Scalars['ID'];
}>;
export type DeleteProjectMutation = { __typename?: 'Mutation', deleteProject: { __typename?: 'DeleteProjectResult', success: boolean } };
export type OpenProjectMutationVariables = Exact<{
id: Scalars['ID'];
}>;
export type OpenProjectMutation = { __typename?: 'Mutation', openProject?: { __typename?: 'Project', id: string, name: string, isActive: boolean } | null };
export type ProjectsQueryVariables = Exact<{ [key: string]: never; }>;
export type ProjectsQuery = { __typename?: 'Query', projects: Array<{ __typename?: 'Project', id: string, name: string, isActive: boolean }> };
export type ClearHttpRequestLogMutationVariables = Exact<{ [key: string]: never; }>;
export type ClearHttpRequestLogMutation = { __typename?: 'Mutation', clearHTTPRequestLog: { __typename?: 'ClearHTTPRequestLogResult', success: boolean } };
export type HttpRequestLogQueryVariables = Exact<{
id: Scalars['ID'];
}>;
export type HttpRequestLogQuery = { __typename?: 'Query', httpRequestLog?: { __typename?: 'HttpRequestLog', id: string, method: HttpMethod, url: string, proto: string, body?: string | null, headers: Array<{ __typename?: 'HttpHeader', key: string, value: string }>, response?: { __typename?: 'HttpResponseLog', id: string, proto: HttpProtocol, statusCode: number, statusReason: string, body?: string | null, headers: Array<{ __typename?: 'HttpHeader', key: string, value: string }> } | null } | null };
export type HttpRequestLogFilterQueryVariables = Exact<{ [key: string]: never; }>;
export type HttpRequestLogFilterQuery = { __typename?: 'Query', httpRequestLogFilter?: { __typename?: 'HttpRequestLogFilter', onlyInScope: boolean, searchExpression?: string | null } | null };
export type HttpRequestLogsQueryVariables = Exact<{ [key: string]: never; }>;
export type HttpRequestLogsQuery = { __typename?: 'Query', httpRequestLogs: Array<{ __typename?: 'HttpRequestLog', id: string, method: HttpMethod, url: string, timestamp: any, response?: { __typename?: 'HttpResponseLog', statusCode: number, statusReason: string } | null }> };
export type SetHttpRequestLogFilterMutationVariables = Exact<{
filter?: InputMaybe<HttpRequestLogFilterInput>;
}>;
export type SetHttpRequestLogFilterMutation = { __typename?: 'Mutation', setHttpRequestLogFilter?: { __typename?: 'HttpRequestLogFilter', onlyInScope: boolean, searchExpression?: string | null } | null };
export type ScopeQueryVariables = Exact<{ [key: string]: never; }>;
export type ScopeQuery = { __typename?: 'Query', scope: Array<{ __typename?: 'ScopeRule', url?: any | null }> };
export type SetScopeMutationVariables = Exact<{
scope: Array<ScopeRuleInput> | ScopeRuleInput;
}>;
export type SetScopeMutation = { __typename?: 'Mutation', setScope: Array<{ __typename?: 'ScopeRule', url?: any | null }> };
export type CreateOrUpdateSenderRequestMutationVariables = Exact<{
request: SenderRequestInput;
}>;
export type CreateOrUpdateSenderRequestMutation = { __typename?: 'Mutation', createOrUpdateSenderRequest: { __typename?: 'SenderRequest', id: string } };
export type CreateSenderRequestFromHttpRequestLogMutationVariables = Exact<{
id: Scalars['ID'];
}>;
export type CreateSenderRequestFromHttpRequestLogMutation = { __typename?: 'Mutation', createSenderRequestFromHttpRequestLog: { __typename?: 'SenderRequest', id: string } };
export type SendRequestMutationVariables = Exact<{
id: Scalars['ID'];
}>;
export type SendRequestMutation = { __typename?: 'Mutation', sendRequest: { __typename?: 'SenderRequest', id: string } };
export type GetSenderRequestQueryVariables = Exact<{
id: Scalars['ID'];
}>;
export type GetSenderRequestQuery = { __typename?: 'Query', senderRequest?: { __typename?: 'SenderRequest', id: string, sourceRequestLogID?: string | null, url: any, method: HttpMethod, proto: HttpProtocol, body?: string | null, timestamp: any, headers?: Array<{ __typename?: 'HttpHeader', key: string, value: string }> | null, response?: { __typename?: 'HttpResponseLog', id: string, proto: HttpProtocol, statusCode: number, statusReason: string, body?: string | null, headers: Array<{ __typename?: 'HttpHeader', key: string, value: string }> } | null } | null };
export type GetSenderRequestsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetSenderRequestsQuery = { __typename?: 'Query', senderRequests: Array<{ __typename?: 'SenderRequest', id: string, url: any, method: HttpMethod, response?: { __typename?: 'HttpResponseLog', id: string, statusCode: number, statusReason: string } | null }> };
export const CloseProjectDocument = gql`
mutation CloseProject {
closeProject {
success
}
}
`;
export type CloseProjectMutationFn = Apollo.MutationFunction<CloseProjectMutation, CloseProjectMutationVariables>;
/**
* __useCloseProjectMutation__
*
* To run a mutation, you first call `useCloseProjectMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCloseProjectMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [closeProjectMutation, { data, loading, error }] = useCloseProjectMutation({
* variables: {
* },
* });
*/
export function useCloseProjectMutation(baseOptions?: Apollo.MutationHookOptions<CloseProjectMutation, CloseProjectMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CloseProjectMutation, CloseProjectMutationVariables>(CloseProjectDocument, options);
}
export type CloseProjectMutationHookResult = ReturnType<typeof useCloseProjectMutation>;
export type CloseProjectMutationResult = Apollo.MutationResult<CloseProjectMutation>;
export type CloseProjectMutationOptions = Apollo.BaseMutationOptions<CloseProjectMutation, CloseProjectMutationVariables>;
export const CreateProjectDocument = gql`
mutation CreateProject($name: String!) {
createProject(name: $name) {
id
name
}
}
`;
export type CreateProjectMutationFn = Apollo.MutationFunction<CreateProjectMutation, CreateProjectMutationVariables>;
/**
* __useCreateProjectMutation__
*
* To run a mutation, you first call `useCreateProjectMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreateProjectMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [createProjectMutation, { data, loading, error }] = useCreateProjectMutation({
* variables: {
* name: // value for 'name'
* },
* });
*/
export function useCreateProjectMutation(baseOptions?: Apollo.MutationHookOptions<CreateProjectMutation, CreateProjectMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateProjectMutation, CreateProjectMutationVariables>(CreateProjectDocument, options);
}
export type CreateProjectMutationHookResult = ReturnType<typeof useCreateProjectMutation>;
export type CreateProjectMutationResult = Apollo.MutationResult<CreateProjectMutation>;
export type CreateProjectMutationOptions = Apollo.BaseMutationOptions<CreateProjectMutation, CreateProjectMutationVariables>;
export const DeleteProjectDocument = gql`
mutation DeleteProject($id: ID!) {
deleteProject(id: $id) {
success
}
}
`;
export type DeleteProjectMutationFn = Apollo.MutationFunction<DeleteProjectMutation, DeleteProjectMutationVariables>;
/**
* __useDeleteProjectMutation__
*
* To run a mutation, you first call `useDeleteProjectMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useDeleteProjectMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [deleteProjectMutation, { data, loading, error }] = useDeleteProjectMutation({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useDeleteProjectMutation(baseOptions?: Apollo.MutationHookOptions<DeleteProjectMutation, DeleteProjectMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DeleteProjectMutation, DeleteProjectMutationVariables>(DeleteProjectDocument, options);
}
export type DeleteProjectMutationHookResult = ReturnType<typeof useDeleteProjectMutation>;
export type DeleteProjectMutationResult = Apollo.MutationResult<DeleteProjectMutation>;
export type DeleteProjectMutationOptions = Apollo.BaseMutationOptions<DeleteProjectMutation, DeleteProjectMutationVariables>;
export const OpenProjectDocument = gql`
mutation OpenProject($id: ID!) {
openProject(id: $id) {
id
name
isActive
}
}
`;
export type OpenProjectMutationFn = Apollo.MutationFunction<OpenProjectMutation, OpenProjectMutationVariables>;
/**
* __useOpenProjectMutation__
*
* To run a mutation, you first call `useOpenProjectMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useOpenProjectMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [openProjectMutation, { data, loading, error }] = useOpenProjectMutation({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useOpenProjectMutation(baseOptions?: Apollo.MutationHookOptions<OpenProjectMutation, OpenProjectMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<OpenProjectMutation, OpenProjectMutationVariables>(OpenProjectDocument, options);
}
export type OpenProjectMutationHookResult = ReturnType<typeof useOpenProjectMutation>;
export type OpenProjectMutationResult = Apollo.MutationResult<OpenProjectMutation>;
export type OpenProjectMutationOptions = Apollo.BaseMutationOptions<OpenProjectMutation, OpenProjectMutationVariables>;
export const ProjectsDocument = gql`
query Projects {
projects {
id
name
isActive
}
}
`;
/**
* __useProjectsQuery__
*
* To run a query within a React component, call `useProjectsQuery` and pass it any options that fit your needs.
* When your component renders, `useProjectsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useProjectsQuery({
* variables: {
* },
* });
*/
export function useProjectsQuery(baseOptions?: Apollo.QueryHookOptions<ProjectsQuery, ProjectsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ProjectsQuery, ProjectsQueryVariables>(ProjectsDocument, options);
}
export function useProjectsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ProjectsQuery, ProjectsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ProjectsQuery, ProjectsQueryVariables>(ProjectsDocument, options);
}
export type ProjectsQueryHookResult = ReturnType<typeof useProjectsQuery>;
export type ProjectsLazyQueryHookResult = ReturnType<typeof useProjectsLazyQuery>;
export type ProjectsQueryResult = Apollo.QueryResult<ProjectsQuery, ProjectsQueryVariables>;
export const ClearHttpRequestLogDocument = gql`
mutation ClearHTTPRequestLog {
clearHTTPRequestLog {
success
}
}
`;
export type ClearHttpRequestLogMutationFn = Apollo.MutationFunction<ClearHttpRequestLogMutation, ClearHttpRequestLogMutationVariables>;
/**
* __useClearHttpRequestLogMutation__
*
* To run a mutation, you first call `useClearHttpRequestLogMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useClearHttpRequestLogMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [clearHttpRequestLogMutation, { data, loading, error }] = useClearHttpRequestLogMutation({
* variables: {
* },
* });
*/
export function useClearHttpRequestLogMutation(baseOptions?: Apollo.MutationHookOptions<ClearHttpRequestLogMutation, ClearHttpRequestLogMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<ClearHttpRequestLogMutation, ClearHttpRequestLogMutationVariables>(ClearHttpRequestLogDocument, options);
}
export type ClearHttpRequestLogMutationHookResult = ReturnType<typeof useClearHttpRequestLogMutation>;
export type ClearHttpRequestLogMutationResult = Apollo.MutationResult<ClearHttpRequestLogMutation>;
export type ClearHttpRequestLogMutationOptions = Apollo.BaseMutationOptions<ClearHttpRequestLogMutation, ClearHttpRequestLogMutationVariables>;
export const HttpRequestLogDocument = gql`
query HttpRequestLog($id: ID!) {
httpRequestLog(id: $id) {
id
method
url
proto
headers {
key
value
}
body
response {
id
proto
headers {
key
value
}
statusCode
statusReason
body
}
}
}
`;
/**
* __useHttpRequestLogQuery__
*
* To run a query within a React component, call `useHttpRequestLogQuery` and pass it any options that fit your needs.
* When your component renders, `useHttpRequestLogQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useHttpRequestLogQuery({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useHttpRequestLogQuery(baseOptions: Apollo.QueryHookOptions<HttpRequestLogQuery, HttpRequestLogQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<HttpRequestLogQuery, HttpRequestLogQueryVariables>(HttpRequestLogDocument, options);
}
export function useHttpRequestLogLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<HttpRequestLogQuery, HttpRequestLogQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<HttpRequestLogQuery, HttpRequestLogQueryVariables>(HttpRequestLogDocument, options);
}
export type HttpRequestLogQueryHookResult = ReturnType<typeof useHttpRequestLogQuery>;
export type HttpRequestLogLazyQueryHookResult = ReturnType<typeof useHttpRequestLogLazyQuery>;
export type HttpRequestLogQueryResult = Apollo.QueryResult<HttpRequestLogQuery, HttpRequestLogQueryVariables>;
export const HttpRequestLogFilterDocument = gql`
query HttpRequestLogFilter {
httpRequestLogFilter {
onlyInScope
searchExpression
}
}
`;
/**
* __useHttpRequestLogFilterQuery__
*
* To run a query within a React component, call `useHttpRequestLogFilterQuery` and pass it any options that fit your needs.
* When your component renders, `useHttpRequestLogFilterQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useHttpRequestLogFilterQuery({
* variables: {
* },
* });
*/
export function useHttpRequestLogFilterQuery(baseOptions?: Apollo.QueryHookOptions<HttpRequestLogFilterQuery, HttpRequestLogFilterQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<HttpRequestLogFilterQuery, HttpRequestLogFilterQueryVariables>(HttpRequestLogFilterDocument, options);
}
export function useHttpRequestLogFilterLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<HttpRequestLogFilterQuery, HttpRequestLogFilterQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<HttpRequestLogFilterQuery, HttpRequestLogFilterQueryVariables>(HttpRequestLogFilterDocument, options);
}
export type HttpRequestLogFilterQueryHookResult = ReturnType<typeof useHttpRequestLogFilterQuery>;
export type HttpRequestLogFilterLazyQueryHookResult = ReturnType<typeof useHttpRequestLogFilterLazyQuery>;
export type HttpRequestLogFilterQueryResult = Apollo.QueryResult<HttpRequestLogFilterQuery, HttpRequestLogFilterQueryVariables>;
export const HttpRequestLogsDocument = gql`
query HttpRequestLogs {
httpRequestLogs {
id
method
url
timestamp
response {
statusCode
statusReason
}
}
}
`;
/**
* __useHttpRequestLogsQuery__
*
* To run a query within a React component, call `useHttpRequestLogsQuery` and pass it any options that fit your needs.
* When your component renders, `useHttpRequestLogsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useHttpRequestLogsQuery({
* variables: {
* },
* });
*/
export function useHttpRequestLogsQuery(baseOptions?: Apollo.QueryHookOptions<HttpRequestLogsQuery, HttpRequestLogsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<HttpRequestLogsQuery, HttpRequestLogsQueryVariables>(HttpRequestLogsDocument, options);
}
export function useHttpRequestLogsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<HttpRequestLogsQuery, HttpRequestLogsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<HttpRequestLogsQuery, HttpRequestLogsQueryVariables>(HttpRequestLogsDocument, options);
}
export type HttpRequestLogsQueryHookResult = ReturnType<typeof useHttpRequestLogsQuery>;
export type HttpRequestLogsLazyQueryHookResult = ReturnType<typeof useHttpRequestLogsLazyQuery>;
export type HttpRequestLogsQueryResult = Apollo.QueryResult<HttpRequestLogsQuery, HttpRequestLogsQueryVariables>;
export const SetHttpRequestLogFilterDocument = gql`
mutation SetHttpRequestLogFilter($filter: HttpRequestLogFilterInput) {
setHttpRequestLogFilter(filter: $filter) {
onlyInScope
searchExpression
}
}
`;
export type SetHttpRequestLogFilterMutationFn = Apollo.MutationFunction<SetHttpRequestLogFilterMutation, SetHttpRequestLogFilterMutationVariables>;
/**
* __useSetHttpRequestLogFilterMutation__
*
* To run a mutation, you first call `useSetHttpRequestLogFilterMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useSetHttpRequestLogFilterMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [setHttpRequestLogFilterMutation, { data, loading, error }] = useSetHttpRequestLogFilterMutation({
* variables: {
* filter: // value for 'filter'
* },
* });
*/
export function useSetHttpRequestLogFilterMutation(baseOptions?: Apollo.MutationHookOptions<SetHttpRequestLogFilterMutation, SetHttpRequestLogFilterMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<SetHttpRequestLogFilterMutation, SetHttpRequestLogFilterMutationVariables>(SetHttpRequestLogFilterDocument, options);
}
export type SetHttpRequestLogFilterMutationHookResult = ReturnType<typeof useSetHttpRequestLogFilterMutation>;
export type SetHttpRequestLogFilterMutationResult = Apollo.MutationResult<SetHttpRequestLogFilterMutation>;
export type SetHttpRequestLogFilterMutationOptions = Apollo.BaseMutationOptions<SetHttpRequestLogFilterMutation, SetHttpRequestLogFilterMutationVariables>;
export const ScopeDocument = gql`
query Scope {
scope {
url
}
}
`;
/**
* __useScopeQuery__
*
* To run a query within a React component, call `useScopeQuery` and pass it any options that fit your needs.
* When your component renders, `useScopeQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useScopeQuery({
* variables: {
* },
* });
*/
export function useScopeQuery(baseOptions?: Apollo.QueryHookOptions<ScopeQuery, ScopeQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<ScopeQuery, ScopeQueryVariables>(ScopeDocument, options);
}
export function useScopeLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ScopeQuery, ScopeQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<ScopeQuery, ScopeQueryVariables>(ScopeDocument, options);
}
export type ScopeQueryHookResult = ReturnType<typeof useScopeQuery>;
export type ScopeLazyQueryHookResult = ReturnType<typeof useScopeLazyQuery>;
export type ScopeQueryResult = Apollo.QueryResult<ScopeQuery, ScopeQueryVariables>;
export const SetScopeDocument = gql`
mutation SetScope($scope: [ScopeRuleInput!]!) {
setScope(scope: $scope) {
url
}
}
`;
export type SetScopeMutationFn = Apollo.MutationFunction<SetScopeMutation, SetScopeMutationVariables>;
/**
* __useSetScopeMutation__
*
* To run a mutation, you first call `useSetScopeMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useSetScopeMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [setScopeMutation, { data, loading, error }] = useSetScopeMutation({
* variables: {
* scope: // value for 'scope'
* },
* });
*/
export function useSetScopeMutation(baseOptions?: Apollo.MutationHookOptions<SetScopeMutation, SetScopeMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<SetScopeMutation, SetScopeMutationVariables>(SetScopeDocument, options);
}
export type SetScopeMutationHookResult = ReturnType<typeof useSetScopeMutation>;
export type SetScopeMutationResult = Apollo.MutationResult<SetScopeMutation>;
export type SetScopeMutationOptions = Apollo.BaseMutationOptions<SetScopeMutation, SetScopeMutationVariables>;
export const CreateOrUpdateSenderRequestDocument = gql`
mutation CreateOrUpdateSenderRequest($request: SenderRequestInput!) {
createOrUpdateSenderRequest(request: $request) {
id
}
}
`;
export type CreateOrUpdateSenderRequestMutationFn = Apollo.MutationFunction<CreateOrUpdateSenderRequestMutation, CreateOrUpdateSenderRequestMutationVariables>;
/**
* __useCreateOrUpdateSenderRequestMutation__
*
* To run a mutation, you first call `useCreateOrUpdateSenderRequestMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreateOrUpdateSenderRequestMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [createOrUpdateSenderRequestMutation, { data, loading, error }] = useCreateOrUpdateSenderRequestMutation({
* variables: {
* request: // value for 'request'
* },
* });
*/
export function useCreateOrUpdateSenderRequestMutation(baseOptions?: Apollo.MutationHookOptions<CreateOrUpdateSenderRequestMutation, CreateOrUpdateSenderRequestMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateOrUpdateSenderRequestMutation, CreateOrUpdateSenderRequestMutationVariables>(CreateOrUpdateSenderRequestDocument, options);
}
export type CreateOrUpdateSenderRequestMutationHookResult = ReturnType<typeof useCreateOrUpdateSenderRequestMutation>;
export type CreateOrUpdateSenderRequestMutationResult = Apollo.MutationResult<CreateOrUpdateSenderRequestMutation>;
export type CreateOrUpdateSenderRequestMutationOptions = Apollo.BaseMutationOptions<CreateOrUpdateSenderRequestMutation, CreateOrUpdateSenderRequestMutationVariables>;
export const CreateSenderRequestFromHttpRequestLogDocument = gql`
mutation CreateSenderRequestFromHttpRequestLog($id: ID!) {
createSenderRequestFromHttpRequestLog(id: $id) {
id
}
}
`;
export type CreateSenderRequestFromHttpRequestLogMutationFn = Apollo.MutationFunction<CreateSenderRequestFromHttpRequestLogMutation, CreateSenderRequestFromHttpRequestLogMutationVariables>;
/**
* __useCreateSenderRequestFromHttpRequestLogMutation__
*
* To run a mutation, you first call `useCreateSenderRequestFromHttpRequestLogMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useCreateSenderRequestFromHttpRequestLogMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [createSenderRequestFromHttpRequestLogMutation, { data, loading, error }] = useCreateSenderRequestFromHttpRequestLogMutation({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useCreateSenderRequestFromHttpRequestLogMutation(baseOptions?: Apollo.MutationHookOptions<CreateSenderRequestFromHttpRequestLogMutation, CreateSenderRequestFromHttpRequestLogMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<CreateSenderRequestFromHttpRequestLogMutation, CreateSenderRequestFromHttpRequestLogMutationVariables>(CreateSenderRequestFromHttpRequestLogDocument, options);
}
export type CreateSenderRequestFromHttpRequestLogMutationHookResult = ReturnType<typeof useCreateSenderRequestFromHttpRequestLogMutation>;
export type CreateSenderRequestFromHttpRequestLogMutationResult = Apollo.MutationResult<CreateSenderRequestFromHttpRequestLogMutation>;
export type CreateSenderRequestFromHttpRequestLogMutationOptions = Apollo.BaseMutationOptions<CreateSenderRequestFromHttpRequestLogMutation, CreateSenderRequestFromHttpRequestLogMutationVariables>;
export const SendRequestDocument = gql`
mutation SendRequest($id: ID!) {
sendRequest(id: $id) {
id
}
}
`;
export type SendRequestMutationFn = Apollo.MutationFunction<SendRequestMutation, SendRequestMutationVariables>;
/**
* __useSendRequestMutation__
*
* To run a mutation, you first call `useSendRequestMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useSendRequestMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [sendRequestMutation, { data, loading, error }] = useSendRequestMutation({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useSendRequestMutation(baseOptions?: Apollo.MutationHookOptions<SendRequestMutation, SendRequestMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<SendRequestMutation, SendRequestMutationVariables>(SendRequestDocument, options);
}
export type SendRequestMutationHookResult = ReturnType<typeof useSendRequestMutation>;
export type SendRequestMutationResult = Apollo.MutationResult<SendRequestMutation>;
export type SendRequestMutationOptions = Apollo.BaseMutationOptions<SendRequestMutation, SendRequestMutationVariables>;
export const GetSenderRequestDocument = gql`
query GetSenderRequest($id: ID!) {
senderRequest(id: $id) {
id
sourceRequestLogID
url
method
proto
headers {
key
value
}
body
timestamp
response {
id
proto
statusCode
statusReason
body
headers {
key
value
}
}
}
}
`;
/**
* __useGetSenderRequestQuery__
*
* To run a query within a React component, call `useGetSenderRequestQuery` and pass it any options that fit your needs.
* When your component renders, `useGetSenderRequestQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetSenderRequestQuery({
* variables: {
* id: // value for 'id'
* },
* });
*/
export function useGetSenderRequestQuery(baseOptions: Apollo.QueryHookOptions<GetSenderRequestQuery, GetSenderRequestQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetSenderRequestQuery, GetSenderRequestQueryVariables>(GetSenderRequestDocument, options);
}
export function useGetSenderRequestLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetSenderRequestQuery, GetSenderRequestQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetSenderRequestQuery, GetSenderRequestQueryVariables>(GetSenderRequestDocument, options);
}
export type GetSenderRequestQueryHookResult = ReturnType<typeof useGetSenderRequestQuery>;
export type GetSenderRequestLazyQueryHookResult = ReturnType<typeof useGetSenderRequestLazyQuery>;
export type GetSenderRequestQueryResult = Apollo.QueryResult<GetSenderRequestQuery, GetSenderRequestQueryVariables>;
export const GetSenderRequestsDocument = gql`
query GetSenderRequests {
senderRequests {
id
url
method
response {
id
statusCode
statusReason
}
}
}
`;
/**
* __useGetSenderRequestsQuery__
*
* To run a query within a React component, call `useGetSenderRequestsQuery` and pass it any options that fit your needs.
* When your component renders, `useGetSenderRequestsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetSenderRequestsQuery({
* variables: {
* },
* });
*/
export function useGetSenderRequestsQuery(baseOptions?: Apollo.QueryHookOptions<GetSenderRequestsQuery, GetSenderRequestsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetSenderRequestsQuery, GetSenderRequestsQueryVariables>(GetSenderRequestsDocument, options);
}
export function useGetSenderRequestsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetSenderRequestsQuery, GetSenderRequestsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetSenderRequestsQuery, GetSenderRequestsQueryVariables>(GetSenderRequestsDocument, options);
}
export type GetSenderRequestsQueryHookResult = ReturnType<typeof useGetSenderRequestsQuery>;
export type GetSenderRequestsLazyQueryHookResult = ReturnType<typeof useGetSenderRequestsLazyQuery>;
export type GetSenderRequestsQueryResult = Apollo.QueryResult<GetSenderRequestsQuery, GetSenderRequestsQueryVariables>;

View File

@ -0,0 +1,7 @@
function omitTypename<T>(key: string, value: T): T | undefined {
return key === "__typename" ? undefined : value;
}
export function withoutTypename<T>(input: T): T {
return JSON.parse(JSON.stringify(input), omitTypename);
}

View File

@ -0,0 +1,24 @@
import { ApolloClient, HttpLink, InMemoryCache, NormalizedCacheObject } from "@apollo/client";
let apolloClient: ApolloClient<NormalizedCacheObject>;
function createApolloClient() {
return new ApolloClient({
ssrMode: typeof window === "undefined",
link: new HttpLink({
uri: "/api/graphql/",
}),
cache: new InMemoryCache(),
});
}
export function useApollo() {
const _apolloClient = apolloClient ?? createApolloClient();
// For SSG and SSR always create a new Apollo Client
if (typeof window === "undefined") return _apolloClient;
// Create the Apollo Client once in the client
if (!apolloClient) apolloClient = _apolloClient;
return _apolloClient;
}

View File

@ -0,0 +1,7 @@
import createCache from "@emotion/cache";
// prepend: true moves MUI styles to the top of the <head> so they're loaded first.
// It allows developers to easily override MUI styles with other styling solutions, like CSS modules.
export default function createEmotionCache() {
return createCache({ key: "css", prepend: true });
}

View File

@ -0,0 +1,75 @@
import * as colors from "@mui/material/colors";
import { createTheme } from "@mui/material/styles";
declare module "@mui/material/Paper" {
interface PaperPropsVariantOverrides {
centered: true;
}
}
const heading = {
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
};
let theme = createTheme({
palette: {
mode: "dark",
primary: {
main: colors.teal["A400"],
},
secondary: {
main: colors.grey[900],
light: "#333",
dark: colors.common.black,
},
},
typography: {
h2: heading,
h3: heading,
h4: heading,
h5: heading,
h6: heading,
},
});
theme = createTheme(theme, {
palette: {
background: {
default: theme.palette.secondary.main,
paper: theme.palette.secondary.light,
},
info: {
main: theme.palette.primary.main,
},
success: {
main: theme.palette.primary.main,
},
},
components: {
MuiTableRow: {
styleOverrides: {
root: {
"&.Mui-selected, &.Mui-selected:hover": {
backgroundColor: theme.palette.grey[700],
},
},
},
},
MuiPaper: {
variants: [
{
props: { variant: "centered" },
style: {
display: "flex",
justifyContent: "center",
alignItems: "center",
padding: theme.spacing(4),
},
},
],
},
},
});
export default theme;

View File

@ -1,5 +0,0 @@
const omitTypename = (key, value) => (key === "__typename" ? undefined : value);
export function withoutTypename(input: any): any {
return JSON.parse(JSON.stringify(input), omitTypename);
}

View File

@ -0,0 +1,17 @@
import { KeyValuePair } from "./components/KeyValuePair";
export function queryParamsFromURL(url: string): KeyValuePair[] {
const questionMarkIndex = url.indexOf("?");
if (questionMarkIndex === -1) {
return [];
}
const queryParams: KeyValuePair[] = [];
const searchParams = new URLSearchParams(url.slice(questionMarkIndex + 1));
for (const [key, value] of searchParams) {
queryParams.push({ key, value });
}
return queryParams;
}

View File

@ -1,52 +0,0 @@
import { createMuiTheme } from "@material-ui/core/styles";
import grey from "@material-ui/core/colors/grey";
import teal from "@material-ui/core/colors/teal";
const theme = createMuiTheme({
palette: {
type: "dark",
primary: {
main: grey[900],
},
secondary: {
main: teal["A400"],
},
info: {
main: teal["A400"],
},
success: {
main: teal["A400"],
},
},
typography: {
h2: {
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
},
h3: {
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
},
h4: {
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
},
h5: {
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
},
h6: {
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 600,
},
},
overrides: {
MuiTableCell: {
stickyHeader: {
backgroundColor: grey[900],
},
},
},
});
export default theme;

View File

@ -1,41 +1,43 @@
import React from "react";
import { AppProps } from "next/app";
import { ApolloProvider } from "@apollo/client";
import { CacheProvider, EmotionCache } from "@emotion/react";
import { ThemeProvider } from "@mui/material";
import CssBaseline from "@mui/material/CssBaseline";
import { AppProps } from "next/app";
import Head from "next/head";
import { ThemeProvider } from "@material-ui/core/styles";
import CssBaseline from "@material-ui/core/CssBaseline";
import React from "react";
import theme from "../lib/theme";
import { useApollo } from "../lib/graphql";
import { ActiveProjectProvider } from "lib/ActiveProjectContext";
import { useApollo } from "lib/graphql/useApollo";
import createEmotionCache from "lib/mui/createEmotionCache";
import theme from "lib/mui/theme";
function App({ Component, pageProps }: AppProps): JSX.Element {
const apolloClient = useApollo(pageProps.initialApolloState);
import "../styles.css";
React.useEffect(() => {
// Remove the server-side injected CSS.
const jssStyles = document.querySelector("#jss-server-side");
if (jssStyles) {
jssStyles.parentElement.removeChild(jssStyles);
}
}, []);
// Client-side cache, shared for the whole session of the user in the browser.
const clientSideEmotionCache = createEmotionCache();
return (
<React.Fragment>
<Head>
<title>Hetty://</title>
<meta
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width"
/>
</Head>
<ApolloProvider client={apolloClient}>
<ThemeProvider theme={theme}>
<CssBaseline />
<Component {...pageProps} />
</ThemeProvider>
</ApolloProvider>
</React.Fragment>
);
interface MyAppProps extends AppProps {
emotionCache?: EmotionCache;
}
export default App;
export default function MyApp(props: MyAppProps) {
const { Component, emotionCache = clientSideEmotionCache, pageProps } = props;
const apolloClient = useApollo();
return (
<CacheProvider value={emotionCache}>
<Head>
<title>Hetty://</title>
<meta name="viewport" content="initial-scale=1, width=device-width" />
</Head>
<ApolloProvider client={apolloClient}>
<ActiveProjectProvider>
<ThemeProvider theme={theme}>
<CssBaseline />
<Component {...pageProps} />
</ThemeProvider>
</ActiveProjectProvider>
</ApolloProvider>
</CacheProvider>
);
}

View File

@ -1,24 +1,21 @@
import React from "react";
import createEmotionServer from "@emotion/server/create-instance";
import Document, { Html, Head, Main, NextScript } from "next/document";
import { ServerStyleSheets } from "@material-ui/core/styles";
import React from "react";
import theme from "../lib/theme";
import createEmotionCache from "lib/mui/createEmotionCache";
import theme from "lib/mui/theme";
export default class MyDocument extends Document {
/* eslint-disable */
render() {
return (
<Html lang="en">
<Head>
<meta name="theme-color" content={theme.palette.primary.main} />
<link rel="stylesheet" href="/style.css" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
/>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap"
/>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap" />
{(this.props as any).emotionStyleTags}
</Head>
<body>
<Main />
@ -30,25 +27,60 @@ export default class MyDocument extends Document {
}
// `getInitialProps` belongs to `_document` (instead of `_app`),
// it's compatible with server-side generation (SSG).
// it's compatible with static-site generation (SSG).
MyDocument.getInitialProps = async (ctx) => {
// Render app and page and get the context of the page with collected side effects.
const sheets = new ServerStyleSheets();
// Resolution order
//
// On the server:
// 1. app.getInitialProps
// 2. page.getInitialProps
// 3. document.getInitialProps
// 4. app.render
// 5. page.render
// 6. document.render
//
// On the server with error:
// 1. document.getInitialProps
// 2. app.render
// 3. page.render
// 4. document.render
//
// On the client
// 1. app.getInitialProps
// 2. page.getInitialProps
// 3. app.render
// 4. page.render
const originalRenderPage = ctx.renderPage;
// You can consider sharing the same emotion cache between all the SSR requests to speed up performance.
// However, be aware that it can have global side effects.
const cache = createEmotionCache();
const { extractCriticalToChunks } = createEmotionServer(cache);
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: (App) => (props) => sheets.collect(<App {...props} />),
enhanceApp: (App: any) =>
function EnhanceApp(props) {
return <App emotionCache={cache} {...props} />;
},
});
const initialProps = await Document.getInitialProps(ctx);
// This is important. It prevents emotion to render invalid HTML.
// See https://github.com/mui-org/material-ui/issues/26561#issuecomment-855286153
const emotionStyles = extractCriticalToChunks(initialProps.html);
const emotionStyleTags = emotionStyles.styles.map((style) => (
<style
data-emotion={`${style.key} ${style.ids.join(" ")}`}
key={style.key}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: style.css }}
/>
));
return {
...initialProps,
// Styles fragment is rendered after the app and page rendering finish.
styles: [
...React.Children.toArray(initialProps.styles),
sheets.getStyleElement(),
],
emotionStyleTags,
};
};

View File

@ -1,36 +0,0 @@
import { Box, Link as MaterialLink, Typography } from "@material-ui/core";
import Link from "next/link";
import React from "react";
import Layout, { Page } from "../../components/Layout";
function Index(): JSX.Element {
return (
<Layout page={Page.GetStarted} title="Get started">
<Box p={4}>
<Box mb={3}>
<Typography variant="h4">Get started</Typography>
</Box>
<Typography paragraph>
Youve loaded a (new) project. Whats next? You can now use the MITM
proxy and review HTTP requests and responses via the{" "}
<Link href="/proxy/logs" passHref>
<MaterialLink color="secondary">Proxy logs</MaterialLink>
</Link>
. Stuck? Ask for help on the{" "}
<MaterialLink
href="https://github.com/dstotijn/hetty/discussions"
color="secondary"
target="_blank"
>
Discussions forum
</MaterialLink>
.
</Typography>
</Box>
</Layout>
);
}
export default Index;

View File

@ -1,253 +1,53 @@
import {
Avatar,
Box,
Button,
CircularProgress,
createStyles,
List,
ListItem,
ListItemAvatar,
ListItemText,
makeStyles,
TextField,
Theme,
Typography,
} from "@material-ui/core";
import AddIcon from "@material-ui/icons/Add";
import FolderIcon from "@material-ui/icons/Folder";
import DescriptionIcon from "@material-ui/icons/Description";
import PlayArrowIcon from "@material-ui/icons/PlayArrow";
import FolderIcon from "@mui/icons-material/Folder";
import { Box, Button, Typography } from "@mui/material";
import Link from "next/link";
import { useState } from "react";
import { gql, useMutation, useQuery } from "@apollo/client";
import { useRouter } from "next/router";
import Layout, { Page } from "../components/Layout";
import { Alert } from "@material-ui/lab";
const useStyles = makeStyles((theme: Theme) =>
createStyles({
titleHighlight: {
color: theme.palette.secondary.main,
},
subtitle: {
fontSize: "1.6rem",
width: "60%",
lineHeight: 2,
marginBottom: theme.spacing(5),
},
projectName: {
marginTop: -6,
marginRight: theme.spacing(2),
},
button: {
marginRight: theme.spacing(2),
},
activeProject: {
color: theme.palette.getContrastText(theme.palette.secondary.main),
backgroundColor: theme.palette.secondary.main,
},
})
);
const ACTIVE_PROJECT = gql`
query ActiveProject {
activeProject {
name
}
}
`;
const OPEN_PROJECT = gql`
mutation OpenProject($name: String!) {
openProject(name: $name) {
name
isActive
}
}
`;
import { Layout, Page } from "features/Layout";
function Index(): JSX.Element {
const classes = useStyles();
const router = useRouter();
const [input, setInput] = useState(null);
const { error: activeProjErr, data: activeProjData } = useQuery(
ACTIVE_PROJECT,
{
pollInterval: 1000,
}
);
const [
openProject,
{ error: openProjErr, data: openProjData, loading: openProjLoading },
] = useMutation(OPEN_PROJECT, {
onError: () => {},
onCompleted({ openProject }) {
if (openProject) {
router.push("/get-started");
}
},
update(cache, { data: { openProject } }) {
cache.modify({
fields: {
activeProject() {
const activeProjRef = cache.writeFragment({
id: openProject.name,
data: openProject,
fragment: gql`
fragment ActiveProject on Project {
name
isActive
type
}
`,
});
return activeProjRef;
},
projects(_, { DELETE }) {
cache.writeFragment({
id: openProject.name,
data: openProject,
fragment: gql`
fragment OpenProject on Project {
name
isActive
type
}
`,
});
return DELETE;
},
},
});
},
});
const handleForm = (e: React.SyntheticEvent) => {
e.preventDefault();
openProject({ variables: { name: input.value } });
};
if (activeProjErr) {
return (
<Layout page={Page.Home} title="">
<Alert severity="error">
Error fetching active project: {activeProjErr.message}
</Alert>
</Layout>
);
}
const highlightSx = { color: "primary.main" };
return (
<Layout page={Page.Home} title="">
<Box p={4}>
<Box mb={4} width="60%">
<Typography variant="h2">
<span className={classes.titleHighlight}>Hetty://</span>
<Box component="span" sx={highlightSx}>
Hetty://
</Box>
<br />
The simple HTTP toolkit for security research.
</Typography>
</Box>
<Typography className={classes.subtitle} paragraph>
What if security testing was intuitive, powerful, and good looking?
What if it was <strong>free</strong>, instead of $400 per year?{" "}
<span className={classes.titleHighlight}>Hetty</span> is listening on{" "}
<code>:8080</code>
<Typography
paragraph
sx={{
fontSize: "1.6rem",
width: "60%",
lineHeight: 2,
mb: 5,
}}
>
Welcome to{" "}
<Box component="span" sx={highlightSx}>
Hetty
</Box>
. Get started by creating a project.
</Typography>
{activeProjData?.activeProject?.name ? (
<div>
<Box mb={1}>
<Typography variant="h6">Active project:</Typography>
</Box>
<Box ml={-2} mb={2}>
<List>
<ListItem>
<ListItemAvatar>
<Avatar className={classes.activeProject}>
<DescriptionIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={activeProjData.activeProject.name} />
</ListItem>
</List>
</Box>
<div>
<Link href="/get-started" passHref>
<Button
className={classes.button}
variant="outlined"
component="a"
color="secondary"
size="large"
startIcon={<PlayArrowIcon />}
>
Get started
</Button>
</Link>
<Link href="/projects" passHref>
<Button
className={classes.button}
variant="outlined"
component="a"
size="large"
startIcon={<FolderIcon />}
>
Manage projects
</Button>
</Link>
</div>
</div>
) : (
<form onSubmit={handleForm} autoComplete="off">
<TextField
className={classes.projectName}
color="secondary"
inputProps={{
id: "projectName",
ref: (node) => {
setInput(node);
},
}}
label="Project name"
placeholder="Project name…"
error={Boolean(openProjErr)}
helperText={openProjErr && openProjErr.message}
/>
<Button
className={classes.button}
type="submit"
variant="contained"
color="secondary"
size="large"
disabled={
openProjLoading || Boolean(openProjData?.openProject?.name)
}
startIcon={
openProjLoading || openProjData?.openProject ? (
<CircularProgress size={22} />
) : (
<AddIcon />
)
}
>
Create project
</Button>
<Link href="/projects" passHref>
<Button
className={classes.button}
variant="outlined"
component="a"
size="large"
startIcon={<FolderIcon />}
>
Open project
</Button>
</Link>
</form>
)}
<Link href="/projects" passHref>
<Button
sx={{ mr: 2 }}
variant="contained"
color="primary"
component="a"
size="large"
startIcon={<FolderIcon />}
>
Manage projects
</Button>
</Link>
</Box>
</Layout>
);

View File

@ -1,7 +1,8 @@
import { Box, Divider, Grid, Typography } from "@material-ui/core";
import Layout, { Page } from "../../components/Layout";
import NewProject from "../../components/projects/NewProject";
import ProjectList from "../../components/projects/ProjectList";
import { Box, Divider, Grid, Typography } from "@mui/material";
import { Layout, Page } from "features/Layout";
import NewProject from "features/projects/components/NewProject";
import ProjectList from "features/projects/components/ProjectList";
function Index(): JSX.Element {
return (
@ -11,8 +12,7 @@ function Index(): JSX.Element {
<Typography variant="h4">Projects</Typography>
</Box>
<Typography paragraph>
Projects contain settings and data generated/processed by Hetty. They
are stored as SQLite database files on disk.
Projects contain settings and data generated/processed by Hetty. They are stored in a single database on disk.
</Typography>
<Box my={4}>
<Divider />

View File

@ -1,22 +1,16 @@
import React from "react";
import { Button, Typography } from "@material-ui/core";
import ListIcon from "@material-ui/icons/List";
import ListIcon from "@mui/icons-material/List";
import { Button, Typography } from "@mui/material";
import Link from "next/link";
import React from "react";
import Layout, { Page } from "../../components/Layout";
import { Layout, Page } from "features/Layout";
function Index(): JSX.Element {
return (
<Layout page={Page.ProxySetup} title="Proxy setup">
<Typography paragraph>Coming soon</Typography>
<Link href="/proxy/logs" passHref>
<Button
variant="contained"
color="secondary"
component="a"
size="large"
startIcon={<ListIcon />}
>
<Button variant="contained" color="primary" component="a" size="large" startIcon={<ListIcon />}>
View logs
</Button>
</Link>

Some files were not shown because too many files have changed in this diff Show More