mirror of
https://github.com/dstotijn/hetty.git
synced 2025-07-01 18:47:29 -04:00
Compare commits
58 Commits
Author | SHA1 | Date | |
---|---|---|---|
565c370bb8 | |||
2dc6538a3b | |||
aa8ddf4122 | |||
73ebb89863 | |||
1489cb16bf | |||
d84d2d0905 | |||
8a3b3cbf02 | |||
b3225bfb99 | |||
4e2eaea499 | |||
8122b2552d | |||
569f7bc76f | |||
ca3a729c36 | |||
ad3dc0da70 | |||
49547f535f | |||
e42e1c212b | |||
6e38b16cf2 | |||
078bf303be | |||
a42f003919 | |||
50c2eac42d | |||
4ead501f53 | |||
d2e97f2acc | |||
ad3fa7d379 | |||
8c2efdb285 | |||
194d727f4f | |||
8ab65fb55f | |||
5bce912e89 | |||
16910bb637 | |||
e59b9d6663 | |||
efc115e961 | |||
471fa212ef | |||
07ef2f9090 | |||
f7550d649a | |||
dbc25774c2 | |||
430670ab54 | |||
f6789fa245 | |||
81fbfe4cb3 | |||
6931d63250 | |||
71e87d3cd3 | |||
0ffbb618fa | |||
c01f190fc8 | |||
ad98dd7f01 | |||
0d04996f06 | |||
98dacbe849 | |||
fedb425381 | |||
ca707d17ea | |||
13240109b6 | |||
fa41e9c46c | |||
5f4bff0155 | |||
073bcea565 | |||
6244d4aa74 | |||
cf687f0bd3 | |||
6fad74c0a5 | |||
248001ec8a | |||
ba7d88dfc5 | |||
d48f1f058d | |||
46caa05d20 | |||
c5bfb96454 | |||
f97e0526d7 |
@ -1,5 +1,8 @@
|
|||||||
**/rice-box.go
|
|
||||||
/admin/.env
|
/admin/.env
|
||||||
/admin/.next
|
/admin/.next
|
||||||
/admin/dist
|
/admin/dist
|
||||||
/admin/node_modules
|
/admin/node_modules
|
||||||
|
/dist
|
||||||
|
/docs
|
||||||
|
/hetty
|
||||||
|
/cmd/hetty/admin
|
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**Desktop (please complete the following information):**
|
||||||
|
- OS: [e.g. iOS]
|
||||||
|
- Browser [e.g. chrome, safari]
|
||||||
|
- Version [e.g. 22]
|
||||||
|
|
||||||
|
**Smartphone (please complete the following information):**
|
||||||
|
- Device: [e.g. iPhone6]
|
||||||
|
- OS: [e.g. iOS8.1]
|
||||||
|
- Browser [e.g. stock browser, safari]
|
||||||
|
- Version [e.g. 22]
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here.
|
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Ask a question
|
||||||
|
url: https://github.com/dstotijn/hetty/discussions
|
||||||
|
about: Ask questions and discuss with other community members
|
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
52
.github/workflows/build-test.yml
vendored
Normal file
52
.github/workflows/build-test.yml
vendored
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
name: Build and Test
|
||||||
|
on: [push, pull_request]
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
go: ["1.17", "1.16"]
|
||||||
|
name: Go ${{ matrix.go }} - Build
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-go@v2
|
||||||
|
with:
|
||||||
|
go-version: ${{ matrix.go }}
|
||||||
|
- uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: "14"
|
||||||
|
- 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/...
|
9
.gitignore
vendored
9
.gitignore
vendored
@ -1,5 +1,6 @@
|
|||||||
**/rice-box.go
|
/.vscode
|
||||||
dist
|
/dist
|
||||||
hetty
|
/hetty
|
||||||
hetty.bolt
|
/cmd/hetty/admin
|
||||||
|
*.pem
|
||||||
*.test
|
*.test
|
48
.golangci.yml
Normal file
48
.golangci.yml
Normal 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"
|
@ -1,29 +1,39 @@
|
|||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- make clean
|
- make clean
|
||||||
- go mod download
|
- sh -c "NEXT_PUBLIC_VERSION={{ .Version}} make build-admin"
|
||||||
- go generate ./...
|
- go mod tidy
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- main: ./cmd/hetty
|
- env:
|
||||||
env:
|
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
|
main: ./cmd/hetty
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X main.version={{.Version}}
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- windows
|
- windows
|
||||||
- darwin
|
- darwin
|
||||||
hooks:
|
goarch:
|
||||||
pre: make embed
|
- amd64
|
||||||
|
- arm64
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- replacements:
|
- replacements:
|
||||||
darwin: Darwin
|
darwin: macOS
|
||||||
linux: Linux
|
linux: Linux
|
||||||
windows: Windows
|
windows: Windows
|
||||||
386: i386
|
|
||||||
amd64: x86_64
|
amd64: x86_64
|
||||||
|
format_overrides:
|
||||||
|
- goos: windows
|
||||||
|
format: zip
|
||||||
|
|
||||||
checksum:
|
checksum:
|
||||||
name_template: "checksums.txt"
|
name_template: "checksums.txt"
|
||||||
|
|
||||||
snapshot:
|
snapshot:
|
||||||
name_template: "{{ .Tag }}-next"
|
name_template: "{{ incpatch .Version }}-next"
|
||||||
|
|
||||||
changelog:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
filters:
|
filters:
|
||||||
|
76
CODE_OF_CONDUCT.md
Normal file
76
CODE_OF_CONDUCT.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
In the interest of fostering an open and welcoming environment, we as
|
||||||
|
contributors and maintainers pledge to making participation in our project and
|
||||||
|
our community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||||
|
level of experience, education, socio-economic status, nationality, personal
|
||||||
|
appearance, race, religion, or sexual identity and orientation.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to creating a positive environment
|
||||||
|
include:
|
||||||
|
|
||||||
|
* Using welcoming and inclusive language
|
||||||
|
* Being respectful of differing viewpoints and experiences
|
||||||
|
* Gracefully accepting constructive criticism
|
||||||
|
* Focusing on what is best for the community
|
||||||
|
* Showing empathy towards other community members
|
||||||
|
|
||||||
|
Examples of unacceptable behavior by participants include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||||
|
advances
|
||||||
|
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or electronic
|
||||||
|
address, without explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Our Responsibilities
|
||||||
|
|
||||||
|
Project maintainers are responsible for clarifying the standards of acceptable
|
||||||
|
behavior and are expected to take appropriate and fair corrective action in
|
||||||
|
response to any instances of unacceptable behavior.
|
||||||
|
|
||||||
|
Project maintainers have the right and responsibility to remove, edit, or
|
||||||
|
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||||
|
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||||
|
permanently any contributor for other behaviors that they deem inappropriate,
|
||||||
|
threatening, offensive, or harmful.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies both within project spaces and in public spaces
|
||||||
|
when an individual is representing the project or its community. Examples of
|
||||||
|
representing a project or community include using an official project e-mail
|
||||||
|
address, posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event. Representation of a project may be
|
||||||
|
further defined and clarified by project maintainers.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported by contacting the project team at dstotijn@gmail.com. All
|
||||||
|
complaints will be reviewed and investigated and will result in a response that
|
||||||
|
is deemed necessary and appropriate to the circumstances. The project team is
|
||||||
|
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||||
|
Further details of specific enforcement policies may be posted separately.
|
||||||
|
|
||||||
|
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||||
|
faith may face temporary or permanent repercussions as determined by other
|
||||||
|
members of the project's leadership.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||||
|
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see
|
||||||
|
https://www.contributor-covenant.org/faq
|
35
CONTRIBUTING.md
Normal file
35
CONTRIBUTING.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Contribution Guidelines
|
||||||
|
|
||||||
|
Thank you for taking an interest in Hetty! If you want to contribute to the
|
||||||
|
project, please read the guidelines below to ensure a smooth develop experience.
|
||||||
|
|
||||||
|
## Code of conduct
|
||||||
|
|
||||||
|
Please first read the [code of conduct](CODE_OF_CONDUCT.md), and abide to it
|
||||||
|
whenever you interact with the community.
|
||||||
|
|
||||||
|
## Issues
|
||||||
|
|
||||||
|
Use [issues](https://github.com/dstotijn/hetty/issues) for reporting bugs,
|
||||||
|
adding feature requests and giving context to PRs you submit. Please use [labels](https://github.com/dstotijn/hetty/labels)
|
||||||
|
in favor of category prefixes in issue titles. To keep the issue tracker
|
||||||
|
focused on development, use [discussions](https://github.com/dstotijn/hetty/discussions)
|
||||||
|
for usage questions and non-code related discourse.
|
||||||
|
|
||||||
|
Before submitting new feature requests, check out the Kanban board for the
|
||||||
|
status of on-going work. There might already be a card/issue.
|
||||||
|
|
||||||
|
## Pull requests
|
||||||
|
|
||||||
|
Before submitting a pull request that introduces a new feature or significantly
|
||||||
|
changes the behavior of Hetty, please consider first using [discussions](https://github.com/dstotijn/hetty/discussions)
|
||||||
|
or commenting on a relevant existing issue to share what you have in mind.
|
||||||
|
Because the project is in an early stage, this is especially important; there
|
||||||
|
are still a lot of major design decisions to be made. Until the foundation has
|
||||||
|
solidified, design and implementation leading up to the first milestone (v1.0)
|
||||||
|
is highly in flux, and your work might not align/be applicable for what the
|
||||||
|
maintainers have envisioned.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
_Todo: Write steps for setting up local development environment._
|
30
Dockerfile
30
Dockerfile
@ -1,14 +1,6 @@
|
|||||||
ARG GO_VERSION=1.15
|
ARG GO_VERSION=1.17
|
||||||
ARG CGO_ENABLED=0
|
ARG NODE_VERSION=16.13
|
||||||
ARG NODE_VERSION=14.11
|
ARG ALPINE_VERSION=3.15
|
||||||
|
|
||||||
FROM golang:${GO_VERSION}-alpine AS go-builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
COPY cmd ./cmd
|
|
||||||
COPY pkg ./pkg
|
|
||||||
RUN go build -o hetty ./cmd/hetty
|
|
||||||
|
|
||||||
FROM node:${NODE_VERSION}-alpine AS node-builder
|
FROM node:${NODE_VERSION}-alpine AS node-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@ -18,11 +10,21 @@ COPY admin/ .
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
RUN yarn run export
|
RUN yarn run export
|
||||||
|
|
||||||
FROM alpine:3.12
|
FROM golang:${GO_VERSION}-alpine AS go-builder
|
||||||
|
ARG HETTY_VERSION=0.0.0
|
||||||
|
ENV CGO_ENABLED=0
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY cmd ./cmd
|
||||||
|
COPY pkg ./pkg
|
||||||
|
COPY --from=node-builder /app/dist ./cmd/hetty/admin
|
||||||
|
RUN go build -ldflags="-s -w -X main.version=${HETTY_VERSION}" ./cmd/hetty
|
||||||
|
|
||||||
|
FROM alpine:${ALPINE_VERSION}
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=go-builder /app/hetty .
|
COPY --from=go-builder /app/hetty .
|
||||||
COPY --from=node-builder /app/dist admin
|
|
||||||
|
|
||||||
ENTRYPOINT ["./hetty", "-adminPath=./admin"]
|
ENTRYPOINT ["./hetty"]
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2020 David Stotijn
|
Copyright (c) 2021 David Stotijn
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
35
Makefile
35
Makefile
@ -1,20 +1,21 @@
|
|||||||
setup:
|
export CGO_ENABLED = 0
|
||||||
go mod download
|
export NEXT_TELEMETRY_DISABLED = 1
|
||||||
go generate ./...
|
|
||||||
.PHONY: setup
|
|
||||||
|
|
||||||
embed:
|
|
||||||
go install github.com/GeertJohan/go.rice/rice
|
|
||||||
cd cmd/hetty && rice embed-go
|
|
||||||
.PHONY: embed
|
|
||||||
|
|
||||||
build: embed
|
|
||||||
go build ./cmd/hetty
|
|
||||||
.PHONY: build
|
|
||||||
|
|
||||||
clean:
|
|
||||||
rm -rf cmd/hetty/rice-box.go
|
|
||||||
.PHONY: clean
|
.PHONY: clean
|
||||||
|
clean:
|
||||||
|
rm -f hetty
|
||||||
|
rm -rf ./cmd/hetty/admin
|
||||||
|
rm -rf ./admin/node_modules
|
||||||
|
rm -rf ./admin/dist
|
||||||
|
rm -rf ./admin/.next
|
||||||
|
|
||||||
release:
|
.PHONY: build-admin
|
||||||
goreleaser -p 1
|
build-admin:
|
||||||
|
cd admin && \
|
||||||
|
yarn install --frozen-lockfile && \
|
||||||
|
yarn run export && \
|
||||||
|
mv dist ../cmd/hetty/admin
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build: build-admin
|
||||||
|
go build ./cmd/hetty
|
278
README.md
278
README.md
@ -1,129 +1,243 @@
|
|||||||
<img src="https://i.imgur.com/AT71SBq.png" width="346" />
|
<h1>
|
||||||
|
<a href="https://github.com/dstotijn/hetty">
|
||||||
|
<img src="https://hetty.xyz/assets/logo.png" width="293">
|
||||||
|
</a>
|
||||||
|
</h1>
|
||||||
|
|
||||||
> Hetty is an HTTP toolkit for security research. It aims to become an open source
|
[](https://github.com/dstotijn/hetty/releases/latest)
|
||||||
> alternative to commercial software like Burp Suite Pro, with powerful features
|
[](https://github.com/dstotijn/hetty/actions/workflows/build-test.yml)
|
||||||
> tailored to the needs of the infosec and bug bounty community.
|

|
||||||
|
[](https://github.com/dstotijn/hetty/blob/master/LICENSE)
|
||||||
|
[](https://hetty.xyz/)
|
||||||
|
|
||||||
<img src="https://i.imgur.com/ZZ6o83X.png">
|
**Hetty** is an HTTP toolkit for security research. It aims to become an open
|
||||||
|
source alternative to commercial software like Burp Suite Pro, with powerful
|
||||||
|
features tailored to the needs of the infosec and bug bounty community.
|
||||||
|
|
||||||
## Features/to do
|
<img src="https://hetty.xyz/assets/hetty_v0.2.0_header.png">
|
||||||
|
|
||||||
- [x] HTTP man-in-the-middle (MITM) proxy and GraphQL server.
|
## Features
|
||||||
- [x] Web interface (Next.js) with proxy log viewer.
|
|
||||||
- [ ] Add scope support to the proxy.
|
- Man-in-the-middle (MITM) HTTP/1.1 proxy with logs
|
||||||
- [ ] Full text search (with regex) in proxy log viewer.
|
- Project based database storage (SQLite)
|
||||||
- [ ] Project management.
|
- Scope support
|
||||||
- [ ] Sender module for sending manual HTTP requests, either from scratch or based
|
- Headless management API using GraphQL
|
||||||
off requests from the proxy log.
|
- Embedded web interface (Next.js)
|
||||||
- [ ] Attacker module for automated sending of HTTP requests. Leverage the concurrency
|
|
||||||
features of Go and its `net/http` package to make it blazingly fast.
|
ℹ️ Hetty is in early development. Additional features are planned
|
||||||
|
for a `v1.0` release. Please see the <a href="https://github.com/dstotijn/hetty/projects/1">backlog</a>
|
||||||
|
for details.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
📖 [Read the docs.](https://hetty.xyz/)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Hetty is packaged on GitHub as a single binary, with the web interface resources
|
Hetty compiles to a self-contained binary, with an embedded BadgerDB database
|
||||||
embedded.
|
and web based admin interface.
|
||||||
|
|
||||||
👉 You can find downloads for Linux, macOS and Windows on the [releases page](https://github.com/dstotijn/hetty/releases).
|
### Install pre-built release (recommended)
|
||||||
|
|
||||||
### Alternatives:
|
👉 Downloads for Linux, macOS and Windows are available on the [releases page](https://github.com/dstotijn/hetty/releases).
|
||||||
|
|
||||||
**Build from source**
|
### Build from source
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
|
|
||||||
|
- [Go 1.16](https://golang.org/)
|
||||||
|
- [Yarn](https://yarnpkg.com/)
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ GO111MODULE=auto go get -u -v github.com/dstotijn/hetty/cmd/hetty
|
$ git clone git@github.com:dstotijn/hetty.git
|
||||||
|
$ cd hetty
|
||||||
|
$ make build
|
||||||
```
|
```
|
||||||
|
|
||||||
Then export the Next.js frontend app:
|
### 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 projects database, mount a volume:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ cd admin
|
$ mkdir -p $HOME/.hetty
|
||||||
$ yarn install
|
$ docker run -v $HOME/.hetty:/root/.hetty -p 8080:8080 dstotijn/hetty
|
||||||
$ yarn export
|
|
||||||
```
|
|
||||||
|
|
||||||
This will ensure a folder `./admin/dist` exists.
|
|
||||||
Then, you can bundle the frontend app using `rice`.
|
|
||||||
The easiest way to do this is via a supplied `Makefile` command in the root of
|
|
||||||
the project:
|
|
||||||
|
|
||||||
```
|
|
||||||
make build
|
|
||||||
```
|
|
||||||
|
|
||||||
**Docker**
|
|
||||||
|
|
||||||
Alternatively, you can run Hetty via Docker. See: [`dstotijn/hetty`](https://hub.docker.com/r/dstotijn/hetty)
|
|
||||||
on Docker Hub.
|
|
||||||
|
|
||||||
```
|
|
||||||
$ docker run \
|
|
||||||
-v $HOME/.hetty/hetty_key.pem:/root/.hetty/hetty_key.pem \
|
|
||||||
-v $HOME/.hetty/hetty_cert.pem:/root/.hetty/hetty_cert.pem \
|
|
||||||
-v $HOME/.hetty/hetty.bolt:/root/.hetty/hetty.bolt \
|
|
||||||
-p 127.0.0.1:8080:8080 \
|
|
||||||
dstotijn/hetty
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Hetty is packaged as a single binary, with the web interface resources embedded.
|
When Hetty is run, by default it listens on `:8080` and is accessible via
|
||||||
When the program is run, it listens by default on `:8080` and is accessible via
|
|
||||||
http://localhost:8080. Depending on incoming HTTP requests, it either acts as a
|
http://localhost:8080. Depending on incoming HTTP requests, it either acts as a
|
||||||
MITM proxy, or it serves the GraphQL API and web interface (Next.js).
|
MITM proxy, or it serves the API and web interface.
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
To start, ensure `hetty` (downloaded from a release, or manually built) is in your
|
||||||
|
`$PATH` and run:
|
||||||
|
|
||||||
```
|
```
|
||||||
$ Usage of ./hetty:
|
$ hetty
|
||||||
|
```
|
||||||
|
|
||||||
|
An overview of configuration flags:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ hetty -h
|
||||||
|
Usage of ./hetty:
|
||||||
-addr string
|
-addr string
|
||||||
TCP address to listen on, in the form "host:port" (default ":80")
|
TCP address to listen on, in the form "host:port" (default ":8080")
|
||||||
-adminPath string
|
-adminPath string
|
||||||
File path to admin build
|
File path to admin build
|
||||||
-cert string
|
-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")
|
||||||
-db string
|
|
||||||
Database file path (default "hetty.db")
|
|
||||||
-key string
|
-key string
|
||||||
CA private key filepath. Creates a new CA private key if file doesn't exist (default "~/.hetty/hetty_key.pem")
|
CA private key filepath. Creates a new CA private key if file doesn't exist (default "~/.hetty/hetty_key.pem")
|
||||||
|
-db string
|
||||||
|
Database directory path (default "~/.hetty/db")
|
||||||
```
|
```
|
||||||
|
|
||||||
⚠️ _Todo: Write instructions for installing CA certificate in local CA store, and_
|
You should see:
|
||||||
_configuring Hetty to be used as a proxy server._
|
|
||||||
|
```
|
||||||
|
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.
|
||||||
|
|
||||||
|
ℹ️ Detailed documentation is under development and will be available soon.
|
||||||
|
|
||||||
|
## Certificate Setup and Installation
|
||||||
|
|
||||||
|
In order for Hetty to proxy requests going to HTTPS endpoints, a root CA certificate for
|
||||||
|
Hetty will need to be set up. Furthermore, the CA certificate may need to be
|
||||||
|
installed to the host for them to be trusted by your browser. The following steps
|
||||||
|
will cover how you can generate your certificate, provide them to hetty, and how
|
||||||
|
you can install them in your local CA store.
|
||||||
|
|
||||||
|
⚠️ _This process was done on a Linux machine but should_
|
||||||
|
_provide guidance on Windows and macOS as well._
|
||||||
|
|
||||||
|
### Generating CA certificates
|
||||||
|
|
||||||
|
You can generate a CA keypair two different ways. The first is bundled directly
|
||||||
|
with Hetty, and simplifies the process immensely. The alternative is using OpenSSL
|
||||||
|
to generate them, which provides more control over expiration time and cryptography
|
||||||
|
used, but requires you install the OpenSSL tooling. The first is suggested for any
|
||||||
|
beginners trying to get started.
|
||||||
|
|
||||||
|
#### Generating CA certificates with hetty
|
||||||
|
|
||||||
|
Hetty will generate the default key and certificate on its own if none are supplied
|
||||||
|
or found in `~/.hetty/` when first running the CLI. To generate a default key and
|
||||||
|
certificate with hetty, simply run the command with no arguments
|
||||||
|
|
||||||
|
```sh
|
||||||
|
hetty
|
||||||
|
```
|
||||||
|
|
||||||
|
You should now have a key and certificate located at `~/.hetty/hetty_key.pem` and
|
||||||
|
`~/.hetty/hetty_cert.pem` respectively.
|
||||||
|
|
||||||
|
#### Generating CA certificates with OpenSSL
|
||||||
|
|
||||||
|
You can start off by generating a new key and CA certificate which will both expire
|
||||||
|
after a month.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir ~/.hetty
|
||||||
|
openssl req -newkey rsa:2048 -new -nodes -x509 -days 31 -keyout ~/.hetty/hetty_key.pem -out ~/.hetty/hetty_cert.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
The default location which `hetty` will check for the key and CA certificate is under
|
||||||
|
`~/.hetty/`, at `hetty_key.pem` and `hetty_cert.pem` respectively. You can move them
|
||||||
|
here and `hetty` will detect them automatically. Otherwise, you can specify the
|
||||||
|
location of these as arguments to `hetty`.
|
||||||
|
|
||||||
|
```
|
||||||
|
hetty -key key.pem -cert cert.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trusting the CA certificate
|
||||||
|
|
||||||
|
In order for your browser to allow traffic to the local Hetty proxy, you may need
|
||||||
|
to install these certificates to your local CA store.
|
||||||
|
|
||||||
|
On Ubuntu, you can update your local CA store with the certificate by running the
|
||||||
|
following commands:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo cp ~/.hetty/hetty_cert.pem /usr/local/share/ca-certificates/hetty.crt
|
||||||
|
sudo update-ca-certificates
|
||||||
|
```
|
||||||
|
|
||||||
|
On Windows, you would add your certificate by using the Certificate Manager. You
|
||||||
|
can launch that by running the command:
|
||||||
|
|
||||||
|
```batch
|
||||||
|
certmgr.msc
|
||||||
|
```
|
||||||
|
|
||||||
|
On macOS, you can add your certificate by using the Keychain Access program. This
|
||||||
|
can be found under `Application/Utilities/Keychain Access.app`. After opening this,
|
||||||
|
drag the certificate into the app. Next, open the certificate in the app, enter the
|
||||||
|
_Trust_ section, and under _When using this certificate_ select _Always Trust_.
|
||||||
|
|
||||||
|
_Note: Various Linux distributions may require other steps or commands for updating_
|
||||||
|
_their certificate authority. See the documentation relevant to your distribution for_
|
||||||
|
_more information on how to update the system to trust your self-signed certificate._
|
||||||
|
|
||||||
## Vision and roadmap
|
## Vision and roadmap
|
||||||
|
|
||||||
The project has just gotten underway, and as such I haven’t had time yet to do a
|
|
||||||
write-up on its mission and roadmap. A short summary/braindump:
|
|
||||||
|
|
||||||
- Fast core/engine, built with Go, with a minimal memory footprint.
|
- Fast core/engine, built with Go, with a minimal memory footprint.
|
||||||
- GraphQL server to interact with the backend.
|
- Easy to use admin interface, built with Next.js and Material UI.
|
||||||
- Easy to use web interface, built with Next.js and Material UI.
|
- Headless management, via GraphQL API.
|
||||||
- Extensibility is top of mind. All modules are written as Go packages, to
|
- Extensibility is top of mind. All modules are written as Go packages, to
|
||||||
be used by the main `hetty` program, but also usable as libraries for other software.
|
be used by Hetty, but also as libraries by other software.
|
||||||
Aside from the GraphQL server, it should (eventually) be possible to also use
|
- Pluggable architecture for MITM proxy, projects, scope. It should be possible.
|
||||||
it as a CLI tool.
|
to build a plugin system in the (near) future.
|
||||||
- Pluggable architecture for the MITM proxy and future modules, making it
|
- Based on feedback and real-world usage of pentesters and bug bounty hunters.
|
||||||
possible for hook into the core engine.
|
- Aim for a relatively small core feature set that the majority of security researchers need.
|
||||||
- I’ve chosen [Cayley](https://cayley.io/) as the graph database (backed by
|
|
||||||
BoltDB storage on disk) for now (not sure if it will work in the long run).
|
|
||||||
The benefit is that Cayley (also written in Go)
|
|
||||||
is embedded as a library. Because of this, the complete application is self contained
|
|
||||||
in a single running binary.
|
|
||||||
- Talk to the community, and focus on the features that the majority.
|
|
||||||
Less features means less code to maintain.
|
|
||||||
|
|
||||||
## Status
|
## Support
|
||||||
|
|
||||||
The project is currently under active development. Please star/follow and check
|
Use [issues](https://github.com/dstotijn/hetty/issues) for bug reports and
|
||||||
back soon. 🤗
|
feature requests, and [discussions](https://github.com/dstotijn/hetty/discussions)
|
||||||
|
for questions and troubleshooting.
|
||||||
|
|
||||||
|
## Community
|
||||||
|
|
||||||
|
💬 [Join the Hetty Discord server](https://discord.gg/3HVsj5pTFP).
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Want to contribute? Great! Please check the [Contribution Guidelines](CONTRIBUTING.md)
|
||||||
|
for details.
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
|
|
||||||
Thanks to the [Hacker101 community on Discord](https://discordapp.com/channels/514337135491416065)
|
- Thanks to the [Hacker101 community on Discord](https://www.hacker101.com/discord)
|
||||||
for all the encouragement to actually start building this thing!
|
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
|
## License
|
||||||
|
|
||||||
[MIT](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)
|
||||||
|
6
admin/.eslintrc.json
Normal file
6
admin/.eslintrc.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals",
|
||||||
|
"rules": {
|
||||||
|
"@next/next/no-css-tags": "off"
|
||||||
|
}
|
||||||
|
}
|
4
admin/.prettierignore
Normal file
4
admin/.prettierignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
/build
|
||||||
|
/coverage
|
3
admin/.prettierrc.json
Normal file
3
admin/.prettierrc.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 120
|
||||||
|
}
|
5
admin/next-env.d.ts
vendored
5
admin/next-env.d.ts
vendored
@ -1,2 +1,5 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
const withCSS = require("@zeit/next-css");
|
// @ts-check
|
||||||
const MonacoWebpackPlugin = require("monaco-editor-webpack-plugin");
|
|
||||||
|
|
||||||
module.exports = withCSS({
|
/**
|
||||||
|
* @type {import('next').NextConfig}
|
||||||
|
**/
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
trailingSlash: true,
|
trailingSlash: true,
|
||||||
async rewrites() {
|
async rewrites() {
|
||||||
return [
|
return [
|
||||||
@ -11,24 +14,6 @@ module.exports = withCSS({
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
webpack: (config) => {
|
};
|
||||||
config.module.rules.push({
|
|
||||||
test: /\.(png|jpg|gif|svg|eot|ttf|woff|woff2)$/,
|
|
||||||
use: {
|
|
||||||
loader: "url-loader",
|
|
||||||
options: {
|
|
||||||
limit: 100000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
config.plugins.push(
|
module.exports = nextConfig;
|
||||||
new MonacoWebpackPlugin({
|
|
||||||
languages: ["html", "json", "javascript"],
|
|
||||||
filename: "static/[name].worker.js",
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
@ -6,31 +6,38 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
"export": "next build && next export -o dist"
|
"export": "next build && next export -o dist"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^3.2.0",
|
"@apollo/client": "^3.2.0",
|
||||||
"@material-ui/core": "^4.11.0",
|
"@emotion/react": "^11.7.1",
|
||||||
"@material-ui/icons": "^4.9.1",
|
"@emotion/server": "^11.4.0",
|
||||||
"@material-ui/lab": "^4.0.0-alpha.56",
|
"@emotion/styled": "^11.6.0",
|
||||||
"@zeit/next-css": "^1.0.1",
|
"@monaco-editor/react": "^4.3.1",
|
||||||
"graphql": "^15.3.0",
|
"@mui/icons-material": "^5.3.1",
|
||||||
"monaco-editor": "^0.20.0",
|
"@mui/lab": "^5.0.0-alpha.66",
|
||||||
"monaco-editor-webpack-plugin": "^1.9.0",
|
"@mui/material": "^5.3.1",
|
||||||
"next": "^9.5.3",
|
"deepmerge": "^4.2.2",
|
||||||
|
"graphql": "^16.2.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"monaco-editor": "^0.31.1",
|
||||||
|
"next": "^12.0.8",
|
||||||
"next-fonts": "^1.0.3",
|
"next-fonts": "^1.0.3",
|
||||||
"react": "^16.13.1",
|
"react": "^17.0.2",
|
||||||
"react-dom": "^16.13.1",
|
"react-dom": "^17.0.2",
|
||||||
"react-monaco-editor": "^0.34.0",
|
|
||||||
"react-syntax-highlighter": "^13.5.3",
|
|
||||||
"typescript": "^4.0.3"
|
"typescript": "^4.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^14.11.1",
|
"@babel/core": "^7.0.0",
|
||||||
"@types/react": "^16.9.49",
|
"@types/lodash": "^4.14.178",
|
||||||
"eslint": "^7.9.0",
|
"@types/node": "^17.0.12",
|
||||||
"eslint-config-prettier": "^6.11.0",
|
"@types/react": "^17.0.38",
|
||||||
"eslint-plugin-prettier": "^3.1.4",
|
"eslint": "^8.7.0",
|
||||||
"prettier": "^2.1.2"
|
"eslint-config-next": "12.0.8",
|
||||||
|
"eslint-config-prettier": "^8.3.0",
|
||||||
|
"eslint-plugin-prettier": "^4.0.0",
|
||||||
|
"prettier": "^2.1.2",
|
||||||
|
"webpack": "^5.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,6 @@
|
|||||||
import { Paper } from "@material-ui/core";
|
import { Paper } from "@mui/material";
|
||||||
|
|
||||||
function CenteredPaper({
|
function CenteredPaper({ children }: { children: React.ReactNode }): JSX.Element {
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}): JSX.Element {
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Paper
|
<Paper
|
||||||
|
@ -1,118 +1,129 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import {
|
||||||
makeStyles,
|
|
||||||
Theme,
|
Theme,
|
||||||
createStyles,
|
|
||||||
useTheme,
|
useTheme,
|
||||||
AppBar,
|
|
||||||
Toolbar,
|
Toolbar,
|
||||||
IconButton,
|
IconButton,
|
||||||
Typography,
|
Typography,
|
||||||
Drawer,
|
|
||||||
Divider,
|
Divider,
|
||||||
List,
|
List,
|
||||||
ListItem,
|
|
||||||
ListItemIcon,
|
|
||||||
ListItemText,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@material-ui/core";
|
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 Link from "next/link";
|
||||||
import MenuIcon from "@material-ui/icons/Menu";
|
import MenuIcon from "@mui/icons-material/Menu";
|
||||||
import HomeIcon from "@material-ui/icons/Home";
|
import HomeIcon from "@mui/icons-material/Home";
|
||||||
import SettingsEthernetIcon from "@material-ui/icons/SettingsEthernet";
|
import SettingsEthernetIcon from "@mui/icons-material/SettingsEthernet";
|
||||||
import SendIcon from "@material-ui/icons/Send";
|
import SendIcon from "@mui/icons-material/Send";
|
||||||
import ChevronLeftIcon from "@material-ui/icons/ChevronLeft";
|
import FolderIcon from "@mui/icons-material/Folder";
|
||||||
import ChevronRightIcon from "@material-ui/icons/ChevronRight";
|
import LocationSearchingIcon from "@mui/icons-material/LocationSearching";
|
||||||
import clsx from "clsx";
|
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||||
|
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
|
||||||
|
|
||||||
export enum Page {
|
export enum Page {
|
||||||
Home,
|
Home,
|
||||||
|
GetStarted,
|
||||||
|
Projects,
|
||||||
ProxySetup,
|
ProxySetup,
|
||||||
ProxyLogs,
|
ProxyLogs,
|
||||||
Sender,
|
Sender,
|
||||||
|
Scope,
|
||||||
}
|
}
|
||||||
|
|
||||||
const drawerWidth = 240;
|
const drawerWidth = 240;
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
const openedMixin = (theme: Theme): CSSObject => ({
|
||||||
createStyles({
|
width: drawerWidth,
|
||||||
root: {
|
transition: theme.transitions.create("width", {
|
||||||
display: "flex",
|
easing: theme.transitions.easing.sharp,
|
||||||
width: "100%",
|
duration: theme.transitions.duration.enteringScreen,
|
||||||
},
|
}),
|
||||||
appBar: {
|
overflowX: "hidden",
|
||||||
zIndex: theme.zIndex.drawer + 1,
|
});
|
||||||
transition: theme.transitions.create(["width", "margin"], {
|
|
||||||
easing: theme.transitions.easing.sharp,
|
const closedMixin = (theme: Theme): CSSObject => ({
|
||||||
duration: theme.transitions.duration.leavingScreen,
|
transition: theme.transitions.create("width", {
|
||||||
}),
|
easing: theme.transitions.easing.sharp,
|
||||||
},
|
duration: theme.transitions.duration.leavingScreen,
|
||||||
appBarShift: {
|
}),
|
||||||
marginLeft: drawerWidth,
|
overflowX: "hidden",
|
||||||
width: `calc(100% - ${drawerWidth}px)`,
|
width: 56,
|
||||||
transition: theme.transitions.create(["width", "margin"], {
|
});
|
||||||
easing: theme.transitions.easing.sharp,
|
|
||||||
duration: theme.transitions.duration.enteringScreen,
|
const DrawerHeader = styled("div")(({ theme }) => ({
|
||||||
}),
|
display: "flex",
|
||||||
},
|
alignItems: "center",
|
||||||
menuButton: {
|
justifyContent: "flex-start",
|
||||||
marginRight: 28,
|
padding: theme.spacing(0, 1),
|
||||||
},
|
// necessary for content to be below app bar
|
||||||
hide: {
|
...theme.mixins.toolbar,
|
||||||
display: "none",
|
}));
|
||||||
},
|
|
||||||
drawer: {
|
interface AppBarProps extends MuiAppBarProps {
|
||||||
width: drawerWidth,
|
open?: boolean;
|
||||||
flexShrink: 0,
|
}
|
||||||
whiteSpace: "nowrap",
|
|
||||||
},
|
const AppBar = styled(MuiAppBar, {
|
||||||
drawerOpen: {
|
shouldForwardProp: (prop) => prop !== "open",
|
||||||
width: drawerWidth,
|
})<AppBarProps>(({ theme, open }) => ({
|
||||||
transition: theme.transitions.create("width", {
|
backgroundColor: theme.palette.secondary.dark,
|
||||||
easing: theme.transitions.easing.sharp,
|
zIndex: theme.zIndex.drawer + 1,
|
||||||
duration: theme.transitions.duration.enteringScreen,
|
transition: theme.transitions.create(["width", "margin"], {
|
||||||
}),
|
easing: theme.transitions.easing.sharp,
|
||||||
},
|
duration: theme.transitions.duration.leavingScreen,
|
||||||
drawerClose: {
|
}),
|
||||||
transition: theme.transitions.create("width", {
|
...(open && {
|
||||||
easing: theme.transitions.easing.sharp,
|
marginLeft: drawerWidth,
|
||||||
duration: theme.transitions.duration.leavingScreen,
|
width: `calc(100% - ${drawerWidth}px)`,
|
||||||
}),
|
transition: theme.transitions.create(["width", "margin"], {
|
||||||
overflowX: "hidden",
|
easing: theme.transitions.easing.sharp,
|
||||||
width: theme.spacing(7) + 1,
|
duration: theme.transitions.duration.enteringScreen,
|
||||||
[theme.breakpoints.up("sm")]: {
|
}),
|
||||||
width: theme.spacing(7) + 8,
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
toolbar: {
|
},
|
||||||
display: "flex",
|
}));
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "flex-end",
|
const ListItemIcon = styled(MuiListItemIcon)<ListItemIconProps>(() => ({
|
||||||
padding: theme.spacing(0, 1),
|
minWidth: 42,
|
||||||
// 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 {
|
interface Props {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -121,7 +132,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Layout({ title, page, children }: Props): JSX.Element {
|
export function Layout({ title, page, children }: Props): JSX.Element {
|
||||||
const classes = useStyles();
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
|
|
||||||
@ -133,113 +143,109 @@ export function Layout({ title, page, children }: Props): JSX.Element {
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SiteTitle = styled("span")({
|
||||||
|
...(title !== "" && {
|
||||||
|
color: theme.palette.primary.main,
|
||||||
|
marginRight: 4,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.root}>
|
<Box sx={{ display: "flex" }}>
|
||||||
<AppBar
|
<AppBar position="fixed" open={open}>
|
||||||
position="fixed"
|
|
||||||
className={clsx(classes.appBar, {
|
|
||||||
[classes.appBarShift]: open,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<IconButton
|
<IconButton
|
||||||
color="inherit"
|
color="inherit"
|
||||||
aria-label="open drawer"
|
aria-label="Open drawer"
|
||||||
onClick={handleDrawerOpen}
|
onClick={handleDrawerOpen}
|
||||||
edge="start"
|
edge="start"
|
||||||
className={clsx(classes.menuButton, {
|
sx={{
|
||||||
[classes.hide]: open,
|
mr: 5,
|
||||||
})}
|
...(open && { display: "none" }),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<Typography variant="h5" noWrap>
|
<Box
|
||||||
<span className={title !== "" && classes.titleHighlight}>
|
sx={{
|
||||||
Hetty://
|
display: "flex",
|
||||||
</span>
|
justifyContent: "space-around",
|
||||||
{title}
|
width: "100%",
|
||||||
</Typography>
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
<Drawer
|
<Drawer variant="permanent" open={open}>
|
||||||
variant="permanent"
|
<DrawerHeader>
|
||||||
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}>
|
<IconButton onClick={handleDrawerClose}>
|
||||||
{theme.direction === "rtl" ? (
|
{theme.direction === "rtl" ? <ChevronRightIcon /> : <ChevronLeftIcon />}
|
||||||
<ChevronRightIcon />
|
|
||||||
) : (
|
|
||||||
<ChevronLeftIcon />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</DrawerHeader>
|
||||||
<Divider />
|
<Divider />
|
||||||
<List>
|
<List sx={{ p: 0 }}>
|
||||||
<Link href="/" passHref>
|
<Link href="/" passHref>
|
||||||
<ListItem
|
<ListItemButton key="home" selected={page === Page.Home}>
|
||||||
button
|
|
||||||
component="a"
|
|
||||||
key="home"
|
|
||||||
selected={page === Page.Home}
|
|
||||||
className={classes.listItem}
|
|
||||||
>
|
|
||||||
<Tooltip title="Home">
|
<Tooltip title="Home">
|
||||||
<ListItemIcon className={classes.listItemIcon}>
|
<ListItemIcon>
|
||||||
<HomeIcon />
|
<HomeIcon />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<ListItemText primary="Home" />
|
<ListItemText primary="Home" />
|
||||||
</ListItem>
|
</ListItemButton>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/proxy/logs" passHref>
|
<Link href="/proxy/logs" passHref>
|
||||||
<ListItem
|
<ListItemButton key="proxyLogs" selected={page === Page.ProxyLogs}>
|
||||||
button
|
|
||||||
component="a"
|
|
||||||
key="proxyLogs"
|
|
||||||
selected={page === Page.ProxyLogs}
|
|
||||||
className={classes.listItem}
|
|
||||||
>
|
|
||||||
<Tooltip title="Proxy">
|
<Tooltip title="Proxy">
|
||||||
<ListItemIcon className={classes.listItemIcon}>
|
<ListItemIcon>
|
||||||
<SettingsEthernetIcon />
|
<SettingsEthernetIcon />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<ListItemText primary="Proxy" />
|
<ListItemText primary="Proxy" />
|
||||||
</ListItem>
|
</ListItemButton>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/sender" passHref>
|
<Link href="/sender" passHref>
|
||||||
<ListItem
|
<ListItemButton key="sender" selected={page === Page.Sender}>
|
||||||
button
|
|
||||||
component="a"
|
|
||||||
key="sender"
|
|
||||||
selected={page === Page.Sender}
|
|
||||||
className={classes.listItem}
|
|
||||||
>
|
|
||||||
<Tooltip title="Sender">
|
<Tooltip title="Sender">
|
||||||
<ListItemIcon className={classes.listItemIcon}>
|
<ListItemIcon>
|
||||||
<SendIcon />
|
<SendIcon />
|
||||||
</ListItemIcon>
|
</ListItemIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<ListItemText primary="Sender" />
|
<ListItemText primary="Sender" />
|
||||||
</ListItem>
|
</ListItemButton>
|
||||||
|
</Link>
|
||||||
|
<Link href="/scope" passHref>
|
||||||
|
<ListItemButton key="scope" 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>
|
</Link>
|
||||||
</List>
|
</List>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
<main className={classes.content}>
|
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
|
||||||
<div className={classes.toolbar} />
|
<DrawerHeader />
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
117
admin/src/components/projects/NewProject.tsx
Normal file
117
admin/src/components/projects/NewProject.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { gql, useMutation } from "@apollo/client";
|
||||||
|
import { Box, Button, CircularProgress, TextField, Typography } from "@mui/material";
|
||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
const CREATE_PROJECT = gql`
|
||||||
|
mutation CreateProject($name: String!) {
|
||||||
|
createProject(name: $name) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const OPEN_PROJECT = gql`
|
||||||
|
mutation OpenProject($id: ID!) {
|
||||||
|
openProject(id: $id) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function NewProject(): JSX.Element {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
|
||||||
|
const [createProject, { error: createProjErr, loading: createProjLoading }] = useMutation(CREATE_PROJECT, {
|
||||||
|
onError: () => {},
|
||||||
|
onCompleted(data) {
|
||||||
|
setName("");
|
||||||
|
openProject({ variables: { id: data.createProject.id } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [openProject, { error: openProjErr, loading: openProjLoading }] = useMutation(OPEN_PROJECT, {
|
||||||
|
onError: () => {},
|
||||||
|
update(cache, { data: { openProject } }) {
|
||||||
|
cache.modify({
|
||||||
|
fields: {
|
||||||
|
activeProject() {
|
||||||
|
const activeProjRef = cache.writeFragment({
|
||||||
|
id: openProject.id,
|
||||||
|
data: openProject,
|
||||||
|
fragment: gql`
|
||||||
|
fragment ActiveProject on Project {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
type
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
return activeProjRef;
|
||||||
|
},
|
||||||
|
projects(_, { DELETE }) {
|
||||||
|
cache.writeFragment({
|
||||||
|
id: openProject.id,
|
||||||
|
data: openProject,
|
||||||
|
fragment: gql`
|
||||||
|
fragment OpenProject on Project {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
type
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
return DELETE;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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(createProjErr || openProjErr)}
|
||||||
|
helperText={(createProjErr && createProjErr.message) || (openProjErr && openProjErr.message)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
sx={{
|
||||||
|
pt: 0.9,
|
||||||
|
pb: 0.7,
|
||||||
|
}}
|
||||||
|
disabled={createProjLoading || openProjLoading}
|
||||||
|
startIcon={createProjLoading || openProjLoading ? <CircularProgress size={22} /> : <AddIcon />}
|
||||||
|
>
|
||||||
|
Create & open project
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NewProject;
|
292
admin/src/components/projects/ProjectList.tsx
Normal file
292
admin/src/components/projects/ProjectList.tsx
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
import { gql, useMutation, useQuery } from "@apollo/client";
|
||||||
|
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 CloseIcon from "@mui/icons-material/Close";
|
||||||
|
import DescriptionIcon from "@mui/icons-material/Description";
|
||||||
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
import LaunchIcon from "@mui/icons-material/Launch";
|
||||||
|
import { Alert } from "@mui/lab";
|
||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
import { Project } from "../../lib/Project";
|
||||||
|
|
||||||
|
const PROJECTS = gql`
|
||||||
|
query Projects {
|
||||||
|
projects {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const OPEN_PROJECT = gql`
|
||||||
|
mutation OpenProject($id: ID!) {
|
||||||
|
openProject(id: $id) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
isActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CLOSE_PROJECT = gql`
|
||||||
|
mutation CloseProject {
|
||||||
|
closeProject {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DELETE_PROJECT = gql`
|
||||||
|
mutation DeleteProject($id: ID!) {
|
||||||
|
deleteProject(id: $id) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function ProjectList(): JSX.Element {
|
||||||
|
const theme = useTheme();
|
||||||
|
const { loading: projLoading, error: projErr, data: projData } = useQuery<{ projects: Project[] }>(PROJECTS);
|
||||||
|
const [openProject, { error: openProjErr, loading: openProjLoading }] = useMutation<{ openProject: Project }>(
|
||||||
|
OPEN_PROJECT,
|
||||||
|
{
|
||||||
|
errorPolicy: "all",
|
||||||
|
onError: () => {},
|
||||||
|
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: openProject,
|
||||||
|
fragment: gql`
|
||||||
|
fragment OpenProject on Project {
|
||||||
|
id
|
||||||
|
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 [deleteProj, setDeleteProj] = useState<Project>();
|
||||||
|
const [deleteDiagOpen, setDeleteDiagOpen] = useState(false);
|
||||||
|
const handleDeleteButtonClick = (project: any) => {
|
||||||
|
setDeleteProj(project);
|
||||||
|
setDeleteDiagOpen(true);
|
||||||
|
};
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
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>
|
||||||
|
{deleteProjErr && <Alert severity="error">Error closing project: {deleteProjErr.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={deleteProjLoading}
|
||||||
|
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}>
|
||||||
|
{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 && projData.projects.length > 0 && (
|
||||||
|
<Paper>
|
||||||
|
<List>
|
||||||
|
{projData.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={openProjLoading || projLoading}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
{projData?.projects.length === 0 && (
|
||||||
|
<Alert severity="info">There are no projects. Create one to get started.</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectList;
|
51
admin/src/components/reqlog/ConfirmationDialog.tsx
Normal file
51
admin/src/components/reqlog/ConfirmationDialog.tsx
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
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";
|
||||||
|
|
||||||
|
export function useConfirmationDialog() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const close = () => setIsOpen(false);
|
||||||
|
const open = () => setIsOpen(true);
|
||||||
|
|
||||||
|
return { open, close, isOpen };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmationDialog {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmationDialog(props: ConfirmationDialog) {
|
||||||
|
const { onClose, onConfirm, isOpen, children } = props;
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
onConfirm();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
aria-labelledby="alert-dialog-title"
|
||||||
|
aria-describedby="alert-dialog-description"
|
||||||
|
>
|
||||||
|
<DialogTitle id="alert-dialog-title">Are you sure?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText id="alert-dialog-description">{children}</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Cancel</Button>
|
||||||
|
<Button onClick={confirm} autoFocus>
|
||||||
|
Confirm
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import dynamic from "next/dynamic";
|
import MonacoEditor from "@monaco-editor/react";
|
||||||
const MonacoEditor = dynamic(import("react-monaco-editor"), { ssr: false });
|
import monaco from "monaco-editor/esm/vs/editor/editor.api";
|
||||||
|
|
||||||
const monacoOptions = {
|
const monacoOptions: monaco.editor.IEditorOptions = {
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
wordWrap: "on",
|
wordWrap: "on",
|
||||||
minimap: {
|
minimap: {
|
||||||
@ -11,20 +11,7 @@ const monacoOptions = {
|
|||||||
|
|
||||||
type language = "html" | "typescript" | "json";
|
type language = "html" | "typescript" | "json";
|
||||||
|
|
||||||
function editorDidMount() {
|
function languageForContentType(contentType?: string): language | undefined {
|
||||||
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) {
|
switch (contentType) {
|
||||||
case "text/html":
|
case "text/html":
|
||||||
return "html";
|
return "html";
|
||||||
@ -41,7 +28,7 @@ function languageForContentType(contentType: string): language {
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
content: string;
|
content: string;
|
||||||
contentType: string;
|
contentType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Editor({ content, contentType }: Props): JSX.Element {
|
function Editor({ content, contentType }: Props): JSX.Element {
|
||||||
@ -50,8 +37,7 @@ function Editor({ content, contentType }: Props): JSX.Element {
|
|||||||
height={"600px"}
|
height={"600px"}
|
||||||
language={languageForContentType(contentType)}
|
language={languageForContentType(contentType)}
|
||||||
theme="vs-dark"
|
theme="vs-dark"
|
||||||
editorDidMount={editorDidMount}
|
options={monacoOptions}
|
||||||
options={monacoOptions as any}
|
|
||||||
value={content}
|
value={content}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
@ -1,83 +1,66 @@
|
|||||||
import {
|
import { Table, TableBody, TableCell, TableContainer, TableRow, Snackbar } from "@mui/material";
|
||||||
makeStyles,
|
import { Alert } from "@mui/lab";
|
||||||
Theme,
|
|
||||||
createStyles,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableRow,
|
|
||||||
Snackbar,
|
|
||||||
} from "@material-ui/core";
|
|
||||||
import { Alert } from "@material-ui/lab";
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) => {
|
const baseCellStyle = {
|
||||||
const paddingX = 0;
|
px: 0,
|
||||||
const paddingY = theme.spacing(1) / 3;
|
py: 0.33,
|
||||||
const tableCell = {
|
verticalAlign: "top",
|
||||||
paddingLeft: paddingX,
|
border: "none",
|
||||||
paddingRight: paddingX,
|
whiteSpace: "nowrap" as any,
|
||||||
paddingTop: paddingY,
|
overflow: "hidden",
|
||||||
paddingBottom: paddingY,
|
textOverflow: "ellipsis",
|
||||||
verticalAlign: "top",
|
"&:hover": {
|
||||||
border: "none",
|
color: "primary.main",
|
||||||
whiteSpace: "nowrap" as any,
|
whiteSpace: "inherit" as any,
|
||||||
overflow: "hidden",
|
overflow: "inherit",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "inherit",
|
||||||
"&:hover": {
|
cursor: "copy",
|
||||||
color: theme.palette.secondary.main,
|
},
|
||||||
whiteSpace: "inherit" as any,
|
};
|
||||||
overflow: "inherit",
|
|
||||||
textOverflow: "inherit",
|
const keyCellStyle = {
|
||||||
cursor: "copy",
|
...baseCellStyle,
|
||||||
},
|
pr: 1,
|
||||||
};
|
width: "40%",
|
||||||
return createStyles({
|
fontWeight: "bold",
|
||||||
root: {},
|
fontSize: ".75rem",
|
||||||
table: {
|
};
|
||||||
tableLayout: "fixed",
|
|
||||||
width: "100%",
|
const valueCellStyle = {
|
||||||
},
|
...baseCellStyle,
|
||||||
keyCell: {
|
width: "60%",
|
||||||
...tableCell,
|
border: "none",
|
||||||
paddingRight: theme.spacing(1),
|
fontSize: ".75rem",
|
||||||
width: "40%",
|
};
|
||||||
fontWeight: "bold",
|
|
||||||
fontSize: ".75rem",
|
|
||||||
},
|
|
||||||
valueCell: {
|
|
||||||
...tableCell,
|
|
||||||
width: "60%",
|
|
||||||
border: "none",
|
|
||||||
fontSize: ".75rem",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
headers: Array<{ key: string; value: string }>;
|
headers: Array<{ key: string; value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function HttpHeadersTable({ headers }: Props): JSX.Element {
|
function HttpHeadersTable({ headers }: Props): JSX.Element {
|
||||||
const classes = useStyles();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
const windowSel = window.getSelection();
|
||||||
|
|
||||||
|
if (!windowSel || !document) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const r = document.createRange();
|
const r = document.createRange();
|
||||||
r.selectNode(e.currentTarget);
|
r.selectNode(e.currentTarget);
|
||||||
window.getSelection().removeAllRanges();
|
windowSel.removeAllRanges();
|
||||||
window.getSelection().addRange(r);
|
windowSel.addRange(r);
|
||||||
document.execCommand("copy");
|
document.execCommand("copy");
|
||||||
window.getSelection().removeAllRanges();
|
windowSel.removeAllRanges();
|
||||||
|
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = (event?: React.SyntheticEvent, reason?: string) => {
|
const handleClose = (event: Event | React.SyntheticEvent, reason?: string) => {
|
||||||
if (reason === "clickaway") {
|
if (reason === "clickaway") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -92,20 +75,21 @@ function HttpHeadersTable({ headers }: Props): JSX.Element {
|
|||||||
Copied to clipboard.
|
Copied to clipboard.
|
||||||
</Alert>
|
</Alert>
|
||||||
</Snackbar>
|
</Snackbar>
|
||||||
<TableContainer className={classes.root}>
|
<TableContainer>
|
||||||
<Table className={classes.table} size="small">
|
<Table
|
||||||
|
sx={{
|
||||||
|
tableLayout: "fixed",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{headers.map(({ key, value }, index) => (
|
{headers.map(({ key, value }, index) => (
|
||||||
<TableRow key={index}>
|
<TableRow key={index}>
|
||||||
<TableCell
|
<TableCell component="th" scope="row" sx={keyCellStyle} onClick={handleClick}>
|
||||||
component="th"
|
|
||||||
scope="row"
|
|
||||||
className={classes.keyCell}
|
|
||||||
onClick={handleClick}
|
|
||||||
>
|
|
||||||
<code>{key}:</code>
|
<code>{key}:</code>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className={classes.valueCell} onClick={handleClick}>
|
<TableCell sx={valueCellStyle} onClick={handleClick}>
|
||||||
<code>{value}</code>
|
<code>{value}</code>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
@ -1,31 +1,25 @@
|
|||||||
import { Theme, withTheme } from "@material-ui/core";
|
import { SvgIconTypeMap } from "@mui/material";
|
||||||
import { orange, red } from "@material-ui/core/colors";
|
import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord";
|
||||||
import FiberManualRecordIcon from "@material-ui/icons/FiberManualRecord";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
status: number;
|
status: number;
|
||||||
theme: Theme;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function HttpStatusIcon({ status, theme }: Props): JSX.Element {
|
export default function HttpStatusIcon({ status }: Props): JSX.Element {
|
||||||
const style = { marginTop: "-.25rem", verticalAlign: "middle" };
|
let color: SvgIconTypeMap["props"]["color"] = "inherit";
|
||||||
|
|
||||||
switch (Math.floor(status / 100)) {
|
switch (Math.floor(status / 100)) {
|
||||||
case 2:
|
case 2:
|
||||||
case 3:
|
case 3:
|
||||||
return (
|
color = "primary";
|
||||||
<FiberManualRecordIcon
|
break;
|
||||||
style={{ ...style, color: theme.palette.secondary.main }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 4:
|
case 4:
|
||||||
return (
|
color = "warning";
|
||||||
<FiberManualRecordIcon style={{ ...style, color: orange["A400"] }} />
|
break;
|
||||||
);
|
|
||||||
case 5:
|
case 5:
|
||||||
return <FiberManualRecordIcon style={{ ...style, color: red["A400"] }} />;
|
color = "error";
|
||||||
default:
|
break;
|
||||||
return <FiberManualRecordIcon style={style} />;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export default withTheme(HttpStatusIcon);
|
return <FiberManualRecordIcon sx={{ marginTop: "-.25rem", verticalAlign: "middle" }} color={color} />;
|
||||||
|
}
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { gql, useQuery } from "@apollo/client";
|
import { gql, useQuery } from "@apollo/client";
|
||||||
import { Box, Grid, Paper, CircularProgress } from "@material-ui/core";
|
import { Box, Grid, Paper, CircularProgress } from "@mui/material";
|
||||||
|
|
||||||
import ResponseDetail from "./ResponseDetail";
|
import ResponseDetail from "./ResponseDetail";
|
||||||
import RequestDetail from "./RequestDetail";
|
import RequestDetail from "./RequestDetail";
|
||||||
import Alert from "@material-ui/lab/Alert";
|
import Alert from "@mui/lab/Alert";
|
||||||
|
|
||||||
const HTTP_REQUEST_LOG = gql`
|
const HTTP_REQUEST_LOG = gql`
|
||||||
query HttpRequestLog($id: ID!) {
|
query HttpRequestLog($id: ID!) {
|
||||||
@ -23,8 +23,8 @@ const HTTP_REQUEST_LOG = gql`
|
|||||||
key
|
key
|
||||||
value
|
value
|
||||||
}
|
}
|
||||||
status
|
|
||||||
statusCode
|
statusCode
|
||||||
|
statusReason
|
||||||
body
|
body
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -44,11 +44,7 @@ function LogDetail({ requestId: id }: Props): JSX.Element {
|
|||||||
return <CircularProgress />;
|
return <CircularProgress />;
|
||||||
}
|
}
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return <Alert severity="error">Error fetching logs details: {error.message}</Alert>;
|
||||||
<Alert severity="error">
|
|
||||||
Error fetching logs details: {error.message}
|
|
||||||
</Alert>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.httpRequestLog) {
|
if (!data.httpRequestLog) {
|
||||||
|
@ -1,36 +1,17 @@
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { gql, useQuery } from "@apollo/client";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { Box, CircularProgress, Link as MaterialLink, Typography } from "@mui/material";
|
||||||
import { Box, Typography, CircularProgress } from "@material-ui/core";
|
import Alert from "@mui/lab/Alert";
|
||||||
import Alert from "@material-ui/lab/Alert";
|
|
||||||
|
|
||||||
import RequestList from "./RequestList";
|
import RequestList from "./RequestList";
|
||||||
import LogDetail from "./LogDetail";
|
import LogDetail from "./LogDetail";
|
||||||
import CenteredPaper from "../CenteredPaper";
|
import CenteredPaper from "../CenteredPaper";
|
||||||
|
import { useHttpRequestLogs } from "./hooks/useHttpRequestLogs";
|
||||||
const HTTP_REQUEST_LOGS = gql`
|
|
||||||
query HttpRequestLogs {
|
|
||||||
httpRequestLogs {
|
|
||||||
id
|
|
||||||
method
|
|
||||||
url
|
|
||||||
timestamp
|
|
||||||
response {
|
|
||||||
status
|
|
||||||
statusCode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
function LogsOverview(): JSX.Element {
|
function LogsOverview(): JSX.Element {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const detailReqLogId = router.query.id as string;
|
const detailReqLogId = router.query.id as string | undefined;
|
||||||
console.log(detailReqLogId);
|
const { loading, error, data } = useHttpRequestLogs();
|
||||||
|
|
||||||
const { loading, error, data } = useQuery(HTTP_REQUEST_LOGS, {
|
|
||||||
pollInterval: 1000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleLogClick = (reqId: string) => {
|
const handleLogClick = (reqId: string) => {
|
||||||
router.push("/proxy/logs?id=" + reqId, undefined, {
|
router.push("/proxy/logs?id=" + reqId, undefined, {
|
||||||
@ -42,6 +23,17 @@ function LogsOverview(): JSX.Element {
|
|||||||
return <CircularProgress />;
|
return <CircularProgress />;
|
||||||
}
|
}
|
||||||
if (error) {
|
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="primary">Create or open</MaterialLink>
|
||||||
|
</Link>{" "}
|
||||||
|
one first.
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
return <Alert severity="error">Error fetching logs: {error.message}</Alert>;
|
return <Alert severity="error">Error fetching logs: {error.message}</Alert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,11 +42,7 @@ function LogsOverview(): JSX.Element {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Box mb={2}>
|
<Box mb={2}>
|
||||||
<RequestList
|
<RequestList logs={logs || []} selectedReqLogId={detailReqLogId} onLogClick={handleLogClick} />
|
||||||
logs={logs}
|
|
||||||
selectedReqLogId={detailReqLogId}
|
|
||||||
onLogClick={handleLogClick}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
{detailReqLogId && <LogDetail requestId={detailReqLogId} />}
|
{detailReqLogId && <LogDetail requestId={detailReqLogId} />}
|
||||||
|
@ -1,42 +1,9 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import { Typography, Box, Divider } from "@mui/material";
|
||||||
Typography,
|
|
||||||
Box,
|
|
||||||
createStyles,
|
|
||||||
makeStyles,
|
|
||||||
Theme,
|
|
||||||
Divider,
|
|
||||||
} from "@material-ui/core";
|
|
||||||
|
|
||||||
import HttpHeadersTable from "./HttpHeadersTable";
|
import HttpHeadersTable from "./HttpHeadersTable";
|
||||||
import Editor from "./Editor";
|
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 {
|
interface Props {
|
||||||
request: {
|
request: {
|
||||||
method: string;
|
method: string;
|
||||||
@ -49,29 +16,27 @@ interface Props {
|
|||||||
|
|
||||||
function RequestDetail({ request }: Props): JSX.Element {
|
function RequestDetail({ request }: Props): JSX.Element {
|
||||||
const { method, url, proto, headers, body } = request;
|
const { method, url, proto, headers, body } = request;
|
||||||
const classes = useStyles();
|
|
||||||
|
|
||||||
const contentType = headers.find((header) => header.key === "Content-Type")
|
const contentType = headers.find((header) => header.key === "Content-Type")?.value;
|
||||||
?.value;
|
|
||||||
const parsedUrl = new URL(url);
|
const parsedUrl = new URL(url);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Box p={2}>
|
<Box p={2}>
|
||||||
<Typography
|
<Typography variant="overline" color="textSecondary" style={{ float: "right" }}>
|
||||||
variant="overline"
|
|
||||||
color="textSecondary"
|
|
||||||
style={{ float: "right" }}
|
|
||||||
>
|
|
||||||
Request
|
Request
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography className={classes.requestTitle} variant="h6">
|
<Typography
|
||||||
|
sx={{
|
||||||
|
width: "calc(100% - 80px)",
|
||||||
|
fontSize: "1rem",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
variant="h6"
|
||||||
|
>
|
||||||
{method} {decodeURIComponent(parsedUrl.pathname + parsedUrl.search)}{" "}
|
{method} {decodeURIComponent(parsedUrl.pathname + parsedUrl.search)}{" "}
|
||||||
<Typography
|
<Typography component="span" color="textSecondary" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
||||||
component="span"
|
|
||||||
color="textSecondary"
|
|
||||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
|
||||||
>
|
|
||||||
{proto}
|
{proto}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Typography>
|
</Typography>
|
||||||
|
@ -8,48 +8,23 @@ import {
|
|||||||
TableBody,
|
TableBody,
|
||||||
Typography,
|
Typography,
|
||||||
Box,
|
Box,
|
||||||
createStyles,
|
useTheme,
|
||||||
makeStyles,
|
} from "@mui/material";
|
||||||
Theme,
|
|
||||||
withTheme,
|
|
||||||
} from "@material-ui/core";
|
|
||||||
|
|
||||||
import HttpStatusIcon from "./HttpStatusCode";
|
import HttpStatusIcon from "./HttpStatusCode";
|
||||||
import CenteredPaper from "../CenteredPaper";
|
import CenteredPaper from "../CenteredPaper";
|
||||||
|
import { RequestLog } from "../../lib/requestLogs";
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
|
||||||
createStyles({
|
|
||||||
row: {
|
|
||||||
"&:hover": {
|
|
||||||
cursor: "pointer",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
/* Pseudo-class applied to the root element if `hover={true}`. */
|
|
||||||
hover: {},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
logs: Array<any>;
|
logs: RequestLog[];
|
||||||
selectedReqLogId?: string;
|
selectedReqLogId?: string;
|
||||||
onLogClick(requestId: string): void;
|
onLogClick(requestId: string): void;
|
||||||
theme: Theme;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function RequestList({
|
export default function RequestList({ logs, onLogClick, selectedReqLogId }: Props): JSX.Element {
|
||||||
logs,
|
|
||||||
onLogClick,
|
|
||||||
selectedReqLogId,
|
|
||||||
theme,
|
|
||||||
}: Props): JSX.Element {
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<RequestListTable
|
<RequestListTable onLogClick={onLogClick} logs={logs} selectedReqLogId={selectedReqLogId} />
|
||||||
onLogClick={onLogClick}
|
|
||||||
logs={logs}
|
|
||||||
selectedReqLogId={selectedReqLogId}
|
|
||||||
theme={theme}
|
|
||||||
/>
|
|
||||||
{logs.length === 0 && (
|
{logs.length === 0 && (
|
||||||
<Box my={1}>
|
<Box my={1}>
|
||||||
<CenteredPaper>
|
<CenteredPaper>
|
||||||
@ -62,19 +37,14 @@ function RequestList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface RequestListTableProps {
|
interface RequestListTableProps {
|
||||||
logs?: any;
|
logs: RequestLog[];
|
||||||
selectedReqLogId?: string;
|
selectedReqLogId?: string;
|
||||||
onLogClick(requestId: string): void;
|
onLogClick(requestId: string): void;
|
||||||
theme: Theme;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function RequestListTable({
|
function RequestListTable({ logs, selectedReqLogId, onLogClick }: RequestListTableProps): JSX.Element {
|
||||||
logs,
|
const theme = useTheme();
|
||||||
selectedReqLogId,
|
|
||||||
onLogClick,
|
|
||||||
theme,
|
|
||||||
}: RequestListTableProps): JSX.Element {
|
|
||||||
const classes = useStyles();
|
|
||||||
return (
|
return (
|
||||||
<TableContainer
|
<TableContainer
|
||||||
component={Paper}
|
component={Paper}
|
||||||
@ -102,33 +72,34 @@ function RequestListTable({
|
|||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const rowStyle = {
|
|
||||||
backgroundColor:
|
|
||||||
id === selectedReqLogId && theme.palette.action.selected,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={id}
|
key={id}
|
||||||
className={classes.row}
|
sx={{
|
||||||
style={rowStyle}
|
"&:hover": {
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
...(id === selectedReqLogId && {
|
||||||
|
bgcolor: theme.palette.action.selected,
|
||||||
|
}),
|
||||||
|
}}
|
||||||
hover
|
hover
|
||||||
onClick={() => onLogClick(id)}
|
onClick={() => onLogClick(id)}
|
||||||
>
|
>
|
||||||
<TableCell style={{ ...cellStyle, width: "100px" }}>
|
<TableCell style={{ ...cellStyle, width: "100px" }}>
|
||||||
<code>{method}</code>
|
<code>{method}</code>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell style={{ ...cellStyle, maxWidth: "100px" }}>
|
<TableCell sx={{ ...cellStyle, maxWidth: "100px" }}>{origin}</TableCell>
|
||||||
{origin}
|
<TableCell sx={{ ...cellStyle, maxWidth: "200px" }}>
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ ...cellStyle, maxWidth: "200px" }}>
|
|
||||||
{decodeURIComponent(pathname + search + hash)}
|
{decodeURIComponent(pathname + search + hash)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell style={{ maxWidth: "100px" }}>
|
<TableCell style={{ maxWidth: "100px" }}>
|
||||||
{response && (
|
{response && (
|
||||||
<div>
|
<div>
|
||||||
<HttpStatusIcon status={response.statusCode} />{" "}
|
<HttpStatusIcon status={response.statusCode} />{" "}
|
||||||
<code>{response.status}</code>
|
<code>
|
||||||
|
{response.statusCode} {response.statusReason}
|
||||||
|
</code>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@ -140,5 +111,3 @@ function RequestListTable({
|
|||||||
</TableContainer>
|
</TableContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withTheme(RequestList);
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Typography, Box, Divider } from "@material-ui/core";
|
import { Typography, Box, Divider } from "@mui/material";
|
||||||
|
|
||||||
import HttpStatusIcon from "./HttpStatusCode";
|
import HttpStatusIcon from "./HttpStatusCode";
|
||||||
import Editor from "./Editor";
|
import Editor from "./Editor";
|
||||||
@ -8,41 +8,28 @@ interface Props {
|
|||||||
response: {
|
response: {
|
||||||
proto: string;
|
proto: string;
|
||||||
statusCode: number;
|
statusCode: number;
|
||||||
status: string;
|
statusReason: string;
|
||||||
headers: Array<{ key: string; value: string }>;
|
headers: Array<{ key: string; value: string }>;
|
||||||
body?: string;
|
body?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResponseDetail({ response }: Props): JSX.Element {
|
function ResponseDetail({ response }: Props): JSX.Element {
|
||||||
const contentType = response.headers.find(
|
const contentType = response.headers.find((header) => header.key === "Content-Type")?.value;
|
||||||
(header) => header.key === "Content-Type"
|
|
||||||
)?.value;
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Box p={2}>
|
<Box p={2}>
|
||||||
<Typography
|
<Typography variant="overline" color="textSecondary" style={{ float: "right" }}>
|
||||||
variant="overline"
|
|
||||||
color="textSecondary"
|
|
||||||
style={{ float: "right" }}
|
|
||||||
>
|
|
||||||
Response
|
Response
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography variant="h6" style={{ fontSize: "1rem", whiteSpace: "nowrap" }}>
|
||||||
variant="h6"
|
|
||||||
style={{ fontSize: "1rem", whiteSpace: "nowrap" }}
|
|
||||||
>
|
|
||||||
<HttpStatusIcon status={response.statusCode} />{" "}
|
<HttpStatusIcon status={response.statusCode} />{" "}
|
||||||
<Typography component="span" color="textSecondary">
|
<Typography component="span" color="textSecondary">
|
||||||
<Typography
|
<Typography component="span" color="textSecondary" style={{ fontFamily: "'JetBrains Mono', monospace" }}>
|
||||||
component="span"
|
|
||||||
color="textSecondary"
|
|
||||||
style={{ fontFamily: "'JetBrains Mono', monospace" }}
|
|
||||||
>
|
|
||||||
{response.proto}
|
{response.proto}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Typography>{" "}
|
</Typography>{" "}
|
||||||
{response.status}
|
{response.statusCode} {response.statusReason}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@ -52,9 +39,7 @@ function ResponseDetail({ response }: Props): JSX.Element {
|
|||||||
<HttpHeadersTable headers={response.headers} />
|
<HttpHeadersTable headers={response.headers} />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{response.body && (
|
{response.body && <Editor content={response.body} contentType={contentType} />}
|
||||||
<Editor content={response.body} contentType={contentType} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
216
admin/src/components/reqlog/Search.tsx
Normal file
216
admin/src/components/reqlog/Search.tsx
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Checkbox,
|
||||||
|
CircularProgress,
|
||||||
|
ClickAwayListener,
|
||||||
|
FormControlLabel,
|
||||||
|
InputBase,
|
||||||
|
Paper,
|
||||||
|
Popper,
|
||||||
|
Tooltip,
|
||||||
|
useTheme,
|
||||||
|
} from "@mui/material";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
|
import FilterListIcon from "@mui/icons-material/FilterList";
|
||||||
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
import React, { useRef, useState } from "react";
|
||||||
|
import { gql, useMutation, useQuery } from "@apollo/client";
|
||||||
|
import { withoutTypename } from "../../lib/omitTypename";
|
||||||
|
import { Alert } from "@mui/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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export interface SearchFilter {
|
||||||
|
onlyInScope: boolean;
|
||||||
|
searchExpression: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Search(): JSX.Element {
|
||||||
|
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 }) {
|
||||||
|
cache.writeQuery({
|
||||||
|
query: FILTER,
|
||||||
|
data: {
|
||||||
|
httpRequestLogFilter: data?.setHttpRequestLogFilter,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [clearHTTPRequestLog, clearHTTPRequestLogResult] = useClearHTTPRequestLog();
|
||||||
|
const clearHTTPConfirmationDialog = useConfirmationDialog();
|
||||||
|
|
||||||
|
const filterRef = useRef<HTMLFormElement>(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: MouseEvent | TouchEvent) => {
|
||||||
|
if (filterRef?.current && 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}
|
||||||
|
sx={{
|
||||||
|
padding: "2px 4px",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
width: 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip title="Toggle filter options">
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setFilterOpen(!filterOpen)}
|
||||||
|
sx={{
|
||||||
|
p: 1,
|
||||||
|
color: filter?.httpRequestLogFilter?.onlyInScope ? "primary.main" : "inherit",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filterLoading || setFilterLoading ? (
|
||||||
|
<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?.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;
|
16
admin/src/components/reqlog/hooks/useClearHTTPRequestLog.ts
Normal file
16
admin/src/components/reqlog/hooks/useClearHTTPRequestLog.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
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 }],
|
||||||
|
});
|
||||||
|
}
|
22
admin/src/components/reqlog/hooks/useHttpRequestLogs.ts
Normal file
22
admin/src/components/reqlog/hooks/useHttpRequestLogs.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
119
admin/src/components/scope/AddRule.tsx
Normal file
119
admin/src/components/scope/AddRule.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { gql, useApolloClient, useMutation } from "@apollo/client";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
FormControl,
|
||||||
|
FormControlLabel,
|
||||||
|
FormLabel,
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
|
TextField,
|
||||||
|
} from "@mui/material";
|
||||||
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
|
import { Alert } from "@mui/lab";
|
||||||
|
import React from "react";
|
||||||
|
import { SCOPE } from "./Rules";
|
||||||
|
import { ScopeRule } from "../../lib/scope";
|
||||||
|
|
||||||
|
const SET_SCOPE = gql`
|
||||||
|
mutation SetScope($scope: [ScopeRuleInput!]!) {
|
||||||
|
setScope(scope: $scope) {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function AddRule(): JSX.Element {
|
||||||
|
const [ruleType, setRuleType] = React.useState("url");
|
||||||
|
const [expression, setExpression] = React.useState("");
|
||||||
|
|
||||||
|
const client = useApolloClient();
|
||||||
|
const [setScope, { error, loading }] = useMutation(SET_SCOPE, {
|
||||||
|
onError() {},
|
||||||
|
onCompleted() {
|
||||||
|
setExpression("");
|
||||||
|
},
|
||||||
|
update(_, { data: { setScope } }) {
|
||||||
|
client.writeQuery({
|
||||||
|
query: SCOPE,
|
||||||
|
data: { scope: setScope },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleTypeChange = (e: React.ChangeEvent, value: string) => {
|
||||||
|
setRuleType(value);
|
||||||
|
};
|
||||||
|
const handleSubmit = (e: React.SyntheticEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
let scope: ScopeRule[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = client.readQuery<{ scope: ScopeRule[] }>({
|
||||||
|
query: SCOPE,
|
||||||
|
});
|
||||||
|
if (data) {
|
||||||
|
scope = data.scope;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
setScope({
|
||||||
|
variables: {
|
||||||
|
scope: [...scope.map(({ url }) => ({ url })), { url: expression }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{error && (
|
||||||
|
<Box mb={4}>
|
||||||
|
<Alert severity="error">Error adding rule: {error.message}</Alert>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSubmit} autoComplete="off">
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<FormLabel color="primary" component="legend">
|
||||||
|
Rule Type
|
||||||
|
</FormLabel>
|
||||||
|
<RadioGroup row name="ruleType" value={ruleType} onChange={handleTypeChange}>
|
||||||
|
<FormControlLabel value="url" control={<Radio />} label="URL" />
|
||||||
|
</RadioGroup>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<TextField
|
||||||
|
label="Expression"
|
||||||
|
placeholder="^https:\/\/(.*)example.com(.*)"
|
||||||
|
helperText="Regular expression to match on."
|
||||||
|
color="primary"
|
||||||
|
variant="outlined"
|
||||||
|
required
|
||||||
|
value={expression}
|
||||||
|
onChange={(e) => setExpression(e.target.value)}
|
||||||
|
InputProps={{
|
||||||
|
sx: { fontFamily: "'JetBrains Mono', monospace" },
|
||||||
|
}}
|
||||||
|
InputLabelProps={{
|
||||||
|
shrink: true,
|
||||||
|
}}
|
||||||
|
margin="normal"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<Box my={2}>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
color="primary"
|
||||||
|
disabled={loading}
|
||||||
|
startIcon={loading ? <CircularProgress size={22} /> : <AddIcon />}
|
||||||
|
>
|
||||||
|
Add rule
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AddRule;
|
97
admin/src/components/scope/RuleListItem.tsx
Normal file
97
admin/src/components/scope/RuleListItem.tsx
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import { gql, useApolloClient, useMutation, useQuery } from "@apollo/client";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Chip,
|
||||||
|
IconButton,
|
||||||
|
ListItem,
|
||||||
|
ListItemAvatar,
|
||||||
|
ListItemSecondaryAction,
|
||||||
|
ListItemText,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mui/material";
|
||||||
|
import CodeIcon from "@mui/icons-material/Code";
|
||||||
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
import React from "react";
|
||||||
|
import { SCOPE } from "./Rules";
|
||||||
|
import { ScopeRule } from "../../lib/scope";
|
||||||
|
|
||||||
|
const SET_SCOPE = gql`
|
||||||
|
mutation SetScope($scope: [ScopeRuleInput!]!) {
|
||||||
|
setScope(scope: $scope) {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type RuleListItemProps = {
|
||||||
|
scope: ScopeRule[];
|
||||||
|
rule: ScopeRule;
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function RuleListItem({ scope, rule, index }: RuleListItemProps): JSX.Element {
|
||||||
|
const client = useApolloClient();
|
||||||
|
const [setScope, { loading }] = useMutation(SET_SCOPE, {
|
||||||
|
update(_, { data: { setScope } }) {
|
||||||
|
client.writeQuery({
|
||||||
|
query: SCOPE,
|
||||||
|
data: { scope: setScope },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = (index: number) => {
|
||||||
|
const clone = [...scope];
|
||||||
|
clone.splice(index, 1);
|
||||||
|
setScope({
|
||||||
|
variables: {
|
||||||
|
scope: clone.map(({ url }) => ({ url })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItem>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Avatar>
|
||||||
|
<CodeIcon />
|
||||||
|
</Avatar>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<RuleListItemText rule={rule} />
|
||||||
|
<ListItemSecondaryAction>
|
||||||
|
<RuleTypeChip rule={rule} />
|
||||||
|
<Tooltip title="Delete rule">
|
||||||
|
<span style={{ marginLeft: 8 }}>
|
||||||
|
<IconButton onClick={() => handleDelete(index)} disabled={loading}>
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</ListItemSecondaryAction>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RuleListItemText({ rule }: { rule: ScopeRule }): JSX.Element {
|
||||||
|
let text: JSX.Element = <div></div>;
|
||||||
|
|
||||||
|
if (rule.url) {
|
||||||
|
text = <code>{rule.url}</code>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Parse and handle rule.header and rule.body.
|
||||||
|
|
||||||
|
return <ListItemText>{text}</ListItemText>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RuleTypeChip({ rule }: { rule: ScopeRule }): JSX.Element {
|
||||||
|
let label = "Unknown";
|
||||||
|
|
||||||
|
if (rule.url) {
|
||||||
|
label = "URL";
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Chip label={label} variant="outlined" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RuleListItem;
|
38
admin/src/components/scope/Rules.tsx
Normal file
38
admin/src/components/scope/Rules.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { gql, useQuery } from "@apollo/client";
|
||||||
|
import { CircularProgress, List } from "@mui/material";
|
||||||
|
import { Alert } from "@mui/lab";
|
||||||
|
import React from "react";
|
||||||
|
import RuleListItem from "./RuleListItem";
|
||||||
|
import { ScopeRule } from "../../lib/scope";
|
||||||
|
|
||||||
|
export const SCOPE = gql`
|
||||||
|
query Scope {
|
||||||
|
scope {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function Rules(): JSX.Element {
|
||||||
|
const { loading, error, data } = useQuery<{ scope: ScopeRule[] }>(SCOPE);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{loading && <CircularProgress />}
|
||||||
|
{error && <Alert severity="error">Error fetching scope: {error.message}</Alert>}
|
||||||
|
{data && data.scope.length > 0 && (
|
||||||
|
<List
|
||||||
|
sx={{
|
||||||
|
bgcolor: "background.paper",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{data.scope.map((rule, index) => (
|
||||||
|
<RuleListItem key={index} rule={rule} scope={data.scope} index={index} />
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Rules;
|
5
admin/src/lib/Project.ts
Normal file
5
admin/src/lib/Project.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export type Project = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
isActive: boolean
|
||||||
|
}
|
7
admin/src/lib/createEmotionCache.ts
Normal file
7
admin/src/lib/createEmotionCache.ts
Normal 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 });
|
||||||
|
}
|
@ -1,8 +1,11 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { ApolloClient, HttpLink, InMemoryCache } from "@apollo/client";
|
import { ApolloClient, HttpLink, InMemoryCache, NormalizedCacheObject } from "@apollo/client";
|
||||||
import { concatPagination } from "@apollo/client/utilities";
|
import merge from "deepmerge";
|
||||||
|
import isEqual from "lodash/isEqual";
|
||||||
|
|
||||||
let apolloClient;
|
export const APOLLO_STATE_PROP_NAME = "__APOLLO_STATE__";
|
||||||
|
|
||||||
|
let apolloClient: ApolloClient<NormalizedCacheObject>;
|
||||||
|
|
||||||
function createApolloClient() {
|
function createApolloClient() {
|
||||||
return new ApolloClient({
|
return new ApolloClient({
|
||||||
@ -10,15 +13,7 @@ function createApolloClient() {
|
|||||||
link: new HttpLink({
|
link: new HttpLink({
|
||||||
uri: "/api/graphql/",
|
uri: "/api/graphql/",
|
||||||
}),
|
}),
|
||||||
cache: new InMemoryCache({
|
cache: new InMemoryCache(),
|
||||||
typePolicies: {
|
|
||||||
Query: {
|
|
||||||
fields: {
|
|
||||||
allPosts: concatPagination(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,9 +25,18 @@ export function initializeApollo(initialState = null) {
|
|||||||
if (initialState) {
|
if (initialState) {
|
||||||
// Get existing cache, loaded during client side data fetching
|
// Get existing cache, loaded during client side data fetching
|
||||||
const existingCache = _apolloClient.extract();
|
const existingCache = _apolloClient.extract();
|
||||||
// Restore the cache using the data passed from getStaticProps/getServerSideProps
|
|
||||||
// combined with the existing cached data
|
// Merge the existing cache into data passed from getStaticProps/getServerSideProps
|
||||||
_apolloClient.cache.restore({ ...existingCache, ...initialState });
|
const data = merge(initialState, existingCache, {
|
||||||
|
// combine arrays using object equality (like in sets)
|
||||||
|
arrayMerge: (destinationArray, sourceArray) => [
|
||||||
|
...sourceArray,
|
||||||
|
...destinationArray.filter((d) => sourceArray.every((s) => !isEqual(d, s))),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore the cache with the merged data
|
||||||
|
_apolloClient.cache.restore(data);
|
||||||
}
|
}
|
||||||
// For SSG and SSR always create a new Apollo Client
|
// For SSG and SSR always create a new Apollo Client
|
||||||
if (typeof window === "undefined") return _apolloClient;
|
if (typeof window === "undefined") return _apolloClient;
|
||||||
@ -42,7 +46,16 @@ export function initializeApollo(initialState = null) {
|
|||||||
return _apolloClient;
|
return _apolloClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useApollo(initialState) {
|
export function addApolloState(client: ApolloClient<NormalizedCacheObject>, pageProps: any) {
|
||||||
const store = useMemo(() => initializeApollo(initialState), [initialState]);
|
if (pageProps?.props) {
|
||||||
|
pageProps.props[APOLLO_STATE_PROP_NAME] = client.cache.extract();
|
||||||
|
}
|
||||||
|
|
||||||
|
return pageProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApollo(pageProps: any) {
|
||||||
|
const state = pageProps[APOLLO_STATE_PROP_NAME];
|
||||||
|
const store = useMemo(() => initializeApollo(state), [state]);
|
||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
|
5
admin/src/lib/omitTypename.ts
Normal file
5
admin/src/lib/omitTypename.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
const omitTypename = (key: string, value: any) => (key === "__typename" ? undefined : value);
|
||||||
|
|
||||||
|
export function withoutTypename(input: any): any {
|
||||||
|
return JSON.parse(JSON.stringify(input), omitTypename);
|
||||||
|
}
|
24
admin/src/lib/requestLogs.ts
Normal file
24
admin/src/lib/requestLogs.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
|
||||||
|
export type RequestLog = {
|
||||||
|
id: string
|
||||||
|
url: string
|
||||||
|
method: string
|
||||||
|
proto: string
|
||||||
|
headers: HTTPHeader[]
|
||||||
|
body?: string
|
||||||
|
timestamp: string
|
||||||
|
response?: ResponseLog
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResponseLog = {
|
||||||
|
proto: string
|
||||||
|
statusCode: number
|
||||||
|
statusReason: string
|
||||||
|
body?: string
|
||||||
|
headers: HTTPHeader[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HTTPHeader = {
|
||||||
|
key: string
|
||||||
|
value: string
|
||||||
|
}
|
3
admin/src/lib/scope.ts
Normal file
3
admin/src/lib/scope.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export type ScopeRule = {
|
||||||
|
url?: string
|
||||||
|
}
|
@ -1,43 +1,51 @@
|
|||||||
import { createMuiTheme } from "@material-ui/core/styles";
|
import { createTheme } from "@mui/material/styles";
|
||||||
import grey from "@material-ui/core/colors/grey";
|
import * as colors from "@mui/material/colors";
|
||||||
import teal from "@material-ui/core/colors/teal";
|
|
||||||
|
|
||||||
const theme = createMuiTheme({
|
const heading = {
|
||||||
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
|
fontWeight: 600,
|
||||||
|
};
|
||||||
|
|
||||||
|
let theme = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
type: "dark",
|
mode: "dark",
|
||||||
primary: {
|
primary: {
|
||||||
main: grey[900],
|
main: colors.teal["A400"],
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
main: teal["A400"],
|
main: colors.grey[900],
|
||||||
|
light: "#333",
|
||||||
|
dark: colors.common.black,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
typography: {
|
typography: {
|
||||||
h2: {
|
h2: heading,
|
||||||
fontFamily: "'JetBrains Mono', monospace",
|
h3: heading,
|
||||||
fontWeight: 600,
|
h4: heading,
|
||||||
|
h5: heading,
|
||||||
|
h6: heading,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
theme = createTheme(theme, {
|
||||||
|
palette: {
|
||||||
|
background: {
|
||||||
|
default: theme.palette.secondary.main,
|
||||||
|
paper: theme.palette.secondary.light,
|
||||||
},
|
},
|
||||||
h3: {
|
info: {
|
||||||
fontFamily: "'JetBrains Mono', monospace",
|
main: theme.palette.primary.main,
|
||||||
fontWeight: 600,
|
|
||||||
},
|
},
|
||||||
h4: {
|
success: {
|
||||||
fontFamily: "'JetBrains Mono', monospace",
|
main: theme.palette.primary.main,
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
h5: {
|
|
||||||
fontFamily: "'JetBrains Mono', monospace",
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
h6: {
|
|
||||||
fontFamily: "'JetBrains Mono', monospace",
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
overrides: {
|
components: {
|
||||||
MuiTableCell: {
|
MuiTableCell: {
|
||||||
stickyHeader: {
|
styleOverrides: {
|
||||||
backgroundColor: grey[900],
|
stickyHeader: {
|
||||||
|
backgroundColor: theme.palette.secondary.dark,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -1,32 +1,31 @@
|
|||||||
import React from "react";
|
import * as React from "react";
|
||||||
|
import Head from "next/head";
|
||||||
import { AppProps } from "next/app";
|
import { AppProps } from "next/app";
|
||||||
import { ApolloProvider } from "@apollo/client";
|
import { ApolloProvider } from "@apollo/client";
|
||||||
import Head from "next/head";
|
import { ThemeProvider } from "@mui/material/styles";
|
||||||
import { ThemeProvider } from "@material-ui/core/styles";
|
import CssBaseline from "@mui/material/CssBaseline";
|
||||||
import CssBaseline from "@material-ui/core/CssBaseline";
|
import { CacheProvider, EmotionCache } from "@emotion/react";
|
||||||
|
|
||||||
|
import createEmotionCache from "../lib/createEmotionCache";
|
||||||
import theme from "../lib/theme";
|
import theme from "../lib/theme";
|
||||||
import { useApollo } from "../lib/graphql";
|
import { useApollo } from "../lib/graphql";
|
||||||
|
|
||||||
function App({ Component, pageProps }: AppProps): JSX.Element {
|
// Client-side cache, shared for the whole session of the user in the browser.
|
||||||
const apolloClient = useApollo(pageProps.initialApolloState);
|
const clientSideEmotionCache = createEmotionCache();
|
||||||
|
|
||||||
React.useEffect(() => {
|
interface MyAppProps extends AppProps {
|
||||||
// Remove the server-side injected CSS.
|
emotionCache?: EmotionCache;
|
||||||
const jssStyles = document.querySelector("#jss-server-side");
|
}
|
||||||
if (jssStyles) {
|
|
||||||
jssStyles.parentElement.removeChild(jssStyles);
|
export default function MyApp(props: MyAppProps) {
|
||||||
}
|
const { Component, emotionCache = clientSideEmotionCache, pageProps } = props;
|
||||||
}, []);
|
const apolloClient = useApollo(pageProps);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<CacheProvider value={emotionCache}>
|
||||||
<Head>
|
<Head>
|
||||||
<title>Hetty://</title>
|
<title>Hetty://</title>
|
||||||
<meta
|
<meta name="viewport" content="initial-scale=1, width=device-width" />
|
||||||
name="viewport"
|
|
||||||
content="minimum-scale=1, initial-scale=1, width=device-width"
|
|
||||||
/>
|
|
||||||
</Head>
|
</Head>
|
||||||
<ApolloProvider client={apolloClient}>
|
<ApolloProvider client={apolloClient}>
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
@ -34,8 +33,6 @@ function App({ Component, pageProps }: AppProps): JSX.Element {
|
|||||||
<Component {...pageProps} />
|
<Component {...pageProps} />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</ApolloProvider>
|
</ApolloProvider>
|
||||||
</React.Fragment>
|
</CacheProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import React from "react";
|
import * as React from "react";
|
||||||
import Document, { Html, Head, Main, NextScript } from "next/document";
|
import Document, { Html, Head, Main, NextScript } from "next/document";
|
||||||
import { ServerStyleSheets } from "@material-ui/core/styles";
|
import createEmotionServer from "@emotion/server/create-instance";
|
||||||
|
|
||||||
|
import createEmotionCache from "../lib/createEmotionCache";
|
||||||
import theme from "../lib/theme";
|
import theme from "../lib/theme";
|
||||||
|
|
||||||
export default class MyDocument extends Document {
|
export default class MyDocument extends Document {
|
||||||
@ -11,14 +12,9 @@ export default class MyDocument extends Document {
|
|||||||
<Head>
|
<Head>
|
||||||
<meta name="theme-color" content={theme.palette.primary.main} />
|
<meta name="theme-color" content={theme.palette.primary.main} />
|
||||||
<link rel="stylesheet" href="/style.css" />
|
<link rel="stylesheet" href="/style.css" />
|
||||||
<link
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
|
||||||
rel="stylesheet"
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap" />
|
||||||
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
|
{(this.props as any).emotionStyleTags}
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap"
|
|
||||||
/>
|
|
||||||
</Head>
|
</Head>
|
||||||
<body>
|
<body>
|
||||||
<Main />
|
<Main />
|
||||||
@ -30,25 +26,60 @@ export default class MyDocument extends Document {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// `getInitialProps` belongs to `_document` (instead of `_app`),
|
// `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) => {
|
MyDocument.getInitialProps = async (ctx) => {
|
||||||
// Render app and page and get the context of the page with collected side effects.
|
// Resolution order
|
||||||
const sheets = new ServerStyleSheets();
|
//
|
||||||
|
// 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;
|
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 = () =>
|
ctx.renderPage = () =>
|
||||||
originalRenderPage({
|
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);
|
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 {
|
return {
|
||||||
...initialProps,
|
...initialProps,
|
||||||
// Styles fragment is rendered after the app and page rendering finish.
|
emotionStyleTags,
|
||||||
styles: [
|
|
||||||
...React.Children.toArray(initialProps.styles),
|
|
||||||
sheets.getStyleElement(),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
32
admin/src/pages/get-started/index.tsx
Normal file
32
admin/src/pages/get-started/index.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Box, Link as MaterialLink, Typography } from "@mui/material";
|
||||||
|
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>
|
||||||
|
You’ve loaded a (new) project. What’s next? You can now use the MITM proxy and review HTTP requests and
|
||||||
|
responses via the{" "}
|
||||||
|
<Link href="/proxy/logs" passHref>
|
||||||
|
<MaterialLink color="primary">Proxy logs</MaterialLink>
|
||||||
|
</Link>
|
||||||
|
. Stuck? Ask for help on the{" "}
|
||||||
|
<MaterialLink href="https://github.com/dstotijn/hetty/discussions" color="primary" target="_blank">
|
||||||
|
Discussions forum
|
||||||
|
</MaterialLink>
|
||||||
|
.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Index;
|
@ -1,79 +1,53 @@
|
|||||||
import {
|
import { Box, Button, Typography } from "@mui/material";
|
||||||
Box,
|
import FolderIcon from "@mui/icons-material/Folder";
|
||||||
Button,
|
|
||||||
createStyles,
|
|
||||||
IconButton,
|
|
||||||
makeStyles,
|
|
||||||
Theme,
|
|
||||||
Typography,
|
|
||||||
} from "@material-ui/core";
|
|
||||||
import SettingsEthernetIcon from "@material-ui/icons/SettingsEthernet";
|
|
||||||
import SendIcon from "@material-ui/icons/Send";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import Layout, { Page } from "../components/Layout";
|
import Layout, { Page } from "../components/Layout";
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
|
||||||
createStyles({
|
|
||||||
titleHighlight: {
|
|
||||||
color: theme.palette.secondary.main,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: "1.6rem",
|
|
||||||
width: "60%",
|
|
||||||
lineHeight: 2,
|
|
||||||
marginBottom: theme.spacing(5),
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
marginRight: theme.spacing(2),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
function Index(): JSX.Element {
|
function Index(): JSX.Element {
|
||||||
const classes = useStyles();
|
const highlightSx = { color: "primary.main" };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout page={Page.Home} title="">
|
<Layout page={Page.Home} title="">
|
||||||
<Box p={4}>
|
<Box p={4}>
|
||||||
<Box mb={4} width="60%">
|
<Box mb={4} width="60%">
|
||||||
<Typography variant="h2">
|
<Typography variant="h2">
|
||||||
<span className={classes.titleHighlight}>Hetty://</span>
|
<Box component="span" sx={highlightSx}>
|
||||||
|
Hetty://
|
||||||
|
</Box>
|
||||||
<br />
|
<br />
|
||||||
The simple HTTP toolkit for security research.
|
The simple HTTP toolkit for security research.
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography className={classes.subtitle} paragraph>
|
|
||||||
What if security testing was intuitive, powerful, and good looking?
|
<Typography
|
||||||
What if it was <strong>free</strong>, instead of $400 per year?{" "}
|
paragraph
|
||||||
<span className={classes.titleHighlight}>Hetty</span> is listening on{" "}
|
sx={{
|
||||||
<code>:8080</code>…
|
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>
|
</Typography>
|
||||||
<Box>
|
|
||||||
<Link href="/proxy" passHref>
|
<Link href="/projects" passHref>
|
||||||
<Button
|
<Button
|
||||||
className={classes.button}
|
sx={{ mr: 2 }}
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="secondary"
|
color="primary"
|
||||||
component="a"
|
component="a"
|
||||||
size="large"
|
size="large"
|
||||||
startIcon={<SettingsEthernetIcon />}
|
startIcon={<FolderIcon />}
|
||||||
>
|
>
|
||||||
Setup proxy
|
Manage projects
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/proxy" passHref>
|
|
||||||
<Button
|
|
||||||
className={classes.button}
|
|
||||||
variant="contained"
|
|
||||||
color="primary"
|
|
||||||
component="a"
|
|
||||||
size="large"
|
|
||||||
startIcon={<SendIcon />}
|
|
||||||
>
|
|
||||||
Send HTTP requests
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
32
admin/src/pages/projects/index.tsx
Normal file
32
admin/src/pages/projects/index.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Box, Divider, Grid, Typography } from "@mui/material";
|
||||||
|
import Layout, { Page } from "../../components/Layout";
|
||||||
|
import NewProject from "../../components/projects/NewProject";
|
||||||
|
import ProjectList from "../../components/projects/ProjectList";
|
||||||
|
|
||||||
|
function Index(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Layout page={Page.Projects} title="Projects">
|
||||||
|
<Box p={4}>
|
||||||
|
<Box mb={3}>
|
||||||
|
<Typography variant="h4">Projects</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography paragraph>
|
||||||
|
Projects contain settings and data generated/processed by Hetty. They are stored in a single database on disk.
|
||||||
|
</Typography>
|
||||||
|
<Box my={4}>
|
||||||
|
<Divider />
|
||||||
|
</Box>
|
||||||
|
<Box mb={8}>
|
||||||
|
<NewProject />
|
||||||
|
</Box>
|
||||||
|
<Grid container>
|
||||||
|
<Grid item xs={12} sm={8} md={6} lg={6}>
|
||||||
|
<ProjectList />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Index;
|
@ -1,6 +1,6 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Button, Typography } from "@material-ui/core";
|
import { Button, Typography } from "@mui/material";
|
||||||
import ListIcon from "@material-ui/icons/List";
|
import ListIcon from "@mui/icons-material/List";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import Layout, { Page } from "../../components/Layout";
|
import Layout, { Page } from "../../components/Layout";
|
||||||
@ -10,13 +10,7 @@ function Index(): JSX.Element {
|
|||||||
<Layout page={Page.ProxySetup} title="Proxy setup">
|
<Layout page={Page.ProxySetup} title="Proxy setup">
|
||||||
<Typography paragraph>Coming soon…</Typography>
|
<Typography paragraph>Coming soon…</Typography>
|
||||||
<Link href="/proxy/logs" passHref>
|
<Link href="/proxy/logs" passHref>
|
||||||
<Button
|
<Button variant="contained" color="primary" component="a" size="large" startIcon={<ListIcon />}>
|
||||||
variant="contained"
|
|
||||||
color="secondary"
|
|
||||||
component="a"
|
|
||||||
size="large"
|
|
||||||
startIcon={<ListIcon />}
|
|
||||||
>
|
|
||||||
View logs
|
View logs
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
@ -1,9 +1,15 @@
|
|||||||
|
import { Box } from "@mui/material";
|
||||||
|
|
||||||
import LogsOverview from "../../../components/reqlog/LogsOverview";
|
import LogsOverview from "../../../components/reqlog/LogsOverview";
|
||||||
import Layout, { Page } from "../../../components/Layout";
|
import Layout, { Page } from "../../../components/Layout";
|
||||||
|
import Search from "../../../components/reqlog/Search";
|
||||||
|
|
||||||
function ProxyLogs(): JSX.Element {
|
function ProxyLogs(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<Layout page={Page.ProxyLogs} title="Proxy logs">
|
<Layout page={Page.ProxyLogs} title="Proxy logs">
|
||||||
|
<Box mb={2}>
|
||||||
|
<Search />
|
||||||
|
</Box>
|
||||||
<LogsOverview />
|
<LogsOverview />
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
37
admin/src/pages/scope/index.tsx
Normal file
37
admin/src/pages/scope/index.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { Box, Divider, Grid, Typography } from "@mui/material";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import Layout, { Page } from "../../components/Layout";
|
||||||
|
import AddRule from "../../components/scope/AddRule";
|
||||||
|
import Rules from "../../components/scope/Rules";
|
||||||
|
|
||||||
|
function Index(): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Layout page={Page.Scope} title="Scope">
|
||||||
|
<Box p={4}>
|
||||||
|
<Box mb={3}>
|
||||||
|
<Typography variant="h4">Scope</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography paragraph>
|
||||||
|
Scope rules are used by various modules in Hetty and can influence their behavior. For example: the Proxy logs
|
||||||
|
module can match incoming requests against scope rules and decide its behavior (e.g. log or bypass) based on
|
||||||
|
the outcome of the match. All scope configuration is stored per project.
|
||||||
|
</Typography>
|
||||||
|
<Box my={4}>
|
||||||
|
<Divider />
|
||||||
|
</Box>
|
||||||
|
<Grid container>
|
||||||
|
<Grid item xs={12} sm={12} md={8} lg={6}>
|
||||||
|
<AddRule />
|
||||||
|
<Box my={4}>
|
||||||
|
<Divider />
|
||||||
|
</Box>
|
||||||
|
<Rules />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Index;
|
@ -1,4 +1,4 @@
|
|||||||
import { Box, Typography } from "@material-ui/core";
|
import { Box, Typography } from "@mui/material";
|
||||||
|
|
||||||
import Layout, { Page } from "../../components/Layout";
|
import Layout, { Page } from "../../components/Layout";
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
],
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": false,
|
"strict": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
@ -16,7 +16,8 @@
|
|||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve"
|
"jsx": "preserve",
|
||||||
|
"incremental": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
6992
admin/yarn.lock
6992
admin/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -2,92 +2,122 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
rice "github.com/GeertJohan/go.rice"
|
|
||||||
"github.com/dstotijn/hetty/pkg/api"
|
|
||||||
"github.com/dstotijn/hetty/pkg/db/cayley"
|
|
||||||
"github.com/dstotijn/hetty/pkg/proxy"
|
|
||||||
"github.com/dstotijn/hetty/pkg/reqlog"
|
|
||||||
|
|
||||||
"github.com/99designs/gqlgen/graphql/handler"
|
"github.com/99designs/gqlgen/graphql/handler"
|
||||||
"github.com/99designs/gqlgen/graphql/playground"
|
"github.com/99designs/gqlgen/graphql/playground"
|
||||||
|
badgerdb "github.com/dgraph-io/badger/v3"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"github.com/mitchellh/go-homedir"
|
"github.com/mitchellh/go-homedir"
|
||||||
|
|
||||||
|
"github.com/dstotijn/hetty/pkg/api"
|
||||||
|
"github.com/dstotijn/hetty/pkg/db/badger"
|
||||||
|
"github.com/dstotijn/hetty/pkg/proj"
|
||||||
|
"github.com/dstotijn/hetty/pkg/proxy"
|
||||||
|
"github.com/dstotijn/hetty/pkg/reqlog"
|
||||||
|
"github.com/dstotijn/hetty/pkg/scope"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var version = "0.0.0"
|
||||||
|
|
||||||
|
// Flag variables.
|
||||||
var (
|
var (
|
||||||
caCertFile string
|
caCertFile string
|
||||||
caKeyFile string
|
caKeyFile string
|
||||||
dbFile string
|
dbPath string
|
||||||
addr string
|
addr string
|
||||||
adminPath string
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed admin
|
||||||
|
//go:embed admin/_next/static
|
||||||
|
//go:embed admin/_next/static/chunks/pages/*.js
|
||||||
|
//go:embed admin/_next/static/*/*.js
|
||||||
|
var adminContent embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
flag.StringVar(&caCertFile, "cert", "~/.hetty/hetty_cert.pem", "CA certificate filepath. Creates a new CA certificate is file doesn't exist")
|
if err := run(); err != nil {
|
||||||
flag.StringVar(&caKeyFile, "key", "~/.hetty/hetty_key.pem", "CA private key filepath. Creates a new CA private key if file doesn't exist")
|
log.Fatalf("[ERROR]: %v", err)
|
||||||
flag.StringVar(&dbFile, "db", "~/.hetty/hetty.bolt", "Database file path")
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() error {
|
||||||
|
flag.StringVar(&caCertFile, "cert", "~/.hetty/hetty_cert.pem",
|
||||||
|
"CA certificate filepath. Creates a new CA certificate if file doesn't exist")
|
||||||
|
flag.StringVar(&caKeyFile, "key", "~/.hetty/hetty_key.pem",
|
||||||
|
"CA private key filepath. Creates a new CA private key if file doesn't exist")
|
||||||
|
flag.StringVar(&dbPath, "db", "~/.hetty/db", "Database directory path")
|
||||||
flag.StringVar(&addr, "addr", ":8080", "TCP address to listen on, in the form \"host:port\"")
|
flag.StringVar(&addr, "addr", ":8080", "TCP address to listen on, in the form \"host:port\"")
|
||||||
flag.StringVar(&adminPath, "adminPath", "", "File path to admin build")
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// Expand `~` in filepaths.
|
// Expand `~` in filepaths.
|
||||||
caCertFile, err := homedir.Expand(caCertFile)
|
caCertFile, err := homedir.Expand(caCertFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("[FATAL] Could not parse CA certificate filepath: %v", err)
|
return fmt.Errorf("could not parse CA certificate filepath: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
caKeyFile, err := homedir.Expand(caKeyFile)
|
caKeyFile, err := homedir.Expand(caKeyFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("[FATAL] Could not parse CA private key filepath: %v", err)
|
return fmt.Errorf("could not parse CA private key filepath: %w", err)
|
||||||
}
|
}
|
||||||
dbFile, err := homedir.Expand(dbFile)
|
|
||||||
|
dbPath, err := homedir.Expand(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("[FATAL] Could not parse CA private key filepath: %v", err)
|
return fmt.Errorf("could not parse projects filepath: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load existing CA certificate and key from disk, or generate and write
|
// Load existing CA certificate and key from disk, or generate and write
|
||||||
// to disk if no files exist yet.
|
// to disk if no files exist yet.
|
||||||
caCert, caKey, err := proxy.LoadOrCreateCA(caKeyFile, caCertFile)
|
caCert, caKey, err := proxy.LoadOrCreateCA(caKeyFile, caCertFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("[FATAL] Could not create/load CA key pair: %v", err)
|
return fmt.Errorf("could not create/load CA key pair: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
db, err := cayley.NewDatabase(dbFile)
|
badger, err := badger.OpenDatabase(badgerdb.DefaultOptions(dbPath))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("[FATAL] Could not initialize database: %v", err)
|
return fmt.Errorf("could not open badger database: %w", err)
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer badger.Close()
|
||||||
|
|
||||||
reqLogService := reqlog.NewService(db)
|
scope := &scope.Scope{}
|
||||||
|
|
||||||
|
reqLogService := reqlog.NewService(reqlog.Config{
|
||||||
|
Scope: scope,
|
||||||
|
Repository: badger,
|
||||||
|
})
|
||||||
|
|
||||||
|
projService, err := proj.NewService(proj.Config{
|
||||||
|
Repository: badger,
|
||||||
|
ReqLogService: reqLogService,
|
||||||
|
Scope: scope,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not create new project service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
p, err := proxy.NewProxy(caCert, caKey)
|
p, err := proxy.NewProxy(caCert, caKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("[FATAL] Could not create Proxy: %v", err)
|
return fmt.Errorf("could not create proxy: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
p.UseRequestModifier(reqLogService.RequestModifier)
|
p.UseRequestModifier(reqLogService.RequestModifier)
|
||||||
p.UseResponseModifier(reqLogService.ResponseModifier)
|
p.UseResponseModifier(reqLogService.ResponseModifier)
|
||||||
|
|
||||||
var adminHandler http.Handler
|
fsSub, err := fs.Sub(adminContent, "admin")
|
||||||
if adminPath == "" {
|
if err != nil {
|
||||||
// Used for embedding with `rice`.
|
return fmt.Errorf("could not prepare subtree file system: %w", err)
|
||||||
box, err := rice.FindBox("../../admin/dist")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("[FATAL] Could not find embedded admin resources: %v", err)
|
|
||||||
}
|
|
||||||
adminHandler = http.FileServer(box.HTTPBox())
|
|
||||||
} else {
|
|
||||||
adminHandler = http.FileServer(http.Dir(adminPath))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
adminHandler := http.FileServer(http.FS(fsSub))
|
||||||
router := mux.NewRouter().SkipClean(true)
|
router := mux.NewRouter().SkipClean(true)
|
||||||
|
|
||||||
adminRouter := router.MatcherFunc(func(req *http.Request, match *mux.RouteMatch) bool {
|
adminRouter := router.MatcherFunc(func(req *http.Request, match *mux.RouteMatch) bool {
|
||||||
hostname, _ := os.Hostname()
|
hostname, _ := os.Hostname()
|
||||||
host, _, _ := net.SplitHostPort(req.Host)
|
host, _, _ := net.SplitHostPort(req.Host)
|
||||||
@ -96,9 +126,11 @@ func main() {
|
|||||||
|
|
||||||
// GraphQL server.
|
// GraphQL server.
|
||||||
adminRouter.Path("/api/playground/").Handler(playground.Handler("GraphQL Playground", "/api/graphql/"))
|
adminRouter.Path("/api/playground/").Handler(playground.Handler("GraphQL Playground", "/api/graphql/"))
|
||||||
adminRouter.Path("/api/graphql/").Handler(handler.NewDefaultServer(api.NewExecutableSchema(api.Config{Resolvers: &api.Resolver{
|
adminRouter.Path("/api/graphql/").Handler(
|
||||||
RequestLogService: reqLogService,
|
handler.NewDefaultServer(api.NewExecutableSchema(api.Config{Resolvers: &api.Resolver{
|
||||||
}})))
|
RequestLogService: reqLogService,
|
||||||
|
ProjectService: projService,
|
||||||
|
}})))
|
||||||
|
|
||||||
// Admin interface.
|
// Admin interface.
|
||||||
adminRouter.PathPrefix("").Handler(adminHandler)
|
adminRouter.PathPrefix("").Handler(adminHandler)
|
||||||
@ -112,9 +144,12 @@ func main() {
|
|||||||
TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){}, // Disable HTTP/2
|
TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){}, // Disable HTTP/2
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[INFO] Running server on %v ...", addr)
|
log.Printf("[INFO] Hetty (v%v) is running on %v ...", version, addr)
|
||||||
|
|
||||||
err = s.ListenAndServe()
|
err = s.ListenAndServe()
|
||||||
if err != nil && err != http.ErrServerClosed {
|
if err != nil && errors.Is(err, http.ErrServerClosed) {
|
||||||
log.Fatalf("[FATAL] HTTP server closed: %v", err)
|
return fmt.Errorf("http server closed unexpected: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
12
docs/.gitignore
vendored
Executable file
12
docs/.gitignore
vendored
Executable file
@ -0,0 +1,12 @@
|
|||||||
|
pids
|
||||||
|
logs
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
coverage/
|
||||||
|
run
|
||||||
|
dist
|
||||||
|
.DS_Store
|
||||||
|
.nyc_output
|
||||||
|
.basement
|
||||||
|
config.local.js
|
||||||
|
basement_dist
|
21
docs/package.json
Executable file
21
docs/package.json
Executable file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "hetty-docs",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "An HTTP toolkit for security research.",
|
||||||
|
"main": "index.js",
|
||||||
|
"authors": {
|
||||||
|
"name": "David Stotijn",
|
||||||
|
"email": "dstotijn@gmail.com"
|
||||||
|
},
|
||||||
|
"repository": "github.com/dstotijn/hetty/docs",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vuepress dev src",
|
||||||
|
"build": "vuepress build src"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"markdown-it-imsize": "^2.0.1",
|
||||||
|
"vuepress": "^1.5.3"
|
||||||
|
},
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
77
docs/src/.vuepress/config.js
Executable file
77
docs/src/.vuepress/config.js
Executable file
@ -0,0 +1,77 @@
|
|||||||
|
const { description } = require("../../package");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
port: 3000,
|
||||||
|
title: "Hetty",
|
||||||
|
description: description,
|
||||||
|
head: [
|
||||||
|
["meta", { name: "theme-color", content: "#30e3b7" }],
|
||||||
|
["meta", { name: "apple-mobile-web-app-capable", content: "yes" }],
|
||||||
|
[
|
||||||
|
"meta",
|
||||||
|
{ name: "apple-mobile-web-app-status-bar-style", content: "black" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"meta",
|
||||||
|
{
|
||||||
|
property: "og:title",
|
||||||
|
content: "Hetty",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"meta",
|
||||||
|
{
|
||||||
|
property: "og:description",
|
||||||
|
content: "An HTTP toolkit for security research.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"meta",
|
||||||
|
{
|
||||||
|
property: "og:image",
|
||||||
|
content: "https://hetty.xyz/assets/hetty_v0.2.0_header.png",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
themeConfig: {
|
||||||
|
repo: "dstotijn/hetty",
|
||||||
|
editLinks: true,
|
||||||
|
docsDir: "docs/src",
|
||||||
|
editLinkText: "",
|
||||||
|
lastUpdated: true,
|
||||||
|
logo: "/assets/logo.png",
|
||||||
|
nav: [
|
||||||
|
{
|
||||||
|
text: "Guide",
|
||||||
|
link: "/guide/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Appendix",
|
||||||
|
link: "/appendix/",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sidebar: {
|
||||||
|
"/guide/": [
|
||||||
|
{
|
||||||
|
title: "Guide",
|
||||||
|
collapsable: false,
|
||||||
|
children: ["", "getting-started", "modules"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"/appendix/": [
|
||||||
|
{
|
||||||
|
title: "Appendix",
|
||||||
|
collapsable: false,
|
||||||
|
children: [""],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: ["@vuepress/plugin-back-to-top", "@vuepress/plugin-medium-zoom"],
|
||||||
|
markdown: {
|
||||||
|
toc: { includeLevel: [2] },
|
||||||
|
extendMarkdown: (md) => {
|
||||||
|
md.use(require("markdown-it-imsize"));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
14
docs/src/.vuepress/enhanceApp.js
Executable file
14
docs/src/.vuepress/enhanceApp.js
Executable file
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Client app enhancement file.
|
||||||
|
*
|
||||||
|
* https://v1.vuepress.vuejs.org/guide/basic-config.html#app-level-enhancements
|
||||||
|
*/
|
||||||
|
|
||||||
|
export default ({
|
||||||
|
Vue, // the version of Vue being used in the VuePress app
|
||||||
|
options, // the options for the root Vue instance
|
||||||
|
router, // the router instance for the app
|
||||||
|
siteData // site metadata
|
||||||
|
}) => {
|
||||||
|
// ...apply enhancements for the site.
|
||||||
|
}
|
BIN
docs/src/.vuepress/public/assets/hetty_v0.2.0_header.png
Normal file
BIN
docs/src/.vuepress/public/assets/hetty_v0.2.0_header.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 144 KiB |
BIN
docs/src/.vuepress/public/assets/logo.png
Normal file
BIN
docs/src/.vuepress/public/assets/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
BIN
docs/src/.vuepress/public/assets/tines-sponsorship-badge.png
Normal file
BIN
docs/src/.vuepress/public/assets/tines-sponsorship-badge.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
9
docs/src/.vuepress/styles/index.styl
Executable file
9
docs/src/.vuepress/styles/index.styl
Executable file
@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Custom Styles here.
|
||||||
|
*
|
||||||
|
* ref:https://v1.vuepress.vuejs.org/config/#index-styl
|
||||||
|
*/
|
||||||
|
|
||||||
|
.home .hero img
|
||||||
|
width 450px
|
||||||
|
max-width 100%!important
|
11
docs/src/.vuepress/styles/palette.styl
Executable file
11
docs/src/.vuepress/styles/palette.styl
Executable file
@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Custom palette here.
|
||||||
|
*
|
||||||
|
* ref:https://v1.vuepress.vuejs.org/zh/config/#palette-styl
|
||||||
|
*/
|
||||||
|
|
||||||
|
$accentColor = #2CC09B
|
||||||
|
$textColor = #2c3e50
|
||||||
|
$borderColor = #eaecef
|
||||||
|
$codeBgColor = #282c34
|
||||||
|
$badgeTipColor = #2CC09B
|
21
docs/src/.vuepress/theme/LICENSE
Normal file
21
docs/src/.vuepress/theme/LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2018-present, Yuxi (Evan) You
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
171
docs/src/.vuepress/theme/components/AlgoliaSearchBox.vue
Normal file
171
docs/src/.vuepress/theme/components/AlgoliaSearchBox.vue
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
<template>
|
||||||
|
<form
|
||||||
|
id="search-form"
|
||||||
|
class="algolia-search-wrapper search-box"
|
||||||
|
role="search"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="algolia-search-input"
|
||||||
|
class="search-query"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'AlgoliaSearchBox',
|
||||||
|
|
||||||
|
props: ['options'],
|
||||||
|
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
placeholder: undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
$lang (newValue) {
|
||||||
|
this.update(this.options, newValue)
|
||||||
|
},
|
||||||
|
|
||||||
|
options (newValue) {
|
||||||
|
this.update(newValue, this.$lang)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted () {
|
||||||
|
this.initialize(this.options, this.$lang)
|
||||||
|
this.placeholder = this.$site.themeConfig.searchPlaceholder || ''
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
initialize (userOptions, lang) {
|
||||||
|
Promise.all([
|
||||||
|
import(/* webpackChunkName: "docsearch" */ 'docsearch.js/dist/cdn/docsearch.min.js'),
|
||||||
|
import(/* webpackChunkName: "docsearch" */ 'docsearch.js/dist/cdn/docsearch.min.css')
|
||||||
|
]).then(([docsearch]) => {
|
||||||
|
docsearch = docsearch.default
|
||||||
|
const { algoliaOptions = {}} = userOptions
|
||||||
|
docsearch(Object.assign(
|
||||||
|
{},
|
||||||
|
userOptions,
|
||||||
|
{
|
||||||
|
inputSelector: '#algolia-search-input',
|
||||||
|
// #697 Make docsearch work well at i18n mode.
|
||||||
|
algoliaOptions: Object.assign({
|
||||||
|
'facetFilters': [`lang:${lang}`].concat(algoliaOptions.facetFilters || [])
|
||||||
|
}, algoliaOptions),
|
||||||
|
handleSelected: (input, event, suggestion) => {
|
||||||
|
const { pathname, hash } = new URL(suggestion.url)
|
||||||
|
const routepath = pathname.replace(this.$site.base, '/')
|
||||||
|
const _hash = decodeURIComponent(hash)
|
||||||
|
this.$router.push(`${routepath}${_hash}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
))
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
update (options, lang) {
|
||||||
|
this.$el.innerHTML = '<input id="algolia-search-input" class="search-query">'
|
||||||
|
this.initialize(options, lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="stylus">
|
||||||
|
.algolia-search-wrapper
|
||||||
|
& > span
|
||||||
|
vertical-align middle
|
||||||
|
.algolia-autocomplete
|
||||||
|
line-height normal
|
||||||
|
.ds-dropdown-menu
|
||||||
|
background-color #fff
|
||||||
|
border 1px solid #999
|
||||||
|
border-radius 4px
|
||||||
|
font-size 16px
|
||||||
|
margin 6px 0 0
|
||||||
|
padding 4px
|
||||||
|
text-align left
|
||||||
|
&:before
|
||||||
|
border-color #999
|
||||||
|
[class*=ds-dataset-]
|
||||||
|
border none
|
||||||
|
padding 0
|
||||||
|
.ds-suggestions
|
||||||
|
margin-top 0
|
||||||
|
.ds-suggestion
|
||||||
|
border-bottom 1px solid $borderColor
|
||||||
|
.algolia-docsearch-suggestion--highlight
|
||||||
|
color #2c815b
|
||||||
|
.algolia-docsearch-suggestion
|
||||||
|
border-color $borderColor
|
||||||
|
padding 0
|
||||||
|
.algolia-docsearch-suggestion--category-header
|
||||||
|
padding 5px 10px
|
||||||
|
margin-top 0
|
||||||
|
background $accentColor
|
||||||
|
color #fff
|
||||||
|
font-weight 600
|
||||||
|
.algolia-docsearch-suggestion--highlight
|
||||||
|
background rgba(255, 255, 255, 0.6)
|
||||||
|
.algolia-docsearch-suggestion--wrapper
|
||||||
|
padding 0
|
||||||
|
.algolia-docsearch-suggestion--title
|
||||||
|
font-weight 600
|
||||||
|
margin-bottom 0
|
||||||
|
color $textColor
|
||||||
|
.algolia-docsearch-suggestion--subcategory-column
|
||||||
|
vertical-align top
|
||||||
|
padding 5px 7px 5px 5px
|
||||||
|
border-color $borderColor
|
||||||
|
background #f1f3f5
|
||||||
|
&:after
|
||||||
|
display none
|
||||||
|
.algolia-docsearch-suggestion--subcategory-column-text
|
||||||
|
color #555
|
||||||
|
.algolia-docsearch-footer
|
||||||
|
border-color $borderColor
|
||||||
|
.ds-cursor .algolia-docsearch-suggestion--content
|
||||||
|
background-color #e7edf3 !important
|
||||||
|
color $textColor
|
||||||
|
|
||||||
|
@media (min-width: $MQMobile)
|
||||||
|
.algolia-search-wrapper
|
||||||
|
.algolia-autocomplete
|
||||||
|
.algolia-docsearch-suggestion
|
||||||
|
.algolia-docsearch-suggestion--subcategory-column
|
||||||
|
float none
|
||||||
|
width 150px
|
||||||
|
min-width 150px
|
||||||
|
display table-cell
|
||||||
|
.algolia-docsearch-suggestion--content
|
||||||
|
float none
|
||||||
|
display table-cell
|
||||||
|
width 100%
|
||||||
|
vertical-align top
|
||||||
|
.ds-dropdown-menu
|
||||||
|
min-width 515px !important
|
||||||
|
|
||||||
|
@media (max-width: $MQMobile)
|
||||||
|
.algolia-search-wrapper
|
||||||
|
.ds-dropdown-menu
|
||||||
|
min-width calc(100vw - 4rem) !important
|
||||||
|
max-width calc(100vw - 4rem) !important
|
||||||
|
.algolia-docsearch-suggestion--wrapper
|
||||||
|
padding 5px 7px 5px 5px !important
|
||||||
|
.algolia-docsearch-suggestion--subcategory-column
|
||||||
|
padding 0 !important
|
||||||
|
background white !important
|
||||||
|
.algolia-docsearch-suggestion--subcategory-column-text:after
|
||||||
|
content " > "
|
||||||
|
font-size 10px
|
||||||
|
line-height 14.4px
|
||||||
|
display inline-block
|
||||||
|
width 5px
|
||||||
|
margin -3px 3px 0
|
||||||
|
vertical-align middle
|
||||||
|
|
||||||
|
</style>
|
252
docs/src/.vuepress/theme/components/DropdownLink.vue
Normal file
252
docs/src/.vuepress/theme/components/DropdownLink.vue
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="dropdown-wrapper"
|
||||||
|
:class="{ open }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="dropdown-title"
|
||||||
|
type="button"
|
||||||
|
:aria-label="dropdownAriaLabel"
|
||||||
|
@click="handleDropdown"
|
||||||
|
>
|
||||||
|
<span class="title">{{ item.text }}</span>
|
||||||
|
<span
|
||||||
|
class="arrow down"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mobile-dropdown-title"
|
||||||
|
type="button"
|
||||||
|
:aria-label="dropdownAriaLabel"
|
||||||
|
@click="setOpen(!open)"
|
||||||
|
>
|
||||||
|
<span class="title">{{ item.text }}</span>
|
||||||
|
<span
|
||||||
|
class="arrow"
|
||||||
|
:class="open ? 'down' : 'right'"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<DropdownTransition>
|
||||||
|
<ul
|
||||||
|
v-show="open"
|
||||||
|
class="nav-dropdown"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="(subItem, index) in item.items"
|
||||||
|
:key="subItem.link || index"
|
||||||
|
class="dropdown-item"
|
||||||
|
>
|
||||||
|
<h4 v-if="subItem.type === 'links'">
|
||||||
|
{{ subItem.text }}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
v-if="subItem.type === 'links'"
|
||||||
|
class="dropdown-subitem-wrapper"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="childSubItem in subItem.items"
|
||||||
|
:key="childSubItem.link"
|
||||||
|
class="dropdown-subitem"
|
||||||
|
>
|
||||||
|
<NavLink
|
||||||
|
:item="childSubItem"
|
||||||
|
@focusout="
|
||||||
|
isLastItemOfArray(childSubItem, subItem.items) &&
|
||||||
|
isLastItemOfArray(subItem, item.items) &&
|
||||||
|
setOpen(false)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<NavLink
|
||||||
|
v-else
|
||||||
|
:item="subItem"
|
||||||
|
@focusout="isLastItemOfArray(subItem, item.items) && setOpen(false)"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</DropdownTransition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import NavLink from '@theme/components/NavLink.vue'
|
||||||
|
import DropdownTransition from '@theme/components/DropdownTransition.vue'
|
||||||
|
import last from 'lodash/last'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'DropdownLink',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
NavLink,
|
||||||
|
DropdownTransition
|
||||||
|
},
|
||||||
|
|
||||||
|
props: {
|
||||||
|
item: {
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
open: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
dropdownAriaLabel () {
|
||||||
|
return this.item.ariaLabel || this.item.text
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
$route () {
|
||||||
|
this.open = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
setOpen (value) {
|
||||||
|
this.open = value
|
||||||
|
},
|
||||||
|
|
||||||
|
isLastItemOfArray (item, array) {
|
||||||
|
return last(array) === item
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the dropdown when user tab and click from keyboard.
|
||||||
|
*
|
||||||
|
* Use event.detail to detect tab and click from keyboard. Ref: https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/detail
|
||||||
|
* The Tab + Click is UIEvent > KeyboardEvent, so the detail is 0.
|
||||||
|
*/
|
||||||
|
handleDropdown () {
|
||||||
|
const isTriggerByTab = event.detail === 0
|
||||||
|
if (isTriggerByTab) this.setOpen(!this.open)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="stylus">
|
||||||
|
.dropdown-wrapper
|
||||||
|
cursor pointer
|
||||||
|
.dropdown-title
|
||||||
|
display block
|
||||||
|
font-size 0.9rem
|
||||||
|
font-family inherit
|
||||||
|
cursor inherit
|
||||||
|
padding inherit
|
||||||
|
line-height 1.4rem
|
||||||
|
background transparent
|
||||||
|
border none
|
||||||
|
font-weight 500
|
||||||
|
color $textColor
|
||||||
|
&:hover
|
||||||
|
border-color transparent
|
||||||
|
.arrow
|
||||||
|
vertical-align middle
|
||||||
|
margin-top -1px
|
||||||
|
margin-left 0.4rem
|
||||||
|
.mobile-dropdown-title
|
||||||
|
@extends .dropdown-title
|
||||||
|
display none
|
||||||
|
font-weight 600
|
||||||
|
font-size inherit
|
||||||
|
&:hover
|
||||||
|
color $accentColor
|
||||||
|
.nav-dropdown
|
||||||
|
.dropdown-item
|
||||||
|
color inherit
|
||||||
|
line-height 1.7rem
|
||||||
|
h4
|
||||||
|
margin 0.45rem 0 0
|
||||||
|
border-top 1px solid #eee
|
||||||
|
padding 1rem 1.5rem 0.45rem 1.25rem
|
||||||
|
.dropdown-subitem-wrapper
|
||||||
|
padding 0
|
||||||
|
list-style none
|
||||||
|
.dropdown-subitem
|
||||||
|
font-size 0.9em
|
||||||
|
a
|
||||||
|
display block
|
||||||
|
line-height 1.7rem
|
||||||
|
position relative
|
||||||
|
border-bottom none
|
||||||
|
font-weight 400
|
||||||
|
margin-bottom 0
|
||||||
|
padding 0 1.5rem 0 1.25rem
|
||||||
|
&:hover
|
||||||
|
color $accentColor
|
||||||
|
&.router-link-active
|
||||||
|
color $accentColor
|
||||||
|
&::after
|
||||||
|
content ""
|
||||||
|
width 0
|
||||||
|
height 0
|
||||||
|
border-left 5px solid $accentColor
|
||||||
|
border-top 3px solid transparent
|
||||||
|
border-bottom 3px solid transparent
|
||||||
|
position absolute
|
||||||
|
top calc(50% - 2px)
|
||||||
|
left 9px
|
||||||
|
&:first-child h4
|
||||||
|
margin-top 0
|
||||||
|
padding-top 0
|
||||||
|
border-top 0
|
||||||
|
|
||||||
|
@media (max-width: $MQMobile)
|
||||||
|
.dropdown-wrapper
|
||||||
|
&.open .dropdown-title
|
||||||
|
margin-bottom 0.5rem
|
||||||
|
.dropdown-title
|
||||||
|
display: none
|
||||||
|
.mobile-dropdown-title
|
||||||
|
display: block
|
||||||
|
.nav-dropdown
|
||||||
|
transition height .1s ease-out
|
||||||
|
overflow hidden
|
||||||
|
.dropdown-item
|
||||||
|
h4
|
||||||
|
border-top 0
|
||||||
|
margin-top 0
|
||||||
|
padding-top 0
|
||||||
|
h4, & > a
|
||||||
|
font-size 15px
|
||||||
|
line-height 2rem
|
||||||
|
.dropdown-subitem
|
||||||
|
font-size 14px
|
||||||
|
padding-left 1rem
|
||||||
|
|
||||||
|
@media (min-width: $MQMobile)
|
||||||
|
.dropdown-wrapper
|
||||||
|
height 1.8rem
|
||||||
|
&:hover .nav-dropdown,
|
||||||
|
&.open .nav-dropdown
|
||||||
|
// override the inline style.
|
||||||
|
display block !important
|
||||||
|
&.open:blur
|
||||||
|
display none
|
||||||
|
.nav-dropdown
|
||||||
|
display none
|
||||||
|
// Avoid height shaked by clicking
|
||||||
|
height auto !important
|
||||||
|
box-sizing border-box;
|
||||||
|
max-height calc(100vh - 2.7rem)
|
||||||
|
overflow-y auto
|
||||||
|
position absolute
|
||||||
|
top 100%
|
||||||
|
right 0
|
||||||
|
background-color #fff
|
||||||
|
padding 0.6rem 0
|
||||||
|
border 1px solid #ddd
|
||||||
|
border-bottom-color #ccc
|
||||||
|
text-align left
|
||||||
|
border-radius 0.25rem
|
||||||
|
white-space nowrap
|
||||||
|
margin 0
|
||||||
|
</style>
|
33
docs/src/.vuepress/theme/components/DropdownTransition.vue
Normal file
33
docs/src/.vuepress/theme/components/DropdownTransition.vue
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<transition
|
||||||
|
name="dropdown"
|
||||||
|
@enter="setHeight"
|
||||||
|
@after-enter="unsetHeight"
|
||||||
|
@before-leave="setHeight"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'DropdownTransition',
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
setHeight (items) {
|
||||||
|
// explicitly set height so that it can be transitioned
|
||||||
|
items.style.height = items.scrollHeight + 'px'
|
||||||
|
},
|
||||||
|
|
||||||
|
unsetHeight (items) {
|
||||||
|
items.style.height = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="stylus">
|
||||||
|
.dropdown-enter, .dropdown-leave-to
|
||||||
|
height 0 !important
|
||||||
|
|
||||||
|
</style>
|
197
docs/src/.vuepress/theme/components/Home.vue
Normal file
197
docs/src/.vuepress/theme/components/Home.vue
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
<template>
|
||||||
|
<main
|
||||||
|
class="home"
|
||||||
|
:aria-labelledby="data.heroText !== null ? 'main-title' : null"
|
||||||
|
>
|
||||||
|
<header class="hero">
|
||||||
|
<h1 v-if="data.heroImage" id="main-title">
|
||||||
|
<img :src="$withBase(data.heroImage)" :alt="data.heroAlt || 'hero'" />
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p v-if="data.tagline !== null" class="description">
|
||||||
|
{{ data.tagline || $description || "Welcome to your VuePress site" }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p v-if="data.actionText && data.actionLink" class="action">
|
||||||
|
<NavLink class="action-button" :item="actionLink" />
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="data.features && data.features.length" class="features">
|
||||||
|
<div
|
||||||
|
v-for="(feature, index) in data.features"
|
||||||
|
:key="index"
|
||||||
|
class="feature"
|
||||||
|
>
|
||||||
|
<h2>{{ feature.title }}</h2>
|
||||||
|
<p>{{ feature.details }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Content class="theme-default-content custom" />
|
||||||
|
|
||||||
|
<div v-if="data.footer" class="footer">
|
||||||
|
{{ data.footer }}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import NavLink from "@theme/components/NavLink.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "Home",
|
||||||
|
|
||||||
|
components: { NavLink },
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
data() {
|
||||||
|
return this.$page.frontmatter;
|
||||||
|
},
|
||||||
|
|
||||||
|
actionLink() {
|
||||||
|
return {
|
||||||
|
link: this.data.actionLink,
|
||||||
|
text: this.data.actionText,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="stylus">
|
||||||
|
.home {
|
||||||
|
padding: $navbarHeight 2rem 0;
|
||||||
|
max-width: $homePageWidth;
|
||||||
|
margin: 0px auto;
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 280px;
|
||||||
|
display: block;
|
||||||
|
margin: 3rem auto 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, .description, .action {
|
||||||
|
margin: 1.8rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
max-width: 35rem;
|
||||||
|
font-size: 1.6rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: lighten($textColor, 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: #fff;
|
||||||
|
background-color: $accentColor;
|
||||||
|
padding: 0.8rem 1.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.1s ease;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-bottom: 1px solid darken($accentColor, 10%);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: lighten($accentColor, 10%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.features {
|
||||||
|
border-top: 1px solid $borderColor;
|
||||||
|
padding: 1.2rem 0;
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
align-content: stretch;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-basis: 30%;
|
||||||
|
max-width: 30%;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0;
|
||||||
|
color: lighten($textColor, 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: lighten($textColor, 25%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 2.5rem;
|
||||||
|
border-top: 1px solid $borderColor;
|
||||||
|
text-align: center;
|
||||||
|
color: lighten($textColor, 25%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $MQMobile) {
|
||||||
|
.home {
|
||||||
|
.features {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0 2.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $MQMobileNarrow) {
|
||||||
|
.home {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
padding-right: 1.5rem;
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
img {
|
||||||
|
max-height: 210px;
|
||||||
|
margin: 2rem auto 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, .description, .action {
|
||||||
|
margin: 1.2rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature {
|
||||||
|
h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
90
docs/src/.vuepress/theme/components/NavLink.vue
Normal file
90
docs/src/.vuepress/theme/components/NavLink.vue
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<RouterLink
|
||||||
|
v-if="isInternal"
|
||||||
|
class="nav-link"
|
||||||
|
:to="link"
|
||||||
|
:exact="exact"
|
||||||
|
@focusout.native="focusoutAction"
|
||||||
|
>
|
||||||
|
{{ item.text }}
|
||||||
|
</RouterLink>
|
||||||
|
<a
|
||||||
|
v-else
|
||||||
|
:href="link"
|
||||||
|
class="nav-link external"
|
||||||
|
:target="target"
|
||||||
|
:rel="rel"
|
||||||
|
@focusout="focusoutAction"
|
||||||
|
>
|
||||||
|
{{ item.text }}
|
||||||
|
<OutboundLink v-if="isBlankTarget" />
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { isExternal, isMailto, isTel, ensureExt } from '../util'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'NavLink',
|
||||||
|
|
||||||
|
props: {
|
||||||
|
item: {
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
link () {
|
||||||
|
return ensureExt(this.item.link)
|
||||||
|
},
|
||||||
|
|
||||||
|
exact () {
|
||||||
|
if (this.$site.locales) {
|
||||||
|
return Object.keys(this.$site.locales).some(rootLink => rootLink === this.link)
|
||||||
|
}
|
||||||
|
return this.link === '/'
|
||||||
|
},
|
||||||
|
|
||||||
|
isNonHttpURI () {
|
||||||
|
return isMailto(this.link) || isTel(this.link)
|
||||||
|
},
|
||||||
|
|
||||||
|
isBlankTarget () {
|
||||||
|
return this.target === '_blank'
|
||||||
|
},
|
||||||
|
|
||||||
|
isInternal () {
|
||||||
|
return !isExternal(this.link) && !this.isBlankTarget
|
||||||
|
},
|
||||||
|
|
||||||
|
target () {
|
||||||
|
if (this.isNonHttpURI) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (this.item.target) {
|
||||||
|
return this.item.target
|
||||||
|
}
|
||||||
|
return isExternal(this.link) ? '_blank' : ''
|
||||||
|
},
|
||||||
|
|
||||||
|
rel () {
|
||||||
|
if (this.isNonHttpURI) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (this.item.rel === false) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (this.item.rel) {
|
||||||
|
return this.item.rel
|
||||||
|
}
|
||||||
|
return this.isBlankTarget ? 'noopener noreferrer' : null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
focusoutAction () {
|
||||||
|
this.$emit('focusout')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
156
docs/src/.vuepress/theme/components/NavLinks.vue
Normal file
156
docs/src/.vuepress/theme/components/NavLinks.vue
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
<template>
|
||||||
|
<nav
|
||||||
|
v-if="userLinks.length || repoLink"
|
||||||
|
class="nav-links"
|
||||||
|
>
|
||||||
|
<!-- user links -->
|
||||||
|
<div
|
||||||
|
v-for="item in userLinks"
|
||||||
|
:key="item.link"
|
||||||
|
class="nav-item"
|
||||||
|
>
|
||||||
|
<DropdownLink
|
||||||
|
v-if="item.type === 'links'"
|
||||||
|
:item="item"
|
||||||
|
/>
|
||||||
|
<NavLink
|
||||||
|
v-else
|
||||||
|
:item="item"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- repo link -->
|
||||||
|
<a
|
||||||
|
v-if="repoLink"
|
||||||
|
:href="repoLink"
|
||||||
|
class="repo-link"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{{ repoLabel }}
|
||||||
|
<OutboundLink />
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import DropdownLink from '@theme/components/DropdownLink.vue'
|
||||||
|
import { resolveNavLinkItem } from '../util'
|
||||||
|
import NavLink from '@theme/components/NavLink.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'NavLinks',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
NavLink,
|
||||||
|
DropdownLink
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
userNav () {
|
||||||
|
return this.$themeLocaleConfig.nav || this.$site.themeConfig.nav || []
|
||||||
|
},
|
||||||
|
|
||||||
|
nav () {
|
||||||
|
const { locales } = this.$site
|
||||||
|
if (locales && Object.keys(locales).length > 1) {
|
||||||
|
const currentLink = this.$page.path
|
||||||
|
const routes = this.$router.options.routes
|
||||||
|
const themeLocales = this.$site.themeConfig.locales || {}
|
||||||
|
const languageDropdown = {
|
||||||
|
text: this.$themeLocaleConfig.selectText || 'Languages',
|
||||||
|
ariaLabel: this.$themeLocaleConfig.ariaLabel || 'Select language',
|
||||||
|
items: Object.keys(locales).map(path => {
|
||||||
|
const locale = locales[path]
|
||||||
|
const text = themeLocales[path] && themeLocales[path].label || locale.lang
|
||||||
|
let link
|
||||||
|
// Stay on the current page
|
||||||
|
if (locale.lang === this.$lang) {
|
||||||
|
link = currentLink
|
||||||
|
} else {
|
||||||
|
// Try to stay on the same page
|
||||||
|
link = currentLink.replace(this.$localeConfig.path, path)
|
||||||
|
// fallback to homepage
|
||||||
|
if (!routes.some(route => route.path === link)) {
|
||||||
|
link = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { text, link }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return [...this.userNav, languageDropdown]
|
||||||
|
}
|
||||||
|
return this.userNav
|
||||||
|
},
|
||||||
|
|
||||||
|
userLinks () {
|
||||||
|
return (this.nav || []).map(link => {
|
||||||
|
return Object.assign(resolveNavLinkItem(link), {
|
||||||
|
items: (link.items || []).map(resolveNavLinkItem)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
repoLink () {
|
||||||
|
const { repo } = this.$site.themeConfig
|
||||||
|
if (repo) {
|
||||||
|
return /^https?:/.test(repo)
|
||||||
|
? repo
|
||||||
|
: `https://github.com/${repo}`
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
repoLabel () {
|
||||||
|
if (!this.repoLink) return
|
||||||
|
if (this.$site.themeConfig.repoLabel) {
|
||||||
|
return this.$site.themeConfig.repoLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoHost = this.repoLink.match(/^https?:\/\/[^/]+/)[0]
|
||||||
|
const platforms = ['GitHub', 'GitLab', 'Bitbucket']
|
||||||
|
for (let i = 0; i < platforms.length; i++) {
|
||||||
|
const platform = platforms[i]
|
||||||
|
if (new RegExp(platform, 'i').test(repoHost)) {
|
||||||
|
return platform
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Source'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="stylus">
|
||||||
|
.nav-links
|
||||||
|
display inline-block
|
||||||
|
a
|
||||||
|
line-height 1.4rem
|
||||||
|
color inherit
|
||||||
|
&:hover, &.router-link-active
|
||||||
|
color $accentColor
|
||||||
|
.nav-item
|
||||||
|
position relative
|
||||||
|
display inline-block
|
||||||
|
margin-left 1.5rem
|
||||||
|
line-height 2rem
|
||||||
|
&:first-child
|
||||||
|
margin-left 0
|
||||||
|
.repo-link
|
||||||
|
margin-left 1.5rem
|
||||||
|
|
||||||
|
@media (max-width: $MQMobile)
|
||||||
|
.nav-links
|
||||||
|
.nav-item, .repo-link
|
||||||
|
margin-left 0
|
||||||
|
|
||||||
|
@media (min-width: $MQMobile)
|
||||||
|
.nav-links a
|
||||||
|
&:hover, &.router-link-active
|
||||||
|
color $textColor
|
||||||
|
.nav-item > a:not(.external)
|
||||||
|
&:hover, &.router-link-active
|
||||||
|
margin-bottom -2px
|
||||||
|
border-bottom 2px solid lighten($accentColor, 8%)
|
||||||
|
</style>
|
162
docs/src/.vuepress/theme/components/Navbar.vue
Normal file
162
docs/src/.vuepress/theme/components/Navbar.vue
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<header class="navbar">
|
||||||
|
<SidebarButton @toggle-sidebar="$emit('toggle-sidebar')" />
|
||||||
|
|
||||||
|
<RouterLink :to="$localePath" class="home-link">
|
||||||
|
<img
|
||||||
|
v-if="$site.themeConfig.logo"
|
||||||
|
class="logo"
|
||||||
|
:src="$withBase($site.themeConfig.logo)"
|
||||||
|
:alt="$siteTitle"
|
||||||
|
/>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="links"
|
||||||
|
:style="
|
||||||
|
linksWrapMaxWidth
|
||||||
|
? {
|
||||||
|
'max-width': linksWrapMaxWidth + 'px',
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<AlgoliaSearchBox v-if="isAlgoliaSearch" :options="algolia" />
|
||||||
|
<SearchBox
|
||||||
|
v-else-if="
|
||||||
|
$site.themeConfig.search !== false &&
|
||||||
|
$page.frontmatter.search !== false
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<NavLinks class="can-hide" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import AlgoliaSearchBox from "@AlgoliaSearchBox";
|
||||||
|
import SearchBox from "@SearchBox";
|
||||||
|
import SidebarButton from "@theme/components/SidebarButton.vue";
|
||||||
|
import NavLinks from "@theme/components/NavLinks.vue";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "Navbar",
|
||||||
|
|
||||||
|
components: {
|
||||||
|
SidebarButton,
|
||||||
|
NavLinks,
|
||||||
|
SearchBox,
|
||||||
|
AlgoliaSearchBox,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
linksWrapMaxWidth: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
algolia() {
|
||||||
|
return (
|
||||||
|
this.$themeLocaleConfig.algolia || this.$site.themeConfig.algolia || {}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
isAlgoliaSearch() {
|
||||||
|
return this.algolia && this.algolia.apiKey && this.algolia.indexName;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
const MOBILE_DESKTOP_BREAKPOINT = 719; // refer to config.styl
|
||||||
|
const NAVBAR_VERTICAL_PADDING =
|
||||||
|
parseInt(css(this.$el, "paddingLeft")) +
|
||||||
|
parseInt(css(this.$el, "paddingRight"));
|
||||||
|
const handleLinksWrapWidth = () => {
|
||||||
|
if (document.documentElement.clientWidth < MOBILE_DESKTOP_BREAKPOINT) {
|
||||||
|
this.linksWrapMaxWidth = null;
|
||||||
|
} else {
|
||||||
|
this.linksWrapMaxWidth =
|
||||||
|
this.$el.offsetWidth -
|
||||||
|
NAVBAR_VERTICAL_PADDING -
|
||||||
|
((this.$refs.siteName && this.$refs.siteName.offsetWidth) || 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
handleLinksWrapWidth();
|
||||||
|
window.addEventListener("resize", handleLinksWrapWidth, false);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function css(el, property) {
|
||||||
|
// NOTE: Known bug, will return 'auto' if style value is 'auto'
|
||||||
|
const win = el.ownerDocument.defaultView;
|
||||||
|
// null means not to return pseudo styles
|
||||||
|
return win.getComputedStyle(el, null)[property];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="stylus">
|
||||||
|
$navbar-vertical-padding = 0.7rem;
|
||||||
|
$navbar-horizontal-padding = 1.5rem;
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
padding: $navbar-vertical-padding $navbar-horizontal-padding;
|
||||||
|
line-height: $navbarHeight - 1.4rem;
|
||||||
|
|
||||||
|
a, span, img {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: $navbarHeight - 1.4rem;
|
||||||
|
min-width: $navbarHeight - 1.4rem;
|
||||||
|
margin-right: 0.8rem;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-name {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $textColor;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: white;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
position: absolute;
|
||||||
|
right: $navbar-horizontal-padding;
|
||||||
|
top: $navbar-vertical-padding;
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: $MQMobile) {
|
||||||
|
.navbar {
|
||||||
|
padding-left: 4rem;
|
||||||
|
|
||||||
|
.can-hide {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.links {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-name {
|
||||||
|
width: calc(100vw - 9.4rem);
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
31
docs/src/.vuepress/theme/components/Page.vue
Normal file
31
docs/src/.vuepress/theme/components/Page.vue
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<main class="page">
|
||||||
|
<slot name="top" />
|
||||||
|
|
||||||
|
<Content class="theme-default-content" />
|
||||||
|
<PageEdit />
|
||||||
|
|
||||||
|
<PageNav v-bind="{ sidebarItems }" />
|
||||||
|
|
||||||
|
<slot name="bottom" />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import PageEdit from '@theme/components/PageEdit.vue'
|
||||||
|
import PageNav from '@theme/components/PageNav.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { PageEdit, PageNav },
|
||||||
|
props: ['sidebarItems']
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="stylus">
|
||||||
|
@require '../styles/wrapper.styl'
|
||||||
|
|
||||||
|
.page
|
||||||
|
padding-bottom 2rem
|
||||||
|
display block
|
||||||
|
|
||||||
|
</style>
|
155
docs/src/.vuepress/theme/components/PageEdit.vue
Normal file
155
docs/src/.vuepress/theme/components/PageEdit.vue
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
<template>
|
||||||
|
<footer class="page-edit">
|
||||||
|
<div
|
||||||
|
v-if="editLink"
|
||||||
|
class="edit-link"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
:href="editLink"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>{{ editLinkText }}</a>
|
||||||
|
<OutboundLink />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="lastUpdated"
|
||||||
|
class="last-updated"
|
||||||
|
>
|
||||||
|
<span class="prefix">{{ lastUpdatedText }}:</span>
|
||||||
|
<span class="time">{{ lastUpdated }}</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import isNil from 'lodash/isNil'
|
||||||
|
import { endingSlashRE, outboundRE } from '../util'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'PageEdit',
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
lastUpdated () {
|
||||||
|
return this.$page.lastUpdated
|
||||||
|
},
|
||||||
|
|
||||||
|
lastUpdatedText () {
|
||||||
|
if (typeof this.$themeLocaleConfig.lastUpdated === 'string') {
|
||||||
|
return this.$themeLocaleConfig.lastUpdated
|
||||||
|
}
|
||||||
|
if (typeof this.$site.themeConfig.lastUpdated === 'string') {
|
||||||
|
return this.$site.themeConfig.lastUpdated
|
||||||
|
}
|
||||||
|
return 'Last Updated'
|
||||||
|
},
|
||||||
|
|
||||||
|
editLink () {
|
||||||
|
const showEditLink = isNil(this.$page.frontmatter.editLink)
|
||||||
|
? this.$site.themeConfig.editLinks
|
||||||
|
: this.$page.frontmatter.editLink
|
||||||
|
|
||||||
|
const {
|
||||||
|
repo,
|
||||||
|
docsDir = '',
|
||||||
|
docsBranch = 'master',
|
||||||
|
docsRepo = repo
|
||||||
|
} = this.$site.themeConfig
|
||||||
|
|
||||||
|
if (showEditLink && docsRepo && this.$page.relativePath) {
|
||||||
|
return this.createEditLink(
|
||||||
|
repo,
|
||||||
|
docsRepo,
|
||||||
|
docsDir,
|
||||||
|
docsBranch,
|
||||||
|
this.$page.relativePath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
|
||||||
|
editLinkText () {
|
||||||
|
return (
|
||||||
|
this.$themeLocaleConfig.editLinkText
|
||||||
|
|| this.$site.themeConfig.editLinkText
|
||||||
|
|| `Edit this page`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
createEditLink (repo, docsRepo, docsDir, docsBranch, path) {
|
||||||
|
const bitbucket = /bitbucket.org/
|
||||||
|
if (bitbucket.test(docsRepo)) {
|
||||||
|
const base = docsRepo
|
||||||
|
return (
|
||||||
|
base.replace(endingSlashRE, '')
|
||||||
|
+ `/src`
|
||||||
|
+ `/${docsBranch}/`
|
||||||
|
+ (docsDir ? docsDir.replace(endingSlashRE, '') + '/' : '')
|
||||||
|
+ path
|
||||||
|
+ `?mode=edit&spa=0&at=${docsBranch}&fileviewer=file-view-default`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitlab = /gitlab.com/
|
||||||
|
if (gitlab.test(docsRepo)) {
|
||||||
|
const base = docsRepo
|
||||||
|
return (
|
||||||
|
base.replace(endingSlashRE, '')
|
||||||
|
+ `/-/edit`
|
||||||
|
+ `/${docsBranch}/`
|
||||||
|
+ (docsDir ? docsDir.replace(endingSlashRE, '') + '/' : '')
|
||||||
|
+ path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = outboundRE.test(docsRepo)
|
||||||
|
? docsRepo
|
||||||
|
: `https://github.com/${docsRepo}`
|
||||||
|
return (
|
||||||
|
base.replace(endingSlashRE, '')
|
||||||
|
+ '/edit'
|
||||||
|
+ `/${docsBranch}/`
|
||||||
|
+ (docsDir ? docsDir.replace(endingSlashRE, '') + '/' : '')
|
||||||
|
+ path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="stylus">
|
||||||
|
@require '../styles/wrapper.styl'
|
||||||
|
|
||||||
|
.page-edit
|
||||||
|
@extend $wrapper
|
||||||
|
padding-top 1rem
|
||||||
|
padding-bottom 1rem
|
||||||
|
overflow auto
|
||||||
|
|
||||||
|
.edit-link
|
||||||
|
display inline-block
|
||||||
|
a
|
||||||
|
color lighten($textColor, 25%)
|
||||||
|
margin-right 0.25rem
|
||||||
|
.last-updated
|
||||||
|
float right
|
||||||
|
font-size 0.9em
|
||||||
|
.prefix
|
||||||
|
font-weight 500
|
||||||
|
color lighten($textColor, 25%)
|
||||||
|
.time
|
||||||
|
font-weight 400
|
||||||
|
color #767676
|
||||||
|
|
||||||
|
@media (max-width: $MQMobile)
|
||||||
|
.page-edit
|
||||||
|
.edit-link
|
||||||
|
margin-bottom 0.5rem
|
||||||
|
.last-updated
|
||||||
|
font-size 0.8em
|
||||||
|
float none
|
||||||
|
text-align left
|
||||||
|
|
||||||
|
</style>
|
163
docs/src/.vuepress/theme/components/PageNav.vue
Normal file
163
docs/src/.vuepress/theme/components/PageNav.vue
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="prev || next"
|
||||||
|
class="page-nav"
|
||||||
|
>
|
||||||
|
<p class="inner">
|
||||||
|
<span
|
||||||
|
v-if="prev"
|
||||||
|
class="prev"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
<a
|
||||||
|
v-if="prev.type === 'external'"
|
||||||
|
class="prev"
|
||||||
|
:href="prev.path"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{{ prev.title || prev.path }}
|
||||||
|
|
||||||
|
<OutboundLink />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
v-else
|
||||||
|
class="prev"
|
||||||
|
:to="prev.path"
|
||||||
|
>
|
||||||
|
{{ prev.title || prev.path }}
|
||||||
|
</RouterLink>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="next"
|
||||||
|
class="next"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
v-if="next.type === 'external'"
|
||||||
|
:href="next.path"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{{ next.title || next.path }}
|
||||||
|
|
||||||
|
<OutboundLink />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
v-else
|
||||||
|
:to="next.path"
|
||||||
|
>
|
||||||
|
{{ next.title || next.path }}
|
||||||
|
</RouterLink>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { resolvePage } from '../util'
|
||||||
|
import isString from 'lodash/isString'
|
||||||
|
import isNil from 'lodash/isNil'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'PageNav',
|
||||||
|
|
||||||
|
props: ['sidebarItems'],
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
prev () {
|
||||||
|
return resolvePageLink(LINK_TYPES.PREV, this)
|
||||||
|
},
|
||||||
|
|
||||||
|
next () {
|
||||||
|
return resolvePageLink(LINK_TYPES.NEXT, this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePrev (page, items) {
|
||||||
|
return find(page, items, -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveNext (page, items) {
|
||||||
|
return find(page, items, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LINK_TYPES = {
|
||||||
|
NEXT: {
|
||||||
|
resolveLink: resolveNext,
|
||||||
|
getThemeLinkConfig: ({ nextLinks }) => nextLinks,
|
||||||
|
getPageLinkConfig: ({ frontmatter }) => frontmatter.next
|
||||||
|
},
|
||||||
|
PREV: {
|
||||||
|
resolveLink: resolvePrev,
|
||||||
|
getThemeLinkConfig: ({ prevLinks }) => prevLinks,
|
||||||
|
getPageLinkConfig: ({ frontmatter }) => frontmatter.prev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePageLink (
|
||||||
|
linkType,
|
||||||
|
{ $themeConfig, $page, $route, $site, sidebarItems }
|
||||||
|
) {
|
||||||
|
const { resolveLink, getThemeLinkConfig, getPageLinkConfig } = linkType
|
||||||
|
|
||||||
|
// Get link config from theme
|
||||||
|
const themeLinkConfig = getThemeLinkConfig($themeConfig)
|
||||||
|
|
||||||
|
// Get link config from current page
|
||||||
|
const pageLinkConfig = getPageLinkConfig($page)
|
||||||
|
|
||||||
|
// Page link config will overwrite global theme link config if defined
|
||||||
|
const link = isNil(pageLinkConfig) ? themeLinkConfig : pageLinkConfig
|
||||||
|
|
||||||
|
if (link === false) {
|
||||||
|
return
|
||||||
|
} else if (isString(link)) {
|
||||||
|
return resolvePage($site.pages, link, $route.path)
|
||||||
|
} else {
|
||||||
|
return resolveLink($page, sidebarItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function find (page, items, offset) {
|
||||||
|
const res = []
|
||||||
|
flatten(items, res)
|
||||||
|
for (let i = 0; i < res.length; i++) {
|
||||||
|
const cur = res[i]
|
||||||
|
if (cur.type === 'page' && cur.path === decodeURIComponent(page.path)) {
|
||||||
|
return res[i + offset]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flatten (items, res) {
|
||||||
|
for (let i = 0, l = items.length; i < l; i++) {
|
||||||
|
if (items[i].type === 'group') {
|
||||||
|
flatten(items[i].children || [], res)
|
||||||
|
} else {
|
||||||
|
res.push(items[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="stylus">
|
||||||
|
@require '../styles/wrapper.styl'
|
||||||
|
|
||||||
|
.page-nav
|
||||||
|
@extend $wrapper
|
||||||
|
padding-top 1rem
|
||||||
|
padding-bottom 0
|
||||||
|
.inner
|
||||||
|
min-height 2rem
|
||||||
|
margin-top 0
|
||||||
|
border-top 1px solid $borderColor
|
||||||
|
padding-top 1rem
|
||||||
|
overflow auto // clear float
|
||||||
|
.next
|
||||||
|
float right
|
||||||
|
</style>
|
64
docs/src/.vuepress/theme/components/Sidebar.vue
Normal file
64
docs/src/.vuepress/theme/components/Sidebar.vue
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<NavLinks />
|
||||||
|
|
||||||
|
<slot name="top" />
|
||||||
|
|
||||||
|
<SidebarLinks
|
||||||
|
:depth="0"
|
||||||
|
:items="items"
|
||||||
|
/>
|
||||||
|
<slot name="bottom" />
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import SidebarLinks from '@theme/components/SidebarLinks.vue'
|
||||||
|
import NavLinks from '@theme/components/NavLinks.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Sidebar',
|
||||||
|
|
||||||
|
components: { SidebarLinks, NavLinks },
|
||||||
|
|
||||||
|
props: ['items']
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="stylus">
|
||||||
|
.sidebar
|
||||||
|
ul
|
||||||
|
padding 0
|
||||||
|
margin 0
|
||||||
|
list-style-type none
|
||||||
|
a
|
||||||
|
display inline-block
|
||||||
|
.nav-links
|
||||||
|
display none
|
||||||
|
border-bottom 1px solid $borderColor
|
||||||
|
padding 0.5rem 0 0.75rem 0
|
||||||
|
a
|
||||||
|
font-weight 600
|
||||||
|
.nav-item, .repo-link
|
||||||
|
display block
|
||||||
|
line-height 1.25rem
|
||||||
|
font-size 1.1em
|
||||||
|
padding 0.5rem 0 0.5rem 1.5rem
|
||||||
|
& > .sidebar-links
|
||||||
|
padding 1.5rem 0
|
||||||
|
& > li > a.sidebar-link
|
||||||
|
font-size 1.1em
|
||||||
|
line-height 1.7
|
||||||
|
font-weight bold
|
||||||
|
& > li:not(:first-child)
|
||||||
|
margin-top .75rem
|
||||||
|
|
||||||
|
@media (max-width: $MQMobile)
|
||||||
|
.sidebar
|
||||||
|
.nav-links
|
||||||
|
display block
|
||||||
|
.dropdown-wrapper .nav-dropdown .dropdown-item a.router-link-active::after
|
||||||
|
top calc(1rem - 2px)
|
||||||
|
& > .sidebar-links
|
||||||
|
padding 1rem 0
|
||||||
|
</style>
|
40
docs/src/.vuepress/theme/components/SidebarButton.vue
Normal file
40
docs/src/.vuepress/theme/components/SidebarButton.vue
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="sidebar-button"
|
||||||
|
@click="$emit('toggle-sidebar')"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="icon"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
role="img"
|
||||||
|
viewBox="0 0 448 512"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M436 124H12c-6.627 0-12-5.373-12-12V80c0-6.627 5.373-12 12-12h424c6.627 0 12 5.373 12 12v32c0 6.627-5.373 12-12 12zm0 160H12c-6.627 0-12-5.373-12-12v-32c0-6.627 5.373-12 12-12h424c6.627 0 12 5.373 12 12v32c0 6.627-5.373 12-12 12zm0 160H12c-6.627 0-12-5.373-12-12v-32c0-6.627 5.373-12 12-12h424c6.627 0 12 5.373 12 12v32c0 6.627-5.373 12-12 12z"
|
||||||
|
class=""
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="stylus">
|
||||||
|
.sidebar-button
|
||||||
|
cursor pointer
|
||||||
|
display none
|
||||||
|
width 1.25rem
|
||||||
|
height 1.25rem
|
||||||
|
position absolute
|
||||||
|
padding 0.6rem
|
||||||
|
top 0.6rem
|
||||||
|
left 1rem
|
||||||
|
.icon
|
||||||
|
display block
|
||||||
|
width 1.25rem
|
||||||
|
height 1.25rem
|
||||||
|
|
||||||
|
@media (max-width: $MQMobile)
|
||||||
|
.sidebar-button
|
||||||
|
display block
|
||||||
|
</style>
|
141
docs/src/.vuepress/theme/components/SidebarGroup.vue
Normal file
141
docs/src/.vuepress/theme/components/SidebarGroup.vue
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="sidebar-group"
|
||||||
|
:class="[
|
||||||
|
{
|
||||||
|
collapsable,
|
||||||
|
'is-sub-group': depth !== 0
|
||||||
|
},
|
||||||
|
`depth-${depth}`
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<RouterLink
|
||||||
|
v-if="item.path"
|
||||||
|
class="sidebar-heading clickable"
|
||||||
|
:class="{
|
||||||
|
open,
|
||||||
|
'active': isActive($route, item.path)
|
||||||
|
}"
|
||||||
|
:to="item.path"
|
||||||
|
@click.native="$emit('toggle')"
|
||||||
|
>
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
<span
|
||||||
|
v-if="collapsable"
|
||||||
|
class="arrow"
|
||||||
|
:class="open ? 'down' : 'right'"
|
||||||
|
/>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="sidebar-heading"
|
||||||
|
:class="{ open }"
|
||||||
|
@click="$emit('toggle')"
|
||||||
|
>
|
||||||
|
<span>{{ item.title }}</span>
|
||||||
|
<span
|
||||||
|
v-if="collapsable"
|
||||||
|
class="arrow"
|
||||||
|
:class="open ? 'down' : 'right'"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<DropdownTransition>
|
||||||
|
<SidebarLinks
|
||||||
|
v-if="open || !collapsable"
|
||||||
|
class="sidebar-group-items"
|
||||||
|
:items="item.children"
|
||||||
|
:sidebar-depth="item.sidebarDepth"
|
||||||
|
:initial-open-group-index="item.initialOpenGroupIndex"
|
||||||
|
:depth="depth + 1"
|
||||||
|
/>
|
||||||
|
</DropdownTransition>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { isActive } from '../util'
|
||||||
|
import DropdownTransition from '@theme/components/DropdownTransition.vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'SidebarGroup',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
DropdownTransition
|
||||||
|
},
|
||||||
|
|
||||||
|
props: [
|
||||||
|
'item',
|
||||||
|
'open',
|
||||||
|
'collapsable',
|
||||||
|
'depth'
|
||||||
|
],
|
||||||
|
|
||||||
|
// ref: https://vuejs.org/v2/guide/components-edge-cases.html#Circular-References-Between-Components
|
||||||
|
beforeCreate () {
|
||||||
|
this.$options.components.SidebarLinks = require('@theme/components/SidebarLinks.vue').default
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: { isActive }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="stylus">
|
||||||
|
.sidebar-group
|
||||||
|
.sidebar-group
|
||||||
|
padding-left 0.5em
|
||||||
|
&:not(.collapsable)
|
||||||
|
.sidebar-heading:not(.clickable)
|
||||||
|
cursor auto
|
||||||
|
color inherit
|
||||||
|
// refine styles of nested sidebar groups
|
||||||
|
&.is-sub-group
|
||||||
|
padding-left 0
|
||||||
|
& > .sidebar-heading
|
||||||
|
font-size 0.95em
|
||||||
|
line-height 1.4
|
||||||
|
font-weight normal
|
||||||
|
padding-left 2rem
|
||||||
|
&:not(.clickable)
|
||||||
|
opacity 0.5
|
||||||
|
& > .sidebar-group-items
|
||||||
|
padding-left 1rem
|
||||||
|
& > li > .sidebar-link
|
||||||
|
font-size: 0.95em;
|
||||||
|
border-left none
|
||||||
|
&.depth-2
|
||||||
|
& > .sidebar-heading
|
||||||
|
border-left none
|
||||||
|
|
||||||
|
.sidebar-heading
|
||||||
|
color $textColor
|
||||||
|
transition color .15s ease
|
||||||
|
cursor pointer
|
||||||
|
font-size 1.1em
|
||||||
|
font-weight bold
|
||||||
|
// text-transform uppercase
|
||||||
|
padding 0.35rem 1.5rem 0.35rem 1.25rem
|
||||||
|
width 100%
|
||||||
|
box-sizing border-box
|
||||||
|
margin 0
|
||||||
|
border-left 0.25rem solid transparent
|
||||||
|
&.open, &:hover
|
||||||
|
color inherit
|
||||||
|
.arrow
|
||||||
|
position relative
|
||||||
|
top -0.12em
|
||||||
|
left 0.5em
|
||||||
|
&.clickable
|
||||||
|
&.active
|
||||||
|
font-weight 600
|
||||||
|
color $accentColor
|
||||||
|
border-left-color $accentColor
|
||||||
|
&:hover
|
||||||
|
color $accentColor
|
||||||
|
|
||||||
|
.sidebar-group-items
|
||||||
|
transition height .1s ease-out
|
||||||
|
font-size 0.95em
|
||||||
|
overflow hidden
|
||||||
|
</style>
|
133
docs/src/.vuepress/theme/components/SidebarLink.vue
Normal file
133
docs/src/.vuepress/theme/components/SidebarLink.vue
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
<script>
|
||||||
|
import { isActive, hashRE, groupHeaders } from '../util'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
functional: true,
|
||||||
|
|
||||||
|
props: ['item', 'sidebarDepth'],
|
||||||
|
|
||||||
|
render (h,
|
||||||
|
{
|
||||||
|
parent: {
|
||||||
|
$page,
|
||||||
|
$site,
|
||||||
|
$route,
|
||||||
|
$themeConfig,
|
||||||
|
$themeLocaleConfig
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
item,
|
||||||
|
sidebarDepth
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
// use custom active class matching logic
|
||||||
|
// due to edge case of paths ending with / + hash
|
||||||
|
const selfActive = isActive($route, item.path)
|
||||||
|
// for sidebar: auto pages, a hash link should be active if one of its child
|
||||||
|
// matches
|
||||||
|
const active = item.type === 'auto'
|
||||||
|
? selfActive || item.children.some(c => isActive($route, item.basePath + '#' + c.slug))
|
||||||
|
: selfActive
|
||||||
|
const link = item.type === 'external'
|
||||||
|
? renderExternal(h, item.path, item.title || item.path)
|
||||||
|
: renderLink(h, item.path, item.title || item.path, active)
|
||||||
|
|
||||||
|
const maxDepth = [
|
||||||
|
$page.frontmatter.sidebarDepth,
|
||||||
|
sidebarDepth,
|
||||||
|
$themeLocaleConfig.sidebarDepth,
|
||||||
|
$themeConfig.sidebarDepth,
|
||||||
|
1
|
||||||
|
].find(depth => depth !== undefined)
|
||||||
|
|
||||||
|
const displayAllHeaders = $themeLocaleConfig.displayAllHeaders
|
||||||
|
|| $themeConfig.displayAllHeaders
|
||||||
|
|
||||||
|
if (item.type === 'auto') {
|
||||||
|
return [link, renderChildren(h, item.children, item.basePath, $route, maxDepth)]
|
||||||
|
} else if ((active || displayAllHeaders) && item.headers && !hashRE.test(item.path)) {
|
||||||
|
const children = groupHeaders(item.headers)
|
||||||
|
return [link, renderChildren(h, children, item.path, $route, maxDepth)]
|
||||||
|
} else {
|
||||||
|
return link
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLink (h, to, text, active, level) {
|
||||||
|
const component = {
|
||||||
|
props: {
|
||||||
|
to,
|
||||||
|
activeClass: '',
|
||||||
|
exactActiveClass: ''
|
||||||
|
},
|
||||||
|
class: {
|
||||||
|
active,
|
||||||
|
'sidebar-link': true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level > 2) {
|
||||||
|
component.style = {
|
||||||
|
'padding-left': level + 'rem'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return h('RouterLink', component, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChildren (h, children, path, route, maxDepth, depth = 1) {
|
||||||
|
if (!children || depth > maxDepth) return null
|
||||||
|
return h('ul', { class: 'sidebar-sub-headers' }, children.map(c => {
|
||||||
|
const active = isActive(route, path + '#' + c.slug)
|
||||||
|
return h('li', { class: 'sidebar-sub-header' }, [
|
||||||
|
renderLink(h, path + '#' + c.slug, c.title, active, c.level - 1),
|
||||||
|
renderChildren(h, c.children, path, route, maxDepth, depth + 1)
|
||||||
|
])
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExternal (h, to, text) {
|
||||||
|
return h('a', {
|
||||||
|
attrs: {
|
||||||
|
href: to,
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener noreferrer'
|
||||||
|
},
|
||||||
|
class: {
|
||||||
|
'sidebar-link': true
|
||||||
|
}
|
||||||
|
}, [text, h('OutboundLink')])
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="stylus">
|
||||||
|
.sidebar .sidebar-sub-headers
|
||||||
|
padding-left 1rem
|
||||||
|
font-size 0.95em
|
||||||
|
|
||||||
|
a.sidebar-link
|
||||||
|
font-size 1em
|
||||||
|
font-weight 400
|
||||||
|
display inline-block
|
||||||
|
color $textColor
|
||||||
|
border-left 0.25rem solid transparent
|
||||||
|
padding 0.35rem 1rem 0.35rem 1.25rem
|
||||||
|
line-height 1.4
|
||||||
|
width: 100%
|
||||||
|
box-sizing: border-box
|
||||||
|
&:hover
|
||||||
|
color $accentColor
|
||||||
|
&.active
|
||||||
|
font-weight 600
|
||||||
|
color $accentColor
|
||||||
|
border-left-color $accentColor
|
||||||
|
.sidebar-group &
|
||||||
|
padding-left 2rem
|
||||||
|
.sidebar-sub-headers &
|
||||||
|
padding-top 0.25rem
|
||||||
|
padding-bottom 0.25rem
|
||||||
|
border-left none
|
||||||
|
&.active
|
||||||
|
font-weight 500
|
||||||
|
</style>
|
103
docs/src/.vuepress/theme/components/SidebarLinks.vue
Normal file
103
docs/src/.vuepress/theme/components/SidebarLinks.vue
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
<template>
|
||||||
|
<ul
|
||||||
|
v-if="items.length"
|
||||||
|
class="sidebar-links"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="(item, i) in items"
|
||||||
|
:key="i"
|
||||||
|
>
|
||||||
|
<SidebarGroup
|
||||||
|
v-if="item.type === 'group'"
|
||||||
|
:item="item"
|
||||||
|
:open="i === openGroupIndex"
|
||||||
|
:collapsable="item.collapsable || item.collapsible"
|
||||||
|
:depth="depth"
|
||||||
|
@toggle="toggleGroup(i)"
|
||||||
|
/>
|
||||||
|
<SidebarLink
|
||||||
|
v-else
|
||||||
|
:sidebar-depth="sidebarDepth"
|
||||||
|
:item="item"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import SidebarGroup from '@theme/components/SidebarGroup.vue'
|
||||||
|
import SidebarLink from '@theme/components/SidebarLink.vue'
|
||||||
|
import { isActive } from '../util'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'SidebarLinks',
|
||||||
|
|
||||||
|
components: { SidebarGroup, SidebarLink },
|
||||||
|
|
||||||
|
props: [
|
||||||
|
'items',
|
||||||
|
'depth', // depth of current sidebar links
|
||||||
|
'sidebarDepth', // depth of headers to be extracted
|
||||||
|
'initialOpenGroupIndex'
|
||||||
|
],
|
||||||
|
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
openGroupIndex: this.initialOpenGroupIndex || 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
'$route' () {
|
||||||
|
this.refreshIndex()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created () {
|
||||||
|
this.refreshIndex()
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
refreshIndex () {
|
||||||
|
const index = resolveOpenGroupIndex(
|
||||||
|
this.$route,
|
||||||
|
this.items
|
||||||
|
)
|
||||||
|
if (index > -1) {
|
||||||
|
this.openGroupIndex = index
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleGroup (index) {
|
||||||
|
this.openGroupIndex = index === this.openGroupIndex ? -1 : index
|
||||||
|
},
|
||||||
|
|
||||||
|
isActive (page) {
|
||||||
|
return isActive(this.$route, page.regularPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOpenGroupIndex (route, items) {
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i]
|
||||||
|
if (descendantIsActive(route, item)) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
function descendantIsActive (route, item) {
|
||||||
|
if (item.type === 'group') {
|
||||||
|
return item.children.some(child => {
|
||||||
|
if (child.type === 'group') {
|
||||||
|
return descendantIsActive(route, child)
|
||||||
|
} else {
|
||||||
|
return child.type === 'page' && isActive(route, child.path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
</script>
|
44
docs/src/.vuepress/theme/global-components/Badge.vue
Normal file
44
docs/src/.vuepress/theme/global-components/Badge.vue
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
functional: true,
|
||||||
|
props: {
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'tip'
|
||||||
|
},
|
||||||
|
text: String,
|
||||||
|
vertical: {
|
||||||
|
type: String,
|
||||||
|
default: 'top'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
render (h, { props, slots }) {
|
||||||
|
return h('span', {
|
||||||
|
class: ['badge', props.type],
|
||||||
|
style: {
|
||||||
|
verticalAlign: props.vertical
|
||||||
|
}
|
||||||
|
}, props.text || slots().default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="stylus" scoped>
|
||||||
|
.badge
|
||||||
|
display inline-block
|
||||||
|
font-size 14px
|
||||||
|
height 18px
|
||||||
|
line-height 18px
|
||||||
|
border-radius 3px
|
||||||
|
padding 0 6px
|
||||||
|
color white
|
||||||
|
background-color #42b983
|
||||||
|
&.tip, &.green
|
||||||
|
background-color $badgeTipColor
|
||||||
|
&.error
|
||||||
|
background-color $badgeErrorColor
|
||||||
|
&.warning, &.warn, &.yellow
|
||||||
|
background-color $badgeWarningColor
|
||||||
|
& + &
|
||||||
|
margin-left 5px
|
||||||
|
</style>
|
36
docs/src/.vuepress/theme/global-components/CodeBlock.vue
Normal file
36
docs/src/.vuepress/theme/global-components/CodeBlock.vue
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="theme-code-block"
|
||||||
|
:class="{ 'theme-code-block__active': active }"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'CodeBlock',
|
||||||
|
props: {
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.theme-code-block {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.theme-code-block__active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.theme-code-block > pre {
|
||||||
|
background-color: orange;
|
||||||
|
}
|
||||||
|
</style>
|
105
docs/src/.vuepress/theme/global-components/CodeGroup.vue
Normal file
105
docs/src/.vuepress/theme/global-components/CodeGroup.vue
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<template>
|
||||||
|
<div class="theme-code-group">
|
||||||
|
<div class="theme-code-group__nav">
|
||||||
|
<ul class="theme-code-group__ul">
|
||||||
|
<li
|
||||||
|
v-for="(tab, i) in codeTabs"
|
||||||
|
:key="tab.title"
|
||||||
|
class="theme-code-group__li"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="theme-code-group__nav-tab"
|
||||||
|
:class="{
|
||||||
|
'theme-code-group__nav-tab-active': i === activeCodeTabIndex,
|
||||||
|
}"
|
||||||
|
@click="changeCodeTab(i)"
|
||||||
|
>
|
||||||
|
{{ tab.title }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
<pre
|
||||||
|
v-if="codeTabs.length < 1"
|
||||||
|
class="pre-blank"
|
||||||
|
>// Make sure to add code blocks to your code group</pre>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'CodeGroup',
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
codeTabs: [],
|
||||||
|
activeCodeTabIndex: -1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
activeCodeTabIndex (index) {
|
||||||
|
this.codeTabs.forEach(tab => {
|
||||||
|
tab.elm.classList.remove('theme-code-block__active')
|
||||||
|
})
|
||||||
|
this.codeTabs[index].elm.classList.add('theme-code-block__active')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted () {
|
||||||
|
this.codeTabs = (this.$slots.default || []).filter(slot => Boolean(slot.componentOptions)).map((slot, index) => {
|
||||||
|
if (slot.componentOptions.propsData.active === '') {
|
||||||
|
this.activeCodeTabIndex = index
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: slot.componentOptions.propsData.title,
|
||||||
|
elm: slot.elm
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.activeCodeTabIndex === -1 && this.codeTabs.length > 0) {
|
||||||
|
this.activeCodeTabIndex = 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
changeCodeTab (index) {
|
||||||
|
this.activeCodeTabIndex = index
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="stylus" scoped>
|
||||||
|
.theme-code-group {}
|
||||||
|
.theme-code-group__nav {
|
||||||
|
margin-bottom: -35px;
|
||||||
|
background-color: $codeBgColor;
|
||||||
|
padding-bottom: 22px;
|
||||||
|
border-top-left-radius: 6px;
|
||||||
|
border-top-right-radius: 6px;
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
.theme-code-group__ul {
|
||||||
|
margin: auto 0;
|
||||||
|
padding-left: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.theme-code-group__li {}
|
||||||
|
.theme-code-group__nav-tab {
|
||||||
|
border: 0;
|
||||||
|
padding: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: transparent;
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.theme-code-group__nav-tab-active {
|
||||||
|
border-bottom: #42b983 1px solid;
|
||||||
|
}
|
||||||
|
.pre-blank {
|
||||||
|
color: #42b983;
|
||||||
|
}
|
||||||
|
</style>
|
59
docs/src/.vuepress/theme/index.js
Normal file
59
docs/src/.vuepress/theme/index.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
// Theme API.
|
||||||
|
module.exports = (options, ctx) => {
|
||||||
|
const { themeConfig, siteConfig } = ctx
|
||||||
|
|
||||||
|
// resolve algolia
|
||||||
|
const isAlgoliaSearch = (
|
||||||
|
themeConfig.algolia
|
||||||
|
|| Object
|
||||||
|
.keys(siteConfig.locales && themeConfig.locales || {})
|
||||||
|
.some(base => themeConfig.locales[base].algolia)
|
||||||
|
)
|
||||||
|
|
||||||
|
const enableSmoothScroll = themeConfig.smoothScroll === true
|
||||||
|
|
||||||
|
return {
|
||||||
|
alias () {
|
||||||
|
return {
|
||||||
|
'@AlgoliaSearchBox': isAlgoliaSearch
|
||||||
|
? path.resolve(__dirname, 'components/AlgoliaSearchBox.vue')
|
||||||
|
: path.resolve(__dirname, 'noopModule.js')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
['@vuepress/active-header-links', options.activeHeaderLinks],
|
||||||
|
'@vuepress/search',
|
||||||
|
'@vuepress/plugin-nprogress',
|
||||||
|
['container', {
|
||||||
|
type: 'tip',
|
||||||
|
defaultTitle: {
|
||||||
|
'/': 'TIP',
|
||||||
|
'/zh/': '提示'
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
['container', {
|
||||||
|
type: 'warning',
|
||||||
|
defaultTitle: {
|
||||||
|
'/': 'WARNING',
|
||||||
|
'/zh/': '注意'
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
['container', {
|
||||||
|
type: 'danger',
|
||||||
|
defaultTitle: {
|
||||||
|
'/': 'WARNING',
|
||||||
|
'/zh/': '警告'
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
['container', {
|
||||||
|
type: 'details',
|
||||||
|
before: info => `<details class="custom-block details">${info ? `<summary>${info}</summary>` : ''}\n`,
|
||||||
|
after: () => '</details>\n'
|
||||||
|
}],
|
||||||
|
['smooth-scroll', enableSmoothScroll]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
30
docs/src/.vuepress/theme/layouts/404.vue
Normal file
30
docs/src/.vuepress/theme/layouts/404.vue
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<div class="theme-container">
|
||||||
|
<div class="theme-default-content">
|
||||||
|
<h1>404</h1>
|
||||||
|
|
||||||
|
<blockquote>{{ getMsg() }}</blockquote>
|
||||||
|
|
||||||
|
<RouterLink to="/">
|
||||||
|
Take me home.
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const msgs = [
|
||||||
|
`There's nothing here.`,
|
||||||
|
`How did we get here?`,
|
||||||
|
`That's a Four-Oh-Four.`,
|
||||||
|
`Looks like we've got some broken links.`
|
||||||
|
]
|
||||||
|
|
||||||
|
export default {
|
||||||
|
methods: {
|
||||||
|
getMsg () {
|
||||||
|
return msgs[Math.floor(Math.random() * msgs.length)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
137
docs/src/.vuepress/theme/layouts/Layout.vue
Normal file
137
docs/src/.vuepress/theme/layouts/Layout.vue
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="theme-container"
|
||||||
|
:class="pageClasses"
|
||||||
|
@touchstart="onTouchStart"
|
||||||
|
@touchend="onTouchEnd"
|
||||||
|
>
|
||||||
|
<Navbar v-if="shouldShowNavbar" @toggle-sidebar="toggleSidebar" />
|
||||||
|
|
||||||
|
<div class="sidebar-mask" @click="toggleSidebar(false)" />
|
||||||
|
|
||||||
|
<Sidebar :items="sidebarItems" @toggle-sidebar="toggleSidebar">
|
||||||
|
<template #top>
|
||||||
|
<slot name="sidebar-top" />
|
||||||
|
</template>
|
||||||
|
<template #bottom>
|
||||||
|
<slot name="sidebar-bottom" />
|
||||||
|
</template>
|
||||||
|
</Sidebar>
|
||||||
|
|
||||||
|
<Home v-if="$page.frontmatter.home" />
|
||||||
|
|
||||||
|
<Page v-else :sidebar-items="sidebarItems">
|
||||||
|
<template #top>
|
||||||
|
<slot name="page-top" />
|
||||||
|
</template>
|
||||||
|
<template #bottom>
|
||||||
|
<slot name="page-bottom" />
|
||||||
|
</template>
|
||||||
|
</Page>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Home from "@theme/components/Home.vue";
|
||||||
|
import Navbar from "@theme/components/Navbar.vue";
|
||||||
|
import Page from "@theme/components/Page.vue";
|
||||||
|
import Sidebar from "@theme/components/Sidebar.vue";
|
||||||
|
import { resolveSidebarItems } from "../util";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: "Layout",
|
||||||
|
|
||||||
|
components: {
|
||||||
|
Home,
|
||||||
|
Page,
|
||||||
|
Sidebar,
|
||||||
|
Navbar,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
isSidebarOpen: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
shouldShowNavbar() {
|
||||||
|
const { themeConfig } = this.$site;
|
||||||
|
const { frontmatter } = this.$page;
|
||||||
|
if (frontmatter.navbar === false || themeConfig.navbar === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
this.$title ||
|
||||||
|
themeConfig.logo ||
|
||||||
|
themeConfig.repo ||
|
||||||
|
themeConfig.nav ||
|
||||||
|
this.$themeLocaleConfig.nav
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
shouldShowSidebar() {
|
||||||
|
const { frontmatter } = this.$page;
|
||||||
|
return (
|
||||||
|
!frontmatter.home &&
|
||||||
|
frontmatter.sidebar !== false &&
|
||||||
|
this.sidebarItems.length
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
sidebarItems() {
|
||||||
|
return resolveSidebarItems(
|
||||||
|
this.$page,
|
||||||
|
this.$page.regularPath,
|
||||||
|
this.$site,
|
||||||
|
this.$localePath
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
pageClasses() {
|
||||||
|
const userPageClass = this.$page.frontmatter.pageClass;
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"no-navbar": !this.shouldShowNavbar,
|
||||||
|
"sidebar-open": this.isSidebarOpen,
|
||||||
|
"no-sidebar": !this.shouldShowSidebar,
|
||||||
|
},
|
||||||
|
userPageClass,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.$router.afterEach(() => {
|
||||||
|
this.isSidebarOpen = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
toggleSidebar(to) {
|
||||||
|
this.isSidebarOpen = typeof to === "boolean" ? to : !this.isSidebarOpen;
|
||||||
|
this.$emit("toggle-sidebar", this.isSidebarOpen);
|
||||||
|
},
|
||||||
|
|
||||||
|
// side swipe
|
||||||
|
onTouchStart(e) {
|
||||||
|
this.touchStart = {
|
||||||
|
x: e.changedTouches[0].clientX,
|
||||||
|
y: e.changedTouches[0].clientY,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
onTouchEnd(e) {
|
||||||
|
const dx = e.changedTouches[0].clientX - this.touchStart.x;
|
||||||
|
const dy = e.changedTouches[0].clientY - this.touchStart.y;
|
||||||
|
if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > 40) {
|
||||||
|
if (dx > 0 && this.touchStart.x <= 80) {
|
||||||
|
this.toggleSidebar(true);
|
||||||
|
} else {
|
||||||
|
this.toggleSidebar(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
1
docs/src/.vuepress/theme/noopModule.js
Normal file
1
docs/src/.vuepress/theme/noopModule.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default {}
|
22
docs/src/.vuepress/theme/styles/arrow.styl
Normal file
22
docs/src/.vuepress/theme/styles/arrow.styl
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
@require './config'
|
||||||
|
|
||||||
|
.arrow
|
||||||
|
display inline-block
|
||||||
|
width 0
|
||||||
|
height 0
|
||||||
|
&.up
|
||||||
|
border-left 4px solid transparent
|
||||||
|
border-right 4px solid transparent
|
||||||
|
border-bottom 6px solid $arrowBgColor
|
||||||
|
&.down
|
||||||
|
border-left 4px solid transparent
|
||||||
|
border-right 4px solid transparent
|
||||||
|
border-top 6px solid $arrowBgColor
|
||||||
|
&.right
|
||||||
|
border-top 4px solid transparent
|
||||||
|
border-bottom 4px solid transparent
|
||||||
|
border-left 6px solid $arrowBgColor
|
||||||
|
&.left
|
||||||
|
border-top 4px solid transparent
|
||||||
|
border-bottom 4px solid transparent
|
||||||
|
border-right 6px solid $arrowBgColor
|
137
docs/src/.vuepress/theme/styles/code.styl
Normal file
137
docs/src/.vuepress/theme/styles/code.styl
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
{$contentClass}
|
||||||
|
code
|
||||||
|
color lighten($textColor, 20%)
|
||||||
|
padding 0.25rem 0.5rem
|
||||||
|
margin 0
|
||||||
|
font-size 0.85em
|
||||||
|
background-color rgba(27,31,35,0.05)
|
||||||
|
border-radius 3px
|
||||||
|
.token
|
||||||
|
&.deleted
|
||||||
|
color #EC5975
|
||||||
|
&.inserted
|
||||||
|
color $accentColor
|
||||||
|
|
||||||
|
{$contentClass}
|
||||||
|
pre, pre[class*="language-"]
|
||||||
|
line-height 1.4
|
||||||
|
padding 1.25rem 1.5rem
|
||||||
|
margin 0.85rem 0
|
||||||
|
background-color $codeBgColor
|
||||||
|
border-radius 6px
|
||||||
|
overflow auto
|
||||||
|
code
|
||||||
|
color #fff
|
||||||
|
padding 0
|
||||||
|
background-color transparent
|
||||||
|
border-radius 0
|
||||||
|
|
||||||
|
div[class*="language-"]
|
||||||
|
position relative
|
||||||
|
background-color $codeBgColor
|
||||||
|
border-radius 6px
|
||||||
|
.highlight-lines
|
||||||
|
user-select none
|
||||||
|
padding-top 1.3rem
|
||||||
|
position absolute
|
||||||
|
top 0
|
||||||
|
left 0
|
||||||
|
width 100%
|
||||||
|
line-height 1.4
|
||||||
|
.highlighted
|
||||||
|
background-color rgba(0, 0, 0, 66%)
|
||||||
|
pre, pre[class*="language-"]
|
||||||
|
background transparent
|
||||||
|
position relative
|
||||||
|
z-index 1
|
||||||
|
&::before
|
||||||
|
position absolute
|
||||||
|
z-index 3
|
||||||
|
top 0.8em
|
||||||
|
right 1em
|
||||||
|
font-size 0.75rem
|
||||||
|
color rgba(255, 255, 255, 0.4)
|
||||||
|
&:not(.line-numbers-mode)
|
||||||
|
.line-numbers-wrapper
|
||||||
|
display none
|
||||||
|
&.line-numbers-mode
|
||||||
|
.highlight-lines .highlighted
|
||||||
|
position relative
|
||||||
|
&:before
|
||||||
|
content ' '
|
||||||
|
position absolute
|
||||||
|
z-index 3
|
||||||
|
left 0
|
||||||
|
top 0
|
||||||
|
display block
|
||||||
|
width $lineNumbersWrapperWidth
|
||||||
|
height 100%
|
||||||
|
background-color rgba(0, 0, 0, 66%)
|
||||||
|
pre
|
||||||
|
padding-left $lineNumbersWrapperWidth + 1 rem
|
||||||
|
vertical-align middle
|
||||||
|
.line-numbers-wrapper
|
||||||
|
position absolute
|
||||||
|
top 0
|
||||||
|
width $lineNumbersWrapperWidth
|
||||||
|
text-align center
|
||||||
|
color rgba(255, 255, 255, 0.3)
|
||||||
|
padding 1.25rem 0
|
||||||
|
line-height 1.4
|
||||||
|
br
|
||||||
|
user-select none
|
||||||
|
.line-number
|
||||||
|
position relative
|
||||||
|
z-index 4
|
||||||
|
user-select none
|
||||||
|
font-size 0.85em
|
||||||
|
&::after
|
||||||
|
content ''
|
||||||
|
position absolute
|
||||||
|
z-index 2
|
||||||
|
top 0
|
||||||
|
left 0
|
||||||
|
width $lineNumbersWrapperWidth
|
||||||
|
height 100%
|
||||||
|
border-radius 6px 0 0 6px
|
||||||
|
border-right 1px solid rgba(0, 0, 0, 66%)
|
||||||
|
background-color $codeBgColor
|
||||||
|
|
||||||
|
|
||||||
|
for lang in $codeLang
|
||||||
|
div{'[class~="language-' + lang + '"]'}
|
||||||
|
&:before
|
||||||
|
content ('' + lang)
|
||||||
|
|
||||||
|
div[class~="language-javascript"]
|
||||||
|
&:before
|
||||||
|
content "js"
|
||||||
|
|
||||||
|
div[class~="language-typescript"]
|
||||||
|
&:before
|
||||||
|
content "ts"
|
||||||
|
|
||||||
|
div[class~="language-markup"]
|
||||||
|
&:before
|
||||||
|
content "html"
|
||||||
|
|
||||||
|
div[class~="language-markdown"]
|
||||||
|
&:before
|
||||||
|
content "md"
|
||||||
|
|
||||||
|
div[class~="language-json"]:before
|
||||||
|
content "json"
|
||||||
|
|
||||||
|
div[class~="language-ruby"]:before
|
||||||
|
content "rb"
|
||||||
|
|
||||||
|
div[class~="language-python"]:before
|
||||||
|
content "py"
|
||||||
|
|
||||||
|
div[class~="language-bash"]:before
|
||||||
|
content "sh"
|
||||||
|
|
||||||
|
div[class~="language-php"]:before
|
||||||
|
content "php"
|
||||||
|
|
||||||
|
@import '~prismjs/themes/prism-tomorrow.css'
|
1
docs/src/.vuepress/theme/styles/config.styl
Normal file
1
docs/src/.vuepress/theme/styles/config.styl
Normal file
@ -0,0 +1 @@
|
|||||||
|
$contentClass = '.theme-default-content'
|
44
docs/src/.vuepress/theme/styles/custom-blocks.styl
Normal file
44
docs/src/.vuepress/theme/styles/custom-blocks.styl
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
.custom-block
|
||||||
|
.custom-block-title
|
||||||
|
font-weight 600
|
||||||
|
margin-bottom -0.4rem
|
||||||
|
&.tip, &.warning, &.danger
|
||||||
|
padding .1rem 1.5rem
|
||||||
|
border-left-width .5rem
|
||||||
|
border-left-style solid
|
||||||
|
margin 1rem 0
|
||||||
|
&.tip
|
||||||
|
background-color #f3f5f7
|
||||||
|
border-color #42b983
|
||||||
|
&.warning
|
||||||
|
background-color rgba(255,229,100,.3)
|
||||||
|
border-color darken(#ffe564, 35%)
|
||||||
|
color darken(#ffe564, 70%)
|
||||||
|
.custom-block-title
|
||||||
|
color darken(#ffe564, 50%)
|
||||||
|
a
|
||||||
|
color $textColor
|
||||||
|
&.danger
|
||||||
|
background-color #ffe6e6
|
||||||
|
border-color darken(red, 20%)
|
||||||
|
color darken(red, 70%)
|
||||||
|
.custom-block-title
|
||||||
|
color darken(red, 40%)
|
||||||
|
a
|
||||||
|
color $textColor
|
||||||
|
&.details
|
||||||
|
display block
|
||||||
|
position relative
|
||||||
|
border-radius 2px
|
||||||
|
margin 1.6em 0
|
||||||
|
padding 1.6em
|
||||||
|
background-color #eee
|
||||||
|
h4
|
||||||
|
margin-top 0
|
||||||
|
figure, p
|
||||||
|
&:last-child
|
||||||
|
margin-bottom 0
|
||||||
|
padding-bottom 0
|
||||||
|
summary
|
||||||
|
outline none
|
||||||
|
cursor pointer
|
200
docs/src/.vuepress/theme/styles/index.styl
Normal file
200
docs/src/.vuepress/theme/styles/index.styl
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
@require './config'
|
||||||
|
@require './code'
|
||||||
|
@require './custom-blocks'
|
||||||
|
@require './arrow'
|
||||||
|
@require './wrapper'
|
||||||
|
@require './toc'
|
||||||
|
|
||||||
|
html, body
|
||||||
|
padding 0
|
||||||
|
margin 0
|
||||||
|
background-color #fff
|
||||||
|
|
||||||
|
body
|
||||||
|
font-family -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif
|
||||||
|
-webkit-font-smoothing antialiased
|
||||||
|
-moz-osx-font-smoothing grayscale
|
||||||
|
font-size 16px
|
||||||
|
color $textColor
|
||||||
|
|
||||||
|
.page
|
||||||
|
padding-left $sidebarWidth
|
||||||
|
|
||||||
|
.navbar
|
||||||
|
position fixed
|
||||||
|
z-index 20
|
||||||
|
top 0
|
||||||
|
left 0
|
||||||
|
right 0
|
||||||
|
height $navbarHeight
|
||||||
|
background-color #fff
|
||||||
|
box-sizing border-box
|
||||||
|
border-bottom 1px solid $borderColor
|
||||||
|
|
||||||
|
.sidebar-mask
|
||||||
|
position fixed
|
||||||
|
z-index 9
|
||||||
|
top 0
|
||||||
|
left 0
|
||||||
|
width 100vw
|
||||||
|
height 100vh
|
||||||
|
display none
|
||||||
|
|
||||||
|
.sidebar
|
||||||
|
font-size 16px
|
||||||
|
background-color #fff
|
||||||
|
width $sidebarWidth
|
||||||
|
position fixed
|
||||||
|
z-index 10
|
||||||
|
margin 0
|
||||||
|
top $navbarHeight
|
||||||
|
left 0
|
||||||
|
bottom 0
|
||||||
|
box-sizing border-box
|
||||||
|
border-right 1px solid $borderColor
|
||||||
|
overflow-y auto
|
||||||
|
|
||||||
|
{$contentClass}:not(.custom)
|
||||||
|
@extend $wrapper
|
||||||
|
> *:first-child
|
||||||
|
margin-top $navbarHeight
|
||||||
|
|
||||||
|
a:hover
|
||||||
|
text-decoration underline
|
||||||
|
|
||||||
|
p.demo
|
||||||
|
padding 1rem 1.5rem
|
||||||
|
border 1px solid #ddd
|
||||||
|
border-radius 4px
|
||||||
|
|
||||||
|
img
|
||||||
|
max-width 100%
|
||||||
|
|
||||||
|
{$contentClass}.custom
|
||||||
|
padding 0
|
||||||
|
margin 0
|
||||||
|
|
||||||
|
img
|
||||||
|
max-width 100%
|
||||||
|
|
||||||
|
a
|
||||||
|
font-weight 500
|
||||||
|
color $accentColor
|
||||||
|
text-decoration none
|
||||||
|
|
||||||
|
p a code
|
||||||
|
font-weight 400
|
||||||
|
color $accentColor
|
||||||
|
|
||||||
|
kbd
|
||||||
|
background #eee
|
||||||
|
border solid 0.15rem #ddd
|
||||||
|
border-bottom solid 0.25rem #ddd
|
||||||
|
border-radius 0.15rem
|
||||||
|
padding 0 0.15em
|
||||||
|
|
||||||
|
blockquote
|
||||||
|
font-size 1rem
|
||||||
|
color #999;
|
||||||
|
border-left .2rem solid #dfe2e5
|
||||||
|
margin 1rem 0
|
||||||
|
padding .25rem 0 .25rem 1rem
|
||||||
|
|
||||||
|
& > p
|
||||||
|
margin 0
|
||||||
|
|
||||||
|
ul, ol
|
||||||
|
padding-left 1.2em
|
||||||
|
|
||||||
|
strong
|
||||||
|
font-weight 600
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6
|
||||||
|
font-weight 600
|
||||||
|
line-height 1.25
|
||||||
|
|
||||||
|
{$contentClass}:not(.custom) > &
|
||||||
|
margin-top (0.5rem - $navbarHeight)
|
||||||
|
padding-top ($navbarHeight + 1rem)
|
||||||
|
margin-bottom 0
|
||||||
|
|
||||||
|
&:first-child
|
||||||
|
margin-top -1.5rem
|
||||||
|
margin-bottom 1rem
|
||||||
|
|
||||||
|
+ p, + pre, + .custom-block
|
||||||
|
margin-top 2rem
|
||||||
|
|
||||||
|
&:hover .header-anchor
|
||||||
|
opacity: 1
|
||||||
|
|
||||||
|
h1
|
||||||
|
font-size 2.2rem
|
||||||
|
|
||||||
|
h2
|
||||||
|
font-size 1.65rem
|
||||||
|
padding-bottom .3rem
|
||||||
|
border-bottom 1px solid $borderColor
|
||||||
|
|
||||||
|
h3
|
||||||
|
font-size 1.35rem
|
||||||
|
|
||||||
|
a.header-anchor
|
||||||
|
font-size 0.85em
|
||||||
|
float left
|
||||||
|
margin-left -0.87em
|
||||||
|
padding-right 0.23em
|
||||||
|
margin-top 0.125em
|
||||||
|
opacity 0
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
text-decoration none
|
||||||
|
|
||||||
|
code, kbd, .line-number
|
||||||
|
font-family source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace
|
||||||
|
|
||||||
|
p, ul, ol
|
||||||
|
line-height 1.7
|
||||||
|
|
||||||
|
hr
|
||||||
|
border 0
|
||||||
|
border-top 1px solid $borderColor
|
||||||
|
|
||||||
|
table
|
||||||
|
border-collapse collapse
|
||||||
|
margin 1rem 0
|
||||||
|
display: block
|
||||||
|
overflow-x: auto
|
||||||
|
|
||||||
|
tr
|
||||||
|
border-top 1px solid #dfe2e5
|
||||||
|
|
||||||
|
&:nth-child(2n)
|
||||||
|
background-color #f6f8fa
|
||||||
|
|
||||||
|
th, td
|
||||||
|
border 1px solid #dfe2e5
|
||||||
|
padding .6em 1em
|
||||||
|
|
||||||
|
.theme-container
|
||||||
|
&.sidebar-open
|
||||||
|
.sidebar-mask
|
||||||
|
display: block
|
||||||
|
|
||||||
|
&.no-navbar
|
||||||
|
{$contentClass}:not(.custom) > h1, h2, h3, h4, h5, h6
|
||||||
|
margin-top 1.5rem
|
||||||
|
padding-top 0
|
||||||
|
|
||||||
|
.sidebar
|
||||||
|
top 0
|
||||||
|
|
||||||
|
@media (min-width: ($MQMobile + 1px))
|
||||||
|
.theme-container.no-sidebar
|
||||||
|
.sidebar
|
||||||
|
display none
|
||||||
|
|
||||||
|
.page
|
||||||
|
padding-left 0
|
||||||
|
|
||||||
|
@require 'mobile.styl'
|
37
docs/src/.vuepress/theme/styles/mobile.styl
Normal file
37
docs/src/.vuepress/theme/styles/mobile.styl
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
@require './config'
|
||||||
|
|
||||||
|
$mobileSidebarWidth = $sidebarWidth * 0.82
|
||||||
|
|
||||||
|
// narrow desktop / iPad
|
||||||
|
@media (max-width: $MQNarrow)
|
||||||
|
.sidebar
|
||||||
|
font-size 15px
|
||||||
|
width $mobileSidebarWidth
|
||||||
|
.page
|
||||||
|
padding-left $mobileSidebarWidth
|
||||||
|
|
||||||
|
// wide mobile
|
||||||
|
@media (max-width: $MQMobile)
|
||||||
|
.sidebar
|
||||||
|
top 0
|
||||||
|
padding-top $navbarHeight
|
||||||
|
transform translateX(-100%)
|
||||||
|
transition transform .2s ease
|
||||||
|
.page
|
||||||
|
padding-left 0
|
||||||
|
.theme-container
|
||||||
|
&.sidebar-open
|
||||||
|
.sidebar
|
||||||
|
transform translateX(0)
|
||||||
|
&.no-navbar
|
||||||
|
.sidebar
|
||||||
|
padding-top: 0
|
||||||
|
|
||||||
|
// narrow mobile
|
||||||
|
@media (max-width: $MQMobileNarrow)
|
||||||
|
h1
|
||||||
|
font-size 1.9rem
|
||||||
|
{$contentClass}
|
||||||
|
div[class*="language-"]
|
||||||
|
margin 0.85rem -1.5rem
|
||||||
|
border-radius 0
|
3
docs/src/.vuepress/theme/styles/toc.styl
Normal file
3
docs/src/.vuepress/theme/styles/toc.styl
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.table-of-contents
|
||||||
|
.badge
|
||||||
|
vertical-align middle
|
9
docs/src/.vuepress/theme/styles/wrapper.styl
Normal file
9
docs/src/.vuepress/theme/styles/wrapper.styl
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
$wrapper
|
||||||
|
max-width $contentWidth
|
||||||
|
margin 0 auto
|
||||||
|
padding 2rem 2.5rem
|
||||||
|
@media (max-width: $MQNarrow)
|
||||||
|
padding 2rem
|
||||||
|
@media (max-width: $MQMobileNarrow)
|
||||||
|
padding 1.5rem
|
||||||
|
|
244
docs/src/.vuepress/theme/util/index.js
Normal file
244
docs/src/.vuepress/theme/util/index.js
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
export const hashRE = /#.*$/
|
||||||
|
export const extRE = /\.(md|html)$/
|
||||||
|
export const endingSlashRE = /\/$/
|
||||||
|
export const outboundRE = /^[a-z]+:/i
|
||||||
|
|
||||||
|
export function normalize (path) {
|
||||||
|
return decodeURI(path)
|
||||||
|
.replace(hashRE, '')
|
||||||
|
.replace(extRE, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHash (path) {
|
||||||
|
const match = path.match(hashRE)
|
||||||
|
if (match) {
|
||||||
|
return match[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isExternal (path) {
|
||||||
|
return outboundRE.test(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMailto (path) {
|
||||||
|
return /^mailto:/.test(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTel (path) {
|
||||||
|
return /^tel:/.test(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureExt (path) {
|
||||||
|
if (isExternal(path)) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
const hashMatch = path.match(hashRE)
|
||||||
|
const hash = hashMatch ? hashMatch[0] : ''
|
||||||
|
const normalized = normalize(path)
|
||||||
|
|
||||||
|
if (endingSlashRE.test(normalized)) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return normalized + '.html' + hash
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isActive (route, path) {
|
||||||
|
const routeHash = decodeURIComponent(route.hash)
|
||||||
|
const linkHash = getHash(path)
|
||||||
|
if (linkHash && routeHash !== linkHash) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const routePath = normalize(route.path)
|
||||||
|
const pagePath = normalize(path)
|
||||||
|
return routePath === pagePath
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePage (pages, rawPath, base) {
|
||||||
|
if (isExternal(rawPath)) {
|
||||||
|
return {
|
||||||
|
type: 'external',
|
||||||
|
path: rawPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (base) {
|
||||||
|
rawPath = resolvePath(rawPath, base)
|
||||||
|
}
|
||||||
|
const path = normalize(rawPath)
|
||||||
|
for (let i = 0; i < pages.length; i++) {
|
||||||
|
if (normalize(pages[i].regularPath) === path) {
|
||||||
|
return Object.assign({}, pages[i], {
|
||||||
|
type: 'page',
|
||||||
|
path: ensureExt(pages[i].path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error(`[vuepress] No matching page found for sidebar item "${rawPath}"`)
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePath (relative, base, append) {
|
||||||
|
const firstChar = relative.charAt(0)
|
||||||
|
if (firstChar === '/') {
|
||||||
|
return relative
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstChar === '?' || firstChar === '#') {
|
||||||
|
return base + relative
|
||||||
|
}
|
||||||
|
|
||||||
|
const stack = base.split('/')
|
||||||
|
|
||||||
|
// remove trailing segment if:
|
||||||
|
// - not appending
|
||||||
|
// - appending to trailing slash (last segment is empty)
|
||||||
|
if (!append || !stack[stack.length - 1]) {
|
||||||
|
stack.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve relative path
|
||||||
|
const segments = relative.replace(/^\//, '').split('/')
|
||||||
|
for (let i = 0; i < segments.length; i++) {
|
||||||
|
const segment = segments[i]
|
||||||
|
if (segment === '..') {
|
||||||
|
stack.pop()
|
||||||
|
} else if (segment !== '.') {
|
||||||
|
stack.push(segment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure leading slash
|
||||||
|
if (stack[0] !== '') {
|
||||||
|
stack.unshift('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return stack.join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { Page } page
|
||||||
|
* @param { string } regularPath
|
||||||
|
* @param { SiteData } site
|
||||||
|
* @param { string } localePath
|
||||||
|
* @returns { SidebarGroup }
|
||||||
|
*/
|
||||||
|
export function resolveSidebarItems (page, regularPath, site, localePath) {
|
||||||
|
const { pages, themeConfig } = site
|
||||||
|
|
||||||
|
const localeConfig = localePath && themeConfig.locales
|
||||||
|
? themeConfig.locales[localePath] || themeConfig
|
||||||
|
: themeConfig
|
||||||
|
|
||||||
|
const pageSidebarConfig = page.frontmatter.sidebar || localeConfig.sidebar || themeConfig.sidebar
|
||||||
|
if (pageSidebarConfig === 'auto') {
|
||||||
|
return resolveHeaders(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarConfig = localeConfig.sidebar || themeConfig.sidebar
|
||||||
|
if (!sidebarConfig) {
|
||||||
|
return []
|
||||||
|
} else {
|
||||||
|
const { base, config } = resolveMatchingConfig(regularPath, sidebarConfig)
|
||||||
|
if (config === 'auto') {
|
||||||
|
return resolveHeaders(page)
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
? config.map(item => resolveItem(item, pages, base))
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { Page } page
|
||||||
|
* @returns { SidebarGroup }
|
||||||
|
*/
|
||||||
|
function resolveHeaders (page) {
|
||||||
|
const headers = groupHeaders(page.headers || [])
|
||||||
|
return [{
|
||||||
|
type: 'group',
|
||||||
|
collapsable: false,
|
||||||
|
title: page.title,
|
||||||
|
path: null,
|
||||||
|
children: headers.map(h => ({
|
||||||
|
type: 'auto',
|
||||||
|
title: h.title,
|
||||||
|
basePath: page.path,
|
||||||
|
path: page.path + '#' + h.slug,
|
||||||
|
children: h.children || []
|
||||||
|
}))
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupHeaders (headers) {
|
||||||
|
// group h3s under h2
|
||||||
|
headers = headers.map(h => Object.assign({}, h))
|
||||||
|
let lastH2
|
||||||
|
headers.forEach(h => {
|
||||||
|
if (h.level === 2) {
|
||||||
|
lastH2 = h
|
||||||
|
} else if (lastH2) {
|
||||||
|
(lastH2.children || (lastH2.children = [])).push(h)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return headers.filter(h => h.level === 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveNavLinkItem (linkItem) {
|
||||||
|
return Object.assign(linkItem, {
|
||||||
|
type: linkItem.items && linkItem.items.length ? 'links' : 'link'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param { Route } route
|
||||||
|
* @param { Array<string|string[]> | Array<SidebarGroup> | [link: string]: SidebarConfig } config
|
||||||
|
* @returns { base: string, config: SidebarConfig }
|
||||||
|
*/
|
||||||
|
export function resolveMatchingConfig (regularPath, config) {
|
||||||
|
if (Array.isArray(config)) {
|
||||||
|
return {
|
||||||
|
base: '/',
|
||||||
|
config: config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const base in config) {
|
||||||
|
if (ensureEndingSlash(regularPath).indexOf(encodeURI(base)) === 0) {
|
||||||
|
return {
|
||||||
|
base,
|
||||||
|
config: config[base]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureEndingSlash (path) {
|
||||||
|
return /(\.html|\/)$/.test(path)
|
||||||
|
? path
|
||||||
|
: path + '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveItem (item, pages, base, groupDepth = 1) {
|
||||||
|
if (typeof item === 'string') {
|
||||||
|
return resolvePage(pages, item, base)
|
||||||
|
} else if (Array.isArray(item)) {
|
||||||
|
return Object.assign(resolvePage(pages, item[0], base), {
|
||||||
|
title: item[1]
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const children = item.children || []
|
||||||
|
if (children.length === 0 && item.path) {
|
||||||
|
return Object.assign(resolvePage(pages, item.path, base), {
|
||||||
|
title: item.title
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'group',
|
||||||
|
path: item.path,
|
||||||
|
title: item.title,
|
||||||
|
sidebarDepth: item.sidebarDepth,
|
||||||
|
initialOpenGroupIndex: item.initialOpenGroupIndex,
|
||||||
|
children: children.map(child => resolveItem(child, pages, base, groupDepth + 1)),
|
||||||
|
collapsable: item.collapsable !== false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
45
docs/src/appendix/index.md
Normal file
45
docs/src/appendix/index.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
sidebarDepth: 1
|
||||||
|
sidebar: auto
|
||||||
|
---
|
||||||
|
|
||||||
|
# Appendix
|
||||||
|
|
||||||
|
## GraphQL API
|
||||||
|
|
||||||
|
Hetty exposes a GraphQL API over HTTP for managing all its features. This API is
|
||||||
|
used by the web admin interface; a Next.js app using Apollo Client.
|
||||||
|
|
||||||
|
### Playground
|
||||||
|
|
||||||
|
You can also introspect and manually experiment with the API via the included GraphQL Playground. To access it, start Hetty and visit: [http://localhost:8080/api/playground](http://localhost:8080/api/playground).
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
<<< @/../pkg/api/schema.graphql
|
||||||
|
|
||||||
|
Source: [pkg/api/schema.graphql](https://github.com/dstotijn/hetty/blob/master/pkg/api/schema.graphql)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
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
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user