├── .chglog ├── CHANGELOG.tpl.md └── config.yml ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── build.yaml │ ├── certs.yaml │ ├── release.yaml │ └── website.yaml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── ghz-web │ └── main.go └── ghz │ └── main.go ├── doc.go ├── extras ├── influx-details-grafana-dashboard.json ├── influx-details-grafana-dashboard.png ├── influx-summary-grafana-dashboard.json └── influx-summary-grafana-dashboard.png ├── go.mod ├── go.sum ├── green_fwd2.svg ├── internal ├── common.go ├── gtime │ ├── gtime.pb.go │ ├── gtime_grpc.pb.go │ └── server.go ├── helloworld │ ├── greeter.pb.go │ └── greeter_server.go ├── sleep │ ├── server.go │ └── service.pb.go └── wrapped │ ├── server.go │ ├── wrapped.pb.go │ └── wrapped_grpc.pb.go ├── load ├── pacer.go ├── pacer_test.go ├── worker_ticker.go └── worker_ticker_test.go ├── printer ├── influx.go ├── influx_test.go ├── printer.go ├── prometheus.go ├── prometheus_test.go └── template.go ├── protodesc ├── protodesc.go └── protodesc_test.go ├── runner ├── calldata.go ├── calldata_test.go ├── config.go ├── config_test.go ├── counter.go ├── data.go ├── data_test.go ├── example_test.go ├── logger.go ├── options.go ├── options_test.go ├── reason.go ├── reason_test.go ├── reporter.go ├── reporter_test.go ├── requester.go ├── run.go ├── run_test.go ├── stats_handler.go ├── stats_handler_test.go └── worker.go ├── statik.sh ├── testdata ├── bundle.protoset ├── bundle │ ├── cap.proto │ ├── common.proto │ └── greeter.proto ├── cfgpath.json ├── config.json ├── config.toml ├── config │ ├── config0.json │ ├── config0.toml │ ├── config0.yaml │ ├── config1.json │ ├── config1.toml │ ├── config1.yaml │ ├── config2.json │ ├── config2.toml │ ├── config2.yaml │ ├── config3.json │ ├── config3.toml │ ├── config3.yaml │ ├── config4.json │ ├── config4.toml │ ├── config4.yaml │ ├── config5.json │ ├── config5.toml │ ├── config5.yaml │ ├── config6.json │ ├── config6.toml │ └── config6.yaml ├── config2.json ├── config2.toml ├── config3.json ├── config3.toml ├── config4.toml ├── config5.toml ├── data.json ├── data.proto ├── data_empty.json ├── ghz.json ├── google │ └── protobuf │ │ ├── duration.proto │ │ ├── timestamp.proto │ │ └── wrappers.proto ├── greeter.pb.go ├── greeter.proto ├── grpcbin.proto ├── gtime.proto ├── hello.proto ├── hello_request_data.bin ├── localhost.crt ├── localhost.key ├── metadata.json ├── optional.proto ├── sleep.proto ├── tsconfig.json ├── wrapped.proto └── wrapped_data.json ├── tools └── tools.go ├── web ├── api │ ├── delete_test.go │ ├── export.go │ ├── export_test.go │ ├── histogram.go │ ├── histogram_test.go │ ├── info.go │ ├── info_test.go │ ├── ingest.go │ ├── ingest_test.go │ ├── options.go │ ├── options_test.go │ ├── project.go │ ├── project_test.go │ ├── report.go │ └── report_test.go ├── config │ ├── config.go │ └── config_test.go ├── database │ ├── database.go │ ├── database_test.go │ ├── detail.go │ ├── detail_test.go │ ├── histogram.go │ ├── histogram_test.go │ ├── options.go │ ├── options_test.go │ ├── project.go │ ├── project_test.go │ ├── report.go │ └── report_test.go ├── model │ ├── detail.go │ ├── detail_test.go │ ├── histogram.go │ ├── histogram_test.go │ ├── model.go │ ├── options.go │ ├── options_test.go │ ├── project.go │ ├── project_test.go │ ├── report.go │ ├── report_test.go │ ├── status.go │ └── status_test.go ├── router │ ├── router.go │ └── statik │ │ └── statik.go ├── test │ ├── SayHello │ │ ├── report1.json │ │ ├── report2.json │ │ ├── report3.json │ │ ├── report4.json │ │ ├── report5.json │ │ ├── report6.json │ │ ├── report7.json │ │ ├── report8.json │ │ └── report9.json │ ├── SayHellos │ │ ├── report1.json │ │ ├── report2.json │ │ ├── report3.json │ │ ├── report4.json │ │ ├── report5.json │ │ ├── report6.json │ │ ├── report7.json │ │ ├── report8.json │ │ └── report9.json │ ├── config1.toml │ ├── config2.json │ ├── config2.toml │ ├── config2.yml │ ├── config3.json │ ├── config3.toml │ ├── config3.yml │ └── create_test_reports.js ├── todo.md └── ui │ ├── .babelrc │ ├── package-lock.json │ ├── package.json │ └── src │ ├── App.jsx │ ├── components │ ├── ComparePage.jsx │ ├── ComparePane.jsx │ ├── DeleteDialog.jsx │ ├── EditProjectDialog.jsx │ ├── Footer.jsx │ ├── GitHubIcon.jsx │ ├── HistogramChart.jsx │ ├── HistogramPane.jsx │ ├── HistoryChart.jsx │ ├── InfoComponent.jsx │ ├── OptionsPane.jsx │ ├── ProjectDetailPage.jsx │ ├── ProjectDetailPane.jsx │ ├── ProjectList.jsx │ ├── ProjectListPage.jsx │ ├── ReportDetailPage.jsx │ ├── ReportDetailPane.jsx │ ├── ReportDistChart.jsx │ ├── ReportList.jsx │ ├── ReportPage.jsx │ ├── ReportsOverTimePane.jsx │ └── StatusBadge.jsx │ ├── containers │ ├── CompareContainer.js │ ├── HistogramContainer.js │ ├── InfoContainer.js │ ├── OptionsContainer.js │ ├── ProjectContainer.js │ └── ReportContainer.js │ ├── index.html │ ├── lib │ ├── colors.js │ ├── common.js │ ├── compareBarChart.js │ ├── doughnutData.js │ ├── histogramData.js │ └── projectChartData.js │ └── main.js └── www ├── docs ├── calldata.md ├── concurrency.md ├── example_config.md ├── examples.md ├── extras.md ├── install.md ├── intro.md ├── load.md ├── options.md ├── output.md ├── package.md ├── usage.md └── web │ ├── api.md │ ├── config.md │ ├── data.md │ ├── install.md │ └── intro.md └── website ├── core └── Footer.js ├── i18n └── en.json ├── package-lock.json ├── package.json ├── pages └── en │ └── index.js ├── sidebars.json ├── siteConfig.js └── static ├── css └── custom.css ├── extras ├── influx-details-grafana-dashboard.json └── influx-summary-grafana-dashboard.json ├── images ├── const_c_const_rps.svg ├── const_c_line_down_rps.svg ├── const_c_line_up_rps.svg ├── const_c_step_down_rps.svg ├── const_c_step_up_rps.svg ├── line_up_c_const_rps_wc.svg ├── step_down_c_const_rps_wc.svg └── step_up_c_const_rps_wc.svg ├── img ├── favicon.ico ├── favicon.png ├── ghz_cobalt_plain.png ├── green_fwd2.png ├── green_fwd2.svg ├── influx-details-grafana-dashboard.png ├── influx-summary-grafana-dashboard.png ├── project_detail_page.png ├── project_detail_page_preview.png ├── report_detail_page.png └── report_detail_page_preview.png ├── pretty.json ├── prometheus.txt └── sample.html /.chglog/CHANGELOG.tpl.md: -------------------------------------------------------------------------------- 1 | ## Changelog 2 | {{ range .Versions }} 3 | 4 | ## {{ if .Tag.Previous }}{{ .Tag.Name }}{{ else }}{{ .Tag.Name }}{{ end }} - {{ datetime "2006-01-02" .Tag.Date }} 5 | 6 | {{ if .CommitGroups -}} 7 | {{ range .CommitGroups -}} 8 | ### {{ .Title }} 9 | {{ range .Commits -}} 10 | - {{.Hash.Short}} {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ if .Subject }}{{ .Subject }}{{ else }}{{ .Header }}{{ end }} 11 | {{ end }} 12 | {{ end }} 13 | {{ end -}} 14 | 15 | ### Commits 16 | {{ range .Commits -}} 17 | - {{.Hash.Short}} {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ if .Subject }}{{ .Subject }}{{ else }}{{ .Header }}{{ end }} 18 | {{ end }} 19 | 20 | {{- if .NoteGroups -}} 21 | {{ range .NoteGroups -}} 22 | ### {{ .Title }} 23 | {{ range .Notes }} 24 | {{ .Body }} 25 | {{ end }} 26 | {{ end -}} 27 | {{ end -}} 28 | 29 | {{ end -}} -------------------------------------------------------------------------------- /.chglog/config.yml: -------------------------------------------------------------------------------- 1 | style: github 2 | template: CHANGELOG.tpl.md 3 | info: 4 | title: CHANGELOG 5 | repository_url: https://github.com/bojand/ghz 6 | options: 7 | commits: 8 | filters: 9 | Type: 10 | - docs 11 | - enhance 12 | - feat 13 | - fix 14 | - build 15 | commit_groups: 16 | title_maps: 17 | docs: Documentation 18 | enhance: Enhancements 19 | feat: Features 20 | fix: Bug Fixes 21 | build: Build 22 | header: 23 | pattern: "^(\\w*)?\\:\\s(.*)$" 24 | pattern_maps: 25 | - Type 26 | - Subject 27 | notes: 28 | keywords: 29 | - BREAKING CHANGE -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # Thank you for your support 🙌 2 | 3 | custom: ['https://www.paypal.me/bojandj', 'https://www.buymeacoffee.com/bojand'] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Proto file(s)** 11 | List protocol buffer definitions 12 | 13 | **Command line arguments / config** 14 | List all command line arguments or config properties 15 | 16 | **Describe the bug** 17 | A clear and concise description of what the bug is. 18 | 19 | **To Reproduce** 20 | Steps to reproduce the behavior: 21 | 22 | **Expected behavior** 23 | A clear and concise description of what you expected to happen. 24 | 25 | **Environment** 26 | - OS: [e.g. macOS 10.14.3] 27 | - ghz: [e.g. 0.33.0] 28 | 29 | **Screenshots** 30 | If applicable, add screenshots to help explain your problem. 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - 'go.mod' 9 | - 'go.sum' 10 | - '*.go' 11 | - '*/*.go' 12 | - '*/*/*.go' 13 | - '*/*/*/*.go' 14 | - '*/*/*/*/*.go' 15 | - '*/*/*/*/*/*.go' 16 | - '*/*/*/*/*/*/*.go' 17 | - '*/*/*/*/*/*/*/*.go' 18 | pull_request: 19 | branches: 20 | - master 21 | paths: 22 | - 'go.mod' 23 | - 'go.sum' 24 | - '*.go' 25 | - '*/*.go' 26 | - '*/*/*.go' 27 | - '*/*/*/*.go' 28 | - '*/*/*/*/*.go' 29 | - '*/*/*/*/*/*.go' 30 | - '*/*/*/*/*/*/*.go' 31 | - '*/*/*/*/*/*/*/*.go' 32 | 33 | jobs: 34 | build: 35 | runs-on: ubuntu-latest 36 | env: 37 | GO111MODULE: on 38 | GOLANGCI_LINT_VERSION: v1.58.0 39 | 40 | steps: 41 | - name: Set up Go 42 | uses: actions/setup-go@v5 43 | with: 44 | go-version: 1.22.2 45 | id: go 46 | - name: Check out code 47 | uses: actions/checkout@v4 48 | - name: Cache Go modules 49 | uses: actions/cache@v4 50 | id: cache-go-mod 51 | with: 52 | path: ~/go/pkg/mod 53 | key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} 54 | restore-keys: | 55 | ${{ runner.os }}-go- 56 | - name: Cache bin directory 57 | id: cache-go-bin 58 | uses: actions/cache@v4 59 | with: 60 | path: ~/go/bin 61 | key: ${{ runner.os }}-go-bin-${{ env.GOLANGCI_LINT_VERSION }} 62 | - name: Install tparse 63 | if: steps.cache-go-bin.outputs.cache-hit != 'true' 64 | run: go install github.com/mfridman/tparse 65 | - name: Install golangci-lint 66 | if: steps.cache-go-bin.outputs.cache-hit != 'true' 67 | run: curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $HOME/go/bin $GOLANGCI_LINT_VERSION 68 | - name: Lint 69 | run: $HOME/go/bin/golangci-lint run --timeout=2m ./... 70 | - name: Test 71 | run: go test -failfast -race -covermode=atomic -coverprofile=coverage.txt -cover -json ./... | $HOME/go/bin/tparse 72 | 73 | build_docker_image: 74 | name: Build Docker image from latest tag 75 | runs-on: ubuntu-latest 76 | steps: 77 | - name: Build image 78 | run: docker build https://github.com/bojand/ghz.git 79 | env: 80 | DOCKER_BUILDKIT: '1' 81 | -------------------------------------------------------------------------------- /.github/workflows/certs.yaml: -------------------------------------------------------------------------------- 1 | name: certs 2 | 3 | on: 4 | schedule: 5 | - cron: '10 0 1 * *' 6 | workflow_dispatch: 7 | inputs: 8 | comment: 9 | description: 'comment on the workflow dispatch' 10 | required: false 11 | default: 'manual workflow dispatch' 12 | type: string 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Refresh certificates 21 | run: openssl req -x509 -out testdata/localhost.crt -keyout testdata/localhost.key -newkey rsa:2048 -nodes -sha256 -subj '/CN=localhost' -extensions EXT -config <( printf "[dn]\nCN=localhost\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:localhost\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth") 22 | - name: Commit to repository 23 | env: 24 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 25 | COMMIT_MSG: Refresh certificates 26 | run: | 27 | git config user.email "dbojan@gmail.com" 28 | git config user.name "Bojan" 29 | # Update origin with token 30 | git remote set-url origin https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git 31 | # Checkout the branch so we can push back to it 32 | git checkout master 33 | git add . 34 | # Only commit and push if we have changes 35 | git diff --quiet && git diff --staged --quiet || (git commit -m "${COMMIT_MSG}"; git push origin master) 36 | -------------------------------------------------------------------------------- /.github/workflows/website.yaml: -------------------------------------------------------------------------------- 1 | name: website 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - 'www/docs/*.md' 9 | - 'www/docs/web/*.md' 10 | - 'www/website/*' 11 | 12 | jobs: 13 | build: 14 | env: 15 | WWW_TARGET_BRANCH: gh-pages 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Check out code 19 | uses: actions/checkout@v3 20 | - name: Use Node.js 18 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: 18.x 24 | - name: Build webpage 25 | run: | 26 | npm ci 27 | npm run build 28 | working-directory: ./www/website 29 | - name: Copy data 30 | run: | 31 | mkdir -p ${{env.HOME}}/tmp/www/build 32 | cp -r ./www/website/build/ghz/. ${{env.HOME}}/tmp/www/build 33 | - name: Check out target branch 34 | uses: actions/checkout@v3 35 | with: 36 | ref: ${{ env.WWW_TARGET_BRANCH }} 37 | - name: Copy new data 38 | run: | 39 | cp -r ${{env.HOME}}/tmp/www/build/. . 40 | - name: Commit to repository 41 | env: 42 | GITHUB_TOKEN: ${{ secrets.GH_PAGES_ACTION_TOKEN }} 43 | COMMIT_MSG: Update website 44 | run: | 45 | git config user.email "dbojan@gmail.com" 46 | git config user.name "Bojan" 47 | # Update origin with token 48 | git remote set-url origin https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git 49 | # Add changed files 50 | git add . 51 | # Only commit and push if we have changes 52 | git diff --quiet && git diff --staged --quiet || (git commit -m "${COMMIT_MSG}"; git push origin ${WWW_TARGET_BRANCH}) 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | # Test binary, build with `go test -c` 8 | *.test 9 | 10 | # Output of the go coverage tool, specifically when used with LiteIDE 11 | *.out 12 | 13 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 14 | .glide/ 15 | 16 | *.out 17 | 18 | /cmd/ghz/ghz 19 | /cmd/ghz-web/ghz-web 20 | /cmd/server/server 21 | /.tmp/ 22 | 23 | dist 24 | build 25 | node_modules 26 | .cache 27 | coverage.html 28 | 29 | .idea 30 | .orig 31 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | # TODO Look into enabling all with some exception where practical 2 | # linters: 3 | # enable-all: true 4 | # disable: 5 | # - gochecknoglobals 6 | # - dupl 7 | # - lll 8 | issues: 9 | exclude-rules: 10 | # Exclude lostcancel govet rule specifically for requester.go 11 | # Since we purposefully do that. See comments in code. 12 | - path: runner/requester.go 13 | text: "lostcancel" 14 | 15 | # TODO Look into fixing time.Tick() usage SA1015 in worker.go 16 | - path: runner/worker.go 17 | text: "SA1015" 18 | 19 | # We intentionally assign nil to err 20 | - path: runner/worker.go 21 | text: "ineffectual assignment to `err`" 22 | 23 | # Debug log Sync() error check in defer in main 24 | - path: cmd/ghz/main.go 25 | text: "Error return value of `logger.Sync` is not checked" 26 | 27 | # sync.once copy in pacer test 28 | - path: load/pacer_test.go 29 | text: "copylocks" 30 | 31 | # TODO fix protobuf deprecated 32 | - path: runner/ 33 | text: 'SA1019: "github.com/golang/protobuf/proto"' 34 | 35 | # TODO fix protobuf deprecated 36 | - path: protodesc/ 37 | text: 'SA1019: "github.com/golang/protobuf/proto"' 38 | 39 | # TODO fix protobuf deprecated 40 | - path: runner/ 41 | text: 'SA1019: "github.com/golang/protobuf/jsonpb" is deprecated' 42 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | builds: 2 | - id: ghz 3 | main: ./cmd/ghz 4 | binary: ghz 5 | env: 6 | - CGO_ENABLED=0 7 | goos: 8 | - darwin 9 | - linux 10 | - windows 11 | goarch: 12 | - amd64 13 | - id: ghz-web 14 | main: ./cmd/ghz-web 15 | binary: ghz-web 16 | goos: 17 | - darwin 18 | - linux 19 | - windows 20 | goarch: 21 | - amd64 22 | archives: 23 | - id: ghz 24 | replacements: 25 | darwin: Darwin 26 | linux: Linux 27 | windows: Windows 28 | amd64: x86_64 29 | checksum: 30 | name_template: 'checksums.txt' 31 | snapshot: 32 | name_template: "{{ .Tag }}-next" 33 | release: 34 | prerelease: auto 35 | changelog: 36 | sort: asc 37 | filters: 38 | exclude: 39 | - '^docs:' 40 | - '^test:' 41 | - '^www:' 42 | - '^\[skip ci\]' 43 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker.io/docker/dockerfile:1.3-labs@sha256:250ce669e1aeeb5ffb892b18039c3f0801466536cb4210c8eb2638e628859bfd 2 | 3 | # FROM --platform=$BUILDPLATFORM docker.io/library/alpine:3.19 AS alpine 4 | # FROM --platform=$BUILDPLATFORM docker.io/library/golang@sha256:403f48633fb5ebd49f9a2b6ad6719f912df23dae44974a0c9445be331e72ff5e AS golang 5 | # FROM --platform=$BUILDPLATFORM gcr.io/distroless/base:nonroot@sha256:e406b1da09bc455495417a809efe48a03c48011a89f6eb57b0ab882508021c0d AS distroless 6 | 7 | FROM golang:1.22.2 AS builder 8 | WORKDIR /app 9 | ARG TARGETOS TARGETARCH 10 | ENV CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH 11 | COPY go.??? . 12 | RUN \ 13 | --mount=type=cache,target=/go/pkg/mod \ 14 | --mount=type=cache,target=/root/.cache/go-build </os 32 | FROM alpine:3.19 AS osmap-macos 33 | RUN echo darwin >/os 34 | FROM alpine:3.19 AS osmap-windows 35 | RUN echo windows >/os 36 | FROM osmap-$TARGETOS AS osmap 37 | 38 | FROM alpine AS fetcher 39 | WORKDIR /app 40 | ARG VERSION 41 | RUN \ 42 | --mount=from=osmap,source=/os,target=/os < 2 | 3 | 5 | 7 | 9 | 10 | 19 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /internal/common.go: -------------------------------------------------------------------------------- 1 | package internal 2 | 3 | import ( 4 | "net" 5 | "strconv" 6 | 7 | "google.golang.org/grpc" 8 | "google.golang.org/grpc/credentials" 9 | "google.golang.org/grpc/reflection" 10 | 11 | "github.com/bojand/ghz/internal/gtime" 12 | "github.com/bojand/ghz/internal/helloworld" 13 | "github.com/bojand/ghz/internal/sleep" 14 | "github.com/bojand/ghz/internal/wrapped" 15 | ) 16 | 17 | // TestPort is the port. 18 | var TestPort string 19 | 20 | // TestLocalhost is the localhost. 21 | var TestLocalhost string 22 | 23 | // StartServer starts the server. 24 | // 25 | // For testing only. 26 | func StartServer(secure bool) (*helloworld.Greeter, *grpc.Server, error) { 27 | lis, err := net.Listen("tcp", ":0") 28 | if err != nil { 29 | return nil, nil, err 30 | } 31 | 32 | var opts []grpc.ServerOption 33 | 34 | if secure { 35 | creds, err := credentials.NewServerTLSFromFile("../testdata/localhost.crt", "../testdata/localhost.key") 36 | if err != nil { 37 | return nil, nil, err 38 | } 39 | opts = []grpc.ServerOption{grpc.Creds(creds)} 40 | } 41 | 42 | stats := helloworld.NewHWStats() 43 | 44 | opts = append(opts, grpc.StatsHandler(stats)) 45 | 46 | s := grpc.NewServer(opts...) 47 | 48 | gs := helloworld.NewGreeter() 49 | helloworld.RegisterGreeterServer(s, gs) 50 | reflection.Register(s) 51 | 52 | gs.Stats = stats 53 | 54 | TestPort = strconv.Itoa(lis.Addr().(*net.TCPAddr).Port) 55 | TestLocalhost = "localhost:" + TestPort 56 | 57 | go func() { 58 | _ = s.Serve(lis) 59 | }() 60 | 61 | return gs, s, err 62 | } 63 | 64 | // StartSleepServer starts the sleep test server 65 | func StartSleepServer(secure bool) (*sleep.SleepService, *grpc.Server, error) { 66 | lis, err := net.Listen("tcp", ":0") 67 | if err != nil { 68 | return nil, nil, err 69 | } 70 | 71 | var opts []grpc.ServerOption 72 | 73 | if secure { 74 | creds, err := credentials.NewServerTLSFromFile("../testdata/localhost.crt", "../testdata/localhost.key") 75 | if err != nil { 76 | return nil, nil, err 77 | } 78 | opts = []grpc.ServerOption{grpc.Creds(creds)} 79 | } 80 | 81 | stats := helloworld.NewHWStats() 82 | 83 | opts = append(opts, grpc.StatsHandler(stats)) 84 | 85 | s := grpc.NewServer(opts...) 86 | 87 | ss := sleep.SleepService{} 88 | sleep.RegisterSleepServiceServer(s, &ss) 89 | reflection.Register(s) 90 | 91 | TestPort = strconv.Itoa(lis.Addr().(*net.TCPAddr).Port) 92 | TestLocalhost = "localhost:" + TestPort 93 | 94 | go func() { 95 | _ = s.Serve(lis) 96 | }() 97 | 98 | return &ss, s, err 99 | } 100 | 101 | // StartWrappedServer starts the wrapped test server 102 | func StartWrappedServer(secure bool) (*wrapped.WrappedService, *grpc.Server, error) { 103 | lis, err := net.Listen("tcp", ":0") 104 | if err != nil { 105 | return nil, nil, err 106 | } 107 | 108 | var opts []grpc.ServerOption 109 | 110 | if secure { 111 | creds, err := credentials.NewServerTLSFromFile("../testdata/localhost.crt", "../testdata/localhost.key") 112 | if err != nil { 113 | return nil, nil, err 114 | } 115 | opts = []grpc.ServerOption{grpc.Creds(creds)} 116 | } 117 | 118 | s := grpc.NewServer(opts...) 119 | 120 | ws := wrapped.WrappedService{} 121 | wrapped.RegisterWrappedServiceServer(s, &ws) 122 | reflection.Register(s) 123 | 124 | TestPort = strconv.Itoa(lis.Addr().(*net.TCPAddr).Port) 125 | TestLocalhost = "localhost:" + TestPort 126 | 127 | go func() { 128 | _ = s.Serve(lis) 129 | }() 130 | 131 | return &ws, s, err 132 | } 133 | 134 | // StartTimeServer starts the wrapped test server 135 | func StartTimeServer(secure bool) (*gtime.TimeService, *grpc.Server, error) { 136 | lis, err := net.Listen("tcp", ":0") 137 | if err != nil { 138 | return nil, nil, err 139 | } 140 | 141 | var opts []grpc.ServerOption 142 | 143 | if secure { 144 | creds, err := credentials.NewServerTLSFromFile("../testdata/localhost.crt", "../testdata/localhost.key") 145 | if err != nil { 146 | return nil, nil, err 147 | } 148 | opts = []grpc.ServerOption{grpc.Creds(creds)} 149 | } 150 | 151 | s := grpc.NewServer(opts...) 152 | 153 | gs := gtime.TimeService{} 154 | gtime.RegisterTimeServiceServer(s, &gs) 155 | reflection.Register(s) 156 | 157 | TestPort = strconv.Itoa(lis.Addr().(*net.TCPAddr).Port) 158 | TestLocalhost = "localhost:" + TestPort 159 | 160 | go func() { 161 | _ = s.Serve(lis) 162 | }() 163 | 164 | return &gs, s, err 165 | } 166 | -------------------------------------------------------------------------------- /internal/gtime/gtime_grpc.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go-grpc. DO NOT EDIT. 2 | 3 | package gtime 4 | 5 | import ( 6 | context "context" 7 | grpc "google.golang.org/grpc" 8 | codes "google.golang.org/grpc/codes" 9 | status "google.golang.org/grpc/status" 10 | ) 11 | 12 | // This is a compile-time assertion to ensure that this generated file 13 | // is compatible with the grpc package it is being compiled against. 14 | // Requires gRPC-Go v1.32.0 or later. 15 | const _ = grpc.SupportPackageIsVersion7 16 | 17 | // TimeServiceClient is the client API for TimeService service. 18 | // 19 | // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. 20 | type TimeServiceClient interface { 21 | TestCall(ctx context.Context, in *CallRequest, opts ...grpc.CallOption) (*CallReply, error) 22 | } 23 | 24 | type timeServiceClient struct { 25 | cc grpc.ClientConnInterface 26 | } 27 | 28 | func NewTimeServiceClient(cc grpc.ClientConnInterface) TimeServiceClient { 29 | return &timeServiceClient{cc} 30 | } 31 | 32 | func (c *timeServiceClient) TestCall(ctx context.Context, in *CallRequest, opts ...grpc.CallOption) (*CallReply, error) { 33 | out := new(CallReply) 34 | err := c.cc.Invoke(ctx, "/gtime.TimeService/TestCall", in, out, opts...) 35 | if err != nil { 36 | return nil, err 37 | } 38 | return out, nil 39 | } 40 | 41 | // TimeServiceServer is the server API for TimeService service. 42 | // All implementations must embed UnimplementedTimeServiceServer 43 | // for forward compatibility 44 | type TimeServiceServer interface { 45 | TestCall(context.Context, *CallRequest) (*CallReply, error) 46 | mustEmbedUnimplementedTimeServiceServer() 47 | } 48 | 49 | // UnimplementedTimeServiceServer must be embedded to have forward compatible implementations. 50 | type UnimplementedTimeServiceServer struct { 51 | } 52 | 53 | func (UnimplementedTimeServiceServer) TestCall(context.Context, *CallRequest) (*CallReply, error) { 54 | return nil, status.Errorf(codes.Unimplemented, "method TestCall not implemented") 55 | } 56 | func (UnimplementedTimeServiceServer) mustEmbedUnimplementedTimeServiceServer() {} 57 | 58 | // UnsafeTimeServiceServer may be embedded to opt out of forward compatibility for this service. 59 | // Use of this interface is not recommended, as added methods to TimeServiceServer will 60 | // result in compilation errors. 61 | type UnsafeTimeServiceServer interface { 62 | mustEmbedUnimplementedTimeServiceServer() 63 | } 64 | 65 | func RegisterTimeServiceServer(s grpc.ServiceRegistrar, srv TimeServiceServer) { 66 | s.RegisterService(&TimeService_ServiceDesc, srv) 67 | } 68 | 69 | func _TimeService_TestCall_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 70 | in := new(CallRequest) 71 | if err := dec(in); err != nil { 72 | return nil, err 73 | } 74 | if interceptor == nil { 75 | return srv.(TimeServiceServer).TestCall(ctx, in) 76 | } 77 | info := &grpc.UnaryServerInfo{ 78 | Server: srv, 79 | FullMethod: "/gtime.TimeService/TestCall", 80 | } 81 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 82 | return srv.(TimeServiceServer).TestCall(ctx, req.(*CallRequest)) 83 | } 84 | return interceptor(ctx, in, info, handler) 85 | } 86 | 87 | // TimeService_ServiceDesc is the grpc.ServiceDesc for TimeService service. 88 | // It's only intended for direct use with grpc.RegisterService, 89 | // and not to be introspected or modified (even as a copy) 90 | var TimeService_ServiceDesc = grpc.ServiceDesc{ 91 | ServiceName: "gtime.TimeService", 92 | HandlerType: (*TimeServiceServer)(nil), 93 | Methods: []grpc.MethodDesc{ 94 | { 95 | MethodName: "TestCall", 96 | Handler: _TimeService_TestCall_Handler, 97 | }, 98 | }, 99 | Streams: []grpc.StreamDesc{}, 100 | Metadata: "gtime.proto", 101 | } 102 | -------------------------------------------------------------------------------- /internal/gtime/server.go: -------------------------------------------------------------------------------- 1 | package gtime 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | "time" 7 | ) 8 | 9 | type TimeService struct { 10 | UnimplementedTimeServiceServer 11 | 12 | LastTimestamp time.Time 13 | LastDuration time.Duration 14 | } 15 | 16 | func (s *TimeService) TestCall(ctx context.Context, req *CallRequest) (*CallReply, error) { 17 | 18 | s.LastTimestamp = req.GetTs().AsTime() 19 | s.LastDuration = req.GetDur().AsDuration() 20 | 21 | return &CallReply{ 22 | Ts: req.GetTs(), 23 | Dur: req.GetDur(), 24 | Message: strconv.FormatUint(req.GetUserId(), 10), 25 | }, nil 26 | } 27 | -------------------------------------------------------------------------------- /internal/sleep/server.go: -------------------------------------------------------------------------------- 1 | package sleep 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | type SleepService struct{} 9 | 10 | func (s *SleepService) SleepFor(ctx context.Context, req *SleepRequest) (*SleepResponse, error) { 11 | time.Sleep(time.Duration(req.Milliseconds) * time.Millisecond) 12 | return &SleepResponse{}, nil 13 | } 14 | -------------------------------------------------------------------------------- /internal/wrapped/server.go: -------------------------------------------------------------------------------- 1 | package wrapped 2 | 3 | import ( 4 | "context" 5 | 6 | wrappers "github.com/golang/protobuf/ptypes/wrappers" 7 | ) 8 | 9 | type WrappedService struct{} 10 | 11 | func (s *WrappedService) GetMessage(ctx context.Context, req *wrappers.StringValue) (*wrappers.StringValue, error) { 12 | return &wrappers.StringValue{Value: "Hello: " + req.GetValue()}, nil 13 | } 14 | 15 | func (s *WrappedService) GetBytesMessage(ctx context.Context, req *wrappers.BytesValue) (*wrappers.BytesValue, error) { 16 | return &wrappers.BytesValue{Value: req.GetValue()}, nil 17 | } 18 | 19 | func (s *WrappedService) mustEmbedUnimplementedWrappedServiceServer() {} 20 | -------------------------------------------------------------------------------- /internal/wrapped/wrapped.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.25.0 4 | // protoc v3.11.4 5 | // source: wrapped.proto 6 | 7 | package wrapped 8 | 9 | import ( 10 | proto "github.com/golang/protobuf/proto" 11 | wrappers "github.com/golang/protobuf/ptypes/wrappers" 12 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 13 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 14 | reflect "reflect" 15 | ) 16 | 17 | const ( 18 | // Verify that this generated code is sufficiently up-to-date. 19 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 20 | // Verify that runtime/protoimpl is sufficiently up-to-date. 21 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 22 | ) 23 | 24 | // This is a compile-time assertion that a sufficiently up-to-date version 25 | // of the legacy proto package is being used. 26 | const _ = proto.ProtoPackageIsVersion4 27 | 28 | var File_wrapped_proto protoreflect.FileDescriptor 29 | 30 | var file_wrapped_proto_rawDesc = []byte{ 31 | 0x0a, 0x0d, 0x77, 0x72, 0x61, 0x70, 0x70, 0x65, 0x64, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 32 | 0x07, 0x77, 0x72, 0x61, 0x70, 0x70, 0x65, 0x64, 0x1a, 0x1e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 33 | 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x77, 0x72, 0x61, 0x70, 0x70, 0x65, 34 | 0x72, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x32, 0xa7, 0x01, 0x0a, 0x0e, 0x57, 0x72, 0x61, 35 | 0x70, 0x70, 0x65, 0x64, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x48, 0x0a, 0x0a, 0x47, 36 | 0x65, 0x74, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 37 | 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x69, 38 | 0x6e, 0x67, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x1a, 0x1c, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 39 | 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 40 | 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x4b, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x42, 0x79, 0x74, 0x65, 41 | 0x73, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x1b, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 42 | 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x42, 0x79, 0x74, 0x65, 0x73, 43 | 0x56, 0x61, 0x6c, 0x75, 0x65, 0x1a, 0x1b, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 44 | 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x42, 0x79, 0x74, 0x65, 0x73, 0x56, 0x61, 0x6c, 45 | 0x75, 0x65, 0x42, 0x12, 0x5a, 0x10, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x77, 46 | 0x72, 0x61, 0x70, 0x70, 0x65, 0x64, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, 47 | } 48 | 49 | var file_wrapped_proto_goTypes = []interface{}{ 50 | (*wrappers.StringValue)(nil), // 0: google.protobuf.StringValue 51 | (*wrappers.BytesValue)(nil), // 1: google.protobuf.BytesValue 52 | } 53 | var file_wrapped_proto_depIdxs = []int32{ 54 | 0, // 0: wrapped.WrappedService.GetMessage:input_type -> google.protobuf.StringValue 55 | 1, // 1: wrapped.WrappedService.GetBytesMessage:input_type -> google.protobuf.BytesValue 56 | 0, // 2: wrapped.WrappedService.GetMessage:output_type -> google.protobuf.StringValue 57 | 1, // 3: wrapped.WrappedService.GetBytesMessage:output_type -> google.protobuf.BytesValue 58 | 2, // [2:4] is the sub-list for method output_type 59 | 0, // [0:2] is the sub-list for method input_type 60 | 0, // [0:0] is the sub-list for extension type_name 61 | 0, // [0:0] is the sub-list for extension extendee 62 | 0, // [0:0] is the sub-list for field type_name 63 | } 64 | 65 | func init() { file_wrapped_proto_init() } 66 | func file_wrapped_proto_init() { 67 | if File_wrapped_proto != nil { 68 | return 69 | } 70 | type x struct{} 71 | out := protoimpl.TypeBuilder{ 72 | File: protoimpl.DescBuilder{ 73 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 74 | RawDescriptor: file_wrapped_proto_rawDesc, 75 | NumEnums: 0, 76 | NumMessages: 0, 77 | NumExtensions: 0, 78 | NumServices: 1, 79 | }, 80 | GoTypes: file_wrapped_proto_goTypes, 81 | DependencyIndexes: file_wrapped_proto_depIdxs, 82 | }.Build() 83 | File_wrapped_proto = out.File 84 | file_wrapped_proto_rawDesc = nil 85 | file_wrapped_proto_goTypes = nil 86 | file_wrapped_proto_depIdxs = nil 87 | } 88 | -------------------------------------------------------------------------------- /runner/config_test.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "encoding/json" 5 | "strconv" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestConfig_Load(t *testing.T) { 13 | var tests = []struct { 14 | name string 15 | expected *Config 16 | ok bool 17 | }{ 18 | { 19 | "invalid data", 20 | &Config{}, 21 | false, 22 | }, 23 | { 24 | "invalid duration", 25 | &Config{}, 26 | false, 27 | }, 28 | { 29 | "invalid max-duration", 30 | &Config{}, 31 | false, 32 | }, 33 | { 34 | "invalid stream-interval", 35 | &Config{}, 36 | false, 37 | }, 38 | { 39 | "invalid timeout", 40 | &Config{}, 41 | false, 42 | }, 43 | { 44 | "valid", 45 | &Config{ 46 | Insecure: true, 47 | ImportPaths: []string{"/home/user/pb/grpcbin"}, 48 | Proto: "grpcbin.proto", 49 | Call: "grpcbin.GRPCBin.DummyUnary", 50 | Host: "127.0.0.1:9000", 51 | Z: Duration(20 * time.Second), 52 | X: Duration(60 * time.Second), 53 | SI: Duration(25 * time.Second), 54 | Timeout: Duration(30 * time.Second), 55 | N: 200, 56 | C: 50, 57 | Connections: 1, 58 | ZStop: "close", 59 | Data: map[string]interface{}{ 60 | "f_strings": []interface{}{"123", "456"}, 61 | }, 62 | Format: "summary", 63 | DialTimeout: Duration(10 * time.Second), 64 | LoadSchedule: "const", 65 | CSchedule: "const", 66 | CStart: 1, 67 | MaxCallRecvMsgSize: "1024mb", 68 | MaxCallSendMsgSize: "2000mib", 69 | }, 70 | true, 71 | }, 72 | { 73 | "invalid message size", 74 | &Config{}, 75 | false, 76 | }, 77 | } 78 | 79 | for i, tt := range tests { 80 | t.Run("toml "+tt.name, func(t *testing.T) { 81 | var actual Config 82 | cfgPath := "../testdata/config/config" + strconv.Itoa(i) + ".toml" 83 | err := LoadConfig(cfgPath, &actual) 84 | if tt.ok { 85 | assert.NoError(t, err) 86 | assert.Equal(t, tt.expected, &actual) 87 | } else { 88 | assert.Error(t, err) 89 | } 90 | }) 91 | 92 | t.Run("json "+tt.name, func(t *testing.T) { 93 | var actual Config 94 | cfgPath := "../testdata/config/config" + strconv.Itoa(i) + ".toml" 95 | err := LoadConfig(cfgPath, &actual) 96 | if tt.ok { 97 | assert.NoError(t, err) 98 | assert.Equal(t, tt.expected, &actual) 99 | } else { 100 | assert.Error(t, err) 101 | } 102 | }) 103 | 104 | t.Run("yaml "+tt.name, func(t *testing.T) { 105 | var actual Config 106 | cfgPath := "../testdata/config/config" + strconv.Itoa(i) + ".yaml" 107 | err := LoadConfig(cfgPath, &actual) 108 | if tt.ok { 109 | assert.NoError(t, err) 110 | assert.Equal(t, tt.expected, &actual) 111 | } else { 112 | assert.Error(t, err) 113 | } 114 | }) 115 | } 116 | } 117 | 118 | func TestConfig_MarshalJSON(t *testing.T) { 119 | cfg := Config{ 120 | Insecure: true, 121 | Proto: "proto/service.proto", 122 | Call: "grpcbin.GRPCBin.DummyUnary", 123 | Data: "{interval_in_seconds:10,latitude:-23.43,longitude:-46.45,radius:5000}", 124 | Host: "localhost:8080", 125 | } 126 | 127 | _, err := json.Marshal(cfg) 128 | 129 | assert.NoError(t, err) 130 | } 131 | -------------------------------------------------------------------------------- /runner/counter.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import "sync/atomic" 4 | 5 | // RequestCounter gets the request count 6 | type RequestCounter interface { 7 | Get() uint64 8 | } 9 | 10 | // Counter is an implementation of the request counter 11 | type Counter struct { 12 | c uint64 13 | } 14 | 15 | // Get retrieves the current count 16 | func (c *Counter) Get() uint64 { 17 | return atomic.LoadUint64(&c.c) 18 | } 19 | 20 | // Inc increases the current count 21 | func (c *Counter) Inc() uint64 { 22 | return atomic.AddUint64(&c.c, 1) 23 | } 24 | -------------------------------------------------------------------------------- /runner/example_test.go: -------------------------------------------------------------------------------- 1 | package runner_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/bojand/ghz/printer" 8 | "github.com/bojand/ghz/runner" 9 | ) 10 | 11 | // ExampleRun demonstrates how to use runner package to perform a gRPC load test programmatically. 12 | // We use the printer package to print the report in pretty JSON format. 13 | func ExampleRun() { 14 | report, err := runner.Run( 15 | "helloworld.Greeter.SayHello", 16 | "localhost:50051", 17 | runner.WithProtoFile("greeter.proto", []string{}), 18 | runner.WithDataFromFile("data.json"), 19 | runner.WithInsecure(true), 20 | ) 21 | 22 | if err != nil { 23 | fmt.Println(err.Error()) 24 | os.Exit(1) 25 | } 26 | 27 | printer := printer.ReportPrinter{ 28 | Out: os.Stdout, 29 | Report: report, 30 | } 31 | 32 | printer.Print("pretty") 33 | } 34 | -------------------------------------------------------------------------------- /runner/logger.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | // Logger interface is the common logger interface for all of web 4 | type Logger interface { 5 | Debug(args ...interface{}) 6 | Debugf(template string, args ...interface{}) 7 | Debugw(msg string, keysAndValues ...interface{}) 8 | Error(args ...interface{}) 9 | Errorf(template string, args ...interface{}) 10 | Errorw(msg string, keysAndValues ...interface{}) 11 | } 12 | -------------------------------------------------------------------------------- /runner/reason.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // StopReason is a reason why the run ended 9 | type StopReason string 10 | 11 | // String() is the string representation of threshold 12 | func (s StopReason) String() string { 13 | if s == ReasonCancel { 14 | return "cancel" 15 | } 16 | 17 | if s == ReasonTimeout { 18 | return "timeout" 19 | } 20 | 21 | return "normal" 22 | } 23 | 24 | // UnmarshalJSON prases a Threshold value from JSON string 25 | func (s *StopReason) UnmarshalJSON(b []byte) error { 26 | input := strings.TrimLeft(string(b), `"`) 27 | input = strings.TrimRight(input, `"`) 28 | *s = ReasonFromString(input) 29 | return nil 30 | } 31 | 32 | // MarshalJSON formats a Threshold value into a JSON string 33 | func (s StopReason) MarshalJSON() ([]byte, error) { 34 | return []byte(fmt.Sprintf("\"%s\"", s.String())), nil 35 | } 36 | 37 | // ReasonFromString creates a Status from a string 38 | func ReasonFromString(str string) StopReason { 39 | str = strings.ToLower(str) 40 | 41 | s := ReasonNormalEnd 42 | 43 | if str == "cancel" { 44 | s = ReasonCancel 45 | } 46 | 47 | if str == "timeout" { 48 | s = ReasonTimeout 49 | } 50 | 51 | return s 52 | } 53 | 54 | const ( 55 | // ReasonNormalEnd indicates a normal end to the run 56 | ReasonNormalEnd = StopReason("normal") 57 | 58 | // ReasonCancel indicates end due to cancellation 59 | ReasonCancel = StopReason("cancel") 60 | 61 | // ReasonTimeout indicates run ended due to Z parameter timeout 62 | ReasonTimeout = StopReason("timeout") 63 | ) 64 | -------------------------------------------------------------------------------- /runner/reason_test.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestReason_String(t *testing.T) { 11 | var tests = []struct { 12 | name string 13 | in StopReason 14 | expected string 15 | }{ 16 | {"normal", ReasonNormalEnd, "normal"}, 17 | {"cancel", ReasonCancel, "cancel"}, 18 | {"timeout", ReasonTimeout, "timeout"}, 19 | {"unknown", StopReason("foo"), "normal"}, 20 | } 21 | 22 | for _, tt := range tests { 23 | t.Run(tt.name, func(t *testing.T) { 24 | actual := tt.in.String() 25 | assert.Equal(t, tt.expected, actual) 26 | }) 27 | } 28 | } 29 | 30 | func TestReason_StatusFromString(t *testing.T) { 31 | var tests = []struct { 32 | name string 33 | in string 34 | expected StopReason 35 | }{ 36 | {"normal", "normal", ReasonNormalEnd}, 37 | {"cancel", "cancel", ReasonCancel}, 38 | {"timeout", "timeout", ReasonTimeout}, 39 | {"unknown", "foo", ReasonNormalEnd}, 40 | } 41 | 42 | for _, tt := range tests { 43 | t.Run(tt.name, func(t *testing.T) { 44 | actual := ReasonFromString(tt.in) 45 | assert.Equal(t, tt.expected, actual) 46 | }) 47 | } 48 | } 49 | 50 | func TestReason_UnmarshalJSON(t *testing.T) { 51 | var tests = []struct { 52 | name string 53 | in string 54 | expected StopReason 55 | }{ 56 | {"normal", `"normal"`, ReasonNormalEnd}, 57 | {"NORMAL", `"NORMAL"`, ReasonNormalEnd}, 58 | {"cancel", `"cancel"`, ReasonCancel}, 59 | {"CANCEL", `"CANCEL"`, ReasonCancel}, 60 | {" CANCEL ", ` "CANCEL" `, ReasonCancel}, 61 | {"timeout", ` "timeout" `, ReasonTimeout}, 62 | } 63 | 64 | for _, tt := range tests { 65 | t.Run(tt.name, func(t *testing.T) { 66 | var actual StopReason 67 | err := json.Unmarshal([]byte(tt.in), &actual) 68 | assert.NoError(t, err) 69 | assert.Equal(t, tt.expected, actual) 70 | }) 71 | } 72 | } 73 | 74 | func TestReason_MarshalJSON(t *testing.T) { 75 | var tests = []struct { 76 | name string 77 | in StopReason 78 | expected string 79 | }{ 80 | {"normal", ReasonNormalEnd, `"normal"`}, 81 | {"cancel", ReasonCancel, `"cancel"`}, 82 | {"timeout", ReasonTimeout, `"timeout"`}, 83 | {"unknown", StopReason("foo"), `"normal"`}, 84 | } 85 | 86 | for _, tt := range tests { 87 | t.Run(tt.name, func(t *testing.T) { 88 | actual, err := json.Marshal(tt.in) 89 | assert.NoError(t, err) 90 | assert.Equal(t, tt.expected, string(actual)) 91 | }) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /runner/run.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | "runtime" 7 | "time" 8 | ) 9 | 10 | // Run executes the test 11 | // 12 | // report, err := runner.Run( 13 | // "helloworld.Greeter.SayHello", 14 | // "localhost:50051", 15 | // WithProtoFile("greeter.proto", []string{}), 16 | // WithDataFromFile("data.json"), 17 | // WithInsecure(true), 18 | // ) 19 | func Run(call, host string, options ...Option) (*Report, error) { 20 | c, err := NewConfig(call, host, options...) 21 | 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | oldCPUs := runtime.NumCPU() 27 | 28 | runtime.GOMAXPROCS(c.cpus) 29 | defer runtime.GOMAXPROCS(oldCPUs) 30 | 31 | reqr, err := NewRequester(c) 32 | 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | cancel := make(chan os.Signal, 1) 38 | signal.Notify(cancel, os.Interrupt) 39 | 40 | go func() { 41 | <-cancel 42 | reqr.Stop(ReasonCancel) 43 | }() 44 | 45 | if c.z > 0 { 46 | go func() { 47 | time.Sleep(c.z) 48 | reqr.Stop(ReasonTimeout) 49 | }() 50 | } 51 | 52 | rep, err := reqr.Run() 53 | 54 | return rep, err 55 | } 56 | -------------------------------------------------------------------------------- /runner/stats_handler.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "google.golang.org/grpc/stats" 8 | "google.golang.org/grpc/status" 9 | ) 10 | 11 | // StatsHandler is for gRPC stats 12 | type statsHandler struct { 13 | results chan *callResult 14 | 15 | id int 16 | hasLog bool 17 | log Logger 18 | 19 | lock sync.RWMutex 20 | ignore bool 21 | } 22 | 23 | // HandleConn handle the connection 24 | func (c *statsHandler) HandleConn(ctx context.Context, cs stats.ConnStats) { 25 | // no-op 26 | } 27 | 28 | // TagConn exists to satisfy gRPC stats.Handler. 29 | func (c *statsHandler) TagConn(ctx context.Context, cti *stats.ConnTagInfo) context.Context { 30 | // no-op 31 | return ctx 32 | } 33 | 34 | // HandleRPC implements per-RPC tracing and stats instrumentation. 35 | func (c *statsHandler) HandleRPC(ctx context.Context, rs stats.RPCStats) { 36 | switch rs := rs.(type) { 37 | case *stats.End: 38 | ign := false 39 | c.lock.RLock() 40 | ign = c.ignore 41 | c.lock.RUnlock() 42 | 43 | if !ign { 44 | duration := rs.EndTime.Sub(rs.BeginTime) 45 | 46 | var st string 47 | s, ok := status.FromError(rs.Error) 48 | if ok { 49 | st = s.Code().String() 50 | } 51 | 52 | c.results <- &callResult{rs.Error, st, duration, rs.EndTime} 53 | 54 | if c.hasLog { 55 | c.log.Debugw("Received RPC Stats", 56 | "statsID", c.id, "code", st, "error", rs.Error, 57 | "duration", duration, "stats", rs) 58 | } 59 | } 60 | } 61 | } 62 | 63 | func (c *statsHandler) Ignore(val bool) { 64 | c.lock.Lock() 65 | defer c.lock.Unlock() 66 | 67 | c.ignore = val 68 | } 69 | 70 | // TagRPC implements per-RPC context management. 71 | func (c *statsHandler) TagRPC(ctx context.Context, info *stats.RPCTagInfo) context.Context { 72 | return ctx 73 | } 74 | -------------------------------------------------------------------------------- /runner/stats_handler_test.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/bojand/ghz/internal" 9 | "github.com/bojand/ghz/internal/helloworld" 10 | "github.com/stretchr/testify/assert" 11 | "google.golang.org/grpc" 12 | "google.golang.org/grpc/credentials/insecure" 13 | ) 14 | 15 | func TestStatsHandler(t *testing.T) { 16 | _, s, err := internal.StartServer(false) 17 | 18 | if err != nil { 19 | assert.FailNow(t, err.Error()) 20 | } 21 | 22 | defer s.Stop() 23 | 24 | rChan := make(chan *callResult, 1) 25 | done := make(chan bool, 1) 26 | results := make([]*callResult, 0, 2) 27 | 28 | go func() { 29 | for res := range rChan { 30 | results = append(results, res) 31 | } 32 | done <- true 33 | }() 34 | 35 | conn, err := grpc.Dial( 36 | internal.TestLocalhost, 37 | grpc.WithTransportCredentials(insecure.NewCredentials()), 38 | grpc.WithStatsHandler(&statsHandler{results: rChan})) 39 | 40 | if err != nil { 41 | assert.FailNow(t, err.Error()) 42 | } 43 | 44 | c := helloworld.NewGreeterClient(conn) 45 | 46 | ctx, cancel := context.WithTimeout(context.Background(), time.Second) 47 | defer cancel() 48 | 49 | _, err = c.SayHello(ctx, &helloworld.HelloRequest{Name: "Bob"}) 50 | assert.NoError(t, err) 51 | 52 | _, err = c.SayHello(ctx, &helloworld.HelloRequest{Name: "Kate"}) 53 | assert.NoError(t, err) 54 | 55 | close(rChan) 56 | 57 | <-done 58 | 59 | assert.Equal(t, 2, len(results)) 60 | assert.NotNil(t, results[0]) 61 | assert.NotNil(t, results[1]) 62 | } 63 | -------------------------------------------------------------------------------- /statik.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # build frontend 4 | cd web/ui 5 | npm install && npm run build 6 | 7 | # back up 8 | cd ../../ 9 | 10 | # build statik bundle 11 | statik -src=web/ui/dist -dest=web/router 12 | -------------------------------------------------------------------------------- /testdata/bundle.protoset: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bojand/ghz/c15f0955552eb8cdf3982365bfb72df721b4d4ad/testdata/bundle.protoset -------------------------------------------------------------------------------- /testdata/bundle/cap.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package cap; 4 | 5 | import "common.proto"; 6 | 7 | service Capper { 8 | rpc Cap (common.HelloRequest) returns (common.HelloReply) {} 9 | } -------------------------------------------------------------------------------- /testdata/bundle/common.proto: -------------------------------------------------------------------------------- 1 | // common.proto 2 | syntax = "proto3"; 3 | 4 | package common; 5 | 6 | // The request message containing the user's name. 7 | message HelloRequest { 8 | string name = 1; 9 | } 10 | 11 | // The response message containing the greetings 12 | message HelloReply { 13 | string message = 1; 14 | } -------------------------------------------------------------------------------- /testdata/bundle/greeter.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package helloworld; 4 | 5 | import "common.proto"; 6 | 7 | service Greeter { 8 | rpc SayHello (common.HelloRequest) returns (common.HelloReply) {} 9 | rpc SayHelloCS (stream common.HelloRequest) returns (common.HelloReply) {} 10 | rpc SayHellos (common.HelloRequest) returns (stream common.HelloReply) {} 11 | rpc SayHelloBidi (stream common.HelloRequest) returns (stream common.HelloReply) {} 12 | } -------------------------------------------------------------------------------- /testdata/cfgpath.json: -------------------------------------------------------------------------------- 1 | { 2 | "proto": "my.proto", 3 | "call": "mycall", 4 | "cert": "mycert", 5 | "cName": "localhost", 6 | "D": "./data.json", 7 | "M": "./metadata.json", 8 | "i": [ 9 | "/path/to/protos" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /testdata/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "total": 5000, 3 | "concurrency": 50, 4 | "skipFirst": 5, 5 | "max-duration": "7s", 6 | "duration": "12s", 7 | "stream-interval": "500ms", 8 | "proto": "../../testdata/greeter.proto", 9 | "call": "helloworld.Greeter.SayHello", 10 | "data": { 11 | "name": "Bob {{.TimestampUnix}}" 12 | }, 13 | "metadata": { 14 | "rn": "{{.RequestNumber}}" 15 | }, 16 | "host": "0.0.0.0:50051", 17 | "insecure": true 18 | } 19 | -------------------------------------------------------------------------------- /testdata/config.toml: -------------------------------------------------------------------------------- 1 | "max-duration" = "7s" 2 | total = 5000 3 | concurrency = 50 4 | skipFirst = 5 5 | proto = "../../testdata/greeter.proto" 6 | call = "helloworld.Greeter.SayHello" 7 | host = "0.0.0.0:50051" 8 | insecure = true 9 | 10 | [data] 11 | name = "Bob {{.TimestampUnix}}" 12 | 13 | [metadata] 14 | rn = "{{.RequestNumber}}" 15 | -------------------------------------------------------------------------------- /testdata/config/config0.json: -------------------------------------------------------------------------------- 1 | { 2 | "insecure": true, 3 | "import-paths": [ 4 | "/home/user/pb/grpcbin" 5 | ], 6 | "proto": "grpcbin.proto", 7 | "call": "grpcbin.GRPCBin.DummyUnary", 8 | "connections": 3, 9 | "keepalive": 0, 10 | "total": 1000, 11 | "concurrency": 1, 12 | "rps": 0, 13 | "host": "127.0.0.1:9000", 14 | "data": "foo" 15 | } -------------------------------------------------------------------------------- /testdata/config/config0.toml: -------------------------------------------------------------------------------- 1 | insecure = true 2 | import-paths = ["/home/user/pb/grpcbin"] 3 | proto = "grpcbin.proto" 4 | call = "grpcbin.GRPCBin.DummyUnary" 5 | # Number of connections to use. Concurrency is distributed evenly among all the connections. Default is 1. 6 | connections = 3 7 | # Keepalive time duration. Only used if present and above 0. 8 | keepalive = 0 9 | # Number of requests to run. Default is 200. 10 | total = 1000 11 | # Number of requests to run concurrently. Total number of requests cannot be smaller than the concurrency level. Default is 50. 12 | concurrency = 1 13 | # Rate limit, in requests per second (RPS). Default is no rate limit. 14 | RPS = 0 15 | host = "127.0.0.1:9000" 16 | data=""" 17 | { 18 | "f_strings": [ 19 | "1234567890", 20 | "1234567890", 21 | ] 22 | } 23 | """ -------------------------------------------------------------------------------- /testdata/config/config0.yaml: -------------------------------------------------------------------------------- 1 | insecure: true 2 | import-paths: 3 | - "/home/user/pb/grpcbin" 4 | proto: grpcbin.proto 5 | call: grpcbin.GRPCBin.DummyUnary 6 | connections: 3 7 | keepalive: 0 8 | total: 1000 9 | concurrency: 1 10 | rps: 0 11 | host: 127.0.0.1:9000 12 | data: foo 13 | -------------------------------------------------------------------------------- /testdata/config/config1.json: -------------------------------------------------------------------------------- 1 | { 2 | "insecure": true, 3 | "import-paths": [ 4 | "/home/user/pb/grpcbin" 5 | ], 6 | "proto": "grpcbin.proto", 7 | "call": "grpcbin.GRPCBin.DummyUnary", 8 | "host": "127.0.0.1:9000", 9 | "duration":"asdf", 10 | "data": { 11 | "f_strings": [ 12 | "1234567890", 13 | "1234567890" 14 | ] 15 | } 16 | } -------------------------------------------------------------------------------- /testdata/config/config1.toml: -------------------------------------------------------------------------------- 1 | insecure = true 2 | import-paths = [ 3 | "/home/user/pb/grpcbin" 4 | ] 5 | proto = "grpcbin.proto" 6 | call = "grpcbin.GRPCBin.DummyUnary" 7 | host = "127.0.0.1:9000" 8 | duration = "asdf" 9 | 10 | [data] 11 | f_strings = [ 12 | "1234567890", 13 | "1234567890" 14 | ] -------------------------------------------------------------------------------- /testdata/config/config1.yaml: -------------------------------------------------------------------------------- 1 | insecure: true 2 | "import-paths": 3 | - "/home/user/pb/grpcbin" 4 | proto: grpcbin.proto 5 | call: grpcbin.GRPCBin.DummyUnary 6 | host: 127.0.0.1:9000 7 | duration: asdf 8 | data: 9 | f_strings: 10 | - '1234567890' 11 | - '1234567890' 12 | -------------------------------------------------------------------------------- /testdata/config/config2.json: -------------------------------------------------------------------------------- 1 | { 2 | "insecure": true, 3 | "import-paths": [ 4 | "/home/user/pb/grpcbin" 5 | ], 6 | "proto": "grpcbin.proto", 7 | "call": "grpcbin.GRPCBin.DummyUnary", 8 | "host": "127.0.0.1:9000", 9 | "max-duration":"asdf", 10 | "data": { 11 | "f_strings": [ 12 | "1234567890", 13 | "1234567890" 14 | ] 15 | } 16 | } -------------------------------------------------------------------------------- /testdata/config/config2.toml: -------------------------------------------------------------------------------- 1 | insecure = true 2 | import-paths = [ 3 | "/home/user/pb/grpcbin" 4 | ] 5 | proto = "grpcbin.proto" 6 | call = "grpcbin.GRPCBin.DummyUnary" 7 | host = "127.0.0.1:9000" 8 | max-duration = "asdf" 9 | 10 | [data] 11 | f_strings = [ 12 | "1234567890", 13 | "1234567890" 14 | ] -------------------------------------------------------------------------------- /testdata/config/config2.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | insecure: true 3 | import-paths: 4 | - "/home/user/pb/grpcbin" 5 | proto: grpcbin.proto 6 | call: grpcbin.GRPCBin.DummyUnary 7 | host: 127.0.0.1:9000 8 | max-duration: asdf 9 | data: 10 | f_strings: 11 | - '1234567890' 12 | - '1234567890' 13 | -------------------------------------------------------------------------------- /testdata/config/config3.json: -------------------------------------------------------------------------------- 1 | { 2 | "insecure": true, 3 | "import-paths": [ 4 | "/home/user/pb/grpcbin" 5 | ], 6 | "proto": "grpcbin.proto", 7 | "call": "grpcbin.GRPCBin.DummyUnary", 8 | "host": "127.0.0.1:9000", 9 | "stream-interval":"foo", 10 | "data": { 11 | "f_strings": [ 12 | "1234567890", 13 | "1234567890" 14 | ] 15 | } 16 | } -------------------------------------------------------------------------------- /testdata/config/config3.toml: -------------------------------------------------------------------------------- 1 | insecure = true 2 | import-paths = [ 3 | "/home/user/pb/grpcbin" 4 | ] 5 | proto = "grpcbin.proto" 6 | call = "grpcbin.GRPCBin.DummyUnary" 7 | host = "127.0.0.1:9000" 8 | stream-interval = "asdf" 9 | 10 | [data] 11 | f_strings = [ 12 | "1234567890", 13 | "1234567890" 14 | ] -------------------------------------------------------------------------------- /testdata/config/config3.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | insecure: true 3 | import-paths: 4 | - "/home/user/pb/grpcbin" 5 | proto: grpcbin.proto 6 | call: grpcbin.GRPCBin.DummyUnary 7 | host: 127.0.0.1:9000 8 | stream-interval: foo 9 | data: 10 | f_strings: 11 | - '1234567890' 12 | - '1234567890' 13 | -------------------------------------------------------------------------------- /testdata/config/config4.json: -------------------------------------------------------------------------------- 1 | { 2 | "insecure": true, 3 | "import-paths": [ 4 | "/home/user/pb/grpcbin" 5 | ], 6 | "proto": "grpcbin.proto", 7 | "call": "grpcbin.GRPCBin.DummyUnary", 8 | "host": "127.0.0.1:9000", 9 | "timeout":"foo", 10 | "data": { 11 | "f_strings": [ 12 | "1234567890", 13 | "1234567890" 14 | ] 15 | } 16 | } -------------------------------------------------------------------------------- /testdata/config/config4.toml: -------------------------------------------------------------------------------- 1 | insecure = true 2 | import-paths = [ 3 | "/home/user/pb/grpcbin" 4 | ] 5 | proto = "grpcbin.proto" 6 | call = "grpcbin.GRPCBin.DummyUnary" 7 | host = "127.0.0.1:9000" 8 | timeout = "asdf" 9 | 10 | [data] 11 | f_strings = [ 12 | "1234567890", 13 | "1234567890" 14 | ] -------------------------------------------------------------------------------- /testdata/config/config4.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | insecure: true 3 | import-paths: 4 | - "/home/user/pb/grpcbin" 5 | proto: grpcbin.proto 6 | call: grpcbin.GRPCBin.DummyUnary 7 | host: 127.0.0.1:9000 8 | timeout: foo 9 | data: 10 | f_strings: 11 | - '1234567890' 12 | - '1234567890' 13 | -------------------------------------------------------------------------------- /testdata/config/config5.json: -------------------------------------------------------------------------------- 1 | { 2 | "insecure": true, 3 | "import-paths": [ 4 | "/home/user/pb/grpcbin" 5 | ], 6 | "proto": "grpcbin.proto", 7 | "call": "grpcbin.GRPCBin.DummyUnary", 8 | "host": "127.0.0.1:9000", 9 | "duration": "20s", 10 | "max-duration": "60s", 11 | "stream-interval": "25s", 12 | "timeout": "30s", 13 | "max-recv-message-size": "1024mb", 14 | "max-send-message-size": "2000mib", 15 | "data": { 16 | "f_strings": [ 17 | "123", 18 | "456" 19 | ] 20 | } 21 | } -------------------------------------------------------------------------------- /testdata/config/config5.toml: -------------------------------------------------------------------------------- 1 | insecure = true 2 | import-paths = [ 3 | "/home/user/pb/grpcbin" 4 | ] 5 | proto = "grpcbin.proto" 6 | call = "grpcbin.GRPCBin.DummyUnary" 7 | host = "127.0.0.1:9000" 8 | duration = "20s" 9 | max-duration = "60s" 10 | stream-interval = "25s" 11 | timeout = "30s" 12 | max-recv-message-size = "1024mb" 13 | max-send-message-size = "2000mib" 14 | 15 | [data] 16 | f_strings = [ 17 | "123", 18 | "456" 19 | ] -------------------------------------------------------------------------------- /testdata/config/config5.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | insecure: true 3 | import-paths: 4 | - "/home/user/pb/grpcbin" 5 | proto: grpcbin.proto 6 | call: grpcbin.GRPCBin.DummyUnary 7 | host: 127.0.0.1:9000 8 | duration: 20s 9 | max-duration: 60s 10 | stream-interval: 25s 11 | timeout: 30s 12 | max-recv-message-size: "1024mb" 13 | max-send-message-size: "2000mib" 14 | data: 15 | f_strings: 16 | - '123' 17 | - '456' 18 | -------------------------------------------------------------------------------- /testdata/config/config6.json: -------------------------------------------------------------------------------- 1 | { 2 | "insecure": true, 3 | "import-paths": [ 4 | "/home/user/pb/grpcbin" 5 | ], 6 | "proto": "grpcbin.proto", 7 | "call": "grpcbin.GRPCBin.DummyUnary", 8 | "host": "127.0.0.1:9000", 9 | "duration": "20s", 10 | "max-duration": "60s", 11 | "stream-interval": "25s", 12 | "timeout": "30s", 13 | "max-recv-message-size": "1024nb", 14 | "max-send-message-size": "2000nib", 15 | "data": { 16 | "f_strings": [ 17 | "123", 18 | "456" 19 | ] 20 | } 21 | } -------------------------------------------------------------------------------- /testdata/config/config6.toml: -------------------------------------------------------------------------------- 1 | insecure = true 2 | import-paths = [ 3 | "/home/user/pb/grpcbin" 4 | ] 5 | proto = "grpcbin.proto" 6 | call = "grpcbin.GRPCBin.DummyUnary" 7 | host = "127.0.0.1:9000" 8 | duration = "20s" 9 | max-duration = "60s" 10 | stream-interval = "25s" 11 | timeout = "30s" 12 | max-recv-message-size = "1024nb" 13 | max-send-message-size = "2000nib" 14 | 15 | [data] 16 | f_strings = [ 17 | "123", 18 | "456" 19 | ] -------------------------------------------------------------------------------- /testdata/config/config6.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | insecure: true 3 | import-paths: 4 | - "/home/user/pb/grpcbin" 5 | proto: grpcbin.proto 6 | call: grpcbin.GRPCBin.DummyUnary 7 | host: 127.0.0.1:9000 8 | duration: 20s 9 | max-duration: 60s 10 | stream-interval: 25s 11 | timeout: 30s 12 | max-recv-message-size: "1024nb" 13 | max-send-message-size: "2000nib" 14 | data: 15 | f_strings: 16 | - '123' 17 | - '456' 18 | -------------------------------------------------------------------------------- /testdata/config2.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pretty-json-test", 3 | "max-duration": "3m", 4 | "total": 5000, 5 | "concurrency": 20, 6 | "proto": "../../testdata/greeter.proto", 7 | "call": "helloworld.Greeter.SayHello", 8 | "binary-file": "../../testdata/hello_request_data.bin", 9 | "metadata": { 10 | "rn": "{{.RequestNumber}}" 11 | }, 12 | "host": "0.0.0.0:50051", 13 | "insecure": true, 14 | "output": "pretty.json", 15 | "format": "pretty" 16 | } 17 | -------------------------------------------------------------------------------- /testdata/config2.toml: -------------------------------------------------------------------------------- 1 | name = "pretty-json-test" 2 | "max-duration" = "3m" 3 | total = 5000 4 | concurrency = 20 5 | proto = "../../testdata/greeter.proto" 6 | call = "helloworld.Greeter.SayHello" 7 | "binary-file" = "../../testdata/hello_request_data.bin" 8 | host = "0.0.0.0:50051" 9 | insecure = true 10 | output = "pretty.json" 11 | format = "pretty" 12 | 13 | [metadata] 14 | rn = "{{.RequestNumber}}" 15 | -------------------------------------------------------------------------------- /testdata/config3.json: -------------------------------------------------------------------------------- 1 | { 2 | "max-duration": "1m", 3 | "total": 40, 4 | "concurreny": 5, 5 | "proto": "../../testdata/greeter.proto", 6 | "call": "helloworld.Greeter.SayHello", 7 | "data-file": "../../testdata/data.json", 8 | "host": "0.0.0.0:50051", 9 | "insecure": true, 10 | "format": "pretty", 11 | "metadata": { 12 | "rn": "{{.RequestNumber}}" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /testdata/config3.toml: -------------------------------------------------------------------------------- 1 | "max-duration" = "1m" 2 | total = 40 3 | concurrency = 5 4 | proto = "../../testdata/greeter.proto" 5 | call = "helloworld.Greeter.SayHello" 6 | "data-file" = "../../testdata/data.json" 7 | host = "0.0.0.0:50051" 8 | insecure = true 9 | format = "pretty" 10 | 11 | [metadata] 12 | rn = "{{.RequestNumber}}" 13 | -------------------------------------------------------------------------------- /testdata/config4.toml: -------------------------------------------------------------------------------- 1 | total = 500 2 | concurrency = 1 3 | "stream-interval" = "200ms" 4 | "max-duration" = "7s" 5 | duration = "12s" 6 | proto = "../../testdata/greeter.proto" 7 | call = "helloworld.Greeter.SayHelloBidi" 8 | host = "0.0.0.0:50051" 9 | insecure = true 10 | 11 | [[data]] 12 | name = "Bob 1 {{.TimestampUnix}}" 13 | [[data]] 14 | name = "Bob 2 {{.TimestampUnix}}" 15 | [[data]] 16 | name = "Bob 3 {{.TimestampUnix}}" 17 | [[data]] 18 | name = "Bob 4 {{.TimestampUnix}}" 19 | 20 | [metadata] 21 | rn = "{{.RequestNumber}}" 22 | -------------------------------------------------------------------------------- /testdata/config5.toml: -------------------------------------------------------------------------------- 1 | total = 5000 2 | concurrency = 40 3 | skipFirst = 100 4 | proto = "../../testdata/greeter.proto" 5 | call = "helloworld.Greeter.SayHello" 6 | host = "0.0.0.0:50051" 7 | insecure = true 8 | 9 | [data] 10 | name = "Bob {{.TimestampUnix}}" 11 | 12 | [metadata] 13 | rn = "{{.RequestNumber}}" 14 | -------------------------------------------------------------------------------- /testdata/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Some Name {{.TimestampUnix}}" 3 | } 4 | -------------------------------------------------------------------------------- /testdata/data.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package data; 4 | 5 | service DataTestService { 6 | rpc TestCall (CallRequestOne) returns (CallReplyOne) {} 7 | rpc TestCallTwo (CallRequestTwo) returns (CallReplyOne) {} 8 | } 9 | 10 | message CallRequestOne { 11 | string param_one = 1; 12 | } 13 | 14 | message CallRequestTwo { 15 | CallRequestOne nested_prop = 1; 16 | } 17 | 18 | message CallReplyOne { 19 | string result_message = 1; 20 | } 21 | -------------------------------------------------------------------------------- /testdata/data_empty.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bojand/ghz/c15f0955552eb8cdf3982365bfb72df721b4d4ad/testdata/data_empty.json -------------------------------------------------------------------------------- /testdata/ghz.json: -------------------------------------------------------------------------------- 1 | { 2 | "proto": "my.proto", 3 | "call": "mycall", 4 | "cert": "mycert", 5 | "cName": "localhost", 6 | "d": { 7 | "name": "mydata" 8 | }, 9 | "i": [ 10 | "/path/to/protos" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /testdata/greeter.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package helloworld; 4 | 5 | service Greeter { 6 | rpc SayHello (HelloRequest) returns (HelloReply) {} 7 | rpc SayHelloCS (stream HelloRequest) returns (HelloReply) {} 8 | rpc SayHellos (HelloRequest) returns (stream HelloReply) {} 9 | rpc SayHelloBidi (stream HelloRequest) returns (stream HelloReply) {} 10 | } 11 | 12 | // The request message containing the user's name. 13 | message HelloRequest { 14 | string name = 1; 15 | } 16 | 17 | // The response message containing the greetings 18 | message HelloReply { 19 | string message = 1; 20 | } 21 | -------------------------------------------------------------------------------- /testdata/grpcbin.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package grpcbin; 4 | 5 | service GRPCBin { 6 | // This endpoint 7 | rpc Index(EmptyMessage) returns (IndexReply) {} 8 | // Unary endpoint that takes no argument and replies an empty message. 9 | rpc Empty(EmptyMessage) returns (EmptyMessage) {} 10 | // Unary endpoint that replies a received DummyMessage 11 | rpc DummyUnary(DummyMessage) returns (DummyMessage) {} 12 | // Stream endpoint that sends back 10 times the received DummyMessage 13 | rpc DummyServerStream(DummyMessage) returns (stream DummyMessage) {} 14 | // Stream endpoint that receives 10 DummyMessages and replies with the last received one 15 | rpc DummyClientStream(stream DummyMessage) returns (DummyMessage) {} 16 | // Stream endpoint that sends back a received DummyMessage indefinitely (chat mode) 17 | rpc DummyBidirectionalStreamStream(stream DummyMessage) returns (stream DummyMessage) {} 18 | // Unary endpoint that raises a specified (by code) gRPC error 19 | rpc SpecificError(SpecificErrorRequest) returns (EmptyMessage) {} 20 | // Unary endpoint that raises a random gRPC error 21 | rpc RandomError(EmptyMessage) returns (EmptyMessage) {} 22 | // Unary endpoint that returns headers 23 | rpc HeadersUnary(EmptyMessage) returns (HeadersMessage) {} 24 | // Unary endpoint that returns no respnose 25 | rpc NoResponseUnary(EmptyMessage) returns (EmptyMessage) {} 26 | } 27 | 28 | message HeadersMessage { 29 | message Values { 30 | repeated string values = 1; 31 | } 32 | map Metadata = 1; 33 | } 34 | 35 | message SpecificErrorRequest { 36 | uint32 code = 1; 37 | string reason = 2; 38 | } 39 | 40 | message EmptyMessage {} 41 | 42 | message DummyMessage { 43 | message Sub { 44 | string f_string = 1; 45 | } 46 | enum Enum { 47 | ENUM_0 = 0; 48 | ENUM_1 = 1; 49 | ENUM_2 = 2; 50 | } 51 | string f_string = 1; 52 | repeated string f_strings = 2; 53 | int32 f_int32 = 3; 54 | repeated int32 f_int32s = 4; 55 | Enum f_enum = 5; 56 | repeated Enum f_enums = 6; 57 | Sub f_sub = 7; 58 | repeated Sub f_subs = 8; 59 | bool f_bool = 9; 60 | repeated bool f_bools = 10; 61 | int64 f_int64 = 11; 62 | repeated int64 f_int64s= 12; 63 | bytes f_bytes = 13; 64 | repeated bytes f_bytess = 14; 65 | float f_float = 15; 66 | repeated float f_floats = 16; 67 | // TODO: timestamp, duration, oneof, any, maps, fieldmask, wrapper type, struct, listvalue, value, nullvalue, deprecated 68 | } 69 | 70 | message IndexReply { 71 | message Endpoint { 72 | string path = 1; 73 | string description = 2; 74 | } 75 | string description = 1; 76 | repeated Endpoint endpoints = 2; 77 | } -------------------------------------------------------------------------------- /testdata/gtime.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package gtime; 4 | 5 | option go_package = "internal/gtime"; 6 | 7 | import "google/protobuf/timestamp.proto"; 8 | import "google/protobuf/duration.proto"; 9 | 10 | service TimeService { 11 | rpc TestCall (CallRequest) returns (CallReply) {} 12 | } 13 | 14 | message CallRequest { 15 | google.protobuf.Timestamp ts = 1; 16 | google.protobuf.Duration dur = 2; 17 | uint64 user_id = 3; 18 | } 19 | 20 | message CallReply { 21 | google.protobuf.Timestamp ts = 1; 22 | google.protobuf.Duration dur = 2; 23 | string message = 3; 24 | } 25 | -------------------------------------------------------------------------------- /testdata/hello.proto: -------------------------------------------------------------------------------- 1 | // based on https://grpc.io/docs/guides/concepts.html 2 | 3 | syntax = "proto2"; 4 | 5 | package hello; 6 | 7 | service HelloService { 8 | rpc SayHello(HelloRequest) returns (HelloResponse); 9 | rpc LotsOfReplies(HelloRequest) returns (stream HelloResponse); 10 | rpc LotsOfGreetings(stream HelloRequest) returns (HelloResponse); 11 | rpc BidiHello(stream HelloRequest) returns (stream HelloResponse); 12 | } 13 | 14 | message HelloRequest { 15 | optional string greeting = 1; 16 | } 17 | 18 | message HelloResponse { 19 | required string reply = 1; 20 | } -------------------------------------------------------------------------------- /testdata/hello_request_data.bin: -------------------------------------------------------------------------------- 1 | 2 | bob -------------------------------------------------------------------------------- /testdata/localhost.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDDzCCAfegAwIBAgIUYQqeFxQtTkBgshJjP9/FLOzANZgwDQYJKoZIhvcNAQEL 3 | BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1MDYwMTAyMzMyNloXDTI1MDcw 4 | MTAyMzMyNlowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF 5 | AAOCAQ8AMIIBCgKCAQEAvghJabU8zQiyGPmv3sGz7Ps3A0xSQGN+iRWAsA0ozwor 6 | b13wdUuTNtVl5zfCPjLiUbSg+fR7aOPsbOhFosVUTnjjZG1p9GUBz3KI96sB0tr5 7 | k3cX3cclUOC2twiyuDj9b5HBmv4SdG/wu6m+oHYOKPlca74d12Ys1mIsQT2VkLpJ 8 | 9IofYzRSVtGqfSw737Q5V5RSGacMp9c6zB/QR+FpVp1x3t4/gs+NntEIbIUPRVBb 9 | NjRrxavS8p+1y89LDAtQgFVqwJ23whdqBmK46m7JWAycnm8ycLXxtX/WHe5tuZBZ 10 | TfhwM5qC2hs8ejWCnGU3rtf5rsoVwl6boVlQVmW5nQIDAQABo1kwVzAUBgNVHREE 11 | DTALgglsb2NhbGhvc3QwCwYDVR0PBAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMB 12 | MB0GA1UdDgQWBBTWbullWSSdSHwDVzQLLikvKgmHojANBgkqhkiG9w0BAQsFAAOC 13 | AQEAVGlCouA1cEPGNRTPcldV4qsBKZc7Wx90Gr1Tugz2ObQArmbK5d2AHPI9Dm5R 14 | MH+vof4GIzf62fJBSHVAzSyXuddVA0mweTmqLKVfv9I2L6hB5DA6IUhSzAvo1ZBw 15 | I9C9cA9lLuAGiIbqybG0WsMeaVSnMTHeeZhOl/CAPSrLgzE0n2yIqeMI3bI6z8JN 16 | b5NHe03aHRTUon2Qz08zuQ/uhMk9JrqD2UHRSSW6FLSWVi4p2w6MJ34Y0P4+Y3Gk 17 | L9gi/ry3Y423iwxzjcaRBDx/nGJ1lI004lU8Y/QwuUDT8Yl2PAzXcSII2993DkDh 18 | YF70jKw/v3QlRINTdKjMrUot8g== 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /testdata/localhost.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC+CElptTzNCLIY 3 | +a/ewbPs+zcDTFJAY36JFYCwDSjPCitvXfB1S5M21WXnN8I+MuJRtKD59Hto4+xs 4 | 6EWixVROeONkbWn0ZQHPcoj3qwHS2vmTdxfdxyVQ4La3CLK4OP1vkcGa/hJ0b/C7 5 | qb6gdg4o+Vxrvh3XZizWYixBPZWQukn0ih9jNFJW0ap9LDvftDlXlFIZpwyn1zrM 6 | H9BH4WlWnXHe3j+Cz42e0QhshQ9FUFs2NGvFq9Lyn7XLz0sMC1CAVWrAnbfCF2oG 7 | YrjqbslYDJyebzJwtfG1f9Yd7m25kFlN+HAzmoLaGzx6NYKcZTeu1/muyhXCXpuh 8 | WVBWZbmdAgMBAAECggEAIpyuSGlpEVw92hQyTwOfcrC/8KMYUR9+Fthah9ZhwjIT 9 | PrXQvAB/qAtew+oxQDRy6dhZQKWhy7VF5QE6W78Oz7sviaVvGMND/OWa4mdcjevx 10 | 7MTSjUO+PXisdvKH4MuKh6V36rPPpzMTWQ7+CEpwYlCm333xf//dd0/KyTg/E2y2 11 | fcIMrADVUKIDaaAhy3+8eCx1cuHilvvTLXG9h2vsJzc3iO5mmF0EmdeR4VtpulHO 12 | QoXSkWDnN7YyUxR5WGMw9hgIXfzLo2sE2Jgs24ZN4I5ruaDCDpkjnagzpduEtoU1 13 | pXA6yvaLPJ1yu/2pz0dbkAtXBzibRK+QrWMlEnuLsQKBgQDeM9E1btk1G/ehsOPb 14 | YtPyLtmQFKqNwQmbgG4guf97BXPbiGGbQpkaXV/uB2zxqbvdsa2U6KgsmhkWhwMY 15 | 92mY/JpTo5Bft5BN108FvWSxKx79BFabeO859Q4yXpMowdSzDMP0VvW0gwQnZlFs 16 | DWbfIbFludbPHJoHRnhYsnpPsQKBgQDa79KzBLxH505AVJQuSi0a27IYpnAsNQk6 17 | nuGHH9TeVMdpPmCOmFVa7t6enmSC3J7teK/BzdYRnjvZPNsn9JyKcbtprny6ctbt 18 | hl5ccqgANlC5+X1pF4B/bV1UKKIZk1P+23My5E8aHherg7xqpqqv31R1/rScIcIg 19 | eHUHU1qPrQKBgQCV+LFGeCeAEf1EE4jmxMA6YGaVOW2XuWdLnhY2XnNRy+9Th6wh 20 | R4TgZ49cr4RXY3EaA1cd/x2q5OLz8nIjwrFyAWQD+YxzHgj2kNCUFi1E6s7ChNAT 21 | pT8Jhh1r36tBQfnWU1JasuqpSBhgo01nOXBqP2plN1YFec94A5csfmHRMQKBgCFf 22 | iViskh7LzYvU2LmtqO59KsrDJDo4421CJtK4MXSqq7MJRSK3adtwqhK3xk6EXt2I 23 | FhKO0+Dfo/PbaPTQPsSDzbOwW2b4dnbCksO43o8ZuHiA5XMNmBLUkvNvNjZ71MP2 24 | o7rQPpaWm7kTXbdMLJyeiHtsFg/uvW7BreUt+ZIJAoGBANulBUd13dVYd3I1w+yu 25 | aglmYZi3VV+HwMAA/PRPQCWMzCn5W4OmfaLorAwejAP8sYzCYBmwPWZweh1VKqpY 26 | H7HRnLznkLYobViatvi6BeE/TY+4TRvR+taoxWNMybEarMPKt9RbiIuLsW9H25iK 27 | KNUIRtzbanjtCrZMR3J6bkhU 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /testdata/metadata.json: -------------------------------------------------------------------------------- 1 | {"request-id": "{{.RequestNumber}}"} -------------------------------------------------------------------------------- /testdata/optional.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package helloworld; 4 | 5 | service OptionalGreeter { 6 | rpc SayHello (HelloRequest) returns (HelloReply) {} 7 | } 8 | 9 | // The request message containing the user's name. 10 | message HelloRequest { 11 | optional string name = 1; 12 | } 13 | 14 | // The response message containing the greetings 15 | message HelloReply { 16 | string message = 1; 17 | } 18 | -------------------------------------------------------------------------------- /testdata/sleep.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package main; 4 | 5 | service SleepService { 6 | rpc SleepFor(SleepRequest) returns (SleepResponse); 7 | } 8 | 9 | message SleepRequest { 10 | int64 Milliseconds = 1; 11 | } 12 | 13 | message SleepResponse {} 14 | 15 | -------------------------------------------------------------------------------- /testdata/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "proto": "../testdata/gtime.proto", 3 | "call": "gtime.TimeService.TestCall", 4 | "name": "TimeService - TestCall", 5 | "total": 1, 6 | "concurrency": 1, 7 | "connections": 1, 8 | "insecure": true, 9 | "data": { 10 | "ts":"2020-01-22T02:30:30.01Z", 11 | "dur": "40s", 12 | "user_id": 100 13 | } 14 | } -------------------------------------------------------------------------------- /testdata/wrapped.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package wrapped; 4 | 5 | option go_package = "internal/wrapped"; 6 | 7 | import "google/protobuf/wrappers.proto"; 8 | 9 | service WrappedService { 10 | rpc GetMessage (google.protobuf.StringValue) returns (google.protobuf.StringValue); 11 | rpc GetBytesMessage (google.protobuf.BytesValue) returns (google.protobuf.BytesValue); 12 | } -------------------------------------------------------------------------------- /testdata/wrapped_data.json: -------------------------------------------------------------------------------- 1 | "foo" -------------------------------------------------------------------------------- /tools/tools.go: -------------------------------------------------------------------------------- 1 | // +build tools 2 | 3 | // This file ensures tool dependencies are kept in sync. This is the 4 | // recommended way of doing this according to 5 | // https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module 6 | // To install the following tools at the version used by this repo run: 7 | // $ make tools 8 | // or 9 | // $ go generate -tags tools tools/tools.go 10 | 11 | package tools 12 | 13 | // NOTE: This must not be indented, so to stop goimports from trying to be 14 | // helpful, it's separated out from the import block below. Please try to keep 15 | // them in the same order. 16 | //go:generate go install github.com/mfridman/tparse 17 | //go:generate go install golang.org/x/tools/cmd/goimports 18 | //go:generate go install github.com/golangci/golangci-lint/cmd/golangci-lint 19 | 20 | import ( 21 | _ "github.com/mfridman/tparse" 22 | 23 | _ "github.com/golangci/golangci-lint/cmd/golangci-lint" 24 | 25 | _ "golang.org/x/tools/cmd/goimports" 26 | ) 27 | -------------------------------------------------------------------------------- /web/api/export.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | "time" 9 | 10 | "github.com/alecthomas/template" 11 | "github.com/bojand/ghz/runner" 12 | "github.com/bojand/ghz/web/model" 13 | "github.com/labstack/echo" 14 | ) 15 | 16 | // ExportDatabase interface for encapsulating database access. 17 | type ExportDatabase interface { 18 | FindReportByID(uint) (*model.Report, error) 19 | GetHistogramForReport(uint) (*model.Histogram, error) 20 | GetOptionsForReport(uint) (*model.Options, error) 21 | ListAllDetailsForReport(uint) ([]*model.Detail, error) 22 | } 23 | 24 | // The ExportAPI provides handlers. 25 | type ExportAPI struct { 26 | DB ExportDatabase 27 | } 28 | 29 | // JSONExportRespose is the response to JSON export 30 | type JSONExportRespose struct { 31 | model.Report 32 | 33 | Options *model.OptionsInfo `json:"options,omitempty"` 34 | 35 | Histogram model.BucketList `json:"histogram"` 36 | 37 | Details []*runner.ResultDetail `json:"details"` 38 | } 39 | 40 | const ( 41 | csvTmpl = ` 42 | duration (ms),status,error{{ range $i, $v := . }} 43 | {{ formatDuration .Latency 1000000 }},{{ .Status }},{{ .Error }}{{ end }} 44 | ` 45 | ) 46 | 47 | var tmplFuncMap = template.FuncMap{ 48 | "formatDuration": formatDuration, 49 | } 50 | 51 | func formatDuration(duration time.Duration, div int64) string { 52 | durationNano := duration.Nanoseconds() 53 | return fmt.Sprintf("%4.2f", float64(durationNano/div)) 54 | } 55 | 56 | // GetExport does export for the report 57 | func (api *ExportAPI) GetExport(ctx echo.Context) error { 58 | var id uint64 59 | var report *model.Report 60 | var err error 61 | 62 | format := strings.ToLower(ctx.QueryParam("format")) 63 | if format != "csv" && format != "json" { 64 | return echo.NewHTTPError(http.StatusBadRequest, "Unsupported format: "+format) 65 | } 66 | 67 | if id, err = getReportID(ctx); err != nil { 68 | return err 69 | } 70 | 71 | var details []*model.Detail 72 | if details, err = api.DB.ListAllDetailsForReport(uint(id)); err != nil { 73 | return echo.NewHTTPError(http.StatusNotFound, err.Error()) 74 | } 75 | 76 | if format == "csv" { 77 | outputTmpl := csvTmpl 78 | 79 | buf := &bytes.Buffer{} 80 | templ := template.Must(template.New("tmpl").Funcs(tmplFuncMap).Parse(outputTmpl)) 81 | if err := templ.Execute(buf, &details); err != nil { 82 | return echo.NewHTTPError(http.StatusInternalServerError, "Bad Request: "+err.Error()) 83 | } 84 | 85 | return ctx.Blob(http.StatusOK, "text/csv", buf.Bytes()) 86 | } 87 | 88 | if report, err = api.DB.FindReportByID(uint(id)); err != nil { 89 | return echo.NewHTTPError(http.StatusNotFound, err.Error()) 90 | } 91 | 92 | var options *model.Options 93 | if options, err = api.DB.GetOptionsForReport(uint(id)); err != nil { 94 | return echo.NewHTTPError(http.StatusNotFound, err.Error()) 95 | } 96 | 97 | var histogram *model.Histogram 98 | if histogram, err = api.DB.GetHistogramForReport(uint(id)); err != nil { 99 | return echo.NewHTTPError(http.StatusNotFound, err.Error()) 100 | } 101 | 102 | jsonRes := JSONExportRespose{ 103 | Report: *report, 104 | } 105 | 106 | jsonRes.Options = options.Info 107 | 108 | jsonRes.Histogram = histogram.Buckets 109 | 110 | jsonRes.Details = make([]*runner.ResultDetail, len(details)) 111 | for i := range details { 112 | jsonRes.Details[i] = &details[i].ResultDetail 113 | } 114 | 115 | return ctx.JSONPretty(http.StatusOK, jsonRes, " ") 116 | } 117 | -------------------------------------------------------------------------------- /web/api/histogram.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/bojand/ghz/web/model" 7 | "github.com/labstack/echo" 8 | ) 9 | 10 | // HistogramDatabase interface for encapsulating database access. 11 | type HistogramDatabase interface { 12 | GetHistogramForReport(uint) (*model.Histogram, error) 13 | } 14 | 15 | // The HistogramAPI provides handlers. 16 | type HistogramAPI struct { 17 | DB HistogramDatabase 18 | } 19 | 20 | // GetHistogram gets a histogram for the report 21 | func (api *HistogramAPI) GetHistogram(ctx echo.Context) error { 22 | var id uint64 23 | var h *model.Histogram 24 | var err error 25 | 26 | if id, err = getReportID(ctx); err != nil { 27 | return err 28 | } 29 | 30 | if h, err = api.DB.GetHistogramForReport(uint(id)); err != nil { 31 | return echo.NewHTTPError(http.StatusNotFound, err.Error()) 32 | } 33 | 34 | return ctx.JSON(http.StatusOK, h) 35 | } 36 | -------------------------------------------------------------------------------- /web/api/info.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | "runtime" 6 | "time" 7 | 8 | "github.com/labstack/echo" 9 | ) 10 | 11 | // ApplicationInfo contains info about the app 12 | type ApplicationInfo struct { 13 | Version string 14 | GOVersion string 15 | BuildDate string 16 | StartTime time.Time 17 | } 18 | 19 | // MemoryInfo some memory stats 20 | type MemoryInfo struct { 21 | // Bytes of allocated heap objects. 22 | Alloc uint64 `json:"allocated"` 23 | 24 | // Cumulative bytes allocated for heap objects. 25 | TotalAlloc uint64 `json:"totalAllocated"` 26 | 27 | // The total bytes of memory obtained from the OS. 28 | System uint64 `json:"system"` 29 | 30 | // The number of pointer lookups performed by the runtime. 31 | Lookups uint64 `json:"lookups"` 32 | 33 | // The cumulative count of heap objects allocated. 34 | // The number of live objects is Mallocs - Frees. 35 | Mallocs uint64 `json:"mallocs"` 36 | 37 | // The cumulative count of heap objects freed. 38 | Frees uint64 `json:"frees"` 39 | 40 | // The number of completed GC cycles. 41 | NumGC uint32 `json:"numGC"` 42 | } 43 | 44 | // InfoResponse is the info response 45 | type InfoResponse struct { 46 | // Version of the application 47 | Version string `json:"version"` 48 | 49 | // Go runtime version 50 | RuntimeVersion string `json:"runtimeVersion"` 51 | 52 | // The build date of the server application 53 | BuildDate string `json:"buildDate"` 54 | 55 | // Uptime of the server 56 | Uptime string `json:"uptime"` 57 | 58 | // Memory info 59 | MemoryInfo *MemoryInfo `json:"memoryInfo,omitempty"` 60 | } 61 | 62 | // The InfoAPI provides handlers for managing projects. 63 | type InfoAPI struct { 64 | Info ApplicationInfo 65 | } 66 | 67 | // GetApplicationInfo gets application info 68 | func (api *InfoAPI) GetApplicationInfo(ctx echo.Context) error { 69 | memStats := &runtime.MemStats{} 70 | runtime.ReadMemStats(memStats) 71 | 72 | ir := InfoResponse{ 73 | Version: api.Info.Version, 74 | RuntimeVersion: api.Info.GOVersion, 75 | BuildDate: api.Info.BuildDate, 76 | Uptime: time.Since(api.Info.StartTime).String(), 77 | MemoryInfo: &MemoryInfo{ 78 | Alloc: memStats.Alloc, 79 | TotalAlloc: memStats.TotalAlloc, 80 | System: memStats.Sys, 81 | Lookups: memStats.Lookups, 82 | Mallocs: memStats.Mallocs, 83 | Frees: memStats.Frees, 84 | NumGC: memStats.NumGC, 85 | }, 86 | } 87 | 88 | return ctx.JSON(http.StatusOK, ir) 89 | } 90 | -------------------------------------------------------------------------------- /web/api/info_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "net/http/httptest" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/labstack/echo" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func TestInfoAPI(t *testing.T) { 16 | 17 | api := InfoAPI{ 18 | Info: ApplicationInfo{ 19 | Version: "1.2.3", 20 | GOVersion: "1.11", 21 | BuildDate: "January 1, 2019", 22 | StartTime: time.Now(), 23 | }} 24 | 25 | t.Run("GetInfo", func(t *testing.T) { 26 | e := echo.New() 27 | req := httptest.NewRequest(http.MethodGet, "/info", strings.NewReader("")) 28 | req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) 29 | 30 | rec := httptest.NewRecorder() 31 | 32 | c := e.NewContext(req, rec) 33 | 34 | if assert.NoError(t, api.GetApplicationInfo(c)) { 35 | assert.Equal(t, http.StatusOK, rec.Code) 36 | 37 | info := new(InfoResponse) 38 | err := json.NewDecoder(rec.Body).Decode(info) 39 | 40 | assert.NoError(t, err) 41 | 42 | assert.Equal(t, "1.2.3", info.Version) 43 | assert.Equal(t, "1.11", info.RuntimeVersion) 44 | assert.NotEmpty(t, "1.11", info.BuildDate) 45 | } 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /web/api/options.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/bojand/ghz/web/model" 7 | "github.com/labstack/echo" 8 | ) 9 | 10 | // OptionsDatabase interface for encapsulating database access. 11 | type OptionsDatabase interface { 12 | GetOptionsForReport(uint) (*model.Options, error) 13 | } 14 | 15 | // The OptionsAPI provides handlers 16 | type OptionsAPI struct { 17 | DB OptionsDatabase 18 | } 19 | 20 | // GetOptions gets options for a report 21 | func (api *OptionsAPI) GetOptions(ctx echo.Context) error { 22 | var id uint64 23 | var o *model.Options 24 | var err error 25 | 26 | if id, err = getReportID(ctx); err != nil { 27 | return err 28 | } 29 | 30 | if o, err = api.DB.GetOptionsForReport(uint(id)); err != nil { 31 | return echo.NewHTTPError(http.StatusNotFound, err.Error()) 32 | } 33 | 34 | return ctx.JSON(http.StatusOK, o) 35 | } 36 | -------------------------------------------------------------------------------- /web/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/jinzhu/configor" 7 | ) 8 | 9 | // Config is the application config 10 | type Config struct { 11 | Server Server 12 | Database Database 13 | Log Log 14 | } 15 | 16 | // Log settings 17 | type Log struct { 18 | Level string `default:"info"` 19 | Path string 20 | } 21 | 22 | // Database settings 23 | type Database struct { 24 | Type string `default:"sqlite3"` 25 | Connection string `default:"data/ghz.db"` 26 | } 27 | 28 | // Server settings 29 | type Server struct { 30 | Port uint `default:"80"` 31 | } 32 | 33 | // Read the config file 34 | func Read(path string) (*Config, error) { 35 | if strings.TrimSpace(path) == "" { 36 | path = "config.toml" 37 | } 38 | 39 | config := Config{} 40 | 41 | err := configor.New(&configor.Config{ENVPrefix: "GHZ"}).Load(&config, path) 42 | 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | return &config, nil 48 | } 49 | -------------------------------------------------------------------------------- /web/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestConfig_Read(t *testing.T) { 10 | var tests = []struct { 11 | name string 12 | in string 13 | expected *Config 14 | }{ 15 | {"config1.toml", 16 | "../test/config1.toml", 17 | &Config{ 18 | Server: Server{Port: 80}, 19 | Database: Database{Type: "sqlite3", Connection: "data/ghz.db"}, 20 | Log: Log{Level: "info"}}}, 21 | {"config2.toml", 22 | "../test/config2.toml", 23 | &Config{ 24 | Server: Server{Port: 4321}, 25 | Database: Database{Type: "postgres", Connection: "host=dbhost user=dbuser dbname=ghz sslmode=disable password=dbpwd"}, 26 | Log: Log{Level: "warn", Path: "/tmp/ghz.log"}}}, 27 | {"config3.toml", 28 | "../test/config3.toml", 29 | &Config{ 30 | Server: Server{Port: 3000}, 31 | Database: Database{Type: "postgres", Connection: "host=localhost port=5432 dbname=ghz sslmode=disable"}, 32 | Log: Log{Level: "debug", Path: ""}}}, 33 | {"config2.json", 34 | "../test/config2.json", 35 | &Config{ 36 | Server: Server{Port: 4321}, 37 | Database: Database{Type: "postgres", Connection: "host=dbhost user=dbuser dbname=ghz sslmode=disable password=dbpwd"}, 38 | Log: Log{Level: "warn", Path: "/tmp/ghz.log"}}}, 39 | {"config2.yml", 40 | "../test/config2.yml", 41 | &Config{ 42 | Server: Server{Port: 4321}, 43 | Database: Database{Type: "postgres", Connection: "host=dbhost user=dbuser dbname=ghz sslmode=disable password=dbpwd"}, 44 | Log: Log{Level: "warn", Path: "/tmp/ghz.log"}}}, 45 | } 46 | 47 | for _, tt := range tests { 48 | t.Run(tt.name, func(t *testing.T) { 49 | actual, err := Read(tt.in) 50 | assert.NoError(t, err) 51 | assert.Equal(t, tt.expected, actual) 52 | }) 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /web/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/bojand/ghz/web/model" 8 | "github.com/jinzhu/gorm" 9 | 10 | _ "github.com/jinzhu/gorm/dialects/mysql" // enable the mysql dialect 11 | _ "github.com/jinzhu/gorm/dialects/postgres" // enable the postgres dialect 12 | _ "github.com/jinzhu/gorm/dialects/sqlite" // enable the sqlite3 dialect 13 | ) 14 | 15 | const dbName = "../test/test.db" 16 | 17 | // New creates a new wrapper for the gorm database framework. 18 | func New(dialect, connection string, log bool) (*Database, error) { 19 | if err := createDirectoryIfSqlite(dialect, connection); err != nil { 20 | return nil, err 21 | } 22 | 23 | db, err := gorm.Open(dialect, connection) 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | db.LogMode(log) 29 | 30 | // We normally don't need that much connections, so we limit them. 31 | db.DB().SetMaxOpenConns(10) 32 | 33 | if dialect == "sqlite3" { 34 | // Sqlite cannot handle concurrent operations well so limit to one connection. 35 | db.DB().SetMaxOpenConns(1) 36 | 37 | // Turn on foreign keys. 38 | db.Exec("PRAGMA foreign_keys = ON;") 39 | } 40 | 41 | db.AutoMigrate( 42 | new(model.Project), 43 | new(model.Report), 44 | new(model.Options), 45 | new(model.Detail), 46 | new(model.Histogram), 47 | ) 48 | 49 | return &Database{DB: db}, nil 50 | } 51 | 52 | func createDirectoryIfSqlite(dialect string, connection string) error { 53 | if dialect == "sqlite3" { 54 | if _, err := os.Stat(filepath.Dir(connection)); os.IsNotExist(err) { 55 | if err := os.MkdirAll(filepath.Dir(connection), 0777); err != nil { 56 | return err 57 | } 58 | } 59 | } 60 | return nil 61 | } 62 | 63 | // Database is a wrapper for the gorm framework. 64 | type Database struct { 65 | DB *gorm.DB 66 | } 67 | 68 | // Close closes the gorm database connection. 69 | func (d *Database) Close() error { 70 | return d.DB.Close() 71 | } 72 | -------------------------------------------------------------------------------- /web/database/database_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestNew(t *testing.T) { 11 | db, err := New("sqlite3", "test/testdb.db", false) 12 | 13 | assert.NotNil(t, db) 14 | assert.Nil(t, err) 15 | 16 | db.Close() 17 | 18 | os.Remove("test/testdb.db") 19 | } 20 | func TestInvalidDialect(t *testing.T) { 21 | _, err := New("asdf", "invalidtestdb.db", false) 22 | assert.NotNil(t, err) 23 | } 24 | 25 | func TestCreateSqliteFolder(t *testing.T) { 26 | // ensure path not exists 27 | os.RemoveAll("test/somepath") 28 | 29 | db, err := New("sqlite3", "test/somepath/testdb.db", false) 30 | assert.Nil(t, err) 31 | assert.DirExists(t, "test/somepath") 32 | db.Close() 33 | 34 | assert.Nil(t, os.RemoveAll("test")) 35 | } 36 | 37 | func TestWithAlreadyExistingSqliteFolder(t *testing.T) { 38 | // ensure path not exists 39 | err := os.RemoveAll("test/somepath") 40 | assert.NoError(t, err) 41 | 42 | err = os.MkdirAll("test/somepath", 0777) 43 | assert.NoError(t, err) 44 | 45 | db, err := New("sqlite3", "test/somepath/testdb.db", false) 46 | assert.Nil(t, err) 47 | assert.NoError(t, err) 48 | assert.DirExists(t, "test/somepath") 49 | db.Close() 50 | 51 | assert.Nil(t, os.RemoveAll("test")) 52 | } 53 | -------------------------------------------------------------------------------- /web/database/detail.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "sync/atomic" 5 | 6 | "github.com/bojand/ghz/web/model" 7 | ) 8 | 9 | // ListAllDetailsForReport lists all details for report 10 | func (d *Database) ListAllDetailsForReport(rid uint) ([]*model.Detail, error) { 11 | r := &model.Report{} 12 | r.ID = rid 13 | 14 | s := make([]*model.Detail, 0) 15 | 16 | err := d.DB.Model(r).Related(&s).Error 17 | 18 | return s, err 19 | } 20 | 21 | // CreateDetailsBatch creates a batch of details 22 | // Returns the number successfully created, and the number failed 23 | func (d *Database) CreateDetailsBatch(rid uint, s []*model.Detail) (uint, uint) { 24 | nReq := len(s) 25 | 26 | NC := 10 27 | 28 | var nErr uint32 29 | 30 | sem := make(chan bool, NC) 31 | 32 | var nCreated, errCount uint 33 | 34 | for _, item := range s { 35 | sem <- true 36 | 37 | go func(detail *model.Detail) { 38 | defer func() { <-sem }() 39 | 40 | detail.ReportID = rid 41 | err := d.createDetail(detail) 42 | 43 | if err != nil { 44 | atomic.AddUint32(&nErr, 1) 45 | } 46 | }(item) 47 | } 48 | 49 | for i := 0; i < cap(sem); i++ { 50 | sem <- true 51 | } 52 | 53 | errCount = uint(atomic.LoadUint32(&nErr)) 54 | nCreated = uint(nReq) - errCount 55 | 56 | return nCreated, errCount 57 | } 58 | 59 | func (d *Database) createDetail(detail *model.Detail) error { 60 | return d.DB.Create(detail).Error 61 | } 62 | -------------------------------------------------------------------------------- /web/database/detail_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | 8 | "github.com/bojand/ghz/runner" 9 | "github.com/bojand/ghz/web/model" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestDatabase_Detail(t *testing.T) { 14 | os.Remove(dbName) 15 | 16 | defer os.Remove(dbName) 17 | 18 | db, err := New("sqlite3", dbName, false) 19 | if err != nil { 20 | assert.FailNow(t, err.Error()) 21 | } 22 | defer db.Close() 23 | 24 | var rid, rid2 uint 25 | 26 | t.Run("new report", func(t *testing.T) { 27 | p := model.Project{ 28 | Name: "Test Proj 111 ", 29 | Description: "Test Description Asdf ", 30 | } 31 | 32 | r := model.Report{ 33 | Project: &p, 34 | Name: "Test report", 35 | EndReason: "normal", 36 | Date: time.Date(2018, 12, 1, 8, 0, 0, 0, time.UTC), 37 | Count: 200, 38 | Total: time.Duration(2 * time.Second), 39 | Average: time.Duration(10 * time.Millisecond), 40 | Fastest: time.Duration(1 * time.Millisecond), 41 | Slowest: time.Duration(100 * time.Millisecond), 42 | Rps: 2000, 43 | } 44 | 45 | err := db.CreateReport(&r) 46 | 47 | assert.NoError(t, err) 48 | assert.NotZero(t, p.ID) 49 | assert.NotZero(t, r.ID) 50 | 51 | rid = r.ID 52 | }) 53 | 54 | t.Run("new report 2", func(t *testing.T) { 55 | p := model.Project{ 56 | Name: "Test Proj 222 ", 57 | Description: "Test Description 222 ", 58 | } 59 | 60 | r := model.Report{ 61 | Project: &p, 62 | Name: "Test report 2", 63 | EndReason: "normal", 64 | Date: time.Date(2018, 12, 1, 8, 0, 0, 0, time.UTC), 65 | Count: 300, 66 | Total: time.Duration(2 * time.Second), 67 | Average: time.Duration(10 * time.Millisecond), 68 | Fastest: time.Duration(1 * time.Millisecond), 69 | Slowest: time.Duration(100 * time.Millisecond), 70 | Rps: 3000, 71 | } 72 | 73 | err := db.CreateReport(&r) 74 | 75 | assert.NoError(t, err) 76 | assert.NotZero(t, p.ID) 77 | assert.NotZero(t, r.ID) 78 | 79 | rid2 = r.ID 80 | }) 81 | 82 | t.Run("CreateDetailsBatch()", func(t *testing.T) { 83 | M := 200 84 | s := make([]*model.Detail, M) 85 | 86 | for n := 0; n < M; n++ { 87 | nd := &model.Detail{ 88 | ReportID: rid, 89 | ResultDetail: runner.ResultDetail{ 90 | Timestamp: time.Now(), 91 | Latency: time.Duration(1 * time.Millisecond), 92 | Status: "OK", 93 | }, 94 | } 95 | 96 | s[n] = nd 97 | } 98 | 99 | created, errored := db.CreateDetailsBatch(rid, s) 100 | 101 | assert.Equal(t, M, int(created)) 102 | assert.Equal(t, 0, int(errored)) 103 | }) 104 | 105 | t.Run("CreateDetailsBatch() 2", func(t *testing.T) { 106 | M := 300 107 | s := make([]*model.Detail, M) 108 | 109 | for n := 0; n < M; n++ { 110 | nd := &model.Detail{ 111 | ReportID: rid2, 112 | ResultDetail: runner.ResultDetail{ 113 | Timestamp: time.Now(), 114 | Latency: time.Duration(1 * time.Millisecond), 115 | Status: "OK", 116 | }, 117 | } 118 | 119 | s[n] = nd 120 | } 121 | 122 | created, errored := db.CreateDetailsBatch(rid2, s) 123 | 124 | assert.Equal(t, M, int(created)) 125 | assert.Equal(t, 0, int(errored)) 126 | }) 127 | 128 | t.Run("CreateDetailsBatch() for unknown", func(t *testing.T) { 129 | M := 100 130 | s := make([]*model.Detail, M) 131 | 132 | for n := 0; n < M; n++ { 133 | nd := &model.Detail{ 134 | ReportID: 43232, 135 | ResultDetail: runner.ResultDetail{ 136 | Timestamp: time.Now(), 137 | Latency: time.Duration(1 * time.Millisecond), 138 | Status: "OK", 139 | }, 140 | } 141 | 142 | s[n] = nd 143 | } 144 | 145 | created, errored := db.CreateDetailsBatch(43232, s) 146 | 147 | assert.Equal(t, 0, int(created)) 148 | assert.Equal(t, M, int(errored)) 149 | }) 150 | 151 | t.Run("ListAllDetailsForReport", func(t *testing.T) { 152 | details, err := db.ListAllDetailsForReport(rid) 153 | 154 | assert.NoError(t, err) 155 | assert.Len(t, details, 200) 156 | }) 157 | 158 | t.Run("ListAllDetailsForReport 2", func(t *testing.T) { 159 | details, err := db.ListAllDetailsForReport(rid2) 160 | 161 | assert.NoError(t, err) 162 | assert.Len(t, details, 300) 163 | }) 164 | 165 | t.Run("ListAllDetailsForReport unknown", func(t *testing.T) { 166 | details, err := db.ListAllDetailsForReport(43332) 167 | 168 | assert.NoError(t, err) 169 | assert.Len(t, details, 0) 170 | }) 171 | } 172 | -------------------------------------------------------------------------------- /web/database/histogram.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "github.com/bojand/ghz/web/model" 5 | ) 6 | 7 | // CreateHistogram creates a new report 8 | func (d *Database) CreateHistogram(h *model.Histogram) error { 9 | return d.DB.Create(h).Error 10 | } 11 | 12 | // GetHistogramForReport creates a new report 13 | func (d *Database) GetHistogramForReport(rid uint) (*model.Histogram, error) { 14 | r := &model.Report{} 15 | r.ID = rid 16 | h := new(model.Histogram) 17 | err := d.DB.Model(r).Related(&h).Error 18 | if err != nil { 19 | return nil, err 20 | } 21 | return h, err 22 | } 23 | -------------------------------------------------------------------------------- /web/database/histogram_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | 8 | "github.com/bojand/ghz/runner" 9 | "github.com/bojand/ghz/web/model" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestDatabase_Histogram(t *testing.T) { 14 | os.Remove(dbName) 15 | 16 | defer os.Remove(dbName) 17 | 18 | db, err := New("sqlite3", dbName, false) 19 | if err != nil { 20 | assert.FailNow(t, err.Error()) 21 | } 22 | defer db.Close() 23 | 24 | var rid, hid uint 25 | 26 | t.Run("new", func(t *testing.T) { 27 | t.Run("test new", func(t *testing.T) { 28 | p := model.Project{ 29 | Name: "Test Proj 111 ", 30 | Description: "Test Description Asdf ", 31 | } 32 | 33 | r := model.Report{ 34 | Project: &p, 35 | Name: "Test report", 36 | EndReason: "normal", 37 | Date: time.Date(2018, 12, 1, 8, 0, 0, 0, time.UTC), 38 | Count: 200, 39 | Total: time.Duration(2 * time.Second), 40 | Average: time.Duration(10 * time.Millisecond), 41 | Fastest: time.Duration(1 * time.Millisecond), 42 | Slowest: time.Duration(100 * time.Millisecond), 43 | Rps: 2000, 44 | } 45 | 46 | r.ErrorDist = map[string]int{ 47 | "rpc error: code = Internal desc = Internal error.": 3, 48 | "rpc error: code = DeadlineExceeded desc = Deadline exceeded.": 2} 49 | 50 | r.StatusCodeDist = map[string]int{ 51 | "OK": 195, 52 | "Internal": 3, 53 | "DeadlineExceeded": 2} 54 | 55 | r.LatencyDistribution = []*runner.LatencyDistribution{ 56 | { 57 | Percentage: 25, 58 | Latency: time.Duration(1 * time.Millisecond), 59 | }, 60 | { 61 | Percentage: 50, 62 | Latency: time.Duration(5 * time.Millisecond), 63 | }, 64 | { 65 | Percentage: 75, 66 | Latency: time.Duration(10 * time.Millisecond), 67 | }, 68 | { 69 | Percentage: 90, 70 | Latency: time.Duration(15 * time.Millisecond), 71 | }, 72 | { 73 | Percentage: 95, 74 | Latency: time.Duration(20 * time.Millisecond), 75 | }, 76 | { 77 | Percentage: 99, 78 | Latency: time.Duration(25 * time.Millisecond), 79 | }, 80 | } 81 | 82 | h := model.Histogram{ 83 | Report: &r, 84 | Buckets: []*runner.Bucket{ 85 | { 86 | Mark: 0.01, 87 | Count: 1, 88 | Frequency: 0.005, 89 | }, 90 | { 91 | Mark: 0.02, 92 | Count: 10, 93 | Frequency: 0.01, 94 | }, 95 | { 96 | Mark: 0.03, 97 | Count: 50, 98 | Frequency: 0.1, 99 | }, 100 | { 101 | Mark: 0.05, 102 | Count: 60, 103 | Frequency: 0.15, 104 | }, 105 | { 106 | Mark: 0.1, 107 | Count: 15, 108 | Frequency: 0.07, 109 | }, 110 | }, 111 | } 112 | 113 | err := db.CreateHistogram(&h) 114 | 115 | assert.NoError(t, err) 116 | assert.NotZero(t, p.ID) 117 | assert.NotZero(t, r.ID) 118 | assert.NotZero(t, h.ID) 119 | 120 | rid = r.ID 121 | hid = h.ID 122 | }) 123 | }) 124 | 125 | t.Run("GetHistogramForReport", func(t *testing.T) { 126 | h, err := db.GetHistogramForReport(rid) 127 | 128 | assert.NoError(t, err) 129 | 130 | assert.Equal(t, rid, h.ReportID) 131 | assert.Equal(t, hid, h.ID) 132 | assert.Nil(t, h.Report) 133 | assert.NotZero(t, h.CreatedAt) 134 | assert.NotZero(t, h.UpdatedAt) 135 | 136 | assert.NotNil(t, h.Buckets) 137 | assert.Len(t, h.Buckets, 5) 138 | assert.Equal(t, &runner.Bucket{ 139 | Mark: 0.01, 140 | Count: 1, 141 | Frequency: 0.005, 142 | }, h.Buckets[0]) 143 | assert.Equal(t, &runner.Bucket{ 144 | Mark: 0.1, 145 | Count: 15, 146 | Frequency: 0.07, 147 | }, h.Buckets[4]) 148 | }) 149 | 150 | t.Run("GetHistogramForReport invalid report id", func(t *testing.T) { 151 | h, err := db.GetOptionsForReport(12321) 152 | 153 | assert.Error(t, err) 154 | assert.Nil(t, h) 155 | }) 156 | } 157 | -------------------------------------------------------------------------------- /web/database/options.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "github.com/bojand/ghz/web/model" 5 | ) 6 | 7 | // CreateOptions creates a new report 8 | func (d *Database) CreateOptions(o *model.Options) error { 9 | return d.DB.Create(o).Error 10 | } 11 | 12 | // GetOptionsForReport creates a new report 13 | func (d *Database) GetOptionsForReport(rid uint) (*model.Options, error) { 14 | r := &model.Report{} 15 | r.ID = rid 16 | o := new(model.Options) 17 | err := d.DB.Model(r).Related(&o).Error 18 | 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | return o, err 24 | } 25 | -------------------------------------------------------------------------------- /web/database/options_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | 8 | "github.com/bojand/ghz/runner" 9 | "github.com/bojand/ghz/web/model" 10 | "github.com/stretchr/testify/assert" 11 | ) 12 | 13 | func TestDatabase_Options(t *testing.T) { 14 | os.Remove(dbName) 15 | 16 | defer os.Remove(dbName) 17 | 18 | db, err := New("sqlite3", dbName, false) 19 | if err != nil { 20 | assert.FailNow(t, err.Error()) 21 | } 22 | defer db.Close() 23 | 24 | var rid, oid uint 25 | 26 | t.Run("new", func(t *testing.T) { 27 | t.Run("test new", func(t *testing.T) { 28 | p := model.Project{ 29 | Name: "Test Proj 111 ", 30 | Description: "Test Description Asdf ", 31 | } 32 | 33 | r := model.Report{ 34 | Project: &p, 35 | Name: "Test report", 36 | EndReason: "normal", 37 | Date: time.Date(2018, 12, 1, 8, 0, 0, 0, time.UTC), 38 | Count: 200, 39 | Total: time.Duration(2 * time.Second), 40 | Average: time.Duration(10 * time.Millisecond), 41 | Fastest: time.Duration(1 * time.Millisecond), 42 | Slowest: time.Duration(100 * time.Millisecond), 43 | Rps: 2000, 44 | } 45 | 46 | r.ErrorDist = map[string]int{ 47 | "rpc error: code = Internal desc = Internal error.": 3, 48 | "rpc error: code = DeadlineExceeded desc = Deadline exceeded.": 2} 49 | 50 | r.StatusCodeDist = map[string]int{ 51 | "OK": 195, 52 | "Internal": 3, 53 | "DeadlineExceeded": 2} 54 | 55 | r.LatencyDistribution = []*runner.LatencyDistribution{ 56 | { 57 | Percentage: 25, 58 | Latency: time.Duration(1 * time.Millisecond), 59 | }, 60 | { 61 | Percentage: 50, 62 | Latency: time.Duration(5 * time.Millisecond), 63 | }, 64 | { 65 | Percentage: 75, 66 | Latency: time.Duration(10 * time.Millisecond), 67 | }, 68 | { 69 | Percentage: 90, 70 | Latency: time.Duration(15 * time.Millisecond), 71 | }, 72 | { 73 | Percentage: 95, 74 | Latency: time.Duration(20 * time.Millisecond), 75 | }, 76 | { 77 | Percentage: 99, 78 | Latency: time.Duration(25 * time.Millisecond), 79 | }, 80 | } 81 | 82 | o := model.Options{ 83 | Report: &r, 84 | Info: &model.OptionsInfo{ 85 | Call: "helloworld.Greeter.SayHi", 86 | Proto: "greeter.proto", 87 | }, 88 | } 89 | 90 | err := db.CreateOptions(&o) 91 | 92 | assert.NoError(t, err) 93 | assert.NotZero(t, p.ID) 94 | assert.NotZero(t, r.ID) 95 | assert.NotZero(t, o.ID) 96 | 97 | rid = r.ID 98 | oid = o.ID 99 | }) 100 | }) 101 | 102 | t.Run("GetOptionsForReport", func(t *testing.T) { 103 | o, err := db.GetOptionsForReport(rid) 104 | 105 | assert.NoError(t, err) 106 | 107 | assert.Equal(t, rid, o.ReportID) 108 | assert.Equal(t, oid, o.ID) 109 | assert.Nil(t, o.Report) 110 | assert.NotZero(t, o.CreatedAt) 111 | assert.NotZero(t, o.UpdatedAt) 112 | 113 | assert.NotNil(t, o.Info) 114 | assert.Equal(t, "helloworld.Greeter.SayHi", o.Info.Call) 115 | assert.Equal(t, "greeter.proto", o.Info.Proto) 116 | }) 117 | 118 | t.Run("GetOptionsForReport invalid report id", func(t *testing.T) { 119 | o, err := db.GetOptionsForReport(12321) 120 | 121 | assert.Error(t, err) 122 | assert.Nil(t, o) 123 | }) 124 | } 125 | -------------------------------------------------------------------------------- /web/database/project.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "github.com/bojand/ghz/web/model" 5 | ) 6 | 7 | // FindProjectByID gets the project by id 8 | func (d *Database) FindProjectByID(id uint) (*model.Project, error) { 9 | p := new(model.Project) 10 | err := d.DB.First(p, id).Error 11 | if err != nil { 12 | p = nil 13 | } 14 | return p, err 15 | } 16 | 17 | // CountProjects returns the number of projects 18 | func (d *Database) CountProjects() (uint, error) { 19 | p := new(model.Project) 20 | count := uint(0) 21 | err := d.DB.Model(p).Count(&count).Error 22 | return count, err 23 | } 24 | 25 | // CreateProject creates a new project 26 | func (d *Database) CreateProject(p *model.Project) error { 27 | return d.DB.Create(p).Error 28 | } 29 | 30 | // UpdateProject update a project 31 | func (d *Database) UpdateProject(p *model.Project) error { 32 | return d.DB.Save(p).Error 33 | } 34 | 35 | // DeleteProject deletas an existing project 36 | func (d *Database) DeleteProject(p *model.Project) error { 37 | return d.DB.Delete(p).Error 38 | } 39 | 40 | // UpdateProjectStatus updates the project's status 41 | func (d *Database) UpdateProjectStatus(pid uint, status model.Status) error { 42 | p := new(model.Project) 43 | p.ID = pid 44 | 45 | // use UpdateColumn to circumvent update hooks and not modify updated at time 46 | return d.DB.Model(p).UpdateColumn("status", status).Error 47 | } 48 | 49 | // ListProjects lists projects using sorting 50 | func (d *Database) ListProjects(limit, page uint, sortField, order string) ([]*model.Project, error) { 51 | if sortField != "name" && sortField != "id" { 52 | sortField = "id" 53 | } 54 | 55 | if order != "asc" && order != "desc" { 56 | order = "desc" 57 | } 58 | 59 | offset := uint(0) 60 | if page > 0 && limit > 0 { 61 | offset = page * limit 62 | } 63 | 64 | orderSQL := sortField + " " + string(order) 65 | 66 | s := make([]*model.Project, limit) 67 | 68 | err := d.DB.Order(orderSQL).Offset(offset).Limit(limit).Find(&s).Error 69 | 70 | return s, err 71 | } 72 | -------------------------------------------------------------------------------- /web/model/detail.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "strings" 7 | "time" 8 | 9 | "github.com/bojand/ghz/runner" 10 | ) 11 | 12 | // Detail represents a report detail 13 | type Detail struct { 14 | Model 15 | 16 | Report *Report `json:"-"` 17 | 18 | // Run id 19 | ReportID uint `json:"reportID" gorm:"type:integer REFERENCES reports(id) ON DELETE CASCADE;not null"` 20 | 21 | runner.ResultDetail 22 | } 23 | 24 | const layoutISO string = "2006-01-02T15:04:05.000Z" 25 | const layoutISO2 string = "2006-01-02T15:04:05-0700" 26 | 27 | // UnmarshalJSON for Detail 28 | func (d *Detail) UnmarshalJSON(data []byte) error { 29 | type Alias Detail 30 | aux := &struct { 31 | Timestamp string `json:"timestamp"` 32 | *Alias 33 | }{ 34 | Alias: (*Alias)(d), 35 | } 36 | 37 | if err := json.Unmarshal(data, &aux); err != nil { 38 | return err 39 | } 40 | 41 | err := json.Unmarshal([]byte(aux.Timestamp), &d.Timestamp) 42 | if err != nil { 43 | d.Timestamp, err = time.Parse(time.RFC3339Nano, aux.Timestamp) 44 | } 45 | if err != nil { 46 | d.Timestamp, err = time.Parse(time.RFC3339, aux.Timestamp) 47 | } 48 | if err != nil { 49 | d.Timestamp, err = time.Parse(layoutISO, aux.Timestamp) 50 | } 51 | if err != nil { 52 | d.Timestamp, err = time.Parse(layoutISO2, aux.Timestamp) 53 | } 54 | 55 | return err 56 | } 57 | 58 | // BeforeSave is called by GORM before save 59 | func (d *Detail) BeforeSave() error { 60 | if d.ReportID == 0 && d.Report == nil { 61 | return errors.New("Detail must belong to a report") 62 | } 63 | 64 | d.Error = strings.TrimSpace(d.Error) 65 | 66 | status := strings.TrimSpace(d.Status) 67 | if status == "" { 68 | status = "OK" 69 | } 70 | d.Status = status 71 | 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /web/model/histogram.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | "errors" 7 | 8 | "github.com/bojand/ghz/runner" 9 | "github.com/jinzhu/gorm" 10 | ) 11 | 12 | // BucketList is a slice of buckets 13 | type BucketList []*runner.Bucket 14 | 15 | // Value converts struct to a database value 16 | func (bl BucketList) Value() (driver.Value, error) { 17 | v, err := json.Marshal(bl) 18 | if err != nil { 19 | return nil, err 20 | } 21 | return string(v), nil 22 | } 23 | 24 | // Scan converts database value to a struct 25 | func (bl *BucketList) Scan(src interface{}) error { 26 | var sourceStr string 27 | sourceByte, ok := src.([]byte) 28 | if !ok { 29 | sourceStr, ok = src.(string) 30 | if !ok { 31 | return errors.New("type assertion from string / byte") 32 | } 33 | sourceByte = []byte(sourceStr) 34 | } 35 | 36 | var buckets []runner.Bucket 37 | if err := json.Unmarshal(sourceByte, &buckets); err != nil { 38 | return err 39 | } 40 | 41 | for index := range buckets { 42 | *bl = append(*bl, &buckets[index]) 43 | } 44 | 45 | return nil 46 | } 47 | 48 | // Histogram represents a histogram 49 | type Histogram struct { 50 | Model 51 | 52 | ReportID uint `json:"reportID" gorm:"type:integer REFERENCES reports(id) ON DELETE CASCADE;not null"` 53 | Report *Report `json:"-"` 54 | 55 | Buckets BucketList `json:"buckets" gorm:"type:TEXT"` 56 | } 57 | 58 | // BeforeSave is called by GORM before save 59 | func (h *Histogram) BeforeSave(scope *gorm.Scope) error { 60 | if h.ReportID == 0 && h.Report == nil { 61 | return errors.New("Histogram must belong to a report") 62 | } 63 | 64 | return nil 65 | } 66 | -------------------------------------------------------------------------------- /web/model/model.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | // Model base model definition. Copy of gorm.Model with custom tags 6 | type Model struct { 7 | // The id 8 | ID uint `json:"id" gorm:"primary_key"` 9 | 10 | // The creation time 11 | CreatedAt time.Time `json:"createdAt"` 12 | 13 | // The updated time 14 | UpdatedAt time.Time `json:"updatedAt"` 15 | } 16 | -------------------------------------------------------------------------------- /web/model/options.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | "errors" 7 | 8 | "github.com/bojand/ghz/runner" 9 | "github.com/jinzhu/gorm" 10 | ) 11 | 12 | // Options represents a report detail 13 | type Options struct { 14 | Model 15 | 16 | Report *Report `json:"-"` 17 | 18 | // Run id 19 | ReportID uint `json:"reportID" gorm:"type:integer REFERENCES reports(id) ON DELETE CASCADE;not null"` 20 | 21 | Info *OptionsInfo `json:"info,omitempty" gorm:"type:TEXT"` 22 | } 23 | 24 | // BeforeSave is called by GORM before save 25 | func (o *Options) BeforeSave(scope *gorm.Scope) error { 26 | if o.ReportID == 0 && o.Report == nil { 27 | return errors.New("Options must belong to a report") 28 | } 29 | 30 | return nil 31 | } 32 | 33 | // OptionsInfo represents the report options 34 | type OptionsInfo runner.Options 35 | 36 | // Value converts options struct to a database value 37 | func (o OptionsInfo) Value() (driver.Value, error) { 38 | v, err := json.Marshal(o) 39 | if err != nil { 40 | return nil, err 41 | } 42 | return string(v), nil 43 | } 44 | 45 | // Scan converts database value to an Options struct 46 | func (o *OptionsInfo) Scan(src interface{}) error { 47 | var sourceStr string 48 | sourceByte, ok := src.([]byte) 49 | if !ok { 50 | sourceStr, ok = src.(string) 51 | if !ok { 52 | return errors.New("type assertion from string / byte") 53 | } 54 | sourceByte = []byte(sourceStr) 55 | } 56 | 57 | if err := json.Unmarshal(sourceByte, o); err != nil { 58 | return err 59 | } 60 | 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /web/model/options_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | 8 | "github.com/jinzhu/gorm" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestOptions_BeforeSave(t *testing.T) { 13 | var hs = []struct { 14 | name string 15 | in *Options 16 | expected *Options 17 | expectError bool 18 | }{ 19 | {"no report id", &Options{}, &Options{}, true}, 20 | {"with report id", &Options{ReportID: 123}, &Options{ReportID: 123}, false}, 21 | } 22 | 23 | for _, tt := range hs { 24 | t.Run(tt.name, func(t *testing.T) { 25 | err := tt.in.BeforeSave(nil) 26 | if tt.expectError { 27 | assert.Error(t, err) 28 | } else { 29 | assert.NoError(t, err) 30 | } 31 | 32 | assert.Equal(t, tt.expected, tt.in) 33 | }) 34 | } 35 | } 36 | 37 | func TestOptions(t *testing.T) { 38 | os.Remove(dbName) 39 | 40 | defer os.Remove(dbName) 41 | 42 | db, err := gorm.Open("sqlite3", dbName) 43 | if err != nil { 44 | assert.FailNow(t, err.Error()) 45 | } 46 | defer db.Close() 47 | 48 | db.LogMode(true) 49 | 50 | db.Exec("PRAGMA foreign_keys = ON;") 51 | db.AutoMigrate(&Project{}, &Report{}, &Options{}) 52 | 53 | var rid, oid uint 54 | 55 | t.Run("test create", func(t *testing.T) { 56 | p := Project{ 57 | Name: "Test Project 111 ", 58 | Description: "Test Description Asdf ", 59 | } 60 | 61 | r := Report{ 62 | Project: &p, 63 | Name: "Test report", 64 | EndReason: "normal", 65 | Date: time.Now(), 66 | Count: 200, 67 | Total: time.Duration(2 * time.Second), 68 | Average: time.Duration(10 * time.Millisecond), 69 | Fastest: time.Duration(1 * time.Millisecond), 70 | Slowest: time.Duration(100 * time.Millisecond), 71 | Rps: 2000, 72 | } 73 | 74 | o := Options{ 75 | Report: &r, 76 | Info: &OptionsInfo{ 77 | Call: "helloworld.Greeter.SayHi", 78 | Proto: "greeter.proto", 79 | }, 80 | } 81 | 82 | err := db.Create(&o).Error 83 | 84 | assert.NoError(t, err) 85 | assert.NotZero(t, p.ID) 86 | assert.NotZero(t, r.ID) 87 | assert.NotZero(t, o.ID) 88 | 89 | oid = o.ID 90 | rid = r.ID 91 | }) 92 | 93 | t.Run("read", func(t *testing.T) { 94 | o := new(Options) 95 | err = db.First(o, oid).Error 96 | 97 | assert.NoError(t, err) 98 | 99 | assert.Equal(t, rid, o.ReportID) 100 | assert.Equal(t, oid, o.ID) 101 | assert.Nil(t, o.Report) 102 | assert.NotZero(t, o.CreatedAt) 103 | assert.NotZero(t, o.UpdatedAt) 104 | 105 | assert.NotNil(t, o.Info) 106 | assert.Equal(t, "helloworld.Greeter.SayHi", o.Info.Call) 107 | assert.Equal(t, "greeter.proto", o.Info.Proto) 108 | }) 109 | 110 | t.Run("fail create with unknown report id", func(t *testing.T) { 111 | o := Options{ 112 | ReportID: 123213, 113 | Info: &OptionsInfo{ 114 | Call: "helloworld.Greeter.SayHi", 115 | Proto: "greeter.proto", 116 | }, 117 | } 118 | 119 | err := db.Create(&o).Error 120 | 121 | assert.Error(t, err) 122 | }) 123 | } 124 | -------------------------------------------------------------------------------- /web/model/project.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/bojand/hri" 8 | ) 9 | 10 | // Project represents a project 11 | type Project struct { 12 | Model 13 | Name string `json:"name" gorm:"not null"` 14 | Description string `json:"description"` 15 | Status Status `json:"status" gorm:"not null"` 16 | } 17 | 18 | // BeforeCreate is a GORM hook called when a model is created 19 | func (p *Project) BeforeCreate() error { 20 | if p.Name == "" { 21 | p.Name = hri.Random() 22 | } 23 | 24 | if string(p.Status) == "" { 25 | p.Status = StatusOK 26 | } 27 | 28 | return nil 29 | } 30 | 31 | // BeforeUpdate is a GORM hook called when a model is updated 32 | func (p *Project) BeforeUpdate() error { 33 | if p.Name == "" { 34 | return errors.New("Project name cannot be empty") 35 | } 36 | 37 | return nil 38 | } 39 | 40 | // BeforeSave is a GORM hook called when a model is created or updated 41 | func (p *Project) BeforeSave() error { 42 | p.Name = strings.TrimSpace(p.Name) 43 | p.Description = strings.TrimSpace(p.Description) 44 | 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /web/model/report.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | "errors" 7 | "time" 8 | 9 | "github.com/bojand/ghz/runner" 10 | ) 11 | 12 | // LatencyDistributionList is a slice of LatencyDistribution pointers 13 | type LatencyDistributionList []*runner.LatencyDistribution 14 | 15 | // Value converts struct to a database value 16 | func (ld LatencyDistributionList) Value() (driver.Value, error) { 17 | v, err := json.Marshal(ld) 18 | if err != nil { 19 | return nil, err 20 | } 21 | return string(v), nil 22 | } 23 | 24 | // Scan converts database value to a struct 25 | func (ld *LatencyDistributionList) Scan(src interface{}) error { 26 | var sourceStr string 27 | sourceByte, ok := src.([]byte) 28 | if !ok { 29 | sourceStr, ok = src.(string) 30 | if !ok { 31 | return errors.New("type assertion from string / byte") 32 | } 33 | sourceByte = []byte(sourceStr) 34 | } 35 | 36 | var lds []runner.LatencyDistribution 37 | if err := json.Unmarshal(sourceByte, &lds); err != nil { 38 | return err 39 | } 40 | 41 | for index := range lds { 42 | *ld = append(*ld, &lds[index]) 43 | } 44 | 45 | return nil 46 | } 47 | 48 | // StringIntMap is a map of string keys to int values 49 | type StringIntMap map[string]int 50 | 51 | // Value converts map to database value 52 | func (m StringIntMap) Value() (driver.Value, error) { 53 | v, err := json.Marshal(m) 54 | if err != nil { 55 | return nil, err 56 | } 57 | return string(v), nil 58 | } 59 | 60 | // Scan converts database value to a map 61 | func (m *StringIntMap) Scan(src interface{}) error { 62 | var sourceStr string 63 | sourceByte, ok := src.([]byte) 64 | if !ok { 65 | sourceStr, ok = src.(string) 66 | if !ok { 67 | return errors.New("type assertion from string / byte") 68 | } 69 | sourceByte = []byte(sourceStr) 70 | } 71 | 72 | if err := json.Unmarshal(sourceByte, m); err != nil { 73 | return err 74 | } 75 | 76 | return nil 77 | } 78 | 79 | // StringStringMap is a map of string keys to int values 80 | type StringStringMap map[string]string 81 | 82 | // Value converts map to database value 83 | func (m StringStringMap) Value() (driver.Value, error) { 84 | v, err := json.Marshal(m) 85 | if err != nil { 86 | return nil, err 87 | } 88 | return string(v), nil 89 | } 90 | 91 | // Scan converts database value to a map 92 | func (m *StringStringMap) Scan(src interface{}) error { 93 | var sourceStr string 94 | sourceByte, ok := src.([]byte) 95 | if !ok { 96 | sourceStr, ok = src.(string) 97 | if !ok { 98 | return errors.New("type assertion from string / byte") 99 | } 100 | sourceByte = []byte(sourceStr) 101 | } 102 | 103 | if err := json.Unmarshal(sourceByte, m); err != nil { 104 | return err 105 | } 106 | 107 | return nil 108 | } 109 | 110 | // Report represents a project 111 | type Report struct { 112 | Model 113 | 114 | ProjectID uint `json:"projectID" gorm:"type:integer REFERENCES projects(id) ON DELETE CASCADE;not null"` 115 | Project *Project `json:"-"` 116 | 117 | Name string `json:"name,omitempty"` 118 | EndReason string `json:"endReason,omitempty"` 119 | Date time.Time `json:"date"` 120 | 121 | Count uint64 `json:"count"` 122 | Total time.Duration `json:"total"` 123 | Average time.Duration `json:"average"` 124 | Fastest time.Duration `json:"fastest"` 125 | Slowest time.Duration `json:"slowest"` 126 | Rps float64 `json:"rps"` 127 | 128 | Status Status `json:"status" gorm:"not null"` 129 | 130 | ErrorDist StringIntMap `json:"errorDistribution,omitempty" gorm:"type:TEXT"` 131 | StatusCodeDist StringIntMap `json:"statusCodeDistribution,omitempty" gorm:"type:TEXT"` 132 | 133 | LatencyDistribution LatencyDistributionList `json:"latencyDistribution" gorm:"type:TEXT"` 134 | 135 | Tags StringStringMap `json:"tags,omitempty" gorm:"type:TEXT"` 136 | } 137 | 138 | // BeforeSave is called by GORM before save 139 | func (r *Report) BeforeSave() error { 140 | if r.ProjectID == 0 && r.Project == nil { 141 | return errors.New("Report must belong to a project") 142 | } 143 | 144 | if string(r.Status) == "" { 145 | r.Status = StatusOK 146 | } 147 | 148 | return nil 149 | } 150 | -------------------------------------------------------------------------------- /web/model/status.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "strings" 4 | 5 | // Status represents a status of a project or record 6 | type Status string 7 | 8 | // StatusFromString creates a Status from a string 9 | func StatusFromString(str string) Status { 10 | str = strings.ToLower(str) 11 | 12 | t := StatusOK 13 | 14 | if str == "fail" { 15 | t = StatusFail 16 | } 17 | 18 | return t 19 | } 20 | 21 | const ( 22 | // StatusOK means the latest run in test was within the threshold 23 | StatusOK = Status("ok") 24 | 25 | // StatusFail means the latest run in test was not within the threshold 26 | StatusFail = Status("fail") 27 | ) 28 | -------------------------------------------------------------------------------- /web/model/status_test.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestStatus_StatusFromString(t *testing.T) { 10 | var tests = []struct { 11 | name string 12 | in string 13 | expected Status 14 | }{ 15 | {"OK", "OK", StatusOK}, 16 | {"ok", "ok", StatusOK}, 17 | {"fail", "fail", StatusFail}, 18 | {"FAIL", "FAIL", StatusFail}, 19 | {"asdf", "asdf", StatusOK}, 20 | {"empty", "", StatusOK}, 21 | } 22 | 23 | for _, tt := range tests { 24 | t.Run(tt.name, func(t *testing.T) { 25 | actual := StatusFromString(tt.in) 26 | assert.Equal(t, tt.expected, actual) 27 | }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /web/test/config1.toml: -------------------------------------------------------------------------------- 1 | [database] 2 | type = "sqlite3" 3 | -------------------------------------------------------------------------------- /web/test/config2.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "port": 4321 4 | }, 5 | "database": { 6 | "type": "postgres", 7 | "connection": "host=dbhost user=dbuser dbname=ghz sslmode=disable password=dbpwd" 8 | }, 9 | "log": { 10 | "level": "warn", 11 | "path": "/tmp/ghz.log" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /web/test/config2.toml: -------------------------------------------------------------------------------- 1 | [database] 2 | type = "postgres" 3 | connection = "host=dbhost user=dbuser dbname=ghz sslmode=disable password=dbpwd" 4 | 5 | [server] 6 | port = 4321 7 | 8 | [log] 9 | level = "warn" 10 | path = "/tmp/ghz.log" 11 | -------------------------------------------------------------------------------- /web/test/config2.yml: -------------------------------------------------------------------------------- 1 | --- 2 | server: 3 | port: 4321 4 | database: 5 | type: postgres 6 | connection: host=dbhost user=dbuser dbname=ghz sslmode=disable password=dbpwd 7 | log: 8 | level: warn 9 | path: "/tmp/ghz.log" 10 | -------------------------------------------------------------------------------- /web/test/config3.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "type": "postgres", 4 | "connection": "host=localhost port=5432 dbname=ghz sslmode=disable" 5 | }, 6 | "log": { 7 | "level": "debug" 8 | }, 9 | "server": { 10 | "port": 3000 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /web/test/config3.toml: -------------------------------------------------------------------------------- 1 | [database] 2 | type = "postgres" 3 | connection = "host=localhost port=5432 dbname=ghz sslmode=disable" 4 | 5 | [server] 6 | port = 3000 7 | 8 | [log] 9 | level = "debug" 10 | -------------------------------------------------------------------------------- /web/test/config3.yml: -------------------------------------------------------------------------------- 1 | --- 2 | server: 3 | port: 3000 4 | database: 5 | type: postgres 6 | connection: host=localhost port=5432 dbname=ghz sslmode=disable 7 | log: 8 | level: debug 9 | -------------------------------------------------------------------------------- /web/test/create_test_reports.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const http = require('http'); 4 | 5 | const folder = process.argv[2] 6 | const projectId = process.argv[3] 7 | 8 | if (!folder) { 9 | console.log('Need folder') 10 | process.exit(1) 11 | } 12 | 13 | if (!projectId) { 14 | console.log('Need projectId') 15 | process.exit(1) 16 | } 17 | 18 | let reportFiles = [ 19 | 'report1.json', 'report2.json', 'report3.json', 'report4.json', 'report5.json', 20 | 'report6.json', 'report7.json', 'report8.json', 'report9.json', 'report1.json', 21 | 'report2.json', 'report3.json', 'report4.json', 'report5.json', 'report6.json', 22 | 'report7.json', 'report8.json', 'report9.json' 23 | ] 24 | 25 | reportFiles = arrayShuffle(reportFiles) 26 | 27 | reportFiles.push(`report3.json`) 28 | 29 | createData() 30 | 31 | async function createData () { 32 | let n = 0 33 | let h = 0 34 | const MONTH = (new Date()).getMonth() 35 | reportFiles.forEach(async fileName => { 36 | 37 | const rf = path.join(__dirname, folder, fileName) 38 | 39 | try { 40 | if (!rf) { 41 | return 42 | } 43 | 44 | n++ 45 | if (n > 27) { 46 | console.log('maximum reached skipping...') 47 | return 48 | } 49 | 50 | h++ 51 | if (h >= 23) { 52 | h = 1 53 | } 54 | 55 | console.log(rf) 56 | const content = fs.readFileSync(rf, 'utf8') 57 | const data = JSON.parse(content) 58 | const date = new Date() 59 | date.setMonth(MONTH) 60 | date.setDate(n) 61 | date.setHours(h) 62 | data.date = date.toISOString() 63 | const status = await doPost(data) 64 | console.log('done: ' + status) 65 | } catch (e) { 66 | console.log(e) 67 | } 68 | }) 69 | } 70 | 71 | function doPost (data) { 72 | return new Promise((resolve, reject) => { 73 | const postData = JSON.stringify(data) 74 | 75 | const options = { 76 | hostname: 'localhost', 77 | port: 3000, 78 | path: `/api/projects/${projectId}/ingest`, 79 | method: 'POST', 80 | headers: { 81 | 'Content-Type': 'application/json', 82 | 'Cache-Control': 'no-cache', 83 | 'Content-Length': postData.length 84 | } 85 | } 86 | 87 | req = http.request(options, res => { 88 | res.setEncoding('utf8') 89 | res.on('end', () => { 90 | if (!res.complete) { 91 | console.error( 92 | 'The connection was terminated while the message was still being sent'); 93 | reject(new Error('Incomplete')) 94 | } else { 95 | resolve(res.statusCode) 96 | } 97 | }); 98 | }) 99 | 100 | req.on('error', function (e) { 101 | reject(e) 102 | }) 103 | 104 | req.write(postData); 105 | req.end() 106 | }) 107 | } 108 | 109 | function arrayShuffle(array) { 110 | return shuffleSelf(copyArray(array)); 111 | } 112 | 113 | function copyArray(source, array) { 114 | var index = -1, 115 | length = source.length; 116 | 117 | array || (array = Array(length)); 118 | while (++index < length) { 119 | array[index] = source[index]; 120 | } 121 | return array; 122 | } 123 | 124 | function shuffleSelf(array, size) { 125 | var index = -1, 126 | length = array.length, 127 | lastIndex = length - 1; 128 | 129 | size = size === undefined ? length : size; 130 | while (++index < size) { 131 | var rand = baseRandom(index, lastIndex), 132 | value = array[rand]; 133 | 134 | array[rand] = array[index]; 135 | array[index] = value; 136 | } 137 | array.length = size; 138 | return array; 139 | } 140 | 141 | function baseRandom(lower, upper) { 142 | return lower + Math.floor(Math.random() * (upper - lower + 1)); 143 | } 144 | 145 | function getRandomInt (max) { 146 | return Math.floor(Math.random() * Math.floor(max)) 147 | } 148 | -------------------------------------------------------------------------------- /web/todo.md: -------------------------------------------------------------------------------- 1 | - [ ] Improve website documentation 2 | - [ ] Make ingest transactional 3 | - [ ] Menu in project listing with Delete option ? 4 | - [ ] Latest run time ago + link in project list (needs API) ? 5 | - [ ] Add config for host to bind to ? 6 | - [ ] Improve invalid request and bad condition tests 7 | - [x] Fix ingest status 8 | - [x] DELETE 9 | - [x] Lock down demo create 10 | - [x] Docs 11 | - [x] Fix UI when going to a direct url 12 | - [x] About page 13 | - [x] Previous report endpoint for compare 14 | - [x] Env config 15 | - [x] Config js 16 | - [x] Demo deployment 17 | -------------------------------------------------------------------------------- /web/ui/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "targets": { 5 | "browsers": ["last 2 Chrome versions"] 6 | } 7 | }] 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /web/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ghz-web-ui-evergreen", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "clean": "rm -rf .cache dist", 9 | "start": "parcel src/index.html --open", 10 | "build": "npm run clean && parcel build src/index.html --no-source-maps" 11 | }, 12 | "author": "", 13 | "license": "ISC", 14 | "devDependencies": { 15 | "babel-core": "^6.26.3", 16 | "babel-preset-env": "^1.7.0", 17 | "parcel-bundler": "^1.12.4", 18 | "standard": "^16.0.0" 19 | }, 20 | "dependencies": { 21 | "chart.js": "^2.9.4", 22 | "chartjs-color": "^2.4.1", 23 | "evergreen-ui": "^5.1.0", 24 | "fuzzaldrin-plus": "^0.6.0", 25 | "ky": "^0.26.0", 26 | "lodash": "^4.17.21", 27 | "react": "^16.13.1", 28 | "react-chartjs-2": "^2.11.1", 29 | "react-dom": "^16.13.1", 30 | "react-router-dom": "^5.2.0", 31 | "timeago.js": "^4.0.2", 32 | "unstated": "^2.1.1" 33 | }, 34 | "standard": { 35 | "env": { 36 | "browser": true 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /web/ui/src/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Pane, Tab, Icon, Text } from 'evergreen-ui' 3 | import { BrowserRouter as Router, Route, Link as RouterLink, Switch } from 'react-router-dom' 4 | import { Provider, Subscribe } from 'unstated' 5 | 6 | import ProjectListPage from './components/ProjectListPage' 7 | import ProjectDetailPage from './components/ProjectDetailPage' 8 | import ReportPage from './components/ReportPage' 9 | import ReportDetailPage from './components/ReportDetailPage' 10 | import Footer from './components/Footer' 11 | import InfoComponent from './components/InfoComponent' 12 | import ComparePage from './components/ComparePage' 13 | 14 | import InfoContainer from './containers/InfoContainer' 15 | 16 | export default class App extends Component { 17 | render () { 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ) 40 | } 41 | } 42 | 43 | function Projects ({ match }) { 44 | return ( 45 | 46 | {match.params.projectId 47 | ? 48 | : 49 | } 50 | 51 | ) 52 | } 53 | 54 | function Reports ({ match }) { 55 | return ( 56 | 57 | {match.params.reportId 58 | ? 59 | : 60 | } 61 | 62 | ) 63 | } 64 | 65 | function Compare ({ match }) { 66 | return ( 67 | 68 | 69 | 70 | ) 71 | } 72 | 73 | function Info () { 74 | return ( 75 | 76 | 77 | 78 | {infoStore => ( 79 | 80 | )} 81 | 82 | 83 | 84 | ) 85 | } 86 | 87 | const TabLink = ({ to, linkText, icon, ...rest }) => ( 88 | ( 91 | 92 | {linkText} 93 | 94 | ) 95 | } 96 | /> 97 | ) 98 | -------------------------------------------------------------------------------- /web/ui/src/components/ComparePage.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Pane } from 'evergreen-ui' 3 | import { Provider, Subscribe } from 'unstated' 4 | 5 | import ComparePane from './ComparePane' 6 | 7 | import CompareContainer from '../containers/CompareContainer' 8 | 9 | export default class ComparePage extends Component { 10 | render () { 11 | return ( 12 | 13 | 14 | {(compareStore) => ( 15 | 16 | 21 | 22 | )} 23 | 24 | 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /web/ui/src/components/DeleteDialog.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Dialog, Pane, Paragraph, TextInputField, Strong } from 'evergreen-ui' 3 | 4 | export default class DeleteDialog extends Component { 5 | constructor (props) { 6 | super(props) 7 | 8 | this.state = { 9 | isShown: props.isShown, 10 | name: '', 11 | description: props.project ? props.project.description || '' : '', 12 | isInvalid: true 13 | } 14 | } 15 | 16 | onChangeText (key, value) { 17 | if (key === 'name') { 18 | this.setState({ 19 | name: value, 20 | isInvalid: value.trim() !== this.props.name 21 | }) 22 | } 23 | } 24 | 25 | render () { 26 | return ( 27 | 28 | { 35 | if (this.state.name.trim() !== this.props.name) { 36 | this.setState({ isInvalid: true }) 37 | return 38 | } 39 | 40 | this.setState({ isShown: false }) 41 | if (typeof this.props.onConfirm === 'function') { 42 | this.props.onConfirm() 43 | } 44 | }} 45 | confirmLabel='Delete'> 46 | {`This will delete ${this.props.dataType} ${this.props.name} and all associated data. 47 | This action cannot be reveresed. Are you sure you want to proceed? 48 | If so type in the name of the ${this.props.dataType}: `} {this.props.name} 49 | 50 | this.onChangeText('name', ev.target.value)} 59 | /> 60 | 61 | 62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /web/ui/src/components/EditProjectDialog.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Dialog, TextInputField, Textarea, Pane, Label } from 'evergreen-ui' 3 | 4 | export default class EditProjectDialog extends Component { 5 | constructor (props) { 6 | super(props) 7 | 8 | this.state = { 9 | isShown: props.isShown, 10 | isLoading: false, 11 | name: props.project ? props.project.name || '' : '', 12 | description: props.project ? props.project.description || '' : '', 13 | isInvalid: false 14 | } 15 | } 16 | 17 | onChangeText (key, value) { 18 | this.setState({ 19 | ...this.state, 20 | [key]: value 21 | }) 22 | } 23 | 24 | render () { 25 | const editId = this.props.project && this.props.project.id 26 | return ( 27 | 28 | { 32 | this.setState({ isShown: false, isLoading: false }) 33 | if (typeof this.props.onDone === 'function') { 34 | this.props.onDone() 35 | } 36 | }} 37 | onConfirm={async () => { 38 | if (this.state.name.trim() === '') { 39 | this.setState({ isInvalid: true }) 40 | return 41 | } 42 | this.setState({ isLoading: true }) 43 | 44 | let newProject = null 45 | 46 | if (editId) { 47 | newProject = await this.props.projectStore.updateProject( 48 | editId, this.state.name, this.state.description) 49 | } else { 50 | newProject = await this.props.projectStore.createProject(this.state.name, this.state.description) 51 | } 52 | 53 | this.setState({ ...this.state, isLoading: false, isShown: false }) 54 | if (typeof this.props.onDone === 'function') { 55 | this.props.onDone(newProject) 56 | } 57 | }} 58 | isConfirmLoading={this.state.isLoading} 59 | confirmLabel='Save'> 60 | this.onChangeText('name', ev.target.value)} 68 | /> 69 | 74 | Description 75 | 76 | this.onChangeText('description', ev.target.value)} 81 | /> 82 | 83 | 84 | 85 | ) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /web/ui/src/components/Footer.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Pane, Strong, Link, Paragraph } from 'evergreen-ui' 3 | import { Link as RouterLink } from 'react-router-dom' 4 | import GitHubIcon from './GitHubIcon' 5 | 6 | export default class Footer extends Component { 7 | render () { 8 | return ( 9 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | ghz The source code is licensed Apache-2.0. 25 | 26 | 27 | 28 | About 29 | 30 | 31 | 32 | ) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /web/ui/src/components/GitHubIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default class GitHubIcon extends React.PureComponent { 4 | render () { 5 | return ( 6 | 14 | 15 | 16 | 17 | 18 | ) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /web/ui/src/components/HistogramChart.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Pane } from 'evergreen-ui' 3 | import { HorizontalBar } from 'react-chartjs-2' 4 | 5 | import { 6 | createHistogramChart 7 | } from '../lib/histogramData' 8 | 9 | export default class HistoryChart extends Component { 10 | constructor (props) { 11 | super(props) 12 | 13 | this.config = null 14 | 15 | this.state = { 16 | config: this.config 17 | } 18 | } 19 | 20 | componentDidMount () { 21 | this.config = createHistogramChart(this.props.report) 22 | this.setState({ 23 | config: this.config 24 | }) 25 | } 26 | 27 | componentDidUpdate (prevProps) { 28 | if (!this.config || 29 | (prevProps.report.id !== this.props.report.id)) { 30 | this.config = createHistogramChart(this.props.report) 31 | } 32 | } 33 | 34 | render () { 35 | const config = this.state.config || this.config 36 | 37 | if (!config) { 38 | return ( 39 | 40 | ) 41 | } 42 | 43 | return ( 44 | 45 | 46 | 47 | ) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /web/ui/src/components/HistogramPane.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Pane, Heading } from 'evergreen-ui' 3 | 4 | import HistogramChart from './HistogramChart' 5 | 6 | export default class HistogramPane extends Component { 7 | constructor (props) { 8 | super(props) 9 | 10 | this.state = { 11 | reportId: props.reportId || 0 12 | } 13 | } 14 | 15 | componentDidMount () { 16 | this.props.histogramStore.fetchHistogram(this.props.reportId) 17 | } 18 | 19 | componentDidUpdate (prevProps) { 20 | if (!this.props.histogramStore.state.isFetching && 21 | (this.props.reportId !== prevProps.reportId)) { 22 | this.props.histogramStore.fetchHistogram(this.props.reportId) 23 | } 24 | } 25 | 26 | render () { 27 | const { state: { histogram } } = this.props.histogramStore 28 | 29 | if (!histogram || !histogram.length) { 30 | return () 31 | } 32 | 33 | const report = { 34 | id: this.props.reportId, 35 | histogram, 36 | count: this.props.count 37 | } 38 | 39 | return ( 40 | 41 | Histogram 42 | 43 | 44 | 45 | 46 | ) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /web/ui/src/components/HistoryChart.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Pane } from 'evergreen-ui' 3 | import { Line } from 'react-chartjs-2' 4 | 5 | import { 6 | createLineChart 7 | } from '../lib/projectChartData' 8 | 9 | export default class HistoryChart extends Component { 10 | constructor (props) { 11 | super(props) 12 | 13 | this.state = { 14 | config: createLineChart(this.props.reports) 15 | } 16 | } 17 | 18 | componentDidUpdate (prevProps) { 19 | if ((prevProps.projectId !== this.props.projectId) || 20 | (prevProps.reports.length !== this.props.reports.length)) { 21 | const config = createLineChart(this.props.reports) 22 | this.setState({ config }) 23 | } 24 | } 25 | 26 | render () { 27 | const { config } = this.state 28 | if (!config) { 29 | return ( 30 | 31 | ) 32 | } 33 | 34 | return ( 35 | 36 | 37 | 38 | ) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /web/ui/src/components/InfoComponent.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Pane, Text, Strong, Heading, Link, Paragraph } from 'evergreen-ui' 3 | import _ from 'lodash' 4 | 5 | export default class InfoComponent extends Component { 6 | componentDidMount () { 7 | this.props.infoStore.fetchInfo() 8 | } 9 | 10 | toWord (v) { 11 | return _.chain(v).upperFirst().words().value().join(' ') 12 | } 13 | 14 | render () { 15 | const { state: { info } } = this.props.infoStore 16 | 17 | if (!info) { 18 | return () 19 | } 20 | 21 | let infoKey = 0 22 | 23 | return ( 24 | 25 | 26 | 27 | General Information 28 | 29 | 30 | 31 | Website 32 | 33 | 34 | 35 | 36 | Github 37 | 38 | 39 | 40 | 41 | 42 | Donate ❤️ 43 | 44 | 45 | 46 | PayPal 47 | 48 | 49 | 50 | 51 | Buy Me A Coffee 52 | 53 | 54 | 55 | 56 | 57 | 58 | Application 59 | 60 | {_.map(info, (v, k) => ( 61 | 62 | {_.isString(k) && !_.isObject(v) && v 63 | ? 64 | {this.toWord(k)}: {v} 65 | 66 | : null} 67 | 68 | ))} 69 | 70 | 71 | 72 | 73 | Memory Info 74 | 75 | {_.map(info.memoryInfo, (v, k) => ( 76 | 77 | {_.isString(k) && !_.isObject(v) && v 78 | ? 79 | {this.toWord(k)}: {v} 80 | 81 | : null} 82 | 83 | ))} 84 | 85 | 86 | ) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /web/ui/src/components/OptionsPane.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Pane, Heading, Pre } from 'evergreen-ui' 3 | 4 | import { pretty } from '../lib/common' 5 | 6 | export default class OptionsPane extends Component { 7 | constructor (props) { 8 | super(props) 9 | 10 | this.state = { 11 | reportId: props.reportId || 0 12 | } 13 | } 14 | 15 | componentDidMount () { 16 | this.props.optionsStore.fetchOptions(this.props.reportId) 17 | } 18 | 19 | componentDidUpdate (prevProps) { 20 | if (!this.props.optionsStore.state.isFetching && 21 | (this.props.reportId !== prevProps.reportId)) { 22 | this.props.optionsStore.fetchOptions(this.props.reportId) 23 | } 24 | } 25 | 26 | render () { 27 | const { state: { options } } = this.props.optionsStore 28 | 29 | if (!options || !options.call) { 30 | return () 31 | } 32 | 33 | return ( 34 | 35 | 36 | Options 37 | 38 | 39 | 40 | {pretty(options)} 41 | 42 | 43 | 44 | ) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /web/ui/src/components/ProjectDetailPage.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Pane } from 'evergreen-ui' 3 | import { Provider, Subscribe } from 'unstated' 4 | 5 | import ReportList from './ReportList' 6 | import ProjectDetailPane from './ProjectDetailPane' 7 | import ReportsOverTimePane from './ReportsOverTimePane' 8 | 9 | import ReportContainer from '../containers/ReportContainer' 10 | import ProjectContainer from '../containers/ProjectContainer' 11 | 12 | export default class ProjectDetailPage extends Component { 13 | render () { 14 | return ( 15 | 16 | 17 | {(reportStore, projectStore) => ( 18 | 19 | 20 | 24 | 25 | 26 | 27 | 31 | 32 | 33 | 34 | 38 | 39 | 40 | )} 41 | 42 | 43 | ) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /web/ui/src/components/ProjectDetailPane.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Pane, Heading, Button, Paragraph, toaster } from 'evergreen-ui' 3 | import { toUpper } from 'lodash' 4 | import { withRouter } from 'react-router-dom' 5 | 6 | import EditProjectDialog from './EditProjectDialog' 7 | import DeleteDialog from './DeleteDialog' 8 | import StatusBadge from './StatusBadge' 9 | 10 | class ProjectDetailPane extends Component { 11 | constructor (props) { 12 | super(props) 13 | 14 | this.state = { 15 | projectId: props.projectId || -1, 16 | editProjectVisible: false, 17 | deleteVisible: false 18 | } 19 | } 20 | 21 | componentDidMount () { 22 | this.props.projectStore.fetchProject(this.props.projectId) 23 | } 24 | 25 | componentDidUpdate (prevProps) { 26 | if (!this.props.projectStore.state.isFetching && 27 | (this.props.projectId !== prevProps.projectId)) { 28 | this.props.projectStore.fetchProject(this.props.projectId) 29 | } 30 | } 31 | 32 | async deleteProject () { 33 | this.setState({ deleteVisible: false }) 34 | 35 | const currentProject = this.props.projectStore.state.currentProject 36 | const id = this.props.projectId 37 | const name = currentProject && currentProject.name ? currentProject.name : id 38 | 39 | const ok = await this.props.projectStore.deleteProject(id) 40 | if (ok) { 41 | toaster.success(`Project ${name} deleted.`) 42 | this.props.history.push('/projects') 43 | } 44 | } 45 | 46 | render () { 47 | const { state: { currentProject } } = this.props.projectStore 48 | 49 | if (!currentProject) { 50 | return () 51 | } 52 | 53 | return ( 54 | 55 | 56 | 57 | 58 | {toUpper(currentProject.name)} 59 | {this.state.editProjectVisible 60 | ? this.setState({ editProjectVisible: false })} 65 | /> : null} 66 | this.setState({ editProjectVisible: !this.state.editProjectVisible })} 68 | marginLeft={14} 69 | iconBefore='edit' 70 | appearance='minimal' 71 | intent='none' 72 | >EDIT 73 | 74 | 75 | 76 | {this.state.deleteVisible 77 | ? this.deleteProject()} 82 | onCancel={() => this.setState({ deleteVisible: !this.state.deleteVisible })} 83 | /> : null} 84 | this.setState({ deleteVisible: !this.state.deleteVisible })} 89 | >DELETE 90 | 91 | 92 | 93 | {currentProject.description} 94 | 95 | ) 96 | } 97 | } 98 | 99 | export default withRouter(ProjectDetailPane) 100 | -------------------------------------------------------------------------------- /web/ui/src/components/ProjectListPage.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Pane } from 'evergreen-ui' 3 | import { Provider, Subscribe } from 'unstated' 4 | 5 | import ProjectList from './ProjectList' 6 | 7 | import ProjectContainer from '../containers/ProjectContainer' 8 | 9 | export default class ProjectListPage extends Component { 10 | render () { 11 | return ( 12 | 13 | 14 | {(projectStore) => ( 15 | 16 | 17 | 18 | )} 19 | 20 | 21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /web/ui/src/components/ReportDetailPage.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Pane } from 'evergreen-ui' 3 | import { Provider, Subscribe } from 'unstated' 4 | 5 | import ReportDetailPane from './ReportDetailPane' 6 | 7 | import CompareContainer from '../containers/CompareContainer' 8 | import ReportContainer from '../containers/ReportContainer' 9 | 10 | export default class ReportDetailPage extends Component { 11 | render () { 12 | return ( 13 | 14 | 15 | {(compareStore, reportStore) => ( 16 | 17 | 18 | 23 | 24 | 25 | )} 26 | 27 | 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /web/ui/src/components/ReportDistChart.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Pane } from 'evergreen-ui' 3 | import { Doughnut } from 'react-chartjs-2' 4 | 5 | import { 6 | createDoughnutChart 7 | } from '../lib/doughnutData' 8 | 9 | export default class ReportDist extends Component { 10 | constructor (props) { 11 | super(props) 12 | 13 | this.config = null 14 | 15 | this.state = { 16 | config: this.config 17 | } 18 | } 19 | 20 | componentDidMount () { 21 | this.config = createDoughnutChart( 22 | this.props.label, 23 | this.props.report[this.props.dataMapKey] 24 | ) 25 | 26 | this.setState({ 27 | config: this.config 28 | }) 29 | } 30 | 31 | componentDidUpdate (prevProps) { 32 | if (!this.config || 33 | (prevProps.report.id !== this.props.report.id)) { 34 | this.config = createDoughnutChart( 35 | this.props.label, 36 | this.props.report[this.props.dataMapKey] 37 | ) 38 | } 39 | } 40 | 41 | render () { 42 | const config = this.state.config || this.config 43 | 44 | if (!config) { 45 | return ( 46 | 47 | ) 48 | } 49 | 50 | return ( 51 | 52 | 53 | 54 | ) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /web/ui/src/components/ReportPage.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Pane } from 'evergreen-ui' 3 | import { Provider, Subscribe } from 'unstated' 4 | 5 | import ReportList from './ReportList' 6 | 7 | import ReportContainer from '../containers/ReportContainer' 8 | 9 | export default class ReportsPage extends Component { 10 | render () { 11 | return ( 12 | 13 | 14 | {(reportStore) => ( 15 | 16 | 17 | 18 | )} 19 | 20 | 21 | ) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /web/ui/src/components/ReportsOverTimePane.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Heading, Pane } from 'evergreen-ui' 3 | 4 | import HistoryChart from './HistoryChart' 5 | 6 | export default class ReportsOverTimePane extends Component { 7 | constructor (props) { 8 | super(props) 9 | 10 | this.state = { 11 | projectId: props.projectId || 0, 12 | reports: this.props.reportStore.state.reports 13 | } 14 | } 15 | 16 | async componentDidMount () { 17 | await this.props.reportStore.fetchReports('desc', 'date', 0, this.state.projectId) 18 | } 19 | 20 | async componentDidUpdate (prevProps) { 21 | if (prevProps.projectId === this.props.projectId) { 22 | const currentList = this.props.reportStore.state.reports 23 | const prevList = prevProps.reportStore.state.reports 24 | 25 | if (!this.props.reportStore.state.isFetching) { 26 | if ((currentList.length === 0 && prevList.length > 0) || (prevList.length > currentList.length)) { 27 | await this.props.reportStore.fetchReports( 28 | 'desc', 'date', 0, this.props.projectId 29 | ) 30 | } 31 | } 32 | } 33 | } 34 | 35 | render () { 36 | const reports = this.props.reportStore.state.reports 37 | const hasReports = reports && reports.length > 0 38 | 39 | if (!hasReports) { 40 | return () 41 | } 42 | 43 | return ( 44 | 45 | 46 | HISTORY 47 | 48 | 49 | 53 | 54 | 55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /web/ui/src/components/StatusBadge.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Badge } from 'evergreen-ui' 3 | 4 | const StatusBadge = ({ status, ...props }) => { 5 | if (status && status.toLowerCase() === 'ok') { 6 | return ( 7 | OK 8 | ) 9 | } 10 | 11 | return ( 12 | fail 13 | ) 14 | } 15 | 16 | export default StatusBadge 17 | -------------------------------------------------------------------------------- /web/ui/src/containers/CompareContainer.js: -------------------------------------------------------------------------------- 1 | import { Container } from 'unstated' 2 | import ky from 'ky' 3 | import { toaster } from 'evergreen-ui' 4 | 5 | import { getAppRoot } from '../lib/common' 6 | 7 | const api = ky.extend({ prefixUrl: getAppRoot() + '/api/reports/' }) 8 | 9 | export default class CompareContainer extends Container { 10 | constructor (props) { 11 | super(props) 12 | this.state = { 13 | report1: null, 14 | report2: null, 15 | isFetching: false 16 | } 17 | } 18 | 19 | async fetchReports (reportId1, reportId2) { 20 | this.setState({ 21 | isFetching: true 22 | }) 23 | 24 | let report1 = null 25 | let report2 = null 26 | 27 | try { 28 | report1 = await api.get(`${reportId1}`).json() 29 | } catch (err) { 30 | toaster.danger(err.message) 31 | console.log('error: ', err) 32 | return 33 | } 34 | 35 | try { 36 | if (reportId2.toLowerCase() === 'previous') { 37 | report2 = await api.get(`${reportId1}/previous`).json() 38 | } else { 39 | report2 = await api.get(`${reportId2}`).json() 40 | } 41 | } catch (err) { 42 | console.log('error: ', err) 43 | } 44 | 45 | this.setState({ 46 | report1, 47 | report2, 48 | isFetching: false 49 | }) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /web/ui/src/containers/HistogramContainer.js: -------------------------------------------------------------------------------- 1 | import { Container } from 'unstated' 2 | import ky from 'ky' 3 | import { toaster } from 'evergreen-ui' 4 | 5 | import { getAppRoot } from '../lib/common' 6 | 7 | const api = ky.extend({ prefixUrl: getAppRoot() + '/api/reports/' }) 8 | 9 | export default class HistogramContainer extends Container { 10 | constructor (props) { 11 | super(props) 12 | this.state = { 13 | histogram: 0, 14 | isFetching: false 15 | } 16 | } 17 | 18 | async fetchHistogram (reportId) { 19 | this.setState({ 20 | isFetching: true 21 | }) 22 | 23 | try { 24 | const { buckets } = await api.get(`${reportId}/histogram`).json() 25 | 26 | this.setState({ 27 | histogram: buckets, 28 | isFetching: false 29 | }) 30 | } catch (err) { 31 | toaster.danger(err.message) 32 | console.log('error: ', err) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /web/ui/src/containers/InfoContainer.js: -------------------------------------------------------------------------------- 1 | import { Container } from 'unstated' 2 | import ky from 'ky' 3 | import { toaster } from 'evergreen-ui' 4 | 5 | import { getAppRoot } from '../lib/common' 6 | 7 | const api = ky.extend({ prefixUrl: getAppRoot() + '/api/' }) 8 | 9 | export default class InfoContainer extends Container { 10 | constructor (props) { 11 | super(props) 12 | 13 | this.state = { 14 | info: null, 15 | isFetching: false 16 | } 17 | } 18 | 19 | async fetchInfo () { 20 | this.setState({ 21 | isFetching: true 22 | }) 23 | 24 | try { 25 | const info = await api.get('info').json() 26 | 27 | this.setState({ 28 | info, 29 | isFetching: false 30 | }) 31 | } catch (err) { 32 | toaster.danger(err.message) 33 | console.log('error: ', err) 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /web/ui/src/containers/OptionsContainer.js: -------------------------------------------------------------------------------- 1 | import { Container } from 'unstated' 2 | import ky from 'ky' 3 | import { toaster } from 'evergreen-ui' 4 | 5 | import { getAppRoot } from '../lib/common' 6 | 7 | const api = ky.extend({ prefixUrl: getAppRoot() + '/api/reports/' }) 8 | 9 | export default class OptionsContainer extends Container { 10 | constructor (props) { 11 | super(props) 12 | this.state = { 13 | options: 0, 14 | isFetching: false 15 | } 16 | } 17 | 18 | async fetchOptions (reportId) { 19 | this.setState({ 20 | isFetching: true 21 | }) 22 | 23 | try { 24 | const { info } = await api.get(`${reportId}/options`).json() 25 | 26 | this.setState({ 27 | options: info, 28 | isFetching: false 29 | }) 30 | } catch (err) { 31 | toaster.danger(err.message) 32 | console.log('error: ', err) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /web/ui/src/containers/ProjectContainer.js: -------------------------------------------------------------------------------- 1 | import { Container } from 'unstated' 2 | import ky from 'ky' 3 | import _ from 'lodash' 4 | import { toaster } from 'evergreen-ui' 5 | 6 | import { getAppRoot } from '../lib/common' 7 | 8 | const api = ky.extend({ prefixUrl: getAppRoot() + '/api/' }) 9 | 10 | export default class ProjectContainer extends Container { 11 | constructor (props) { 12 | super(props) 13 | this.state = { 14 | totalProjects: 0, 15 | projects: [], 16 | isFetching: false, 17 | currentProject: {} 18 | } 19 | } 20 | 21 | async fetchProjects (order = 'desc', page = 0) { 22 | this.setState({ 23 | isFetching: true 24 | }) 25 | 26 | const searchParams = new URLSearchParams() 27 | 28 | if (order) { 29 | searchParams.append('order', order) 30 | searchParams.append('page', page) 31 | } 32 | 33 | try { 34 | const { data, total } = await api.get('projects', { searchParams }).json() 35 | 36 | this.setState({ 37 | totalProjects: total, 38 | projects: data, 39 | isFetching: false 40 | }) 41 | } catch (err) { 42 | toaster.danger(err.message) 43 | console.log('error: ', err) 44 | } 45 | } 46 | 47 | async createProject (name, description) { 48 | this.setState({ 49 | isFetching: true 50 | }) 51 | 52 | try { 53 | const newProject = await api.post('projects', { json: { name, description } }).json() 54 | this.setState({ 55 | projects: [newProject, ...this.state.projects], 56 | totalProjects: this.state.totalProjects + 1, 57 | isFetching: false 58 | }) 59 | } catch (err) { 60 | toaster.danger(err.message) 61 | console.log('error: ', err) 62 | } 63 | } 64 | 65 | async updateProject (id, name, description) { 66 | this.setState({ 67 | isFetching: true 68 | }) 69 | 70 | try { 71 | const newProject = await api.put(`projects/${id}`, { json: { name, description } }).json() 72 | 73 | const index = _.findIndex(this.state.projects, p => p.id.toString() === id.toString()) 74 | 75 | let projects = this.state.projects 76 | if (index >= 0) { 77 | projects[index] = newProject 78 | } 79 | 80 | this.setState({ 81 | projects, 82 | totalProjects: this.state.totalProjects + 1, 83 | isFetching: false, 84 | currentProject: newProject 85 | }) 86 | } catch (err) { 87 | toaster.danger(err.message) 88 | console.log('error: ', err) 89 | } 90 | } 91 | 92 | async fetchProject (id) { 93 | this.setState({ 94 | isFetching: true 95 | }) 96 | 97 | try { 98 | const project = await api.get(`projects/${id}`).json() 99 | this.setState({ 100 | currentProject: project, 101 | isFetching: false 102 | }) 103 | } catch (err) { 104 | toaster.danger(err.message) 105 | console.log('error: ', err) 106 | } 107 | } 108 | 109 | async deleteProject (id) { 110 | this.setState({ 111 | isFetching: true 112 | }) 113 | 114 | try { 115 | await api.delete(`projects/${id}`).json() 116 | this.setState({ 117 | isFetching: false 118 | }) 119 | 120 | return true 121 | } catch (err) { 122 | toaster.danger(err.message) 123 | console.log('error: ', err) 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /web/ui/src/containers/ReportContainer.js: -------------------------------------------------------------------------------- 1 | import { Container } from 'unstated' 2 | import ky from 'ky' 3 | import { toaster } from 'evergreen-ui' 4 | 5 | import { getAppRoot } from '../lib/common' 6 | 7 | const api = ky.extend({ prefixUrl: getAppRoot() + '/api/' }) 8 | 9 | export default class ReportContainer extends Container { 10 | constructor (props) { 11 | super(props) 12 | this.state = { 13 | total: 0, 14 | reports: [], 15 | currentReport: {}, 16 | isFetching: false 17 | } 18 | } 19 | 20 | async fetchReports (order = 'desc', sort = 'date', page = 0, projectId = 0) { 21 | this.setState({ 22 | isFetching: true 23 | }) 24 | 25 | const searchParams = new URLSearchParams() 26 | 27 | if (order) { 28 | searchParams.append('order', order) 29 | searchParams.append('sort', sort) 30 | searchParams.append('page', page) 31 | } 32 | 33 | try { 34 | let data 35 | let total 36 | if (!projectId) { 37 | const res = await api.get('reports', { searchParams }).json() 38 | data = res.data 39 | total = res.total 40 | } else { 41 | const res = await api.get(`projects/${projectId}/reports`, { searchParams }).json() 42 | data = res.data 43 | total = res.total 44 | } 45 | 46 | this.setState({ 47 | total, 48 | reports: data, 49 | isFetching: false 50 | }) 51 | } catch (err) { 52 | toaster.danger(err.message) 53 | console.log('error: ', err) 54 | } 55 | } 56 | 57 | async fetchReport (id) { 58 | this.setState({ 59 | isFetching: true 60 | }) 61 | 62 | try { 63 | const r = await api.get(`reports/${id}`).json() 64 | this.setState({ 65 | currentReport: r, 66 | isFetching: false 67 | }) 68 | } catch (err) { 69 | toaster.danger(err.message) 70 | console.log('error: ', err) 71 | } 72 | } 73 | 74 | async deleteReport (id) { 75 | this.setState({ 76 | isFetching: true 77 | }) 78 | 79 | try { 80 | await api.delete(`reports/${id}`).json() 81 | this.setState({ 82 | isFetching: false 83 | }) 84 | 85 | return true 86 | } catch (err) { 87 | toaster.danger(err.message) 88 | console.log('error: ', err) 89 | } 90 | } 91 | 92 | async deleteReports (ids) { 93 | this.setState({ 94 | isFetching: true 95 | }) 96 | 97 | try { 98 | const res = await api.post('reports/bulk_delete', { json: { ids } }).json() 99 | this.setState({ 100 | isFetching: false 101 | }) 102 | 103 | return res 104 | } catch (err) { 105 | toaster.danger(err.message) 106 | console.log('error: ', err) 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /web/ui/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ghz 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /web/ui/src/lib/colors.js: -------------------------------------------------------------------------------- 1 | import { getRandomInt } from '../lib/common' 2 | 3 | const colors = { 4 | red: 'rgb(255, 0, 0)', 5 | orange: 'rgb(255, 128, 0)', 6 | yellow: 'rgb(255, 255, 0)', 7 | green: 'rgb(0, 151, 0)', 8 | blue: 'rgb(0, 128, 255)', 9 | purple: 'rgb(127, 0, 255)', 10 | grey: 'rgb(201, 203, 207)', 11 | teal: 'rgb(75, 192, 192)', 12 | // red: 'rgb(255, 99, 132)', 13 | darkYellow: 'rgb(255, 205, 86)', 14 | skyBlue: 'rgb(54, 162, 235)', 15 | lightPurple: 'rgb(153, 102, 255)', 16 | 17 | } 18 | 19 | function randomColor () { 20 | const r = getRandomInt(255) 21 | const g = getRandomInt(255) 22 | const b = getRandomInt(255) 23 | 24 | return `rgb(${r}, ${g}, ${b})` 25 | } 26 | 27 | module.exports = { 28 | colors, 29 | randomColor 30 | } 31 | -------------------------------------------------------------------------------- /web/ui/src/lib/common.js: -------------------------------------------------------------------------------- 1 | const Order = { 2 | NONE: 'NONE', 3 | ASC: 'ASC', 4 | DESC: 'DESC' 5 | } 6 | 7 | function getIconForOrder (order) { 8 | switch (order) { 9 | case Order.ASC: 10 | return 'sort-asc' 11 | case Order.DESC: 12 | return 'sort-desc' 13 | default: 14 | return 'sort-desc' 15 | } 16 | } 17 | 18 | function getIconForStatus (status) { 19 | switch (status) { 20 | case 'OK': 21 | case 'ok': 22 | return 'tick-circle' 23 | default: 24 | return 'error' 25 | } 26 | } 27 | 28 | function getColorForStatus (status) { 29 | switch (status) { 30 | case 'OK': 31 | case 'ok': 32 | return 'success' 33 | default: 34 | return 'danger' 35 | } 36 | } 37 | 38 | function formatFloat (val, fixed) { 39 | if (!Number.isInteger(fixed)) { 40 | fixed = 2 41 | } 42 | 43 | return Number.parseFloat(val).toFixed(fixed) 44 | } 45 | 46 | function formatNano (val) { 47 | return Number.parseFloat(val / 1000000).toFixed(2) 48 | } 49 | 50 | function formatDiv (val, div) { 51 | return Number.parseFloat(val / div).toFixed(2) 52 | } 53 | 54 | function formatNanoUnit (val) { 55 | let v = Number.parseFloat(val) 56 | if (Math.abs(v) < 10000) { 57 | return `${v} ns` 58 | } 59 | 60 | let valMs = v / 1000000 61 | if (Math.abs(valMs) < 1000) { 62 | valMs = valMs.toFixed(2) 63 | return `${valMs} ms` 64 | } 65 | 66 | return Number.parseFloat(valMs / 1000).toFixed(2) + ' s' 67 | } 68 | 69 | function pretty (value) { 70 | let v = value 71 | if (typeof v === 'string') { 72 | v = JSON.parse(value) 73 | } 74 | return JSON.stringify(v, null, 2) 75 | } 76 | 77 | function getRandomInt (max) { 78 | return Math.floor(Math.random() * Math.floor(max)) 79 | } 80 | 81 | function toLocaleString (date) { 82 | if (date instanceof Date) { 83 | return date.toLocaleString 84 | } 85 | 86 | const dateObj = new Date(date + '') 87 | return dateObj.toLocaleString() 88 | } 89 | 90 | function getAppRoot () { 91 | if (process.env.NODE_ENV !== 'production') { 92 | return 'http://localhost:3000' 93 | } 94 | 95 | return '' 96 | } 97 | 98 | module.exports = { 99 | getIconForOrder, 100 | getIconForStatus, 101 | getColorForStatus, 102 | Order, 103 | formatFloat, 104 | formatNano, 105 | formatNanoUnit, 106 | pretty, 107 | getRandomInt, 108 | toLocaleString, 109 | getAppRoot, 110 | formatDiv 111 | } 112 | -------------------------------------------------------------------------------- /web/ui/src/lib/compareBarChart.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import Chart from 'chart.js' 3 | 4 | import { formatDiv } from '../lib/common' 5 | 6 | export function createComparisonChart (report1, report2, color1, color2) { 7 | const color = Chart.helpers.color 8 | 9 | const report1Name = report1.name 10 | ? `${report1.name} [ID: ${report1.id}]` 11 | : `Report: ${report1.id}` 12 | 13 | const report2Name = report2.name 14 | ? `${report2.name} [ID: ${report2.id}]` 15 | : `Report: ${report2.id}` 16 | 17 | const report1Latencies = _.keyBy(report1.latencyDistribution, 'percentage') 18 | const report2Latencies = _.keyBy(report2.latencyDistribution, 'percentage') 19 | 20 | let unit = 'ns' 21 | let testValue = report1.average 22 | let divr = 1 23 | 24 | if (testValue > 1000000) { 25 | unit = 'ms' 26 | divr = 1000000 27 | testValue = testValue / divr 28 | } 29 | 30 | if (testValue > 1000000000) { 31 | unit = 's' 32 | divr = 1000000000 33 | } 34 | 35 | const chartData = { 36 | labels: ['Fastest', 'Average', 'Slowest', '10 %', '25 %', '50 %', '75 %', '90 %', '95 %', '99 %'], 37 | datasets: [{ 38 | label: report1Name, 39 | backgroundColor: color(color1) 40 | .alpha(0.5) 41 | .rgbString(), 42 | borderColor: color1, 43 | borderWidth: 1, 44 | data: [ 45 | formatDiv(report1.fastest, divr), 46 | formatDiv(report1.average, divr), 47 | formatDiv(report1.slowest, divr), 48 | formatDiv(report1Latencies['10'].latency, divr), 49 | formatDiv(report1Latencies['25'].latency, divr), 50 | formatDiv(report1Latencies['50'].latency, divr), 51 | formatDiv(report1Latencies['75'].latency, divr), 52 | formatDiv(report1Latencies['90'].latency, divr), 53 | formatDiv(report1Latencies['95'].latency, divr), 54 | formatDiv(report1Latencies['99'].latency, divr) 55 | ] 56 | }, { 57 | label: report2Name, 58 | backgroundColor: color(color2) 59 | .alpha(0.5) 60 | .rgbString(), 61 | borderColor: color2, 62 | borderWidth: 1, 63 | data: [ 64 | formatDiv(report2.fastest, divr), 65 | formatDiv(report2.average, divr), 66 | formatDiv(report2.slowest, divr), 67 | formatDiv(report2Latencies['10'].latency, divr), 68 | formatDiv(report2Latencies['25'].latency, divr), 69 | formatDiv(report2Latencies['50'].latency, divr), 70 | formatDiv(report2Latencies['75'].latency, divr), 71 | formatDiv(report2Latencies['90'].latency, divr), 72 | formatDiv(report2Latencies['95'].latency, divr), 73 | formatDiv(report2Latencies['99'].latency, divr) 74 | ] 75 | }] 76 | } 77 | 78 | const labelStr = `Latency (${unit})` 79 | 80 | const barOptions = { 81 | elements: { 82 | rectangle: { 83 | borderWidth: 2 84 | } 85 | }, 86 | responsive: true, 87 | legend: { 88 | display: false 89 | }, 90 | scales: { 91 | yAxes: [ 92 | { 93 | display: true, 94 | scaleLabel: { 95 | display: true, 96 | labelString: labelStr 97 | } 98 | } 99 | ] 100 | } 101 | } 102 | 103 | const barConfig = { 104 | data: chartData, 105 | options: barOptions 106 | } 107 | 108 | return barConfig 109 | } 110 | -------------------------------------------------------------------------------- /web/ui/src/lib/doughnutData.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | // import Chart from 'chart.js' 3 | 4 | import { colors, randomColor } from './colors' 5 | 6 | const staticColors = [ 7 | colors.red, 8 | colors.teal, 9 | colors.orange, 10 | colors.blue, 11 | colors.purple, 12 | colors.yellow 13 | ] 14 | 15 | export function createDoughnutChart (label, dataMap) { 16 | const dataArray = [] 17 | const backgroundColor = [] 18 | const labels = [] 19 | 20 | let counter = 0 21 | 22 | _.forEach(dataMap, (v, k) => { 23 | dataArray.push(v) 24 | 25 | if (k.indexOf('code =') > 0) { 26 | const start = k.indexOf('code =') + 'code ='.length 27 | let l = k.substring(start) 28 | if (l.indexOf('desc') > 0) { 29 | l = l.substring(0, l.indexOf('desc')) 30 | } 31 | labels.push(l) 32 | } else { 33 | labels.push(k) 34 | } 35 | 36 | if (k.toString().toLowerCase() === 'ok') { 37 | backgroundColor.push(colors.green) 38 | } else if (counter < staticColors.length) { 39 | backgroundColor.push(staticColors[counter]) 40 | } else { 41 | backgroundColor.push(randomColor()) 42 | } 43 | 44 | counter++ 45 | }) 46 | 47 | const data = { 48 | datasets: [{ 49 | data: dataArray, 50 | backgroundColor, 51 | label 52 | }], 53 | labels 54 | } 55 | 56 | const options = { 57 | legend: { 58 | position: 'bottom' 59 | } 60 | } 61 | 62 | const config = { 63 | data, 64 | options 65 | } 66 | 67 | return config 68 | } 69 | -------------------------------------------------------------------------------- /web/ui/src/lib/histogramData.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import Chart from 'chart.js' 3 | 4 | import { colors } from './colors' 5 | 6 | export function createHistogramChart (report) { 7 | const categories = _.map(report.histogram, h => { 8 | return Number.parseFloat(h.mark * 1000).toFixed(2) 9 | }) 10 | const series = _.map(report.histogram, 'count') 11 | const totalCount = report.count 12 | const color = Chart.helpers.color 13 | const barChartData = { 14 | labels: categories, 15 | datasets: [ 16 | { 17 | label: 'Count', 18 | backgroundColor: color(colors.skyBlue) 19 | .alpha(0.5) 20 | .rgbString(), 21 | borderColor: colors.skyBlue, 22 | borderWidth: 1, 23 | data: series 24 | } 25 | ] 26 | } 27 | const barOptions = { 28 | elements: { 29 | rectangle: { 30 | borderWidth: 2 31 | } 32 | }, 33 | responsive: true, 34 | legend: { 35 | display: false 36 | }, 37 | tooltips: { 38 | callbacks: { 39 | title: function (tooltipItem, data) { 40 | const value = Number.parseInt(tooltipItem[0].xLabel) 41 | const percent = value / totalCount * 100 42 | return value + ' ' + '(' + Number.parseFloat(percent).toFixed(1) + ' %)' 43 | } 44 | } 45 | }, 46 | scales: { 47 | xAxes: [ 48 | { 49 | display: true, 50 | scaleLabel: { 51 | display: true, 52 | labelString: 'Count' 53 | } 54 | } 55 | ], 56 | yAxes: [ 57 | { 58 | display: true, 59 | scaleLabel: { 60 | display: true, 61 | labelString: 'Latency (ms)' 62 | } 63 | } 64 | ] 65 | } 66 | } 67 | 68 | const barConfig = { 69 | type: 'horizontalBar', 70 | data: barChartData, 71 | options: barOptions 72 | } 73 | 74 | return barConfig 75 | } 76 | -------------------------------------------------------------------------------- /web/ui/src/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | 4 | import App from './App' 5 | 6 | render(, document.getElementById('app')) 7 | -------------------------------------------------------------------------------- /www/docs/calldata.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: calldata 3 | title: Call Data 4 | --- 5 | 6 | Data and metadata can specify [template actions](https://golang.org/pkg/text/template/) that will be parsed and evaluated at every request. Each request gets a new instance of the data. The available variables / actions are: 7 | 8 | ```go 9 | // CallData represents contextualized data available for templating 10 | type CallData struct { 11 | 12 | // unique worker ID 13 | WorkerID string 14 | 15 | // unique incremented request number for each call 16 | RequestNumber int64 17 | 18 | // fully-qualified name of the method call 19 | FullyQualifiedName string 20 | 21 | // shorter call method name 22 | MethodName string 23 | 24 | // the service name 25 | ServiceName string 26 | 27 | // name of the input message type 28 | InputName string 29 | 30 | // name of the output message type 31 | OutputName string 32 | 33 | // whether this call is client streaming 34 | IsClientStreaming bool 35 | 36 | // whether this call is server streaming 37 | IsServerStreaming bool 38 | 39 | // timestamp of the call in RFC3339 format 40 | Timestamp string 41 | 42 | // timestamp of the call as unix time in seconds 43 | TimestampUnix int64 44 | 45 | // timestamp of the call as unix time in milliseconds 46 | TimestampUnixMilli int64 47 | 48 | // timestamp of the call as unix time in nanoseconds 49 | TimestampUnixNano int64 50 | 51 | // UUID v4 for each call 52 | UUID string 53 | } 54 | ``` 55 | 56 | **Template Functions** 57 | 58 | There are also template functions available: 59 | 60 | `func newUUID() string` 61 | Generates a new UUID for each invocation. 62 | 63 | `func randomString(length int) string` 64 | Generates a new random string for each invocation. Accepts a length parameter. If the argument is `<= 0` then a random string is generated with a random length between length of `2` and `16`. 65 | 66 | `func randomInt(min, max int) int` 67 | Generates a new non-negative pseudo-random number in range `[min, max)`. 68 | 69 | You can also use [sprig functions](http://masterminds.github.io/sprig/) within a template. 70 | 71 | **Examples** 72 | 73 | This can be useful to inject variable information into the message data JSON or metadata JSON payloads for each request, such as timestamp or unique request number. For example: 74 | 75 | ```sh 76 | -m '{"request-id":"{{.RequestNumber}}", "timestamp":"{{.TimestampUnix}}"}' 77 | ``` 78 | 79 | Would result in server getting the following metadata map represented here in JSON: 80 | 81 | ```json 82 | { 83 | "user-agent": "grpc-go/1.11.1", 84 | "request-id": "1", 85 | "timestamp": "1544890252" 86 | } 87 | ``` 88 | 89 | ```sh 90 | -d '{"order_id":"{{newUUID}}", "item_id":"{{newUUID}}", "sku":"{{randomString 8 }}", "product_name":"{{randomString 0}}"}' 91 | ``` 92 | 93 | Would result in data with JSON representation: 94 | 95 | ```json 96 | { 97 | "order_id": "3974e7b3-5946-4df5-bed3-8c3dc9a0be19", 98 | "item_id": "cd9c2604-cd9b-43a8-9cbb-d1ad26ca93a4", 99 | "sku": "HlFTAxcm", 100 | "product_name": "xg3NEC" 101 | } 102 | ``` 103 | 104 | See [example calls](examples.md) for some more usage examples. 105 | 106 | ### Data Function API 107 | 108 | When using the `ghz/runner` package programmatically, we can dynamically create data for each request using `WithBinaryDataFunc()` API: 109 | 110 | ```go 111 | func dataFunc(mtd *desc.MethodDescriptor, cd *runner.CallData) []byte { 112 | msg := &helloworld.HelloRequest{} 113 | msg.Name = cd.WorkerID 114 | binData, err := proto.Marshal(msg) 115 | return binData 116 | } 117 | 118 | report, err := runner.Run( 119 | "helloworld.Greeter.SayHello", 120 | "0.0.0.0:50051", 121 | runner.WithProtoFile("./testdata/greeter.proto", []string{}), 122 | runner.WithInsecure(true), 123 | runner.WithBinaryDataFunc(dataFunc), 124 | ) 125 | ``` 126 | 127 | ### Disabling 128 | 129 | Execution of call template functions can be done by setting `--disable-template-functions` to `true` or by using `WithDisableTemplateFuncs(true)`. 130 | 131 | Execution of call data template can be disabled completely by setting `--disable-template-data` to `true` or by using `WithDisableTemplateData(true)`. 132 | -------------------------------------------------------------------------------- /www/docs/example_config.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: example_config 3 | title: Configuration Files 4 | --- 5 | 6 | All the call options can be specified in JSON or TOML config files and used as input via the `--config` option. 7 | 8 | An example JSON config file: 9 | 10 | ```json 11 | { 12 | "proto": "/path/to/greeter.proto", 13 | "call": "helloworld.Greeter.SayHello", 14 | "total": 2000, 15 | "concurrency": 50, 16 | "data": { 17 | "name": "Joe" 18 | }, 19 | "metadata": { 20 | "foo": "bar", 21 | "trace_id": "{{.RequestNumber}}", 22 | "timestamp": "{{.TimestampUnix}}" 23 | }, 24 | "import-paths": [ 25 | "/path/to/protos" 26 | ], 27 | "max-duration": "10s", 28 | "host": "0.0.0.0:50051" 29 | } 30 | ``` 31 | 32 | An example TOML config file: 33 | 34 | ```toml 35 | "max-duration" = "7s" 36 | total = 5000 37 | concurrency = 50 38 | proto = "../../testdata/greeter.proto" 39 | call = "helloworld.Greeter.SayHello" 40 | host = "0.0.0.0:50051" 41 | insecure = true 42 | output = "pretty.json" 43 | format = "pretty" 44 | 45 | [data] 46 | name = "Bob {{.TimestampUnix}}" 47 | 48 | [metadata] 49 | rn = "{{.RequestNumber}}" 50 | ``` 51 | -------------------------------------------------------------------------------- /www/docs/extras.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: extras 3 | title: Extras 4 | --- 5 | 6 | ## Grafana dashboards 7 | 8 | For convenience we include prebuilt [Grafana](http://grafana.com/) dashboards for [summary](/extras/influx-summary-grafana-dashboard.json) and [details](/extras/influx-details-grafana-dashboard.json). 9 | 10 | #### Summary Grafana Dashboard 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | #### Details Grafana Dashboard: 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ## Prototool 27 | 28 | `ghz` can be used with [Prototool](https://github.com/uber/prototool) using the [`descriptor-set`](https://github.com/uber/prototool/tree/dev/docs#prototool-descriptor-set) command: 29 | 30 | ``` 31 | ghz --protoset $(prototool descriptor-set --include-imports --tmp) ... 32 | ``` 33 | -------------------------------------------------------------------------------- /www/docs/install.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: install 3 | title: Installation 4 | --- 5 | 6 | ## Download 7 | 8 | 1. Download a prebuilt executable binary for your operating system from the [GitHub releases page](https://github.com/bojand/ghz/releases). 9 | 2. Unzip the archive and place the executable binary wherever you would like to run it from. Additionally consider adding the location directory in the `PATH` variable if you would like the `ghz` command to be available everywhere. 10 | 11 | ## Homebrew 12 | 13 | ```sh 14 | brew install ghz 15 | ``` 16 | 17 | ## Compile 18 | 19 | **Clone** 20 | 21 | ```sh 22 | git clone https://github.com/bojand/ghz 23 | ``` 24 | 25 | **Build using make** 26 | 27 | ```sh 28 | make build 29 | ``` 30 | 31 | **Build using go** 32 | 33 | ```sh 34 | cd cmd/ghz 35 | go build . 36 | ``` 37 | -------------------------------------------------------------------------------- /www/docs/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: intro 3 | title: Introduction 4 | --- 5 | 6 | [](https://github.com/bojand/ghz/releases/latest) 7 |  8 | [](https://goreportcard.com/report/github.com/bojand/ghz) 9 | [](https://raw.githubusercontent.com/bojand/ghz/master/LICENSE) 10 | [](https://www.paypal.me/bojandj) 11 | [](https://www.buymeacoffee.com/bojand) 12 | 13 | `ghz` is a command line utility and [Go](http://golang.org/) package for load testing and benchmarking [gRPC](http://grpc.io) services. It is intended to be used for testing and debugging services locally, and in automated continous intergration environments for performance regression testing. 14 | 15 | Additionally the core of the command line tool is implemented as a Go library package that can be used to programatically implement performance tests as well. 16 | -------------------------------------------------------------------------------- /www/docs/package.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: package 3 | title: Package 4 | --- 5 | 6 | `ghz` can be used programmatically as Go package within Go applications. See detailed [godoc](https://godoc.org/github.com/bojand/ghz) documentation. Example usage: 7 | 8 | 9 | ```go 10 | package main 11 | 12 | import ( 13 | "fmt" 14 | "os" 15 | 16 | "github.com/bojand/ghz/printer" 17 | "github.com/bojand/ghz/runner" 18 | ) 19 | 20 | func main() { 21 | report, err := runner.Run( 22 | "helloworld.Greeter.SayHello", 23 | "localhost:50051", 24 | runner.WithProtoFile("greeter.proto", []string{}), 25 | runner.WithDataFromFile("data.json"), 26 | runner.WithInsecure(true), 27 | ) 28 | 29 | if err != nil { 30 | fmt.Println(err.Error()) 31 | os.Exit(1) 32 | } 33 | 34 | printer := printer.ReportPrinter{ 35 | Out: os.Stdout, 36 | Report: report, 37 | } 38 | 39 | printer.Print("pretty") 40 | } 41 | ``` 42 | -------------------------------------------------------------------------------- /www/docs/web/api.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: api 3 | title: API 4 | --- 5 | 6 | Reports are created in the system using the **Ingest API**. There are two endpoints for ingesting **raw JSON report** data into the system: 7 | 8 | ```sh 9 | POST /api/ingest 10 | ``` 11 | 12 | This endpoint automatically creates a new project, ingests a report and assigns it to the project. 13 | 14 | ```sh 15 | POST /api/projects/:id/ingest 16 | ``` 17 | 18 | Alternatively we can manually create a project ahead of time and then ingest reports specifically for an existing project using this endoint. 19 | 20 | ### Example 21 | 22 | ```sh 23 | ghz -insecure \ 24 | -proto ./greeter.proto \ 25 | -call helloworld.Greeter.SayHello \ 26 | -d '{"name": "Bob"}' \ 27 | -tags '{"env": "staging", "created by":"Joe Developer"}' \ 28 | -name 'Greeter SayHello' \ 29 | -O json \ 30 | 0.0.0.0:50051 | http POST localhost:3000/api/projects/34/ingest 31 | ``` 32 | -------------------------------------------------------------------------------- /www/docs/web/config.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: config 3 | title: Configuration 4 | --- 5 | 6 | `ghz-web` can be configured using environment variables or a configuration file. 7 | 8 | ## Environment Variables 9 | 10 | - `GHZ_SERVER_PORT` - The port for the http server. Default is `80`. 11 | - `GHZ_DATABASE_TYPE` - The SQL database dialect / type. Default is `sqlite3`. 12 | - `GHZ_DATABASE_CONNECTION` - The SQL database connection string. Default is `data/ghz.db`. 13 | - `GHZ_LOG_LEVEL` - The log level. One of `debug`, `info`, `warn`, or `error`. Default is `info`. 14 | - `GHZ_LOG_PATH` - By default the logs go to `stdout`. This option can be used to set the log path for a log file. 15 | 16 | ## Configuration File 17 | 18 | A cofiguration file can be specified using `--config` option. Configuration file can be in YAML, TOML or JSON format. 19 | 20 | **YAML** 21 | 22 | ```yaml 23 | --- 24 | server: 25 | port: 3000 # the port for the http server 26 | database: # the database options 27 | type: sqlite3 28 | connection: data/ghz.db 29 | log: 30 | level: info 31 | path: /tmp/ghz.log # the path to log file, otherwize stdout is used 32 | ``` 33 | 34 | **TOML** 35 | 36 | ```toml 37 | [server] 38 | port = 80 # the port for the http server 39 | 40 | [database] # the database options 41 | type = "sqlite3" 42 | connection = "data/ghz.db" 43 | 44 | [log] 45 | level = "info" # log level 46 | path = "/tmp/ghz.log" # the path to log file, otherwize stdout is used 47 | ``` 48 | 49 | **JSON** 50 | 51 | ```json 52 | { 53 | "server": { 54 | "port": 80 55 | }, 56 | "database": { 57 | "type": "sqlite3", 58 | "connection": "data/ghz.db" 59 | }, 60 | "log": { 61 | "level": "info", 62 | "path": "/tmp/ghz.log" 63 | } 64 | } 65 | 66 | ``` 67 | 68 | ## Database 69 | 70 | | Dialect | Connection | 71 | | :------: | :------------------------------------------------------------------: | 72 | | sqlite3 | `path/to/database.db` | 73 | | mysql | `dbuser:dbpassword@/ghz` | 74 | | postgres | `host=dbhost user=dbuser dbname=ghz sslmode=disable password=dbpassword` | 75 | 76 | When using postgres without SSL then `sslmode=disable` must be added to the connection string. 77 | When using mysql with host then `tcp(host)` must be added to the connection string like that `dbuser:dbpassword@tcp(dbhost)/ghz`. 78 | -------------------------------------------------------------------------------- /www/docs/web/data.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: data 3 | title: Data 4 | --- 5 | 6 | The main data components of the application are **Projects** and **Reports**. 7 | 8 | A Report represent a result of a single **ghz** test run. Reports can be grouped together into **Projects** to be tracked, analyzed and compared over time. Thus it is important to group reports together that make sense, normally tests against a single same gRPC call. 9 | 10 | 11 | For example if we are testing `helloworld.Greeter.SayHello` call, we may have a separate project for development, staging and production deployment environments for `helloworld.Greeter.SayHello` test only. For example our staging environment project may be called `"helloworld.Greeter.SayHello - staging"`. 12 | 13 | We would keep test results against this gRPC call for each deployment environment grouped together respectively and therefore be able to track change in performance over time and compare different environments as our service changes. 14 | 15 | ### Status 16 | 17 | Each Report and Project has a status associated with it. A Status can be either `OK` or `FAIL`. If the test result had any errors in it then its status will be `FAIL`. Similarly a projects status always reflects the status of the latest report created for it. 18 | -------------------------------------------------------------------------------- /www/docs/web/install.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: install 3 | title: Installation 4 | --- 5 | 6 | 1. Download a prebuilt executable binary for your operating system from the [GitHub releases page](https://github.com/bojand/ghz/releases). 7 | 2. Unzip the archive and place the `ghz-web` executable binary wherever you would like to run it from. 8 | 3. Configure using Envrionment variables or a configuration file. See [config](config) for more details. 9 | -------------------------------------------------------------------------------- /www/docs/web/intro.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: intro 3 | title: Introduction 4 | --- 5 | 6 | > ghz-web is in beta state. Please report any issues. 7 | 8 | **ghz-web** is a complementary server and a web application for storing, viewing and comparing `ghz` test results. 9 | 10 | The basic general idea is that `ghz` would be used to generate JSON report and the JSON report would be ingested using [curl](https://curl.haxx.se/) or [similar tool](https://httpie.org/) to save the results so that they can be stored, viewed, compared, and tracked over time. 11 | -------------------------------------------------------------------------------- /www/website/core/Footer.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) 2017-present, Facebook, Inc. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | const React = require('react'); 9 | 10 | class Footer extends React.Component { 11 | docUrl(doc, language) { 12 | const baseUrl = this.props.config.baseUrl; 13 | const docsUrl = this.props.config.docsUrl; 14 | const docsPart = `${docsUrl ? `${docsUrl}/` : ''}`; 15 | const langPart = `${language ? `${language}/` : ''}`; 16 | return `${baseUrl}${docsPart}${langPart}${doc}`; 17 | } 18 | 19 | pageUrl(doc, language) { 20 | const baseUrl = this.props.config.baseUrl; 21 | return baseUrl + (language ? `${language}/` : '') + doc; 22 | } 23 | 24 | render() { 25 | return ( 26 | 71 | ); 72 | } 73 | } 74 | 75 | module.exports = Footer; 76 | -------------------------------------------------------------------------------- /www/website/i18n/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "_comment": "This file is auto-generated by write-translations.js", 3 | "localized-strings": { 4 | "next": "Next", 5 | "previous": "Previous", 6 | "tagline": "gRPC benchmarking and load testing tool", 7 | "docs": { 8 | "calldata": { 9 | "title": "Call Template Data" 10 | }, 11 | "example_config": { 12 | "title": "Configuration Files" 13 | }, 14 | "examples": { 15 | "title": "Examples" 16 | }, 17 | "extras": { 18 | "title": "Extras" 19 | }, 20 | "install": { 21 | "title": "Installation" 22 | }, 23 | "intro": { 24 | "title": "Introduction" 25 | }, 26 | "options": { 27 | "title": "Options Reference" 28 | }, 29 | "output": { 30 | "title": "Output" 31 | }, 32 | "package": { 33 | "title": "Package" 34 | }, 35 | "usage": { 36 | "title": "Usage" 37 | }, 38 | "web/api": { 39 | "title": "API" 40 | }, 41 | "web/config": { 42 | "title": "Configuration" 43 | }, 44 | "web/data": { 45 | "title": "Data" 46 | }, 47 | "web/demo": { 48 | "title": "Demo" 49 | }, 50 | "web/install": { 51 | "title": "Installation" 52 | }, 53 | "web/intro": { 54 | "title": "Introduction" 55 | } 56 | }, 57 | "links": { 58 | "CLI": "CLI", 59 | "Web": "Web", 60 | "GoDoc": "GoDoc", 61 | "GitHub": "GitHub" 62 | }, 63 | "categories": { 64 | "Guide": "Guide", 65 | "Web Server": "Web Server" 66 | } 67 | }, 68 | "pages-strings": { 69 | "Help Translate|recruit community translators for your project": "Help Translate", 70 | "Edit this Doc|recruitment message asking to edit the doc source": "Edit", 71 | "Translate this Doc|recruitment message asking to translate the docs": "Translate" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /www/website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "examples": "docusaurus-examples", 4 | "start": "docusaurus-start", 5 | "build": "docusaurus-build", 6 | "publish-gh-pages": "docusaurus-publish", 7 | "write-translations": "docusaurus-write-translations", 8 | "version": "docusaurus-version", 9 | "rename-version": "docusaurus-rename-version" 10 | }, 11 | "devDependencies": { 12 | "docusaurus": "^1.14.4" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /www/website/sidebars.json: -------------------------------------------------------------------------------- 1 | { 2 | "docs": { 3 | "Guide": [ 4 | "intro", 5 | "install", 6 | "usage", 7 | "options", 8 | "load", 9 | "concurrency", 10 | "calldata", 11 | "examples", 12 | "example_config", 13 | "output", 14 | "extras", 15 | "package" 16 | ] 17 | }, 18 | "web": { 19 | "Web Server": [ 20 | "web/intro", 21 | "web/install", 22 | "web/config", 23 | "web/data", 24 | "web/api", 25 | "web/demo" 26 | ] 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /www/website/siteConfig.js: -------------------------------------------------------------------------------- 1 | // See https://docusaurus.io/docs/site-config for all the possible site configuration options. 2 | 3 | // List of projects/orgs using your project for the users page. 4 | const users = []; 5 | 6 | const siteConfig = { 7 | title: 'ghz', // Title for your website. 8 | tagline: 'gRPC benchmarking and load testing tool', 9 | url: 'https://ghz.sh', // Your website URL 10 | baseUrl: '/', // Base URL for your project */ 11 | 12 | // Used for publishing and more 13 | projectName: 'ghz', 14 | organizationName: 'bojand', 15 | // For top-level user or org sites, the organization is still the same. 16 | // e.g., for the https://JoelMarcey.github.io site, it would be set like... 17 | // organizationName: 'JoelMarcey' 18 | 19 | // For no header links in the top nav bar -> headerLinks: [], 20 | headerLinks: [ 21 | { doc: 'intro', label: 'CLI'}, 22 | { doc: 'web/intro', label: 'Web'}, 23 | { href: "https://godoc.org/github.com/bojand/ghz", label: "GoDoc" }, 24 | { href: "https://github.com/bojand/ghz", label: "GitHub" } 25 | ], 26 | 27 | // If you have users set above, you add it here: 28 | users, 29 | 30 | /* path to images for header/footer */ 31 | headerIcon: 'img/green_fwd2.svg', 32 | footerIcon: 'img/green_fwd2.svg', 33 | favicon: 'img/favicon.ico', 34 | 35 | /* Colors for website */ 36 | colors: { 37 | primaryColor: '#3CBCBC', 38 | secondaryColor: '#205C3B', 39 | }, 40 | 41 | // This copyright info is used in /core/Footer.js and blog RSS/Atom feeds. 42 | copyright: `Copyright © ${new Date().getFullYear()} Bojan D.`, 43 | 44 | highlight: { 45 | // Highlight.js theme to use for syntax highlighting in code blocks. 46 | theme: 'default', 47 | }, 48 | 49 | // Add custom scripts here that would be placed in
40 | {pretty(options)} 41 |