Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions runner/data.go
Original file line number Diff line number Diff line change
Expand Up @@ -486,13 +486,13 @@ func (m *dynamicMessageProvider) GetStreamMessage(parentCallData *CallData) (*dy
msg := dynamic.NewMessage(md)
err = jsonpb.UnmarshalString(string(buf), msg)
if err != nil {
return nil, fmt.Errorf("Error creating message from data. Data: '%v' Error: %v", data, err.Error())
return nil, fmt.Errorf("error creating message from data. Data: '%v' Error: %v", data, err.Error())
}

m.counter++
m.indexCounter++

if err == nil && isLast {
if isLast {
err = ErrLastMessage
}

Expand Down
4 changes: 2 additions & 2 deletions runner/requester.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ func NewRequester(c *RunConfig) (*Requester, error) {
ctx, _ := context.WithTimeout(context.Background(), c.dialTimeout)

md := make(metadata.MD)
if c.rmd != nil && len(c.rmd) > 0 {
if len(c.rmd) > 0 {
md = metadata.New(c.rmd)
}

Expand Down Expand Up @@ -246,7 +246,7 @@ func (b *Requester) openClientConns() ([]*grpc.ClientConn, error) {
return b.conns, nil
}

for n := 0; n < b.config.nConns; n++ {
for n := len(b.conns); n < b.config.nConns; n++ {
c, err := b.newClientConn(true)
if err != nil {
if b.config.hasLog {
Expand Down
254 changes: 254 additions & 0 deletions web/api/run.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
package api

import (
"encoding/json"
"net/http"
"os"
"time"

"github.com/bojand/ghz/runner"
"github.com/bojand/ghz/web/model"
"github.com/labstack/echo"
)

// RunDatabase abstracts DB operations we need (reuse from ingest)
type RunDatabase interface {
IngestDatabase
}

// RunRequest defines the JSON body accepted by /api/run/ endpoints
// Minimal initial version; can be extended.
type RunRequest struct {
Call string `json:"call" validate:"required"` // fully qualified method e.g. helloworld.Greeter.SayHello
Host string `json:"host" validate:"required"` // target host:port
Concurrency uint `json:"concurrency" validate:"gt=0"` // -c
Total uint `json:"total" validate:"gt=0"` // -n total requests (exclusive with Duration)
DurationSec uint `json:"duration_sec" validate:"gte=0"` // run duration seconds (exclusive with Total)
DataJSON map[string]any `json:"data"` // simple JSON payload (one request)
Metadata map[string]string `json:"metadata"` // metadata headers
Tags map[string]string `json:"tags"` // tags into report
Insecure bool `json:"insecure"`
ProtoFile string `json:"proto_file"` // single proto content (raw text)
ProtoPath []string `json:"proto_path"` // import paths (not implemented yet)
Name string `json:"name"` // optional report name override
Async bool `json:"async"` // if true, run asynchronously and return job id
}

// RunResponse response after executing run and persisting like ingest
type RunResponse struct {
IngestResponse
}

// RunAPI executes a ghz run directly from UI request
// It converts RunRequest -> runner options -> runner.Report -> persist (as ingest)
// For now only simple JSON body and single proto file content are supported.
type RunAPI struct {
DB RunDatabase
Jobs *RunJobManager
}

// Run executes the test creating a new project implicitly
func (api *RunAPI) Run(ctx echo.Context) error {
rr := new(RunRequest)
if err := ctx.Bind(rr); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if ctx.Echo().Validator != nil {
if err := ctx.Validate(rr); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
}

// create project (same as ingest)
p := new(model.Project)
if err := api.DB.CreateProject(p); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return api.execute(rr, p, ctx)
}

// RunToProject executes test into existing project
func (api *RunAPI) RunToProject(ctx echo.Context) error {
var project *model.Project
var err error
if project, err = findProject(api.DB.FindProjectByID, ctx); err != nil {
return err
}
rr := new(RunRequest)
if err := ctx.Bind(rr); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if ctx.Echo().Validator != nil {
if err := ctx.Validate(rr); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
}
return api.execute(rr, project, ctx)
}

// GetJob returns async job status
func (api *RunAPI) GetJob(ctx echo.Context) error {
if api.Jobs == nil {
return echo.NewHTTPError(http.StatusNotFound, "jobs not enabled")
}
jid := ctx.Param("jid")
if jid == "" {
return echo.NewHTTPError(http.StatusBadRequest, "missing job id")
}
job, ok := api.Jobs.Get(jid)
if !ok {
return echo.NewHTTPError(http.StatusNotFound, "job not found")
}
return ctx.JSON(http.StatusOK, job)
}

func (api *RunAPI) execute(rr *RunRequest, p *model.Project, ctx echo.Context) error {
if rr.Async {
if api.Jobs == nil {
return echo.NewHTTPError(http.StatusInternalServerError, "job manager not configured")
}
job := api.Jobs.NewJob(p.ID)
api.Jobs.Start(job.ID)
go func(jobID string, rrCpy *RunRequest, proj *model.Project) {
resp, err := api.runAndPersist(rrCpy, proj)
if err != nil {
api.Jobs.Fail(jobID, err.Error())
} else {
api.Jobs.Succeed(jobID, resp)
}
}(job.ID, rr, p)
return ctx.JSON(http.StatusAccepted, map[string]any{"job_id": job.ID, "status": job.Status})
}

resp, err := api.runAndPersist(rr, p)
if err != nil {
if he, ok := err.(*echo.HTTPError); ok {
return he
}
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return ctx.JSON(http.StatusCreated, resp)
}

// runAndPersist executes the ghz run and persists report returning ingest-like response.
func (api *RunAPI) runAndPersist(rr *RunRequest, p *model.Project) (*IngestResponse, error) {
options := []runner.Option{}

if rr.Concurrency > 0 {
options = append(options, runner.WithConcurrency(rr.Concurrency))
}
if rr.Total > 0 && rr.DurationSec == 0 {
options = append(options, runner.WithTotalRequests(rr.Total))
}
if rr.DurationSec > 0 && rr.Total == 0 {
options = append(options, runner.WithRunDuration(time.Duration(rr.DurationSec)*time.Second))
}
if rr.Insecure {
options = append(options, runner.WithInsecure(true))
}
if rr.DataJSON != nil {
if b, err := json.Marshal(rr.DataJSON); err == nil {
options = append(options, runner.WithDataFromJSON(string(b)))
} else {
return nil, echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
}
if len(rr.Metadata) > 0 {
options = append(options, runner.WithMetadata(rr.Metadata))
}
if len(rr.Tags) > 0 {
options = append(options, runner.WithTags(rr.Tags))
}
if rr.ProtoFile != "" {
f, err := os.CreateTemp("", "ghz-proto-*.proto")
if err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
defer os.Remove(f.Name())
if _, err := f.WriteString(rr.ProtoFile); err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
f.Close()
options = append(options, runner.WithProtoFile(f.Name(), rr.ProtoPath))
}

rep, err := runner.Run(rr.Call, rr.Host, options...)
if err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
if rr.Name != "" {
rep.Name = rr.Name
}

ir := (*IngestRequest)(rep)
// persist
// reuse existing logic
// we cannot use ctx here (async case) so call persist helper directly but adapt to return response
ingestResp, err := api.persistRunToProjectReturn(p, ir)
if err != nil {
return nil, err
}
return ingestResp, nil
}

// helper like persistRunToProject but returns response instead of writing JSON
func (api *RunAPI) persistRunToProjectReturn(p *model.Project, ir *IngestRequest) (*IngestResponse, error) {
latest, _ := api.DB.FindLatestReportForProject(p.ID)

report := convertIngestToReport(p.ID, ir)
if err := api.DB.CreateReport(report); err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}

o := &model.Options{ReportID: report.ID}
opts := model.OptionsInfo(ir.Options)
o.Info = &opts
if err := api.DB.CreateOptions(o); err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}

h := &model.Histogram{ReportID: report.ID}
h.Buckets = make(model.BucketList, len(ir.Histogram))
for i := range ir.Histogram {
h.Buckets[i] = &ir.Histogram[i]
}
if err := api.DB.CreateHistogram(h); err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}

details := make([]*model.Detail, len(ir.Details))
for i, v := range ir.Details {
det := model.Detail{ReportID: report.ID, ResultDetail: v}
details[i] = &det
}
created, errored := api.DB.CreateDetailsBatch(report.ID, details)

if latest == nil || report.Date.After(latest.Date) {
if err := api.DB.UpdateProjectStatus(p.ID, report.Status); err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
p.Status = report.Status
}

rres := &IngestResponse{ // reuse structure
Project: p,
Report: report,
Options: o,
Histogram: h,
Details: &DetailsCreated{Success: created, Fail: errored},
}
return rres, nil
}

// persistRunToProject duplicates ingestToProject logic for run api to avoid export changes
// legacy synchronous path still uses old function name via execute->runAndPersist; keep wrapper if needed
func (api *RunAPI) persistRunToProject(p *model.Project, ir *IngestRequest, ctx echo.Context) error {
resp, err := api.persistRunToProjectReturn(p, ir)
if err != nil {
if he, ok := err.(*echo.HTTPError); ok {
return he
}
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return ctx.JSON(http.StatusCreated, resp)
}
83 changes: 83 additions & 0 deletions web/api/run_job.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package api

import (
"sync"
"time"
"github.com/google/uuid"
)

// RunJobStatus represents lifecycle state of a run job
type RunJobStatus string

const (
JobPending RunJobStatus = "PENDING"
JobRunning RunJobStatus = "RUNNING"
JobSucceeded RunJobStatus = "SUCCEEDED"
JobFailed RunJobStatus = "FAILED"
)

// RunJob holds async job info
type RunJob struct {
ID string `json:"id"`
ProjectID uint `json:"project_id"`
Status RunJobStatus `json:"status"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Result *IngestResponse `json:"result,omitempty"`
}

// RunJobManager manages async jobs in-memory
type RunJobManager struct {
mu sync.RWMutex
jobs map[string]*RunJob
}

func NewRunJobManager() *RunJobManager {
return &RunJobManager{jobs: map[string]*RunJob{}}
}

func (m *RunJobManager) NewJob(projectID uint) *RunJob {
m.mu.Lock()
defer m.mu.Unlock()
id := uuid.New().String()
j := &RunJob{ID: id, ProjectID: projectID, Status: JobPending, CreatedAt: time.Now(), UpdatedAt: time.Now()}
m.jobs[id] = j
return j
}

func (m *RunJobManager) Start(id string) {
m.mu.Lock()
if j, ok := m.jobs[id]; ok {
j.Status = JobRunning
j.UpdatedAt = time.Now()
}
m.mu.Unlock()
}

func (m *RunJobManager) Succeed(id string, res *IngestResponse) {
m.mu.Lock()
if j, ok := m.jobs[id]; ok {
j.Status = JobSucceeded
j.Result = res
j.UpdatedAt = time.Now()
}
m.mu.Unlock()
}

func (m *RunJobManager) Fail(id, errMsg string) {
m.mu.Lock()
if j, ok := m.jobs[id]; ok {
j.Status = JobFailed
j.Error = errMsg
j.UpdatedAt = time.Now()
}
m.mu.Unlock()
}

func (m *RunJobManager) Get(id string) (*RunJob, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
j, ok := m.jobs[id]
return j, ok
}
6 changes: 6 additions & 0 deletions web/router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,12 @@ func New(db *database.Database, appInfo *api.ApplicationInfo, conf *config.Confi
// Ingest to project
projectGroup.POST("/:pid/ingest/", ingestAPI.IngestToProject).Name = "ghz api: ingest to project"

// Run
runAPI := api.RunAPI{DB: db, Jobs: api.NewRunJobManager()}
apiRoot.POST("/run/", runAPI.Run).Name = "ghz api: run test"
projectGroup.POST("/:pid/run/", runAPI.RunToProject).Name = "ghz api: run test to project"
apiRoot.GET("/run/jobs/:jid/", runAPI.GetJob).Name = "ghz api: get run job"

// Info

infoAPI := api.InfoAPI{Info: *appInfo}
Expand Down
9 changes: 5 additions & 4 deletions web/router/statik/statik.go

Large diffs are not rendered by default.

Loading