├── .golangci.yaml ├── ui ├── webpack.config.js ├── package.json ├── src │ ├── index.js │ └── plain.js ├── html │ ├── index.html.tmpl │ └── job.html.tmpl ├── css │ └── styles.css └── package-lock.json ├── .pre-commit-config.yaml ├── Dockerfile ├── utils.go ├── .gitignore ├── example ├── goflow-example.go ├── docker-compose.yml └── goflow-example-postgres.go ├── .github ├── workflows │ ├── dependency-review.yml │ ├── go.yml │ ├── golangci-lint.yml │ └── deploy-image.yml └── dependabot.yml ├── utils_test.go ├── log.go ├── LICENSE ├── stream.go ├── dag_test.go ├── go.mod ├── task.go ├── operator.go ├── execution.go ├── operator_test.go ├── dag.go ├── example.go ├── goflow.go ├── job_test.go ├── job.go ├── routes.go ├── goflow_test.go ├── go.sum ├── swagger.json └── Readme.md /.golangci.yaml: -------------------------------------------------------------------------------- 1 | linters: 2 | disable: 3 | - errcheck 4 | -------------------------------------------------------------------------------- /ui/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | mode: "production", 3 | output: { 4 | filename: "./dist.js", 5 | library: "goflowUI" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/golangci/golangci-lint 3 | rev: v1.56.2 4 | hooks: 5 | - id: golangci-lint 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM centos 2 | 3 | ENV GIN_MODE=release 4 | 5 | WORKDIR /opt 6 | COPY goflow-example goflow-example 7 | COPY ui ui 8 | 9 | EXPOSE 8181 10 | 11 | CMD ["./goflow-example"] 12 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package goflow 2 | 3 | func equal(a, b []string) bool { 4 | if len(a) != len(b) { 5 | return false 6 | } 7 | for i, v := range a { 8 | if v != b[i] { 9 | return false 10 | } 11 | } 12 | return true 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Output of the go test coverage tool 2 | *.out 3 | 4 | # The goflow-example binary 5 | goflow-example 6 | 7 | # Front-end dependencies 8 | ui/node_modules 9 | 10 | # Front-end compiled assets 11 | ui/dist 12 | 13 | # The default goflow database 14 | goflow.db 15 | 16 | # The test database 17 | test.db 18 | .vscode -------------------------------------------------------------------------------- /example/goflow-example.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/fieldryand/goflow/v2" 5 | ) 6 | 7 | func main() { 8 | options := goflow.Options{ 9 | UIPath: "ui/", 10 | ShowExamples: true, 11 | WithSeconds: true, 12 | } 13 | gf := goflow.New(options) 14 | gf.Use(goflow.DefaultLogger()) 15 | gf.Run(":8181") 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: Dependency review 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | - name: Dependency Review 15 | uses: actions/dependency-review-action@v3.0.6 16 | 17 | 18 | -------------------------------------------------------------------------------- /example/docker-compose.yml: -------------------------------------------------------------------------------- 1 | # for testing goflow-example-postgres.go 2 | version: '3.1' 3 | 4 | services: 5 | 6 | db: 7 | image: postgres 8 | restart: always 9 | environment: 10 | POSTGRES_PASSWORD: example 11 | ports: 12 | - 5432:5432 13 | 14 | adminer: 15 | image: adminer 16 | restart: always 17 | ports: 18 | - 8080:8080 19 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "goflow-ui", 3 | "version": "1.0.0", 4 | "description": "", 5 | "private": true, 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "webpack" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "d3-color": "^3.1.0", 15 | "dagre-d3": "^0.6.4", 16 | "webpack": "^5.76.0", 17 | "webpack-cli": "^4.6.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v4 18 | with: 19 | go-version: '1.20' 20 | 21 | - name: Test 22 | run: go test ./... -coverprofile=coverage.txt -race 23 | 24 | - name: Codecov 25 | uses: codecov/codecov-action@v3 26 | -------------------------------------------------------------------------------- /utils_test.go: -------------------------------------------------------------------------------- 1 | package goflow 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | type test struct { 8 | a []string 9 | b []string 10 | out bool 11 | } 12 | 13 | var tests = []test{ 14 | {[]string{"a"}, []string{"a"}, true}, 15 | {[]string{"a"}, []string{"a", "b"}, false}, 16 | {[]string{"a"}, []string{"b"}, false}, 17 | } 18 | 19 | func TestEqual(t *testing.T) { 20 | for _, v := range tests { 21 | got := equal(v.a, v.b) 22 | if got != v.out { 23 | t.Errorf("Test failed: got %t, expected %t", got, v.out) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | 13 | -------------------------------------------------------------------------------- /log.go: -------------------------------------------------------------------------------- 1 | package goflow 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | // DefaultLogger returns the default logging middleware. 11 | func DefaultLogger() gin.HandlerFunc { 12 | return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { 13 | return fmt.Sprintf("%s [GOFLOW] - \"%s %s %s %d %s \"%s\" %s\"\n", 14 | param.TimeStamp.Format(time.RFC3339), 15 | param.Method, 16 | param.Path, 17 | param.Request.Proto, 18 | param.StatusCode, 19 | param.Latency, 20 | param.Request.UserAgent(), 21 | param.ErrorMessage, 22 | ) 23 | }) 24 | } 25 | 26 | type logWriter struct { 27 | } 28 | 29 | func (writer logWriter) Write(bytes []byte) (int, error) { 30 | return fmt.Print(time.Now().Format(time.RFC3339) + " [GOFLOW] - " + string(bytes)) 31 | } 32 | -------------------------------------------------------------------------------- /example/goflow-example-postgres.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | //import ( 4 | // "github.com/fieldryand/goflow/v2" 5 | // "github.com/philippgille/gokv/encoding" 6 | // "github.com/philippgille/gokv/postgresql" 7 | //) 8 | // 9 | //func main() { 10 | // storeOptions := postgresql.Options{ 11 | // ConnectionURL: "postgres://postgres:example@0.0.0.0:5432/postgres?sslmode=disable", 12 | // TableName: "Item", 13 | // MaxOpenConnections: 100, 14 | // Codec: encoding.JSON, 15 | // } 16 | // 17 | // client, err := postgresql.NewClient(storeOptions) 18 | // if err != nil { 19 | // panic(err) 20 | // } 21 | // defer client.Close() 22 | // 23 | // options := goflow.Options{ 24 | // Store: client, 25 | // Streaming: true, 26 | // ShowExamples: true, 27 | // WithSeconds: true, 28 | // } 29 | // gf := goflow.New(options) 30 | // gf.Use(goflow.DefaultLogger()) 31 | // gf.Run(":8181") 32 | //} 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ryan Field 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /stream.go: -------------------------------------------------------------------------------- 1 | package goflow 2 | 3 | import ( 4 | "io" 5 | "time" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | // Set keepOpen to false when testing--one event will be sent and 11 | // then the channel is closed by the server. 12 | func (g *Goflow) stream(keepOpen bool) func(*gin.Context) { 13 | 14 | return func(c *gin.Context) { 15 | job := c.Query("jobname") 16 | 17 | history := make([]*execution, 0) 18 | 19 | // periodically push the list of job runs into the stream 20 | c.Stream(func(w io.Writer) bool { 21 | for jobname := range g.Jobs { 22 | executions, _ := readExecutions(g.Store, jobname) 23 | for _, e := range executions { 24 | 25 | // make sure it wasn't already sent 26 | inHistory := false 27 | 28 | for _, h := range history { 29 | if e.ID == h.ID && e.ModifiedTimestamp == h.ModifiedTimestamp { 30 | inHistory = true 31 | } 32 | } 33 | 34 | if !inHistory { 35 | if (job != "" && job == e.JobName) || job == "" { 36 | c.SSEvent("message", e) 37 | history = append(history, e) 38 | } 39 | } 40 | 41 | } 42 | } 43 | 44 | time.Sleep(time.Second * 1) 45 | 46 | return keepOpen 47 | }) 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /dag_test.go: -------------------------------------------------------------------------------- 1 | package goflow 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestDag(t *testing.T) { 8 | d := make(dag) 9 | 10 | d.addNode("a") 11 | d.addNode("b") 12 | d.addNode("c") 13 | d.addNode("d") 14 | d.setDownstream("a", "b") 15 | d.setDownstream("a", "c") 16 | d.setDownstream("b", "d") 17 | d.setDownstream("c", "d") 18 | 19 | if !d.validate() { 20 | t.Errorf("Valid dag failed validation check") 21 | } 22 | 23 | if !equal(d.dependencies("b"), []string{"a"}) { 24 | t.Errorf("d.dependencies() returned %s, expected %s", 25 | d.dependencies("b"), 26 | []string{"a"}) 27 | } 28 | 29 | if !equal(d.independentNodes(), []string{"a"}) { 30 | t.Errorf("d.independentNodes() returned %s, expected %s", 31 | d.dependencies("b"), 32 | []string{"a"}) 33 | } 34 | 35 | e := make(dag) 36 | 37 | e.addNode("a") 38 | e.addNode("b") 39 | e.addNode("c") 40 | e.setDownstream("c", "a") 41 | e.setDownstream("a", "b") 42 | e.setDownstream("b", "a") 43 | 44 | if e.validate() { 45 | t.Errorf("Invalid dag passed validation check") 46 | } 47 | } 48 | 49 | func TestDagWithSingleNode(t *testing.T) { 50 | d := make(dag) 51 | d.addNode("a") 52 | res := d.isDownstream("a") 53 | 54 | if res { 55 | t.Errorf("isDownstream() returned true for an independent node") 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /ui/src/index.js: -------------------------------------------------------------------------------- 1 | var dagreD3 = require("dagre-d3"); 2 | var d3 = require("d3"); 3 | 4 | async function getDag(jobName) { 5 | const response = await fetch(`/api/jobs/${jobName}`); 6 | const json = await response.json(); 7 | return json.dag 8 | } 9 | 10 | export async function graphViz(jobName) { 11 | 12 | const dag = await getDag(jobName); 13 | 14 | // Create a new directed graph 15 | var g = new dagreD3.graphlib.Graph().setGraph({}); 16 | 17 | for (var key in dag) { 18 | g.setNode(key, { id: "node-" + key, label: key }); 19 | for (var val in dag[key]) { 20 | g.setEdge(key, dag[key][val], {}); 21 | } 22 | } 23 | 24 | var svg = d3.select("svg"); 25 | var inner = svg.select("g"); 26 | 27 | g.nodes().forEach(function(v) { 28 | var node = g.node(v); 29 | node.rx = node.ry = 5; 30 | }); 31 | 32 | // Set up zoom support 33 | var zoom = d3.zoom().on("zoom", function() { 34 | inner.attr("transform", d3.event.transform); 35 | }); 36 | svg.call(zoom); 37 | 38 | // Create the renderer 39 | var render = new dagreD3.render(); 40 | 41 | // Run the renderer. This is what draws the final graph. 42 | render(inner, g); 43 | 44 | // Center the graph 45 | var initialScale = 1.00; 46 | svg.call(zoom.transform, d3.zoomIdentity.translate(20, 20).scale(initialScale)); 47 | 48 | svg.attr('height', g.graph().height * initialScale + 40); 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | branches: 7 | - main 8 | pull_request: 9 | permissions: 10 | contents: read 11 | # Optional: allow read access to pull request. Use with `only-new-issues` option. 12 | # pull-requests: read 13 | jobs: 14 | golangci: 15 | name: lint 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: golangci-lint 20 | uses: golangci/golangci-lint-action@v2 21 | with: 22 | # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version 23 | version: latest 24 | 25 | # Optional: working directory, useful for monorepos 26 | # working-directory: somedir 27 | 28 | # Optional: golangci-lint command line arguments. 29 | # args: --issues-exit-code=0 30 | 31 | # Optional: show only new issues if it's a pull request. The default value is `false`. 32 | # only-new-issues: true 33 | 34 | # Optional: if set to true then the action will use pre-installed Go. 35 | # skip-go-installation: true 36 | 37 | # Optional: if set to true then the action don't cache or restore ~/go/pkg. 38 | # skip-pkg-cache: true 39 | 40 | # Optional: if set to true then the action don't cache or restore ~/.cache/go-build. 41 | # skip-build-cache: true 42 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/fieldryand/goflow/v2 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/ef-ds/deque v1.0.4 7 | github.com/gin-gonic/gin v1.9.1 8 | github.com/google/uuid v1.6.0 9 | github.com/philippgille/gokv v0.7.0 10 | github.com/philippgille/gokv/gomap v0.7.0 11 | github.com/robfig/cron/v3 v3.0.1 12 | ) 13 | 14 | require ( 15 | github.com/bytedance/sonic v1.9.1 // indirect 16 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect 17 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 18 | github.com/gin-contrib/sse v0.1.0 // indirect 19 | github.com/go-playground/locales v0.14.1 // indirect 20 | github.com/go-playground/universal-translator v0.18.1 // indirect 21 | github.com/go-playground/validator/v10 v10.14.0 // indirect 22 | github.com/goccy/go-json v0.10.2 // indirect 23 | github.com/json-iterator/go v1.1.12 // indirect 24 | github.com/klauspost/cpuid/v2 v2.2.4 // indirect 25 | github.com/leodido/go-urn v1.2.4 // indirect 26 | github.com/mattn/go-isatty v0.0.19 // indirect 27 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 28 | github.com/modern-go/reflect2 v1.0.2 // indirect 29 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect 30 | github.com/philippgille/gokv/encoding v0.7.0 // indirect 31 | github.com/philippgille/gokv/util v0.7.0 // indirect 32 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 33 | github.com/ugorji/go/codec v1.2.11 // indirect 34 | golang.org/x/arch v0.3.0 // indirect 35 | golang.org/x/crypto v0.21.0 // indirect 36 | golang.org/x/net v0.23.0 // indirect 37 | golang.org/x/sys v0.18.0 // indirect 38 | golang.org/x/text v0.14.0 // indirect 39 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect 40 | google.golang.org/protobuf v1.33.0 // indirect 41 | gopkg.in/yaml.v3 v3.0.1 // indirect 42 | ) 43 | -------------------------------------------------------------------------------- /task.go: -------------------------------------------------------------------------------- 1 | package goflow 2 | 3 | import ( 4 | "math" 5 | "time" 6 | ) 7 | 8 | // A Task is the unit of work that makes up a job. Whenever a task is executed, it 9 | // calls its associated operator. 10 | type Task struct { 11 | Name string 12 | Operator Operator 13 | TriggerRule triggerRule 14 | Retries int 15 | RetryDelay RetryDelay 16 | remaining int 17 | state state 18 | } 19 | 20 | type triggerRule string 21 | 22 | const ( 23 | allDone triggerRule = "allDone" 24 | allSuccessful triggerRule = "allSuccessful" 25 | ) 26 | 27 | func (t *Task) run(writes chan writeOp) error { 28 | 29 | _, err := t.Operator.Run() 30 | 31 | // retry 32 | if err != nil && t.remaining > 0 { 33 | writes <- writeOp{t.Name, upForRetry} 34 | return nil 35 | } 36 | 37 | // failed 38 | if err != nil && t.remaining <= 0 { 39 | writes <- writeOp{t.Name, failed} 40 | return err 41 | } 42 | 43 | // success 44 | writes <- writeOp{t.Name, successful} 45 | return nil 46 | } 47 | 48 | func (t *Task) skip(writes chan writeOp) error { 49 | writes <- writeOp{t.Name, skipped} 50 | return nil 51 | } 52 | 53 | // RetryDelay is a type that implements a Wait() method, which is called in between 54 | // task retry attempts. 55 | type RetryDelay interface { 56 | wait(taskName string, attempt int) 57 | } 58 | 59 | // ConstantDelay waits a constant number of seconds between task retries. 60 | type ConstantDelay struct{ Period int } 61 | 62 | func (d ConstantDelay) wait(task string, attempt int) { 63 | time.Sleep(time.Duration(d.Period) * time.Second) 64 | } 65 | 66 | // ExponentialBackoff waits exponentially longer between each retry attempt. 67 | type ExponentialBackoff struct{} 68 | 69 | func (d ExponentialBackoff) wait(task string, attempt int) { 70 | delay := math.Pow(2, float64(attempt)) 71 | time.Sleep(time.Duration(delay) * time.Second) 72 | } 73 | -------------------------------------------------------------------------------- /ui/html/index.html.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Goflow 7 | 8 | 9 |
10 |

Goflow

11 |
12 |
13 | Jobs 14 |
15 |
16 | 22 |
23 |
24 |
25 |
26 |
27 |
Job
28 |
Schedule
29 |
State
30 |
31 | {{ range .jobs }} 32 |
{{ .Name }}
33 |
34 |
{{ .Schedule }}
35 |
36 |
37 |
38 | 39 | 40 |
41 | {{ end }} 42 |
43 |
44 | 45 | 46 | 49 | -------------------------------------------------------------------------------- /operator.go: -------------------------------------------------------------------------------- 1 | package goflow 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "os/exec" 8 | ) 9 | 10 | // An Operator implements a Run() method. When a job executes a task that 11 | // uses the operator, the Run() method is called. 12 | type Operator interface { 13 | Run() (interface{}, error) 14 | } 15 | 16 | // Command executes a shell command. 17 | type Command struct { 18 | Cmd string 19 | Args []string 20 | } 21 | 22 | // Run passes the command and arguments to exec.Command and captures the 23 | // output. 24 | func (o Command) Run() (interface{}, error) { 25 | out, err := exec.Command(o.Cmd, o.Args...).Output() 26 | return string(out), err 27 | } 28 | 29 | // Get makes a GET request. 30 | type Get struct { 31 | Client *http.Client 32 | URL string 33 | } 34 | 35 | // Run sends the request and returns an error if the status code is 36 | // outside the 2xx range. 37 | func (o Get) Run() (interface{}, error) { 38 | res, err := o.Client.Get(o.URL) 39 | if err != nil { 40 | return nil, err 41 | } 42 | defer res.Body.Close() 43 | 44 | if res.StatusCode < 200 || res.StatusCode > 299 { 45 | return nil, fmt.Errorf("Received status code %v", res.StatusCode) 46 | } 47 | 48 | content, err := io.ReadAll(res.Body) 49 | return string(content), err 50 | } 51 | 52 | // Post makes a POST request. 53 | type Post struct { 54 | Client *http.Client 55 | URL string 56 | Body io.Reader 57 | } 58 | 59 | // Run sends the request and returns an error if the status code is 60 | // outside the 2xx range. 61 | func (o Post) Run() (interface{}, error) { 62 | res, err := o.Client.Post(o.URL, "application/json", o.Body) 63 | if err != nil { 64 | return nil, err 65 | } 66 | defer res.Body.Close() 67 | 68 | if res.StatusCode < 200 || res.StatusCode > 299 { 69 | return nil, fmt.Errorf("Received status code %v", res.StatusCode) 70 | } 71 | 72 | content, err := io.ReadAll(res.Body) 73 | return string(content), err 74 | } 75 | -------------------------------------------------------------------------------- /ui/html/job.html.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Goflow 6 | 7 | 8 | 9 |
10 |

Goflow

11 |
12 |
13 | {{ .jobName }} 14 |
15 |
{{ .schedule }}
16 |
17 |
18 | 19 | 20 |
21 | 27 |
28 |
29 |
30 |
31 |
32 |
Task
33 |
State
34 | {{ range $ix, $taskName := .taskNames }} 35 |
{{ $taskName }}
36 |
37 | {{ end }} 38 |
39 |
40 |
Last run:
41 | 42 |
43 |
44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /.github/workflows/deploy-image.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: Create and publish a Docker image 7 | 8 | on: 9 | push: 10 | tags: 11 | - "v*.*.*" 12 | 13 | env: 14 | REGISTRY: ghcr.io 15 | IMAGE_NAME: fieldryand/goflow-example 16 | 17 | jobs: 18 | build-and-push-image: 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: write 22 | packages: write 23 | 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v2 27 | 28 | - name: Set up Go 29 | uses: actions/setup-go@v4 30 | with: 31 | go-version: '1.20' 32 | 33 | - name: Set up Node 34 | uses: actions/setup-node@v2 35 | 36 | - name: Compile UI 37 | run: cd ui && npm install && npm run build 38 | 39 | - name: Build goflow-example 40 | run: go build example/goflow-example.go 41 | 42 | - name: Log in to the Container registry 43 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 44 | with: 45 | registry: ${{ env.REGISTRY }} 46 | username: ${{ github.actor }} 47 | password: ${{ secrets.GITHUB_TOKEN }} 48 | 49 | - name: Extract metadata (tags, labels) for Docker 50 | id: meta 51 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 52 | with: 53 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 54 | 55 | - name: Build and push Docker image 56 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 57 | with: 58 | context: . 59 | push: true 60 | tags: ${{ steps.meta.outputs.tags }} 61 | labels: org.opencontainers.image.source=https://github.com/fieldryand/goflow 62 | 63 | - name: Tarball ui 64 | run: tar -czvf goflow-ui.tar.gz ui 65 | 66 | - name: Release 67 | uses: softprops/action-gh-release@v1 68 | with: 69 | files: goflow-ui.tar.gz 70 | -------------------------------------------------------------------------------- /execution.go: -------------------------------------------------------------------------------- 1 | package goflow 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/google/uuid" 7 | "github.com/philippgille/gokv" 8 | ) 9 | 10 | // Execution of a job. 11 | type execution struct { 12 | ID uuid.UUID `json:"id"` 13 | JobName string `json:"job"` 14 | StartedAt string `json:"submitted"` 15 | ModifiedTimestamp string `json:"modifiedTimestamp"` 16 | State state `json:"state"` 17 | TaskExecutions []taskExecution `json:"tasks"` 18 | } 19 | 20 | type taskExecution struct { 21 | Name string `json:"name"` 22 | State state `json:"state"` 23 | } 24 | 25 | func (j *Job) newExecution() *execution { 26 | taskExecutions := make([]taskExecution, 0) 27 | for _, task := range j.Tasks { 28 | taskrun := taskExecution{task.Name, none} 29 | taskExecutions = append(taskExecutions, taskrun) 30 | } 31 | return &execution{ 32 | ID: uuid.New(), 33 | JobName: j.Name, 34 | StartedAt: time.Now().UTC().Format(time.RFC3339Nano), 35 | ModifiedTimestamp: time.Now().UTC().Format(time.RFC3339Nano), 36 | State: none, 37 | TaskExecutions: taskExecutions} 38 | } 39 | 40 | // Persist a new execution. 41 | func persistNewExecution(s gokv.Store, e *execution) error { 42 | key := e.ID 43 | return s.Set(key.String(), e) 44 | } 45 | 46 | type executionIndex struct { 47 | ExecutionIDs []string `json:"executions"` 48 | } 49 | 50 | // Index the job runs 51 | func indexExecutions(s gokv.Store, e *execution) error { 52 | 53 | // get the job from the execution 54 | j := e.JobName 55 | 56 | // retrieve the list of executions of that job 57 | i := executionIndex{} 58 | s.Get(j, &i) 59 | 60 | // append to the list 61 | i.ExecutionIDs = append(i.ExecutionIDs, e.ID.String()) 62 | return s.Set(e.JobName, i) 63 | } 64 | 65 | // Read all the persisted executions for a given job. 66 | func readExecutions(s gokv.Store, j string) ([]*execution, error) { 67 | 68 | // retrieve the list of executions of the job 69 | i := executionIndex{} 70 | s.Get(j, &i) 71 | 72 | // return the list 73 | executions := make([]*execution, 0) 74 | for _, key := range i.ExecutionIDs { 75 | val := execution{} 76 | s.Get(key, &val) 77 | executions = append(executions, &val) 78 | } 79 | 80 | return executions, nil 81 | } 82 | 83 | // Sync the current state to the persisted execution. 84 | func syncStateToStore(s gokv.Store, e *execution, taskName string, taskState state) error { 85 | key := e.ID 86 | for ix, task := range e.TaskExecutions { 87 | if task.Name == taskName { 88 | e.TaskExecutions[ix].State = taskState 89 | } 90 | } 91 | return s.Set(key.String(), e) 92 | } 93 | -------------------------------------------------------------------------------- /operator_test.go: -------------------------------------------------------------------------------- 1 | package goflow 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | ) 10 | 11 | func TestCommand(t *testing.T) { 12 | result, _ := Command{Cmd: "sh", Args: []string{"-c", "echo $((2 + 4))"}}.Run() 13 | resultStr := fmt.Sprintf("%v", result) 14 | expected := "6\n" 15 | 16 | if resultStr != expected { 17 | t.Errorf("Expected %s, got %s", expected, resultStr) 18 | } 19 | } 20 | 21 | func TestGetSuccess(t *testing.T) { 22 | expected := "OK" 23 | srv := httptest.NewServer( 24 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 25 | w.WriteHeader(200) 26 | w.Write([]byte(expected)) 27 | })) 28 | defer srv.Close() 29 | 30 | client := &http.Client{} 31 | result, _ := Get{client, srv.URL}.Run() 32 | 33 | if result != expected { 34 | t.Errorf("Expected %s, got %s", expected, result) 35 | } 36 | } 37 | 38 | func TestGetNotFound(t *testing.T) { 39 | srv := httptest.NewServer( 40 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 41 | w.WriteHeader(404) 42 | w.Write([]byte("Page not found")) 43 | })) 44 | defer srv.Close() 45 | 46 | client := &http.Client{} 47 | _, err := Get{client, srv.URL}.Run() 48 | 49 | if err == nil { 50 | t.Errorf("Expected an error") 51 | } 52 | } 53 | 54 | func TestGetInvalid(t *testing.T) { 55 | client := &http.Client{} 56 | _, err := Get{client, ""}.Run() 57 | 58 | if err == nil { 59 | t.Errorf("Expected an error") 60 | } 61 | } 62 | 63 | func TestPostSuccess(t *testing.T) { 64 | expected := "OK" 65 | srv := httptest.NewServer( 66 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 67 | w.WriteHeader(200) 68 | w.Write([]byte(expected)) 69 | })) 70 | defer srv.Close() 71 | 72 | client := &http.Client{} 73 | result, _ := Post{client, srv.URL, bytes.NewBuffer([]byte(""))}.Run() 74 | 75 | if result != expected { 76 | t.Errorf("Expected %s, got %s", expected, result) 77 | } 78 | } 79 | 80 | func TestPostNotFound(t *testing.T) { 81 | srv := httptest.NewServer( 82 | http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 83 | w.WriteHeader(404) 84 | w.Write([]byte("Page not found")) 85 | })) 86 | defer srv.Close() 87 | 88 | client := &http.Client{} 89 | _, err := Post{client, srv.URL, bytes.NewBuffer([]byte(""))}.Run() 90 | 91 | if err == nil { 92 | t.Errorf("Expected an error") 93 | } 94 | } 95 | 96 | func TestPostInvalid(t *testing.T) { 97 | client := &http.Client{} 98 | _, err := Post{client, "", bytes.NewBuffer([]byte(""))}.Run() 99 | 100 | if err == nil { 101 | t.Errorf("Expected an error") 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /dag.go: -------------------------------------------------------------------------------- 1 | package goflow 2 | 3 | import ( 4 | "github.com/ef-ds/deque" 5 | ) 6 | 7 | // Credit: The DAG implementation here is roughly a port of the 8 | // one from this Python project: https://github.com/thieman/dagobah 9 | 10 | // A DAG is a directed acyclic graph represented by a simple map 11 | // where a key is a node in the graph, and a value is a slice of 12 | // immediately downstream dependent nodes. 13 | type dag map[string][]string 14 | 15 | // A node has a name and 0 or more dependent nodes 16 | func (d dag) addNode(name string) { 17 | deps := make([]string, 0) 18 | d[name] = deps 19 | } 20 | 21 | // Create an edge between an independent and dependent node 22 | func (d dag) setDownstream(ind, dep string) { 23 | d[ind] = append(d[ind], dep) 24 | } 25 | 26 | // Returns true if a node is a downstream node, false if it 27 | // is independent. 28 | func (d dag) isDownstream(nodeName string) bool { 29 | ind := d.independentNodes() 30 | 31 | for _, name := range ind { 32 | if nodeName == name { 33 | return false 34 | } 35 | } 36 | 37 | return true 38 | } 39 | 40 | // Ensure the DAG is acyclic 41 | func (d dag) validate() bool { 42 | degree := make(map[string]int) 43 | 44 | for node := range d { 45 | degree[node] = 0 46 | } 47 | 48 | for _, ds := range d { 49 | for _, i := range ds { 50 | degree[i]++ 51 | } 52 | } 53 | 54 | var deq deque.Deque 55 | 56 | for node, val := range degree { 57 | if val == 0 { 58 | deq.PushFront(node) 59 | } 60 | } 61 | 62 | l := make([]string, 0) 63 | 64 | for { 65 | popped, ok := deq.PopBack() 66 | 67 | if !ok { 68 | break 69 | } else { 70 | node := popped.(string) 71 | l = append(l, node) 72 | dsNodes := d[node] 73 | for _, dsNode := range dsNodes { 74 | degree[dsNode]-- 75 | if degree[dsNode] == 0 { 76 | deq.PushFront(dsNode) 77 | } 78 | } 79 | } 80 | } 81 | 82 | return len(l) == len(d) 83 | } 84 | 85 | // Return the immediately upstream nodes for a given node 86 | func (d dag) dependencies(node string) []string { 87 | 88 | dependencies := make([]string, 0) 89 | 90 | for dep, ds := range d { 91 | for _, i := range ds { 92 | if node == i { 93 | dependencies = append(dependencies, dep) 94 | } 95 | } 96 | } 97 | 98 | return dependencies 99 | } 100 | 101 | // Return all the independent nodes in the graph 102 | func (d dag) independentNodes() []string { 103 | 104 | downstream := make([]string, 0) 105 | 106 | for _, ds := range d { 107 | downstream = append(downstream, ds...) 108 | } 109 | 110 | ind := make([]string, 0) 111 | 112 | for node := range d { 113 | ctr := 0 114 | for _, i := range downstream { 115 | if node == i { 116 | ctr++ 117 | } 118 | } 119 | if ctr == 0 { 120 | ind = append(ind, node) 121 | } 122 | } 123 | 124 | return ind 125 | 126 | } 127 | -------------------------------------------------------------------------------- /example.go: -------------------------------------------------------------------------------- 1 | package goflow 2 | 3 | import ( 4 | "errors" 5 | "math/rand" 6 | ) 7 | 8 | // Crunch some numbers 9 | func complexAnalyticsJob() *Job { 10 | j := &Job{ 11 | Name: "example-complex-analytics", 12 | Schedule: "* * * * * *", 13 | Active: false, 14 | } 15 | 16 | j.Add(&Task{ 17 | Name: "sleep-one", 18 | Operator: Command{Cmd: "sleep", Args: []string{"1"}}, 19 | }) 20 | j.Add(&Task{ 21 | Name: "add-one-one", 22 | Operator: Command{Cmd: "sh", Args: []string{"-c", "echo $((1 + 1))"}}, 23 | }) 24 | j.Add(&Task{ 25 | Name: "sleep-two", 26 | Operator: Command{Cmd: "sleep", Args: []string{"2"}}, 27 | }) 28 | j.Add(&Task{ 29 | Name: "add-two-four", 30 | Operator: Command{Cmd: "sh", Args: []string{"-c", "echo $((2 + 4))"}}, 31 | }) 32 | j.Add(&Task{ 33 | Name: "add-three-four", 34 | Operator: Command{Cmd: "sh", Args: []string{"-c", "echo $((3 + 4))"}}, 35 | }) 36 | j.Add(&Task{ 37 | Name: "whoops-with-constant-delay", 38 | Operator: Command{Cmd: "whoops", Args: []string{}}, 39 | Retries: 5, 40 | RetryDelay: ConstantDelay{Period: 1}, 41 | }) 42 | j.Add(&Task{ 43 | Name: "whoops-with-exponential-backoff", 44 | Operator: Command{Cmd: "whoops", Args: []string{}}, 45 | Retries: 1, 46 | RetryDelay: ExponentialBackoff{}, 47 | }) 48 | j.Add(&Task{ 49 | Name: "totally-skippable", 50 | Operator: Command{Cmd: "sh", Args: []string{"-c", "echo 'everything succeeded'"}}, 51 | TriggerRule: "allSuccessful", 52 | }) 53 | j.Add(&Task{ 54 | Name: "clean-up", 55 | Operator: Command{Cmd: "sh", Args: []string{"-c", "echo 'cleaning up now'"}}, 56 | TriggerRule: "allDone", 57 | }) 58 | 59 | j.SetDownstream(j.Task("sleep-one"), j.Task("add-one-one")) 60 | j.SetDownstream(j.Task("add-one-one"), j.Task("sleep-two")) 61 | j.SetDownstream(j.Task("sleep-two"), j.Task("add-two-four")) 62 | j.SetDownstream(j.Task("add-one-one"), j.Task("add-three-four")) 63 | j.SetDownstream(j.Task("sleep-one"), j.Task("whoops-with-constant-delay")) 64 | j.SetDownstream(j.Task("sleep-one"), j.Task("whoops-with-exponential-backoff")) 65 | j.SetDownstream(j.Task("whoops-with-constant-delay"), j.Task("totally-skippable")) 66 | j.SetDownstream(j.Task("whoops-with-exponential-backoff"), j.Task("totally-skippable")) 67 | j.SetDownstream(j.Task("totally-skippable"), j.Task("clean-up")) 68 | 69 | return j 70 | } 71 | 72 | // RandomFailure fails randomly. This is a contrived example for demo purposes. 73 | type RandomFailure struct{ n int } 74 | 75 | // rng with seed=1 76 | var r = rand.New(rand.NewSource(1)) 77 | 78 | // Run implements failures at random intervals. 79 | func (o RandomFailure) Run() (interface{}, error) { 80 | x := r.Intn(o.n) 81 | 82 | if x == o.n-1 { 83 | return nil, errors.New("unlucky") 84 | } 85 | 86 | return x, nil 87 | } 88 | 89 | // Use our custom operation in a job. 90 | func customOperatorJob() *Job { 91 | j := &Job{Name: "example-custom-operator", Schedule: "* * * * * *", Active: true} 92 | j.Add(&Task{Name: "random-failure", Operator: RandomFailure{4}}) 93 | return j 94 | } 95 | -------------------------------------------------------------------------------- /ui/css/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: sans-serif; 3 | } 4 | 5 | .job-table { 6 | display: grid; 7 | grid-template-columns: 1fr 1fr 2fr 0.5fr; 8 | } 9 | 10 | .job-table > div { 11 | padding: .5em 12 | } 13 | 14 | .job-table>div:nth-child(-n+5) { 15 | background-color: mediumslateblue; 16 | color:white; 17 | } 18 | 19 | .job-table>div:nth-child(8n+5), .job-table>div:nth-child(8n+6), .job-table>div:nth-child(8n+7), .job-table>div:nth-child(8n+8) { 20 | background-color: whitesmoke 21 | } 22 | 23 | .task-table { 24 | display: grid; 25 | grid-template-columns: max-content auto; 26 | } 27 | 28 | .task-table > div { 29 | padding: .5em 30 | } 31 | 32 | .task-table>div:nth-child(-n+2) { 33 | background-color: mediumslateblue; 34 | color:white; 35 | } 36 | 37 | .task-table>div:nth-child(4n+3), .task-table>div:nth-child(4n+4) { 38 | background-color: whitesmoke 39 | } 40 | 41 | .button-container { 42 | display: flex; 43 | justify-content: flex-end; 44 | align-items: flex-start; 45 | gap: 0.5em; 46 | } 47 | 48 | .button-container-job-page { 49 | display: flex; 50 | align-items: flex-start; 51 | gap: 0.5em; 52 | } 53 | 54 | .top-nav { 55 | padding-left: 5em; 56 | padding-right: 5em; 57 | padding-top: 1em; 58 | padding-bottom: 1em; 59 | } 60 | 61 | .job-container { 62 | padding-left: 5em; 63 | padding-right: 5em; 64 | } 65 | 66 | .job-info { 67 | padding-left: 5em; 68 | padding-right: 5em; 69 | padding-bottom: 1em; 70 | display: grid; 71 | grid-template-columns: auto; 72 | grid-gap: 1em; 73 | } 74 | 75 | .job-info-title { 76 | font-size: 1.5em; 77 | } 78 | 79 | /* Default select style */ 80 | select { 81 | background-color: mediumslateblue; 82 | border-radius: .2em; 83 | color:white; 84 | border-color:mediumslateblue; 85 | border-style: none; 86 | padding:.5em .75em; 87 | white-space: nowrap; 88 | } 89 | 90 | /* Default button style */ 91 | button { 92 | background-color: mediumslateblue; 93 | border-radius: .2em; 94 | color:white; 95 | border-color:mediumslateblue; 96 | border-style: none; 97 | padding:.5em .75em; 98 | white-space: nowrap; 99 | } 100 | 101 | /* Style when button is clicked */ 102 | button.clicked { 103 | transform: translateY(2px); 104 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); 105 | } 106 | 107 | a { 108 | color: mediumslateblue; 109 | } 110 | 111 | .node rect { 112 | stroke: #333; 113 | fill: #fff; 114 | } 115 | 116 | .edgePath path { 117 | stroke: #333; 118 | fill: #333; 119 | stroke-width: 1.5px; 120 | } 121 | 122 | .graph { 123 | border: 1px solid #ccc; 124 | overflow: hidden; 125 | margin: 0 auto; 126 | } 127 | 128 | .graph-container { 129 | padding-top: 2em; 130 | } 131 | 132 | .status-indicator { 133 | height: 1em; 134 | width: 1em; 135 | border-radius: 50%; 136 | border: 1px solid #676767; 137 | } 138 | 139 | .status-wrapper { 140 | display: flex; 141 | flex-wrap: wrap; 142 | gap: 0.5em 143 | } 144 | 145 | .schedule-badge-active-true::after { 146 | content: 'Active'; 147 | background-color: white; 148 | color:mediumslateblue; 149 | border: 1px solid mediumslateblue; 150 | border-radius: 1em; 151 | font-size: .75rem; 152 | padding: 0.1em 0.75em; 153 | margin-left: 1em; 154 | } 155 | 156 | .schedule-badge-active-false { 157 | } 158 | -------------------------------------------------------------------------------- /goflow.go: -------------------------------------------------------------------------------- 1 | // Package goflow implements a simple but powerful DAG scheduler and dashboard. 2 | package goflow 3 | 4 | import ( 5 | "log" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/google/uuid" 9 | "github.com/philippgille/gokv" 10 | "github.com/philippgille/gokv/gomap" 11 | "github.com/robfig/cron/v3" 12 | ) 13 | 14 | // Goflow contains job data and a router. 15 | type Goflow struct { 16 | Store gokv.Store 17 | Options Options 18 | Jobs map[string](func() *Job) 19 | router *gin.Engine 20 | cron *cron.Cron 21 | jobs []string 22 | } 23 | 24 | // Options to control various Goflow behavior. 25 | type Options struct { 26 | Store gokv.Store 27 | UIPath string 28 | Streaming bool 29 | ShowExamples bool 30 | WithSeconds bool 31 | } 32 | 33 | // New returns a Goflow engine. 34 | func New(opts Options) *Goflow { 35 | 36 | // Add a default store if necessary 37 | if opts.Store == nil { 38 | opts.Store = gomap.NewStore(gomap.DefaultOptions) 39 | } 40 | 41 | // Add the cron schedule 42 | var c *cron.Cron 43 | if opts.WithSeconds { 44 | c = cron.New(cron.WithSeconds()) 45 | } else { 46 | c = cron.New() 47 | } 48 | 49 | g := &Goflow{ 50 | Store: opts.Store, 51 | Options: opts, 52 | Jobs: make(map[string](func() *Job)), 53 | router: gin.New(), 54 | cron: c, 55 | } 56 | 57 | if opts.ShowExamples { 58 | g.AddJob(complexAnalyticsJob) 59 | g.AddJob(customOperatorJob) 60 | } 61 | 62 | return g 63 | } 64 | 65 | // scheduledExecution implements cron.Job 66 | type scheduledExecution struct { 67 | store gokv.Store 68 | jobFunc func() *Job 69 | } 70 | 71 | func (schedExec *scheduledExecution) Run() { 72 | 73 | // create job 74 | job := schedExec.jobFunc() 75 | 76 | // create and persist a new execution 77 | e := job.newExecution() 78 | persistNewExecution(schedExec.store, e) 79 | indexExecutions(schedExec.store, e) 80 | 81 | // start running the job 82 | job.run(schedExec.store, e) 83 | } 84 | 85 | // AddJob takes a job-emitting function and registers it 86 | // with the engine. 87 | func (g *Goflow) AddJob(jobFunc func() *Job) *Goflow { 88 | 89 | j := jobFunc() 90 | 91 | // TODO: change the return type here to error 92 | // "" is not a valid key in the storage layer 93 | //if j.Name == "" { 94 | // return errors.New("\"\" is not a valid job name") 95 | // } 96 | 97 | // Register the job 98 | g.Jobs[j.Name] = jobFunc 99 | g.jobs = append(g.jobs, j.Name) 100 | 101 | // If the job is active by default, add it to the cron schedule 102 | if j.Active { 103 | e := &scheduledExecution{g.Store, jobFunc} 104 | _, err := g.cron.AddJob(j.Schedule, e) 105 | 106 | if err != nil { 107 | panic(err) 108 | } 109 | } 110 | 111 | return g 112 | } 113 | 114 | // toggle flips a job's cron schedule status from active to inactive 115 | // and vice versa. It returns true if the new status is active and false 116 | // if it is inactive. 117 | func (g *Goflow) toggle(jobName string) (bool, error) { 118 | 119 | // if the job is found in the list of entries, remove it 120 | for _, entry := range g.cron.Entries() { 121 | if name := entry.Job.(*scheduledExecution).jobFunc().Name; name == jobName { 122 | g.cron.Remove(entry.ID) 123 | return false, nil 124 | } 125 | } 126 | 127 | // else add a new entry 128 | jobFunc := g.Jobs[jobName] 129 | e := &scheduledExecution{g.Store, jobFunc} 130 | g.cron.AddJob(jobFunc().Schedule, e) 131 | return true, nil 132 | } 133 | 134 | // execute tells the engine to run a given job in a new goroutine. 135 | func (g *Goflow) execute(job string) uuid.UUID { 136 | 137 | // create job 138 | j := g.Jobs[job]() 139 | 140 | // create and persist a new execution 141 | e := j.newExecution() 142 | persistNewExecution(g.Store, e) 143 | indexExecutions(g.Store, e) 144 | 145 | // start running the job 146 | go j.run(g.Store, e) 147 | 148 | return e.ID 149 | } 150 | 151 | // Use middleware in the Gin router. 152 | func (g *Goflow) Use(middleware gin.HandlerFunc) *Goflow { 153 | g.router.Use(middleware) 154 | return g 155 | } 156 | 157 | // Run runs the webserver. 158 | func (g *Goflow) Run(port string) { 159 | log.SetFlags(0) 160 | log.SetOutput(new(logWriter)) 161 | g.router.Use(gin.Recovery()) 162 | g.addStreamRoute(true) 163 | g.addAPIRoutes() 164 | if g.Options.UIPath != "" { 165 | g.addUIRoutes() 166 | g.addStaticRoutes() 167 | } 168 | g.cron.Start() 169 | g.router.Run(port) 170 | } 171 | -------------------------------------------------------------------------------- /job_test.go: -------------------------------------------------------------------------------- 1 | package goflow 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/philippgille/gokv/gomap" 7 | ) 8 | 9 | func TestJob(t *testing.T) { 10 | j := &Job{Name: "example", Schedule: "* * * * *"} 11 | 12 | j.Add(&Task{ 13 | Name: "add-one-one", 14 | Operator: Command{Cmd: "sh", Args: []string{"-c", "echo $((1 + 1))"}}, 15 | }) 16 | j.Add(&Task{ 17 | Name: "sleep-two", 18 | Operator: Command{Cmd: "sleep", Args: []string{"2"}}, 19 | }) 20 | j.Add(&Task{ 21 | Name: "add-two-four", 22 | Operator: Command{Cmd: "sh", Args: []string{"-c", "echo $((2 + 4))"}}, 23 | }) 24 | j.Add(&Task{ 25 | Name: "add-three-four", 26 | Operator: Command{Cmd: "sh", Args: []string{"-c", "echo $((3 + 4))"}}, 27 | }) 28 | j.Add(&Task{ 29 | Name: "whoops-with-constant-delay", 30 | Operator: Command{Cmd: "whoops", Args: []string{}}, 31 | Retries: 5, 32 | RetryDelay: ConstantDelay{1}, 33 | }) 34 | j.Add(&Task{ 35 | Name: "whoops-with-exponential-backoff", 36 | Operator: Command{Cmd: "whoops", Args: []string{}}, 37 | Retries: 1, 38 | RetryDelay: ExponentialBackoff{}, 39 | }) 40 | j.Add(&Task{ 41 | Name: "totally-skippable", 42 | Operator: Command{Cmd: "sh", Args: []string{"-c", "echo 'everything succeeded'"}}, 43 | TriggerRule: "allSuccessful", 44 | }) 45 | j.Add(&Task{ 46 | Name: "clean-up", 47 | Operator: Command{Cmd: "sh", Args: []string{"-c", "echo 'cleaning up now'"}}, 48 | TriggerRule: "allDone", 49 | }) 50 | j.Add(&Task{ 51 | Name: "failure", 52 | Operator: RandomFailure{1}, 53 | }) 54 | 55 | j.SetDownstream(j.Task("add-one-one"), j.Task("sleep-two")) 56 | j.SetDownstream(j.Task("sleep-two"), j.Task("add-two-four")) 57 | j.SetDownstream(j.Task("add-one-one"), j.Task("add-three-four")) 58 | j.SetDownstream(j.Task("add-one-one"), j.Task("whoops-with-constant-delay")) 59 | j.SetDownstream(j.Task("add-one-one"), j.Task("whoops-with-exponential-backoff")) 60 | j.SetDownstream(j.Task("whoops-with-constant-delay"), j.Task("totally-skippable")) 61 | j.SetDownstream(j.Task("whoops-with-exponential-backoff"), j.Task("totally-skippable")) 62 | j.SetDownstream(j.Task("totally-skippable"), j.Task("clean-up")) 63 | 64 | store := gomap.NewStore(gomap.DefaultOptions) 65 | 66 | go j.run(store, j.newExecution()) 67 | 68 | for { 69 | if j.allDone() { 70 | break 71 | } 72 | } 73 | 74 | if j.loadTaskState("add-one-one") != successful { 75 | t.Errorf("Got status %v, expected %v", j.loadTaskState("add-one-one"), successful) 76 | } 77 | if j.loadTaskState("sleep-two") != successful { 78 | t.Errorf("Got status %v, expected %v", j.loadTaskState("sleep-two"), successful) 79 | } 80 | if j.loadTaskState("add-two-four") != successful { 81 | t.Errorf("Got status %v, expected %v", j.loadTaskState("add-two-four"), successful) 82 | } 83 | if j.loadTaskState("add-three-four") != successful { 84 | t.Errorf("Got status %v, expected %v", j.loadTaskState("add-three-four"), successful) 85 | } 86 | if j.loadTaskState("whoops-with-constant-delay") != failed { 87 | t.Errorf("Got status %v, expected %v", j.loadTaskState("whoops-with-constant-delay"), failed) 88 | } 89 | if j.loadTaskState("whoops-with-exponential-backoff") != failed { 90 | t.Errorf("Got status %v, expected %v", j.loadTaskState("whoops-with-exponential-backoff"), failed) 91 | } 92 | if j.loadTaskState("totally-skippable") != skipped { 93 | t.Errorf("Got status %v, expected %v", j.loadTaskState("totally-skippable"), skipped) 94 | } 95 | if j.loadTaskState("clean-up") != successful { 96 | t.Errorf("Got status %v, expected %v", j.loadTaskState("clean-up"), successful) 97 | } 98 | if j.loadTaskState("failure") != failed { 99 | t.Errorf("Got status %v, expected %v", j.loadTaskState("failure"), failed) 100 | } 101 | 102 | } 103 | 104 | func TestCyclicJob(t *testing.T) { 105 | j := &Job{Name: "cyclic", Schedule: "* * * * *"} 106 | 107 | j.Add(&Task{ 108 | Name: "add-two-four", 109 | Operator: Command{Cmd: "sh", Args: []string{"-c", "echo $((2 + 4))"}}, 110 | }) 111 | j.Add(&Task{ 112 | Name: "add-three-four", 113 | Operator: Command{Cmd: "sh", Args: []string{"-c", "echo $((3 + 4))"}}, 114 | }) 115 | 116 | j.SetDownstream(j.Task("add-two-four"), j.Task("add-three-four")) 117 | j.SetDownstream(j.Task("add-three-four"), j.Task("add-two-four")) 118 | 119 | store := gomap.NewStore(gomap.DefaultOptions) 120 | 121 | j.run(store, j.newExecution()) 122 | } 123 | -------------------------------------------------------------------------------- /ui/src/plain.js: -------------------------------------------------------------------------------- 1 | function getDropdownValue() { 2 | const selectDropdown = document.querySelector('select'); 3 | return selectDropdown.value 4 | } 5 | 6 | function indexPageEventListener() { 7 | var stream = new EventSource(`/stream`); 8 | stream.addEventListener("message", indexPageEventHandler) 9 | } 10 | 11 | function jobPageEventListener(job) { 12 | var stream = new EventSource(`/stream?jobname=${job}`); 13 | stream.addEventListener("message", jobPageEventHandler) 14 | } 15 | 16 | function indexPageEventHandler(message) { 17 | const d = JSON.parse(message.data); 18 | const s = stateColor(d.state); 19 | updateStateCircles("job-table", d.id, d.job, s, d.submitted); 20 | } 21 | 22 | function jobPageEventHandler(message) { 23 | const d = JSON.parse(message.data); 24 | updateTaskStateCircles(d); 25 | updateGraphViz(d); 26 | updateLastRunTs(d); 27 | } 28 | 29 | function updateStateCircles(tableName, jobID, wrapperId, color, startTimestamp) { 30 | const options = { 31 | hour: '2-digit', 32 | minute: '2-digit', 33 | second: '2-digit' 34 | }; 35 | const limit = getDropdownValue(); 36 | const wrapper = document.getElementById(wrapperId); 37 | const startTs = new Date(startTimestamp); 38 | const formattedTs = startTs.toLocaleString(undefined, options); 39 | div = document.createElement("div"); 40 | div.setAttribute("id", jobID); 41 | div.setAttribute("class", "status-indicator"); 42 | div.setAttribute("style", `background-color:${color}`); 43 | div.setAttribute("title", `ID: ${jobID}\nStarted: ${formattedTs}`); 44 | if (jobID in wrapper.children) { 45 | wrapper.replaceChild(div, document.getElementById(jobID)); 46 | } else { 47 | if (wrapper.childElementCount >= limit) { 48 | wrapper.removeChild(wrapper.firstElementChild); 49 | wrapper.appendChild(div); 50 | } else { 51 | wrapper.appendChild(div); 52 | } 53 | } 54 | } 55 | 56 | function updateTaskStateCircles(execution) { 57 | for (i in execution.tasks) { 58 | const t = execution.tasks[i]; 59 | const s = stateColor(t.state); 60 | updateStateCircles("task-table", `${execution.id}-${t.name}`, t.name, s, execution.submitted); 61 | } 62 | } 63 | 64 | function updateGraphViz(execution) { 65 | const tasks = execution.tasks 66 | for (i in tasks) { 67 | if (document.getElementsByClassName("output")) { 68 | try { 69 | const rect = document.getElementById("node-" + tasks[i].name).querySelector("rect"); 70 | rect.setAttribute("style", "stroke-width: 2; stroke: " + stateColor(tasks[i].state)); 71 | } 72 | catch(err) { 73 | console.log(`${err}. This might be a temporary error when the graph is still loading.`) 74 | } 75 | } 76 | } 77 | } 78 | 79 | function updateLastRunTs(execution) { 80 | const lastExecutionTs = execution.submitted; 81 | const lastExecutionTsHTML = document.getElementById("last-execution-ts-wrapper").innerHTML; 82 | const newHTML = lastExecutionTsHTML.replace(/.*/, `Last run: ${lastExecutionTs}`); 83 | document.getElementById("last-execution-ts-wrapper").innerHTML = newHTML; 84 | } 85 | 86 | function updateJobActive(jobName) { 87 | fetch(`/api/jobs/${jobName}`) 88 | .then(response => response.json()) 89 | .then(data => { 90 | if (data.active) { 91 | document 92 | .getElementById("schedule-badge-" + jobName) 93 | .setAttribute("class", "schedule-badge-active-true"); 94 | } else { 95 | document 96 | .getElementById("schedule-badge-" + jobName) 97 | .setAttribute("class", "schedule-badge-active-false"); 98 | } 99 | }) 100 | } 101 | 102 | function stateColor(taskState) { 103 | switch (taskState) { 104 | case "running": 105 | var color = "#dffbe3"; 106 | break; 107 | case "upforretry": 108 | var color = "#ffc620"; 109 | break; 110 | case "successful": 111 | var color = "#39c84e"; 112 | break; 113 | case "skipped": 114 | var color = "#abbefb"; 115 | break; 116 | case "failed": 117 | var color = "#ff4020"; 118 | break; 119 | case "notstarted": 120 | var color = "white"; 121 | break; 122 | } 123 | 124 | return color 125 | } 126 | 127 | async function buttonPress(buttonName, jobName) { 128 | var button = document.getElementById(`button-${buttonName}-${jobName}`); 129 | // Add 'clicked' class to apply the style 130 | button.classList.add('clicked'); 131 | 132 | const options = { 133 | method: 'POST' 134 | } 135 | await fetch(`/api/jobs/${jobName}/${buttonName}`, options) 136 | .then(updateJobActive(jobName)) 137 | 138 | setTimeout(function() { 139 | button.classList.remove('clicked'); 140 | }, 200); // 200 milliseconds delay 141 | } 142 | -------------------------------------------------------------------------------- /job.go: -------------------------------------------------------------------------------- 1 | package goflow 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "sync" 7 | "time" 8 | 9 | "github.com/philippgille/gokv" 10 | ) 11 | 12 | // A Job is a workflow consisting of independent and dependent tasks 13 | // organized into a graph. 14 | type Job struct { 15 | Name string 16 | Tasks map[string]*Task 17 | Schedule string 18 | Dag dag 19 | Active bool 20 | state state 21 | tasks []string 22 | sync.RWMutex 23 | } 24 | 25 | // Jobs and tasks are stateful. 26 | type state string 27 | 28 | const ( 29 | none state = "notstarted" 30 | running state = "running" 31 | upForRetry state = "upforretry" 32 | skipped state = "skipped" 33 | failed state = "failed" 34 | successful state = "successful" 35 | ) 36 | 37 | func (j *Job) loadState() state { 38 | if !j.allDone() { 39 | j.storeState(running) 40 | } 41 | if j.allSuccessful() { 42 | j.storeState(successful) 43 | } 44 | if j.allDone() && j.anyFailed() { 45 | j.storeState(failed) 46 | } 47 | return j.state 48 | } 49 | 50 | func (j *Job) loadTaskState(task string) state { 51 | j.RLock() 52 | result := none 53 | for _, t := range j.Tasks { 54 | if t.Name == task { 55 | result = t.state 56 | break 57 | } 58 | } 59 | j.RUnlock() 60 | return result 61 | } 62 | 63 | func (j *Job) storeState(value state) { 64 | j.Lock() 65 | j.state = value 66 | j.Unlock() 67 | } 68 | 69 | func (j *Job) storeTaskState(task string, value state) { 70 | j.Lock() 71 | for _, t := range j.Tasks { 72 | if t.Name == task { 73 | t.state = value 74 | } 75 | } 76 | j.Unlock() 77 | } 78 | 79 | type writeOp struct { 80 | key string 81 | val state 82 | } 83 | 84 | // Initialize a job. 85 | func (j *Job) initialize() *Job { 86 | j.Dag = make(dag) 87 | j.Tasks = make(map[string]*Task) 88 | j.tasks = make([]string, 0) 89 | j.storeState(none) 90 | return j 91 | } 92 | 93 | // Add a task to a job. 94 | func (j *Job) Add(t *Task) *Job { 95 | if j.Dag == nil { 96 | j.initialize() 97 | } 98 | 99 | if !(t.TriggerRule == allDone || t.TriggerRule == allSuccessful) { 100 | t.TriggerRule = allSuccessful 101 | } 102 | 103 | t.remaining = t.Retries 104 | 105 | j.Tasks[t.Name] = t 106 | j.tasks = append(j.tasks, t.Name) 107 | j.Dag.addNode(t.Name) 108 | j.storeTaskState(t.Name, none) 109 | return j 110 | } 111 | 112 | // Task getter 113 | func (j *Job) Task(name string) *Task { 114 | return j.Tasks[name] 115 | } 116 | 117 | // SetDownstream sets a dependency relationship between two tasks in the job. 118 | // The dependent task is downstream of the independent task and 119 | // waits for the independent task to finish before starting 120 | // execution. 121 | func (j *Job) SetDownstream(ind, dep *Task) *Job { 122 | j.Dag.setDownstream(ind.Name, dep.Name) 123 | return j 124 | } 125 | 126 | func (j *Job) run(store gokv.Store, e *execution) error { 127 | 128 | if !j.Dag.validate() { 129 | return fmt.Errorf("Invalid Dag for job %s", j.Name) 130 | } 131 | 132 | log.Printf("jobID=%v, jobname=%v, msg=starting", e.ID, j.Name) 133 | 134 | writes := make(chan writeOp) 135 | 136 | for { 137 | for _, task := range j.Tasks { 138 | 139 | // Start the independent tasks 140 | v := j.loadTaskState(task.Name) 141 | if v == none && !j.Dag.isDownstream(task.Name) { 142 | j.storeTaskState(task.Name, running) 143 | log.Printf("jobID=%v, job=%v, task=%v, msg=starting", e.ID, j.Name, task.Name) 144 | go task.run(writes) 145 | } 146 | 147 | // Start the tasks that need to be re-tried 148 | if v == upForRetry { 149 | task.RetryDelay.wait(task.Name, task.Retries-task.remaining) 150 | task.remaining = task.remaining - 1 151 | j.storeTaskState(task.Name, running) 152 | log.Printf("jobID=%v, job=%v, task=%v, msg=starting", e.ID, j.Name, task.Name) 153 | go task.run(writes) 154 | } 155 | 156 | // If dependencies are done, start the dependent tasks 157 | if v == none && j.Dag.isDownstream(task.Name) { 158 | upstreamDone := true 159 | upstreamSuccessful := true 160 | for _, us := range j.Dag.dependencies(task.Name) { 161 | w := j.loadTaskState(us) 162 | if w == none || w == running || w == upForRetry { 163 | upstreamDone = false 164 | } 165 | if w != successful { 166 | upstreamSuccessful = false 167 | } 168 | } 169 | 170 | if upstreamDone && task.TriggerRule == allDone { 171 | j.storeTaskState(task.Name, running) 172 | log.Printf("jobID=%v, job=%v, task=%v, msg=starting", e.ID, j.Name, task.Name) 173 | go task.run(writes) 174 | } 175 | 176 | if upstreamSuccessful && task.TriggerRule == allSuccessful { 177 | j.storeTaskState(task.Name, running) 178 | log.Printf("jobID=%v, job=%v, task=%v, msg=starting", e.ID, j.Name, task.Name) 179 | go task.run(writes) 180 | } 181 | 182 | if upstreamDone && !upstreamSuccessful && task.TriggerRule == allSuccessful { 183 | j.storeTaskState(task.Name, skipped) 184 | log.Printf("jobID=%v, job=%v, task=%v, msg=skipping", e.ID, j.Name, task.Name) 185 | go task.skip(writes) 186 | } 187 | 188 | } 189 | } 190 | 191 | // Receive updates on task state 192 | write := <-writes 193 | j.storeTaskState(write.key, write.val) 194 | log.Printf("jobID=%v, job=%v, task=%v, msg=%v", e.ID, j.Name, write.key, write.val) 195 | 196 | // Sync to store 197 | e.State = j.loadState() 198 | e.ModifiedTimestamp = time.Now().UTC().Format(time.RFC3339Nano) 199 | syncStateToStore(store, e, write.key, write.val) 200 | 201 | if j.allDone() { 202 | break 203 | } 204 | } 205 | 206 | log.Printf("jobID=%v, job=%v, msg=%v", e.ID, j.Name, j.loadState()) 207 | 208 | return nil 209 | } 210 | 211 | func (j *Job) allDone() bool { 212 | j.RLock() 213 | out := true 214 | for _, t := range j.Tasks { 215 | if t.state == none || t.state == running || t.state == upForRetry { 216 | out = false 217 | } 218 | } 219 | j.RUnlock() 220 | return out 221 | } 222 | 223 | func (j *Job) allSuccessful() bool { 224 | j.RLock() 225 | out := true 226 | for _, t := range j.Tasks { 227 | if t.state != successful { 228 | out = false 229 | } 230 | } 231 | j.RUnlock() 232 | return out 233 | } 234 | 235 | func (j *Job) anyFailed() bool { 236 | j.RLock() 237 | out := false 238 | for _, t := range j.Tasks { 239 | if t.state == failed { 240 | out = true 241 | } 242 | } 243 | j.RUnlock() 244 | return out 245 | } 246 | -------------------------------------------------------------------------------- /routes.go: -------------------------------------------------------------------------------- 1 | package goflow 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | func (g *Goflow) addStaticRoutes() *Goflow { 11 | g.router.Static("/css", g.Options.UIPath+"css") 12 | g.router.Static("/dist", g.Options.UIPath+"dist") 13 | g.router.Static("/src", g.Options.UIPath+"src") 14 | g.router.LoadHTMLGlob(g.Options.UIPath + "html/*.html.tmpl") 15 | return g 16 | } 17 | 18 | func (g *Goflow) addStreamRoute(keepOpen bool) *Goflow { 19 | g.router.GET("/stream", g.stream(keepOpen)) 20 | return g 21 | } 22 | 23 | type jobrun struct { 24 | JobName string `json:"job"` 25 | Submitted string `json:"submitted"` 26 | JobState jobstate `json:"state"` 27 | } 28 | 29 | type jobstate struct { 30 | State state `json:"job"` 31 | TaskState taskstate `json:"tasks"` 32 | } 33 | 34 | type taskstate struct { 35 | Taskstate map[string]state `json:"state"` 36 | } 37 | 38 | func (g *Goflow) addAPIRoutes() *Goflow { 39 | api := g.router.Group("/api") 40 | { 41 | api.GET("/health", func(c *gin.Context) { 42 | var msg struct { 43 | Health string `json:"health"` 44 | } 45 | msg.Health = "OK" 46 | c.JSON(http.StatusOK, msg) 47 | }) 48 | 49 | api.GET("/jobs", func(c *gin.Context) { 50 | var msg struct { 51 | Jobs []string `json:"jobs"` 52 | } 53 | msg.Jobs = g.jobs 54 | c.JSON(http.StatusOK, msg) 55 | }) 56 | 57 | // Deprecated: will be removed in v3.0.0 58 | api.GET("/jobruns", func(c *gin.Context) { 59 | jobName := c.Query("jobname") 60 | stateQuery := c.Query("state") 61 | 62 | jobruns := make([]jobrun, 0) 63 | 64 | for job := range g.Jobs { 65 | stored, _ := readExecutions(g.Store, job) 66 | for _, execution := range stored { 67 | if stateQuery != "" && stateQuery != string(execution.State) { 68 | } else if jobName != "" && jobName != execution.JobName { 69 | } else { 70 | 71 | t := taskstate{make(map[string]state, 0)} 72 | 73 | for _, task := range execution.TaskExecutions { 74 | t.Taskstate[task.Name] = task.State 75 | } 76 | 77 | j := jobrun{ 78 | JobName: job, 79 | Submitted: execution.StartedAt, 80 | JobState: jobstate{ 81 | State: execution.State, 82 | TaskState: t, 83 | }, 84 | } 85 | 86 | jobruns = append(jobruns, j) 87 | } 88 | } 89 | } 90 | 91 | var msg struct { 92 | Jobruns []jobrun `json:"jobruns"` 93 | } 94 | msg.Jobruns = jobruns 95 | 96 | c.JSON(http.StatusOK, msg) 97 | }) 98 | 99 | api.GET("/executions", func(c *gin.Context) { 100 | jobName := c.Query("jobname") 101 | stateQuery := c.Query("state") 102 | 103 | executions := make([]*execution, 0) 104 | 105 | for job := range g.Jobs { 106 | stored, _ := readExecutions(g.Store, job) 107 | for _, execution := range stored { 108 | if stateQuery != "" && stateQuery != string(execution.State) { 109 | } else if jobName != "" && jobName != execution.JobName { 110 | } else { 111 | executions = append(executions, execution) 112 | } 113 | } 114 | } 115 | 116 | var msg struct { 117 | Executions []*execution `json:"executions"` 118 | } 119 | msg.Executions = executions 120 | 121 | c.JSON(http.StatusOK, msg) 122 | }) 123 | 124 | api.GET("/jobs/:name", func(c *gin.Context) { 125 | name := c.Param("name") 126 | jobFn, ok := g.Jobs[name] 127 | 128 | var msg struct { 129 | JobName string `json:"job"` 130 | TaskNames []string `json:"tasks"` 131 | Dag dag `json:"dag"` 132 | Schedule string `json:"schedule"` 133 | Active bool `json:"active"` 134 | } 135 | 136 | if ok { 137 | msg.JobName = name 138 | msg.TaskNames = jobFn().tasks 139 | msg.Dag = jobFn().Dag 140 | msg.Schedule = g.Jobs[name]().Schedule 141 | 142 | // check if the job is active by looking in the list of cron entries 143 | for _, entry := range g.cron.Entries() { 144 | if jobName := entry.Job.(*scheduledExecution).jobFunc().Name; name == jobName { 145 | msg.Active = true 146 | } 147 | } 148 | 149 | c.JSON(http.StatusOK, msg) 150 | } else { 151 | c.JSON(http.StatusNotFound, msg) 152 | } 153 | }) 154 | 155 | api.POST("/jobs/:name/submit", func(c *gin.Context) { 156 | name := c.Param("name") 157 | _, ok := g.Jobs[name] 158 | 159 | var msg struct { 160 | Job string `json:"job"` 161 | Success bool `json:"success"` 162 | Submitted string `json:"submitted"` 163 | } 164 | msg.Job = name 165 | 166 | if ok { 167 | g.execute(name) 168 | msg.Success = true 169 | msg.Submitted = time.Now().UTC().Format(time.RFC3339Nano) 170 | c.JSON(http.StatusOK, msg) 171 | } else { 172 | msg.Success = false 173 | c.JSON(http.StatusNotFound, msg) 174 | } 175 | }) 176 | 177 | api.POST("/jobs/:name/toggle", func(c *gin.Context) { 178 | name := c.Param("name") 179 | _, ok := g.Jobs[name] 180 | 181 | var msg struct { 182 | Job string `json:"job"` 183 | Success bool `json:"success"` 184 | Active bool `json:"active"` 185 | } 186 | msg.Job = name 187 | 188 | if ok { 189 | isActive, _ := g.toggle(name) 190 | msg.Success = true 191 | msg.Active = isActive 192 | c.JSON(http.StatusOK, msg) 193 | } else { 194 | msg.Success = false 195 | c.JSON(http.StatusNotFound, msg) 196 | } 197 | }) 198 | } 199 | 200 | return g 201 | } 202 | 203 | func (g *Goflow) addUIRoutes() *Goflow { 204 | ui := g.router.Group("/ui") 205 | { 206 | ui.GET("/", func(c *gin.Context) { 207 | jobs := make([]*Job, 0) 208 | for _, job := range g.jobs { 209 | 210 | // create the job, assume it's inactive 211 | j := g.Jobs[job]() 212 | j.Active = false 213 | 214 | // check if the job is active by looking in the list of cron entries 215 | for _, entry := range g.cron.Entries() { 216 | if name := entry.Job.(*scheduledExecution).jobFunc().Name; name == j.Name { 217 | j.Active = true 218 | } 219 | } 220 | 221 | jobs = append(jobs, j) 222 | } 223 | c.HTML(http.StatusOK, "index.html.tmpl", gin.H{"jobs": jobs}) 224 | }) 225 | 226 | ui.GET("/jobs/:name", func(c *gin.Context) { 227 | name := c.Param("name") 228 | jobFn, ok := g.Jobs[name] 229 | 230 | if ok { 231 | c.HTML(http.StatusOK, "job.html.tmpl", gin.H{ 232 | "jobName": name, 233 | "taskNames": jobFn().tasks, 234 | "schedule": g.Jobs[name]().Schedule, 235 | }) 236 | } else { 237 | c.String(http.StatusNotFound, "Not found") 238 | } 239 | }) 240 | } 241 | 242 | g.router.GET("/", func(c *gin.Context) { 243 | c.Redirect(http.StatusFound, "/ui/") 244 | }) 245 | 246 | return g 247 | } 248 | -------------------------------------------------------------------------------- /goflow_test.go: -------------------------------------------------------------------------------- 1 | package goflow 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "testing" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/philippgille/gokv/gomap" 10 | ) 11 | 12 | var router = exampleRouter() 13 | 14 | type TestResponseRecorder struct { 15 | *httptest.ResponseRecorder 16 | closeChannel chan bool 17 | } 18 | 19 | func (r *TestResponseRecorder) CloseNotify() <-chan bool { 20 | return r.closeChannel 21 | } 22 | 23 | func CreateTestResponseRecorder() *TestResponseRecorder { 24 | return &TestResponseRecorder{ 25 | httptest.NewRecorder(), 26 | make(chan bool, 1), 27 | } 28 | } 29 | 30 | func TestIndexRoute(t *testing.T) { 31 | var w = httptest.NewRecorder() 32 | req, _ := http.NewRequest("GET", "/ui/", nil) 33 | router.ServeHTTP(w, req) 34 | 35 | if w.Code != http.StatusOK { 36 | t.Errorf("/ui/ status is %d, expected %d", w.Code, http.StatusOK) 37 | } 38 | 39 | req, _ = http.NewRequest("GET", "/", nil) 40 | router.ServeHTTP(w, req) 41 | 42 | if w.Code != http.StatusOK { 43 | t.Errorf("/ status is %d, expected %d", w.Code, http.StatusOK) 44 | } 45 | } 46 | 47 | func TestHealthRoute(t *testing.T) { 48 | var w = httptest.NewRecorder() 49 | req, _ := http.NewRequest("GET", "/api/health", nil) 50 | router.ServeHTTP(w, req) 51 | 52 | if w.Code != http.StatusOK { 53 | t.Errorf("httpStatus is %d, expected %d", w.Code, http.StatusOK) 54 | } 55 | } 56 | 57 | func TestJobsRoute(t *testing.T) { 58 | var w = httptest.NewRecorder() 59 | req, _ := http.NewRequest("GET", "/api/jobs", nil) 60 | router.ServeHTTP(w, req) 61 | 62 | if w.Code != http.StatusOK { 63 | t.Errorf("httpStatus is %d, expected %d", w.Code, http.StatusOK) 64 | } 65 | 66 | req, _ = http.NewRequest("GET", "/api/jobs/example-complex-analytics", nil) 67 | router.ServeHTTP(w, req) 68 | 69 | if w.Code != http.StatusOK { 70 | t.Errorf("httpStatus is %d, expected %d", w.Code, http.StatusOK) 71 | } 72 | } 73 | 74 | func TestJobRunsRoute(t *testing.T) { 75 | var w = httptest.NewRecorder() 76 | req, _ := http.NewRequest("GET", "/api/jobruns", nil) 77 | router.ServeHTTP(w, req) 78 | 79 | if w.Code != http.StatusOK { 80 | t.Errorf("httpStatus is %d, expected %d", w.Code, http.StatusOK) 81 | } 82 | } 83 | 84 | func TestExecutionsRoute(t *testing.T) { 85 | var w = httptest.NewRecorder() 86 | req, _ := http.NewRequest("GET", "/api/executions", nil) 87 | router.ServeHTTP(w, req) 88 | 89 | if w.Code != http.StatusOK { 90 | t.Errorf("httpStatus is %d, expected %d", w.Code, http.StatusOK) 91 | } 92 | } 93 | 94 | func TestJobSubmitToRouter(t *testing.T) { 95 | var w = httptest.NewRecorder() 96 | req, _ := http.NewRequest("POST", "/api/jobs/example-complex-analytics/submit", nil) 97 | 98 | router.ServeHTTP(w, req) 99 | 100 | if w.Code != http.StatusOK { 101 | t.Errorf("httpStatus is %d, expected %d", w.Code, http.StatusOK) 102 | } 103 | 104 | w = httptest.NewRecorder() 105 | req, _ = http.NewRequest("POST", "/api/jobs/example-custom-operator/submit", nil) 106 | router.ServeHTTP(w, req) 107 | 108 | if w.Code != http.StatusOK { 109 | t.Errorf("httpStatus is %d, expected %d", w.Code, http.StatusOK) 110 | } 111 | 112 | w = httptest.NewRecorder() 113 | req, _ = http.NewRequest("POST", "/api/jobs/bla/submit", nil) 114 | router.ServeHTTP(w, req) 115 | 116 | if w.Code != http.StatusNotFound { 117 | t.Errorf("httpStatus is %d, expected %d", w.Code, http.StatusNotFound) 118 | } 119 | } 120 | 121 | func TestJobToggleActiveRoute(t *testing.T) { 122 | var w = httptest.NewRecorder() 123 | req, _ := http.NewRequest("POST", "/api/jobs/example-complex-analytics/toggle", nil) 124 | router.ServeHTTP(w, req) 125 | 126 | if w.Code != http.StatusOK { 127 | t.Errorf("httpStatus is %d, expected %d", w.Code, http.StatusOK) 128 | } 129 | 130 | req, _ = http.NewRequest("POST", "/api/jobs/example-custom-operator/toggle", nil) 131 | router.ServeHTTP(w, req) 132 | 133 | if w.Code != http.StatusOK { 134 | t.Errorf("httpStatus is %d, expected %d", w.Code, http.StatusOK) 135 | } 136 | 137 | w = httptest.NewRecorder() 138 | req, _ = http.NewRequest("POST", "/api/jobs/bla/toggle", nil) 139 | router.ServeHTTP(w, req) 140 | 141 | if w.Code != http.StatusNotFound { 142 | t.Errorf("httpStatus is %d, expected %d", w.Code, http.StatusNotFound) 143 | } 144 | } 145 | 146 | func TestRouteNotFound(t *testing.T) { 147 | var w = httptest.NewRecorder() 148 | req, _ := http.NewRequest("GET", "/blaaaa", nil) 149 | router.ServeHTTP(w, req) 150 | 151 | if w.Code != http.StatusNotFound { 152 | t.Errorf("httpStatus is %d, expected %d", w.Code, http.StatusNotFound) 153 | } 154 | 155 | req, _ = http.NewRequest("GET", "/api/jobs/blaaaa", nil) 156 | router.ServeHTTP(w, req) 157 | 158 | if w.Code != http.StatusNotFound { 159 | t.Errorf("httpStatus is %d, expected %d", w.Code, http.StatusNotFound) 160 | } 161 | 162 | req, _ = http.NewRequest("GET", "/ui/jobs/blaaaa", nil) 163 | router.ServeHTTP(w, req) 164 | 165 | if w.Code != http.StatusNotFound { 166 | t.Errorf("httpStatus is %d, expected %d", w.Code, http.StatusNotFound) 167 | } 168 | } 169 | 170 | func TestJobOverviewRoute(t *testing.T) { 171 | var w = httptest.NewRecorder() 172 | req, _ := http.NewRequest("GET", "/ui/jobs/example-complex-analytics", nil) 173 | router.ServeHTTP(w, req) 174 | 175 | if w.Code != http.StatusOK { 176 | t.Errorf("httpStatus is %d, expected %d", w.Code, http.StatusOK) 177 | } 178 | 179 | w = httptest.NewRecorder() 180 | req, _ = http.NewRequest("GET", "/jobs/bla", nil) 181 | router.ServeHTTP(w, req) 182 | 183 | if w.Code != http.StatusNotFound { 184 | t.Errorf("httpStatus is %d, expected %d", w.Code, http.StatusNotFound) 185 | } 186 | } 187 | 188 | func TestStreamRoute(t *testing.T) { 189 | var w = CreateTestResponseRecorder() 190 | req, _ := http.NewRequest("GET", "/stream", nil) 191 | router.ServeHTTP(w, req) 192 | 193 | if w.Code != http.StatusOK { 194 | t.Errorf("httpStatus is %d, expected %d", w.Code, http.StatusOK) 195 | } 196 | 197 | w = CreateTestResponseRecorder() 198 | req, _ = http.NewRequest("GET", "/stream?jobname=example-complex-analytics", nil) 199 | router.ServeHTTP(w, req) 200 | 201 | if w.Code != http.StatusOK { 202 | t.Errorf("httpStatus is %d, expected %d", w.Code, http.StatusOK) 203 | } 204 | } 205 | 206 | // check for a race against /stream 207 | func TestToggleRaceCondition(t *testing.T) { 208 | var w = httptest.NewRecorder() 209 | req, _ := http.NewRequest("POST", "/api/jobs/example-complex-analytics/toggle", nil) 210 | router.ServeHTTP(w, req) 211 | } 212 | 213 | func exampleRouter() *gin.Engine { 214 | g := New(Options{UIPath: "ui/", ShowExamples: true, WithSeconds: true}) 215 | g.execute("example-custom-operator") 216 | g.Use(DefaultLogger()) 217 | g.addStaticRoutes() 218 | g.addStreamRoute(false) 219 | g.addUIRoutes() 220 | g.addAPIRoutes() 221 | return g.router 222 | } 223 | 224 | func TestScheduledExecution(t *testing.T) { 225 | store := gomap.NewStore(gomap.DefaultOptions) 226 | schedExec := scheduledExecution{store, customOperatorJob} 227 | schedExec.Run() 228 | } 229 | 230 | func TestGoflowWithoutOptions(t *testing.T) { 231 | g := New(Options{}) 232 | g.Use(DefaultLogger()) 233 | } 234 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= 2 | github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= 3 | github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= 4 | github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= 5 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= 6 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/ef-ds/deque v1.0.4 h1:iFAZNmveMT9WERAkqLJ+oaABF9AcVQ5AjXem/hroniI= 11 | github.com/ef-ds/deque v1.0.4/go.mod h1:gXDnTC3yqvBcHbq2lcExjtAcVrOnJCbMcZXmuj8Z4tg= 12 | github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= 13 | github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= 14 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 15 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 16 | github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= 17 | github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= 18 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 19 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 20 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 21 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 22 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 23 | github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= 24 | github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 25 | github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= 26 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 27 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 28 | github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= 29 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 30 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 31 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 32 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 33 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 34 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 35 | github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= 36 | github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= 37 | github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= 38 | github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= 39 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 40 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 41 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 42 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 43 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 44 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 45 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 46 | github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= 47 | github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= 48 | github.com/philippgille/gokv v0.7.0 h1:rQSIQspete82h78Br7k7rKUZ8JYy/hWlwzm/W5qobPI= 49 | github.com/philippgille/gokv v0.7.0/go.mod h1:OwiTP/3bhEBhSuOmFmq1+rszglfSgjJVxd1HOgOa2N4= 50 | github.com/philippgille/gokv/encoding v0.7.0 h1:2oxepKzzTsi00iLZBCZ7Rmqrallh9zws3iqSrLGfkgo= 51 | github.com/philippgille/gokv/encoding v0.7.0/go.mod h1:yncOBBUciyniPI8t5ECF8XSCwhONE9Rjf3My5IHs3fA= 52 | github.com/philippgille/gokv/gomap v0.7.0 h1:RR+cgJl1aMxw8CkxGczRwCbC42tHJ7cRwaaD4Ycgg9k= 53 | github.com/philippgille/gokv/gomap v0.7.0/go.mod h1:HJ+PC2y/knRG2RrdH81N+BkjDhmbPQMUj+tRHgarvSg= 54 | github.com/philippgille/gokv/test v0.7.0 h1:0wBKnKaFZlSeHxLXcmUJqK//IQGUMeu+o8B876KCiOM= 55 | github.com/philippgille/gokv/util v0.7.0 h1:5avUK/a3aSj/aWjhHv4/FkqgMon2B7k2BqFgLcR+DYg= 56 | github.com/philippgille/gokv/util v0.7.0/go.mod h1:i9KLHbPxGiHLMhkix/CcDQhpPbCkJy5BkW+RKgwDHMo= 57 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 58 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 59 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= 60 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 61 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 62 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 63 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 64 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 65 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 66 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 67 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 68 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 69 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 70 | github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= 71 | github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 72 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 73 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 74 | github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= 75 | github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 76 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 77 | golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= 78 | golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 79 | golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= 80 | golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= 81 | golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= 82 | golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= 83 | golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 84 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 85 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 86 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 87 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 88 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 89 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 90 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 91 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 92 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 93 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 94 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 95 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 96 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 97 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 98 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 99 | -------------------------------------------------------------------------------- /swagger.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "title": "Goflow API", 5 | "version": "2.0.0" 6 | }, 7 | "paths": { 8 | "/api/health": { 9 | "get": { 10 | "operationId": "health", 11 | "summary": "check health of the service", 12 | "responses": { 13 | "200": { 14 | "description": "200 response", 15 | "content": { 16 | "application/json": { 17 | "examples": { 18 | "OK": { 19 | "value": { 20 | "health": "OK" 21 | } 22 | } 23 | } 24 | } 25 | } 26 | } 27 | } 28 | } 29 | }, 30 | "/api/jobs": { 31 | "get": { 32 | "operationId": "listJobs", 33 | "summary": "list jobs", 34 | "responses": { 35 | "200": { 36 | "description": "200 response", 37 | "content": { 38 | "application/json": { 39 | "examples": { 40 | "exampleJobs": { 41 | "value": { 42 | "jobs": [ 43 | "exampleComplexAnalytics", 44 | "exampleCustomOperator" 45 | ] 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | } 53 | } 54 | }, 55 | "/api/jobs/{jobname}": { 56 | "get": { 57 | "operationId": "jobDetails", 58 | "summary": "get the details for a given job", 59 | "parameters": [ 60 | { 61 | "in": "path", 62 | "name": "jobname" 63 | } 64 | ], 65 | "responses": { 66 | "200": { 67 | "description": "200 response", 68 | "content": { 69 | "application/json": { 70 | "examples": { 71 | "complexAnalyticsJob": { 72 | "value": { 73 | "job": "exampleComplexAnalytics", 74 | "tasks": [ 75 | "sleepOne", 76 | "addTwoFour", 77 | "addThreeFour", 78 | "whoopsWithConstantDelay", 79 | "whoopsWithExponentialBackoff", 80 | "totallySkippable", 81 | "cleanUp", 82 | "addOneOne", 83 | "sleepTwo" 84 | ], 85 | "dag": { 86 | "addOneOne": [ 87 | "sleepTwo", 88 | "addThreeFour" 89 | ], 90 | "addThreeFour": [], 91 | "addTwoFour": [], 92 | "cleanUp": [], 93 | "sleepOne": [ 94 | "addOneOne", 95 | "whoopsWithConstantDelay", 96 | "whoopsWithExponentialBackoff" 97 | ], 98 | "sleepTwo": [ 99 | "addTwoFour" 100 | ], 101 | "totallySkippable": [ 102 | "cleanUp" 103 | ], 104 | "whoopsWithConstantDelay": [ 105 | "totallySkippable" 106 | ], 107 | "whoopsWithExponentialBackoff": [ 108 | "totallySkippable" 109 | ] 110 | }, 111 | "schedule": "* * * * *", 112 | "active": false 113 | } 114 | } 115 | } 116 | } 117 | } 118 | } 119 | } 120 | } 121 | }, 122 | "/api/jobruns": { 123 | "get": { 124 | "operationId": "listJobRuns", 125 | "summary": "query and list job runs", 126 | "parameters": [ 127 | { 128 | "in": "query", 129 | "name": "jobname", 130 | "schema": { 131 | "type": "string" 132 | }, 133 | "description": "(optional) the job name" 134 | }, 135 | { 136 | "in": "query", 137 | "name": "state", 138 | "schema": { 139 | "type": "string" 140 | }, 141 | "description": "(optional) the job state, valid values are [running, failed, successful]" 142 | } 143 | ], 144 | "responses": { 145 | "200": { 146 | "description": "200 response", 147 | "content": { 148 | "application/json": { 149 | "examples": { 150 | "exampleJobRuns": { 151 | "value": { 152 | "jobruns": [ 153 | { 154 | "job": "exampleComplexAnalytics", 155 | "submitted": "2023-06-24T07:23:11.208506156Z", 156 | "state": { 157 | "job": "running", 158 | "tasks": { 159 | "state": { 160 | "addOneOne": "running", 161 | "addThreeFour": "notstarted", 162 | "addTwoFour": "notstarted", 163 | "cleanUp": "notstarted", 164 | "sleepOne": "successful", 165 | "sleepTwo": "notstarted", 166 | "totallySkippable": "notstarted", 167 | "whoopsWithConstantDelay": "running", 168 | "whoopsWithExponentialBackoff": "upforretry" 169 | } 170 | } 171 | } 172 | } 173 | ] 174 | } 175 | } 176 | } 177 | } 178 | } 179 | } 180 | } 181 | } 182 | }, 183 | "/api/executions": { 184 | "get": { 185 | "operationId": "listExecutions", 186 | "summary": "query and list job executions", 187 | "parameters": [ 188 | { 189 | "in": "query", 190 | "name": "jobname", 191 | "schema": { 192 | "type": "string" 193 | }, 194 | "description": "(optional) the job name" 195 | }, 196 | { 197 | "in": "query", 198 | "name": "state", 199 | "schema": { 200 | "type": "string" 201 | }, 202 | "description": "(optional) the job state, valid values are [running, failed, successful]" 203 | } 204 | ], 205 | "responses": { 206 | "200": { 207 | "description": "200 response", 208 | "content": { 209 | "application/json": { 210 | "examples": { 211 | "exampleJobRuns": { 212 | "value": { 213 | "executions": [ 214 | { 215 | "id": "b43e5f75-aa2a-4859-b6b9-f551ca258196", 216 | "job": "example-complex-analytics", 217 | "submitted": "2024-02-03T13:26:42.038130297Z", 218 | "state": "failed", 219 | "tasks": [ 220 | { 221 | "name": "sleep-one", 222 | "state": "successful" 223 | }, 224 | { 225 | "name": "add-one-one", 226 | "state": "successful" 227 | }, 228 | { 229 | "name": "sleep-two", 230 | "state": "successful" 231 | }, 232 | { 233 | "name": "add-two-four", 234 | "state": "successful" 235 | }, 236 | { 237 | "name": "add-three-four", 238 | "state": "successful" 239 | }, 240 | { 241 | "name": "whoops-with-constant-delay", 242 | "state": "failed" 243 | }, 244 | { 245 | "name": "whoops-with-exponential-backoff", 246 | "state": "failed" 247 | }, 248 | { 249 | "name": "totally-skippable", 250 | "state": "skipped" 251 | }, 252 | { 253 | "name": "clean-up", 254 | "state": "successful" 255 | } 256 | ] 257 | } 258 | ] 259 | } 260 | } 261 | } 262 | } 263 | } 264 | } 265 | } 266 | } 267 | }, 268 | "/api/jobs/{jobname}/submit": { 269 | "post": { 270 | "operationId": "submitJob", 271 | "summary": "submit a job for execution", 272 | "parameters": [ 273 | { 274 | "in": "path", 275 | "name": "jobname" 276 | } 277 | ], 278 | "responses": { 279 | "200": { 280 | "description": "200 response", 281 | "content": { 282 | "application/json": { 283 | "examples": { 284 | "customOperator": { 285 | "value": { 286 | "job": "exampleCustomOperator", 287 | "success": true, 288 | "submitted": "2023-06-21T15:02:39.943428403Z" 289 | } 290 | } 291 | } 292 | } 293 | } 294 | } 295 | } 296 | } 297 | }, 298 | "/api/jobs/{jobname}/toggle": { 299 | "post": { 300 | "operationId": "toggleJobSchedule", 301 | "summary": "toggle a job schedule on or off", 302 | "parameters": [ 303 | { 304 | "in": "path", 305 | "name": "jobname" 306 | } 307 | ], 308 | "responses": { 309 | "200": { 310 | "description": "200 response", 311 | "content": { 312 | "application/json": { 313 | "examples": { 314 | "customOperator": { 315 | "value": { 316 | "job": "exampleCustomOperator", 317 | "success": true, 318 | "active": true 319 | } 320 | } 321 | } 322 | } 323 | } 324 | } 325 | } 326 | } 327 | } 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | ![Build Status](https://github.com/fieldryand/goflow/actions/workflows/go.yml/badge.svg) 2 | [![codecov](https://codecov.io/gh/fieldryand/goflow/branch/master/graph/badge.svg)](https://codecov.io/gh/fieldryand/goflow) 3 | [![Go Report Card](https://goreportcard.com/badge/github.com/fieldryand/goflow)](https://goreportcard.com/report/github.com/fieldryand/goflow) 4 | [![GoDoc](https://pkg.go.dev/badge/github.com/fieldryand/goflow/v2?status.svg)](https://pkg.go.dev/github.com/fieldryand/goflow/v2?tab=doc) 5 | [![Release](https://img.shields.io/github/v/release/fieldryand/goflow)](https://github.com/fieldryand/goflow/releases) 6 | 7 | # Goflow 8 | 9 | A simple but powerful DAG scheduler and dashboard, written in Go. 10 | 11 | ![goflow-demo](https://user-images.githubusercontent.com/3333324/147818084-ade84547-4404-4d58-a697-c18ecb06fd30.gif) 12 | 13 | ------ 14 | 15 | **Use it if:** 16 | - you need a directed acyclic graph (DAG) scheduler like Apache Airflow, but without the complexity. 17 | - you have a variety of clusters or services performing heavy computations and you want something small and light to orchestrate them. 18 | - you want a monitoring dashboard. 19 | - you want the easiest possible deployment with a single binary or container, saving you time. Volume mounts etc are too much headache. 20 | - you want it to run on a single tiny VM, saving on cloud costs. 21 | - you want to choose your storage technology--embedded, Postgres, Redis, S3, DynamoDB or something else. 22 | - you prefer to define your DAGs with code rather than configuration files. This approach can make it easier to manage complex DAGs. 23 | 24 | **Don't use it if:** 25 | - you need to queue a huge number of tasks. Goflow is not tested at massive scale and does not support horizontal scaling. 26 | 27 | ## Contents 28 | 29 | - [Quick start](#quick-start) 30 | - [With Docker](#with-docker) 31 | - [Without Docker](#without-docker) 32 | - [Development overview](#development-overview) 33 | - [Jobs and tasks](#jobs-and-tasks) 34 | - [Custom Operators](#custom-operators) 35 | - [Retries](#retries) 36 | - [Task dependencies](#task-dependencies) 37 | - [Trigger rules](#trigger-rules) 38 | - [The Goflow engine](#the-goflow-engine) 39 | - [Available operators](#available-operators) 40 | - [Storage](#storage) 41 | - [API and integration](#api-and-integration) 42 | 43 | ## Quick start 44 | 45 | ### With Docker 46 | 47 | ```shell 48 | docker run -p 8181:8181 ghcr.io/fieldryand/goflow-example:latest 49 | ``` 50 | 51 | Check out the dashboard at `localhost:8181`. 52 | 53 | ### Without Docker 54 | 55 | In a fresh project directory: 56 | 57 | ```shell 58 | go mod init # create a new module 59 | go get github.com/fieldryand/goflow/v2 # install dependencies 60 | ``` 61 | 62 | Create a file `main.go` with contents: 63 | ```go 64 | package main 65 | 66 | import "github.com/fieldryand/goflow/v2" 67 | 68 | func main() { 69 | options := goflow.Options{ 70 | UIPath: "ui/", 71 | ShowExamples: true, 72 | WithSeconds: true, 73 | } 74 | gf := goflow.New(options) 75 | gf.Use(goflow.DefaultLogger()) 76 | gf.Run(":8181") 77 | } 78 | ``` 79 | 80 | Download and untar the dashboard: 81 | 82 | ```shell 83 | wget https://github.com/fieldryand/goflow/releases/latest/download/goflow-ui.tar.gz 84 | tar -xvzf goflow-ui.tar.gz 85 | rm goflow-ui.tar.gz 86 | ``` 87 | 88 | Now run the application with `go run main.go` and see it in the browser at localhost:8181. 89 | 90 | ## Development overview 91 | 92 | First a few definitions. 93 | 94 | - `Job`: A Goflow workflow is called a `Job`. Jobs can be scheduled using cron syntax. 95 | - `Task`: Each job consists of one or more tasks organized into a dependency graph. A task can be run under certain conditions; by default, a task runs when all of its dependencies finish successfully. 96 | - Concurrency: Jobs and tasks execute concurrently. 97 | - `Operator`: An `Operator` defines the work done by a `Task`. Goflow comes with a handful of basic operators, and implementing your own `Operator` is straightforward. 98 | - Retries: You can allow a `Task` a given number of retry attempts. Goflow comes with two retry strategies, `ConstantDelay` and `ExponentialBackoff`. 99 | - Streaming: Goflow uses server-sent events to stream the status of jobs and tasks to the dashboard in real time. 100 | 101 | ### Jobs and tasks 102 | 103 | Let's start by creating a function that returns a job called `my-job`. There is a single task in this job that sleeps for one second. 104 | 105 | ```go 106 | package main 107 | 108 | import ( 109 | "errors" 110 | 111 | "github.com/fieldryand/goflow/v2" 112 | ) 113 | 114 | func myJob() *goflow.Job { 115 | j := &goflow.Job{Name: "my-job", Schedule: "* * * * *", Active: true} 116 | j.Add(&goflow.Task{ 117 | Name: "sleep-for-one-second", 118 | Operator: goflow.Command{Cmd: "sleep", Args: []string{"1"}}, 119 | }) 120 | return j 121 | } 122 | ``` 123 | 124 | By setting `Active: true`, we are telling Goflow to apply the provided cron schedule for this job when the application starts. 125 | Job scheduling can be activated and deactivated from the dashboard. 126 | 127 | ### Custom operators 128 | 129 | A custom `Operator` needs to implement the `Run` method. Here's an example of an operator that adds two positive numbers. 130 | 131 | ```go 132 | type PositiveAddition struct{ a, b int } 133 | 134 | func (o PositiveAddition) Run() (interface{}, error) { 135 | if o.a < 0 || o.b < 0 { 136 | return 0, errors.New("Can't add negative numbers") 137 | } 138 | result := o.a + o.b 139 | return result, nil 140 | } 141 | ``` 142 | 143 | ### Retries 144 | 145 | Let's add a retry strategy to the `sleep-for-one-second` task: 146 | 147 | ```go 148 | func myJob() *goflow.Job { 149 | j := &goflow.Job{Name: "my-job", Schedule: "* * * * *"} 150 | j.Add(&goflow.Task{ 151 | Name: "sleep-for-one-second", 152 | Operator: goflow.Command{Cmd: "sleep", Args: []string{"1"}}, 153 | Retries: 5, 154 | RetryDelay: goflow.ConstantDelay{Period: 1}, 155 | }) 156 | return j 157 | } 158 | ``` 159 | 160 | Instead of `ConstantDelay`, we could also use `ExponentialBackoff` (see https://en.wikipedia.org/wiki/Exponential_backoff). 161 | 162 | ### Task dependencies 163 | 164 | A job can define a directed acyclic graph (DAG) of independent and dependent tasks. Let's use the `SetDownstream` method to 165 | define two tasks that are dependent on `sleep-for-one-second`. The tasks will use the `PositiveAddition` operator we defined earlier, 166 | as well as a new operator provided by Goflow, `Get`. 167 | 168 | ```go 169 | func myJob() *goflow.Job { 170 | j := &goflow.Job{Name: "my-job", Schedule: "* * * * *"} 171 | j.Add(&goflow.Task{ 172 | Name: "sleep-for-one-second", 173 | Operator: goflow.Command{Cmd: "sleep", Args: []string{"1"}}, 174 | Retries: 5, 175 | RetryDelay: goflow.ConstantDelay{Period: 1}, 176 | }) 177 | j.Add(&goflow.Task{ 178 | Name: "get-google", 179 | Operator: goflow.Get{Client: &http.Client{}, URL: "https://www.google.com"}, 180 | }) 181 | j.Add(&goflow.Task{ 182 | Name: "add-two-plus-three", 183 | Operator: PositiveAddition{a: 2, b: 3}, 184 | }) 185 | j.SetDownstream(j.Task("sleep-for-one-second"), j.Task("get-google")) 186 | j.SetDownstream(j.Task("sleep-for-one-second"), j.Task("add-two-plus-three")) 187 | return j 188 | } 189 | ``` 190 | 191 | ### Trigger rules 192 | 193 | By default, a task has the trigger rule `allSuccessful`, meaning the task starts executing when all the tasks directly 194 | upstream exit successfully. If any dependency exits with an error, all downstream tasks are skipped, and the job exits with an error. 195 | 196 | Sometimes you want a downstream task to execute even if there are upstream failures. Often these are situations where you want 197 | to perform some cleanup task, such as shutting down a server. In such cases, you can give a task the trigger rule `allDone`. 198 | 199 | Let's modify `sleep-for-one-second` to have the trigger rule `allDone`. 200 | 201 | 202 | ```go 203 | func myJob() *goflow.Job { 204 | // other stuff 205 | j.Add(&goflow.Task{ 206 | Name: "sleep-for-one-second", 207 | Operator: goflow.Command{Cmd: "sleep", Args: []string{"1"}}, 208 | Retries: 5, 209 | RetryDelay: goflow.ConstantDelay{Period: 1}, 210 | TriggerRule: "allDone", 211 | }) 212 | // other stuff 213 | } 214 | ``` 215 | 216 | ### The Goflow Engine 217 | 218 | Finally, let's create a Goflow engine, register our job, attach a logger, and run the application. 219 | 220 | ```go 221 | func main() { 222 | gf := goflow.New(goflow.Options{Streaming: true}) 223 | gf.AddJob(myJob) 224 | gf.Use(goflow.DefaultLogger()) 225 | gf.Run(":8181") 226 | } 227 | ``` 228 | 229 | You can pass different options to the engine. Options currently supported: 230 | - `Store`: This is [described in more detail below.](#storage) 231 | - `UIPath`: The path to the dashboard code. The default value is an empty string, meaning Goflow serves only the API and not the dashboard. Suggested value if you want the dashboard: `ui/` 232 | - `ShowExamples`: Whether to show the example jobs. Default value: `false` 233 | - `WithSeconds`: Whether to include the seconds field in the cron spec. See the [cron package documentation](https://github.com/robfig/cron) for details. Default value: `false` 234 | 235 | Goflow is built on the [Gin framework](https://github.com/gin-gonic/gin), so you can pass any Gin handler to `Use`. 236 | 237 | ### Available operators 238 | 239 | Goflow provides several operators for common tasks. [See the package documentation](https://pkg.go.dev/github.com/fieldryand/goflow) for details on each. 240 | 241 | - `Command` executes a shell command. 242 | - `Get` makes a GET request. 243 | - `Post` makes a POST request. 244 | 245 | ## Storage 246 | 247 | For persisting your job execution history, Goflow allows you to plug in many different key-value stores thanks to the [excellent gokv package](https://github.com/philippgille/gokv/). This way you can recover from a crash or deploy a new version of your app without losing your data. 248 | 249 | > Note: the gokv API is not yet stable. Goflow has been tested against v0.6.0. 250 | 251 | By default, Goflow uses an in-memory database, but you can easily replace it with Postgres, Redis, S3 or any other `gokv.Store`. Here is an example: 252 | 253 | ```go 254 | package main 255 | 256 | import "github.com/fieldryand/goflow/v2" 257 | import "github.com/philippgille/gokv/redis" 258 | 259 | func main() { 260 | // create a storage client 261 | client, err := redis.NewClient(redis.DefaultOptions) 262 | if err != nil { 263 | panic(err) 264 | } 265 | defer client.Close() 266 | 267 | // pass the client as a Goflow option 268 | options := goflow.Options{ 269 | Store: client, 270 | UIPath: "ui/", 271 | Streaming: true, 272 | ShowExamples: true, 273 | } 274 | gf := goflow.New(options) 275 | gf.Use(goflow.DefaultLogger()) 276 | gf.Run(":8181") 277 | } 278 | ``` 279 | 280 | 281 | ## API and integration 282 | 283 | You can use the API to integrate Goflow with other applications, such as an existing dashboard. Here is an overview of available endpoints: 284 | - `GET /api/health`: Check health of the service 285 | - `GET /api/jobs`: List registered jobs 286 | - `GET /api/jobs/{jobname}`: Get the details for a given job 287 | - `GET /api/executions`: Query and list job executions 288 | - `POST /api/jobs/{jobname}/submit`: Submit a job for execution 289 | - `POST /api/jobs/{jobname}/toggle`: Toggle a job schedule on or off 290 | - `/stream`: This endpoint returns Server-Sent Events with a `data` payload matching the one returned by `/api/executions`. The dashboard that ships with Goflow uses this endpoint. 291 | 292 | Check out the OpenAPI spec for more details. Easiest way is to clone the repo, then within the repo use Swagger as in the following: 293 | 294 | ```shell 295 | docker run -p 8080:8080 -e SWAGGER_JSON=/app/swagger.json -v $(pwd):/app swaggerapi/swagger-ui 296 | ``` 297 | -------------------------------------------------------------------------------- /ui/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "goflow-ui", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "goflow-ui", 9 | "version": "1.0.0", 10 | "license": "ISC", 11 | "dependencies": { 12 | "d3-color": "^3.1.0", 13 | "dagre-d3": "^0.6.4", 14 | "webpack": "^5.76.0", 15 | "webpack-cli": "^4.6.0" 16 | } 17 | }, 18 | "node_modules/@discoveryjs/json-ext": { 19 | "version": "0.5.2", 20 | "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.2.tgz", 21 | "integrity": "sha512-HyYEUDeIj5rRQU2Hk5HTB2uHsbRQpF70nvMhVzi+VJR0X+xNEhjPui4/kBf3VeH/wqD28PT4sVOm8qqLjBrSZg==", 22 | "engines": { 23 | "node": ">=10.0.0" 24 | } 25 | }, 26 | "node_modules/@jridgewell/gen-mapping": { 27 | "version": "0.3.2", 28 | "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz", 29 | "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==", 30 | "dependencies": { 31 | "@jridgewell/set-array": "^1.0.1", 32 | "@jridgewell/sourcemap-codec": "^1.4.10", 33 | "@jridgewell/trace-mapping": "^0.3.9" 34 | }, 35 | "engines": { 36 | "node": ">=6.0.0" 37 | } 38 | }, 39 | "node_modules/@jridgewell/resolve-uri": { 40 | "version": "3.1.0", 41 | "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", 42 | "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", 43 | "engines": { 44 | "node": ">=6.0.0" 45 | } 46 | }, 47 | "node_modules/@jridgewell/set-array": { 48 | "version": "1.1.2", 49 | "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", 50 | "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", 51 | "engines": { 52 | "node": ">=6.0.0" 53 | } 54 | }, 55 | "node_modules/@jridgewell/source-map": { 56 | "version": "0.3.2", 57 | "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz", 58 | "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==", 59 | "dependencies": { 60 | "@jridgewell/gen-mapping": "^0.3.0", 61 | "@jridgewell/trace-mapping": "^0.3.9" 62 | } 63 | }, 64 | "node_modules/@jridgewell/sourcemap-codec": { 65 | "version": "1.4.14", 66 | "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", 67 | "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==" 68 | }, 69 | "node_modules/@jridgewell/trace-mapping": { 70 | "version": "0.3.17", 71 | "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz", 72 | "integrity": "sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g==", 73 | "dependencies": { 74 | "@jridgewell/resolve-uri": "3.1.0", 75 | "@jridgewell/sourcemap-codec": "1.4.14" 76 | } 77 | }, 78 | "node_modules/@types/eslint": { 79 | "version": "8.21.2", 80 | "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.21.2.tgz", 81 | "integrity": "sha512-EMpxUyystd3uZVByZap1DACsMXvb82ypQnGn89e1Y0a+LYu3JJscUd/gqhRsVFDkaD2MIiWo0MT8EfXr3DGRKw==", 82 | "dependencies": { 83 | "@types/estree": "*", 84 | "@types/json-schema": "*" 85 | } 86 | }, 87 | "node_modules/@types/eslint-scope": { 88 | "version": "3.7.4", 89 | "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz", 90 | "integrity": "sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==", 91 | "dependencies": { 92 | "@types/eslint": "*", 93 | "@types/estree": "*" 94 | } 95 | }, 96 | "node_modules/@types/estree": { 97 | "version": "0.0.51", 98 | "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz", 99 | "integrity": "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==" 100 | }, 101 | "node_modules/@types/json-schema": { 102 | "version": "7.0.11", 103 | "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", 104 | "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==" 105 | }, 106 | "node_modules/@types/node": { 107 | "version": "18.15.3", 108 | "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.3.tgz", 109 | "integrity": "sha512-p6ua9zBxz5otCmbpb5D3U4B5Nanw6Pk3PPyX05xnxbB/fRv71N7CPmORg7uAD5P70T0xmx1pzAx/FUfa5X+3cw==" 110 | }, 111 | "node_modules/@webassemblyjs/ast": { 112 | "version": "1.11.1", 113 | "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz", 114 | "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==", 115 | "dependencies": { 116 | "@webassemblyjs/helper-numbers": "1.11.1", 117 | "@webassemblyjs/helper-wasm-bytecode": "1.11.1" 118 | } 119 | }, 120 | "node_modules/@webassemblyjs/floating-point-hex-parser": { 121 | "version": "1.11.1", 122 | "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz", 123 | "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==" 124 | }, 125 | "node_modules/@webassemblyjs/helper-api-error": { 126 | "version": "1.11.1", 127 | "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz", 128 | "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==" 129 | }, 130 | "node_modules/@webassemblyjs/helper-buffer": { 131 | "version": "1.11.1", 132 | "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz", 133 | "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==" 134 | }, 135 | "node_modules/@webassemblyjs/helper-numbers": { 136 | "version": "1.11.1", 137 | "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz", 138 | "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==", 139 | "dependencies": { 140 | "@webassemblyjs/floating-point-hex-parser": "1.11.1", 141 | "@webassemblyjs/helper-api-error": "1.11.1", 142 | "@xtuc/long": "4.2.2" 143 | } 144 | }, 145 | "node_modules/@webassemblyjs/helper-wasm-bytecode": { 146 | "version": "1.11.1", 147 | "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz", 148 | "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==" 149 | }, 150 | "node_modules/@webassemblyjs/helper-wasm-section": { 151 | "version": "1.11.1", 152 | "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz", 153 | "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==", 154 | "dependencies": { 155 | "@webassemblyjs/ast": "1.11.1", 156 | "@webassemblyjs/helper-buffer": "1.11.1", 157 | "@webassemblyjs/helper-wasm-bytecode": "1.11.1", 158 | "@webassemblyjs/wasm-gen": "1.11.1" 159 | } 160 | }, 161 | "node_modules/@webassemblyjs/ieee754": { 162 | "version": "1.11.1", 163 | "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz", 164 | "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==", 165 | "dependencies": { 166 | "@xtuc/ieee754": "^1.2.0" 167 | } 168 | }, 169 | "node_modules/@webassemblyjs/leb128": { 170 | "version": "1.11.1", 171 | "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz", 172 | "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==", 173 | "dependencies": { 174 | "@xtuc/long": "4.2.2" 175 | } 176 | }, 177 | "node_modules/@webassemblyjs/utf8": { 178 | "version": "1.11.1", 179 | "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz", 180 | "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==" 181 | }, 182 | "node_modules/@webassemblyjs/wasm-edit": { 183 | "version": "1.11.1", 184 | "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz", 185 | "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==", 186 | "dependencies": { 187 | "@webassemblyjs/ast": "1.11.1", 188 | "@webassemblyjs/helper-buffer": "1.11.1", 189 | "@webassemblyjs/helper-wasm-bytecode": "1.11.1", 190 | "@webassemblyjs/helper-wasm-section": "1.11.1", 191 | "@webassemblyjs/wasm-gen": "1.11.1", 192 | "@webassemblyjs/wasm-opt": "1.11.1", 193 | "@webassemblyjs/wasm-parser": "1.11.1", 194 | "@webassemblyjs/wast-printer": "1.11.1" 195 | } 196 | }, 197 | "node_modules/@webassemblyjs/wasm-gen": { 198 | "version": "1.11.1", 199 | "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz", 200 | "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==", 201 | "dependencies": { 202 | "@webassemblyjs/ast": "1.11.1", 203 | "@webassemblyjs/helper-wasm-bytecode": "1.11.1", 204 | "@webassemblyjs/ieee754": "1.11.1", 205 | "@webassemblyjs/leb128": "1.11.1", 206 | "@webassemblyjs/utf8": "1.11.1" 207 | } 208 | }, 209 | "node_modules/@webassemblyjs/wasm-opt": { 210 | "version": "1.11.1", 211 | "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz", 212 | "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==", 213 | "dependencies": { 214 | "@webassemblyjs/ast": "1.11.1", 215 | "@webassemblyjs/helper-buffer": "1.11.1", 216 | "@webassemblyjs/wasm-gen": "1.11.1", 217 | "@webassemblyjs/wasm-parser": "1.11.1" 218 | } 219 | }, 220 | "node_modules/@webassemblyjs/wasm-parser": { 221 | "version": "1.11.1", 222 | "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz", 223 | "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==", 224 | "dependencies": { 225 | "@webassemblyjs/ast": "1.11.1", 226 | "@webassemblyjs/helper-api-error": "1.11.1", 227 | "@webassemblyjs/helper-wasm-bytecode": "1.11.1", 228 | "@webassemblyjs/ieee754": "1.11.1", 229 | "@webassemblyjs/leb128": "1.11.1", 230 | "@webassemblyjs/utf8": "1.11.1" 231 | } 232 | }, 233 | "node_modules/@webassemblyjs/wast-printer": { 234 | "version": "1.11.1", 235 | "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz", 236 | "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==", 237 | "dependencies": { 238 | "@webassemblyjs/ast": "1.11.1", 239 | "@xtuc/long": "4.2.2" 240 | } 241 | }, 242 | "node_modules/@webpack-cli/configtest": { 243 | "version": "1.0.2", 244 | "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.0.2.tgz", 245 | "integrity": "sha512-3OBzV2fBGZ5TBfdW50cha1lHDVf9vlvRXnjpVbJBa20pSZQaSkMJZiwA8V2vD9ogyeXn8nU5s5A6mHyf5jhMzA==", 246 | "peerDependencies": { 247 | "webpack": "4.x.x || 5.x.x", 248 | "webpack-cli": "4.x.x" 249 | } 250 | }, 251 | "node_modules/@webpack-cli/info": { 252 | "version": "1.2.3", 253 | "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.2.3.tgz", 254 | "integrity": "sha512-lLek3/T7u40lTqzCGpC6CAbY6+vXhdhmwFRxZLMnRm6/sIF/7qMpT8MocXCRQfz0JAh63wpbXLMnsQ5162WS7Q==", 255 | "dependencies": { 256 | "envinfo": "^7.7.3" 257 | }, 258 | "peerDependencies": { 259 | "webpack-cli": "4.x.x" 260 | } 261 | }, 262 | "node_modules/@webpack-cli/serve": { 263 | "version": "1.3.1", 264 | "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.3.1.tgz", 265 | "integrity": "sha512-0qXvpeYO6vaNoRBI52/UsbcaBydJCggoBBnIo/ovQQdn6fug0BgwsjorV1hVS7fMqGVTZGcVxv8334gjmbj5hw==", 266 | "peerDependencies": { 267 | "webpack-cli": "4.x.x" 268 | }, 269 | "peerDependenciesMeta": { 270 | "webpack-dev-server": { 271 | "optional": true 272 | } 273 | } 274 | }, 275 | "node_modules/@xtuc/ieee754": { 276 | "version": "1.2.0", 277 | "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", 278 | "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==" 279 | }, 280 | "node_modules/@xtuc/long": { 281 | "version": "4.2.2", 282 | "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", 283 | "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" 284 | }, 285 | "node_modules/acorn": { 286 | "version": "8.8.2", 287 | "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", 288 | "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", 289 | "bin": { 290 | "acorn": "bin/acorn" 291 | }, 292 | "engines": { 293 | "node": ">=0.4.0" 294 | } 295 | }, 296 | "node_modules/acorn-import-assertions": { 297 | "version": "1.8.0", 298 | "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz", 299 | "integrity": "sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw==", 300 | "peerDependencies": { 301 | "acorn": "^8" 302 | } 303 | }, 304 | "node_modules/ajv": { 305 | "version": "6.12.6", 306 | "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", 307 | "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", 308 | "dependencies": { 309 | "fast-deep-equal": "^3.1.1", 310 | "fast-json-stable-stringify": "^2.0.0", 311 | "json-schema-traverse": "^0.4.1", 312 | "uri-js": "^4.2.2" 313 | }, 314 | "funding": { 315 | "type": "github", 316 | "url": "https://github.com/sponsors/epoberezkin" 317 | } 318 | }, 319 | "node_modules/ajv-keywords": { 320 | "version": "3.5.2", 321 | "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", 322 | "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", 323 | "peerDependencies": { 324 | "ajv": "^6.9.1" 325 | } 326 | }, 327 | "node_modules/ansi-colors": { 328 | "version": "4.1.1", 329 | "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", 330 | "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==", 331 | "engines": { 332 | "node": ">=6" 333 | } 334 | }, 335 | "node_modules/browserslist": { 336 | "version": "4.21.5", 337 | "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.21.5.tgz", 338 | "integrity": "sha512-tUkiguQGW7S3IhB7N+c2MV/HZPSCPAAiYBZXLsBhFB/PCy6ZKKsZrmBayHV9fdGV/ARIfJ14NkxKzRDjvp7L6w==", 339 | "funding": [ 340 | { 341 | "type": "opencollective", 342 | "url": "https://opencollective.com/browserslist" 343 | }, 344 | { 345 | "type": "tidelift", 346 | "url": "https://tidelift.com/funding/github/npm/browserslist" 347 | } 348 | ], 349 | "dependencies": { 350 | "caniuse-lite": "^1.0.30001449", 351 | "electron-to-chromium": "^1.4.284", 352 | "node-releases": "^2.0.8", 353 | "update-browserslist-db": "^1.0.10" 354 | }, 355 | "bin": { 356 | "browserslist": "cli.js" 357 | }, 358 | "engines": { 359 | "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" 360 | } 361 | }, 362 | "node_modules/buffer-from": { 363 | "version": "1.1.2", 364 | "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", 365 | "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" 366 | }, 367 | "node_modules/caniuse-lite": { 368 | "version": "1.0.30001466", 369 | "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001466.tgz", 370 | "integrity": "sha512-ewtFBSfWjEmxUgNBSZItFSmVtvk9zkwkl1OfRZlKA8slltRN+/C/tuGVrF9styXkN36Yu3+SeJ1qkXxDEyNZ5w==", 371 | "funding": [ 372 | { 373 | "type": "opencollective", 374 | "url": "https://opencollective.com/browserslist" 375 | }, 376 | { 377 | "type": "tidelift", 378 | "url": "https://tidelift.com/funding/github/npm/caniuse-lite" 379 | } 380 | ] 381 | }, 382 | "node_modules/chrome-trace-event": { 383 | "version": "1.0.3", 384 | "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz", 385 | "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", 386 | "engines": { 387 | "node": ">=6.0" 388 | } 389 | }, 390 | "node_modules/clone-deep": { 391 | "version": "4.0.1", 392 | "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", 393 | "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", 394 | "dependencies": { 395 | "is-plain-object": "^2.0.4", 396 | "kind-of": "^6.0.2", 397 | "shallow-clone": "^3.0.0" 398 | }, 399 | "engines": { 400 | "node": ">=6" 401 | } 402 | }, 403 | "node_modules/colorette": { 404 | "version": "1.2.2", 405 | "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz", 406 | "integrity": "sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==" 407 | }, 408 | "node_modules/commander": { 409 | "version": "2.20.3", 410 | "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", 411 | "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" 412 | }, 413 | "node_modules/cross-spawn": { 414 | "version": "7.0.3", 415 | "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", 416 | "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", 417 | "dependencies": { 418 | "path-key": "^3.1.0", 419 | "shebang-command": "^2.0.0", 420 | "which": "^2.0.1" 421 | }, 422 | "engines": { 423 | "node": ">= 8" 424 | } 425 | }, 426 | "node_modules/d3": { 427 | "version": "5.16.0", 428 | "resolved": "https://registry.npmjs.org/d3/-/d3-5.16.0.tgz", 429 | "integrity": "sha512-4PL5hHaHwX4m7Zr1UapXW23apo6pexCgdetdJ5kTmADpG/7T9Gkxw0M0tf/pjoB63ezCCm0u5UaFYy2aMt0Mcw==", 430 | "dependencies": { 431 | "d3-array": "1", 432 | "d3-axis": "1", 433 | "d3-brush": "1", 434 | "d3-chord": "1", 435 | "d3-collection": "1", 436 | "d3-color": "1", 437 | "d3-contour": "1", 438 | "d3-dispatch": "1", 439 | "d3-drag": "1", 440 | "d3-dsv": "1", 441 | "d3-ease": "1", 442 | "d3-fetch": "1", 443 | "d3-force": "1", 444 | "d3-format": "1", 445 | "d3-geo": "1", 446 | "d3-hierarchy": "1", 447 | "d3-interpolate": "1", 448 | "d3-path": "1", 449 | "d3-polygon": "1", 450 | "d3-quadtree": "1", 451 | "d3-random": "1", 452 | "d3-scale": "2", 453 | "d3-scale-chromatic": "1", 454 | "d3-selection": "1", 455 | "d3-shape": "1", 456 | "d3-time": "1", 457 | "d3-time-format": "2", 458 | "d3-timer": "1", 459 | "d3-transition": "1", 460 | "d3-voronoi": "1", 461 | "d3-zoom": "1" 462 | } 463 | }, 464 | "node_modules/d3-array": { 465 | "version": "1.2.4", 466 | "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-1.2.4.tgz", 467 | "integrity": "sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==" 468 | }, 469 | "node_modules/d3-axis": { 470 | "version": "1.0.12", 471 | "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-1.0.12.tgz", 472 | "integrity": "sha512-ejINPfPSNdGFKEOAtnBtdkpr24c4d4jsei6Lg98mxf424ivoDP2956/5HDpIAtmHo85lqT4pruy+zEgvRUBqaQ==" 473 | }, 474 | "node_modules/d3-brush": { 475 | "version": "1.1.6", 476 | "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-1.1.6.tgz", 477 | "integrity": "sha512-7RW+w7HfMCPyZLifTz/UnJmI5kdkXtpCbombUSs8xniAyo0vIbrDzDwUJB6eJOgl9u5DQOt2TQlYumxzD1SvYA==", 478 | "dependencies": { 479 | "d3-dispatch": "1", 480 | "d3-drag": "1", 481 | "d3-interpolate": "1", 482 | "d3-selection": "1", 483 | "d3-transition": "1" 484 | } 485 | }, 486 | "node_modules/d3-chord": { 487 | "version": "1.0.6", 488 | "resolved": "https://registry.npmjs.org/d3-chord/-/d3-chord-1.0.6.tgz", 489 | "integrity": "sha512-JXA2Dro1Fxw9rJe33Uv+Ckr5IrAa74TlfDEhE/jfLOaXegMQFQTAgAw9WnZL8+HxVBRXaRGCkrNU7pJeylRIuA==", 490 | "dependencies": { 491 | "d3-array": "1", 492 | "d3-path": "1" 493 | } 494 | }, 495 | "node_modules/d3-collection": { 496 | "version": "1.0.7", 497 | "resolved": "https://registry.npmjs.org/d3-collection/-/d3-collection-1.0.7.tgz", 498 | "integrity": "sha512-ii0/r5f4sjKNTfh84Di+DpztYwqKhEyUlKoPrzUFfeSkWxjW49xU2QzO9qrPrNkpdI0XJkfzvmTu8V2Zylln6A==" 499 | }, 500 | "node_modules/d3-color": { 501 | "version": "3.1.0", 502 | "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", 503 | "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", 504 | "engines": { 505 | "node": ">=12" 506 | } 507 | }, 508 | "node_modules/d3-contour": { 509 | "version": "1.3.2", 510 | "resolved": "https://registry.npmjs.org/d3-contour/-/d3-contour-1.3.2.tgz", 511 | "integrity": "sha512-hoPp4K/rJCu0ladiH6zmJUEz6+u3lgR+GSm/QdM2BBvDraU39Vr7YdDCicJcxP1z8i9B/2dJLgDC1NcvlF8WCg==", 512 | "dependencies": { 513 | "d3-array": "^1.1.1" 514 | } 515 | }, 516 | "node_modules/d3-dispatch": { 517 | "version": "1.0.6", 518 | "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-1.0.6.tgz", 519 | "integrity": "sha512-fVjoElzjhCEy+Hbn8KygnmMS7Or0a9sI2UzGwoB7cCtvI1XpVN9GpoYlnb3xt2YV66oXYb1fLJ8GMvP4hdU1RA==" 520 | }, 521 | "node_modules/d3-drag": { 522 | "version": "1.2.5", 523 | "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-1.2.5.tgz", 524 | "integrity": "sha512-rD1ohlkKQwMZYkQlYVCrSFxsWPzI97+W+PaEIBNTMxRuxz9RF0Hi5nJWHGVJ3Om9d2fRTe1yOBINJyy/ahV95w==", 525 | "dependencies": { 526 | "d3-dispatch": "1", 527 | "d3-selection": "1" 528 | } 529 | }, 530 | "node_modules/d3-dsv": { 531 | "version": "1.2.0", 532 | "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-1.2.0.tgz", 533 | "integrity": "sha512-9yVlqvZcSOMhCYzniHE7EVUws7Fa1zgw+/EAV2BxJoG3ME19V6BQFBwI855XQDsxyOuG7NibqRMTtiF/Qup46g==", 534 | "dependencies": { 535 | "commander": "2", 536 | "iconv-lite": "0.4", 537 | "rw": "1" 538 | }, 539 | "bin": { 540 | "csv2json": "bin/dsv2json", 541 | "csv2tsv": "bin/dsv2dsv", 542 | "dsv2dsv": "bin/dsv2dsv", 543 | "dsv2json": "bin/dsv2json", 544 | "json2csv": "bin/json2dsv", 545 | "json2dsv": "bin/json2dsv", 546 | "json2tsv": "bin/json2dsv", 547 | "tsv2csv": "bin/dsv2dsv", 548 | "tsv2json": "bin/dsv2json" 549 | } 550 | }, 551 | "node_modules/d3-ease": { 552 | "version": "1.0.7", 553 | "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-1.0.7.tgz", 554 | "integrity": "sha512-lx14ZPYkhNx0s/2HX5sLFUI3mbasHjSSpwO/KaaNACweVwxUruKyWVcb293wMv1RqTPZyZ8kSZ2NogUZNcLOFQ==" 555 | }, 556 | "node_modules/d3-fetch": { 557 | "version": "1.2.0", 558 | "resolved": "https://registry.npmjs.org/d3-fetch/-/d3-fetch-1.2.0.tgz", 559 | "integrity": "sha512-yC78NBVcd2zFAyR/HnUiBS7Lf6inSCoWcSxFfw8FYL7ydiqe80SazNwoffcqOfs95XaLo7yebsmQqDKSsXUtvA==", 560 | "dependencies": { 561 | "d3-dsv": "1" 562 | } 563 | }, 564 | "node_modules/d3-force": { 565 | "version": "1.2.1", 566 | "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-1.2.1.tgz", 567 | "integrity": "sha512-HHvehyaiUlVo5CxBJ0yF/xny4xoaxFxDnBXNvNcfW9adORGZfyNF1dj6DGLKyk4Yh3brP/1h3rnDzdIAwL08zg==", 568 | "dependencies": { 569 | "d3-collection": "1", 570 | "d3-dispatch": "1", 571 | "d3-quadtree": "1", 572 | "d3-timer": "1" 573 | } 574 | }, 575 | "node_modules/d3-format": { 576 | "version": "1.4.5", 577 | "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-1.4.5.tgz", 578 | "integrity": "sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ==" 579 | }, 580 | "node_modules/d3-geo": { 581 | "version": "1.12.1", 582 | "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-1.12.1.tgz", 583 | "integrity": "sha512-XG4d1c/UJSEX9NfU02KwBL6BYPj8YKHxgBEw5om2ZnTRSbIcego6dhHwcxuSR3clxh0EpE38os1DVPOmnYtTPg==", 584 | "dependencies": { 585 | "d3-array": "1" 586 | } 587 | }, 588 | "node_modules/d3-hierarchy": { 589 | "version": "1.1.9", 590 | "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz", 591 | "integrity": "sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==" 592 | }, 593 | "node_modules/d3-interpolate": { 594 | "version": "1.4.0", 595 | "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-1.4.0.tgz", 596 | "integrity": "sha512-V9znK0zc3jOPV4VD2zZn0sDhZU3WAE2bmlxdIwwQPPzPjvyLkd8B3JUVdS1IDUFDkWZ72c9qnv1GK2ZagTZ8EA==", 597 | "dependencies": { 598 | "d3-color": "1" 599 | } 600 | }, 601 | "node_modules/d3-interpolate/node_modules/d3-color": { 602 | "version": "1.4.1", 603 | "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz", 604 | "integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==" 605 | }, 606 | "node_modules/d3-path": { 607 | "version": "1.0.9", 608 | "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz", 609 | "integrity": "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==" 610 | }, 611 | "node_modules/d3-polygon": { 612 | "version": "1.0.6", 613 | "resolved": "https://registry.npmjs.org/d3-polygon/-/d3-polygon-1.0.6.tgz", 614 | "integrity": "sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==" 615 | }, 616 | "node_modules/d3-quadtree": { 617 | "version": "1.0.7", 618 | "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-1.0.7.tgz", 619 | "integrity": "sha512-RKPAeXnkC59IDGD0Wu5mANy0Q2V28L+fNe65pOCXVdVuTJS3WPKaJlFHer32Rbh9gIo9qMuJXio8ra4+YmIymA==" 620 | }, 621 | "node_modules/d3-random": { 622 | "version": "1.1.2", 623 | "resolved": "https://registry.npmjs.org/d3-random/-/d3-random-1.1.2.tgz", 624 | "integrity": "sha512-6AK5BNpIFqP+cx/sreKzNjWbwZQCSUatxq+pPRmFIQaWuoD+NrbVWw7YWpHiXpCQ/NanKdtGDuB+VQcZDaEmYQ==" 625 | }, 626 | "node_modules/d3-scale": { 627 | "version": "2.2.2", 628 | "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-2.2.2.tgz", 629 | "integrity": "sha512-LbeEvGgIb8UMcAa0EATLNX0lelKWGYDQiPdHj+gLblGVhGLyNbaCn3EvrJf0A3Y/uOOU5aD6MTh5ZFCdEwGiCw==", 630 | "dependencies": { 631 | "d3-array": "^1.2.0", 632 | "d3-collection": "1", 633 | "d3-format": "1", 634 | "d3-interpolate": "1", 635 | "d3-time": "1", 636 | "d3-time-format": "2" 637 | } 638 | }, 639 | "node_modules/d3-scale-chromatic": { 640 | "version": "1.5.0", 641 | "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-1.5.0.tgz", 642 | "integrity": "sha512-ACcL46DYImpRFMBcpk9HhtIyC7bTBR4fNOPxwVSl0LfulDAwyiHyPOTqcDG1+t5d4P9W7t/2NAuWu59aKko/cg==", 643 | "dependencies": { 644 | "d3-color": "1", 645 | "d3-interpolate": "1" 646 | } 647 | }, 648 | "node_modules/d3-scale-chromatic/node_modules/d3-color": { 649 | "version": "1.4.1", 650 | "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz", 651 | "integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==" 652 | }, 653 | "node_modules/d3-selection": { 654 | "version": "1.4.2", 655 | "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-1.4.2.tgz", 656 | "integrity": "sha512-SJ0BqYihzOjDnnlfyeHT0e30k0K1+5sR3d5fNueCNeuhZTnGw4M4o8mqJchSwgKMXCNFo+e2VTChiSJ0vYtXkg==" 657 | }, 658 | "node_modules/d3-shape": { 659 | "version": "1.3.7", 660 | "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz", 661 | "integrity": "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==", 662 | "dependencies": { 663 | "d3-path": "1" 664 | } 665 | }, 666 | "node_modules/d3-time": { 667 | "version": "1.1.0", 668 | "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-1.1.0.tgz", 669 | "integrity": "sha512-Xh0isrZ5rPYYdqhAVk8VLnMEidhz5aP7htAADH6MfzgmmicPkTo8LhkLxci61/lCB7n7UmE3bN0leRt+qvkLxA==" 670 | }, 671 | "node_modules/d3-time-format": { 672 | "version": "2.3.0", 673 | "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-2.3.0.tgz", 674 | "integrity": "sha512-guv6b2H37s2Uq/GefleCDtbe0XZAuy7Wa49VGkPVPMfLL9qObgBST3lEHJBMUp8S7NdLQAGIvr2KXk8Hc98iKQ==", 675 | "dependencies": { 676 | "d3-time": "1" 677 | } 678 | }, 679 | "node_modules/d3-timer": { 680 | "version": "1.0.10", 681 | "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-1.0.10.tgz", 682 | "integrity": "sha512-B1JDm0XDaQC+uvo4DT79H0XmBskgS3l6Ve+1SBCfxgmtIb1AVrPIoqd+nPSv+loMX8szQ0sVUhGngL7D5QPiXw==" 683 | }, 684 | "node_modules/d3-transition": { 685 | "version": "1.3.2", 686 | "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-1.3.2.tgz", 687 | "integrity": "sha512-sc0gRU4PFqZ47lPVHloMn9tlPcv8jxgOQg+0zjhfZXMQuvppjG6YuwdMBE0TuqCZjeJkLecku/l9R0JPcRhaDA==", 688 | "dependencies": { 689 | "d3-color": "1", 690 | "d3-dispatch": "1", 691 | "d3-ease": "1", 692 | "d3-interpolate": "1", 693 | "d3-selection": "^1.1.0", 694 | "d3-timer": "1" 695 | } 696 | }, 697 | "node_modules/d3-transition/node_modules/d3-color": { 698 | "version": "1.4.1", 699 | "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz", 700 | "integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==" 701 | }, 702 | "node_modules/d3-voronoi": { 703 | "version": "1.1.4", 704 | "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz", 705 | "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==" 706 | }, 707 | "node_modules/d3-zoom": { 708 | "version": "1.8.3", 709 | "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-1.8.3.tgz", 710 | "integrity": "sha512-VoLXTK4wvy1a0JpH2Il+F2CiOhVu7VRXWF5M/LroMIh3/zBAC3WAt7QoIvPibOavVo20hN6/37vwAsdBejLyKQ==", 711 | "dependencies": { 712 | "d3-dispatch": "1", 713 | "d3-drag": "1", 714 | "d3-interpolate": "1", 715 | "d3-selection": "1", 716 | "d3-transition": "1" 717 | } 718 | }, 719 | "node_modules/d3/node_modules/d3-color": { 720 | "version": "1.4.1", 721 | "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-1.4.1.tgz", 722 | "integrity": "sha512-p2sTHSLCJI2QKunbGb7ocOh7DgTAn8IrLx21QRc/BSnodXM4sv6aLQlnfpvehFMLZEfBc6g9pH9SWQccFYfJ9Q==" 723 | }, 724 | "node_modules/dagre": { 725 | "version": "0.8.5", 726 | "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", 727 | "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", 728 | "dependencies": { 729 | "graphlib": "^2.1.8", 730 | "lodash": "^4.17.15" 731 | } 732 | }, 733 | "node_modules/dagre-d3": { 734 | "version": "0.6.4", 735 | "resolved": "https://registry.npmjs.org/dagre-d3/-/dagre-d3-0.6.4.tgz", 736 | "integrity": "sha512-e/6jXeCP7/ptlAM48clmX4xTZc5Ek6T6kagS7Oz2HrYSdqcLZFLqpAfh7ldbZRFfxCZVyh61NEPR08UQRVxJzQ==", 737 | "dependencies": { 738 | "d3": "^5.14", 739 | "dagre": "^0.8.5", 740 | "graphlib": "^2.1.8", 741 | "lodash": "^4.17.15" 742 | } 743 | }, 744 | "node_modules/electron-to-chromium": { 745 | "version": "1.4.328", 746 | "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.328.tgz", 747 | "integrity": "sha512-DE9tTy2PNmy1v55AZAO542ui+MLC2cvINMK4P2LXGsJdput/ThVG9t+QGecPuAZZSgC8XoI+Jh9M1OG9IoNSCw==" 748 | }, 749 | "node_modules/enhanced-resolve": { 750 | "version": "5.12.0", 751 | "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz", 752 | "integrity": "sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==", 753 | "dependencies": { 754 | "graceful-fs": "^4.2.4", 755 | "tapable": "^2.2.0" 756 | }, 757 | "engines": { 758 | "node": ">=10.13.0" 759 | } 760 | }, 761 | "node_modules/enquirer": { 762 | "version": "2.3.6", 763 | "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz", 764 | "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==", 765 | "dependencies": { 766 | "ansi-colors": "^4.1.1" 767 | }, 768 | "engines": { 769 | "node": ">=8.6" 770 | } 771 | }, 772 | "node_modules/envinfo": { 773 | "version": "7.8.1", 774 | "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.8.1.tgz", 775 | "integrity": "sha512-/o+BXHmB7ocbHEAs6F2EnG0ogybVVUdkRunTT2glZU9XAaGmhqskrvKwqXuDfNjEO0LZKWdejEEpnq8aM0tOaw==", 776 | "bin": { 777 | "envinfo": "dist/cli.js" 778 | }, 779 | "engines": { 780 | "node": ">=4" 781 | } 782 | }, 783 | "node_modules/es-module-lexer": { 784 | "version": "0.9.3", 785 | "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz", 786 | "integrity": "sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==" 787 | }, 788 | "node_modules/escalade": { 789 | "version": "3.1.1", 790 | "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", 791 | "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", 792 | "engines": { 793 | "node": ">=6" 794 | } 795 | }, 796 | "node_modules/eslint-scope": { 797 | "version": "5.1.1", 798 | "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", 799 | "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", 800 | "dependencies": { 801 | "esrecurse": "^4.3.0", 802 | "estraverse": "^4.1.1" 803 | }, 804 | "engines": { 805 | "node": ">=8.0.0" 806 | } 807 | }, 808 | "node_modules/esrecurse": { 809 | "version": "4.3.0", 810 | "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", 811 | "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", 812 | "dependencies": { 813 | "estraverse": "^5.2.0" 814 | }, 815 | "engines": { 816 | "node": ">=4.0" 817 | } 818 | }, 819 | "node_modules/esrecurse/node_modules/estraverse": { 820 | "version": "5.3.0", 821 | "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", 822 | "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", 823 | "engines": { 824 | "node": ">=4.0" 825 | } 826 | }, 827 | "node_modules/estraverse": { 828 | "version": "4.3.0", 829 | "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", 830 | "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", 831 | "engines": { 832 | "node": ">=4.0" 833 | } 834 | }, 835 | "node_modules/events": { 836 | "version": "3.3.0", 837 | "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", 838 | "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", 839 | "engines": { 840 | "node": ">=0.8.x" 841 | } 842 | }, 843 | "node_modules/execa": { 844 | "version": "5.0.0", 845 | "resolved": "https://registry.npmjs.org/execa/-/execa-5.0.0.tgz", 846 | "integrity": "sha512-ov6w/2LCiuyO4RLYGdpFGjkcs0wMTgGE8PrkTHikeUy5iJekXyPIKUjifk5CsE0pt7sMCrMZ3YNqoCj6idQOnQ==", 847 | "dependencies": { 848 | "cross-spawn": "^7.0.3", 849 | "get-stream": "^6.0.0", 850 | "human-signals": "^2.1.0", 851 | "is-stream": "^2.0.0", 852 | "merge-stream": "^2.0.0", 853 | "npm-run-path": "^4.0.1", 854 | "onetime": "^5.1.2", 855 | "signal-exit": "^3.0.3", 856 | "strip-final-newline": "^2.0.0" 857 | }, 858 | "engines": { 859 | "node": ">=10" 860 | }, 861 | "funding": { 862 | "url": "https://github.com/sindresorhus/execa?sponsor=1" 863 | } 864 | }, 865 | "node_modules/fast-deep-equal": { 866 | "version": "3.1.3", 867 | "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", 868 | "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" 869 | }, 870 | "node_modules/fast-json-stable-stringify": { 871 | "version": "2.1.0", 872 | "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", 873 | "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" 874 | }, 875 | "node_modules/fastest-levenshtein": { 876 | "version": "1.0.12", 877 | "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.12.tgz", 878 | "integrity": "sha512-On2N+BpYJ15xIC974QNVuYGMOlEVt4s0EOI3wwMqOmK1fdDY+FN/zltPV8vosq4ad4c/gJ1KHScUn/6AWIgiow==" 879 | }, 880 | "node_modules/find-up": { 881 | "version": "4.1.0", 882 | "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", 883 | "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", 884 | "dependencies": { 885 | "locate-path": "^5.0.0", 886 | "path-exists": "^4.0.0" 887 | }, 888 | "engines": { 889 | "node": ">=8" 890 | } 891 | }, 892 | "node_modules/function-bind": { 893 | "version": "1.1.1", 894 | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", 895 | "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" 896 | }, 897 | "node_modules/get-stream": { 898 | "version": "6.0.1", 899 | "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", 900 | "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", 901 | "engines": { 902 | "node": ">=10" 903 | }, 904 | "funding": { 905 | "url": "https://github.com/sponsors/sindresorhus" 906 | } 907 | }, 908 | "node_modules/glob-to-regexp": { 909 | "version": "0.4.1", 910 | "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", 911 | "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==" 912 | }, 913 | "node_modules/graceful-fs": { 914 | "version": "4.2.10", 915 | "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", 916 | "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==" 917 | }, 918 | "node_modules/graphlib": { 919 | "version": "2.1.8", 920 | "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", 921 | "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", 922 | "dependencies": { 923 | "lodash": "^4.17.15" 924 | } 925 | }, 926 | "node_modules/has": { 927 | "version": "1.0.3", 928 | "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", 929 | "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", 930 | "dependencies": { 931 | "function-bind": "^1.1.1" 932 | }, 933 | "engines": { 934 | "node": ">= 0.4.0" 935 | } 936 | }, 937 | "node_modules/has-flag": { 938 | "version": "4.0.0", 939 | "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 940 | "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 941 | "engines": { 942 | "node": ">=8" 943 | } 944 | }, 945 | "node_modules/human-signals": { 946 | "version": "2.1.0", 947 | "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", 948 | "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", 949 | "engines": { 950 | "node": ">=10.17.0" 951 | } 952 | }, 953 | "node_modules/iconv-lite": { 954 | "version": "0.4.24", 955 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", 956 | "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", 957 | "dependencies": { 958 | "safer-buffer": ">= 2.1.2 < 3" 959 | }, 960 | "engines": { 961 | "node": ">=0.10.0" 962 | } 963 | }, 964 | "node_modules/import-local": { 965 | "version": "3.0.2", 966 | "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.0.2.tgz", 967 | "integrity": "sha512-vjL3+w0oulAVZ0hBHnxa/Nm5TAurf9YLQJDhqRZyqb+VKGOB6LU8t9H1Nr5CIo16vh9XfJTOoHwU0B71S557gA==", 968 | "dependencies": { 969 | "pkg-dir": "^4.2.0", 970 | "resolve-cwd": "^3.0.0" 971 | }, 972 | "bin": { 973 | "import-local-fixture": "fixtures/cli.js" 974 | }, 975 | "engines": { 976 | "node": ">=8" 977 | } 978 | }, 979 | "node_modules/interpret": { 980 | "version": "2.2.0", 981 | "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", 982 | "integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==", 983 | "engines": { 984 | "node": ">= 0.10" 985 | } 986 | }, 987 | "node_modules/is-core-module": { 988 | "version": "2.2.0", 989 | "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.2.0.tgz", 990 | "integrity": "sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==", 991 | "dependencies": { 992 | "has": "^1.0.3" 993 | }, 994 | "funding": { 995 | "url": "https://github.com/sponsors/ljharb" 996 | } 997 | }, 998 | "node_modules/is-plain-object": { 999 | "version": "2.0.4", 1000 | "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", 1001 | "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", 1002 | "dependencies": { 1003 | "isobject": "^3.0.1" 1004 | }, 1005 | "engines": { 1006 | "node": ">=0.10.0" 1007 | } 1008 | }, 1009 | "node_modules/is-stream": { 1010 | "version": "2.0.0", 1011 | "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz", 1012 | "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==", 1013 | "engines": { 1014 | "node": ">=8" 1015 | } 1016 | }, 1017 | "node_modules/isexe": { 1018 | "version": "2.0.0", 1019 | "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", 1020 | "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=" 1021 | }, 1022 | "node_modules/isobject": { 1023 | "version": "3.0.1", 1024 | "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", 1025 | "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", 1026 | "engines": { 1027 | "node": ">=0.10.0" 1028 | } 1029 | }, 1030 | "node_modules/jest-worker": { 1031 | "version": "27.5.1", 1032 | "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", 1033 | "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", 1034 | "dependencies": { 1035 | "@types/node": "*", 1036 | "merge-stream": "^2.0.0", 1037 | "supports-color": "^8.0.0" 1038 | }, 1039 | "engines": { 1040 | "node": ">= 10.13.0" 1041 | } 1042 | }, 1043 | "node_modules/json-parse-even-better-errors": { 1044 | "version": "2.3.1", 1045 | "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", 1046 | "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" 1047 | }, 1048 | "node_modules/json-schema-traverse": { 1049 | "version": "0.4.1", 1050 | "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", 1051 | "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" 1052 | }, 1053 | "node_modules/kind-of": { 1054 | "version": "6.0.3", 1055 | "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", 1056 | "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", 1057 | "engines": { 1058 | "node": ">=0.10.0" 1059 | } 1060 | }, 1061 | "node_modules/loader-runner": { 1062 | "version": "4.3.0", 1063 | "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", 1064 | "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", 1065 | "engines": { 1066 | "node": ">=6.11.5" 1067 | } 1068 | }, 1069 | "node_modules/locate-path": { 1070 | "version": "5.0.0", 1071 | "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", 1072 | "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", 1073 | "dependencies": { 1074 | "p-locate": "^4.1.0" 1075 | }, 1076 | "engines": { 1077 | "node": ">=8" 1078 | } 1079 | }, 1080 | "node_modules/lodash": { 1081 | "version": "4.17.21", 1082 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", 1083 | "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" 1084 | }, 1085 | "node_modules/merge-stream": { 1086 | "version": "2.0.0", 1087 | "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", 1088 | "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" 1089 | }, 1090 | "node_modules/mime-db": { 1091 | "version": "1.52.0", 1092 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", 1093 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", 1094 | "engines": { 1095 | "node": ">= 0.6" 1096 | } 1097 | }, 1098 | "node_modules/mime-types": { 1099 | "version": "2.1.35", 1100 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", 1101 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 1102 | "dependencies": { 1103 | "mime-db": "1.52.0" 1104 | }, 1105 | "engines": { 1106 | "node": ">= 0.6" 1107 | } 1108 | }, 1109 | "node_modules/mimic-fn": { 1110 | "version": "2.1.0", 1111 | "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", 1112 | "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", 1113 | "engines": { 1114 | "node": ">=6" 1115 | } 1116 | }, 1117 | "node_modules/neo-async": { 1118 | "version": "2.6.2", 1119 | "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", 1120 | "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" 1121 | }, 1122 | "node_modules/node-releases": { 1123 | "version": "2.0.10", 1124 | "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.10.tgz", 1125 | "integrity": "sha512-5GFldHPXVG/YZmFzJvKK2zDSzPKhEp0+ZR5SVaoSag9fsL5YgHbUHDfnG5494ISANDcK4KwPXAx2xqVEydmd7w==" 1126 | }, 1127 | "node_modules/npm-run-path": { 1128 | "version": "4.0.1", 1129 | "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", 1130 | "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", 1131 | "dependencies": { 1132 | "path-key": "^3.0.0" 1133 | }, 1134 | "engines": { 1135 | "node": ">=8" 1136 | } 1137 | }, 1138 | "node_modules/onetime": { 1139 | "version": "5.1.2", 1140 | "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", 1141 | "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", 1142 | "dependencies": { 1143 | "mimic-fn": "^2.1.0" 1144 | }, 1145 | "engines": { 1146 | "node": ">=6" 1147 | }, 1148 | "funding": { 1149 | "url": "https://github.com/sponsors/sindresorhus" 1150 | } 1151 | }, 1152 | "node_modules/p-locate": { 1153 | "version": "4.1.0", 1154 | "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", 1155 | "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", 1156 | "dependencies": { 1157 | "p-limit": "^2.2.0" 1158 | }, 1159 | "engines": { 1160 | "node": ">=8" 1161 | } 1162 | }, 1163 | "node_modules/p-locate/node_modules/p-limit": { 1164 | "version": "2.3.0", 1165 | "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", 1166 | "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", 1167 | "dependencies": { 1168 | "p-try": "^2.0.0" 1169 | }, 1170 | "engines": { 1171 | "node": ">=6" 1172 | }, 1173 | "funding": { 1174 | "url": "https://github.com/sponsors/sindresorhus" 1175 | } 1176 | }, 1177 | "node_modules/p-try": { 1178 | "version": "2.2.0", 1179 | "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", 1180 | "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", 1181 | "engines": { 1182 | "node": ">=6" 1183 | } 1184 | }, 1185 | "node_modules/path-exists": { 1186 | "version": "4.0.0", 1187 | "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", 1188 | "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", 1189 | "engines": { 1190 | "node": ">=8" 1191 | } 1192 | }, 1193 | "node_modules/path-key": { 1194 | "version": "3.1.1", 1195 | "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", 1196 | "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", 1197 | "engines": { 1198 | "node": ">=8" 1199 | } 1200 | }, 1201 | "node_modules/path-parse": { 1202 | "version": "1.0.7", 1203 | "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", 1204 | "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" 1205 | }, 1206 | "node_modules/picocolors": { 1207 | "version": "1.0.0", 1208 | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", 1209 | "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" 1210 | }, 1211 | "node_modules/pkg-dir": { 1212 | "version": "4.2.0", 1213 | "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", 1214 | "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", 1215 | "dependencies": { 1216 | "find-up": "^4.0.0" 1217 | }, 1218 | "engines": { 1219 | "node": ">=8" 1220 | } 1221 | }, 1222 | "node_modules/punycode": { 1223 | "version": "2.3.0", 1224 | "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", 1225 | "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", 1226 | "engines": { 1227 | "node": ">=6" 1228 | } 1229 | }, 1230 | "node_modules/randombytes": { 1231 | "version": "2.1.0", 1232 | "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", 1233 | "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", 1234 | "dependencies": { 1235 | "safe-buffer": "^5.1.0" 1236 | } 1237 | }, 1238 | "node_modules/rechoir": { 1239 | "version": "0.7.0", 1240 | "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.7.0.tgz", 1241 | "integrity": "sha512-ADsDEH2bvbjltXEP+hTIAmeFekTFK0V2BTxMkok6qILyAJEXV0AFfoWcAq4yfll5VdIMd/RVXq0lR+wQi5ZU3Q==", 1242 | "dependencies": { 1243 | "resolve": "^1.9.0" 1244 | }, 1245 | "engines": { 1246 | "node": ">= 0.10" 1247 | } 1248 | }, 1249 | "node_modules/resolve": { 1250 | "version": "1.20.0", 1251 | "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz", 1252 | "integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==", 1253 | "dependencies": { 1254 | "is-core-module": "^2.2.0", 1255 | "path-parse": "^1.0.6" 1256 | }, 1257 | "funding": { 1258 | "url": "https://github.com/sponsors/ljharb" 1259 | } 1260 | }, 1261 | "node_modules/resolve-cwd": { 1262 | "version": "3.0.0", 1263 | "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", 1264 | "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", 1265 | "dependencies": { 1266 | "resolve-from": "^5.0.0" 1267 | }, 1268 | "engines": { 1269 | "node": ">=8" 1270 | } 1271 | }, 1272 | "node_modules/resolve-from": { 1273 | "version": "5.0.0", 1274 | "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", 1275 | "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", 1276 | "engines": { 1277 | "node": ">=8" 1278 | } 1279 | }, 1280 | "node_modules/rw": { 1281 | "version": "1.3.3", 1282 | "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", 1283 | "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" 1284 | }, 1285 | "node_modules/safe-buffer": { 1286 | "version": "5.2.1", 1287 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 1288 | "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", 1289 | "funding": [ 1290 | { 1291 | "type": "github", 1292 | "url": "https://github.com/sponsors/feross" 1293 | }, 1294 | { 1295 | "type": "patreon", 1296 | "url": "https://www.patreon.com/feross" 1297 | }, 1298 | { 1299 | "type": "consulting", 1300 | "url": "https://feross.org/support" 1301 | } 1302 | ] 1303 | }, 1304 | "node_modules/safer-buffer": { 1305 | "version": "2.1.2", 1306 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 1307 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" 1308 | }, 1309 | "node_modules/schema-utils": { 1310 | "version": "3.1.1", 1311 | "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz", 1312 | "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==", 1313 | "dependencies": { 1314 | "@types/json-schema": "^7.0.8", 1315 | "ajv": "^6.12.5", 1316 | "ajv-keywords": "^3.5.2" 1317 | }, 1318 | "engines": { 1319 | "node": ">= 10.13.0" 1320 | }, 1321 | "funding": { 1322 | "type": "opencollective", 1323 | "url": "https://opencollective.com/webpack" 1324 | } 1325 | }, 1326 | "node_modules/serialize-javascript": { 1327 | "version": "6.0.1", 1328 | "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", 1329 | "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", 1330 | "dependencies": { 1331 | "randombytes": "^2.1.0" 1332 | } 1333 | }, 1334 | "node_modules/shallow-clone": { 1335 | "version": "3.0.1", 1336 | "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", 1337 | "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", 1338 | "dependencies": { 1339 | "kind-of": "^6.0.2" 1340 | }, 1341 | "engines": { 1342 | "node": ">=8" 1343 | } 1344 | }, 1345 | "node_modules/shebang-command": { 1346 | "version": "2.0.0", 1347 | "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", 1348 | "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 1349 | "dependencies": { 1350 | "shebang-regex": "^3.0.0" 1351 | }, 1352 | "engines": { 1353 | "node": ">=8" 1354 | } 1355 | }, 1356 | "node_modules/shebang-regex": { 1357 | "version": "3.0.0", 1358 | "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", 1359 | "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", 1360 | "engines": { 1361 | "node": ">=8" 1362 | } 1363 | }, 1364 | "node_modules/signal-exit": { 1365 | "version": "3.0.3", 1366 | "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz", 1367 | "integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==" 1368 | }, 1369 | "node_modules/source-map": { 1370 | "version": "0.6.1", 1371 | "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", 1372 | "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", 1373 | "engines": { 1374 | "node": ">=0.10.0" 1375 | } 1376 | }, 1377 | "node_modules/source-map-support": { 1378 | "version": "0.5.21", 1379 | "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", 1380 | "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", 1381 | "dependencies": { 1382 | "buffer-from": "^1.0.0", 1383 | "source-map": "^0.6.0" 1384 | } 1385 | }, 1386 | "node_modules/strip-final-newline": { 1387 | "version": "2.0.0", 1388 | "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", 1389 | "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", 1390 | "engines": { 1391 | "node": ">=6" 1392 | } 1393 | }, 1394 | "node_modules/supports-color": { 1395 | "version": "8.1.1", 1396 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", 1397 | "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", 1398 | "dependencies": { 1399 | "has-flag": "^4.0.0" 1400 | }, 1401 | "engines": { 1402 | "node": ">=10" 1403 | }, 1404 | "funding": { 1405 | "url": "https://github.com/chalk/supports-color?sponsor=1" 1406 | } 1407 | }, 1408 | "node_modules/tapable": { 1409 | "version": "2.2.1", 1410 | "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", 1411 | "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", 1412 | "engines": { 1413 | "node": ">=6" 1414 | } 1415 | }, 1416 | "node_modules/terser": { 1417 | "version": "5.16.6", 1418 | "resolved": "https://registry.npmjs.org/terser/-/terser-5.16.6.tgz", 1419 | "integrity": "sha512-IBZ+ZQIA9sMaXmRZCUMDjNH0D5AQQfdn4WUjHL0+1lF4TP1IHRJbrhb6fNaXWikrYQTSkb7SLxkeXAiy1p7mbg==", 1420 | "dependencies": { 1421 | "@jridgewell/source-map": "^0.3.2", 1422 | "acorn": "^8.5.0", 1423 | "commander": "^2.20.0", 1424 | "source-map-support": "~0.5.20" 1425 | }, 1426 | "bin": { 1427 | "terser": "bin/terser" 1428 | }, 1429 | "engines": { 1430 | "node": ">=10" 1431 | } 1432 | }, 1433 | "node_modules/terser-webpack-plugin": { 1434 | "version": "5.3.7", 1435 | "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.7.tgz", 1436 | "integrity": "sha512-AfKwIktyP7Cu50xNjXF/6Qb5lBNzYaWpU6YfoX3uZicTx0zTy0stDDCsvjDapKsSDvOeWo5MEq4TmdBy2cNoHw==", 1437 | "dependencies": { 1438 | "@jridgewell/trace-mapping": "^0.3.17", 1439 | "jest-worker": "^27.4.5", 1440 | "schema-utils": "^3.1.1", 1441 | "serialize-javascript": "^6.0.1", 1442 | "terser": "^5.16.5" 1443 | }, 1444 | "engines": { 1445 | "node": ">= 10.13.0" 1446 | }, 1447 | "funding": { 1448 | "type": "opencollective", 1449 | "url": "https://opencollective.com/webpack" 1450 | }, 1451 | "peerDependencies": { 1452 | "webpack": "^5.1.0" 1453 | }, 1454 | "peerDependenciesMeta": { 1455 | "@swc/core": { 1456 | "optional": true 1457 | }, 1458 | "esbuild": { 1459 | "optional": true 1460 | }, 1461 | "uglify-js": { 1462 | "optional": true 1463 | } 1464 | } 1465 | }, 1466 | "node_modules/update-browserslist-db": { 1467 | "version": "1.0.10", 1468 | "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", 1469 | "integrity": "sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==", 1470 | "funding": [ 1471 | { 1472 | "type": "opencollective", 1473 | "url": "https://opencollective.com/browserslist" 1474 | }, 1475 | { 1476 | "type": "tidelift", 1477 | "url": "https://tidelift.com/funding/github/npm/browserslist" 1478 | } 1479 | ], 1480 | "dependencies": { 1481 | "escalade": "^3.1.1", 1482 | "picocolors": "^1.0.0" 1483 | }, 1484 | "bin": { 1485 | "browserslist-lint": "cli.js" 1486 | }, 1487 | "peerDependencies": { 1488 | "browserslist": ">= 4.21.0" 1489 | } 1490 | }, 1491 | "node_modules/uri-js": { 1492 | "version": "4.4.1", 1493 | "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", 1494 | "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", 1495 | "dependencies": { 1496 | "punycode": "^2.1.0" 1497 | } 1498 | }, 1499 | "node_modules/v8-compile-cache": { 1500 | "version": "2.3.0", 1501 | "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz", 1502 | "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==" 1503 | }, 1504 | "node_modules/watchpack": { 1505 | "version": "2.4.0", 1506 | "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", 1507 | "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", 1508 | "dependencies": { 1509 | "glob-to-regexp": "^0.4.1", 1510 | "graceful-fs": "^4.1.2" 1511 | }, 1512 | "engines": { 1513 | "node": ">=10.13.0" 1514 | } 1515 | }, 1516 | "node_modules/webpack": { 1517 | "version": "5.76.0", 1518 | "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.76.0.tgz", 1519 | "integrity": "sha512-l5sOdYBDunyf72HW8dF23rFtWq/7Zgvt/9ftMof71E/yUb1YLOBmTgA2K4vQthB3kotMrSj609txVE0dnr2fjA==", 1520 | "dependencies": { 1521 | "@types/eslint-scope": "^3.7.3", 1522 | "@types/estree": "^0.0.51", 1523 | "@webassemblyjs/ast": "1.11.1", 1524 | "@webassemblyjs/wasm-edit": "1.11.1", 1525 | "@webassemblyjs/wasm-parser": "1.11.1", 1526 | "acorn": "^8.7.1", 1527 | "acorn-import-assertions": "^1.7.6", 1528 | "browserslist": "^4.14.5", 1529 | "chrome-trace-event": "^1.0.2", 1530 | "enhanced-resolve": "^5.10.0", 1531 | "es-module-lexer": "^0.9.0", 1532 | "eslint-scope": "5.1.1", 1533 | "events": "^3.2.0", 1534 | "glob-to-regexp": "^0.4.1", 1535 | "graceful-fs": "^4.2.9", 1536 | "json-parse-even-better-errors": "^2.3.1", 1537 | "loader-runner": "^4.2.0", 1538 | "mime-types": "^2.1.27", 1539 | "neo-async": "^2.6.2", 1540 | "schema-utils": "^3.1.0", 1541 | "tapable": "^2.1.1", 1542 | "terser-webpack-plugin": "^5.1.3", 1543 | "watchpack": "^2.4.0", 1544 | "webpack-sources": "^3.2.3" 1545 | }, 1546 | "bin": { 1547 | "webpack": "bin/webpack.js" 1548 | }, 1549 | "engines": { 1550 | "node": ">=10.13.0" 1551 | }, 1552 | "funding": { 1553 | "type": "opencollective", 1554 | "url": "https://opencollective.com/webpack" 1555 | }, 1556 | "peerDependenciesMeta": { 1557 | "webpack-cli": { 1558 | "optional": true 1559 | } 1560 | } 1561 | }, 1562 | "node_modules/webpack-cli": { 1563 | "version": "4.6.0", 1564 | "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.6.0.tgz", 1565 | "integrity": "sha512-9YV+qTcGMjQFiY7Nb1kmnupvb1x40lfpj8pwdO/bom+sQiP4OBMKjHq29YQrlDWDPZO9r/qWaRRywKaRDKqBTA==", 1566 | "dependencies": { 1567 | "@discoveryjs/json-ext": "^0.5.0", 1568 | "@webpack-cli/configtest": "^1.0.2", 1569 | "@webpack-cli/info": "^1.2.3", 1570 | "@webpack-cli/serve": "^1.3.1", 1571 | "colorette": "^1.2.1", 1572 | "commander": "^7.0.0", 1573 | "enquirer": "^2.3.6", 1574 | "execa": "^5.0.0", 1575 | "fastest-levenshtein": "^1.0.12", 1576 | "import-local": "^3.0.2", 1577 | "interpret": "^2.2.0", 1578 | "rechoir": "^0.7.0", 1579 | "v8-compile-cache": "^2.2.0", 1580 | "webpack-merge": "^5.7.3" 1581 | }, 1582 | "bin": { 1583 | "webpack-cli": "bin/cli.js" 1584 | }, 1585 | "engines": { 1586 | "node": ">=10.13.0" 1587 | }, 1588 | "peerDependencies": { 1589 | "webpack": "4.x.x || 5.x.x" 1590 | }, 1591 | "peerDependenciesMeta": { 1592 | "@webpack-cli/generators": { 1593 | "optional": true 1594 | }, 1595 | "@webpack-cli/migrate": { 1596 | "optional": true 1597 | }, 1598 | "webpack-bundle-analyzer": { 1599 | "optional": true 1600 | }, 1601 | "webpack-dev-server": { 1602 | "optional": true 1603 | } 1604 | } 1605 | }, 1606 | "node_modules/webpack-cli/node_modules/commander": { 1607 | "version": "7.2.0", 1608 | "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", 1609 | "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", 1610 | "engines": { 1611 | "node": ">= 10" 1612 | } 1613 | }, 1614 | "node_modules/webpack-merge": { 1615 | "version": "5.7.3", 1616 | "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.7.3.tgz", 1617 | "integrity": "sha512-6/JUQv0ELQ1igjGDzHkXbVDRxkfA57Zw7PfiupdLFJYrgFqY5ZP8xxbpp2lU3EPwYx89ht5Z/aDkD40hFCm5AA==", 1618 | "dependencies": { 1619 | "clone-deep": "^4.0.1", 1620 | "wildcard": "^2.0.0" 1621 | }, 1622 | "engines": { 1623 | "node": ">=10.0.0" 1624 | } 1625 | }, 1626 | "node_modules/webpack-sources": { 1627 | "version": "3.2.3", 1628 | "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", 1629 | "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", 1630 | "engines": { 1631 | "node": ">=10.13.0" 1632 | } 1633 | }, 1634 | "node_modules/which": { 1635 | "version": "2.0.2", 1636 | "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", 1637 | "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", 1638 | "dependencies": { 1639 | "isexe": "^2.0.0" 1640 | }, 1641 | "bin": { 1642 | "node-which": "bin/node-which" 1643 | }, 1644 | "engines": { 1645 | "node": ">= 8" 1646 | } 1647 | }, 1648 | "node_modules/wildcard": { 1649 | "version": "2.0.0", 1650 | "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.0.tgz", 1651 | "integrity": "sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==" 1652 | } 1653 | } 1654 | } 1655 | --------------------------------------------------------------------------------