Compare commits
65 Commits
v0.3.0
...
dependabot
Author | SHA1 | Date | |
---|---|---|---|
0d68d7d7b2 | |||
f7def87d0f | |||
aa9822854d | |||
2ce4218a30 | |||
fd27955e11 | |||
426a7d5f96 | |||
21b679dc91 | |||
e4f468d4d2 | |||
d3246b0918 | |||
61fd3fcc45 | |||
d34258dfd1 | |||
0e9fb0ac91 | |||
02408b5196 | |||
6ffc55cde3 | |||
bdd667381a | |||
f60202e41c | |||
87b8b18047 | |||
edab744d01 | |||
3f5277e419 | |||
29550ff43b | |||
7afc23b3ff | |||
6aa93b782e | |||
ed9a539ce3 | |||
857aa0c49e | |||
af26987601 | |||
ad26478043 | |||
ca0c085021 | |||
d438f93ee0 | |||
fa3f24eb70 | |||
f15438e10b | |||
bef52d956e | |||
8269af9478 | |||
c5f76e1f9a | |||
2ddf2a77e8 | |||
d2858a2be4 | |||
7e43479b54 | |||
11f70282d7 | |||
efc20564c1 | |||
afa211d0ec | |||
44193cd723 | |||
e07163fef3 | |||
ed394507d3 | |||
cd5403e353 | |||
565c370bb8 | |||
2dc6538a3b | |||
aa8ddf4122 | |||
73ebb89863 | |||
1489cb16bf | |||
d84d2d0905 | |||
8a3b3cbf02 | |||
b3225bfb99 | |||
4e2eaea499 | |||
8122b2552d | |||
569f7bc76f | |||
ca3a729c36 | |||
ad3dc0da70 | |||
49547f535f | |||
e42e1c212b | |||
6e38b16cf2 | |||
078bf303be | |||
a42f003919 | |||
50c2eac42d | |||
4ead501f53 | |||
d2e97f2acc | |||
ad3fa7d379 |
@ -2,3 +2,7 @@
|
|||||||
/admin/.next
|
/admin/.next
|
||||||
/admin/dist
|
/admin/dist
|
||||||
/admin/node_modules
|
/admin/node_modules
|
||||||
|
/dist
|
||||||
|
/docs
|
||||||
|
/hetty
|
||||||
|
/cmd/hetty/admin
|
4
.github/FUNDING.yml
vendored
@ -1,3 +1,3 @@
|
|||||||
# These are supported funding model platforms
|
github: dstotijn
|
||||||
|
|
||||||
patreon: dstotijn
|
patreon: dstotijn
|
||||||
|
custom: "https://www.paypal.com/paypalme/dstotijn"
|
||||||
|
52
.github/workflows/build-test.yml
vendored
Normal 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
@ -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
|
9
.gitignore
vendored
@ -1,7 +1,6 @@
|
|||||||
.release-env
|
*.vscode
|
||||||
.vscode
|
/dist
|
||||||
**/rice-box.go
|
/hetty
|
||||||
dist
|
/cmd/hetty/admin
|
||||||
hetty
|
|
||||||
*.pem
|
*.pem
|
||||||
*.test
|
*.test
|
53
.golangci.yml
Normal 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"
|
116
.goreleaser.yml
@ -1,60 +1,104 @@
|
|||||||
env:
|
before:
|
||||||
- GO111MODULE=on
|
hooks:
|
||||||
- CGO_ENABLED=1
|
- make clean
|
||||||
|
- sh -c "NEXT_PUBLIC_VERSION={{ .Version}} make build-admin"
|
||||||
|
- go mod tidy
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: hetty-darwin-amd64
|
- env:
|
||||||
|
- CGO_ENABLED=0
|
||||||
main: ./cmd/hetty
|
main: ./cmd/hetty
|
||||||
goarch:
|
ldflags:
|
||||||
- amd64
|
- -s -w -X main.version={{.Version}}
|
||||||
goos:
|
|
||||||
- darwin
|
|
||||||
env:
|
|
||||||
- CC=o64-clang
|
|
||||||
- CXX=o64-clang++
|
|
||||||
flags:
|
|
||||||
- -mod=readonly
|
|
||||||
|
|
||||||
- id: hetty-linux-amd64
|
|
||||||
main: ./cmd/hetty
|
|
||||||
goarch:
|
|
||||||
- amd64
|
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
flags:
|
- windows
|
||||||
- -mod=readonly
|
- darwin
|
||||||
|
|
||||||
- id: hetty-windows-amd64
|
|
||||||
main: ./cmd/hetty
|
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
goos:
|
- arm64
|
||||||
- windows
|
|
||||||
env:
|
|
||||||
- CC=x86_64-w64-mingw32-gcc
|
|
||||||
- CXX=x86_64-w64-mingw32-g++
|
|
||||||
flags:
|
|
||||||
- -mod=readonly
|
|
||||||
ldflags:
|
|
||||||
- -buildmode=exe
|
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
-
|
- replacements:
|
||||||
replacements:
|
|
||||||
darwin: macOS
|
darwin: macOS
|
||||||
linux: Linux
|
linux: Linux
|
||||||
windows: Windows
|
windows: Windows
|
||||||
386: i386
|
|
||||||
amd64: x86_64
|
amd64: x86_64
|
||||||
format_overrides:
|
format_overrides:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
format: zip
|
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:
|
checksum:
|
||||||
name_template: "checksums.txt"
|
name_template: "checksums.txt"
|
||||||
|
|
||||||
snapshot:
|
snapshot:
|
||||||
name_template: "{{ .Tag }}-next"
|
name_template: "{{ incpatch .Version }}-next"
|
||||||
|
|
||||||
changelog:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
|
32
Dockerfile
@ -1,16 +1,6 @@
|
|||||||
ARG GO_VERSION=1.15
|
ARG GO_VERSION=1.17
|
||||||
ARG CGO_ENABLED=1
|
ARG NODE_VERSION=16.13
|
||||||
ARG NODE_VERSION=14.11
|
ARG ALPINE_VERSION=3.15
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
FROM node:${NODE_VERSION}-alpine AS node-builder
|
FROM node:${NODE_VERSION}-alpine AS node-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@ -20,11 +10,21 @@ COPY admin/ .
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
RUN yarn run export
|
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
|
WORKDIR /app
|
||||||
COPY --from=go-builder /app/hetty .
|
COPY --from=go-builder /app/hetty .
|
||||||
COPY --from=node-builder /app/dist admin
|
|
||||||
|
|
||||||
ENTRYPOINT ["./hetty", "-adminPath=./admin"]
|
ENTRYPOINT ["./hetty"]
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
2
LICENSE
@ -1,6 +1,6 @@
|
|||||||
MIT License
|
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
46
Makefile
@ -1,34 +1,20 @@
|
|||||||
PACKAGE_NAME := github.com/dstotijn/hetty
|
export CGO_ENABLED = 0
|
||||||
GOLANG_CROSS_VERSION ?= v1.15.2
|
export NEXT_TELEMETRY_DISABLED = 1
|
||||||
|
|
||||||
.PHONY: embed
|
|
||||||
embed:
|
|
||||||
NEXT_TELEMETRY_DISABLED=1 cd admin && yarn install && yarn run export
|
|
||||||
cd cmd/hetty && rice embed-go
|
|
||||||
|
|
||||||
.PHONY: build
|
.PHONY: build
|
||||||
build: embed
|
build: build-admin
|
||||||
CGO_ENABLED=1 go build ./cmd/hetty
|
go build ./cmd/hetty
|
||||||
|
|
||||||
.PHONY: release-dry-run
|
.PHONY: build-admin
|
||||||
release-dry-run: embed
|
build-admin:
|
||||||
@docker run \
|
cd admin && \
|
||||||
--rm \
|
yarn install --frozen-lockfile && \
|
||||||
-v `pwd`:/go/src/$(PACKAGE_NAME) \
|
yarn run export && \
|
||||||
-w /go/src/$(PACKAGE_NAME) \
|
mv dist ../cmd/hetty/admin
|
||||||
troian/golang-cross:${GOLANG_CROSS_VERSION} \
|
|
||||||
--rm-dist --skip-validate --skip-publish
|
|
||||||
|
|
||||||
.PHONY: release
|
.PHONY: clean
|
||||||
release: embed
|
clean:
|
||||||
@if [ ! -f ".release-env" ]; then \
|
rm -f hetty
|
||||||
echo "\033[91mFile \`.release-env\` is missing.\033[0m";\
|
rm -rf ./cmd/hetty/admin
|
||||||
exit 1;\
|
rm -rf ./admin/dist
|
||||||
fi
|
rm -rf ./admin/.next
|
||||||
@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
|
|
278
README.md
@ -1,237 +1,159 @@
|
|||||||
<h1>
|
<img src="https://user-images.githubusercontent.com/983924/156430531-6193e187-7400-436b-81c6-f86862783ea5.svg#gh-light-mode-only" width="240"/>
|
||||||
<a href="https://github.com/dstotijn/hetty">
|
<img src="https://user-images.githubusercontent.com/983924/156430660-9d5bd555-dcfd-47e2-ba70-54294c20c1b4.svg#gh-dark-mode-only" width="240"/>
|
||||||
<img src="https://hetty.xyz/assets/logo.png" width="293">
|
|
||||||
</a>
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
[](https://github.com/dstotijn/hetty/releases/latest)
|
[](https://github.com/dstotijn/hetty/releases/latest)
|
||||||

|
[](https://github.com/dstotijn/hetty/actions/workflows/build-test.yml)
|
||||||
[](https://github.com/dstotijn/hetty/blob/master/LICENSE)
|

|
||||||
[](https://hetty.xyz/)
|
[](https://github.com/dstotijn/hetty/blob/master/LICENSE)
|
||||||
|
[](https://hetty.xyz/)
|
||||||
|
|
||||||
**Hetty** is an HTTP toolkit for security research. It aims to become an open
|
**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
|
source alternative to commercial software like Burp Suite Pro, with powerful
|
||||||
features tailored to the needs of the infosec and bug bounty community.
|
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
|
## Features
|
||||||
|
|
||||||
- Man-in-the-middle (MITM) HTTP/1.1 proxy with logs
|
- Machine-in-the-middle (MITM) HTTP proxy, with logs and advanced search
|
||||||
- Project based database storage (SQLite)
|
- HTTP client for manually creating/editing requests, and replay proxied requests
|
||||||
- Scope support
|
- Intercept requests and responses for manual review (edit, send/receive, cancel)
|
||||||
- Headless management API using GraphQL
|
- Scope support, to help keep work organized
|
||||||
- Embedded web interface (Next.js)
|
- 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
|
👷♂️ Hetty is under active development. Check the <a
|
||||||
for a `v1.0` release. Please see the <a href="https://github.com/dstotijn/hetty/projects/1">backlog</a>
|
href="https://github.com/dstotijn/hetty/projects/1">backlog</a> for the current
|
||||||
for details.
|
status.
|
||||||
|
|
||||||
## Documentation
|
📣 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!
|
||||||
|
|
||||||
📖 [Read the docs.](https://hetty.xyz/)
|
## Getting started
|
||||||
|
|
||||||
## Installation
|
💡 The [Getting started](https://hetty.xyz/docs/getting-started) doc has more
|
||||||
|
detailed install and usage instructions.
|
||||||
|
|
||||||
Hetty compiles to a self-contained binary, with an embedded SQLite database
|
### Installation
|
||||||
and web based admin interface.
|
|
||||||
|
|
||||||
### Install pre-built release (recommended)
|
The quickest way to install and update Hetty is via a package manager:
|
||||||
|
|
||||||
👉 Downloads for Linux, macOS and Windows are available on the [releases page](https://github.com/dstotijn/hetty/releases).
|
#### macOS
|
||||||
|
|
||||||
### Build from source
|
```sh
|
||||||
|
brew install hettysoft/tap/hetty
|
||||||
#### Prerequisites
|
|
||||||
|
|
||||||
- [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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker
|
#### Linux
|
||||||
|
|
||||||
A Docker image is available on Docker Hub: [`dstotijn/hetty`](https://hub.docker.com/r/dstotijn/hetty).
|
```sh
|
||||||
For persistent storage of CA certificates and project databases, mount a volume:
|
sudo snap install hetty
|
||||||
|
|
||||||
```
|
|
||||||
$ mkdir -p $HOME/.hetty
|
|
||||||
$ docker run -v $HOME/.hetty:/root/.hetty -p 8080:8080 dstotijn/hetty
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
#### Windows
|
||||||
|
|
||||||
When Hetty is run, by default it listens on `:8080` and is accessible via
|
```sh
|
||||||
http://localhost:8080. Depending on incoming HTTP requests, it either acts as a
|
scoop bucket add hettysoft https://github.com/hettysoft/scoop-bucket.git
|
||||||
MITM proxy, or it serves the API and web interface.
|
scoop install hettysoft/hetty
|
||||||
|
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
docker run -v $HOME/.hetty:/root/.hetty -p 8080:8080 \
|
||||||
Usage of ./hetty:
|
ghcr.io/dstotijn/hetty:latest
|
||||||
-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")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
You should see:
|
### Usage
|
||||||
|
|
||||||
```
|
Once installed, start Hetty via:
|
||||||
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
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
hetty
|
hetty
|
||||||
```
|
```
|
||||||
|
|
||||||
You should now have a key and certificate located at `~/.hetty/hetty_key.pem` and
|
💡 Read the [Getting started](https://hetty.xyz/docs/getting-started) doc for
|
||||||
`~/.hetty/hetty_cert.pem` respectively.
|
more details.
|
||||||
|
|
||||||
#### Generating CA certificates with OpenSSL
|
To list all available options, run: `hetty --help`:
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
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
|
📖 [Read the docs](https://hetty.xyz/docs)
|
||||||
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.
|
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
Use [issues](https://github.com/dstotijn/hetty/issues) for bug reports and
|
Use [issues](https://github.com/dstotijn/hetty/issues) for bug reports and
|
||||||
feature requests, and [discussions](https://github.com/dstotijn/hetty/discussions)
|
feature requests, and
|
||||||
for questions and troubleshooting.
|
[discussions](https://github.com/dstotijn/hetty/discussions) for questions and
|
||||||
|
troubleshooting.
|
||||||
|
|
||||||
## Community
|
## Community
|
||||||
|
|
||||||
💬 [Join the Hetty Discord server](https://discord.gg/3HVsj5pTFP).
|
💬 [Join the Hetty Discord server](https://discord.gg/3HVsj5pTFP)
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Want to contribute? Great! Please check the [Contribution Guidelines](CONTRIBUTING.md)
|
Want to contribute? Great! Please check the [Contribution
|
||||||
for details.
|
Guidelines](CONTRIBUTING.md) for details.
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
|
|
||||||
- Thanks to the [Hacker101 community on Discord](https://www.hacker101.com/discord)
|
- Thanks to the [Hacker101 community on Discord](https://www.hacker101.com/discord)
|
||||||
for all the encouragement and feedback.
|
for the encouragement and early feedback.
|
||||||
- The font used in the logo and admin interface is [JetBrains Mono](https://www.jetbrains.com/lp/mono/).
|
- 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
|
## License
|
||||||
|
|
||||||
[MIT License](LICENSE)
|
[MIT](LICENSE)
|
||||||
|
|
||||||
---
|
© 2022 Hetty Software
|
||||||
|
|
||||||
© 2020 David Stotijn — [Twitter](https://twitter.com/dstotijn), [Email](mailto:dstotijn@gmail.com)
|
|
||||||
|
56
admin/.eslintrc.json
Normal 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
@ -0,0 +1,4 @@
|
|||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
/build
|
||||||
|
/coverage
|
3
admin/.prettierrc.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 120
|
||||||
|
}
|
9
admin/gqlcodegen.yml
Normal 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
@ -1,2 +1,5 @@
|
|||||||
/// <reference types="next" />
|
/// <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.
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
const withCSS = require("@zeit/next-css");
|
// @ts-check
|
||||||
const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin");
|
|
||||||
|
|
||||||
module.exports = withCSS({
|
/**
|
||||||
|
* @type {import('next').NextConfig}
|
||||||
|
**/
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
trailingSlash: true,
|
trailingSlash: true,
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
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(
|
module.exports = nextConfig;
|
||||||
new MonacoWebpackPlugin({
|
|
||||||
languages: ["html", "json", "javascript"],
|
|
||||||
filename: "static/[name].worker.js",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
@ -6,31 +6,51 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"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": {
|
"dependencies": {
|
||||||
"@apollo/client": "^3.2.0",
|
"@apollo/client": "^3.2.0",
|
||||||
"@material-ui/core": "^4.11.0",
|
"@emotion/react": "^11.7.1",
|
||||||
"@material-ui/icons": "^4.9.1",
|
"@emotion/server": "^11.4.0",
|
||||||
"@material-ui/lab": "^4.0.0-alpha.56",
|
"@emotion/styled": "^11.6.0",
|
||||||
"@zeit/next-css": "^1.0.1",
|
"@monaco-editor/react": "^4.3.1",
|
||||||
"graphql": "^15.3.0",
|
"@mui/icons-material": "^5.3.1",
|
||||||
"monaco-editor": "^0.20.0",
|
"@mui/lab": "^5.0.0-alpha.66",
|
||||||
"monaco-editor-webpack-plugin": "^1.9.0",
|
"@mui/material": "^5.3.1",
|
||||||
"next": "^9.5.4",
|
"@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",
|
"next-fonts": "^1.0.3",
|
||||||
"react": "^16.13.1",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^16.13.1",
|
"react-dom": "^17.0.2",
|
||||||
"react-monaco-editor": "^0.34.0",
|
"react-split-pane": "^0.1.92"
|
||||||
"react-syntax-highlighter": "^13.5.3",
|
|
||||||
"typescript": "^4.0.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^14.11.1",
|
"@babel/core": "^7.0.0",
|
||||||
"@types/react": "^16.9.49",
|
"@graphql-codegen/cli": "2.6.1",
|
||||||
"eslint": "^7.9.0",
|
"@graphql-codegen/introspection": "2.1.1",
|
||||||
"eslint-config-prettier": "^6.11.0",
|
"@graphql-codegen/typescript": "2.4.3",
|
||||||
"eslint-plugin-prettier": "^3.1.4",
|
"@graphql-codegen/typescript-operations": "2.3.0",
|
||||||
"prettier": "^2.1.2"
|
"@graphql-codegen/typescript-react-apollo": "3.2.6",
|
||||||
|
"@types/lodash": "^4.14.178",
|
||||||
|
"@types/node": "^17.0.12",
|
||||||
|
"@types/react": "^17.0.38",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^5.12.0",
|
||||||
|
"@typescript-eslint/parser": "^5.12.0",
|
||||||
|
"eslint": "^8.7.0",
|
||||||
|
"eslint-config-next": "12.0.8",
|
||||||
|
"eslint-config-prettier": "^8.3.0",
|
||||||
|
"eslint-import-resolver-typescript": "^2.5.0",
|
||||||
|
"eslint-plugin-import": "^2.25.4",
|
||||||
|
"eslint-plugin-prettier": "^4.0.0",
|
||||||
|
"prettier": "^2.1.2",
|
||||||
|
"typescript": "^4.0.3",
|
||||||
|
"webpack": "^5.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
BIN
admin/public/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 5.5 KiB |
BIN
admin/public/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 12 KiB |
BIN
admin/public/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 5.0 KiB |
BIN
admin/public/favicon-16x16.png
Normal file
After Width: | Height: | Size: 454 B |
BIN
admin/public/favicon-32x32.png
Normal file
After Width: | Height: | Size: 840 B |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
1
admin/public/site.webmanifest
Normal 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"}
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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);
|
|
@ -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;
|
|
@ -1,70 +0,0 @@
|
|||||||
import { useRouter } from "next/router";
|
|
||||||
import Link from "next/link";
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
CircularProgress,
|
|
||||||
Link as MaterialLink,
|
|
||||||
Typography,
|
|
||||||
} from "@material-ui/core";
|
|
||||||
import Alert from "@material-ui/lab/Alert";
|
|
||||||
|
|
||||||
import RequestList from "./RequestList";
|
|
||||||
import LogDetail from "./LogDetail";
|
|
||||||
import CenteredPaper from "../CenteredPaper";
|
|
||||||
import { useHttpRequestLogs } from "./hooks/useHttpRequestLogs";
|
|
||||||
|
|
||||||
function LogsOverview(): JSX.Element {
|
|
||||||
const router = useRouter();
|
|
||||||
const detailReqLogId =
|
|
||||||
router.query.id && parseInt(router.query.id as string, 10);
|
|
||||||
|
|
||||||
const { loading, error, data } = useHttpRequestLogs();
|
|
||||||
|
|
||||||
const handleLogClick = (reqId: number) => {
|
|
||||||
router.push("/proxy/logs?id=" + reqId, undefined, {
|
|
||||||
shallow: false,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <CircularProgress />;
|
|
||||||
}
|
|
||||||
if (error) {
|
|
||||||
if (error.graphQLErrors[0]?.extensions?.code === "no_active_project") {
|
|
||||||
return (
|
|
||||||
<Alert severity="info">
|
|
||||||
There is no project active.{" "}
|
|
||||||
<Link href="/projects" passHref>
|
|
||||||
<MaterialLink color="secondary">Create or open</MaterialLink>
|
|
||||||
</Link>{" "}
|
|
||||||
one first.
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <Alert severity="error">Error fetching logs: {error.message}</Alert>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { httpRequestLogs: logs } = data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Box mb={2}>
|
|
||||||
<RequestList
|
|
||||||
logs={logs}
|
|
||||||
selectedReqLogId={detailReqLogId}
|
|
||||||
onLogClick={handleLogClick}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box>
|
|
||||||
{detailReqLogId && <LogDetail requestId={detailReqLogId} />}
|
|
||||||
{logs.length !== 0 && !detailReqLogId && (
|
|
||||||
<CenteredPaper>
|
|
||||||
<Typography>Select a log entry…</Typography>
|
|
||||||
</CenteredPaper>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LogsOverview;
|
|
@ -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;
|
|
@ -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);
|
|
@ -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;
|
|
@ -1,253 +0,0 @@
|
|||||||
import {
|
|
||||||
Box,
|
|
||||||
Checkbox,
|
|
||||||
CircularProgress,
|
|
||||||
ClickAwayListener,
|
|
||||||
createStyles,
|
|
||||||
FormControlLabel,
|
|
||||||
InputBase,
|
|
||||||
makeStyles,
|
|
||||||
Paper,
|
|
||||||
Popper,
|
|
||||||
Theme,
|
|
||||||
Tooltip,
|
|
||||||
useTheme,
|
|
||||||
} from "@material-ui/core";
|
|
||||||
import IconButton from "@material-ui/core/IconButton";
|
|
||||||
import SearchIcon from "@material-ui/icons/Search";
|
|
||||||
import FilterListIcon from "@material-ui/icons/FilterList";
|
|
||||||
import DeleteIcon from "@material-ui/icons/Delete";
|
|
||||||
import React, { useRef, useState } from "react";
|
|
||||||
import { gql, useMutation, useQuery } from "@apollo/client";
|
|
||||||
import { withoutTypename } from "../../lib/omitTypename";
|
|
||||||
import { Alert } from "@material-ui/lab";
|
|
||||||
import { useClearHTTPRequestLog } from "./hooks/useClearHTTPRequestLog";
|
|
||||||
import {
|
|
||||||
ConfirmationDialog,
|
|
||||||
useConfirmationDialog,
|
|
||||||
} from "./ConfirmationDialog";
|
|
||||||
|
|
||||||
const FILTER = gql`
|
|
||||||
query HttpRequestLogFilter {
|
|
||||||
httpRequestLogFilter {
|
|
||||||
onlyInScope
|
|
||||||
searchExpression
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const SET_FILTER = gql`
|
|
||||||
mutation SetHttpRequestLogFilter($filter: HttpRequestLogFilterInput) {
|
|
||||||
setHttpRequestLogFilter(filter: $filter) {
|
|
||||||
onlyInScope
|
|
||||||
searchExpression
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
|
||||||
createStyles({
|
|
||||||
root: {
|
|
||||||
padding: "2px 4px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
width: 400,
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
marginLeft: theme.spacing(1),
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
iconButton: {
|
|
||||||
padding: 10,
|
|
||||||
},
|
|
||||||
filterPopper: {
|
|
||||||
width: 400,
|
|
||||||
marginTop: 6,
|
|
||||||
zIndex: 99,
|
|
||||||
},
|
|
||||||
filterOptions: {
|
|
||||||
padding: theme.spacing(2),
|
|
||||||
},
|
|
||||||
filterLoading: {
|
|
||||||
marginRight: 1,
|
|
||||||
color: theme.palette.text.primary,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface SearchFilter {
|
|
||||||
onlyInScope: boolean;
|
|
||||||
searchExpression: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Search(): JSX.Element {
|
|
||||||
const classes = useStyles();
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
const [searchExpr, setSearchExpr] = useState("");
|
|
||||||
const { loading: filterLoading, error: filterErr, data: filter } = useQuery(
|
|
||||||
FILTER,
|
|
||||||
{
|
|
||||||
onCompleted: (data) => {
|
|
||||||
setSearchExpr(data.httpRequestLogFilter.searchExpression || "");
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const [
|
|
||||||
setFilterMutate,
|
|
||||||
{ error: setFilterErr, loading: setFilterLoading },
|
|
||||||
] = useMutation<{
|
|
||||||
setHttpRequestLogFilter: SearchFilter | null;
|
|
||||||
}>(SET_FILTER, {
|
|
||||||
update(cache, { data: { setHttpRequestLogFilter } }) {
|
|
||||||
cache.writeQuery({
|
|
||||||
query: FILTER,
|
|
||||||
data: {
|
|
||||||
httpRequestLogFilter: setHttpRequestLogFilter,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [
|
|
||||||
clearHTTPRequestLog,
|
|
||||||
clearHTTPRequestLogResult,
|
|
||||||
] = useClearHTTPRequestLog();
|
|
||||||
const clearHTTPConfirmationDialog = useConfirmationDialog();
|
|
||||||
|
|
||||||
const filterRef = useRef<HTMLElement | null>();
|
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.SyntheticEvent) => {
|
|
||||||
setFilterMutate({
|
|
||||||
variables: {
|
|
||||||
filter: {
|
|
||||||
...withoutTypename(filter?.httpRequestLogFilter),
|
|
||||||
searchExpression: searchExpr,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
setFilterOpen(false);
|
|
||||||
e.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClickAway = (event: React.MouseEvent<EventTarget>) => {
|
|
||||||
if (filterRef.current.contains(event.target as HTMLElement)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setFilterOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box>
|
|
||||||
<Error prefix="Error fetching filter" error={filterErr} />
|
|
||||||
<Error prefix="Error setting filter" error={setFilterErr} />
|
|
||||||
<Error
|
|
||||||
prefix="Error clearing all HTTP logs"
|
|
||||||
error={clearHTTPRequestLogResult.error}
|
|
||||||
/>
|
|
||||||
<Box style={{ display: "flex", flex: 1 }}>
|
|
||||||
<ClickAwayListener onClickAway={handleClickAway}>
|
|
||||||
<Paper
|
|
||||||
component="form"
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
ref={filterRef}
|
|
||||||
className={classes.root}
|
|
||||||
>
|
|
||||||
<Tooltip title="Toggle filter options">
|
|
||||||
<IconButton
|
|
||||||
className={classes.iconButton}
|
|
||||||
onClick={() => setFilterOpen(!filterOpen)}
|
|
||||||
style={{
|
|
||||||
color: filter?.httpRequestLogFilter?.onlyInScope
|
|
||||||
? theme.palette.secondary.main
|
|
||||||
: "inherit",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{filterLoading || setFilterLoading ? (
|
|
||||||
<CircularProgress
|
|
||||||
className={classes.filterLoading}
|
|
||||||
size={23}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<FilterListIcon />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<InputBase
|
|
||||||
className={classes.input}
|
|
||||||
placeholder="Search proxy logs…"
|
|
||||||
value={searchExpr}
|
|
||||||
onChange={(e) => setSearchExpr(e.target.value)}
|
|
||||||
onFocus={() => setFilterOpen(true)}
|
|
||||||
/>
|
|
||||||
<Tooltip title="Search">
|
|
||||||
<IconButton type="submit" className={classes.iconButton}>
|
|
||||||
<SearchIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Popper
|
|
||||||
className={classes.filterPopper}
|
|
||||||
open={filterOpen}
|
|
||||||
anchorEl={filterRef.current}
|
|
||||||
placement="bottom-start"
|
|
||||||
>
|
|
||||||
<Paper className={classes.filterOptions}>
|
|
||||||
<FormControlLabel
|
|
||||||
control={
|
|
||||||
<Checkbox
|
|
||||||
checked={
|
|
||||||
filter?.httpRequestLogFilter?.onlyInScope ? true : false
|
|
||||||
}
|
|
||||||
disabled={filterLoading || setFilterLoading}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFilterMutate({
|
|
||||||
variables: {
|
|
||||||
filter: {
|
|
||||||
...withoutTypename(filter?.httpRequestLogFilter),
|
|
||||||
onlyInScope: e.target.checked,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
label="Only show in-scope requests"
|
|
||||||
/>
|
|
||||||
</Paper>
|
|
||||||
</Popper>
|
|
||||||
</Paper>
|
|
||||||
</ClickAwayListener>
|
|
||||||
<Box style={{ marginLeft: "auto" }}>
|
|
||||||
<Tooltip title="Clear all">
|
|
||||||
<IconButton onClick={clearHTTPConfirmationDialog.open}>
|
|
||||||
<DeleteIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<ConfirmationDialog
|
|
||||||
isOpen={clearHTTPConfirmationDialog.isOpen}
|
|
||||||
onClose={clearHTTPConfirmationDialog.close}
|
|
||||||
onConfirm={clearHTTPRequestLog}
|
|
||||||
>
|
|
||||||
All proxy logs are going to be removed. This action cannot be undone.
|
|
||||||
</ConfirmationDialog>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Error(props: { prefix: string; error?: Error }) {
|
|
||||||
if (!props.error) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box mb={4}>
|
|
||||||
<Alert severity="error">
|
|
||||||
{props.prefix}: {props.error.message}
|
|
||||||
</Alert>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Search;
|
|
@ -1,16 +0,0 @@
|
|||||||
import { gql, useMutation } from "@apollo/client";
|
|
||||||
import { HTTP_REQUEST_LOGS } from "./useHttpRequestLogs";
|
|
||||||
|
|
||||||
const CLEAR_HTTP_REQUEST_LOG = gql`
|
|
||||||
mutation ClearHTTPRequestLog {
|
|
||||||
clearHTTPRequestLog {
|
|
||||||
success
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export function useClearHTTPRequestLog() {
|
|
||||||
return useMutation(CLEAR_HTTP_REQUEST_LOG, {
|
|
||||||
refetchQueries: [{ query: HTTP_REQUEST_LOGS }],
|
|
||||||
});
|
|
||||||
}
|
|
@ -1,22 +0,0 @@
|
|||||||
import { gql, useQuery } from "@apollo/client";
|
|
||||||
|
|
||||||
export const HTTP_REQUEST_LOGS = gql`
|
|
||||||
query HttpRequestLogs {
|
|
||||||
httpRequestLogs {
|
|
||||||
id
|
|
||||||
method
|
|
||||||
url
|
|
||||||
timestamp
|
|
||||||
response {
|
|
||||||
statusCode
|
|
||||||
statusReason
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export function useHttpRequestLogs() {
|
|
||||||
return useQuery(HTTP_REQUEST_LOGS, {
|
|
||||||
pollInterval: 1000,
|
|
||||||
});
|
|
||||||
}
|
|
@ -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;
|
|
270
admin/src/features/Layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
366
admin/src/features/intercept/components/EditRequest.tsx
Normal 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;
|
21
admin/src/features/intercept/components/Intercept.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
33
admin/src/features/intercept/components/Requests.tsx
Normal 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;
|
@ -0,0 +1,5 @@
|
|||||||
|
mutation CancelRequest($id: ID!) {
|
||||||
|
cancelRequest(id: $id) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
mutation CancelResponse($requestID: ID!) {
|
||||||
|
cancelResponse(requestID: $requestID) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
mutation ModifyRequest($request: ModifyRequestInput!) {
|
||||||
|
modifyRequest(request: $request) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
mutation ModifyResponse($response: ModifyResponseInput!) {
|
||||||
|
modifyResponse(response: $response) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
67
admin/src/features/projects/components/NewProject.tsx
Normal 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;
|
232
admin/src/features/projects/components/ProjectList.tsx
Normal 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;
|
15
admin/src/features/projects/graphql/activeProject.graphql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
query ActiveProject {
|
||||||
|
activeProject {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
settings {
|
||||||
|
intercept {
|
||||||
|
requestsEnabled
|
||||||
|
responsesEnabled
|
||||||
|
requestFilter
|
||||||
|
responseFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
5
admin/src/features/projects/graphql/closeProject.graphql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
mutation CloseProject {
|
||||||
|
closeProject {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
mutation CreateProject($name: String!) {
|
||||||
|
createProject(name: $name) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
mutation DeleteProject($id: ID!) {
|
||||||
|
deleteProject(id: $id) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
7
admin/src/features/projects/graphql/openProject.graphql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
mutation OpenProject($id: ID!) {
|
||||||
|
openProject(id: $id) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
}
|
||||||
|
}
|
7
admin/src/features/projects/graphql/projects.graphql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
query Projects {
|
||||||
|
projects {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
}
|
||||||
|
}
|
47
admin/src/features/projects/hooks/useOpenProjectMutation.ts
Normal 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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
61
admin/src/features/reqlog/components/Actions.tsx
Normal 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;
|
57
admin/src/features/reqlog/components/LogDetail.tsx
Normal 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;
|
47
admin/src/features/reqlog/components/RequestDetail.tsx
Normal 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;
|
137
admin/src/features/reqlog/components/RequestLogs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
174
admin/src/features/reqlog/components/Search.tsx
Normal 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;
|
@ -0,0 +1,5 @@
|
|||||||
|
mutation ClearHTTPRequestLog {
|
||||||
|
clearHTTPRequestLog {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
24
admin/src/features/reqlog/graphql/httpRequestLog.graphql
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
query HttpRequestLogFilter {
|
||||||
|
httpRequestLogFilter {
|
||||||
|
onlyInScope
|
||||||
|
searchExpression
|
||||||
|
}
|
||||||
|
}
|
12
admin/src/features/reqlog/graphql/httpRequestLogs.graphql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
query HttpRequestLogs {
|
||||||
|
httpRequestLogs {
|
||||||
|
id
|
||||||
|
method
|
||||||
|
url
|
||||||
|
timestamp
|
||||||
|
response {
|
||||||
|
statusCode
|
||||||
|
statusReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
mutation SetHttpRequestLogFilter($filter: HttpRequestLogFilterInput) {
|
||||||
|
setHttpRequestLogFilter(filter: $filter) {
|
||||||
|
onlyInScope
|
||||||
|
searchExpression
|
||||||
|
}
|
||||||
|
}
|
3
admin/src/features/reqlog/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { RequestLogs } from "./components/RequestLogs";
|
||||||
|
|
||||||
|
export default RequestLogs;
|
@ -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 {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
createStyles,
|
|
||||||
FormControl,
|
FormControl,
|
||||||
FormControlLabel,
|
FormControlLabel,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
makeStyles,
|
|
||||||
Radio,
|
Radio,
|
||||||
RadioGroup,
|
RadioGroup,
|
||||||
TextField,
|
TextField,
|
||||||
Theme,
|
} from "@mui/material";
|
||||||
} from "@material-ui/core";
|
import React, { useState } from "react";
|
||||||
import AddIcon from "@material-ui/icons/Add";
|
|
||||||
import { Alert } from "@material-ui/lab";
|
|
||||||
import React from "react";
|
|
||||||
import { SCOPE } from "./Rules";
|
|
||||||
|
|
||||||
const SET_SCOPE = gql`
|
import { ScopeDocument, ScopeQuery, ScopeRule, useSetScopeMutation } from "lib/graphql/generated";
|
||||||
mutation SetScope($scope: [ScopeRuleInput!]!) {
|
|
||||||
setScope(scope: $scope) {
|
|
||||||
url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
|
||||||
createStyles({
|
|
||||||
ruleExpression: {
|
|
||||||
fontFamily: "'JetBrains Mono', monospace",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
function AddRule(): JSX.Element {
|
function AddRule(): JSX.Element {
|
||||||
const classes = useStyles();
|
const [ruleType, setRuleType] = useState("url");
|
||||||
|
const [expression, setExpression] = useState("");
|
||||||
const [ruleType, setRuleType] = React.useState("url");
|
|
||||||
const [expression, setExpression] = React.useState(null);
|
|
||||||
|
|
||||||
const client = useApolloClient();
|
const client = useApolloClient();
|
||||||
const [setScope, { error, loading }] = useMutation(SET_SCOPE, {
|
const [setScope, { error, loading }] = useSetScopeMutation({
|
||||||
onError() {},
|
onCompleted({ setScope }) {
|
||||||
onCompleted() {
|
|
||||||
expression.value = "";
|
|
||||||
},
|
|
||||||
update(_, { data: { setScope } }) {
|
|
||||||
client.writeQuery({
|
client.writeQuery({
|
||||||
query: SCOPE,
|
query: ScopeDocument,
|
||||||
data: { scope: setScope },
|
data: { scope: setScope },
|
||||||
});
|
});
|
||||||
|
setExpression("");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -59,21 +36,20 @@ function AddRule(): JSX.Element {
|
|||||||
};
|
};
|
||||||
const handleSubmit = (e: React.SyntheticEvent) => {
|
const handleSubmit = (e: React.SyntheticEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let scope = [];
|
let scope: ScopeRule[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = client.readQuery({
|
const data = client.readQuery<ScopeQuery>({
|
||||||
query: SCOPE,
|
query: ScopeDocument,
|
||||||
});
|
});
|
||||||
|
if (data) {
|
||||||
scope = data.scope;
|
scope = data.scope;
|
||||||
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
setScope({
|
setScope({
|
||||||
variables: {
|
variables: {
|
||||||
scope: [
|
scope: [...scope.map(({ url }) => ({ url })), { url: expression }],
|
||||||
...scope.map(({ url }) => ({ url })),
|
|
||||||
{ url: expression.value },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -87,15 +63,10 @@ function AddRule(): JSX.Element {
|
|||||||
)}
|
)}
|
||||||
<form onSubmit={handleSubmit} autoComplete="off">
|
<form onSubmit={handleSubmit} autoComplete="off">
|
||||||
<FormControl fullWidth>
|
<FormControl fullWidth>
|
||||||
<FormLabel color="secondary" component="legend">
|
<FormLabel color="primary" component="legend">
|
||||||
Rule Type
|
Rule Type
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<RadioGroup
|
<RadioGroup row name="ruleType" value={ruleType} onChange={handleTypeChange}>
|
||||||
row
|
|
||||||
name="ruleType"
|
|
||||||
value={ruleType}
|
|
||||||
onChange={handleTypeChange}
|
|
||||||
>
|
|
||||||
<FormControlLabel value="url" control={<Radio />} label="URL" />
|
<FormControlLabel value="url" control={<Radio />} label="URL" />
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -104,20 +75,17 @@ function AddRule(): JSX.Element {
|
|||||||
label="Expression"
|
label="Expression"
|
||||||
placeholder="^https:\/\/(.*)example.com(.*)"
|
placeholder="^https:\/\/(.*)example.com(.*)"
|
||||||
helperText="Regular expression to match on."
|
helperText="Regular expression to match on."
|
||||||
color="secondary"
|
color="primary"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
required
|
required
|
||||||
|
value={expression}
|
||||||
|
onChange={(e) => setExpression(e.target.value)}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
className: classes.ruleExpression,
|
sx: { fontFamily: "'JetBrains Mono', monospace" },
|
||||||
}}
|
}}
|
||||||
InputLabelProps={{
|
InputLabelProps={{
|
||||||
shrink: true,
|
shrink: true,
|
||||||
}}
|
}}
|
||||||
inputProps={{
|
|
||||||
ref: (node) => {
|
|
||||||
setExpression(node);
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
margin="normal"
|
margin="normal"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -125,7 +93,7 @@ function AddRule(): JSX.Element {
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="secondary"
|
color="primary"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
startIcon={loading ? <CircularProgress size={22} /> : <AddIcon />}
|
startIcon={loading ? <CircularProgress size={22} /> : <AddIcon />}
|
||||||
>
|
>
|
@ -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 {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Chip,
|
Chip,
|
||||||
@ -8,26 +10,25 @@ import {
|
|||||||
ListItemSecondaryAction,
|
ListItemSecondaryAction,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@material-ui/core";
|
} from "@mui/material";
|
||||||
import CodeIcon from "@material-ui/icons/Code";
|
|
||||||
import DeleteIcon from "@material-ui/icons/Delete";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { SCOPE } from "./Rules";
|
|
||||||
|
|
||||||
const SET_SCOPE = gql`
|
import { ScopeDocument, ScopeQuery, useSetScopeMutation } from "lib/graphql/generated";
|
||||||
mutation SetScope($scope: [ScopeRuleInput!]!) {
|
|
||||||
setScope(scope: $scope) {
|
|
||||||
url
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
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 client = useApolloClient();
|
||||||
const [setScope, { loading }] = useMutation(SET_SCOPE, {
|
const [setScope, { loading }] = useSetScopeMutation({
|
||||||
update(_, { data: { setScope } }) {
|
onCompleted({ setScope }) {
|
||||||
client.writeQuery({
|
client.writeQuery({
|
||||||
query: SCOPE,
|
query: ScopeDocument,
|
||||||
data: { scope: setScope },
|
data: { scope: setScope },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -65,8 +66,8 @@ function RuleListItem({ scope, rule, index }): JSX.Element {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RuleListItemText({ rule }): JSX.Element {
|
function RuleListItemText({ rule }: { rule: ScopeRule }): JSX.Element {
|
||||||
let text: JSX.Element;
|
let text: JSX.Element = <div></div>;
|
||||||
|
|
||||||
if (rule.url) {
|
if (rule.url) {
|
||||||
text = <code>{rule.url}</code>;
|
text = <code>{rule.url}</code>;
|
||||||
@ -77,10 +78,14 @@ function RuleListItemText({ rule }): JSX.Element {
|
|||||||
return <ListItemText>{text}</ListItemText>;
|
return <ListItemText>{text}</ListItemText>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function RuleTypeChip({ rule }): JSX.Element {
|
function RuleTypeChip({ rule }: { rule: ScopeRule }): JSX.Element {
|
||||||
|
let label = "Unknown";
|
||||||
|
|
||||||
if (rule.url) {
|
if (rule.url) {
|
||||||
return <Chip label="URL" variant="outlined" />;
|
label = "URL";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return <Chip label={label} variant="outlined" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default RuleListItem;
|
export default RuleListItem;
|
31
admin/src/features/scope/components/Rules.tsx
Normal 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;
|
5
admin/src/features/scope/graphql/scope.graphql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
query Scope {
|
||||||
|
scope {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
5
admin/src/features/scope/graphql/setScope.graphql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
mutation SetScope($scope: [ScopeRuleInput!]!) {
|
||||||
|
setScope(scope: $scope) {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
220
admin/src/features/sender/components/EditRequest.tsx
Normal 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;
|
35
admin/src/features/sender/components/History.tsx
Normal 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;
|
21
admin/src/features/sender/components/Sender.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
mutation CreateOrUpdateSenderRequest($request: SenderRequestInput!) {
|
||||||
|
createOrUpdateSenderRequest(request: $request) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
mutation CreateSenderRequestFromHttpRequestLog($id: ID!) {
|
||||||
|
createSenderRequestFromHttpRequestLog(id: $id) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
5
admin/src/features/sender/graphql/sendRequest.graphql
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
mutation SendRequest($id: ID!) {
|
||||||
|
sendRequest(id: $id) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
26
admin/src/features/sender/graphql/senderRequest.graphql
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
12
admin/src/features/sender/graphql/senderRequests.graphql
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
query GetSenderRequests {
|
||||||
|
senderRequests {
|
||||||
|
id
|
||||||
|
url
|
||||||
|
method
|
||||||
|
response {
|
||||||
|
id
|
||||||
|
statusCode
|
||||||
|
statusReason
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
3
admin/src/features/sender/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import Sender from "./components/Sender";
|
||||||
|
|
||||||
|
export default Sender;
|
307
admin/src/features/settings/components/Settings.tsx
Normal 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 Hetty’s 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>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
mutation UpdateInterceptSettings($input: UpdateInterceptSettingsInput!) {
|
||||||
|
updateInterceptSettings(input: $input) {
|
||||||
|
requestsEnabled
|
||||||
|
responsesEnabled
|
||||||
|
requestFilter
|
||||||
|
responseFilter
|
||||||
|
}
|
||||||
|
}
|
20
admin/src/lib/ActiveProjectContext.tsx
Normal 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);
|
||||||
|
}
|
22
admin/src/lib/InterceptedRequestsContext.tsx
Normal 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);
|
||||||
|
}
|
@ -1,10 +1,10 @@
|
|||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogContentText from "@mui/material/DialogContentText";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import Button from "@material-ui/core/Button";
|
|
||||||
import Dialog from "@material-ui/core/Dialog";
|
|
||||||
import DialogActions from "@material-ui/core/DialogActions";
|
|
||||||
import DialogContent from "@material-ui/core/DialogContent";
|
|
||||||
import DialogContentText from "@material-ui/core/DialogContentText";
|
|
||||||
import DialogTitle from "@material-ui/core/DialogTitle";
|
|
||||||
|
|
||||||
export function useConfirmationDialog() {
|
export function useConfirmationDialog() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@ -38,12 +38,10 @@ export function ConfirmationDialog(props: ConfirmationDialog) {
|
|||||||
>
|
>
|
||||||
<DialogTitle id="alert-dialog-title">Are you sure?</DialogTitle>
|
<DialogTitle id="alert-dialog-title">Are you sure?</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText id="alert-dialog-description">
|
<DialogContentText id="alert-dialog-description">{children}</DialogContentText>
|
||||||
{children}
|
|
||||||
</DialogContentText>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={onClose}>Abort</Button>
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
<Button onClick={confirm} autoFocus>
|
<Button onClick={confirm} autoFocus>
|
||||||
Confirm
|
Confirm
|
||||||
</Button>
|
</Button>
|
48
admin/src/lib/components/Editor.tsx
Normal 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;
|
25
admin/src/lib/components/HttpStatusIcon.tsx
Normal 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} />;
|
||||||
|
}
|
187
admin/src/lib/components/KeyValuePair.tsx
Normal 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;
|
94
admin/src/lib/components/Link.tsx
Normal 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;
|
91
admin/src/lib/components/RequestTabs.tsx
Normal 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;
|
128
admin/src/lib/components/RequestsTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
38
admin/src/lib/components/Response.tsx
Normal 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;
|
38
admin/src/lib/components/ResponseStatus.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
78
admin/src/lib/components/ResponseTabs.tsx
Normal 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;
|
53
admin/src/lib/components/SplitPane.tsx
Normal 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;
|
122
admin/src/lib/components/UrlBar.tsx
Normal 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;
|