Compare commits

...

31 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@ -2,3 +2,7 @@
/admin/.next
/admin/dist
/admin/node_modules
/dist
/docs
/hetty
/cmd/hetty/admin

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

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

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

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

9
.gitignore vendored
View File

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

48
.golangci.yml Normal file
View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020 David Stotijn
Copyright (c) 2021 David Stotijn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

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

View File

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

51
admin/.eslintrc.json Normal file
View File

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

4
admin/.prettierignore Normal file
View File

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

3
admin/.prettierrc.json Normal file
View File

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

9
admin/gqlcodegen.yml Normal file
View File

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

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

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,49 @@
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 {
console.log(content);
return (
<MonacoEditor
language={languageForContentType(contentType)}
theme="vs-dark"
options={{ ...defaultMonacoOptions, ...monacoOptions }}
value={content}
onChange={onChange}
/>
);
}
export default Editor;

View File

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

View File

@ -0,0 +1,203 @@
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 function sortKeyValuePairs(items: KeyValuePair[]): KeyValuePair[] {
const sorted = [...items];
sorted.sort((a, b) => {
if (a.key < b.key) {
return -1;
}
if (a.key > b.key) {
return 1;
}
return 0;
});
return sorted;
}
export default KeyValuePairTable;

View File

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

View File

@ -0,0 +1,125 @@
import {
TableContainer,
Table,
TableHead,
TableRow,
TableCell,
TableBody,
styled,
TableCellProps,
TableRowProps,
} from "@mui/material";
import HttpStatusIcon from "./HttpStatusIcon";
import { HttpMethod } from "lib/graphql/generated";
const baseCellStyle = {
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
} as const;
const MethodTableCell = styled(TableCell)<TableCellProps>(() => ({
...baseCellStyle,
width: "100px",
}));
const OriginTableCell = styled(TableCell)<TableCellProps>(() => ({
...baseCellStyle,
maxWidth: "100px",
}));
const PathTableCell = styled(TableCell)<TableCellProps>(() => ({
...baseCellStyle,
maxWidth: "200px",
}));
const StatusTableCell = styled(TableCell)<TableCellProps>(() => ({
...baseCellStyle,
width: "100px",
}));
const RequestTableRow = styled(TableRow)<TableRowProps>(() => ({
"&:hover": {
cursor: "pointer",
},
}));
interface HttpRequest {
id: string;
url: string;
method: HttpMethod;
response?: HttpResponse | null;
}
interface HttpResponse {
statusCode: number;
statusReason: string;
body?: string;
}
interface Props {
requests: HttpRequest[];
activeRowId?: string;
onRowClick?: (id: string) => void;
onContextMenu?: (e: React.MouseEvent, id: string) => void;
}
export default function RequestsTable(props: Props): JSX.Element {
const { requests, activeRowId, 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>
</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>
</RequestTableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);
}
function Status({ code, reason }: { code: number; reason: string }): JSX.Element {
return (
<div>
<HttpStatusIcon status={code} />{" "}
<code>
{code} {reason}
</code>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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