├── .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 | 76 |