Compare commits

..

77 Commits

Author SHA1 Message Date
aa3f9d3da9 Bump webpack from 5.70.0 to 5.76.0 in /admin
Bumps [webpack](https://github.com/webpack/webpack) from 5.70.0 to 5.76.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Commits](https://github.com/webpack/webpack/compare/v5.70.0...v5.76.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-03-14 22:32:31 +00:00
f7def87d0f Add HTTP header support to string literal matching 2022-03-31 15:23:56 +02:00
aa9822854d Rename search package to filter package 2022-03-31 15:12:54 +02:00
2ce4218a30 Add filter support for HTTP headers 2022-03-31 14:53:40 +02:00
fd27955e11 Sort HTTP headers 2022-03-31 12:07:35 +02:00
426a7d5f96 Add "New request" button to Sender page 2022-03-31 11:23:17 +02:00
21b679dc91 Add new sponsorship options 2022-03-30 13:02:58 +02:00
e4f468d4d2 Publish Docker image to ghcr.io and docker.io 2022-03-30 11:50:16 +02:00
d3246b0918 Add intercept feature to README 2022-03-29 14:06:51 +02:00
61fd3fcc45 Update admin dependencies 2022-03-29 13:58:38 +02:00
d34258dfd1 Add links to intercept filter docs 2022-03-29 13:52:03 +02:00
0e9fb0ac91 Add tests for search.Expression interface implementations 2022-03-23 15:39:45 +01:00
02408b5196 Add intercept module 2022-03-23 14:31:27 +01:00
6ffc55cde3 Fix Snapcraft plugs config to allow network binding 2022-03-15 10:11:53 +01:00
bdd667381a Add Snapcraft notice 2022-03-06 14:18:25 +01:00
f60202e41c Add Snapcraft and Scoop config 2022-03-03 14:31:58 +01:00
87b8b18047 Fix README badges 2022-03-03 08:29:14 +01:00
edab744d01 Remove old docs 2022-03-02 20:08:39 +01:00
3f5277e419 Fix light/dark mode logo in README 2022-03-02 20:05:37 +01:00
29550ff43b Update README 2022-03-02 19:16:53 +01:00
7afc23b3ff Add Homebrew tap to GoReleaser config 2022-03-02 14:49:24 +01:00
6aa93b782e Add "Copy to Sender" button in reqlog table 2022-03-02 08:14:44 +01:00
ed9a539ce3 Remove stray console.log calls 2022-03-02 07:52:17 +01:00
857aa0c49e Misc lint fixes 2022-02-28 16:21:01 +01:00
af26987601 Fix sort order of request logs 2022-02-28 15:40:13 +01:00
ad26478043 Add certificate management subcommands 2022-02-28 15:31:16 +01:00
ca0c085021 Use ffcli, tidy up usage message 2022-02-28 12:50:09 +01:00
d438f93ee0 Fix incorrect var names 2022-02-28 09:41:55 +01:00
fa3f24eb70 Move gql handler out of main, improve admin route matching 2022-02-28 09:23:08 +01:00
f15438e10b Fix stray outdated enum values 2022-02-28 09:21:43 +01:00
bef52d956e Add support to launch Chrome 2022-02-27 19:00:11 +01:00
8269af9478 Fix missing HTTP/1.0 proto enums 2022-02-27 17:55:41 +01:00
c5f76e1f9a Remove unused project open/close event listeners 2022-02-27 14:42:39 +01:00
2ddf2a77e8 Add logger 2022-02-27 14:28:28 +01:00
d2858a2be4 Fix input fields for key-value pair tables losing focus 2022-02-26 09:58:00 +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
8c2efdb285 Add support for string literal expressions in sqlite pkg 2020-12-24 18:58:04 +01:00
194d727f4f Add search expression support to admin interface 2020-12-22 12:35:14 +01:00
8ab65fb55f Support implicit boolean expression nested in groups 2020-12-20 13:13:12 +01:00
5bce912e89 Replace lexer, add parser 2020-12-20 13:13:12 +01:00
16910bb637 Add lexer for reqlog search 2020-12-20 13:13:12 +01:00
e59b9d6663 Clear all HTTP request logs (#49)
* Create mutation to clear request logs

* Add UI for clearing all HTTP request logs

* Use consistent naming

* Explicitly delete only from http_requests

* Check if datebase is open

* Add confirmation dialog
2020-11-28 15:48:19 +01:00
efc115e961 Fix docs dir 2020-11-09 21:20:49 +01:00
471fa212ef Add links to docs website in README 2020-11-09 21:16:56 +01:00
07ef2f9090 Add OG meta tags 2020-11-09 21:06:40 +01:00
f7550d649a Add static asset for header image for README 2020-11-09 20:50:56 +01:00
dbc25774c2 Small doc fixes for mobile/lighthouse 2020-11-09 20:44:01 +01:00
430670ab54 Add docs 2020-11-09 20:28:10 +01:00
183 changed files with 23734 additions and 8694 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

4
.github/FUNDING.yml vendored
View File

@ -1,3 +1,3 @@
# These are supported funding model platforms
github: dstotijn
patreon: dstotijn
custom: "https://www.paypal.com/paypalme/dstotijn"

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

53
.golangci.yml Normal file
View File

@ -0,0 +1,53 @@
linters:
presets:
- bugs
- comment
- error
- format
- import
- metalinter
- module
- performance
- style
- test
- unused
disable:
- dupl
- exhaustive
- exhaustivestruct
- gochecknoglobals
- gochecknoinits
- godox
- goerr113
- gomnd
- interfacer
- maligned
- nilnil
- nlreturn
- scopelint
- testpackage
- varnamelen
- wrapcheck
linters-settings:
gci:
local-prefixes: github.com/dstotijn/hetty
godot:
capital: true
ireturn:
allow: "error,empty,anon,stdlib,.*(or|er)$,github.com/99designs/gqlgen/graphql.Marshaler,github.com/dstotijn/hetty/pkg/api.QueryResolver,github.com/dstotijn/hetty/pkg/filter.Expression"
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,64 +1,108 @@
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
brews:
- tap:
owner: hettysoft
name: homebrew-tap
folder: Formula
homepage: https://hetty.xyz
description: An HTTP toolkit for security research.
license: MIT
commit_author:
name: David Stotijn
email: dstotijn@gmail.com
test: |
system "#{bin}/hetty -v"
snapcrafts:
- publish: true
summary: An HTTP toolkit for security research.
description: |
Hetty is an HTTP toolkit for security research. It aims to become an open
source alternative to commercial software like Burp Suite Pro, with
powerful features tailored to the needs of the infosec and bug bounty
community.
grade: stable
confinement: strict
license: MIT
apps:
hetty:
command: hetty
plugs: ["network", "network-bind"]
scoop:
bucket:
owner: hettysoft
name: scoop-bucket
commit_author:
name: David Stotijn
email: dstotijn@gmail.com
homepage: https://hetty.xyz
description: An HTTP toolkit for security research.
license: MIT
dockers:
- extra_files:
- go.mod
- go.sum
- pkg
- cmd
- admin
image_templates:
- "ghcr.io/dstotijn/hetty:{{ .Version }}"
- "ghcr.io/dstotijn/hetty:{{ .Major }}"
- "ghcr.io/dstotijn/hetty:{{ .Major }}.{{ .Minor }}"
- "ghcr.io/dstotijn/hetty:latest"
- "dstotijn/hetty:{{ .Version }}"
- "dstotijn/hetty:{{ .Major }}"
- "dstotijn/hetty:{{ .Major }}.{{ .Minor }}"
- "dstotijn/hetty:latest"
build_flag_templates:
- "--pull"
- "--label=org.opencontainers.image.created={{.Date}}"
- "--label=org.opencontainers.image.title={{.ProjectName}}"
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
- "--label=org.opencontainers.image.version={{.Version}}"
- "--label=org.opencontainers.image.source=https://github.com/dstotijn/hetty"
- "--build-arg=HETTY_VERSION={{.Version}}"
checksum:
name_template: "checksums.txt"
snapshot:
name_template: "{{ .Tag }}-next"
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^test:"

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

273
README.md
View File

@ -1,232 +1,159 @@
<h1>
<a href="https://github.com/dstotijn/hetty">
<img src="https://hetty.xyz/assets/logo.png" width="293">
</a>
</h1>
<img src="https://user-images.githubusercontent.com/983924/156430531-6193e187-7400-436b-81c6-f86862783ea5.svg#gh-light-mode-only" width="240"/>
<img src="https://user-images.githubusercontent.com/983924/156430660-9d5bd555-dcfd-47e2-ba70-54294c20c1b4.svg#gh-dark-mode-only" width="240"/>
[![Latest GitHub release](https://img.shields.io/github/v/release/dstotijn/hetty?color=18BA91&style=flat-square)](https://github.com/dstotijn/hetty/releases/latest)
![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)
[![Latest GitHub release](https://img.shields.io/github/v/release/dstotijn/hetty?color=25ae8f)](https://github.com/dstotijn/hetty/releases/latest)
[![Build Status](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fdstotijn%2Fhetty%2Fbadge%3Fref%3Dmain&label=build&color=24ae8f)](https://github.com/dstotijn/hetty/actions/workflows/build-test.yml)
![GitHub download count](https://img.shields.io/github/downloads/dstotijn/hetty/total?color=25ae8f)
[![GitHub](https://img.shields.io/github/license/dstotijn/hetty?color=25ae8f)](https://github.com/dstotijn/hetty/blob/master/LICENSE)
[![Documentation](https://img.shields.io/badge/hetty-docs-25ae8f)](https://hetty.xyz/)
**Hetty** is an HTTP toolkit for security research. It aims to become an open
source alternative to commercial software like Burp Suite Pro, with powerful
features tailored to the needs of the infosec and bug bounty community.
<img src="https://hetty.xyz/assets/hetty_v0.2.0_header.png">
<img src="https://hetty.xyz/img/hero.png" width="907" alt="Hetty proxy logs (screenshot)" />
## Features
- Man-in-the-middle (MITM) HTTP/1.1 proxy with logs
- Project based database storage (SQLite)
- Scope support
- Headless management API using GraphQL
- Embedded web interface (Next.js)
- Machine-in-the-middle (MITM) HTTP proxy, with logs and advanced search
- HTTP client for manually creating/editing requests, and replay proxied requests
- Intercept requests and responses for manual review (edit, send/receive, cancel)
- Scope support, to help keep work organized
- Easy-to-use web based admin interface
- Project based database storage, to help keep work organized
Hetty is in early development. Additional features are planned
for a `v1.0` release. Please see the <a href="https://github.com/dstotijn/hetty/projects/1">backlog</a>
for details.
👷‍♂ Hetty is under active development. Check the <a
href="https://github.com/dstotijn/hetty/projects/1">backlog</a> for the current
status.
## Installation
📣 Are you pen testing professionaly in a team? I would love to hear your
thoughts on tooling via [this 5 minute
survey](https://forms.gle/36jtgNc3TJ2imi5A8). Thank you!
Hetty compiles to a self-contained binary, with an embedded SQLite database
and web based admin interface.
## Getting started
### Install pre-built release (recommended)
💡 The [Getting started](https://hetty.xyz/docs/getting-started) doc has more
detailed install and usage instructions.
👉 Downloads for Linux, macOS and Windows are available on the [releases page](https://github.com/dstotijn/hetty/releases).
### Installation
### Build from source
The quickest way to install and update Hetty is via a package manager:
#### Prerequisites
#### macOS
- [Go](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.
Clone the repository and use the `build` make target to create a binary:
```
$ git clone git@github.com:dstotijn/hetty.git
$ cd hetty
$ make build
```sh
brew install hettysoft/tap/hetty
```
### Docker
#### Linux
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:
```
$ mkdir -p $HOME/.hetty
$ docker run -v $HOME/.hetty:/root/.hetty -p 8080:8080 dstotijn/hetty
```sh
sudo snap install hetty
```
## Usage
#### Windows
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`
directory under the user's home directory (`$HOME` on Linux/macOS, `%USERPROFILE%`
on Windows).
To start, ensure `hetty` (downloaded from a release, or manually built) is in your
`$PATH` and run:
```
$ hetty
```sh
scoop bucket add hettysoft https://github.com/hettysoft/scoop-bucket.git
scoop install hettysoft/hetty
```
An overview of configuration flags:
#### Other
Alternatively, you can [download the latest release from
GitHub](https://github.com/dstotijn/hetty/releases/latest) for your OS and
architecture, and move the binary to a directory in your `$PATH`. If your OS is
not available for one of the package managers or not listed in the GitHub
releases, you can compile from source _(link coming soon)_.
#### Docker
Docker images are distributed via [GitHub's Container registry](https://github.com/dstotijn/hetty/pkgs/container/hetty)
and [Docker Hub](https://hub.docker.com/r/dstotijn/hetty). To run Hetty via with a volume for database and certificate
storage, and port 8080 forwarded:
```
$ hetty -h
Usage of ./hetty:
-addr string
TCP address to listen on, in the form "host:port" (default ":8080")
-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")
-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")
docker run -v $HOME/.hetty:/root/.hetty -p 8080:8080 \
ghcr.io/dstotijn/hetty:latest
```
You should see:
### Usage
```
2020/11/01 14:47:10 [INFO] Running server on :8080 ...
```
Then, visit [http://localhost:8080](http://localhost:8080) to get started.
Detailed documentation is under development and will be available soon.
## Certificate Setup and Installation
In order for Hetty to proxy requests going to HTTPS endpoints, a root CA certificate for
Hetty will need to be set up. Furthermore, the CA certificate may need to be
installed to the host for them to be trusted by your browser. The following steps
will cover how you can generate your certificate, provide them to hetty, and how
you can install them in your local CA store.
⚠️ _This process was done on a Linux machine but should_
_provide guidance on Windows and macOS as well._
### Generating CA certificates
You can generate a CA keypair two different ways. The first is bundled directly
with Hetty, and simplifies the process immensely. The alternative is using OpenSSL
to generate them, which provides more control over expiration time and cryptography
used, but requires you install the OpenSSL tooling. The first is suggested for any
beginners trying to get started.
#### Generating CA certificates with hetty
Hetty will generate the default key and certificate on its own if none are supplied
or found in `~/.hetty/` when first running the CLI. To generate a default key and
certificate with hetty, simply run the command with no arguments
Once installed, start Hetty via:
```sh
hetty
```
You should now have a key and certificate located at `~/.hetty/hetty_key.pem` and
`~/.hetty/hetty_cert.pem` respectively.
💡 Read the [Getting started](https://hetty.xyz/docs/getting-started) doc for
more details.
#### Generating CA certificates with OpenSSL
You can start off by generating a new key and CA certificate which will both expire
after a month.
```sh
mkdir ~/.hetty
openssl req -newkey rsa:2048 -new -nodes -x509 -days 31 -keyout ~/.hetty/hetty_key.pem -out ~/.hetty/hetty_cert.pem
```
The default location which `hetty` will check for the key and CA certificate is under
`~/.hetty/`, at `hetty_key.pem` and `hetty_cert.pem` respectively. You can move them
here and `hetty` will detect them automatically. Otherwise, you can specify the
location of these as arguments to `hetty`.
To list all available options, run: `hetty --help`:
```
hetty -key key.pem -cert cert.pem
$ hetty --help
Usage:
hetty [flags] [subcommand] [flags]
Runs an HTTP server with (MITM) proxy, GraphQL service, and a web based admin interface.
Options:
--cert Path to root CA certificate. Creates file if it doesn't exist. (Default: "~/.hetty/hetty_cert.pem")
--key Path to root CA private key. Creates file if it doesn't exist. (Default: "~/.hetty/hetty_key.pem")
--db Database directory path. (Default: "~/.hetty/db")
--addr TCP address for HTTP server to listen on, in the form \"host:port\". (Default: ":8080")
--chrome Launch Chrome with proxy settings applied and certificate errors ignored. (Default: false)
--verbose Enable verbose logging.
--json Encode logs as JSON, instead of pretty/human readable output.
--version, -v Output version.
--help, -h Output this usage text.
Subcommands:
- cert Certificate management
Run `hetty <subcommand> --help` for subcommand specific usage instructions.
Visit https://hetty.xyz to learn more about Hetty.
```
### Trusting the CA certificate
## Documentation
In order for your browser to allow traffic to the local Hetty proxy, you may need
to install these certificates to your local CA store.
On Ubuntu, you can update your local CA store with the certificate by running the
following commands:
```sh
sudo cp ~/.hetty/hetty_cert.pem /usr/local/share/ca-certificates/hetty.crt
sudo update-ca-certificates
```
On Windows, you would add your certificate by using the Certificate Manager. You
can launch that by running the command:
```batch
certmgr.msc
```
On macOS, you can add your certificate by using the Keychain Access program. This
can be found under `Application/Utilities/Keychain Access.app`. After opening this,
drag the certificate into the app. Next, open the certificate in the app, enter the
_Trust_ section, and under _When using this certificate_ select _Always Trust_.
_Note: Various Linux distributions may require other steps or commands for updating_
_their certificate authority. See the documentation relevant to your distribution for_
_more information on how to update the system to trust your self-signed certificate._
## Vision and roadmap
- Fast core/engine, built with Go, with a minimal memory footprint.
- Easy to use admin interface, built with Next.js and Material UI.
- Headless management, via GraphQL API.
- Extensibility is top of mind. All modules are written as Go packages, to
be used by Hetty, but also as libraries by other software.
- Pluggable architecture for MITM proxy, projects, scope. It should be possible.
to build a plugin system in the (near) future.
- Based on feedback and real-world usage of pentesters and bug bounty hunters.
- Aim for a relatively small core feature set that the majority of security researchers need.
📖 [Read the docs](https://hetty.xyz/docs)
## Support
Use [issues](https://github.com/dstotijn/hetty/issues) for bug reports and
feature requests, and [discussions](https://github.com/dstotijn/hetty/discussions)
for questions and troubleshooting.
feature requests, and
[discussions](https://github.com/dstotijn/hetty/discussions) for questions and
troubleshooting.
## Community
💬 [Join the Hetty Discord server](https://discord.gg/3HVsj5pTFP).
💬 [Join the Hetty Discord server](https://discord.gg/3HVsj5pTFP)
## Contributing
Want to contribute? Great! Please check the [Contribution Guidelines](CONTRIBUTING.md)
for details.
Want to contribute? Great! Please check the [Contribution
Guidelines](CONTRIBUTING.md) for details.
## Acknowledgements
- Thanks to the [Hacker101 community on Discord](https://www.hacker101.com/discord)
for all the encouragement and feedback.
- The font used in the logo and admin interface is [JetBrains Mono](https://www.jetbrains.com/lp/mono/).
for the encouragement and early feedback.
- The font used in the logo and admin interface is [JetBrains
Mono](https://www.jetbrains.com/lp/mono/).
## Sponsors
<p><a href="https://www.tines.com/?utm_source=oss&utm_medium=sponsorship&utm_campaign=hetty">
<img src="https://hetty.xyz/img/tines-sponsorship-badge.png" width="140" alt="Sponsored by Tines">
</a></p>
💖 Are you enjoying Hetty? You can [sponsor me](https://github.com/sponsors/dstotijn)!
## License
[MIT License](LICENSE)
[MIT](LICENSE)
---
© 2020 David Stotijn — [Twitter](https://twitter.com/dstotijn), [Email](mailto:dstotijn@gmail.com)
© 2022 Hetty Software

56
admin/.eslintrc.json Normal file
View File

@ -0,0 +1,56 @@
{
"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",
{
"ignoreRestSiblings": true
}
],
"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.76.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,87 +0,0 @@
import { useRouter } from "next/router";
import { gql, useQuery } from "@apollo/client";
import Link from "next/link";
import {
Box,
Typography,
CircularProgress,
Link as MaterialLink,
} from "@material-ui/core";
import Alert from "@material-ui/lab/Alert";
import RequestList from "./RequestList";
import LogDetail from "./LogDetail";
import CenteredPaper from "../CenteredPaper";
const HTTP_REQUEST_LOGS = gql`
query HttpRequestLogs {
httpRequestLogs {
id
method
url
timestamp
response {
statusCode
statusReason
}
}
}
`;
function LogsOverview(): JSX.Element {
const router = useRouter();
const detailReqLogId =
router.query.id && parseInt(router.query.id as string, 10);
const { loading, error, data } = useQuery(HTTP_REQUEST_LOGS, {
pollInterval: 1000,
});
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,201 +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 React, { useRef, useState } from "react";
import { gql, useApolloClient, useMutation, useQuery } from "@apollo/client";
import { withoutTypename } from "../../lib/omitTypename";
import { Alert } from "@material-ui/lab";
const FILTER = gql`
query HttpRequestLogFilter {
httpRequestLogFilter {
onlyInScope
}
}
`;
const SET_FILTER = gql`
mutation SetHttpRequestLogFilter($filter: HttpRequestLogFilterInput) {
setHttpRequestLogFilter(filter: $filter) {
onlyInScope
}
}
`;
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;
}
function Search(): JSX.Element {
const classes = useStyles();
const theme = useTheme();
const { loading: filterLoading, error: filterErr, data: filter } = useQuery(
FILTER
);
const client = useApolloClient();
const [
setFilterMutate,
{ error: setFilterErr, loading: setFilterLoading },
] = useMutation<{
setHttpRequestLogFilter: SearchFilter | null;
}>(SET_FILTER, {
update(_, { data: { setHttpRequestLogFilter } }) {
client.writeQuery({
query: FILTER,
data: {
httpRequestLogFilter: setHttpRequestLogFilter,
},
});
},
});
const filterRef = useRef<HTMLElement | null>();
const [filterOpen, setFilterOpen] = useState(false);
const handleSubmit = (e: React.SyntheticEvent) => {
e.preventDefault();
};
const handleClickAway = (event: React.MouseEvent<EventTarget>) => {
if (filterRef.current.contains(event.target as HTMLElement)) {
return;
}
setFilterOpen(false);
};
return (
<ClickAwayListener onClickAway={handleClickAway}>
<Box style={{ display: "inline-block" }}>
{filterErr && (
<Box mb={4}>
<Alert severity="error">
Error fetching filter: {filterErr.message}
</Alert>
</Box>
)}
{setFilterErr && (
<Box mb={4}>
<Alert severity="error">
Error setting filter: {setFilterErr.message}
</Alert>
</Box>
)}
<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 !== null
? theme.palette.secondary.main
: "inherit",
}}
>
{filterLoading || setFilterLoading ? (
<CircularProgress className={classes.filterLoading} size={23} />
) : (
<FilterListIcon />
)}
</IconButton>
</Tooltip>
<InputBase
className={classes.input}
placeholder="Search proxy logs…"
onFocus={() => setFilterOpen(true)}
/>
<Tooltip title="Search">
<IconButton type="submit" className={classes.iconButton}>
<SearchIcon />
</IconButton>
</Tooltip>
</Paper>
<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>
</Box>
</ClickAwayListener>
);
}
export default Search;

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,270 @@
import AltRouteIcon from "@mui/icons-material/AltRoute";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import FolderIcon from "@mui/icons-material/Folder";
import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted";
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 {
Theme,
useTheme,
Toolbar,
IconButton,
Typography,
Divider,
List,
Tooltip,
styled,
CSSObject,
Box,
ListItemText,
Badge,
} 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";
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
export enum Page {
Home,
GetStarted,
Intercept,
Projects,
ProxySetup,
ProxyLogs,
Sender,
Scope,
Settings,
}
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 interceptedRequests = useInterceptedRequests();
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 logs">
<ListItemIcon>
<FormatListBulletedIcon />
</ListItemIcon>
</Tooltip>
<ListItemText primary="Logs" />
</ListItemButton>
</Link>
<Link href="/proxy/intercept" passHref>
<ListItemButton key="proxyIntercept" disabled={!activeProject} selected={page === Page.Intercept}>
<Tooltip title="Proxy intercept">
<ListItemIcon>
<Badge color="error" badgeContent={interceptedRequests?.length || 0}>
<AltRouteIcon />
</Badge>
</ListItemIcon>
</Tooltip>
<ListItemText primary="Intercept" />
</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,366 @@
import CancelIcon from "@mui/icons-material/Cancel";
import DownloadIcon from "@mui/icons-material/Download";
import SendIcon from "@mui/icons-material/Send";
import SettingsIcon from "@mui/icons-material/Settings";
import { Alert, Box, Button, CircularProgress, IconButton, Tooltip, Typography } from "@mui/material";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
import { KeyValuePair } from "lib/components/KeyValuePair";
import Link from "lib/components/Link";
import RequestTabs from "lib/components/RequestTabs";
import ResponseStatus from "lib/components/ResponseStatus";
import ResponseTabs from "lib/components/ResponseTabs";
import UrlBar, { HttpMethod, HttpProto, httpProtoMap } from "lib/components/UrlBar";
import {
HttpProtocol,
HttpRequest,
useCancelRequestMutation,
useCancelResponseMutation,
useGetInterceptedRequestQuery,
useModifyRequestMutation,
useModifyResponseMutation,
} from "lib/graphql/generated";
import { queryParamsFromURL } from "lib/queryParamsFromURL";
import updateKeyPairItem from "lib/updateKeyPairItem";
import updateURLQueryParams from "lib/updateURLQueryParams";
function EditRequest(): JSX.Element {
const router = useRouter();
const interceptedRequests = useInterceptedRequests();
useEffect(() => {
// If there's no request selected and there are pending reqs, navigate to
// the first one in the list. This helps you quickly review/handle reqs
// without having to manually select the next one in the requests table.
if (router.isReady && !router.query.id && interceptedRequests?.length) {
const req = interceptedRequests[0];
router.replace(`/proxy/intercept?id=${req.id}`);
}
}, [router, interceptedRequests]);
const reqId = router.query.id as string | undefined;
const [method, setMethod] = useState(HttpMethod.Get);
const [url, setURL] = useState("");
const [proto, setProto] = useState(HttpProto.Http20);
const [queryParams, setQueryParams] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
const [reqHeaders, setReqHeaders] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
const [resHeaders, setResHeaders] = useState<KeyValuePair[]>([{ key: "", value: "" }]);
const [reqBody, setReqBody] = useState("");
const [resBody, setResBody] = 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 handleReqHeaderChange = (key: string, value: string, idx: number) => {
setReqHeaders((prev) => updateKeyPairItem(key, value, idx, prev));
};
const handleReqHeaderDelete = (idx: number) => {
setReqHeaders((prev) => prev.slice(0, idx).concat(prev.slice(idx + 1, prev.length)));
};
const handleResHeaderChange = (key: string, value: string, idx: number) => {
setResHeaders((prev) => updateKeyPairItem(key, value, idx, prev));
};
const handleResHeaderDelete = (idx: number) => {
setResHeaders((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 getReqResult = useGetInterceptedRequestQuery({
variables: { id: reqId as string },
skip: reqId === undefined,
onCompleted: ({ interceptedRequest }) => {
if (!interceptedRequest) {
return;
}
setURL(interceptedRequest.url);
setMethod(interceptedRequest.method);
setReqBody(interceptedRequest.body || "");
const newQueryParams = queryParamsFromURL(interceptedRequest.url);
// Push empty row.
newQueryParams.push({ key: "", value: "" });
setQueryParams(newQueryParams);
const newReqHeaders = interceptedRequest.headers || [];
setReqHeaders([...newReqHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]);
setResBody(interceptedRequest.response?.body || "");
const newResHeaders = interceptedRequest.response?.headers || [];
setResHeaders([...newResHeaders.map(({ key, value }) => ({ key, value })), { key: "", value: "" }]);
},
});
const interceptedReq =
reqId && !getReqResult?.data?.interceptedRequest?.response ? getReqResult?.data?.interceptedRequest : undefined;
const interceptedRes = reqId ? getReqResult?.data?.interceptedRequest?.response : undefined;
const [modifyRequest, modifyReqResult] = useModifyRequestMutation();
const [cancelRequest, cancelReqResult] = useCancelRequestMutation();
const [modifyResponse, modifyResResult] = useModifyResponseMutation();
const [cancelResponse, cancelResResult] = useCancelResponseMutation();
const onActionCompleted = () => {
setURL("");
setMethod(HttpMethod.Get);
setReqBody("");
setQueryParams([]);
setReqHeaders([]);
router.replace(`/proxy/intercept`);
};
const handleFormSubmit: React.FormEventHandler = (e) => {
e.preventDefault();
if (interceptedReq) {
modifyRequest({
variables: {
request: {
id: interceptedReq.id,
url,
method,
proto: httpProtoMap.get(proto) || HttpProtocol.Http20,
headers: reqHeaders.filter((kv) => kv.key !== ""),
body: reqBody || undefined,
},
},
update(cache) {
cache.modify({
fields: {
interceptedRequests(existing: HttpRequest[], { readField }) {
return existing.filter((ref) => interceptedReq.id !== readField("id", ref));
},
},
});
},
onCompleted: onActionCompleted,
});
}
if (interceptedRes) {
modifyResponse({
variables: {
response: {
requestID: interceptedRes.id,
proto: interceptedRes.proto, // TODO: Allow modifying
statusCode: interceptedRes.statusCode, // TODO: Allow modifying
statusReason: interceptedRes.statusReason, // TODO: Allow modifying
headers: resHeaders.filter((kv) => kv.key !== ""),
body: resBody || undefined,
},
},
update(cache) {
cache.modify({
fields: {
interceptedRequests(existing: HttpRequest[], { readField }) {
return existing.filter((ref) => interceptedRes.id !== readField("id", ref));
},
},
});
},
onCompleted: onActionCompleted,
});
}
};
const handleReqCancelClick = () => {
if (!interceptedReq) {
return;
}
cancelRequest({
variables: {
id: interceptedReq.id,
},
update(cache) {
cache.modify({
fields: {
interceptedRequests(existing: HttpRequest[], { readField }) {
return existing.filter((ref) => interceptedReq.id !== readField("id", ref));
},
},
});
},
onCompleted: onActionCompleted,
});
};
const handleResCancelClick = () => {
if (!interceptedRes) {
return;
}
cancelResponse({
variables: {
requestID: interceptedRes.id,
},
update(cache) {
cache.modify({
fields: {
interceptedRequests(existing: HttpRequest[], { readField }) {
return existing.filter((ref) => interceptedRes.id !== readField("id", ref));
},
},
});
},
onCompleted: onActionCompleted,
});
};
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={interceptedReq ? setMethod : undefined}
url={url.toString()}
onUrlChange={interceptedReq ? handleURLChange : undefined}
proto={proto}
onProtoChange={interceptedReq ? setProto : undefined}
sx={{ flex: "1 auto" }}
/>
{!interceptedRes && (
<>
<Button
variant="contained"
disableElevation
type="submit"
disabled={!interceptedReq || modifyReqResult.loading || cancelReqResult.loading}
startIcon={modifyReqResult.loading ? <CircularProgress size={22} /> : <SendIcon />}
>
Send
</Button>
<Button
variant="contained"
color="error"
disableElevation
onClick={handleReqCancelClick}
disabled={!interceptedReq || modifyReqResult.loading || cancelReqResult.loading}
startIcon={cancelReqResult.loading ? <CircularProgress size={22} /> : <CancelIcon />}
>
Cancel
</Button>
</>
)}
{interceptedRes && (
<>
<Button
variant="contained"
disableElevation
type="submit"
disabled={modifyResResult.loading || cancelResResult.loading}
endIcon={modifyResResult.loading ? <CircularProgress size={22} /> : <DownloadIcon />}
>
Receive
</Button>
<Button
variant="contained"
color="error"
disableElevation
onClick={handleResCancelClick}
disabled={modifyResResult.loading || cancelResResult.loading}
endIcon={cancelResResult.loading ? <CircularProgress size={22} /> : <CancelIcon />}
>
Cancel
</Button>
</>
)}
<Tooltip title="Intercept settings">
<IconButton LinkComponent={Link} href="/settings#intercept">
<SettingsIcon />
</IconButton>
</Tooltip>
</Box>
{modifyReqResult.error && (
<Alert severity="error" sx={{ mt: 1 }}>
{modifyReqResult.error.message}
</Alert>
)}
{cancelReqResult.error && (
<Alert severity="error" sx={{ mt: 1 }}>
{cancelReqResult.error.message}
</Alert>
)}
</Box>
<Box flex="1 auto" overflow="scroll">
{interceptedReq && (
<Box sx={{ height: "100%", pb: 2 }}>
<Typography variant="overline" color="textSecondary" sx={{ position: "absolute", right: 0, mt: 1.2 }}>
Request
</Typography>
<RequestTabs
queryParams={interceptedReq ? queryParams : []}
headers={interceptedReq ? reqHeaders : []}
body={reqBody}
onQueryParamChange={interceptedReq ? handleQueryParamChange : undefined}
onQueryParamDelete={interceptedReq ? handleQueryParamDelete : undefined}
onHeaderChange={interceptedReq ? handleReqHeaderChange : undefined}
onHeaderDelete={interceptedReq ? handleReqHeaderDelete : undefined}
onBodyChange={interceptedReq ? setReqBody : undefined}
/>
</Box>
)}
{interceptedRes && (
<Box sx={{ height: "100%", pb: 2 }}>
<Box sx={{ position: "absolute", right: 0, mt: 1.4 }}>
<Typography variant="overline" color="textSecondary" sx={{ float: "right", ml: 3 }}>
Response
</Typography>
{interceptedRes && (
<Box sx={{ float: "right", mt: 0.2 }}>
<ResponseStatus
proto={interceptedRes.proto}
statusCode={interceptedRes.statusCode}
statusReason={interceptedRes.statusReason}
/>
</Box>
)}
</Box>
<ResponseTabs
headers={interceptedRes ? resHeaders : []}
body={resBody}
onHeaderChange={interceptedRes ? handleResHeaderChange : undefined}
onHeaderDelete={interceptedRes ? handleResHeaderDelete : undefined}
onBodyChange={interceptedRes ? setResBody : undefined}
hasResponse={interceptedRes !== undefined && interceptedRes !== null}
/>
</Box>
)}
</Box>
</Box>
);
}
export default EditRequest;

View File

@ -0,0 +1,21 @@
import { Box } from "@mui/material";
import EditRequest from "./EditRequest";
import Requests from "./Requests";
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" }}>
<Requests />
</Box>
</SplitPane>
</Box>
);
}

View File

@ -0,0 +1,33 @@
import { Box, Paper, Typography } from "@mui/material";
import { useRouter } from "next/router";
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
import RequestsTable from "lib/components/RequestsTable";
function Requests(): JSX.Element {
const interceptedRequests = useInterceptedRequests();
const router = useRouter();
const activeId = router.query.id as string | undefined;
const handleRowClick = (id: string) => {
router.push(`/proxy/intercept?id=${id}`);
};
return (
<Box>
{interceptedRequests && interceptedRequests.length > 0 && (
<RequestsTable requests={interceptedRequests} onRowClick={handleRowClick} activeRowId={activeId} />
)}
<Box sx={{ mt: 2, height: "100%" }}>
{interceptedRequests?.length === 0 && (
<Paper variant="centered">
<Typography>No pending intercepted requests.</Typography>
</Paper>
)}
</Box>
</Box>
);
}
export default Requests;

View File

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

View File

@ -0,0 +1,5 @@
mutation CancelResponse($requestID: ID!) {
cancelResponse(requestID: $requestID) {
success
}
}

View File

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

View File

@ -0,0 +1,5 @@
mutation ModifyRequest($request: ModifyRequestInput!) {
modifyRequest(request: $request) {
success
}
}

View File

@ -0,0 +1,5 @@
mutation ModifyResponse($response: ModifyResponseInput!) {
modifyResponse(response: $response) {
success
}
}

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,232 @@
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 SettingsIcon from "@mui/icons-material/Settings";
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 Link, { NextLinkComposed } from "lib/components/Link";
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>
<Tooltip title="Project settings">
<IconButton LinkComponent={Link} href="/settings" disabled={!project.isActive}>
<SettingsIcon />
</IconButton>
</Tooltip>
{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,15 @@
query ActiveProject {
activeProject {
id
name
isActive
settings {
intercept {
requestsEnabled
responsesEnabled
requestFilter
responseFilter
}
}
}
}

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,61 @@
import AltRouteIcon from "@mui/icons-material/AltRoute";
import DeleteIcon from "@mui/icons-material/Delete";
import { Alert } from "@mui/lab";
import { Badge, Button, IconButton, Tooltip } from "@mui/material";
import Link from "next/link";
import { useActiveProject } from "lib/ActiveProjectContext";
import { useInterceptedRequests } from "lib/InterceptedRequestsContext";
import { ConfirmationDialog, useConfirmationDialog } from "lib/components/ConfirmationDialog";
import { HttpRequestLogsDocument, useClearHttpRequestLogMutation } from "lib/graphql/generated";
function Actions(): JSX.Element {
const activeProject = useActiveProject();
const interceptedRequests = useInterceptedRequests();
const [clearHTTPRequestLog, clearLogsResult] = useClearHttpRequestLogMutation({
refetchQueries: [{ query: HttpRequestLogsDocument }],
});
const clearHTTPConfirmationDialog = useConfirmationDialog();
return (
<div>
<ConfirmationDialog
isOpen={clearHTTPConfirmationDialog.isOpen}
onClose={clearHTTPConfirmationDialog.close}
onConfirm={clearHTTPRequestLog}
>
All proxy logs are going to be removed. This action cannot be undone.
</ConfirmationDialog>
{clearLogsResult.error && <Alert severity="error">Failed to clear HTTP logs: {clearLogsResult.error}</Alert>}
{(activeProject?.settings.intercept.requestsEnabled || activeProject?.settings.intercept.responsesEnabled) && (
<Link href="/proxy/intercept/?id=" passHref>
<Button
variant="contained"
disabled={interceptedRequests === null || interceptedRequests.length === 0}
color="primary"
component="a"
size="large"
startIcon={
<Badge color="error" badgeContent={interceptedRequests?.length || 0}>
<AltRouteIcon />
</Badge>
}
sx={{ mr: 1 }}
>
Review Intercepted
</Button>
</Link>
)}
<Tooltip title="Clear all">
<IconButton onClick={clearHTTPConfirmationDialog.open}>
<DeleteIcon />
</IconButton>
</Tooltip>
</div>
);
}
export default Actions;

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,137 @@
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import {
Alert,
Box,
IconButton,
Link,
MenuItem,
Snackbar,
styled,
TableCell,
TableCellProps,
Tooltip,
} from "@mui/material";
import { useRouter } from "next/router";
import { useState } from "react";
import Actions from "./Actions";
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";
const ActionsTableCell = styled(TableCell)<TableCellProps>(() => ({
paddingTop: 0,
paddingBottom: 0,
}));
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 actionsCell = (id: string) => (
<ActionsTableCell>
<Tooltip title="Copy to Sender">
<IconButton
size="small"
onClick={() => {
setCopyToSenderId(id);
createSenderReqFromLog({
variables: {
id,
},
});
}}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</ActionsTableCell>
);
return (
<Box display="flex" flexDirection="column" height="100%">
<Box display="flex">
<Box flex="1 auto">
<Search />
</Box>
<Box pt={0.5}>
<Actions />
</Box>
</Box>
<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}
actionsCell={actionsCell}
onRowClick={handleRowClick}
onContextMenu={handleRowContextClick}
/>
</Box>
</Box>
<LogDetail id={id} />
</SplitPane>
</Box>
</Box>
);
}

View File

@ -0,0 +1,174 @@
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 {
HttpRequestLogFilterDocument,
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 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} />
<Box style={{ display: "flex", flex: 1 }}>
<ClickAwayListener onClickAway={handleClickAway}>
<Paper
component="form"
autoComplete="off"
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)}
autoCorrect="false"
spellCheck="false"
/>
<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>
</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,220 @@
import AddIcon from "@mui/icons-material/Add";
import { Alert, Box, Button, Fab, Tooltip, Typography, useTheme } from "@mui/material";
import { useRouter } from "next/router";
import React, { useState } from "react";
import { KeyValuePair } from "lib/components/KeyValuePair";
import RequestTabs from "lib/components/RequestTabs";
import Response from "lib/components/Response";
import SplitPane from "lib/components/SplitPane";
import UrlBar, { HttpMethod, HttpProto, httpProtoMap } from "lib/components/UrlBar";
import {
GetSenderRequestQuery,
useCreateOrUpdateSenderRequestMutation,
useGetSenderRequestQuery,
useSendRequestMutation,
} from "lib/graphql/generated";
import { queryParamsFromURL } from "lib/queryParamsFromURL";
import updateKeyPairItem from "lib/updateKeyPairItem";
import updateURLQueryParams from "lib/updateURLQueryParams";
const defaultMethod = HttpMethod.Get;
const defaultProto = HttpProto.Http20;
const emptyKeyPair = [{ key: "", value: "" }];
function EditRequest(): JSX.Element {
const router = useRouter();
const reqId = router.query.id as string | undefined;
const theme = useTheme();
const [method, setMethod] = useState(defaultMethod);
const [url, setURL] = useState("");
const [proto, setProto] = useState(defaultProto);
const [queryParams, setQueryParams] = useState<KeyValuePair[]>(emptyKeyPair);
const [headers, setHeaders] = useState<KeyValuePair[]>(emptyKeyPair);
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 = 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();
};
const handleNewRequest = () => {
setURL("");
setMethod(defaultMethod);
setProto(defaultProto);
setQueryParams(emptyKeyPair);
setHeaders(emptyKeyPair);
setBody("");
setResponse(null);
router.push(`/sender`);
};
return (
<Box display="flex" flexDirection="column" height="100%" gap={2}>
<Box sx={{ position: "absolute", bottom: theme.spacing(2), right: theme.spacing(2) }}>
<Tooltip title="New request">
<Fab color="primary" onClick={handleNewRequest}>
<AddIcon />
</Fab>
</Tooltip>
</Box>
<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>
);
}
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,307 @@
import { useApolloClient } from "@apollo/client";
import { TabContext, TabPanel } from "@mui/lab";
import TabList from "@mui/lab/TabList";
import {
Alert,
Box,
Button,
CircularProgress,
FormControl,
FormControlLabel,
FormHelperText,
Snackbar,
Switch,
Tab,
TextField,
TextFieldProps,
Typography,
} from "@mui/material";
import MaterialLink from "@mui/material/Link";
import { SwitchBaseProps } from "@mui/material/internal/SwitchBase";
import { useEffect, useState } from "react";
import { useActiveProject } from "lib/ActiveProjectContext";
import Link from "lib/components/Link";
import { ActiveProjectDocument, useUpdateInterceptSettingsMutation } from "lib/graphql/generated";
import { withoutTypename } from "lib/graphql/omitTypename";
enum TabValue {
Intercept = "intercept",
}
function FilterTextField(props: TextFieldProps): JSX.Element {
return (
<TextField
color="primary"
variant="outlined"
InputProps={{
sx: { fontFamily: "'JetBrains Mono', monospace" },
autoCorrect: "false",
spellCheck: "false",
}}
InputLabelProps={{
shrink: true,
}}
margin="normal"
sx={{ mr: 1 }}
{...props}
/>
);
}
export default function Settings(): JSX.Element {
const client = useApolloClient();
const activeProject = useActiveProject();
const [updateInterceptSettings, updateIntercepSettingsResult] = useUpdateInterceptSettingsMutation({
onCompleted(data) {
client.cache.updateQuery({ query: ActiveProjectDocument }, (cachedData) => ({
activeProject: {
...cachedData.activeProject,
settings: {
...cachedData.activeProject.settings,
intercept: data.updateInterceptSettings,
},
},
}));
setInterceptReqFilter(data.updateInterceptSettings.requestFilter || "");
setInterceptResFilter(data.updateInterceptSettings.responseFilter || "");
setSettingsUpdatedOpen(true);
},
});
const [interceptReqFilter, setInterceptReqFilter] = useState("");
const [interceptResFilter, setInterceptResFilter] = useState("");
useEffect(() => {
setInterceptReqFilter(activeProject?.settings.intercept.requestFilter || "");
}, [activeProject?.settings.intercept.requestFilter]);
useEffect(() => {
setInterceptResFilter(activeProject?.settings.intercept.responseFilter || "");
}, [activeProject?.settings.intercept.responseFilter]);
const handleReqInterceptEnabled: SwitchBaseProps["onChange"] = (e, checked) => {
if (!activeProject) {
e.preventDefault();
return;
}
updateInterceptSettings({
variables: {
input: {
...withoutTypename(activeProject.settings.intercept),
requestsEnabled: checked,
},
},
});
};
const handleResInterceptEnabled: SwitchBaseProps["onChange"] = (e, checked) => {
if (!activeProject) {
e.preventDefault();
return;
}
updateInterceptSettings({
variables: {
input: {
...withoutTypename(activeProject.settings.intercept),
responsesEnabled: checked,
},
},
});
};
const handleInterceptReqFilter = () => {
if (!activeProject) {
return;
}
updateInterceptSettings({
variables: {
input: {
...withoutTypename(activeProject.settings.intercept),
requestFilter: interceptReqFilter,
},
},
});
};
const handleInterceptResFilter = () => {
if (!activeProject) {
return;
}
updateInterceptSettings({
variables: {
input: {
...withoutTypename(activeProject.settings.intercept),
responseFilter: interceptResFilter,
},
},
});
};
const [tabValue, setTabValue] = useState(TabValue.Intercept);
const [settingsUpdatedOpen, setSettingsUpdatedOpen] = useState(false);
const handleSettingsUpdatedClose = (_: Event | React.SyntheticEvent, reason?: string) => {
if (reason === "clickaway") {
return;
}
setSettingsUpdatedOpen(false);
};
const tabSx = {
textTransform: "none",
};
return (
<Box p={4}>
<Snackbar open={settingsUpdatedOpen} autoHideDuration={3000} onClose={handleSettingsUpdatedClose}>
<Alert onClose={handleSettingsUpdatedClose} severity="info">
Intercept settings have been updated.
</Alert>
</Snackbar>
<Typography variant="h4" sx={{ mb: 2 }}>
Settings
</Typography>
<Typography paragraph sx={{ mb: 4 }}>
Settings allow you to tweak the behaviour of Hettys features.
</Typography>
<Typography variant="h5" sx={{ mb: 2 }}>
Project settings
</Typography>
{!activeProject && (
<Typography paragraph>
There is no project active. To configure project settings, first <Link href="/projects">open a project</Link>.
</Typography>
)}
{activeProject && (
<>
<TabContext value={tabValue}>
<TabList onChange={(_, value) => setTabValue(value)} sx={{ borderBottom: 1, borderColor: "divider" }}>
<Tab value={TabValue.Intercept} label="Intercept" sx={tabSx} />
</TabList>
<TabPanel value={TabValue.Intercept} sx={{ px: 0 }}>
<Typography variant="h6" sx={{ mt: 3, mb: 1 }}>
Requests
</Typography>
<FormControl sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
disabled={updateIntercepSettingsResult.loading}
onChange={handleReqInterceptEnabled}
checked={activeProject.settings.intercept.requestsEnabled}
/>
}
label="Enable request interception"
labelPlacement="start"
sx={{ display: "inline-block", m: 0 }}
/>
<FormHelperText>
When enabled, incoming HTTP requests to the proxy are stalled for{" "}
<Link href="/proxy/intercept">manual review</Link>.
</FormHelperText>
</FormControl>
<form>
<FormControl sx={{ width: "50%" }}>
<FilterTextField
label="Request filter"
placeholder={`Example: method = "GET" OR url =~ "/foobar"`}
value={interceptReqFilter}
onChange={(e) => setInterceptReqFilter(e.target.value)}
/>
<FormHelperText>
Filter expression to match incoming requests on. When set, only matching requests are intercepted.{" "}
<MaterialLink
href="https://hetty.xyz/docs/guides/intercept?utm_source=hettyapp#request-filter"
target="_blank"
>
Read docs.
</MaterialLink>
</FormHelperText>
</FormControl>
<Button
type="submit"
variant="text"
color="primary"
size="large"
sx={{
mt: 2,
py: 1.8,
}}
onClick={handleInterceptReqFilter}
disabled={updateIntercepSettingsResult.loading}
startIcon={updateIntercepSettingsResult.loading ? <CircularProgress size={22} /> : undefined}
>
Update
</Button>
</form>
<Typography variant="h6" sx={{ mt: 3 }}>
Responses
</Typography>
<FormControl sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch
disabled={updateIntercepSettingsResult.loading}
onChange={handleResInterceptEnabled}
checked={activeProject.settings.intercept.responsesEnabled}
/>
}
label="Enable response interception"
labelPlacement="start"
sx={{ display: "inline-block", m: 0 }}
/>
<FormHelperText>
When enabled, HTTP responses received by the proxy are stalled for{" "}
<Link href="/proxy/intercept">manual review</Link>.
</FormHelperText>
</FormControl>
<form>
<FormControl sx={{ width: "50%" }}>
<FilterTextField
label="Response filter"
placeholder={`Example: statusCode =~ "^2" OR body =~ "foobar"`}
value={interceptResFilter}
onChange={(e) => setInterceptResFilter(e.target.value)}
/>
<FormHelperText>
Filter expression to match received responses on. When set, only matching responses are intercepted.{" "}
<MaterialLink
href="https://hetty.xyz/docs/guides/intercept/?utm_source=hettyapp#response-filter"
target="_blank"
>
Read docs.
</MaterialLink>
</FormHelperText>
</FormControl>
<Button
type="submit"
variant="text"
color="primary"
size="large"
sx={{
mt: 2,
py: 1.8,
}}
onClick={handleInterceptResFilter}
disabled={updateIntercepSettingsResult.loading}
startIcon={updateIntercepSettingsResult.loading ? <CircularProgress size={22} /> : undefined}
>
Update
</Button>
</form>
</TabPanel>
</TabContext>
</>
)}
</Box>
);
}

View File

@ -0,0 +1,8 @@
mutation UpdateInterceptSettings($input: UpdateInterceptSettingsInput!) {
updateInterceptSettings(input: $input) {
requestsEnabled
responsesEnabled
requestFilter
responseFilter
}
}

View File

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

View File

@ -0,0 +1,22 @@
import React, { createContext, useContext } from "react";
import { GetInterceptedRequestsQuery, useGetInterceptedRequestsQuery } from "./graphql/generated";
const InterceptedRequestsContext = createContext<GetInterceptedRequestsQuery["interceptedRequests"] | null>(null);
interface Props {
children?: React.ReactNode | undefined;
}
export function InterceptedRequestsProvider({ children }: Props): JSX.Element {
const { data } = useGetInterceptedRequestsQuery({
pollInterval: 1000,
});
const reqs = data?.interceptedRequests || null;
return <InterceptedRequestsContext.Provider value={reqs}>{children}</InterceptedRequestsContext.Provider>;
}
export function useInterceptedRequests() {
return useContext(InterceptedRequestsContext);
}

View File

@ -0,0 +1,51 @@
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";
export function useConfirmationDialog() {
const [isOpen, setIsOpen] = useState(false);
const close = () => setIsOpen(false);
const open = () => setIsOpen(true);
return { open, close, isOpen };
}
interface ConfirmationDialog {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
children: React.ReactNode;
}
export function ConfirmationDialog(props: ConfirmationDialog) {
const { onClose, onConfirm, isOpen, children } = props;
function confirm() {
onConfirm();
onClose();
}
return (
<Dialog
open={isOpen}
onClose={onClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<DialogTitle id="alert-dialog-title">Are you sure?</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-description">{children}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={confirm} autoFocus>
Confirm
</Button>
</DialogActions>
</Dialog>
);
}

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,187 @@
import ClearIcon from "@mui/icons-material/Clear";
import {
Alert,
IconButton,
InputBase,
InputBaseProps,
Snackbar,
styled,
Table,
TableBody,
TableCell,
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);
};
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>
<TableCell
component="th"
scope="row"
onClick={(e) => {
!onChange && handleCellClick(e);
}}
sx={{
...(!onChange && {
"&:hover": {
cursor: "copy",
},
}),
}}
>
{!onChange && <span>{key}</span>}
{onChange && (
<StyledInputBase
size="small"
fullWidth
placeholder="Key"
value={key}
onChange={(e) => {
onChange && onChange(e.target.value, value, idx);
}}
/>
)}
</TableCell>
<TableCell
onClick={(e) => {
!onChange && handleCellClick(e);
}}
sx={{
width: "60%",
wordBreak: "break-all",
...(!onChange && {
"&:hover": {
cursor: "copy",
},
}),
}}
>
{!onChange && value}
{onChange && (
<StyledInputBase
size="small"
fullWidth
placeholder="Value"
value={value}
onChange={(e) => {
onChange && onChange(key, e.target.value, idx);
}}
/>
)}
</TableCell>
{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 default KeyValuePairTable;

View File

@ -0,0 +1,94 @@
import MuiLink, { LinkProps as MuiLinkProps } from "@mui/material/Link";
import { styled } from "@mui/material/styles";
import clsx from "clsx";
import NextLink, { LinkProps as NextLinkProps } from "next/link";
import { useRouter } from "next/router";
import * as React from "react";
// Add support for the sx prop for consistency with the other branches.
const Anchor = styled("a")({});
interface NextLinkComposedProps
extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href">,
Omit<NextLinkProps, "href" | "as"> {
to: NextLinkProps["href"];
linkAs?: NextLinkProps["as"];
}
export const NextLinkComposed = React.forwardRef<HTMLAnchorElement, NextLinkComposedProps>(function NextLinkComposed(
props,
ref
) {
const { to, linkAs, replace, scroll, shallow, prefetch, locale, ...other } = props;
return (
<NextLink
href={to}
prefetch={prefetch}
as={linkAs}
replace={replace}
scroll={scroll}
shallow={shallow}
passHref
locale={locale}
>
<Anchor ref={ref} {...other} />
</NextLink>
);
});
export type LinkProps = {
activeClassName?: string;
as?: NextLinkProps["as"];
href: NextLinkProps["href"];
linkAs?: NextLinkProps["as"]; // Useful when the as prop is shallow by styled().
noLinkStyle?: boolean;
} & Omit<NextLinkComposedProps, "to" | "linkAs" | "href"> &
Omit<MuiLinkProps, "href">;
// A styled version of the Next.js Link component:
// https://nextjs.org/docs/api-reference/next/link
const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(function Link(props, ref) {
const {
activeClassName = "active",
as,
className: classNameProps,
href,
linkAs: linkAsProp,
locale,
noLinkStyle,
prefetch,
replace,
role, // Link don't have roles.
scroll,
shallow,
...other
} = props;
const router = useRouter();
const pathname = typeof href === "string" ? href : href.pathname;
const className = clsx(classNameProps, {
[activeClassName]: router.pathname === pathname && activeClassName,
});
const isExternal = typeof href === "string" && (href.indexOf("http") === 0 || href.indexOf("mailto:") === 0);
if (isExternal) {
if (noLinkStyle) {
return <Anchor className={className} href={href} ref={ref} {...other} />;
}
return <MuiLink className={className} href={href} ref={ref} {...other} />;
}
const linkAs = linkAsProp || as;
const nextjsProps = { to: href, linkAs, replace, scroll, shallow, prefetch, locale };
if (noLinkStyle) {
return <NextLinkComposed className={className} ref={ref} {...nextjsProps} {...other} />;
}
return <MuiLink component={NextLinkComposed} className={className} ref={ref} {...nextjsProps} {...other} />;
});
export default Link;

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;
actionsCell?: (id: string) => JSX.Element;
onRowClick?: (id: string) => void;
onContextMenu?: (e: React.MouseEvent, id: string) => void;
}
export default function RequestsTable(props: Props): JSX.Element {
const { requests, activeRowId, actionsCell, onRowClick, onContextMenu } = props;
return (
<TableContainer sx={{ overflowX: "initial" }}>
<Table size="small" stickyHeader>
<TableHead>
<TableRow>
<TableCell>Method</TableCell>
<TableCell>Origin</TableCell>
<TableCell>Path</TableCell>
<TableCell>Status</TableCell>
{actionsCell && <TableCell padding="checkbox"></TableCell>}
</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>
{actionsCell && actionsCell(id)}
</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,38 @@
import { Box, Typography } from "@mui/material";
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={response?.headers || []}
hasResponse={response !== undefined && response !== null}
/>
</Box>
);
}
export default Response;

View File

@ -0,0 +1,38 @@
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.Http10:
return "HTTP/1.0";
case HttpProtocol.Http11:
return "HTTP/1.1";
case HttpProtocol.Http20:
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,78 @@
import { TabContext, TabList, TabPanel } from "@mui/lab";
import { Box, Paper, Tab, Typography } from "@mui/material";
import React, { useState } from "react";
import { KeyValuePairTable, KeyValuePair, KeyValuePairTableProps } from "./KeyValuePair";
import Editor from "lib/components/Editor";
interface ResponseTabsProps {
headers: KeyValuePair[];
onHeaderChange?: KeyValuePairTableProps["onChange"];
onHeaderDelete?: KeyValuePairTableProps["onDelete"];
body?: string | null;
onBodyChange?: (value: string) => void;
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, onHeaderChange, onHeaderDelete, body, onBodyChange, hasResponse } = props;
const [tabValue, setTabValue] = useState(TabValue.Body);
const contentType = headers.find((header) => header.key.toLowerCase() === "content-type")?.value;
const tabSx = {
textTransform: "none",
};
const headersLength = onHeaderChange ? headers.length - 1 : headers.length;
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" + (headersLength ? ` (${headersLength})` : "")} sx={tabSx} />
</TabList>
</Box>
<Box flex="1 auto" overflow="hidden">
<TabPanel value={TabValue.Body} sx={{ p: 0, height: "100%" }}>
{hasResponse && (
<Editor
content={body || ""}
onChange={(value) => {
onBodyChange && onBodyChange(value || "");
}}
monacoOptions={{ readOnly: onBodyChange === undefined }}
contentType={contentType}
/>
)}
{!hasResponse && reqNotSent}
</TabPanel>
<TabPanel value={TabValue.Headers} sx={{ p: 0, height: "100%", overflow: "scroll" }}>
{hasResponse && <KeyValuePairTable items={headers} onChange={onHeaderChange} onDelete={onHeaderDelete} />}
{!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,122 @@
import { Box, BoxProps, FormControl, InputLabel, MenuItem, Select, TextField } from "@mui/material";
import { HttpProtocol } from "lib/graphql/generated";
export enum HttpMethod {
Get = "GET",
Post = "POST",
Put = "PUT",
Patch = "PATCH",
Delete = "DELETE",
Head = "HEAD",
Options = "OPTIONS",
Connect = "CONNECT",
Trace = "TRACE",
}
export enum HttpProto {
Http10 = "HTTP/1.0",
Http11 = "HTTP/1.1",
Http20 = "HTTP/2.0",
}
export const httpProtoMap = new Map([
[HttpProto.Http10, HttpProtocol.Http10],
[HttpProto.Http11, HttpProtocol.Http11],
[HttpProto.Http20, HttpProtocol.Http20],
]);
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"
disabled={!onMethodChange}
onChange={(e) => onMethodChange && 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}
disabled={!onUrlChange}
onChange={(e) => onUrlChange && 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"
disabled={!onProtoChange}
onChange={(e) => onProtoChange && 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 UrlBar;

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;
}

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