├── .dockerignore
├── ui
├── shared
│ ├── setup-tests.js
│ ├── components
│ │ ├── Tip.js
│ │ ├── Layout.js
│ │ ├── FieldError.js
│ │ ├── Footer.test.js
│ │ ├── index.js
│ │ ├── Footer.js
│ │ ├── Layout.test.js
│ │ ├── FieldError.test.js
│ │ ├── Header.test.js
│ │ ├── ErrorSummary.js
│ │ ├── Header.js
│ │ ├── GroupByList.js
│ │ └── ErrorSummary.test.js
│ ├── fetch.js
│ └── fetch.test.js
├── favicon.ico
├── features
│ ├── collections
│ │ ├── collections-api.js
│ │ ├── collections-api.test.js
│ │ ├── collections.js
│ │ ├── collections-components.js
│ │ ├── collections-components.test.js
│ │ └── collections.test.js
│ ├── variables
│ │ ├── variables-api.js
│ │ ├── variables.js
│ │ ├── variables-components.js
│ │ ├── variables-api.test.js
│ │ ├── variables-components.test.js
│ │ └── variables.test.js
│ ├── jobs
│ │ ├── jobs-api.js
│ │ ├── jobs.js
│ │ ├── jobs-api.test.js
│ │ └── jobs-components.js
│ ├── collection
│ │ ├── collection-api.js
│ │ ├── collection.js
│ │ └── collection-api.test.js
│ ├── variable
│ │ ├── variable-api.js
│ │ ├── variable-api.test.js
│ │ ├── variable.js
│ │ └── variable-components.js
│ ├── history
│ │ ├── history-api.js
│ │ ├── history-components.js
│ │ ├── history.js
│ │ └── history-api.test.js
│ └── job
│ │ └── job-api.js
├── index.test.js
├── index.html
└── index.js
├── infrastructure
├── http
│ ├── testdata
│ │ ├── jobs
│ │ │ ├── get
│ │ │ │ ├── error-golden.json
│ │ │ │ ├── empty-input.json
│ │ │ │ ├── error-input.json
│ │ │ │ ├── match-etag-golden.json
│ │ │ │ ├── match-etag-input.json
│ │ │ │ ├── no-match-etag-input.json
│ │ │ │ ├── empty-golden.json
│ │ │ │ ├── no-match-etag-golden.json
│ │ │ │ ├── ok-input.json
│ │ │ │ └── ok-golden.json
│ │ │ └── post
│ │ │ │ ├── conflict-golden.json
│ │ │ │ ├── unprocessable-input.json
│ │ │ │ ├── ok-golden.json
│ │ │ │ ├── validation-error-input.json
│ │ │ │ ├── unprocessable-golden.json
│ │ │ │ ├── conflict-input.json
│ │ │ │ ├── ok-input.json
│ │ │ │ └── validation-error-golden.json
│ │ ├── collections
│ │ │ ├── get
│ │ │ │ ├── error-golden.json
│ │ │ │ ├── empty-input.json
│ │ │ │ ├── error-input.json
│ │ │ │ ├── match-etag-golden.json
│ │ │ │ ├── match-etag-input.json
│ │ │ │ ├── no-match-etag-input.json
│ │ │ │ ├── empty-golden.json
│ │ │ │ ├── no-match-etag-golden.json
│ │ │ │ ├── ok-input.json
│ │ │ │ └── ok-golden.json
│ │ │ └── post
│ │ │ │ ├── conflict-golden.json
│ │ │ │ ├── unprocessable-input.json
│ │ │ │ ├── ok-golden.json
│ │ │ │ ├── validation-error-input.json
│ │ │ │ ├── conflict-input.json
│ │ │ │ ├── ok-input.json
│ │ │ │ ├── validation-error-golden.json
│ │ │ │ └── unprocessable-golden.json
│ │ ├── jobs-item
│ │ │ ├── delete
│ │ │ │ ├── ok-golden.json
│ │ │ │ ├── match-etag-golden.json
│ │ │ │ ├── not-found-golden.json
│ │ │ │ ├── etag-not-found-golden.json
│ │ │ │ ├── no-match-etag-golden.json
│ │ │ │ ├── invalid-id-input.json
│ │ │ │ ├── ok-input.json
│ │ │ │ ├── not-found-input.json
│ │ │ │ ├── etag-not-found-input.json
│ │ │ │ ├── invalid-id-golden.json
│ │ │ │ ├── match-etag-input.json
│ │ │ │ └── no-match-etag-input.json
│ │ │ ├── patch
│ │ │ │ ├── ok-golden.json
│ │ │ │ ├── match-etag-golden.json
│ │ │ │ ├── not-found-golden.json
│ │ │ │ ├── save-error-golden.json
│ │ │ │ ├── no-match-etag-golden.json
│ │ │ │ ├── save-conflict-golden.json
│ │ │ │ ├── invalid-id-input.json
│ │ │ │ ├── unprocessable-input.json
│ │ │ │ ├── not-found-input.json
│ │ │ │ ├── invalid-id-golden.json
│ │ │ │ ├── save-conflict-input.json
│ │ │ │ ├── validation-error-golden.json
│ │ │ │ ├── unprocessable-golden.json
│ │ │ │ ├── no-match-etag-input.json
│ │ │ │ ├── ok-input.json
│ │ │ │ ├── save-error-input.json
│ │ │ │ ├── validation-error-input.json
│ │ │ │ └── match-etag-input.json
│ │ │ └── get
│ │ │ │ ├── match-etag-golden.json
│ │ │ │ ├── not-found-golden.json
│ │ │ │ ├── invalid-id-input.json
│ │ │ │ ├── not-found-input.json
│ │ │ │ ├── invalid-id-golden.json
│ │ │ │ ├── match-etag-input.json
│ │ │ │ ├── ok-input.json
│ │ │ │ ├── ok-golden.json
│ │ │ │ ├── no-match-etag-golden.json
│ │ │ │ └── no-match-etag-input.json
│ │ ├── collections-item
│ │ │ ├── delete
│ │ │ │ ├── ok-golden.json
│ │ │ │ ├── match-etag-golden.json
│ │ │ │ ├── no-match-etag-golden.json
│ │ │ │ ├── not-found-golden.json
│ │ │ │ ├── etag-not-found-golden.json
│ │ │ │ ├── invalid-id-input.json
│ │ │ │ ├── ok-input.json
│ │ │ │ ├── not-found-input.json
│ │ │ │ ├── etag-not-found-input.json
│ │ │ │ ├── invalid-id-golden.json
│ │ │ │ ├── match-etag-input.json
│ │ │ │ └── no-match-etag-input.json
│ │ │ ├── patch
│ │ │ │ ├── ok-golden.json
│ │ │ │ ├── not-found-golden.json
│ │ │ │ ├── match-etag-golden.json
│ │ │ │ ├── no-match-etag-golden.json
│ │ │ │ ├── save-conflict-golden.json
│ │ │ │ ├── save-error-golden.json
│ │ │ │ ├── invalid-id-input.json
│ │ │ │ ├── unprocessable-input.json
│ │ │ │ ├── validation-error-input.json
│ │ │ │ ├── not-found-input.json
│ │ │ │ ├── ok-input.json
│ │ │ │ ├── invalid-id-golden.json
│ │ │ │ ├── save-conflict-input.json
│ │ │ │ ├── validation-error-golden.json
│ │ │ │ ├── save-error-input.json
│ │ │ │ ├── unprocessable-golden.json
│ │ │ │ ├── no-match-etag-input.json
│ │ │ │ └── match-etag-input.json
│ │ │ └── get
│ │ │ │ ├── match-etag-golden.json
│ │ │ │ ├── not-found-golden.json
│ │ │ │ ├── invalid-id-input.json
│ │ │ │ ├── not-found-input.json
│ │ │ │ ├── ok-input.json
│ │ │ │ ├── ok-golden.json
│ │ │ │ ├── no-match-etag-golden.json
│ │ │ │ ├── invalid-id-golden.json
│ │ │ │ ├── match-etag-input.json
│ │ │ │ └── no-match-etag-input.json
│ │ ├── jobs-item-history
│ │ │ ├── delete
│ │ │ │ ├── ok-golden.json
│ │ │ │ ├── delete-error-golden.json
│ │ │ │ ├── match-etag-golden.json
│ │ │ │ ├── not-found-golden.json
│ │ │ │ ├── etag-not-found-golden.json
│ │ │ │ ├── no-match-etag-golden.json
│ │ │ │ ├── invalid-id-input.json
│ │ │ │ ├── ok-input.json
│ │ │ │ ├── validation-error-input.json
│ │ │ │ ├── not-found-input.json
│ │ │ │ ├── delete-error-input.json
│ │ │ │ ├── etag-not-found-input.json
│ │ │ │ ├── match-etag-input.json
│ │ │ │ ├── no-match-etag-input.json
│ │ │ │ ├── invalid-id-golden.json
│ │ │ │ └── validation-error-golden.json
│ │ │ └── get
│ │ │ │ ├── match-etag-golden.json
│ │ │ │ ├── not-found-golden.json
│ │ │ │ ├── match-not-found-golden.json
│ │ │ │ ├── status-failed-golden.json
│ │ │ │ ├── invalid-id-input.json
│ │ │ │ ├── not-found-input.json
│ │ │ │ ├── status-failed-input.json
│ │ │ │ ├── no-match-etag-golden.json
│ │ │ │ ├── match-not-found-input.json
│ │ │ │ ├── match-etag-input.json
│ │ │ │ ├── no-match-etag-input.json
│ │ │ │ ├── invalid-id-golden.json
│ │ │ │ ├── ok-input.json
│ │ │ │ └── ok-golden.json
│ │ ├── jobs-item-status
│ │ │ ├── patch
│ │ │ │ ├── ok-golden.json
│ │ │ │ ├── not-found-golden.json
│ │ │ │ ├── run-error-golden.json
│ │ │ │ ├── match-etag-golden.json
│ │ │ │ ├── no-match-etag-golden.json
│ │ │ │ ├── run-running-golden.json
│ │ │ │ ├── cancel-not-running-golden.json
│ │ │ │ ├── invalid-id-input.json
│ │ │ │ ├── unprocessable-input.json
│ │ │ │ ├── no-match-etag-input.json
│ │ │ │ ├── not-found-input.json
│ │ │ │ ├── cancel-not-running-input.json
│ │ │ │ ├── cancel-input.json
│ │ │ │ ├── run-running-input.json
│ │ │ │ ├── run-error-input.json
│ │ │ │ ├── invalid-id-golden.json
│ │ │ │ ├── cancel-golden.json
│ │ │ │ ├── ok-input.json
│ │ │ │ ├── unprocessable-golden.json
│ │ │ │ └── match-etag-input.json
│ │ │ └── get
│ │ │ │ ├── match-etag-golden.json
│ │ │ │ ├── not-found-golden.json
│ │ │ │ ├── invalid-id-input.json
│ │ │ │ ├── not-found-input.json
│ │ │ │ ├── ok-input.json
│ │ │ │ ├── match-etag-input.json
│ │ │ │ ├── no-match-etag-input.json
│ │ │ │ ├── ok-golden.json
│ │ │ │ ├── no-match-etag-golden.json
│ │ │ │ └── invalid-id-golden.json
│ │ └── health
│ │ │ └── get
│ │ │ ├── ok-input.json
│ │ │ ├── error-input.json
│ │ │ ├── ok-golden.json
│ │ │ └── error-golden.json
│ ├── etag.go
│ ├── ui.go
│ ├── runner.go
│ ├── server.go
│ ├── routes.go
│ └── runner_test.go
├── postgres
│ ├── history.go
│ ├── collections.go
│ ├── variables.go
│ └── subscriber.go
└── cron
│ └── scheduler.go
├── .gitignore
├── misc
├── docs
│ └── img
│ │ ├── job.png
│ │ ├── jobs.png
│ │ ├── collection.png
│ │ ├── db-schema.png
│ │ ├── variable.png
│ │ ├── variables.png
│ │ ├── architecture.png
│ │ ├── collections.png
│ │ ├── job-history.png
│ │ └── general-error.png
├── k8s
│ ├── README.md
│ ├── db-service.yaml
│ ├── db-persistentvolumeclaim.yaml
│ ├── app-service.yaml
│ ├── db-deployment.yaml
│ └── app-deployment.yaml
├── docker
│ ├── README.md
│ ├── docker-compose.yaml
│ └── Dockerfile
└── db
│ ├── patch-ids.sql
│ └── samples.sql
├── domain
├── testdata
│ └── validation
│ │ ├── collection
│ │ ├── ok.json
│ │ └── invalid.json
│ │ └── job
│ │ ├── invalid-headers.json
│ │ ├── request-null.json
│ │ ├── uri-not-http.json
│ │ ├── invalid-uri.json
│ │ ├── ok.json
│ │ └── invalid.json
├── subscriber.go
├── scheduler.go
├── runner.go
├── models_etag.go
├── models_etag_test.go
├── repository.go
├── template.go
└── validation_test.go
├── go.mod
├── .babelrc
├── .github
├── dependabot.yml
└── workflows
│ ├── release.yml
│ ├── image.yml
│ └── tests.yaml
├── core
├── history.go
├── collections.go
├── scheduler.go
├── service.go
├── variables.go
├── subscription.go
├── runner.go
└── jobs.go
├── LICENSE
├── main.go
├── .eslintrc
├── shared
└── rule
│ └── validation.go
├── webpack.config.js
├── go.sum
└── package.json
/.dockerignore:
--------------------------------------------------------------------------------
1 | .git/
2 | static/
3 | node_modules/
4 | *.exe
5 |
--------------------------------------------------------------------------------
/ui/shared/setup-tests.js:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom';
2 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs/get/error-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 503
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections/get/error-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 503
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/delete/ok-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 204
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/patch/ok-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 204
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs/post/conflict-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 409
3 | }
--------------------------------------------------------------------------------
/ui/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akornatskyy/scheduler/HEAD/ui/favicon.ico
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/delete/ok-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 204
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/patch/ok-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 204
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections/post/conflict-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 409
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/delete/ok-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 204
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/patch/ok-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 204
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/delete/match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 204
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/delete/not-found-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 404
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/get/match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 304
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/get/not-found-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 404
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/patch/match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 204
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/patch/not-found-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 404
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/patch/save-error-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 503
3 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.exe
2 | *.exe~
3 | *.test
4 | *.out
5 | node_modules/
6 | static/
7 | coverage/
8 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/get/match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 304
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/get/not-found-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 404
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/patch/not-found-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 404
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/get/match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 304
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/get/not-found-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 404
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/get/match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 304
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/get/not-found-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 404
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/patch/not-found-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 404
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/patch/run-error-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 503
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/delete/etag-not-found-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 404
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/delete/no-match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 412
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/patch/no-match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 412
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/patch/save-conflict-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 409
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/delete/match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 204
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/delete/no-match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 412
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/delete/not-found-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 404
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/patch/match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 204
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/patch/no-match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 412
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/patch/save-conflict-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 409
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/patch/save-error-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 503
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/delete/delete-error-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 503
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/delete/match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 204
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/delete/not-found-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 404
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/get/match-not-found-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 404
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/get/status-failed-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 503
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/patch/match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 204
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/patch/no-match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 412
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/patch/run-running-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 204
3 | }
--------------------------------------------------------------------------------
/misc/docs/img/job.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akornatskyy/scheduler/HEAD/misc/docs/img/job.png
--------------------------------------------------------------------------------
/misc/docs/img/jobs.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akornatskyy/scheduler/HEAD/misc/docs/img/jobs.png
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/delete/etag-not-found-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 404
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/delete/etag-not-found-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 404
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/delete/no-match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 412
3 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/patch/cancel-not-running-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 204
3 | }
--------------------------------------------------------------------------------
/misc/docs/img/collection.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akornatskyy/scheduler/HEAD/misc/docs/img/collection.png
--------------------------------------------------------------------------------
/misc/docs/img/db-schema.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akornatskyy/scheduler/HEAD/misc/docs/img/db-schema.png
--------------------------------------------------------------------------------
/misc/docs/img/variable.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akornatskyy/scheduler/HEAD/misc/docs/img/variable.png
--------------------------------------------------------------------------------
/misc/docs/img/variables.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akornatskyy/scheduler/HEAD/misc/docs/img/variables.png
--------------------------------------------------------------------------------
/misc/docs/img/architecture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akornatskyy/scheduler/HEAD/misc/docs/img/architecture.png
--------------------------------------------------------------------------------
/misc/docs/img/collections.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akornatskyy/scheduler/HEAD/misc/docs/img/collections.png
--------------------------------------------------------------------------------
/misc/docs/img/job-history.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akornatskyy/scheduler/HEAD/misc/docs/img/job-history.png
--------------------------------------------------------------------------------
/misc/docs/img/general-error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/akornatskyy/scheduler/HEAD/misc/docs/img/general-error.png
--------------------------------------------------------------------------------
/domain/testdata/validation/collection/ok.json:
--------------------------------------------------------------------------------
1 | {
2 | "collection": {
3 | "id": "",
4 | "name": "My App #1"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/health/get/ok-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/health"
4 | },
5 | "mock": {
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs/get/empty-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs"
4 | },
5 | "mock": {
6 | "jobs": []
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/get/invalid-id-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs/1234567890123456789012345678901234567"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/health/get/error-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/health"
4 | },
5 | "mock": {
6 | "err": "ping"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs/get/error-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs"
4 | },
5 | "mock": {
6 | "err": "unexpected"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs/post/unprocessable-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "POST",
4 | "path": "/jobs"
5 | },
6 | "mock": {
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/get/invalid-id-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/collections/1234567890123456789012345678901234567"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections/get/empty-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/collections"
4 | },
5 | "mock": {
6 | "collections": []
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/get/invalid-id-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs/1234567890123456789012345678901234567/history"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/get/invalid-id-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs/1234567890123456789012345678901234567/status"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/ui/features/collections/collections-api.js:
--------------------------------------------------------------------------------
1 | import {go} from '../../shared/fetch';
2 |
3 | export function listCollections() {
4 | return go('GET', '/collections');
5 | }
6 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections/get/error-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/collections"
4 | },
5 | "mock": {
6 | "err": "unexpected"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections/post/unprocessable-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "POST",
4 | "path": "/collections"
5 | },
6 | "mock": {
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/delete/invalid-id-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "DELETE",
4 | "path": "/jobs/1234567890123456789012345678901234567"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/patch/invalid-id-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/jobs/1234567890123456789012345678901234567"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs/get/match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 304,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | }
8 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections/get/match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 304,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | }
8 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/delete/invalid-id-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "DELETE",
4 | "path": "/collections/1234567890123456789012345678901234567"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/patch/invalid-id-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/collections/1234567890123456789012345678901234567"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/delete/invalid-id-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "DELETE",
4 | "path": "/jobs/1234567890123456789012345678901234567/history"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/patch/invalid-id-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/jobs/1234567890123456789012345678901234567/status"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/delete/ok-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "DELETE",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899"
5 | },
6 | "mock": {
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/get/not-found-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899"
4 | },
5 | "mock": {
6 | "err": "not found"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/patch/unprocessable-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899"
5 | },
6 | "mock": {
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/domain/subscriber.go:
--------------------------------------------------------------------------------
1 | package domain
2 |
3 | type UpdateEventCallback func(*UpdateEvent) error
4 |
5 | type Subscriber interface {
6 | SetCallback(callback UpdateEventCallback)
7 | Start()
8 | Stop()
9 | }
10 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/delete/ok-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "DELETE",
4 | "path": "/collections/d4be3c55-039a-4480-a85c-820bbbdd4899"
5 | },
6 | "mock": {
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/misc/k8s/README.md:
--------------------------------------------------------------------------------
1 | # K8S
2 |
3 | ```sh
4 | kubectl apply -f misc/k8s
5 |
6 | kubectl port-forward service/scheduler-db 5432
7 | # apply sql scripts
8 | kubectl port-forward service/scheduler 8080
9 | ```
10 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/get/not-found-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/collections/d4be3c55-039a-4480-a85c-820bbbdd4899"
4 | },
5 | "mock": {
6 | "err": "not found"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/delete/ok-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "DELETE",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899/history"
5 | },
6 | "mock": {
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/get/not-found-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899/history"
4 | },
5 | "mock": {
6 | "err": "not found"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/get/not-found-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899/status"
4 | },
5 | "mock": {
6 | "err": "not found"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/patch/unprocessable-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/collections/d4be3c55-039a-4480-a85c-820bbbdd4899"
5 | },
6 | "mock": {
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/get/status-failed-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899/history"
4 | },
5 | "mock": {
6 | "err": "retrieve-job-status"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs/post/ok-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 201,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": "8a332e22-5b6d-4173-a61f-bc0863fb60bb"
9 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections/post/ok-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 201,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": "fc62401e-7ad4-4050-995a-56bed972a7a6"
9 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/health/get/ok-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 200,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "status": "up"
10 | }
11 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/delete/validation-error-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "DELETE",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899/history?before=x"
5 | },
6 | "mock": {
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/misc/k8s/db-service.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: scheduler-db
5 | spec:
6 | selector:
7 | app: scheduler-db
8 | ports:
9 | - name: postgresql
10 | port: 5432
11 | targetPort: 5432
12 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/delete/not-found-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "DELETE",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899"
5 | },
6 | "mock": {
7 | "err": "not found"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/misc/k8s/db-persistentvolumeclaim.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: PersistentVolumeClaim
3 | metadata:
4 | name: scheduler-db
5 | spec:
6 | accessModes:
7 | - ReadWriteOnce
8 | resources:
9 | requests:
10 | storage: 100Mi
11 |
--------------------------------------------------------------------------------
/misc/k8s/app-service.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: v1
2 | kind: Service
3 | metadata:
4 | name: scheduler
5 | spec:
6 | selector:
7 | app: scheduler
8 | type: LoadBalancer
9 | ports:
10 | - name: http
11 | port: 8080
12 | targetPort: 8080
13 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/delete/not-found-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "DELETE",
4 | "path": "/collections/d4be3c55-039a-4480-a85c-820bbbdd4899"
5 | },
6 | "mock": {
7 | "err": "not found"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/delete/not-found-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "DELETE",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899/history"
5 | },
6 | "mock": {
7 | "err": "not found"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/patch/unprocessable-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899/status"
5 | },
6 | "mock": {
7 | "jobStatus": {
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/health/get/error-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 503,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "message": "ping",
10 | "status": "down"
11 | }
12 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/delete/delete-error-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "DELETE",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899/history"
5 | },
6 | "mock": {
7 | "err": "delete-job-history"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs/get/match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs",
4 | "headers": {
5 | "If-None-Match": [
6 | "\"57k0vs3uybfn\""
7 | ]
8 | }
9 | },
10 | "mock": {
11 | "jobs": []
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs/get/no-match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs",
4 | "headers": {
5 | "If-None-Match": [
6 | "\"no match\""
7 | ]
8 | }
9 | },
10 | "mock": {
11 | "jobs": []
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/get/ok-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs/dc93f741-ccc4-4d15-9023-950392a74309/status"
4 | },
5 | "mock": {
6 | "jobStatus": {
7 | "updated": "2019-07-03T10:02:04.436276Z"
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/domain/scheduler.go:
--------------------------------------------------------------------------------
1 | package domain
2 |
3 | import "time"
4 |
5 | type Scheduler interface {
6 | SetRunner(f func(*JobDefinition))
7 | ListIDs() []string
8 | Add(j *JobDefinition) error
9 | Remove(id string)
10 | NextRun(id string) *time.Time
11 | Start()
12 | Stop()
13 | }
14 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs/post/validation-error-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "POST",
4 | "path": "/jobs",
5 | "headers": {
6 | "Content-Type": ["application/json"]
7 | },
8 | "body": {
9 | }
10 | },
11 | "mock": {
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/ui/shared/components/Tip.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const Tip = ({children}) => (
4 |
5 |
6 | Tip! {children}
7 |
8 | );
9 |
10 | export default Tip;
11 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections/get/match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/collections",
4 | "headers": {
5 | "If-None-Match": [
6 | "\"57k0vs3uybfn\""
7 | ]
8 | }
9 | },
10 | "mock": {
11 | "collections": []
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections/get/no-match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/collections",
4 | "headers": {
5 | "If-None-Match": [
6 | "\"no match\""
7 | ]
8 | }
9 | },
10 | "mock": {
11 | "collections": []
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections/post/validation-error-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "POST",
4 | "path": "/collections",
5 | "headers": {
6 | "Content-Type": ["application/json"]
7 | },
8 | "body": {}
9 | },
10 | "mock": {
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs/get/empty-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 200,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ],
7 | "Etag": [
8 | "\"57k0vs3uybfn\""
9 | ]
10 | },
11 | "body": {
12 | "items": []
13 | }
14 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections/get/empty-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 200,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ],
7 | "Etag": [
8 | "\"57k0vs3uybfn\""
9 | ]
10 | },
11 | "body": {
12 | "items": []
13 | }
14 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs/get/no-match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 200,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ],
7 | "Etag": [
8 | "\"57k0vs3uybfn\""
9 | ]
10 | },
11 | "body": {
12 | "items": []
13 | }
14 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections/get/no-match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 200,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ],
7 | "Etag": [
8 | "\"57k0vs3uybfn\""
9 | ]
10 | },
11 | "body": {
12 | "items": []
13 | }
14 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/get/no-match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 200,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ],
7 | "Etag": [
8 | "\"fdqgxvtir8\""
9 | ]
10 | },
11 | "body": {
12 | "items": []
13 | }
14 | }
--------------------------------------------------------------------------------
/ui/features/variables/variables-api.js:
--------------------------------------------------------------------------------
1 | import {go} from '../../shared/fetch';
2 |
3 | export {listCollections} from '../collections/collections-api';
4 |
5 | export function listVariables(collectionId) {
6 | return go(
7 | 'GET',
8 | collectionId ? `/variables?collectionId=${collectionId}` : '/variables',
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/akornatskyy/scheduler
2 |
3 | go 1.11
4 |
5 | require (
6 | github.com/NYTimes/gziphandler v1.1.1
7 | github.com/akornatskyy/goext v1.4.5
8 | github.com/google/uuid v1.6.0 // indirect
9 | github.com/julienschmidt/httprouter v1.3.0
10 | github.com/lib/pq v1.10.9
11 | github.com/robfig/cron/v3 v3.0.1
12 | )
13 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/get/match-not-found-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs/dc93f741-ccc4-4d15-9023-950392a74309/history",
4 | "headers": {
5 | "If-None-Match": [
6 | "\"fdqgxvtir8\""
7 | ]
8 | }
9 | },
10 | "mock": {
11 | "err": "not found"
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/domain/runner.go:
--------------------------------------------------------------------------------
1 | package domain
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | )
7 |
8 | type RunError struct {
9 | Code int
10 | Err error
11 | }
12 |
13 | func (r *RunError) Error() string {
14 | return fmt.Sprintf("%d %s", r.Code, r.Err)
15 | }
16 |
17 | type Runner interface {
18 | Run(ctx context.Context, a *Action) error
19 | }
20 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections/post/conflict-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "POST",
4 | "path": "/collections",
5 | "headers": {
6 | "Content-Type": ["application/json"]
7 | },
8 | "body": {
9 | "name": "my-app"
10 | }
11 | },
12 | "mock": {
13 | "err": "conflict"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/delete/etag-not-found-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "DELETE",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899",
5 | "headers": {
6 | "If-Match": [
7 | "\"fdqgxvtir8\""
8 | ]
9 | }
10 | },
11 | "mock": {
12 | "err": "not found"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "@babel/preset-env",
5 | {
6 | "targets": {
7 | "node": "18"
8 | }
9 | }
10 | ],
11 | "@babel/preset-react"
12 | ],
13 | "plugins": [
14 | "@babel/plugin-transform-class-properties",
15 | "@babel/plugin-transform-optional-chaining"
16 | ]
17 | }
18 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/delete/etag-not-found-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "DELETE",
4 | "path": "/collections/d4be3c55-039a-4480-a85c-820bbbdd4899",
5 | "headers": {
6 | "If-Match": [
7 | "\"fdqgxvtir8\""
8 | ]
9 | }
10 | },
11 | "mock": {
12 | "err": "not found"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/delete/etag-not-found-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "DELETE",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899/history",
5 | "headers": {
6 | "If-Match": [
7 | "\"fdqgxvtir8\""
8 | ]
9 | }
10 | },
11 | "mock": {
12 | "err": "not found"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections/post/ok-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "POST",
4 | "path": "/collections",
5 | "headers": {
6 | "Content-Type": ["application/json"]
7 | },
8 | "body": {
9 | "id": "fc62401e-7ad4-4050-995a-56bed972a7a6",
10 | "name": "my-app"
11 | }
12 | },
13 | "mock": {
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/ui/shared/components/Layout.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import ErrorSummary from './ErrorSummary';
4 |
5 | const Layout = ({title, errors, children}) => (
6 | <>
7 |
{title}
8 |
9 |
10 |
11 | {children}
12 |
13 | >
14 | );
15 |
16 | export default Layout;
17 |
--------------------------------------------------------------------------------
/ui/features/jobs/jobs-api.js:
--------------------------------------------------------------------------------
1 | import {go} from '../../shared/fetch';
2 |
3 | export {listCollections} from '../collections/collections-api';
4 |
5 | export function listJobs(collectionId) {
6 | return go(
7 | 'GET',
8 | collectionId
9 | ? `/jobs?fields=status,errorRate&collectionId=${collectionId}`
10 | : '/jobs?fields=status,errorRate',
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/ui/shared/components/FieldError.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const FieldError = ({message}) => {
4 | if (!message) {
5 | return null;
6 | }
7 | return (
8 |
9 |
10 | {message}
11 |
12 | );
13 | };
14 |
15 | export default FieldError;
16 |
--------------------------------------------------------------------------------
/ui/shared/components/Footer.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, screen} from '@testing-library/react';
3 |
4 | import Footer from './Footer';
5 |
6 | describe('footer component', () => {
7 | it('renders a link to documentation', () => {
8 | render();
9 |
10 | expect(screen.getByText('Documentation')).toBeVisible();
11 | });
12 | });
13 |
--------------------------------------------------------------------------------
/ui/shared/components/index.js:
--------------------------------------------------------------------------------
1 | export {default as ErrorSummary} from './ErrorSummary';
2 | export {default as FieldError} from './FieldError';
3 | export {default as Footer} from './Footer';
4 | export {default as GroupByList} from './GroupByList';
5 | export {default as Header} from './Header';
6 | export {default as Layout} from './Layout';
7 | export {default as Tip} from './Tip';
8 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/get/match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs/dc93f741-ccc4-4d15-9023-950392a74309/history",
4 | "headers": {
5 | "If-None-Match": [
6 | "\"fdqgxvtir8\""
7 | ]
8 | }
9 | },
10 | "mock": {
11 | "jobStatus": {
12 | "updated": "2019-07-03T10:02:04.436276Z"
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/get/match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs/dc93f741-ccc4-4d15-9023-950392a74309/status",
4 | "headers": {
5 | "If-None-Match": [
6 | "\"fdqgxvtir8\""
7 | ]
8 | }
9 | },
10 | "mock": {
11 | "jobStatus": {
12 | "updated": "2019-07-03T10:02:04.436276Z"
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/get/no-match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs/dc93f741-ccc4-4d15-9023-950392a74309/status",
4 | "headers": {
5 | "If-None-Match": [
6 | "\"no match\""
7 | ]
8 | }
9 | },
10 | "mock": {
11 | "jobStatus": {
12 | "updated": "2019-07-03T10:02:04.436276Z"
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/get/ok-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/collections/d4be3c55-039a-4480-a85c-820bbbdd4899"
4 | },
5 | "mock": {
6 | "collection": {
7 | "id": "d4be3c55-039a-4480-a85c-820bbbdd4899",
8 | "name": "my-app",
9 | "updated": "2019-07-03T10:02:04.436276Z",
10 | "state": "disabled"
11 | }
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/patch/validation-error-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/collections/d4be3c55-039a-4480-a85c-820bbbdd4899",
5 | "headers": {
6 | "Content-Type": [
7 | "application/json"
8 | ]
9 | },
10 | "body": {
11 | "state": "disabled"
12 | }
13 | },
14 | "mock": {
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/get/ok-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 200,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ],
7 | "Etag": [
8 | "\"fdqgxvtir8\""
9 | ]
10 | },
11 | "body": {
12 | "errorCount": 0,
13 | "runCount": 0,
14 | "running": false,
15 | "updated": "2019-07-03T10:02:04.436276Z"
16 | }
17 | }
--------------------------------------------------------------------------------
/domain/testdata/validation/job/invalid-headers.json:
--------------------------------------------------------------------------------
1 | {
2 | "job": {
3 | "id": "8a332e22-5b6d-4173-a61f-bc0863fb60bb",
4 | "name": "my-task",
5 | "collectionId": "f493d75f-3239-4136-ad39-19bff1d409ee",
6 | "schedule": "@every 10s",
7 | "action": {
8 | "type": "HTTP",
9 | "request": {
10 | "uri": "http://localhost:8080/test"
11 | }
12 | }
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/patch/not-found-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899",
5 | "headers": {
6 | "Content-Type": [
7 | "application/json"
8 | ]
9 | },
10 | "body": {
11 | "state": "disabled"
12 | }
13 | },
14 | "mock": {
15 | "err": "not found"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/patch/no-match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899/status",
5 | "headers": {
6 | "If-Match": [
7 | "\"no match\""
8 | ]
9 | }
10 | },
11 | "mock": {
12 | "jobStatus": {
13 | "updated": "2019-07-03T10:02:04.436276Z"
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/delete/match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "DELETE",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899/history",
5 | "headers": {
6 | "If-Match": [
7 | "\"fdqgxvtir8\""
8 | ]
9 | }
10 | },
11 | "mock": {
12 | "jobStatus": {
13 | "updated": "2019-07-03T10:02:04.436276Z"
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/delete/no-match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "DELETE",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899/history",
5 | "headers": {
6 | "If-Match": [
7 | "\"no match\""
8 | ]
9 | }
10 | },
11 | "mock": {
12 | "jobStatus": {
13 | "updated": "2019-07-03T10:02:04.436276Z"
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/get/no-match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs/dc93f741-ccc4-4d15-9023-950392a74309/history",
4 | "headers": {
5 | "If-None-Match": [
6 | "\"no match\""
7 | ]
8 | }
9 | },
10 | "mock": {
11 | "jobStatus": {
12 | "updated": "2019-07-03T10:02:04.436276Z"
13 | },
14 | "jobHistory": []
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/get/no-match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 200,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ],
7 | "Etag": [
8 | "\"fdqgxvtir8\""
9 | ]
10 | },
11 | "body": {
12 | "errorCount": 0,
13 | "runCount": 0,
14 | "running": false,
15 | "updated": "2019-07-03T10:02:04.436276Z"
16 | }
17 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/patch/not-found-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899/status",
5 | "headers": {
6 | "Content-Type": [
7 | "application/json"
8 | ]
9 | },
10 | "body": {
11 | "running": false
12 | }
13 | },
14 | "mock": {
15 | "err": "not found"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/patch/not-found-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/collections/d4be3c55-039a-4480-a85c-820bbbdd4899",
5 | "headers": {
6 | "Content-Type": [
7 | "application/json"
8 | ]
9 | },
10 | "body": {
11 | "state": "disabled"
12 | }
13 | },
14 | "mock": {
15 | "err": "not found"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/patch/cancel-not-running-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899/status",
5 | "headers": {
6 | "Content-Type": [
7 | "application/json"
8 | ]
9 | },
10 | "body": {
11 | "running": false
12 | }
13 | },
14 | "mock": {
15 | "jobStatus": {
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/get/ok-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 200,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ],
7 | "Etag": [
8 | "\"fdqgxvtir8\""
9 | ]
10 | },
11 | "body": {
12 | "id": "d4be3c55-039a-4480-a85c-820bbbdd4899",
13 | "name": "my-app",
14 | "state": "disabled",
15 | "updated": "2019-07-03T10:02:04.436276Z"
16 | }
17 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/patch/ok-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/collections/d4be3c55-039a-4480-a85c-820bbbdd4899",
5 | "headers": {
6 | "Content-Type": [
7 | "application/json"
8 | ]
9 | },
10 | "body": {
11 | "state": "disabled"
12 | }
13 | },
14 | "mock": {
15 | "collection": {
16 | "name": "my-app"
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/patch/cancel-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899/status",
5 | "headers": {
6 | "Content-Type": [
7 | "application/json"
8 | ]
9 | },
10 | "body": {
11 | "running": false
12 | }
13 | },
14 | "mock": {
15 | "jobStatus": {
16 | "running": true
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/get/no-match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 200,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ],
7 | "Etag": [
8 | "\"fdqgxvtir8\""
9 | ]
10 | },
11 | "body": {
12 | "id": "4cc78806-10cb-40ee-b9e5-3c0b5da877b1",
13 | "name": "my-app",
14 | "state": "disabled",
15 | "updated": "2019-07-03T10:02:04.436276Z"
16 | }
17 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/patch/run-running-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899/status",
5 | "headers": {
6 | "Content-Type": [
7 | "application/json"
8 | ]
9 | },
10 | "body": {
11 | "running": true
12 | }
13 | },
14 | "mock": {
15 | "jobStatus": {
16 | "running": true
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/patch/run-error-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899/status",
5 | "headers": {
6 | "Content-Type": [
7 | "application/json"
8 | ]
9 | },
10 | "body": {
11 | "running": true
12 | }
13 | },
14 | "mock": {
15 | "jobStatus": {
16 | },
17 | "err": "retrieve-job"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/get/invalid-id-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 400,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "scheduler",
12 | "location": "id",
13 | "message": "Exceeds maximum length of 36.",
14 | "reason": "max length",
15 | "type": "field"
16 | }
17 | ]
18 | }
19 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/patch/invalid-id-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 400,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "scheduler",
12 | "location": "id",
13 | "message": "Exceeds maximum length of 36.",
14 | "reason": "max length",
15 | "type": "field"
16 | }
17 | ]
18 | }
19 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/patch/save-conflict-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899",
5 | "headers": {
6 | "Content-Type": [
7 | "application/json"
8 | ]
9 | },
10 | "body": {
11 | "state": "disabled"
12 | }
13 | },
14 | "mock": {
15 | "job": {
16 | "name": "my-task"
17 | },
18 | "err": "conflict"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/get/invalid-id-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 400,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "scheduler",
12 | "location": "id",
13 | "message": "Exceeds maximum length of 36.",
14 | "reason": "max length",
15 | "type": "field"
16 | }
17 | ]
18 | }
19 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections/get/ok-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/collections"
4 | },
5 | "mock": {
6 | "collections": [{
7 | "id": "d4be3c55-039a-4480-a85c-820bbbdd4899",
8 | "name": "my-app-1",
9 | "state": "enabled"
10 | },
11 | {
12 | "id": "f493d75f-3239-4136-ad39-19bff1d409ee",
13 | "name": "my-app-2",
14 | "state": "disabled"
15 | }
16 | ]
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/get/invalid-id-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 400,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "scheduler",
12 | "location": "id",
13 | "message": "Exceeds maximum length of 36.",
14 | "reason": "max length",
15 | "type": "field"
16 | }
17 | ]
18 | }
19 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/delete/invalid-id-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 400,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "scheduler",
12 | "location": "id",
13 | "message": "Exceeds maximum length of 36.",
14 | "reason": "max length",
15 | "type": "field"
16 | }
17 | ]
18 | }
19 | }
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: github-actions
4 | directory: '/'
5 | schedule:
6 | interval: daily
7 | - package-ecosystem: gomod
8 | directory: '/'
9 | schedule:
10 | interval: daily
11 | - package-ecosystem: npm
12 | directory: '/'
13 | schedule:
14 | interval: daily
15 | open-pull-requests-limit: 10
16 | - package-ecosystem: docker
17 | directory: '/misc/docker'
18 | schedule:
19 | interval: daily
20 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/delete/invalid-id-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 400,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "scheduler",
12 | "location": "id",
13 | "message": "Exceeds maximum length of 36.",
14 | "reason": "max length",
15 | "type": "field"
16 | }
17 | ]
18 | }
19 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/patch/invalid-id-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 400,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "scheduler",
12 | "location": "id",
13 | "message": "Exceeds maximum length of 36.",
14 | "reason": "max length",
15 | "type": "field"
16 | }
17 | ]
18 | }
19 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/delete/invalid-id-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 400,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "scheduler",
12 | "location": "id",
13 | "message": "Exceeds maximum length of 36.",
14 | "reason": "max length",
15 | "type": "field"
16 | }
17 | ]
18 | }
19 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/get/invalid-id-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 400,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "scheduler",
12 | "location": "id",
13 | "message": "Exceeds maximum length of 36.",
14 | "reason": "max length",
15 | "type": "field"
16 | }
17 | ]
18 | }
19 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/patch/invalid-id-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 400,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "scheduler",
12 | "location": "id",
13 | "message": "Exceeds maximum length of 36.",
14 | "reason": "max length",
15 | "type": "field"
16 | }
17 | ]
18 | }
19 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections/post/validation-error-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 400,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "scheduler",
12 | "location": "name",
13 | "message": "Required field cannot be left blank.",
14 | "reason": "required",
15 | "type": "field"
16 | }
17 | ]
18 | }
19 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/patch/save-conflict-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/collections/d4be3c55-039a-4480-a85c-820bbbdd4899",
5 | "headers": {
6 | "Content-Type": [
7 | "application/json"
8 | ]
9 | },
10 | "body": {
11 | "state": "disabled"
12 | }
13 | },
14 | "mock": {
15 | "collection": {
16 | "name": "my-app"
17 | },
18 | "err": "conflict"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/patch/validation-error-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 400,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "scheduler",
12 | "location": "name",
13 | "message": "Required field cannot be left blank.",
14 | "reason": "required",
15 | "type": "field"
16 | }
17 | ]
18 | }
19 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/patch/cancel-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 400,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "scheduler",
12 | "location": "running",
13 | "message": "Unable to cancel the running job.",
14 | "reason": "not implemented",
15 | "type": "field"
16 | }
17 | ]
18 | }
19 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/get/match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs/dc93f741-ccc4-4d15-9023-950392a74309",
4 | "headers": {
5 | "If-None-Match": [
6 | "\"fdqgxvtir8\""
7 | ]
8 | }
9 | },
10 | "mock": {
11 | "job": {
12 | "id": "dc93f741-ccc4-4d15-9023-950392a74309",
13 | "name": "my-task",
14 | "updated": "2019-07-03T10:02:04.436276Z",
15 | "state": "disabled"
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/ui/features/collection/collection-api.js:
--------------------------------------------------------------------------------
1 | import {go} from '../../shared/fetch';
2 |
3 | export function retrieveCollection(id) {
4 | return go('GET', `/collections/${id}`);
5 | }
6 |
7 | export function saveCollection(c) {
8 | if (c.id) {
9 | return go('PATCH', `/collections/${c.id}`, c);
10 | }
11 |
12 | return go('POST', '/collections', c);
13 | }
14 |
15 | export function deleteCollection(id, etag) {
16 | return go('DELETE', `/collections/${id}`, etag);
17 | }
18 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/patch/save-error-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/collections/d4be3c55-039a-4480-a85c-820bbbdd4899",
5 | "headers": {
6 | "Content-Type": [
7 | "application/json"
8 | ]
9 | },
10 | "body": {
11 | "state": "disabled"
12 | }
13 | },
14 | "mock": {
15 | "collection": {
16 | "name": "my-app"
17 | },
18 | "err": "update-collection"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/ui/shared/components/Footer.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const year = new Date().getFullYear();
4 |
5 | const Footer = () => (
6 |
17 | );
18 |
19 | export default Footer;
20 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/patch/validation-error-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 400,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "scheduler",
12 | "location": "name",
13 | "message": "Required to be a minimum of 3 characters in length.",
14 | "reason": "min length",
15 | "type": "field"
16 | }
17 | ]
18 | }
19 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs/post/unprocessable-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 422,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "HTTP",
12 | "location": "Content-Type",
13 | "message": "Expecting 'application/json' content type.",
14 | "reason": "unexpected content type",
15 | "type": "header"
16 | }
17 | ]
18 | }
19 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/patch/ok-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899/status",
5 | "headers": {
6 | "Content-Type": [
7 | "application/json"
8 | ]
9 | },
10 | "body": {
11 | "running": true
12 | }
13 | },
14 | "mock": {
15 | "job": {
16 | "action": {
17 | "type": "UNKNOWN"
18 | }
19 | },
20 | "jobStatus": {}
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/patch/unprocessable-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 422,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "HTTP",
12 | "location": "Content-Type",
13 | "message": "Expecting 'application/json' content type.",
14 | "reason": "unexpected content type",
15 | "type": "header"
16 | }
17 | ]
18 | }
19 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/get/match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/collections/d4be3c55-039a-4480-a85c-820bbbdd4899",
4 | "headers": {
5 | "If-None-Match": [
6 | "\"fdqgxvtir8\""
7 | ]
8 | }
9 | },
10 | "mock": {
11 | "collection": {
12 | "id": "4cc78806-10cb-40ee-b9e5-3c0b5da877b1",
13 | "name": "my-app",
14 | "updated": "2019-07-03T10:02:04.436276Z",
15 | "state": "disabled"
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/get/no-match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/collections/d4be3c55-039a-4480-a85c-820bbbdd4899",
4 | "headers": {
5 | "If-None-Match": [
6 | "\"no match\""
7 | ]
8 | }
9 | },
10 | "mock": {
11 | "collection": {
12 | "id": "4cc78806-10cb-40ee-b9e5-3c0b5da877b1",
13 | "name": "my-app",
14 | "updated": "2019-07-03T10:02:04.436276Z",
15 | "state": "disabled"
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections/post/unprocessable-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 422,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "HTTP",
12 | "location": "Content-Type",
13 | "message": "Expecting 'application/json' content type.",
14 | "reason": "unexpected content type",
15 | "type": "header"
16 | }
17 | ]
18 | }
19 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/patch/unprocessable-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 422,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "HTTP",
12 | "location": "Content-Type",
13 | "message": "Expecting 'application/json' content type.",
14 | "reason": "unexpected content type",
15 | "type": "header"
16 | }
17 | ]
18 | }
19 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/patch/unprocessable-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 422,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "HTTP",
12 | "location": "Content-Type",
13 | "message": "Expecting 'application/json' content type.",
14 | "reason": "unexpected content type",
15 | "type": "header"
16 | }
17 | ]
18 | }
19 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/delete/match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "DELETE",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899",
5 | "headers": {
6 | "If-Match": [
7 | "\"fdqgxvtir8\""
8 | ]
9 | }
10 | },
11 | "mock": {
12 | "job": {
13 | "id": "d4be3c55-039a-4480-a85c-820bbbdd4899",
14 | "name": "my-task",
15 | "updated": "2019-07-03T10:02:04.436276Z",
16 | "state": "disabled"
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/delete/no-match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "DELETE",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899",
5 | "headers": {
6 | "If-Match": [
7 | "\"no match\""
8 | ]
9 | }
10 | },
11 | "mock": {
12 | "job": {
13 | "id": "d4be3c55-039a-4480-a85c-820bbbdd4899",
14 | "name": "my-task",
15 | "updated": "2019-07-03T10:02:04.436276Z",
16 | "state": "disabled"
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/patch/no-match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899",
5 | "headers": {
6 | "If-Match": [
7 | "\"no match\""
8 | ]
9 | }
10 | },
11 | "mock": {
12 | "job": {
13 | "id": "d4be3c55-039a-4480-a85c-820bbbdd4899",
14 | "name": "my-task",
15 | "updated": "2019-07-03T10:02:04.436276Z",
16 | "state": "enabled"
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: release
2 |
3 | on:
4 | push:
5 | tags:
6 | - '*'
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - name: Checkout code
13 | uses: actions/checkout@v5
14 | - name: Create release
15 | id: create_release
16 | uses: actions/create-release@v1
17 | env:
18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
19 | with:
20 | tag_name: ${{ github.ref }}
21 | release_name: Release ${{ github.ref }}
22 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/delete/match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "DELETE",
4 | "path": "/collections/d4be3c55-039a-4480-a85c-820bbbdd4899",
5 | "headers": {
6 | "If-Match": [
7 | "\"fdqgxvtir8\""
8 | ]
9 | }
10 | },
11 | "mock": {
12 | "collection": {
13 | "id": "4cc78806-10cb-40ee-b9e5-3c0b5da877b1",
14 | "name": "my-app",
15 | "updated": "2019-07-03T10:02:04.436276Z",
16 | "state": "disabled"
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/delete/no-match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "DELETE",
4 | "path": "/collections/d4be3c55-039a-4480-a85c-820bbbdd4899",
5 | "headers": {
6 | "If-Match": [
7 | "\"no match\""
8 | ]
9 | }
10 | },
11 | "mock": {
12 | "collection": {
13 | "id": "4cc78806-10cb-40ee-b9e5-3c0b5da877b1",
14 | "name": "my-app",
15 | "updated": "2019-07-03T10:02:04.436276Z",
16 | "state": "disabled"
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/patch/no-match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/collections/d4be3c55-039a-4480-a85c-820bbbdd4899",
5 | "headers": {
6 | "If-Match": [
7 | "\"no match\""
8 | ]
9 | }
10 | },
11 | "mock": {
12 | "collection": {
13 | "id": "4cc78806-10cb-40ee-b9e5-3c0b5da877b1",
14 | "name": "my-app",
15 | "updated": "2019-07-03T10:02:04.436276Z",
16 | "state": "enabled"
17 | }
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/delete/validation-error-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 400,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "scheduler",
12 | "location": "before",
13 | "message": "parsing time \"x\" as \"2006-01-02T15:04:05Z07:00\": cannot parse \"x\" as \"2006\"",
14 | "reason": "format",
15 | "type": "field"
16 | }
17 | ]
18 | }
19 | }
--------------------------------------------------------------------------------
/ui/features/variable/variable-api.js:
--------------------------------------------------------------------------------
1 | import {go} from '../../shared/fetch';
2 |
3 | export {listCollections} from '../collections/collections-api';
4 |
5 | export function retrieveVariable(id) {
6 | return go('GET', `/variables/${id}`);
7 | }
8 |
9 | export function saveVariable(c) {
10 | if (c.id) {
11 | return go('PATCH', `/variables/${c.id}`, c);
12 | }
13 |
14 | return go('POST', '/variables', c);
15 | }
16 |
17 | export function deleteVariable(id, etag) {
18 | return go('DELETE', `/variables/${id}`, etag);
19 | }
20 |
--------------------------------------------------------------------------------
/ui/shared/components/Layout.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, screen} from '@testing-library/react';
3 |
4 | import Layout from './Layout';
5 |
6 | describe('layout component', () => {
7 | it('renders title, errors summary and child', () => {
8 | render(
9 |
10 | Child
11 | ,
12 | );
13 |
14 | expect(screen.getByRole('heading')).toHaveTextContent('My Title');
15 | expect(screen.getByText('Child')).toBeVisible();
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-status/patch/match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899/status",
5 | "headers": {
6 | "Content-Type": [
7 | "application/json"
8 | ],
9 | "If-Match": [
10 | "\"fdqgxvtir8\""
11 | ]
12 | },
13 | "body": {
14 | "running": false
15 | }
16 | },
17 | "mock": {
18 | "jobStatus": {
19 | "updated": "2019-07-03T10:02:04.436276Z"
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/misc/docker/README.md:
--------------------------------------------------------------------------------
1 | # Docker
2 |
3 | Build docker images.
4 |
5 | ```sh
6 | docker build -t akorn/scheduler -f misc/docker/Dockerfile .
7 | ```
8 |
9 | ## Docker componse
10 |
11 | ```sh
12 | cd misc/docker
13 |
14 | docker-compose up -d
15 | docker-compose logs -f --tail=10
16 | docker-compose down
17 | ```
18 |
19 | Update api with a fresh image.
20 |
21 | ```sh
22 | docker-compose stop api
23 | docker-compose up -d api
24 |
25 | docker image prune --all
26 | # or
27 | docker rmi $(docker images | grep "^" | awk "{print $3}")
28 | ```
29 |
--------------------------------------------------------------------------------
/ui/features/history/history-api.js:
--------------------------------------------------------------------------------
1 | import {go} from '../../shared/fetch';
2 |
3 | export {retrieveJob} from '../job/job-api';
4 |
5 | export function retrieveJobStatus(id) {
6 | return go('GET', `/jobs/${id}/status`);
7 | }
8 |
9 | export function patchJobStatus(id, j) {
10 | return go('PATCH', `/jobs/${id}/status`, j);
11 | }
12 |
13 | export function listJobHistory(id) {
14 | return go('GET', `/jobs/${id}/history`);
15 | }
16 |
17 | export function deleteJobHistory(id, etag) {
18 | return go('DELETE', `/jobs/${id}/history`, etag);
19 | }
20 |
--------------------------------------------------------------------------------
/ui/shared/components/FieldError.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, screen} from '@testing-library/react';
3 |
4 | import FieldError from './FieldError';
5 |
6 | describe('field error', () => {
7 | it('handles no error', () => {
8 | const {container} = render();
9 |
10 | expect(container.firstChild).toBeNull();
11 | });
12 |
13 | it('shows error', () => {
14 | render();
15 |
16 | expect(screen.getByText('The error message.')).toBeVisible();
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/domain/testdata/validation/job/request-null.json:
--------------------------------------------------------------------------------
1 | {
2 | "job": {
3 | "id": "8a332e22-5b6d-4173-a61f-bc0863fb60bb",
4 | "name": "my-task",
5 | "collectionId": "f493d75f-3239-4136-ad39-19bff1d409ee",
6 | "schedule": "@every 10s",
7 | "action": {
8 | "type": "HTTP"
9 | }
10 | },
11 | "err": {
12 | "errors": [
13 | {
14 | "domain": "scheduler",
15 | "type": "field",
16 | "location": "request",
17 | "reason": "required",
18 | "message": "Required object cannot be null."
19 | }
20 | ]
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs/post/conflict-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "POST",
4 | "path": "/jobs",
5 | "headers": {
6 | "Content-Type": ["application/json"]
7 | },
8 | "body": {
9 | "name": "my-task",
10 | "collectionId": "f493d75f-3239-4136-ad39-19bff1d409ee",
11 | "schedule": "@every 10s",
12 | "action": {
13 | "type": "HTTP",
14 | "request": {
15 | "uri": "http://localhost:8080/test"
16 | }
17 | }
18 | }
19 | },
20 | "mock": {
21 | "err": "conflict"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections/get/ok-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 200,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ],
7 | "Etag": [
8 | "\"26091qnu116yn\""
9 | ]
10 | },
11 | "body": {
12 | "items": [
13 | {
14 | "id": "d4be3c55-039a-4480-a85c-820bbbdd4899",
15 | "name": "my-app-1",
16 | "state": "enabled"
17 | },
18 | {
19 | "id": "f493d75f-3239-4136-ad39-19bff1d409ee",
20 | "name": "my-app-2",
21 | "state": "disabled"
22 | }
23 | ]
24 | }
25 | }
--------------------------------------------------------------------------------
/ui/shared/components/Header.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {MemoryRouter as Router} from 'react-router-dom';
3 | import {render, screen} from '@testing-library/react';
4 |
5 | import Header from './Header';
6 |
7 | describe('header component', () => {
8 | it('renders links', () => {
9 | render(
10 |
11 |
12 | ,
13 | );
14 |
15 | expect(screen.getByText('Collections')).toBeVisible();
16 | expect(screen.getByText('Variables')).toBeVisible();
17 | expect(screen.getByText('Jobs')).toBeVisible();
18 | });
19 | });
20 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs/get/ok-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs"
4 | },
5 | "mock": {
6 | "jobs": [{
7 | "id": "7304ad9e-8341-46f8-aa17-8cfff403e7e7",
8 | "collectionId": "xebs7HqKQpU",
9 | "name": "my-task-1",
10 | "schedule": "@every 20s",
11 | "state": "disabled"
12 | },
13 | {
14 | "id": "99bcad96-e74e-4084-b4d1-8acc9ba66542",
15 | "collectionId": "kUgrsOoGDuY",
16 | "name": "my-task-2",
17 | "schedule": "@every 1m",
18 | "state": "enabled"
19 | }
20 | ]
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs/post/ok-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "POST",
4 | "path": "/jobs",
5 | "headers": {
6 | "Content-Type": ["application/json"]
7 | },
8 | "body": {
9 | "id": "8a332e22-5b6d-4173-a61f-bc0863fb60bb",
10 | "name": "my-task",
11 | "collectionId": "f493d75f-3239-4136-ad39-19bff1d409ee",
12 | "schedule": "@every 10s",
13 | "action": {
14 | "type": "HTTP",
15 | "request": {
16 | "uri": "http://localhost:8080/test"
17 | }
18 | }
19 | }
20 | },
21 | "mock": {
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/core/history.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/akornatskyy/scheduler/domain"
7 | )
8 |
9 | func (s *Service) ListJobHistory(id string) ([]*domain.JobHistory, error) {
10 | if err := domain.ValidateID(id); err != nil {
11 | return nil, err
12 | }
13 | return s.Repository.ListJobHistory(id)
14 | }
15 |
16 | func (s *Service) DeleteJobHistory(id string, before time.Time) error {
17 | if err := domain.ValidateID(id); err != nil {
18 | return err
19 | }
20 | if before.IsZero() {
21 | before = time.Now().UTC()
22 | }
23 | return s.Repository.DeleteJobHistory(id, before)
24 | }
25 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/get/ok-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs/dc93f741-ccc4-4d15-9023-950392a74309"
4 | },
5 | "mock": {
6 | "job": {
7 | "id": "dc93f741-ccc4-4d15-9023-950392a74309",
8 | "collectionId": "4cc78806-10cb-40ee-b9e5-3c0b5da877b1",
9 | "name": "my-task",
10 | "updated": "2019-07-03T10:02:04.436276Z",
11 | "state": "disabled",
12 | "schedule": "5s",
13 | "action": {
14 | "type": "http",
15 | "request": {
16 | "uri": "http://localhost:8080/test"
17 | }
18 | }
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/domain/testdata/validation/collection/invalid.json:
--------------------------------------------------------------------------------
1 | {
2 | "collection": {
3 | "id": "1234567890123456789012345678901234567",
4 | "name": ""
5 | },
6 | "err": {
7 | "errors": [
8 | {
9 | "domain": "scheduler",
10 | "location": "id",
11 | "message": "Exceeds maximum length of 36.",
12 | "reason": "max length",
13 | "type": "field"
14 | },
15 | {
16 | "domain": "scheduler",
17 | "location": "name",
18 | "message": "Required field cannot be left blank.",
19 | "reason": "required",
20 | "type": "field"
21 | }
22 | ]
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/domain/testdata/validation/job/uri-not-http.json:
--------------------------------------------------------------------------------
1 | {
2 | "job": {
3 | "id": "8a332e22-5b6d-4173-a61f-bc0863fb60bb",
4 | "name": "my-task",
5 | "collectionId": "f493d75f-3239-4136-ad39-19bff1d409ee",
6 | "schedule": "@every 10s",
7 | "action": {
8 | "type": "HTTP",
9 | "request": {
10 | "uri": "ftp://localhost:33"
11 | }
12 | }
13 | },
14 | "err": {
15 | "errors": [
16 | {
17 | "domain": "scheduler",
18 | "type": "field",
19 | "location": "uri",
20 | "reason": "pattern",
21 | "message": "Must begin with http or https."
22 | }
23 | ]
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/collections-item/patch/match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/collections/d4be3c55-039a-4480-a85c-820bbbdd4899",
5 | "headers": {
6 | "Content-Type": [
7 | "application/json"
8 | ],
9 | "If-Match": [
10 | "\"fdqgxvtir8\""
11 | ]
12 | },
13 | "body": {
14 | "state": "disabled"
15 | }
16 | },
17 | "mock": {
18 | "collection": {
19 | "id": "4cc78806-10cb-40ee-b9e5-3c0b5da877b1",
20 | "name": "my-app",
21 | "updated": "2019-07-03T10:02:04.436276Z",
22 | "state": "enabled"
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/ui/shared/components/ErrorSummary.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Alert from 'react-bootstrap/Alert';
3 |
4 | const ErrorSummary = ({errors}) => {
5 | const message = errors['__ERROR__'];
6 | if (!message) {
7 | return null;
8 | }
9 | return (
10 |
11 |
12 |
13 | {message}
14 |
15 |
16 | An unexpected error has occurred. Retry your request later, please.
17 |
18 |
19 | );
20 | };
21 |
22 | export default ErrorSummary;
23 |
--------------------------------------------------------------------------------
/ui/shared/components/Header.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Nav from 'react-bootstrap/Nav';
3 | import Navbar from 'react-bootstrap/Navbar';
4 | import {Link} from 'react-router-dom';
5 |
6 | const Header = () => (
7 |
8 | Scheduler
9 |
20 |
21 | );
22 |
23 | export default Header;
24 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/get/ok-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 200,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ],
7 | "Etag": [
8 | "\"fdqgxvtir8\""
9 | ]
10 | },
11 | "body": {
12 | "action": {
13 | "request": {
14 | "uri": "http://localhost:8080/test"
15 | },
16 | "type": "http"
17 | },
18 | "collectionId": "4cc78806-10cb-40ee-b9e5-3c0b5da877b1",
19 | "id": "dc93f741-ccc4-4d15-9023-950392a74309",
20 | "name": "my-task",
21 | "schedule": "5s",
22 | "state": "disabled",
23 | "updated": "2019-07-03T10:02:04.436276Z"
24 | }
25 | }
--------------------------------------------------------------------------------
/domain/models_etag.go:
--------------------------------------------------------------------------------
1 | package domain
2 |
3 | import (
4 | "strconv"
5 | "time"
6 | )
7 |
8 | func (c *Collection) ETag() string {
9 | return etag(c.Updated)
10 | }
11 |
12 | func (c *Variable) ETag() string {
13 | return etag(c.Updated)
14 | }
15 |
16 | func (j *JobDefinition) ETag() string {
17 | return etag(j.Updated)
18 | }
19 |
20 | func (j *JobStatus) ETag() string {
21 | if j.NextRun == nil {
22 | return etag(j.Updated)
23 | }
24 |
25 | return "\"" + strconv.FormatInt(
26 | j.Updated.UnixMicro()+j.NextRun.UnixMicro(), 36) + "\""
27 | }
28 |
29 | func etag(t time.Time) string {
30 | return "\"" + strconv.FormatInt(t.UnixMicro(), 36) + "\""
31 | }
32 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/get/no-match-etag-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 200,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ],
7 | "Etag": [
8 | "\"fdqgxvtir8\""
9 | ]
10 | },
11 | "body": {
12 | "action": {
13 | "request": {
14 | "uri": "http://localhost:8080/test"
15 | },
16 | "type": "http"
17 | },
18 | "collectionId": "4cc78806-10cb-40ee-b9e5-3c0b5da877b1",
19 | "id": "dc93f741-ccc4-4d15-9023-950392a74309",
20 | "name": "my-task",
21 | "schedule": "5s",
22 | "state": "enabled",
23 | "updated": "2019-07-03T10:02:04.436276Z"
24 | }
25 | }
--------------------------------------------------------------------------------
/ui/shared/components/GroupByList.js:
--------------------------------------------------------------------------------
1 | function groupBy(items, key) {
2 | return items.reduce((result, value) => {
3 | (result[value[key]] = result[value[key]] || []).push(value);
4 | return result;
5 | }, {});
6 | }
7 |
8 | const GroupByList = ({groups, items, groupKey, groupRow, itemRow}) => {
9 | const grouped = groupBy(items, groupKey);
10 | const rows = [];
11 | groups.forEach((c) => {
12 | const itemsByGroup = grouped[c.id];
13 | if (!itemsByGroup) {
14 | return;
15 | }
16 | rows.push(groupRow(c));
17 | rows.push(itemsByGroup.map((i) => itemRow(i)));
18 | });
19 | return rows;
20 | };
21 |
22 | export default GroupByList;
23 |
--------------------------------------------------------------------------------
/domain/testdata/validation/job/invalid-uri.json:
--------------------------------------------------------------------------------
1 | {
2 | "job": {
3 | "id": "8a332e22-5b6d-4173-a61f-bc0863fb60bb",
4 | "name": "my-task",
5 | "collectionId": "f493d75f-3239-4136-ad39-19bff1d409ee",
6 | "schedule": "@every 10s",
7 | "action": {
8 | "type": "HTTP",
9 | "request": {
10 | "uri": "sdasdasda"
11 | }
12 | }
13 | },
14 | "err": {
15 | "errors": [
16 | {
17 | "domain": "scheduler",
18 | "type": "field",
19 | "location": "uri",
20 | "reason": "pattern",
21 | "message": "Unrecognized format: parse sdasdasda: invalid URI for request."
22 | }
23 | ]
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/patch/ok-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899",
5 | "headers": {
6 | "Content-Type": [
7 | "application/json"
8 | ]
9 | },
10 | "body": {
11 | "name": "My Task #1"
12 | }
13 | },
14 | "mock": {
15 | "job": {
16 | "name": "my-task",
17 | "collectionId": "f493d75f-3239-4136-ad39-19bff1d409ee",
18 | "schedule": "@every 10s",
19 | "action": {
20 | "type": "HTTP",
21 | "request": {
22 | "uri": "http://localhost:8080/test"
23 | }
24 | }
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/domain/testdata/validation/job/ok.json:
--------------------------------------------------------------------------------
1 | {
2 | "job": {
3 | "id": "8a332e22-5b6d-4173-a61f-bc0863fb60bb",
4 | "name": "my-task",
5 | "collectionId": "f493d75f-3239-4136-ad39-19bff1d409ee",
6 | "schedule": "@every 10s",
7 | "action": {
8 | "type": "HTTP",
9 | "request": {
10 | "uri": "http://localhost:8080/test",
11 | "headers": [
12 | {
13 | "name": "X-Requested-With",
14 | "value": "XMLHttpRequest"
15 | }
16 | ]
17 | },
18 | "retryPolicy": {
19 | "retryCount": 3,
20 | "retryInterval": "10s",
21 | "deadline": "1m0s"
22 | }
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/patch/save-error-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899",
5 | "headers": {
6 | "Content-Type": [
7 | "application/json"
8 | ]
9 | },
10 | "body": {
11 | }
12 | },
13 | "mock": {
14 | "job": {
15 | "name": "my-task",
16 | "collectionId": "f493d75f-3239-4136-ad39-19bff1d409ee",
17 | "schedule": "@every 10s",
18 | "action": {
19 | "type": "HTTP",
20 | "request": {
21 | "uri": "http://localhost:8080/test"
22 | }
23 | }
24 | },
25 | "err": "update-job"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/patch/validation-error-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899",
5 | "headers": {
6 | "Content-Type": [
7 | "application/json"
8 | ]
9 | },
10 | "body": {
11 | "name": "x"
12 | }
13 | },
14 | "mock": {
15 | "job": {
16 | "name": "my-task",
17 | "collectionId": "f493d75f-3239-4136-ad39-19bff1d409ee",
18 | "schedule": "@every 10s",
19 | "action": {
20 | "type": "HTTP",
21 | "request": {
22 | "uri": "http://localhost:8080/test"
23 | }
24 | }
25 | }
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/get/no-match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs/dc93f741-ccc4-4d15-9023-950392a74309",
4 | "headers": {
5 | "If-None-Match": [
6 | "\"no match\""
7 | ]
8 | }
9 | },
10 | "mock": {
11 | "job": {
12 | "id": "dc93f741-ccc4-4d15-9023-950392a74309",
13 | "collectionId": "4cc78806-10cb-40ee-b9e5-3c0b5da877b1",
14 | "name": "my-task",
15 | "updated": "2019-07-03T10:02:04.436276Z",
16 | "state": "enabled",
17 | "schedule": "5s",
18 | "action": {
19 | "type": "http",
20 | "request": {
21 | "uri": "http://localhost:8080/test"
22 | }
23 | }
24 | }
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs/get/ok-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 200,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ],
7 | "Etag": [
8 | "\"1n9er1hz749r\""
9 | ]
10 | },
11 | "body": {
12 | "items": [
13 | {
14 | "collectionId": "xebs7HqKQpU",
15 | "id": "7304ad9e-8341-46f8-aa17-8cfff403e7e7",
16 | "name": "my-task-1",
17 | "schedule": "@every 20s",
18 | "state": "disabled"
19 | },
20 | {
21 | "collectionId": "kUgrsOoGDuY",
22 | "id": "99bcad96-e74e-4084-b4d1-8acc9ba66542",
23 | "name": "my-task-2",
24 | "schedule": "@every 1m",
25 | "state": "enabled"
26 | }
27 | ]
28 | }
29 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/get/ok-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "path": "/jobs/dc93f741-ccc4-4d15-9023-950392a74309/history"
4 | },
5 | "mock": {
6 | "jobStatus": {
7 | "updated": "2019-08-06T10:48:00.358915Z"
8 | },
9 | "jobHistory": [{
10 | "action": "http",
11 | "started": "2019-08-06T10:47:45.34846Z",
12 | "finished": "2019-08-06T10:48:00.358915Z",
13 | "status": "failed",
14 | "retryCount": 3,
15 | "message": "404 Not Found"
16 | },
17 | {
18 | "action": "http",
19 | "started": "2019-08-06T10:47:23.43524Z",
20 | "finished": "2019-08-06T10:47:38.445094Z",
21 | "status": "completed"
22 | }
23 | ]
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item-history/get/ok-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 200,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ],
7 | "Etag": [
8 | "\"ferzpztgxv\""
9 | ]
10 | },
11 | "body": {
12 | "items": [
13 | {
14 | "action": "http",
15 | "finished": "2019-08-06T10:48:00.358915Z",
16 | "message": "404 Not Found",
17 | "retryCount": 3,
18 | "started": "2019-08-06T10:47:45.34846Z",
19 | "status": "failed"
20 | },
21 | {
22 | "action": "http",
23 | "finished": "2019-08-06T10:47:38.445094Z",
24 | "started": "2019-08-06T10:47:23.43524Z",
25 | "status": "completed"
26 | }
27 | ]
28 | }
29 | }
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs-item/patch/match-etag-input.json:
--------------------------------------------------------------------------------
1 | {
2 | "req": {
3 | "method": "PATCH",
4 | "path": "/jobs/d4be3c55-039a-4480-a85c-820bbbdd4899",
5 | "headers": {
6 | "Content-Type": [
7 | "application/json"
8 | ],
9 | "If-Match": [
10 | "\"fdqgxvtir8\""
11 | ]
12 | },
13 | "body": {
14 | "state": "disabled"
15 | }
16 | },
17 | "mock": {
18 | "job": {
19 | "name": "my-task",
20 | "updated": "2019-07-03T10:02:04.436276Z",
21 | "collectionId": "f493d75f-3239-4136-ad39-19bff1d409ee",
22 | "schedule": "@every 10s",
23 | "action": {
24 | "type": "HTTP",
25 | "request": {
26 | "uri": "http://localhost:8080/test"
27 | }
28 | }
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/misc/docker/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | db:
4 | container_name: db
5 | restart: always
6 | image: postgres:12-alpine
7 | ports:
8 | - 5432:5432
9 | volumes:
10 | - db:/var/lib/postgresql/data
11 | environment:
12 | - POSTGRES_DB=postgres
13 | #command: postgres -c 'log_statement=all'
14 | networks:
15 | - db
16 | api:
17 | container_name: api
18 | depends_on:
19 | - db
20 | restart: always
21 | stop_signal: SIGINT
22 | image: akorn/scheduler
23 | ports:
24 | - 8080:8080
25 | environment:
26 | - DSN=postgres://postgres:@db:5432/postgres?sslmode=disable
27 | networks:
28 | - api
29 | - db
30 | volumes:
31 | db:
32 | driver: local
33 | networks:
34 | db:
35 | api:
36 |
--------------------------------------------------------------------------------
/ui/features/collections/collections-api.test.js:
--------------------------------------------------------------------------------
1 | import * as api from './collections-api';
2 |
3 | describe('collections api', () => {
4 | afterEach(() => {
5 | global.fetch.mockClear();
6 | delete global.fetch;
7 | });
8 |
9 | it('list', async () => {
10 | global.fetch = jest.fn().mockResolvedValue({
11 | status: 200,
12 | headers: {get: () => '"2hhaswzbz72p8"'},
13 | json: () => Promise.resolve({items: []}),
14 | });
15 |
16 | const d = await api.listCollections();
17 |
18 | expect(d).toEqual({
19 | etag: '"2hhaswzbz72p8"',
20 | items: [],
21 | });
22 | expect(global.fetch).toHaveBeenCalledWith('/collections', {
23 | method: 'GET',
24 | headers: {
25 | 'X-Requested-With': 'XMLHttpRequest',
26 | },
27 | });
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/ui/shared/components/ErrorSummary.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {render, screen} from '@testing-library/react';
3 |
4 | import ErrorSummary from './ErrorSummary';
5 |
6 | describe('errors summary', () => {
7 | it('handles no errors', () => {
8 | const {container} = render();
9 |
10 | expect(container.firstChild).toBeNull();
11 | });
12 |
13 | it('ignores field error', () => {
14 | const errors = {name: 'some error'};
15 | const {container} = render();
16 |
17 | expect(container.firstChild).toBeNull();
18 | });
19 |
20 | it('shows error', () => {
21 | const errors = {__ERROR__: 'The error message.'};
22 | render();
23 |
24 | expect(screen.getByText(errors.__ERROR__)).toBeVisible();
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/ui/index.test.js:
--------------------------------------------------------------------------------
1 | import {act, render, screen} from '@testing-library/react';
2 | import React from 'react';
3 | import {MemoryRouter as Router} from 'react-router-dom';
4 |
5 | import {App} from './index';
6 |
7 | describe('index', () => {
8 | it.each([
9 | ['/', 'Collections'],
10 | ['/collections', 'Collections'],
11 | ['/jobs', 'Jobs'],
12 | ['/collections/add', 'Collection'],
13 | ['/collections/65ada2f9', 'Collection'],
14 | ['/jobs/add', 'Job'],
15 | ['/jobs/7ce1f17e', 'Job'],
16 | ['/jobs/7ce1f17e/history', 'Job History'],
17 | ])('routes %s to %s', async (path, component) => {
18 | await act(async () => {
19 | render(
20 |
21 |
22 | ,
23 | );
24 | });
25 |
26 | expect(screen.getByRole('heading')).toHaveTextContent(component);
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/ui/features/collections/collections.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Link} from 'react-router-dom';
3 | import {Button} from 'react-bootstrap';
4 |
5 | import {Layout} from '../../shared/components';
6 | import * as api from './collections-api';
7 | import {CollectionList} from './collections-components';
8 |
9 | export default class Collections extends React.Component {
10 | state = {items: [], errors: {}};
11 |
12 | componentDidMount() {
13 | api
14 | .listCollections()
15 | .then(({items}) => this.setState({items}))
16 | .catch((errors) => this.setState({errors}));
17 | }
18 |
19 | render() {
20 | const {items, errors} = this.state;
21 | return (
22 |
23 |
24 |
27 |
28 | );
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/ui/features/collections/collections-components.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Link} from 'react-router-dom';
3 | import {Table} from 'react-bootstrap';
4 |
5 | export const CollectionList = ({items}) => (
6 |
7 |
8 |
9 | | Name |
10 | State |
11 |
12 |
13 |
14 | {items.map((i) => (
15 |
16 | |
17 | {i.name}
18 |
19 | variables
20 |
21 |
22 | jobs
23 |
24 | |
25 | {i.state} |
26 |
27 | ))}
28 |
29 |
30 | );
31 |
--------------------------------------------------------------------------------
/domain/models_etag_test.go:
--------------------------------------------------------------------------------
1 | package domain
2 |
3 | import (
4 | "testing"
5 | "time"
6 | )
7 |
8 | func TestETag(t *testing.T) {
9 | var testcases = []struct {
10 | sample string
11 | expected string
12 | }{
13 | {`2003-01-19T11:45:00Z`, `"a9pcvqbi80"`},
14 | {`2019-10-23T06:55:05Z`, `"fh5t8wxgzk"`},
15 | }
16 | for _, tt := range testcases {
17 | updated, _ := time.Parse(time.RFC3339, tt.sample)
18 | var c Collection
19 | c.Updated = updated
20 | if c.ETag() != tt.expected {
21 | t.Errorf("Collection.ETag() got: %s, expected: %s",
22 | c.ETag(), tt.expected)
23 | }
24 |
25 | var d JobDefinition
26 | d.Updated = updated
27 | if d.ETag() != tt.expected {
28 | t.Errorf("JobDefinition.ETag() got: %s, expected: %s",
29 | d.ETag(), tt.expected)
30 | }
31 |
32 | var s JobStatus
33 | s.Updated = updated
34 | if s.ETag() != tt.expected {
35 | t.Errorf("JobStatus.ETag() got: %s, expected: %s",
36 | s.ETag(), tt.expected)
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/misc/k8s/db-deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 | metadata:
4 | name: scheduler-db
5 | spec:
6 | replicas: 1
7 | selector:
8 | matchLabels:
9 | app: scheduler-db
10 | template:
11 | metadata:
12 | labels:
13 | app: scheduler-db
14 | spec:
15 | containers:
16 | - name: scheduler-db
17 | image: postgres:alpine
18 | imagePullPolicy: IfNotPresent
19 | ports:
20 | - containerPort: 5432
21 | args:
22 | - postgres
23 | - -c
24 | - log_statement=all
25 | resources:
26 | requests:
27 | cpu: 100m
28 | memory: 32Mi
29 | limits:
30 | cpu: 250m
31 | memory: 64Mi
32 | volumeMounts:
33 | - name: scheduler-db
34 | mountPath: /var/lib/postgresql/data
35 | volumes:
36 | - name: scheduler-db
37 | persistentVolumeClaim:
38 | claimName: scheduler-db
39 |
40 |
--------------------------------------------------------------------------------
/ui/features/collections/collections-components.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {MemoryRouter as Router} from 'react-router-dom';
3 | import {render, screen} from '@testing-library/react';
4 |
5 | import {CollectionList} from './collections-components';
6 |
7 | describe('collections list component ', () => {
8 | it('renders empty list', () => {
9 | const items = [];
10 |
11 | const {container} = render();
12 |
13 | expect(container.querySelector('tbody')).toBeEmptyDOMElement();
14 | });
15 |
16 | it('renders items', () => {
17 | const items = [
18 | {
19 | id: '65ada2f9',
20 | name: 'My App #1',
21 | state: 'enabled',
22 | },
23 | ];
24 |
25 | render(
26 |
27 |
28 | ,
29 | );
30 |
31 | expect(screen.getByText('My App #1')).toBeVisible();
32 | expect(screen.getByText('enabled')).toBeVisible();
33 | });
34 | });
35 |
--------------------------------------------------------------------------------
/domain/testdata/validation/job/invalid.json:
--------------------------------------------------------------------------------
1 | {
2 | "job": {
3 | "schedule": "123456789"
4 | },
5 | "err": {
6 | "errors": [
7 | {
8 | "domain": "scheduler",
9 | "type": "field",
10 | "location": "name",
11 | "reason": "required",
12 | "message": "Required field cannot be left blank."
13 | },
14 | {
15 | "domain": "scheduler",
16 | "type": "field",
17 | "location": "collectionId",
18 | "reason": "required",
19 | "message": "Required field cannot be left blank."
20 | },
21 | {
22 | "domain": "scheduler",
23 | "type": "field",
24 | "location": "schedule",
25 | "reason": "pattern",
26 | "message": "Unrecognized format: expected exactly 5 fields, found 1: [123456789]."
27 | },
28 | {
29 | "domain": "scheduler",
30 | "type": "field",
31 | "location": "action",
32 | "reason": "required",
33 | "message": "Required object cannot be null."
34 | }
35 | ]
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/misc/docker/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:alpine as g
2 |
3 | ADD . /src
4 | WORKDIR /src
5 |
6 | RUN set -ex \
7 | \
8 | && apk add --no-cache git upx \
9 | \
10 | && go get -v -d \
11 | && CGO_ENABLED=0 go build -ldflags '-s -w -extldflags "-static"' \
12 | && upx -q /src/scheduler
13 |
14 | RUN apk --no-cache add ca-certificates
15 | RUN update-ca-certificates
16 |
17 | FROM node:alpine as n
18 |
19 | ADD . /src
20 | WORKDIR /src
21 |
22 | RUN set -ex \
23 | \
24 | && npm --no-update-notifier --no-fund --no-audit --omit=optional ci \
25 | && NODE_OPTIONS="--no-experimental-webstorage" \
26 | npm --no-update-notifier run build -- --mode=production
27 |
28 | FROM scratch
29 |
30 | LABEL maintainer="Andriy Kornatskyy "
31 |
32 | COPY --from=g /src/scheduler /
33 | COPY --from=g /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
34 | COPY --from=n /src/static /static
35 | COPY --from=n /etc/passwd /etc/passwd
36 |
37 | USER nobody
38 | STOPSIGNAL SIGINT
39 | EXPOSE 8080
40 |
41 | CMD ["/scheduler"]
42 |
--------------------------------------------------------------------------------
/ui/features/job/job-api.js:
--------------------------------------------------------------------------------
1 | import update from 'immutability-helper';
2 |
3 | import {go} from '../../shared/fetch';
4 | export {listCollections} from '../collections/collections-api';
5 |
6 | const defaultRequest = {
7 | method: 'GET',
8 | headers: [],
9 | body: '',
10 | };
11 |
12 | const defaultRetryPolicy = {
13 | retryCount: 3,
14 | retryInterval: '10s',
15 | deadline: '1m',
16 | };
17 |
18 | export function retrieveJob(id) {
19 | return go('GET', `/jobs/${id}`).then((data) => {
20 | const a = data.action;
21 | a.request = update(defaultRequest, {$merge: a.request});
22 | if (a.retryPolicy) {
23 | a.retryPolicy = update(defaultRetryPolicy, {$merge: a.retryPolicy});
24 | } else {
25 | a.retryPolicy = {...defaultRetryPolicy};
26 | }
27 |
28 | return data;
29 | });
30 | }
31 |
32 | export function saveJob(j) {
33 | if (j.id) {
34 | return go('PATCH', `/jobs/${j.id}`, j);
35 | }
36 |
37 | return go('POST', '/jobs', j);
38 | }
39 |
40 | export function deleteJob(id, etag) {
41 | return go('DELETE', `/jobs/${id}`, etag);
42 | }
43 |
--------------------------------------------------------------------------------
/core/collections.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "github.com/akornatskyy/scheduler/domain"
5 | )
6 |
7 | func (s *Service) ListCollections() ([]*domain.CollectionItem, error) {
8 | return s.Repository.ListCollections()
9 | }
10 |
11 | func (s *Service) CreateCollection(c *domain.Collection) error {
12 | if err := domain.ValidateCollection(c); err != nil {
13 | return err
14 | }
15 | if c.ID == "" {
16 | c.ID = domain.NewID()
17 | }
18 | return s.Repository.CreateCollection(c)
19 | }
20 |
21 | func (s *Service) RetrieveCollection(id string) (*domain.Collection, error) {
22 | if err := domain.ValidateID(id); err != nil {
23 | return nil, err
24 | }
25 | return s.Repository.RetrieveCollection(id)
26 | }
27 |
28 | func (s *Service) UpdateCollection(c *domain.Collection) error {
29 | if err := domain.ValidateCollection(c); err != nil {
30 | return err
31 | }
32 | return s.Repository.UpdateCollection(c)
33 | }
34 |
35 | func (s *Service) DeleteCollection(id string) error {
36 | if err := domain.ValidateID(id); err != nil {
37 | return err
38 | }
39 | return s.Repository.DeleteCollection(id)
40 | }
41 |
--------------------------------------------------------------------------------
/core/scheduler.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "log"
5 |
6 | "github.com/akornatskyy/scheduler/domain"
7 | )
8 |
9 | func (s *Service) scheduleJobs() error {
10 | collections, err := s.Repository.ListCollections()
11 | if err != nil {
12 | return err
13 | }
14 | scheduled := s.Scheduler.ListIDs()
15 | added := make(map[string]bool)
16 | n := 0
17 | for _, c := range collections {
18 | jobs, err := s.Repository.ListJobs(c.ID, []string{})
19 | if err != nil {
20 | return err
21 | }
22 | for _, j := range jobs {
23 | if c.State != domain.CollectionStateEnabled ||
24 | j.State != domain.JobStateEnabled {
25 | s.Scheduler.Remove(j.ID)
26 | continue
27 | }
28 | j, err := s.Repository.RetrieveJob(j.ID)
29 | if err != nil {
30 | return err
31 | }
32 | if err = s.Scheduler.Add(j); err != nil {
33 | return err
34 | }
35 | added[j.ID] = true
36 | n++
37 | }
38 | }
39 | // remove orphaned jobs if any
40 | for _, id := range scheduled {
41 | if !added[id] {
42 | s.Scheduler.Remove(id)
43 | }
44 | }
45 | log.Printf("scheduled %d jobs", n)
46 | return nil
47 | }
48 |
--------------------------------------------------------------------------------
/infrastructure/http/testdata/jobs/post/validation-error-golden.json:
--------------------------------------------------------------------------------
1 | {
2 | "code": 400,
3 | "headers": {
4 | "Content-Type": [
5 | "application/json; charset=UTF-8"
6 | ]
7 | },
8 | "body": {
9 | "errors": [
10 | {
11 | "domain": "scheduler",
12 | "location": "name",
13 | "message": "Required field cannot be left blank.",
14 | "reason": "required",
15 | "type": "field"
16 | },
17 | {
18 | "domain": "scheduler",
19 | "location": "collectionId",
20 | "message": "Required field cannot be left blank.",
21 | "reason": "required",
22 | "type": "field"
23 | },
24 | {
25 | "domain": "scheduler",
26 | "location": "schedule",
27 | "message": "Required field cannot be left blank.",
28 | "reason": "required",
29 | "type": "field"
30 | },
31 | {
32 | "domain": "scheduler",
33 | "location": "action",
34 | "message": "Required object cannot be null.",
35 | "reason": "required",
36 | "type": "field"
37 | }
38 | ]
39 | }
40 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Andriy Kornatskyy
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 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "log"
5 | "os"
6 | "os/signal"
7 | "syscall"
8 |
9 | "github.com/akornatskyy/scheduler/core"
10 | "github.com/akornatskyy/scheduler/domain"
11 | "github.com/akornatskyy/scheduler/infrastructure/cron"
12 | "github.com/akornatskyy/scheduler/infrastructure/http"
13 | "github.com/akornatskyy/scheduler/infrastructure/postgres"
14 | )
15 |
16 | func main() {
17 | log.Printf("starting scheduler version %s...", domain.Version)
18 |
19 | dsn := os.Getenv("DSN")
20 | service := &core.Service{
21 | Repository: postgres.NewRepository(dsn),
22 | Scheduler: cron.New(),
23 | Runners: map[string]domain.Runner{
24 | "HTTP": http.NewRunner(),
25 | },
26 | }
27 |
28 | subscriber := postgres.NewSubscriber(dsn)
29 | subscriber.SetCallback(service.OnUpdateEvent)
30 |
31 | server := &http.Server{
32 | Service: service,
33 | }
34 |
35 | service.Start()
36 | subscriber.Start()
37 | server.Start()
38 |
39 | c := make(chan os.Signal, 1)
40 | signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
41 | <-c
42 |
43 | log.Println("shutting down...")
44 |
45 | server.Stop()
46 | subscriber.Stop()
47 | service.Stop()
48 |
49 | log.Println("done")
50 | }
51 |
--------------------------------------------------------------------------------
/infrastructure/postgres/history.go:
--------------------------------------------------------------------------------
1 | package postgres
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/akornatskyy/scheduler/domain"
7 | )
8 |
9 | func (r *sqlRepository) ListJobHistory(id string) ([]*domain.JobHistory, error) {
10 | items := make([]*domain.JobHistory, 0, 100)
11 | rows, err := r.selectJobHistory.Query(id)
12 | if err != nil {
13 | return nil, err
14 | }
15 | defer rows.Close()
16 | for rows.Next() {
17 | j := &domain.JobHistory{}
18 | err := rows.Scan(
19 | &j.Action, &j.Started, &j.Finished, &j.Status,
20 | &j.RetryCount, &j.Message,
21 | )
22 | if err != nil {
23 | return nil, err
24 | }
25 | items = append(items, j)
26 | }
27 | if err := rows.Err(); err != nil {
28 | return nil, err
29 | }
30 | return items, nil
31 | }
32 |
33 | func (r *sqlRepository) AddJobHistory(jh *domain.JobHistory) error {
34 | return checkExec(r.insertJobHistory.Exec(
35 | domain.NewID(), jh.JobID, jh.Action, jh.Started, jh.Finished,
36 | jh.Status, jh.RetryCount, jh.Message,
37 | ))
38 | }
39 |
40 | func (r *sqlRepository) DeleteJobHistory(id string, before time.Time) error {
41 | _, err := r.deleteJobHistory.Exec(id, before)
42 | if err != nil {
43 | return err
44 | }
45 | return nil
46 | }
47 |
--------------------------------------------------------------------------------
/core/service.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "context"
5 | "log"
6 | "os"
7 | "strings"
8 |
9 | "github.com/akornatskyy/scheduler/domain"
10 | )
11 |
12 | type Service struct {
13 | Repository domain.Repository
14 | Scheduler domain.Scheduler
15 | Runners map[string]domain.Runner
16 | ctx context.Context
17 | cancel context.CancelFunc
18 | variables map[string]string
19 | }
20 |
21 | func (s *Service) Start() {
22 | ctx, cancel := context.WithCancel(context.Background())
23 | s.ctx = ctx
24 | s.cancel = cancel
25 | s.variables = mapEnviron()
26 |
27 | s.resetLeftOverJobs()
28 | s.Scheduler.SetRunner(s.OnRunJob)
29 | s.Scheduler.Start()
30 | }
31 |
32 | func (s *Service) Stop() {
33 | log.Println("cancelling all running jobs...")
34 | s.cancel()
35 | s.Scheduler.Stop()
36 | s.Repository.Close()
37 | }
38 |
39 | func (s *Service) Health() error {
40 | return s.Repository.Ping()
41 | }
42 |
43 | func mapEnviron() map[string]string {
44 | variables := make(map[string]string)
45 | for _, e := range os.Environ() {
46 | pair := strings.SplitN(e, "=", 2)
47 | if !strings.HasPrefix(pair[0], "SCHEDULER_") {
48 | continue
49 | }
50 | variables[pair[0][10:]] = pair[1]
51 | }
52 | return variables
53 | }
54 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "strict": [
4 | 1,
5 | "global"
6 | ],
7 | "linebreak-style": 0,
8 | "comma-dangle": 0,
9 | "require-jsdoc": 0,
10 | "no-console": [
11 | "error",
12 | {
13 | "allow": [
14 | "warn",
15 | "error"
16 | ]
17 | }
18 | ],
19 | "no-invalid-this": 0,
20 | "prefer-promise-reject-errors": 0,
21 | "react/prop-types": 0
22 | },
23 | "env": {
24 | "browser": true,
25 | "node": true
26 | },
27 | "extends": [
28 | "eslint:recommended",
29 | "google",
30 | "plugin:react/recommended",
31 | "prettier"
32 | ],
33 | "parser": "@babel/eslint-parser",
34 | "parserOptions": {
35 | "ecmaVersion": 6,
36 | "sourceType": "module",
37 | "ecmaFeatures": {
38 | "jsx": true
39 | }
40 | },
41 | "globals": {
42 | "Promise": true,
43 | "describe": true,
44 | "it": true,
45 | "expect": true,
46 | "beforeEach": true,
47 | "afterEach": true,
48 | "jest": true,
49 | "resolvePromise": true,
50 | "rejectPromise": true
51 | },
52 | "plugins": [
53 | "react"
54 | ],
55 | "settings": {
56 | "react": {
57 | "version": "18.2"
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/misc/k8s/app-deployment.yaml:
--------------------------------------------------------------------------------
1 | apiVersion: apps/v1
2 | kind: Deployment
3 | metadata:
4 | name: scheduler
5 | spec:
6 | replicas: 2
7 | selector:
8 | matchLabels:
9 | app: scheduler
10 | template:
11 | metadata:
12 | labels:
13 | app: scheduler
14 | spec:
15 | containers:
16 | - name: scheduler
17 | image: akorn/scheduler
18 | imagePullPolicy: IfNotPresent
19 | ports:
20 | - containerPort: 8080
21 | env:
22 | - name: PGHOST
23 | value: scheduler-db
24 | - name: PGDATABASE
25 | value: postgres
26 | - name: PGUSER
27 | value: postgres
28 | - name: PGPASSWORD
29 | value: ''
30 | - name: PGSSLMODE
31 | value: disable
32 | resources:
33 | requests:
34 | cpu: 100m
35 | memory: 16Mi
36 | limits:
37 | cpu: 200m
38 | memory: 24Mi
39 | readinessProbe:
40 | initialDelaySeconds: 10
41 | httpGet:
42 | port: 8080
43 | path: /health
44 | livenessProbe:
45 | initialDelaySeconds: 10
46 | httpGet:
47 | port: 8080
48 | path: /health
49 |
--------------------------------------------------------------------------------
/domain/repository.go:
--------------------------------------------------------------------------------
1 | package domain
2 |
3 | import (
4 | "time"
5 | )
6 |
7 | type Repository interface {
8 | Ping() error
9 | Close() error
10 |
11 | ListCollections() ([]*CollectionItem, error)
12 | CreateCollection(c *Collection) error
13 | RetrieveCollection(id string) (*Collection, error)
14 | UpdateCollection(c *Collection) error
15 | DeleteCollection(id string) error
16 |
17 | ListVariables(collectionID string) ([]*VariableItem, error)
18 | MapVariables(collectionID string) (map[string]string, error)
19 | CreateVariable(v *Variable) error
20 | RetrieveVariable(id string) (*Variable, error)
21 | UpdateVariable(v *Variable) error
22 | DeleteVariable(id string) error
23 |
24 | ListJobs(collectionID string, fields []string) ([]*JobItem, error)
25 | CreateJob(j *JobDefinition) error
26 | RetrieveJob(id string) (*JobDefinition, error)
27 | UpdateJob(j *JobDefinition) error
28 | DeleteJob(id string) error
29 |
30 | RetrieveJobStatus(id string) (*JobStatus, error)
31 | ListLeftOverJobs() ([]string, error)
32 | ResetJobStatus(id string) error
33 |
34 | ListJobHistory(id string) ([]*JobHistory, error)
35 | DeleteJobHistory(id string, before time.Time) error
36 |
37 | AcquireJob(id string, deadline time.Duration) error
38 | AddJobHistory(*JobHistory) error
39 | }
40 |
--------------------------------------------------------------------------------
/ui/features/variables/variables.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Link} from 'react-router-dom';
3 | import {Button} from 'react-bootstrap';
4 |
5 | import {Layout} from '../../shared/components';
6 | import * as api from './variables-api';
7 | import {VariableList} from './variables-components';
8 |
9 | export default class Variables extends React.Component {
10 | state = {collections: [], variables: [], errors: {}};
11 |
12 | componentDidMount() {
13 | const collectionId = new URLSearchParams(this.props.location.search).get(
14 | 'collectionId',
15 | );
16 | api
17 | .listCollections()
18 | .then(({items}) => this.setState({collections: items}))
19 | .catch((errors) => this.setState({errors}));
20 | api
21 | .listVariables(collectionId)
22 | .then(({items}) => this.setState({variables: items}))
23 | .catch((errors) => this.setState({errors}));
24 | }
25 |
26 | render() {
27 | const {variables, collections, errors} = this.state;
28 | return (
29 |
30 |
31 |
34 |
35 | );
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/infrastructure/http/etag.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "bytes"
5 | "hash"
6 | "hash/crc64"
7 | "net/http"
8 | "strconv"
9 | )
10 |
11 | type middleware struct {
12 | hash hash.Hash64
13 | w http.ResponseWriter
14 | buf *bytes.Buffer
15 | status int
16 | }
17 |
18 | func ETagHandler(next http.Handler) http.HandlerFunc {
19 | return func(w http.ResponseWriter, r *http.Request) {
20 | m := middleware{
21 | hash: crc64.New(crc64.MakeTable(crc64.ECMA)),
22 | w: w,
23 | buf: bytes.NewBuffer(nil),
24 | }
25 | next.ServeHTTP(&m, r)
26 | if m.buf.Len() == 0 {
27 | w.WriteHeader(m.status)
28 | return
29 | }
30 | etag := "\"" + strconv.FormatUint(m.hash.Sum64(), 36) + "\""
31 | if etag == r.Header.Get("If-None-Match") {
32 | w.WriteHeader(http.StatusNotModified)
33 | return
34 | }
35 | w.Header().Add("ETag", etag)
36 | w.WriteHeader(m.status)
37 | if _, err := w.Write(m.buf.Bytes()); err != nil {
38 | panic(err)
39 | }
40 | }
41 | }
42 |
43 | func (m middleware) Header() http.Header {
44 | return m.w.Header()
45 | }
46 |
47 | func (m *middleware) WriteHeader(status int) {
48 | m.status = status
49 | }
50 |
51 | func (m *middleware) Write(b []byte) (int, error) {
52 | l, _ := m.buf.Write(b)
53 | _, err := m.hash.Write(b)
54 | return l, err
55 | }
56 |
--------------------------------------------------------------------------------
/ui/features/variables/variables-components.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Link} from 'react-router-dom';
3 | import {Table} from 'react-bootstrap';
4 |
5 | import {GroupByList} from '../../shared/components';
6 |
7 | export const VariableList = ({collections, variables}) => (
8 |
9 |
10 |
11 | | Name |
12 | Updated |
13 |
14 |
15 |
16 | }
21 | itemRow={(i) => }
22 | />
23 |
24 |
25 | );
26 |
27 | export const GroupRow = ({collection}) => (
28 |
29 | |
30 | {collection.name}
31 |
32 | jobs
33 |
34 | |
35 |
36 | );
37 |
38 | export const ItemRow = ({variable}) => (
39 |
40 | |
41 | {variable.name}
42 | |
43 | {new Date(variable.updated).toLocaleString()} |
44 |
45 | );
46 |
--------------------------------------------------------------------------------
/ui/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Scheduler
6 |
7 |
9 |
13 |
17 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/shared/rule/validation.go:
--------------------------------------------------------------------------------
1 | package rule
2 |
3 | import (
4 | "github.com/akornatskyy/goext/validator"
5 | )
6 |
7 | const (
8 | idPattern = "^[A-Za-z0-9][A-Za-z0-9_\\-]*$"
9 | idMessage = "Required to match URL safe characters only."
10 | )
11 |
12 | var (
13 | ID = validator.String("id").
14 | Min(3).Max(36).
15 | Pattern(idPattern, idMessage).Build()
16 | Name = validator.String("name").
17 | Required().Min(3).Max(64).Build()
18 | CollectionID = validator.String("collectionId").
19 | Required().Min(3).Max(36).
20 | Pattern(idPattern, idMessage).Build()
21 | Schedule = validator.String("schedule").
22 | Required().Min(6).Max(64).Build()
23 | ActionType = validator.String("type").
24 | Required().Exactly(4).Pattern("^HTTP$", "Must be 'HTTP' only.").Build()
25 | Method = validator.String("method").
26 | Min(3).Max(6).
27 | Pattern("^(HEAD|GET|POST|PUT|PATCH|DELETE)$", "Must be a valid HTTP verb.").
28 | Build()
29 | URI = validator.String("uri").
30 | Required().Min(8).Max(256).Build()
31 | HeaderName = validator.String("header.name").
32 | Required().Min(5).Max(32).Build()
33 | HeaderValue = validator.String("header.value").
34 | Required().Max(256).Build()
35 | Body = validator.String("body").
36 | Max(1024).Build()
37 | VariableValue = validator.String("value").
38 | Max(1024).Build()
39 | RetryCount = validator.Number("retryCount").
40 | Max(10).Build()
41 | )
42 |
--------------------------------------------------------------------------------
/.github/workflows/image.yml:
--------------------------------------------------------------------------------
1 | name: image
2 |
3 | on:
4 | schedule:
5 | - cron: "0 4 * * 0" # At 04:00 on Sunday.
6 | workflow_dispatch:
7 | inputs:
8 | tag:
9 | type: string
10 | description: Docker tag
11 | required: false
12 |
13 | jobs:
14 | build:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - name: checkout code
18 | uses: actions/checkout@v5
19 | - name: login to docker hub
20 | uses: docker/login-action@v3
21 | with:
22 | username: akorn
23 | password: ${{ secrets.DOCKER_PASSWORD }}
24 | - name: install buildx
25 | uses: docker/setup-buildx-action@v3
26 | - id: vars
27 | if: ${{ github.ref_type == 'tag' }}
28 | run: |
29 | echo ::set-output name=tag::$(echo ${{ github.ref_name }} | sed s/v//)
30 | - name: build and push
31 | uses: docker/build-push-action@v6
32 | with:
33 | context: .
34 | file: misc/docker/Dockerfile
35 | tags: akorn/scheduler${{
36 | github.event.inputs.tag
37 | && format(',akorn/scheduler:{0}', github.event.inputs.tag)
38 | || ''
39 | }}${{
40 | steps.vars.outputs.tag
41 | && format(',akorn/scheduler:{0}', steps.vars.outputs.tag)
42 | || ''
43 | }}
44 | push: true
45 |
--------------------------------------------------------------------------------
/ui/features/jobs/jobs.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Link} from 'react-router-dom';
3 | import {Button} from 'react-bootstrap';
4 |
5 | import {Layout} from '../../shared/components';
6 | import * as api from './jobs-api';
7 | import {JobList} from './jobs-components';
8 |
9 | export default class Jobs extends React.Component {
10 | state = {collections: [], jobs: [], errors: {}};
11 |
12 | componentDidMount() {
13 | this.refresh();
14 | this.timer = setInterval(() => this.refresh(), 10000);
15 | }
16 |
17 | componentWillUnmount() {
18 | clearInterval(this.timer);
19 | }
20 |
21 | refresh() {
22 | const collectionId = new URLSearchParams(this.props.location.search).get(
23 | 'collectionId',
24 | );
25 | api
26 | .listCollections()
27 | .then(({items}) => this.setState({collections: items}))
28 | .catch((errors) => this.setState({errors}));
29 | api
30 | .listJobs(collectionId)
31 | .then(({items}) => this.setState({jobs: items}))
32 | .catch((errors) => this.setState({errors}));
33 | }
34 |
35 | render() {
36 | const {collections, jobs, errors} = this.state;
37 | return (
38 |
39 |
40 |
43 |
44 | );
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/ui/features/variables/variables-api.test.js:
--------------------------------------------------------------------------------
1 | import * as api from './variables-api';
2 |
3 | describe('variables api', () => {
4 | afterEach(() => {
5 | global.fetch.mockClear();
6 | delete global.fetch;
7 | });
8 |
9 | it('list', async () => {
10 | global.fetch = jest.fn().mockResolvedValue({
11 | status: 200,
12 | headers: {get: () => '"2hhaswzbz72p8"'},
13 | json: () => Promise.resolve({items: []}),
14 | });
15 |
16 | const d = await api.listVariables();
17 |
18 | expect(d).toEqual({
19 | etag: '"2hhaswzbz72p8"',
20 | items: [],
21 | });
22 | expect(global.fetch).toHaveBeenCalledWith('/variables', {
23 | method: 'GET',
24 | headers: {
25 | 'X-Requested-With': 'XMLHttpRequest',
26 | },
27 | });
28 | });
29 |
30 | it('list by collection id', async () => {
31 | global.fetch = jest.fn().mockResolvedValue({
32 | status: 200,
33 | headers: {get: () => '"2hhaswzbz72p8"'},
34 | json: () => Promise.resolve({items: []}),
35 | });
36 |
37 | const d = await api.listVariables('123');
38 |
39 | expect(d).toEqual({
40 | etag: '"2hhaswzbz72p8"',
41 | items: [],
42 | });
43 | expect(global.fetch).toHaveBeenCalledWith('/variables?collectionId=123', {
44 | method: 'GET',
45 | headers: {
46 | 'X-Requested-With': 'XMLHttpRequest',
47 | },
48 | });
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/infrastructure/http/ui.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "net/http"
5 | "strings"
6 |
7 | "github.com/NYTimes/gziphandler"
8 | "github.com/julienschmidt/httprouter"
9 | )
10 |
11 | func serveIndex() httprouter.Handle {
12 | return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
13 | w.Header().Set("X-Content-Type-Options", "nosniff")
14 | w.Header().Set("Cache-Control", "max-age=180")
15 | http.ServeFile(w, r, "static/index.html")
16 | }
17 | }
18 |
19 | func serveFavicon() httprouter.Handle {
20 | return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
21 | w.Header().Set("X-Content-Type-Options", "nosniff")
22 | w.Header().Set("Cache-Control", "max-age=180, immutable")
23 | http.ServeFile(w, r, "static/favicon.ico")
24 | }
25 | }
26 |
27 | func serveJavascript() httprouter.Handle {
28 | fileServer := gziphandler.GzipHandler(http.FileServer(http.Dir("static/js")))
29 | return func(w http.ResponseWriter, req *http.Request, p httprouter.Params) {
30 | req.URL.Path = p.ByName("filepath")
31 | w.Header().Set("X-Content-Type-Options", "nosniff")
32 | w.Header().Set("Cache-Control", "max-age=31536000, immutable")
33 | if strings.HasSuffix(req.URL.Path, ".map") {
34 | w.Header().Set("Content-Type", "application/json; charset=UTF-8")
35 | } else {
36 | w.Header().Set("Content-Type", "application/javascript; charset=UTF-8")
37 | }
38 | fileServer.ServeHTTP(w, req)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/infrastructure/http/runner.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "io/ioutil"
9 | "log"
10 | "net/http"
11 | "strings"
12 |
13 | "github.com/akornatskyy/scheduler/domain"
14 | )
15 |
16 | var (
17 | userAgent = fmt.Sprintf("Scheduler/%s", domain.Version)
18 | )
19 |
20 | type httpRunner struct {
21 | client *http.Client
22 | }
23 |
24 | func NewRunner() domain.Runner {
25 | return &httpRunner{
26 | client: &http.Client{},
27 | }
28 | }
29 |
30 | func (runner *httpRunner) Run(ctx context.Context, a *domain.Action) error {
31 | r := a.Request
32 | var reader io.Reader
33 | if r.Body != "" {
34 | reader = strings.NewReader(r.Body)
35 | }
36 | req, err := http.NewRequest(r.Method, r.URI, reader)
37 | if err != nil {
38 | return err
39 | }
40 | for _, p := range r.Headers {
41 | req.Header.Add(p.Name, p.Value)
42 | }
43 | req.Header.Set("User-Agent", userAgent)
44 | resp, err := runner.client.Do(req.WithContext(ctx))
45 | if err != nil {
46 | log.Printf("%s %s - %s", r.Method, r.URI, err)
47 | return err
48 | }
49 | body, err := ioutil.ReadAll(resp.Body)
50 | log.Printf("%s %s - %d %d", r.Method, r.URI, resp.StatusCode, len(body))
51 | if err != nil {
52 | return err
53 | }
54 | if resp.StatusCode < 200 || resp.StatusCode >= 300 {
55 | return &domain.RunError{
56 | Code: resp.StatusCode,
57 | Err: errors.New(http.StatusText(resp.StatusCode)),
58 | }
59 | }
60 | return nil
61 | }
62 |
--------------------------------------------------------------------------------
/ui/features/jobs/jobs-api.test.js:
--------------------------------------------------------------------------------
1 | import * as api from './jobs-api';
2 |
3 | describe('jobs api', () => {
4 | afterEach(() => {
5 | global.fetch.mockClear();
6 | delete global.fetch;
7 | });
8 |
9 | it('list', async () => {
10 | global.fetch = jest.fn().mockResolvedValue({
11 | status: 200,
12 | headers: {get: () => '"2hhaswzbz72p8"'},
13 | json: () => Promise.resolve({items: []}),
14 | });
15 |
16 | const d = await api.listJobs();
17 |
18 | expect(d).toEqual({
19 | etag: '"2hhaswzbz72p8"',
20 | items: [],
21 | });
22 | expect(global.fetch).toHaveBeenCalledWith('/jobs?fields=status,errorRate', {
23 | method: 'GET',
24 | headers: {
25 | 'X-Requested-With': 'XMLHttpRequest',
26 | },
27 | });
28 | });
29 |
30 | it('list by collection id', async () => {
31 | global.fetch = jest.fn().mockResolvedValue({
32 | status: 200,
33 | headers: {get: () => '"2hhaswzbz72p8"'},
34 | json: () => Promise.resolve({items: []}),
35 | });
36 |
37 | const d = await api.listJobs('123');
38 |
39 | expect(d).toEqual({
40 | etag: '"2hhaswzbz72p8"',
41 | items: [],
42 | });
43 | expect(global.fetch).toHaveBeenCalledWith(
44 | '/jobs?fields=status,errorRate&collectionId=123',
45 | {
46 | method: 'GET',
47 | headers: {
48 | 'X-Requested-With': 'XMLHttpRequest',
49 | },
50 | },
51 | );
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yaml:
--------------------------------------------------------------------------------
1 | name: tests
2 |
3 | on:
4 | schedule:
5 | - cron: '0 3 * * 0' # At 03:00 on Sunday.
6 | push:
7 | branches:
8 | - master
9 | paths-ignore:
10 | - 'misc/docs/**'
11 | - '**.md'
12 | pull_request:
13 | branches:
14 | - master
15 |
16 | jobs:
17 | go:
18 | runs-on: ubuntu-latest
19 |
20 | strategy:
21 | matrix:
22 | go: ['1.24', '1.25']
23 |
24 | steps:
25 | - uses: actions/checkout@v5
26 | - uses: actions/setup-go@v6
27 | with:
28 | go-version: ${{ matrix.go }}
29 | - run: go test ./...
30 |
31 | node:
32 | runs-on: ubuntu-latest
33 |
34 | strategy:
35 | matrix:
36 | node: [24, 25]
37 |
38 | steps:
39 | - uses: actions/checkout@v5
40 | - uses: actions/setup-node@v6
41 | with:
42 | node-version: ${{ matrix.node }}
43 | cache: 'npm'
44 | - run: npm ci
45 | - run: npm run lint
46 | - run: npm t
47 | - env:
48 | NODE_OPTIONS: --no-experimental-webstorage
49 | run: npm run build -- --mode=production
50 |
51 | analyze:
52 | needs: [go, node]
53 | runs-on: ubuntu-latest
54 |
55 | permissions:
56 | security-events: write
57 |
58 | steps:
59 | - uses: actions/checkout@v5
60 | - uses: github/codeql-action/init@v3
61 | with:
62 | languages: go, javascript
63 | - uses: github/codeql-action/analyze@v3
64 |
--------------------------------------------------------------------------------
/infrastructure/postgres/collections.go:
--------------------------------------------------------------------------------
1 | package postgres
2 |
3 | import (
4 | "database/sql"
5 |
6 | "github.com/akornatskyy/scheduler/domain"
7 | )
8 |
9 | func (r *sqlRepository) ListCollections() ([]*domain.CollectionItem, error) {
10 | items := make([]*domain.CollectionItem, 0, 10)
11 | rows, err := r.selectCollections.Query()
12 | if err != nil {
13 | return nil, err
14 | }
15 | defer rows.Close()
16 | for rows.Next() {
17 | c := &domain.CollectionItem{}
18 | err := rows.Scan(&c.ID, &c.Name, &c.State)
19 | if err != nil {
20 | return nil, err
21 | }
22 | items = append(items, c)
23 | }
24 | if err := rows.Err(); err != nil {
25 | return nil, err
26 | }
27 | return items, nil
28 | }
29 |
30 | func (r *sqlRepository) CreateCollection(c *domain.Collection) error {
31 | return checkExec(r.insertCollection.Exec(
32 | c.ID, c.Name, c.State,
33 | ))
34 | }
35 |
36 | func (r *sqlRepository) RetrieveCollection(id string) (*domain.Collection, error) {
37 | c := &domain.Collection{}
38 | err := r.selectCollection.QueryRow(id).Scan(
39 | &c.ID, &c.Name, &c.Updated, &c.State,
40 | )
41 | if err != nil {
42 | if err == sql.ErrNoRows {
43 | return nil, domain.ErrNotFound
44 | }
45 | return nil, err
46 | }
47 | return c, nil
48 | }
49 |
50 | func (r *sqlRepository) UpdateCollection(c *domain.Collection) error {
51 | return checkExec(r.updateCollection.Exec(
52 | c.ID, c.Updated, c.Name, c.State,
53 | ))
54 | }
55 |
56 | func (r *sqlRepository) DeleteCollection(id string) error {
57 | return checkExec(r.deleteCollection.Exec(id))
58 | }
59 |
--------------------------------------------------------------------------------
/ui/features/collections/collections.test.js:
--------------------------------------------------------------------------------
1 | import {act, render, screen} from '@testing-library/react';
2 | import React from 'react';
3 | import {MemoryRouter as Router} from 'react-router-dom';
4 |
5 | import Collections from './collections';
6 | import * as api from './collections-api';
7 |
8 | jest.mock('./collections-api');
9 |
10 | describe('collections', () => {
11 | beforeEach(() => {
12 | jest.clearAllMocks();
13 | });
14 |
15 | it('handles list error', async () => {
16 | const errors = {__ERROR__: 'The error text.'};
17 | api.listCollections.mockRejectedValue(errors);
18 |
19 | await act(async () => {
20 | render(
21 |
22 |
23 | ,
24 | );
25 | });
26 | expect(api.listCollections).toHaveBeenCalledTimes(1);
27 | expect(api.listCollections).toHaveBeenCalledWith();
28 | expect(screen.getByText(errors.__ERROR__)).toBeVisible();
29 | });
30 |
31 | it('updates state with fetched items', async () => {
32 | const items = [
33 | {
34 | id: '65ada2f9',
35 | name: 'My App #1',
36 | state: 'enabled',
37 | },
38 | ];
39 | api.listCollections.mockResolvedValue({items});
40 |
41 | await act(async () => {
42 | render(
43 |
44 |
45 | ,
46 | );
47 | });
48 |
49 | expect(api.listCollections).toHaveBeenCalled();
50 | expect(api.listCollections).toHaveBeenCalledTimes(1);
51 | expect(screen.getByText('My App #1')).toBeVisible();
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/infrastructure/http/server.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "context"
5 | "log"
6 | "net/http"
7 |
8 | "github.com/akornatskyy/goext/errorstate"
9 | "github.com/akornatskyy/goext/httpjson"
10 | "github.com/akornatskyy/scheduler/core"
11 | "github.com/akornatskyy/scheduler/domain"
12 | )
13 |
14 | const (
15 | addr = ":8080"
16 | )
17 |
18 | type Server struct {
19 | Service *core.Service
20 | srv *http.Server
21 | }
22 |
23 | func (s *Server) Start() {
24 | s.srv = &http.Server{
25 | Addr: addr,
26 | Handler: s.Routes(),
27 | }
28 |
29 | go func() {
30 | log.Println("http started")
31 | if err := s.srv.ListenAndServe(); err != nil {
32 | switch err {
33 | case http.ErrServerClosed:
34 | log.Println("http stopped")
35 | default:
36 | log.Printf("http serve: %s", err)
37 | }
38 | }
39 | }()
40 | log.Printf("http listening on %s", s.srv.Addr)
41 | }
42 |
43 | func (s *Server) Stop() {
44 | if err := s.srv.Shutdown(context.Background()); err != nil {
45 | log.Printf("shutdown: %s", err)
46 | }
47 | if err := s.srv.Close(); err != nil {
48 | log.Printf("close: %s", err)
49 | }
50 | }
51 |
52 | func writeError(w http.ResponseWriter, err error) {
53 | switch err {
54 | case domain.ErrNotFound:
55 | w.WriteHeader(http.StatusNotFound)
56 | case domain.ErrConflict:
57 | w.WriteHeader(http.StatusConflict)
58 | default:
59 | switch err.(type) {
60 | case *errorstate.ErrorState:
61 | httpjson.Encode(w, err, http.StatusBadRequest)
62 | default:
63 | log.Printf("ERR: %s", err)
64 | w.WriteHeader(http.StatusServiceUnavailable)
65 | }
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const HtmlPlugin = require('html-webpack-plugin');
3 | const TerserPlugin = require('terser-webpack-plugin');
4 |
5 | const plugins = [
6 | new HtmlPlugin({
7 | template: 'index.html',
8 | favicon: 'favicon.ico'
9 | })
10 | ];
11 |
12 | module.exports = {
13 | mode: 'development',
14 | context: path.resolve(__dirname, 'ui'),
15 | entry: {
16 | app: ['./index.js']
17 | },
18 | output: {
19 | path: path.resolve(__dirname, 'static'),
20 | filename: 'js/[name].[chunkhash:5].js'
21 | },
22 | devtool: 'source-map',
23 | plugins: plugins,
24 | optimization: {
25 | splitChunks: {
26 | cacheGroups: {
27 | lib: {
28 | name: 'lib',
29 | chunks: 'all',
30 | test: /[\\/]node_modules[\\/]/
31 | }
32 | }
33 | },
34 | minimizer: [new TerserPlugin({
35 | extractComments: false,
36 | terserOptions: {
37 | output: {
38 | comments: false,
39 | },
40 | },
41 | })],
42 | },
43 | module: {
44 | rules: [
45 | {
46 | test: /\.js$/,
47 | loader: 'babel-loader',
48 | exclude: /node_modules/
49 | }
50 | ]
51 | },
52 | devServer: {
53 | static: {
54 | directory: path.join(__dirname, 'ui/'),
55 | },
56 | host: '127.0.0.1',
57 | port: 3000,
58 | compress: true,
59 | proxy: [
60 | {
61 | target: 'http://127.0.0.1:8080',
62 | context: ['/collections', '/variables', '/jobs']
63 | // pathRewrite: {'^/api' : ''}
64 | }
65 | ]
66 | }
67 | };
68 |
--------------------------------------------------------------------------------
/infrastructure/http/routes.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "net/http"
5 |
6 | "github.com/julienschmidt/httprouter"
7 | )
8 |
9 | func (s *Server) Routes() http.Handler {
10 | r := httprouter.New()
11 |
12 | r.HandlerFunc("GET", "/collections", ETagHandler(s.listCollections()))
13 | r.HandlerFunc("POST", "/collections", s.createCollection())
14 | r.Handle("GET", "/collections/:id", s.retrieveCollection())
15 | r.Handle("PATCH", "/collections/:id", s.patchCollection())
16 | r.Handle("DELETE", "/collections/:id", s.deleteCollection())
17 |
18 | r.HandlerFunc("GET", "/variables", ETagHandler(s.listVariables()))
19 | r.HandlerFunc("POST", "/variables", s.createVariable())
20 | r.Handle("GET", "/variables/:id", s.retrieveVariable())
21 | r.Handle("PATCH", "/variables/:id", s.patchVariable())
22 | r.Handle("DELETE", "/variables/:id", s.deleteVariable())
23 |
24 | r.HandlerFunc("GET", "/jobs", ETagHandler(s.listJobs()))
25 | r.HandlerFunc("POST", "/jobs", s.createJob())
26 | r.Handle("GET", "/jobs/:id", s.retrieveJob())
27 | r.Handle("PATCH", "/jobs/:id", s.patchJob())
28 | r.Handle("DELETE", "/jobs/:id", s.deleteJob())
29 |
30 | r.Handle("GET", "/jobs/:id/status", s.retrieveJobStatus())
31 | r.Handle("PATCH", "/jobs/:id/status", s.patchJobStatus())
32 |
33 | r.Handle("GET", "/jobs/:id/history", s.listJobHistory())
34 | r.Handle("DELETE", "/jobs/:id/history", s.deleteJobHistory())
35 |
36 | r.HandlerFunc("GET", "/health", s.health())
37 |
38 | r.Handle("GET", "/", serveIndex())
39 | r.Handle("GET", "/favicon.ico", serveFavicon())
40 | r.Handle("GET", "/js/*filepath", serveJavascript())
41 |
42 | return r
43 | }
44 |
--------------------------------------------------------------------------------
/core/variables.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "github.com/akornatskyy/scheduler/domain"
5 | )
6 |
7 | func (s *Service) ListVariables(collectionID string) ([]*domain.VariableItem, error) {
8 | if err := domain.ValidateID(collectionID); err != nil {
9 | return nil, err
10 | }
11 | return s.Repository.ListVariables(collectionID)
12 | }
13 |
14 | func (s *Service) CreateVariable(Variable *domain.Variable) error {
15 | if err := domain.ValidateVariable(Variable); err != nil {
16 | return err
17 | }
18 | if Variable.ID == "" {
19 | Variable.ID = domain.NewID()
20 | }
21 | return s.Repository.CreateVariable(Variable)
22 | }
23 |
24 | func (s *Service) RetrieveVariable(id string) (*domain.Variable, error) {
25 | if err := domain.ValidateID(id); err != nil {
26 | return nil, err
27 | }
28 | return s.Repository.RetrieveVariable(id)
29 | }
30 |
31 | func (s *Service) UpdateVariable(Variable *domain.Variable) error {
32 | if err := domain.ValidateVariable(Variable); err != nil {
33 | return err
34 | }
35 | return s.Repository.UpdateVariable(Variable)
36 | }
37 |
38 | func (s *Service) DeleteVariable(id string) error {
39 | if err := domain.ValidateID(id); err != nil {
40 | return err
41 | }
42 | return s.Repository.DeleteVariable(id)
43 | }
44 |
45 | func (s *Service) mapVariables(collectionID string) (map[string]string, error) {
46 | variables, err := s.Repository.MapVariables(collectionID)
47 | if err != nil {
48 | return nil, err
49 | }
50 | for key, value := range s.variables {
51 | if _, ok := variables[key]; ok {
52 | continue
53 | }
54 | variables[key] = value
55 | }
56 | return variables, nil
57 | }
58 |
--------------------------------------------------------------------------------
/ui/shared/fetch.js:
--------------------------------------------------------------------------------
1 | const host = '';
2 |
3 | const thenHandle = (r, resolve, reject) => {
4 | if (r.status === 201 || r.status === 204) {
5 | return resolve();
6 | } else if (r.status >= 200 && r.status < 300) {
7 | return r.json().then((d) => {
8 | d.etag = r.headers.get('etag');
9 | resolve(d);
10 | });
11 | } else if (r.status === 400) {
12 | return r.json().then((data) => {
13 | const errors = {};
14 | data.errors
15 | .filter((err) => err.type === 'field')
16 | .forEach((err) => {
17 | errors[err.location] = err.message;
18 | });
19 | reject(errors);
20 | });
21 | }
22 |
23 | return reject({__ERROR__: `${r.status}: ${r.statusText}`});
24 | };
25 |
26 | export const go = (method, path, data) => {
27 | const options = {
28 | method: method,
29 | headers: {
30 | 'X-Requested-With': 'XMLHttpRequest',
31 | },
32 | };
33 | switch (method) {
34 | case 'DELETE':
35 | options.headers['If-Match'] = data;
36 | break;
37 | case 'PATCH':
38 | options.headers['If-Match'] = data.etag;
39 | data = {...data};
40 | delete data.id;
41 | delete data.etag;
42 | delete data.updated;
43 | // eslint-disable-next-line no-fallthrough
44 | case 'POST':
45 | options.headers['Content-Type'] = 'application/json';
46 | options.body = JSON.stringify(data);
47 | break;
48 | }
49 | return new Promise((resolve, reject) =>
50 | fetch(host + path, options)
51 | .then((r) => thenHandle(r, resolve, reject))
52 | .catch((error) => reject({__ERROR__: error.message})),
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/ui/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {createRoot} from 'react-dom/client';
3 | import {Container} from 'react-bootstrap';
4 | import {HashRouter as Router, Switch, Redirect, Route} from 'react-router-dom';
5 |
6 | import Collection from './features/collection/collection';
7 | import Collections from './features/collections/collections';
8 | import Job from './features/job/job';
9 | import JobHistory from './features/history/history';
10 | import Jobs from './features/jobs/jobs';
11 | import Variable from './features/variable/variable';
12 | import Variables from './features/variables/variables';
13 | import {Header, Footer} from './shared/components';
14 |
15 | export const App = () => (
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | );
34 |
35 | const root = createRoot(
36 | document.querySelector('#root') || document.createElement('div'),
37 | );
38 | root.render(
39 |
40 |
41 | ,
42 | );
43 |
--------------------------------------------------------------------------------
/domain/template.go:
--------------------------------------------------------------------------------
1 | package domain
2 |
3 | import (
4 | "html/template"
5 | "strings"
6 |
7 | "github.com/akornatskyy/goext/errorstate"
8 | )
9 |
10 | func (req *HttpRequest) Transpose(variables map[string]string) (*HttpRequest, error) {
11 | e := &errorstate.ErrorState{
12 | Domain: domain,
13 | }
14 | uri, err := renderTemplate("uri", req.URI, variables)
15 | if err != nil {
16 | e.Add(&errorstate.Detail{
17 | Domain: domain,
18 | Type: "field",
19 | Location: "uri",
20 | Reason: "template",
21 | Message: err.Error(),
22 | })
23 | }
24 | headers := make([]*NameValuePair, 0, len(req.Headers))
25 | for _, pair := range req.Headers {
26 | value, err := renderTemplate("header value", pair.Value, variables)
27 | if err != nil {
28 | e.Add(&errorstate.Detail{
29 | Domain: domain,
30 | Type: "field",
31 | Location: "__ERROR__",
32 | Reason: "template",
33 | Message: err.Error(),
34 | })
35 | break
36 | }
37 | headers = append(headers, &NameValuePair{
38 | Name: pair.Name,
39 | Value: value,
40 | })
41 | }
42 | body, err := renderTemplate("body", req.Body, variables)
43 | if err != nil {
44 | e.Add(&errorstate.Detail{
45 | Domain: domain,
46 | Type: "field",
47 | Location: "body",
48 | Reason: "template",
49 | Message: err.Error(),
50 | })
51 | }
52 | if e.Errors != nil {
53 | return nil, e
54 | }
55 | return &HttpRequest{
56 | Method: req.Method,
57 | URI: uri,
58 | Headers: headers,
59 | Body: body,
60 | }, nil
61 | }
62 |
63 | func renderTemplate(name string, text string, variables map[string]string) (string, error) {
64 | t, err := template.New(name).Parse(text)
65 | if err != nil {
66 | return "", err
67 | }
68 | var b strings.Builder
69 | if err := t.Execute(&b, variables); err != nil {
70 | return "", err
71 | }
72 | return b.String(), nil
73 | }
74 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I=
2 | github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c=
3 | github.com/akornatskyy/goext v1.4.5 h1:1FhEBV9rGFyQgQQPaPnOLa49JJhGOtshOcHqg14JY3Y=
4 | github.com/akornatskyy/goext v1.4.5/go.mod h1:ciaK9EtoTj6gXELhvEgaUpKRps+sz8e85zlIMwWfr3s=
5 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
6 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
7 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
8 | github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
9 | github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
10 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
11 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
12 | github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
13 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
14 | github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
15 | github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
16 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
17 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
18 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
19 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
20 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
21 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
22 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
23 |
--------------------------------------------------------------------------------
/ui/features/collection/collection.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import {Layout} from '../../shared/components';
4 | import * as api from './collection-api';
5 | import {CollectionForm} from './collection-components';
6 |
7 | export default class Collection extends React.Component {
8 | state = {
9 | item: {
10 | name: '',
11 | state: '',
12 | },
13 | pending: true,
14 | errors: {},
15 | };
16 |
17 | componentDidMount() {
18 | const {id} = this.props.match.params;
19 | if (id) {
20 | api
21 | .retrieveCollection(id)
22 | .then((data) => this.setState({item: data, pending: false}))
23 | .catch((errors) => this.setState({errors, pending: false}));
24 | } else {
25 | this.setState({item: {name: '', state: 'enabled'}, pending: false});
26 | }
27 | }
28 |
29 | handleChange = (name, value) => {
30 | this.setState({
31 | item: {...this.state.item, [name]: value},
32 | });
33 | };
34 |
35 | handleSave = () => {
36 | this.setState({pending: true});
37 | api
38 | .saveCollection(this.state.item)
39 | .then(() => this.props.history.goBack())
40 | .catch((errors) => this.setState({errors, pending: false}));
41 | };
42 |
43 | handleDelete = () => {
44 | const {id, etag} = this.state.item;
45 | this.setState({pending: true});
46 | api
47 | .deleteCollection(id, etag)
48 | .then(() => this.props.history.goBack())
49 | .catch((errors) => this.setState({errors, pending: false}));
50 | };
51 |
52 | render() {
53 | const {item, pending, errors} = this.state;
54 | return (
55 |
56 |
64 |
65 | );
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/misc/db/patch-ids.sql:
--------------------------------------------------------------------------------
1 | BEGIN;
2 |
3 | ALTER TABLE job_history DROP CONSTRAINT job_history_job_fk;
4 | ALTER TABLE job_history ALTER COLUMN job_id TYPE varchar(36) USING job_id::varchar;
5 |
6 | ALTER TABLE job_status DROP CONSTRAINT job_status_job_fk;
7 | ALTER TABLE job_status ALTER COLUMN id TYPE varchar(36) USING id::varchar;
8 |
9 | ALTER TABLE job ALTER COLUMN id TYPE varchar(36) USING id::varchar;
10 |
11 | ALTER TABLE job_status ADD CONSTRAINT job_status_job_fk FOREIGN KEY (id) REFERENCES job(id);
12 | ALTER TABLE job_history ADD CONSTRAINT job_history_job_fk FOREIGN KEY (job_id) REFERENCES job(id);
13 |
14 | --
15 |
16 | ALTER TABLE job DROP CONSTRAINT job_collection_fk;
17 | ALTER TABLE job ALTER COLUMN collection_id TYPE varchar(36) USING collection_id::varchar;
18 |
19 | ALTER TABLE collection DROP CONSTRAINT collection_pkey;
20 | ALTER TABLE collection ALTER COLUMN id TYPE varchar(36) USING id::varchar;
21 | ALTER TABLE collection ADD CONSTRAINT collection_pkey PRIMARY KEY (id);
22 |
23 |
24 | ALTER TABLE job ADD CONSTRAINT job_collection_fk FOREIGN KEY (collection_id) REFERENCES collection(id);
25 |
26 | ALTER TABLE job_history ALTER COLUMN id DROP DEFAULT;
27 | ALTER TABLE job_history ALTER COLUMN id TYPE varchar(36) USING id::varchar;
28 | DROP SEQUENCE job_history_seq;
29 |
30 |
31 | DROP TRIGGER collection_notify ON collection;
32 | DROP TRIGGER job_notify ON job;
33 |
34 | DROP FUNCTION table_update_notify();
35 |
36 | CREATE OR REPLACE FUNCTION table_update_notify()
37 | RETURNS trigger
38 | LANGUAGE plpgsql
39 | AS $function$
40 | DECLARE
41 | id varchar;
42 | BEGIN
43 | IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
44 | id = NEW.id;
45 | ELSE
46 | id = OLD.id;
47 | END IF;
48 | PERFORM pg_notify(
49 | 'table_update', TG_OP || ' ' || TG_TABLE_NAME || ' ' || id);
50 | RETURN NEW;
51 | END;
52 | $function$
53 | ;
54 |
55 | CREATE TRIGGER collection_notify AFTER UPDATE ON
56 | collection FOR EACH ROW EXECUTE FUNCTION table_update_notify();
57 | CREATE TRIGGER job_notify AFTER INSERT OR UPDATE OR DELETE ON
58 | job FOR EACH ROW EXECUTE FUNCTION table_update_notify();
59 |
60 | COMMIT;
--------------------------------------------------------------------------------
/infrastructure/postgres/variables.go:
--------------------------------------------------------------------------------
1 | package postgres
2 |
3 | import (
4 | "database/sql"
5 |
6 | "github.com/akornatskyy/scheduler/domain"
7 | )
8 |
9 | func (r *sqlRepository) ListVariables(collectionID string) ([]*domain.VariableItem, error) {
10 | items := make([]*domain.VariableItem, 0, 10)
11 | rows, err := r.selectVariables.Query(collectionID)
12 | if err != nil {
13 | return nil, err
14 | }
15 | defer rows.Close()
16 | for rows.Next() {
17 | v := &domain.VariableItem{}
18 | err := rows.Scan(&v.ID, &v.Name, &v.CollectionID, &v.Updated)
19 | if err != nil {
20 | return nil, err
21 | }
22 | items = append(items, v)
23 | }
24 | if err := rows.Err(); err != nil {
25 | return nil, err
26 | }
27 | return items, nil
28 | }
29 |
30 | func (r *sqlRepository) MapVariables(collectionID string) (map[string]string, error) {
31 | items := make(map[string]string)
32 | rows, err := r.selectVariablesNameValue.Query(collectionID)
33 | if err != nil {
34 | return nil, err
35 | }
36 | defer rows.Close()
37 | for rows.Next() {
38 | var name, value string
39 | err := rows.Scan(&name, &value)
40 | if err != nil {
41 | return nil, err
42 | }
43 | items[name] = value
44 | }
45 | if err := rows.Err(); err != nil {
46 | return nil, err
47 | }
48 | return items, nil
49 | }
50 |
51 | func (r *sqlRepository) CreateVariable(v *domain.Variable) error {
52 | return checkExec(r.insertVariable.Exec(
53 | v.ID, v.Name, v.CollectionID, v.Value,
54 | ))
55 | }
56 |
57 | func (r *sqlRepository) RetrieveVariable(id string) (*domain.Variable, error) {
58 | v := &domain.Variable{}
59 | err := r.selectVariable.QueryRow(id).Scan(
60 | &v.ID, &v.Name, &v.Updated, &v.CollectionID, &v.Value,
61 | )
62 | if err != nil {
63 | if err == sql.ErrNoRows {
64 | return nil, domain.ErrNotFound
65 | }
66 | return nil, err
67 | }
68 | return v, nil
69 | }
70 |
71 | func (r *sqlRepository) UpdateVariable(v *domain.Variable) error {
72 | return checkExec(r.updateVariable.Exec(
73 | v.ID, v.Updated, v.Name, v.CollectionID, v.Value,
74 | ))
75 | }
76 |
77 | func (r *sqlRepository) DeleteVariable(id string) error {
78 | return checkExec(r.deleteVariable.Exec(id))
79 | }
80 |
--------------------------------------------------------------------------------
/ui/shared/fetch.test.js:
--------------------------------------------------------------------------------
1 | import {go} from './fetch';
2 |
3 | describe('fetch go', () => {
4 | afterEach(() => {
5 | global.fetch.mockClear();
6 | delete global.fetch;
7 | });
8 |
9 | describe('handles error: ', () => {
10 | it('unexpected', async () => {
11 | global.fetch = jest.fn().mockRejectedValue({
12 | message: 'unexpected error',
13 | });
14 |
15 | await go('GET', '/').catch((e) =>
16 | expect(e).toEqual({
17 | __ERROR__: 'unexpected error',
18 | }),
19 | );
20 |
21 | expect(global.fetch).toHaveBeenCalledWith('/', {
22 | method: 'GET',
23 | headers: {
24 | 'X-Requested-With': 'XMLHttpRequest',
25 | },
26 | });
27 | });
28 |
29 | it('not found', async () => {
30 | global.fetch = jest.fn().mockResolvedValue({
31 | status: 404,
32 | statusText: 'Not Found',
33 | });
34 |
35 | await go('GET', '/').catch((e) =>
36 | expect(e).toEqual({
37 | __ERROR__: '404: Not Found',
38 | }),
39 | );
40 |
41 | expect(global.fetch).toHaveBeenCalledWith('/', {
42 | method: 'GET',
43 | headers: {
44 | 'X-Requested-With': 'XMLHttpRequest',
45 | },
46 | });
47 | });
48 |
49 | it('bad request', async () => {
50 | global.fetch = jest.fn().mockResolvedValue({
51 | status: 400,
52 | json: () => {
53 | return {
54 | then: (f) =>
55 | f({
56 | errors: [
57 | {
58 | type: 'other',
59 | },
60 | {
61 | type: 'field',
62 | location: 'name',
63 | message: 'Required field cannot be left blank.',
64 | },
65 | ],
66 | }),
67 | };
68 | },
69 | });
70 |
71 | await go('POST', '/').catch((e) =>
72 | expect(e).toEqual({
73 | name: 'Required field cannot be left blank.',
74 | }),
75 | );
76 |
77 | expect(global.fetch.mock.calls.length).toBe(1);
78 | });
79 | });
80 | });
81 |
--------------------------------------------------------------------------------
/ui/features/history/history-components.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Table, Button, Row, Col} from 'react-bootstrap';
3 |
4 | export const JobHistoryList = ({status, items, onBack, onRun, onDelete}) => (
5 | <>
6 |
7 |
8 |
9 |
10 | {formatRunning(status.running)}
11 |
12 |
13 |
14 |
15 | {status.runCount} / {status.errorCount}
16 |
17 |
18 |
19 |
20 |
21 |
22 | {formatDate(status.lastRun)}
23 |
24 |
25 |
26 | {formatDate(status.nextRun)}
27 |
28 |
29 |
30 |
31 | | Action |
32 | Started |
33 | Finished |
34 | Status |
35 | Retries |
36 | Message |
37 |
38 |
39 |
40 | {items.map((i, index) => (
41 |
42 | | {i.action} |
43 | {formatDate(i.started)} |
44 | {formatDate(i.finished)} |
45 | {i.status} |
46 | {i.retryCount} |
47 | {i.message} |
48 |
49 | ))}
50 |
51 |
52 |
53 |
61 | {items.length > 0 && (
62 |
65 | )}
66 | >
67 | );
68 |
69 | export const formatRunning = (r) =>
70 | r === true ? 'Running' : r === false ? 'Scheduled' : '';
71 |
72 | export const formatDate = (s) => {
73 | if (!s) {
74 | return 'N/A';
75 | }
76 |
77 | return new Date(s).toLocaleString();
78 | };
79 |
--------------------------------------------------------------------------------
/misc/db/samples.sql:
--------------------------------------------------------------------------------
1 | INSERT INTO collection (id,"name",updated,state_id) VALUES
2 | ('xebs7HqKQpU','My App #1','2020-03-29 13:29:36.976',1),
3 | ('kUgrsOoGDuY','My Other App','2020-03-29 13:29:44.200',2);
4 |
5 | INSERT INTO job (id,"name",updated,collection_id,state_id,schedule,"action") VALUES
6 | ('1hZsD7XGqPE','My Task #1','2020-04-10 11:35:30.342','xebs7HqKQpU',2,'@every 15s','{"type":"HTTP","request":{"method":"GET","uri":"http://{{.Host}}","headers":[{"name":"X-Requested-With","value":"XMLHttpRequest"},{"name": "Authorization", "value": "Bearer: {{.Token}}"}]},"retryPolicy":{"retryCount":3,"retryInterval":"10s","deadline":"1m0s"}}'),
7 | ('cBFGwbpvWkQ','My Task #2','2020-03-29 13:31:37.493','kUgrsOoGDuY',1,'@every 1m','{"type":"HTTP","request":{"method":"POST","uri":"http://localhost:8000/test","headers":[{"name":"X-Requested-With","value":"XMLHttpRequest"},{"name":"Content-Type","value":"application/json"}],"body":"{}"},"retryPolicy":{"retryCount":3,"retryInterval":"10s","deadline":"1m0s"}}');
8 |
9 | INSERT INTO job_status (id,updated,running,run_count,error_count,last_run) VALUES
10 | ('1hZsD7XGqPE','2020-03-29 13:30:30.342',false,4,2,'2020-04-01 09:26:13.000'),
11 | ('cBFGwbpvWkQ','2020-03-29 13:31:37.493',false,0,0,NULL);
12 |
13 | INSERT INTO job_history (id, job_id,"action",started,finished,status_id,retry_count,message) VALUES
14 | ('7LFKl36KpvE', '1hZsD7XGqPE','HTTP','2020-04-01 06:24:43.000','2020-04-01 06:25:23.000',2,3,'404 Not Found'),
15 | ('mFtYG5ZkM08', '1hZsD7XGqPE','HTTP','2020-04-01 06:25:41.000','2020-04-01 06:25:41.000',1,0,NULL),
16 | ('tKUYfJldG3M', '1hZsD7XGqPE','HTTP','2020-04-01 06:25:56.000','2020-04-01 06:25:56.000',1,0,NULL),
17 | ('x3NvtXaBYvE', '1hZsD7XGqPE','HTTP','2020-04-01 06:26:13.000','2020-04-01 06:27:02.000',2,3,'Get http://localhost2:8080/: dial tcp: lookup localhost2: no such host');
18 |
19 | INSERT INTO variable (id, collection_id, "name", updated, value) VALUES
20 | ('xphekQqIUM8', 'xebs7HqKQpU', 'Host', '2020-04-10 11:26:53.000', 'localhost:8081'),
21 | ('v5k0ZORBdDk', 'xebs7HqKQpU', 'Token', '2020-04-10 11:29:29.000', 'eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.bQTnz6AuMJvmXXQsVPrxeQNvzDkimo7VNXxHeSBfClLufmCVZRUuyTwJF311JHuh');
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "scheduler-ui",
3 | "version": "1.5.1",
4 | "description": "A fully managed cron job scheduler",
5 | "main": "ui/index.js",
6 | "scripts": {
7 | "start": "webpack serve",
8 | "lint": "eslint webpack.config.js ui/ --ext .js",
9 | "build": "rm -rf static && webpack",
10 | "test": "jest"
11 | },
12 | "jest": {
13 | "testEnvironment": "jsdom",
14 | "setupFilesAfterEnv": [
15 | "/ui/shared/setup-tests.js"
16 | ]
17 | },
18 | "repository": {
19 | "type": "git",
20 | "url": "https://github.com/akornatskyy/scheduler.git"
21 | },
22 | "keywords": [
23 | "scheduler",
24 | "cron",
25 | "job",
26 | "service"
27 | ],
28 | "author": "Andriy Kornatskyy",
29 | "license": "MIT",
30 | "bugs": {
31 | "url": "https://github.com/akornatskyy/scheduler/issues"
32 | },
33 | "homepage": "https://github.com/akornatskyy/scheduler",
34 | "dependencies": {
35 | "immutability-helper": "^3.1.1",
36 | "react": "^18.3.1",
37 | "react-bootstrap": "^2.10.10",
38 | "react-dom": "^18.3.1",
39 | "react-router-dom": "^5.3.4"
40 | },
41 | "devDependencies": {
42 | "@babel/core": "^7.28.5",
43 | "@babel/eslint-parser": "^7.28.5",
44 | "@babel/plugin-transform-class-properties": "^7.27.1",
45 | "@babel/plugin-transform-optional-chaining": "^7.28.5",
46 | "@babel/preset-env": "^7.28.5",
47 | "@babel/preset-react": "^7.28.5",
48 | "@testing-library/jest-dom": "^6.9.1",
49 | "@testing-library/react": "^16.3.0",
50 | "@types/jest": "^30.0.0",
51 | "@types/react": "^19.2.5",
52 | "babel-loader": "^10.0.0",
53 | "eslint": "^8.57.1",
54 | "eslint-config-google": "^0.14.0",
55 | "eslint-config-prettier": "^10.1.8",
56 | "eslint-plugin-react": "^7.37.5",
57 | "html-webpack-plugin": "^5.6.4",
58 | "jest": "^30.2.0",
59 | "jest-environment-jsdom": "^30.2.0",
60 | "terser-webpack-plugin": "^5.3.14",
61 | "webpack": "^5.102.1",
62 | "webpack-cli": "^6.0.1",
63 | "webpack-dev-server": "^5.2.2"
64 | },
65 | "prettier": {
66 | "singleQuote": true,
67 | "trailingComma": "all",
68 | "bracketSpacing": false,
69 | "printWidth": 80
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/infrastructure/cron/scheduler.go:
--------------------------------------------------------------------------------
1 | package cron
2 |
3 | import (
4 | "log"
5 | "sync"
6 | "time"
7 |
8 | "github.com/akornatskyy/scheduler/domain"
9 | "github.com/robfig/cron/v3"
10 | )
11 |
12 | type cronSheduler struct {
13 | mu sync.Mutex
14 | c *cron.Cron
15 | jobs map[string]*cronJob
16 | runner func(*domain.JobDefinition)
17 | }
18 |
19 | type cronJob struct {
20 | id cron.EntryID
21 | j *domain.JobDefinition
22 | }
23 |
24 | func New() domain.Scheduler {
25 | c := cron.New(
26 | cron.WithLocation(time.UTC),
27 | )
28 | return &cronSheduler{c: c, jobs: make(map[string]*cronJob)}
29 | }
30 |
31 | func (s *cronSheduler) SetRunner(f func(*domain.JobDefinition)) {
32 | s.runner = f
33 | }
34 |
35 | func (s *cronSheduler) ListIDs() []string {
36 | defer s.mu.Unlock()
37 | s.mu.Lock()
38 | var l = make([]string, 0, len(s.jobs))
39 | for id := range s.jobs {
40 | l = append(l, id)
41 | }
42 | return l
43 | }
44 |
45 | func (s *cronSheduler) Add(j *domain.JobDefinition) error {
46 | defer s.mu.Unlock()
47 | s.mu.Lock()
48 | cj := s.jobs[j.ID]
49 | if cj != nil {
50 | if j.Updated == cj.j.Updated {
51 | return nil
52 | }
53 | s.c.Remove(cj.id)
54 | delete(s.jobs, j.ID)
55 | }
56 |
57 | id, err := s.c.AddFunc(j.Schedule, func() {
58 | s.Run(j)
59 | })
60 | if err != nil {
61 | return err
62 | }
63 | s.jobs[j.ID] = &cronJob{
64 | id: id,
65 | j: j,
66 | }
67 | return nil
68 | }
69 |
70 | func (s *cronSheduler) Remove(id string) {
71 | defer s.mu.Unlock()
72 | s.mu.Lock()
73 | j := s.jobs[id]
74 | if j == nil {
75 | return
76 | }
77 |
78 | s.c.Remove(j.id)
79 | delete(s.jobs, id)
80 | }
81 |
82 | func (s *cronSheduler) NextRun(id string) *time.Time {
83 | defer s.mu.Unlock()
84 | s.mu.Lock()
85 | cj := s.jobs[id]
86 | if cj == nil {
87 | return nil
88 | }
89 | e := s.c.Entry(cj.id)
90 | return &e.Next
91 | }
92 |
93 | func (s *cronSheduler) Start() {
94 | s.c.Start()
95 | log.Print("scheduler started")
96 | }
97 |
98 | func (s *cronSheduler) Stop() {
99 | log.Println("scheduler is awaiting jobs to finish")
100 | <-s.c.Stop().Done()
101 |
102 | log.Print("scheduler stopped")
103 | }
104 |
105 | func (s *cronSheduler) Run(j *domain.JobDefinition) {
106 | s.runner(j)
107 | }
108 |
--------------------------------------------------------------------------------
/ui/features/history/history.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import {Layout} from '../../shared/components';
4 | import * as api from './history-api';
5 | import {JobHistoryList} from './history-components';
6 |
7 | export default class JobHistory extends React.Component {
8 | state = {
9 | job: {
10 | name: '',
11 | },
12 | status: {
13 | running: '',
14 | runCount: '',
15 | errorCount: '',
16 | lastRun: '',
17 | nextRun: '',
18 | },
19 | items: [],
20 | errors: {},
21 | };
22 |
23 | componentDidMount() {
24 | const {id} = this.props.match.params;
25 | api
26 | .retrieveJob(id)
27 | .then((data) => this.setState({job: data}))
28 | .catch((errors) => this.setState({errors}));
29 | api
30 | .retrieveJobStatus(id)
31 | .then((data) => this.setState({status: data}))
32 | .catch((errors) => this.setState({errors}));
33 | api
34 | .listJobHistory(id)
35 | .then(({items}) => this.setState({items: items.slice(0, 7)}))
36 | .catch((errors) => this.setState({errors}));
37 | }
38 |
39 | handleBack = () => {
40 | this.props.history.goBack();
41 | };
42 |
43 | handleRun = () => {
44 | const {id} = this.props.match.params;
45 | const {etag} = this.state.status;
46 | api
47 | .patchJobStatus(id, {running: true, etag})
48 | .then(() =>
49 | api
50 | .retrieveJobStatus(id)
51 | .then((data) => this.setState({status: data}))
52 | .catch((errors) => this.setState({errors})),
53 | )
54 | .catch((errors) => this.setState({errors}));
55 | };
56 |
57 | handleDelete = () => {
58 | const {id} = this.props.match.params;
59 | const {etag} = this.state.status;
60 | api
61 | .deleteJobHistory(id, etag)
62 | .then(() => this.props.history.goBack())
63 | .catch((errors) => this.setState({errors}));
64 | };
65 |
66 | render() {
67 | const {job, status, items, errors} = this.state;
68 | return (
69 |
70 |
77 |
78 | );
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/ui/features/variable/variable-api.test.js:
--------------------------------------------------------------------------------
1 | import * as api from './variable-api';
2 |
3 | describe('variable api', () => {
4 | afterEach(() => {
5 | global.fetch.mockClear();
6 | delete global.fetch;
7 | });
8 |
9 | it('retrieve', async () => {
10 | global.fetch = jest.fn().mockResolvedValue({
11 | status: 200,
12 | headers: {get: () => '"2hhaswzbz72p8"'},
13 | json: () => Promise.resolve({name: 'My Var'}),
14 | });
15 |
16 | const d = await api.retrieveVariable('123');
17 |
18 | expect(d).toEqual({
19 | etag: '"2hhaswzbz72p8"',
20 | name: 'My Var',
21 | });
22 | expect(global.fetch).toHaveBeenCalledWith('/variables/123', {
23 | method: 'GET',
24 | headers: {
25 | 'X-Requested-With': 'XMLHttpRequest',
26 | },
27 | });
28 | });
29 |
30 | it('save (create)', async () => {
31 | global.fetch = jest.fn().mockResolvedValue({
32 | status: 201,
33 | });
34 |
35 | await api.saveVariable({
36 | name: 'My Var',
37 | });
38 |
39 | expect(global.fetch).toHaveBeenCalledWith('/variables', {
40 | method: 'POST',
41 | headers: {
42 | 'X-Requested-With': 'XMLHttpRequest',
43 | 'Content-Type': 'application/json',
44 | },
45 | body: '{"name":"My Var"}',
46 | });
47 | });
48 |
49 | it('save (update)', async () => {
50 | global.fetch = jest.fn().mockResolvedValue({
51 | status: 204,
52 | });
53 |
54 | await api.saveVariable({
55 | id: '123',
56 | etag: '"2hhaswzbz72p8"',
57 | updated: '2019-08-29T13:29:36.976Z',
58 | name: 'My Var',
59 | });
60 |
61 | expect(global.fetch).toHaveBeenCalledWith('/variables/123', {
62 | method: 'PATCH',
63 | headers: {
64 | 'X-Requested-With': 'XMLHttpRequest',
65 | 'Content-Type': 'application/json',
66 | 'If-Match': '"2hhaswzbz72p8"',
67 | },
68 | body: '{"name":"My Var"}',
69 | });
70 | });
71 |
72 | it('delete', async () => {
73 | global.fetch = jest.fn().mockResolvedValue({
74 | status: 204,
75 | });
76 |
77 | await api.deleteVariable('123', '"2hhaswzbz72p8"');
78 |
79 | expect(global.fetch).toHaveBeenCalledWith('/variables/123', {
80 | method: 'DELETE',
81 | headers: {
82 | 'X-Requested-With': 'XMLHttpRequest',
83 | 'If-Match': '"2hhaswzbz72p8"',
84 | },
85 | });
86 | });
87 | });
88 |
--------------------------------------------------------------------------------
/ui/features/collection/collection-api.test.js:
--------------------------------------------------------------------------------
1 | import * as api from './collection-api';
2 |
3 | describe('collection api', () => {
4 | afterEach(() => {
5 | global.fetch.mockClear();
6 | delete global.fetch;
7 | });
8 |
9 | it('retrieve', async () => {
10 | global.fetch = jest.fn().mockResolvedValue({
11 | status: 200,
12 | headers: {get: () => '"2hhaswzbz72p8"'},
13 | json: () => Promise.resolve({name: 'My App #1'}),
14 | });
15 |
16 | const d = await api.retrieveCollection('123');
17 |
18 | expect(d).toEqual({
19 | etag: '"2hhaswzbz72p8"',
20 | name: 'My App #1',
21 | });
22 | expect(global.fetch).toHaveBeenCalledWith('/collections/123', {
23 | method: 'GET',
24 | headers: {
25 | 'X-Requested-With': 'XMLHttpRequest',
26 | },
27 | });
28 | });
29 |
30 | it('save (create)', async () => {
31 | global.fetch = jest.fn().mockResolvedValue({
32 | status: 201,
33 | });
34 |
35 | await api.saveCollection({
36 | name: 'My App',
37 | });
38 |
39 | expect(global.fetch).toHaveBeenCalledWith('/collections', {
40 | method: 'POST',
41 | headers: {
42 | 'X-Requested-With': 'XMLHttpRequest',
43 | 'Content-Type': 'application/json',
44 | },
45 | body: '{"name":"My App"}',
46 | });
47 | });
48 |
49 | it('save (update)', async () => {
50 | global.fetch = jest.fn().mockResolvedValue({
51 | status: 204,
52 | });
53 |
54 | await api.saveCollection({
55 | id: '123',
56 | etag: '"2hhaswzbz72p8"',
57 | updated: '2019-08-29T13:29:36.976Z',
58 | name: 'My App',
59 | });
60 |
61 | expect(global.fetch).toHaveBeenCalledWith('/collections/123', {
62 | method: 'PATCH',
63 | headers: {
64 | 'X-Requested-With': 'XMLHttpRequest',
65 | 'Content-Type': 'application/json',
66 | 'If-Match': '"2hhaswzbz72p8"',
67 | },
68 | body: '{"name":"My App"}',
69 | });
70 | });
71 |
72 | it('delete', async () => {
73 | global.fetch = jest.fn().mockResolvedValue({
74 | status: 204,
75 | });
76 |
77 | await api.deleteCollection('123', '"2hhaswzbz72p8"');
78 |
79 | expect(global.fetch).toHaveBeenCalledWith('/collections/123', {
80 | method: 'DELETE',
81 | headers: {
82 | 'X-Requested-With': 'XMLHttpRequest',
83 | 'If-Match': '"2hhaswzbz72p8"',
84 | },
85 | });
86 | });
87 | });
88 |
--------------------------------------------------------------------------------
/ui/features/variables/variables-components.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {MemoryRouter as Router} from 'react-router-dom';
3 | import {render, screen} from '@testing-library/react';
4 |
5 | import {VariableList, GroupRow, ItemRow} from './variables-components';
6 |
7 | describe('variables list component', () => {
8 | it('renders empty list', () => {
9 | const collections = [];
10 | const variables = [];
11 |
12 | const {container} = render(
13 | ,
14 | );
15 |
16 | expect(container.querySelector('tbody')).toBeEmptyDOMElement();
17 | });
18 |
19 | it('renders items', () => {
20 | const collections = [
21 | {
22 | id: '65ada2f9',
23 | name: 'My App #1',
24 | state: 'enabled',
25 | },
26 | {
27 | id: '340de3dd',
28 | name: 'My App #2',
29 | state: 'disabled',
30 | },
31 | {
32 | id: '4502ad33',
33 | name: 'My App #3',
34 | state: 'enabled',
35 | },
36 | ];
37 | const variables = [
38 | {
39 | id: 'ce3457aa',
40 | collectionId: '65ada2f9',
41 | name: 'My Var #1',
42 | },
43 | {
44 | id: '562da233',
45 | collectionId: '340de3dd',
46 | name: 'My Var #2',
47 | },
48 | ];
49 |
50 | render(
51 |
52 |
53 | ,
54 | );
55 |
56 | expect(screen.getByText('My Var #1')).toBeVisible();
57 | expect(screen.getByText('My Var #2')).toBeVisible();
58 | });
59 | });
60 |
61 | describe('variables group row component', () => {
62 | it('renders collection name', () => {
63 | const c = {name: 'My App #1'};
64 |
65 | render(
66 |
67 |
72 | ,
73 | );
74 |
75 | expect(screen.getByText('My App #1')).toBeVisible();
76 | });
77 | });
78 |
79 | describe('variables item row component', () => {
80 | it('renders variable name', () => {
81 | const v = {name: 'My Var'};
82 |
83 | render(
84 |
85 |
90 | ,
91 | );
92 |
93 | expect(screen.getByText('My Var')).toBeVisible();
94 | });
95 | });
96 |
--------------------------------------------------------------------------------
/ui/features/jobs/jobs-components.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Link} from 'react-router-dom';
3 | import {Table} from 'react-bootstrap';
4 |
5 | import {GroupByList} from '../../shared/components';
6 |
7 | export const JobList = ({jobs, collections}) => (
8 |
9 |
10 |
11 | | Name |
12 | Schedule |
13 | Status |
14 |
15 |
16 |
17 | }
22 | itemRow={(i) => }
23 | />
24 |
25 |
26 | );
27 |
28 | export const GroupRow = ({collection}) => (
29 |
30 | |
31 |
35 | {collection.name}
36 |
37 |
38 | variables
39 |
40 | |
41 |
42 | );
43 |
44 | export const ItemRow = ({job}) => (
45 |
46 | |
47 |
51 | {job.name}
52 |
53 | |
54 |
58 | {job.schedule}
59 | |
60 |
61 |
62 | |
63 |
64 | );
65 |
66 | export const JobStatus = ({job}) => {
67 | let style = 'secondary';
68 | let text = job.status;
69 | switch (job.status) {
70 | case 'ready':
71 | style = 'warning';
72 | break;
73 | case 'passing':
74 | style = 'success';
75 | text = `${Math.round((1 - (job.errorRate || 0)) * 100)}% ${text}`;
76 | break;
77 | case 'failing':
78 | style = 'danger';
79 | text = `${Math.round((job.errorRate || 0) * 100)}% ${text}`;
80 | break;
81 | case 'running':
82 | style = 'info';
83 | break;
84 | }
85 | if (job.state === 'disabled') {
86 | style = 'secondary fw-normal';
87 | }
88 | return {text};
89 | };
90 |
--------------------------------------------------------------------------------
/infrastructure/http/runner_test.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | import (
4 | "context"
5 | "net"
6 | "net/http"
7 | "net/http/httptest"
8 | "reflect"
9 | "testing"
10 |
11 | "github.com/akornatskyy/scheduler/domain"
12 | )
13 |
14 | func TestRun(t *testing.T) {
15 | type tc struct {
16 | Req *domain.HttpRequest
17 | Expected *http.Request
18 | }
19 | var testcases = []tc{
20 | {
21 | Req: &domain.HttpRequest{
22 | URI: "http://127.0.0.1:8000/test",
23 | Headers: []*domain.NameValuePair{
24 | {
25 | Name: "X-Requested-With",
26 | Value: "XMLHttpRequest",
27 | },
28 | },
29 | },
30 | Expected: &http.Request{
31 | Method: "GET",
32 | Host: "127.0.0.1:8000",
33 | RequestURI: "/test",
34 | Header: http.Header{
35 | "Accept-Encoding": []string{"gzip"},
36 | "User-Agent": []string{userAgent},
37 | "X-Requested-With": []string{"XMLHttpRequest"},
38 | },
39 | },
40 | },
41 | }
42 | for _, tt := range testcases {
43 | client, teardown := setupClient(func(w http.ResponseWriter, r *http.Request) {
44 | if r.Method != tt.Expected.Method {
45 | t.Errorf(
46 | "method, got: %s, expected: %s",
47 | r.Method,
48 | tt.Expected.Method)
49 | }
50 | if r.Host != tt.Expected.Host {
51 | t.Errorf(
52 | "Host, got: %s, expected: %s",
53 | r.Host,
54 | tt.Expected.Host)
55 | }
56 | if r.RequestURI != tt.Expected.RequestURI {
57 | t.Errorf(
58 | "RequestURI, got: %s, expected: %s",
59 | r.RequestURI,
60 | tt.Expected.RequestURI)
61 | }
62 | if (len(r.Header) != 0 || len(tt.Expected.Header) != 0) &&
63 | !reflect.DeepEqual(r.Header, tt.Expected.Header) {
64 | t.Errorf(
65 | "headers, %d %d got: %v, expected: %v",
66 | len(r.Header), len(tt.Expected.Header),
67 | r.Header,
68 | tt.Expected.Header)
69 | }
70 | })
71 | defer teardown()
72 | runner := &httpRunner{
73 | client: client,
74 | }
75 |
76 | ctx := context.Background()
77 | err := runner.Run(ctx, &domain.Action{
78 | Request: tt.Req,
79 | })
80 |
81 | if err != nil {
82 | t.Fatal(err)
83 | }
84 | }
85 | }
86 |
87 | func setupClient(handler http.HandlerFunc) (*http.Client, func()) {
88 | s := httptest.NewServer(http.HandlerFunc(handler))
89 | c := &http.Client{
90 | Transport: &http.Transport{
91 | DialContext: func(_ context.Context, network, _ string) (net.Conn, error) {
92 | return net.Dial(network, s.Listener.Addr().String())
93 | },
94 | },
95 | }
96 | return c, s.Close
97 | }
98 |
--------------------------------------------------------------------------------
/core/subscription.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "log"
5 | "time"
6 |
7 | "github.com/akornatskyy/scheduler/domain"
8 | )
9 |
10 | func (s *Service) OnUpdateEvent(m *domain.UpdateEvent) error {
11 | log.Printf("event: %v", m)
12 | switch m.ObjectType {
13 | case "collection":
14 | switch m.Operation {
15 | case "UPDATE":
16 | return s.onCollectionUpdate(m.ObjectID)
17 | }
18 | case "job":
19 | switch m.Operation {
20 | case "INSERT":
21 | return s.onJobInsert(m.ObjectID)
22 | case "UPDATE":
23 | return s.onJobUpdate(m.ObjectID)
24 | case "DELETE":
25 | s.Scheduler.Remove(m.ObjectID)
26 | }
27 | case "connection":
28 | switch m.Operation {
29 | case "connected", "reconnected":
30 | for i := 1; i <= 5; i++ {
31 | // might fail in case reconnected
32 | if err := s.Repository.Ping(); err != nil {
33 | log.Printf("ping attempt %d failed, %s", i, err)
34 | time.Sleep(time.Second)
35 | } else {
36 | break
37 | }
38 | }
39 | return s.scheduleJobs()
40 | }
41 | }
42 | return nil
43 | }
44 |
45 | func (s *Service) onCollectionUpdate(id string) error {
46 | c, err := s.Repository.RetrieveCollection(id)
47 | if err != nil {
48 | return err
49 | }
50 | jobs, err := s.Repository.ListJobs(c.ID, []string{})
51 | if err != nil {
52 | return err
53 | }
54 | for _, j := range jobs {
55 | if c.State != domain.CollectionStateEnabled {
56 | s.Scheduler.Remove(j.ID)
57 | } else {
58 | j, err := s.Repository.RetrieveJob(j.ID)
59 | if err != nil {
60 | return err
61 | }
62 | if j.State == domain.JobStateEnabled {
63 | s.Scheduler.Add(j)
64 | }
65 | }
66 | }
67 | return nil
68 | }
69 |
70 | func (s *Service) onJobInsert(id string) error {
71 | j, err := s.Repository.RetrieveJob(id)
72 | if err != nil {
73 | return err
74 | }
75 | if j.State == domain.JobStateEnabled {
76 | c, err := s.Repository.RetrieveCollection(j.CollectionID)
77 | if err != nil {
78 | return err
79 | }
80 | if c.State == domain.CollectionStateEnabled {
81 | s.Scheduler.Add(j)
82 | }
83 | }
84 | return nil
85 | }
86 |
87 | func (s *Service) onJobUpdate(id string) error {
88 | j, err := s.Repository.RetrieveJob(id)
89 | if err != nil {
90 | return err
91 | }
92 | s.Scheduler.Remove(id)
93 | if j.State == domain.JobStateEnabled {
94 | c, err := s.Repository.RetrieveCollection(j.CollectionID)
95 | if err != nil {
96 | return err
97 | }
98 | if c.State == domain.CollectionStateEnabled {
99 | s.Scheduler.Add(j)
100 | }
101 | }
102 | return nil
103 | }
104 |
--------------------------------------------------------------------------------
/ui/features/variable/variable.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import {Layout} from '../../shared/components';
4 | import * as api from './variable-api';
5 | import {VariableForm} from './variable-components';
6 |
7 | export default class Variable extends React.Component {
8 | state = {
9 | item: {
10 | name: '',
11 | value: '',
12 | },
13 | collections: [],
14 | pending: true,
15 | errors: {},
16 | };
17 |
18 | componentDidMount() {
19 | const {id} = this.props.match.params;
20 | if (id) {
21 | api
22 | .retrieveVariable(id)
23 | .then((data) => this.setState({item: data, pending: false}))
24 | .catch((errors) => this.setState({errors, pending: false}));
25 | } else {
26 | this.setState({item: {name: '', value: ''}, pending: false});
27 | }
28 | api
29 | .listCollections()
30 | .then(({items}) =>
31 | this.setState(({item}) => {
32 | const s = {collections: items};
33 | if (!item.collectionId) {
34 | if (items.length > 0) {
35 | s.item = {
36 | ...item,
37 | collectionId: items[0].id,
38 | };
39 | } else {
40 | s.errors = {
41 | collectionId: 'There is no collection available.',
42 | };
43 | }
44 | }
45 |
46 | return s;
47 | }),
48 | )
49 | .catch((errors) => this.setState({errors}));
50 | }
51 |
52 | handleChange = (name, value) => {
53 | this.setState({
54 | item: {...this.state.item, [name]: value},
55 | });
56 | };
57 |
58 | handleSave = () => {
59 | this.setState({pending: true});
60 | api
61 | .saveVariable(this.state.item)
62 | .then(() => this.props.history.goBack())
63 | .catch((errors) => this.setState({errors, pending: false}));
64 | };
65 |
66 | handleDelete = () => {
67 | const {id, etag} = this.state.item;
68 | this.setState({pending: true});
69 | api
70 | .deleteVariable(id, etag)
71 | .then(() => this.props.history.goBack())
72 | .catch((errors) => this.setState({errors, pending: false}));
73 | };
74 |
75 | render() {
76 | const {item, collections, pending, errors} = this.state;
77 | return (
78 |
79 |
88 |
89 | );
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/core/runner.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "context"
5 | "log"
6 | "time"
7 |
8 | "github.com/akornatskyy/scheduler/domain"
9 | )
10 |
11 | func (s *Service) OnRunJob(j *domain.JobDefinition) {
12 | log.Printf("attempting to run job %s", j.ID)
13 | a := j.Action
14 | runner := s.Runners[a.Type]
15 | if runner == nil {
16 | log.Printf("unsupported action type: %s", a.Type)
17 | return
18 | }
19 |
20 | p := a.RetryPolicy
21 | if p == nil {
22 | p = domain.DefaultRetryPolicy
23 | }
24 |
25 | err := s.Repository.AcquireJob(j.ID, time.Duration(p.Deadline))
26 | if err != nil {
27 | log.Printf("WARN: acquire job %s: %s", j.ID, err)
28 | return
29 | }
30 |
31 | jh := &domain.JobHistory{
32 | JobID: j.ID,
33 | Action: a.Type,
34 | Started: time.Now().UTC(),
35 | }
36 |
37 | attempt := 0
38 |
39 | a, err = s.transposeAction(j)
40 | if err == nil {
41 | ctx, cancel := context.WithTimeout(s.ctx, time.Duration(p.Deadline))
42 | defer cancel()
43 |
44 | loop:
45 | for {
46 | err = runner.Run(ctx, a)
47 | if err == nil || attempt == p.RetryCount {
48 | break
49 | }
50 | if re, ok := err.(*domain.RunError); ok {
51 | switch re.Code {
52 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#Client_error_responses
53 | // TODO: add all client error responses (4XX) as unrecoverable?
54 | case 400, 401, 403, 404, 422:
55 | break loop
56 | }
57 | }
58 | select {
59 | case <-ctx.Done():
60 | err = ctx.Err()
61 | if err != nil {
62 | break loop
63 | }
64 | case <-time.After(time.Duration(p.RetryInterval)):
65 | }
66 | attempt++
67 | }
68 | }
69 |
70 | jh.Finished = time.Now().UTC()
71 | jh.RetryCount = attempt
72 | if err != nil {
73 | jh.Status = domain.JobHistoryStatusFailed
74 | msg := err.Error()
75 | jh.Message = &msg
76 | } else {
77 | jh.Status = domain.JobHistoryStatusCompleted
78 | }
79 |
80 | log.Printf("job %s: %s", j.ID, jh.Status)
81 | if err = s.Repository.AddJobHistory(jh); err != nil {
82 | log.Printf("ERR: job %s: %s", j.ID, err)
83 | }
84 | }
85 |
86 | func (s *Service) transposeAction(j *domain.JobDefinition) (*domain.Action, error) {
87 | variables, err := s.mapVariables(j.CollectionID)
88 | if err != nil {
89 | return nil, err
90 | }
91 | variables["CollectionID"] = j.CollectionID
92 | variables["JobID"] = j.ID
93 | a := j.Action
94 | req, err := a.Request.Transpose(variables)
95 | if err != nil {
96 | return nil, err
97 | }
98 | return &domain.Action{
99 | Type: a.Type,
100 | Request: req,
101 | RetryPolicy: a.RetryPolicy,
102 | }, nil
103 | }
104 |
--------------------------------------------------------------------------------
/infrastructure/postgres/subscriber.go:
--------------------------------------------------------------------------------
1 | package postgres
2 |
3 | import (
4 | "log"
5 | "strings"
6 | "time"
7 |
8 | "github.com/akornatskyy/scheduler/domain"
9 | "github.com/lib/pq"
10 | )
11 |
12 | const (
13 | chTableUpdate = "table_update"
14 | )
15 |
16 | type sqlSubscriber struct {
17 | callback domain.UpdateEventCallback
18 | listener *pq.Listener
19 | done chan struct{}
20 | }
21 |
22 | func NewSubscriber(dsn string) domain.Subscriber {
23 | minReconn := 5 * time.Second
24 | maxReconn := time.Minute
25 |
26 | s := &sqlSubscriber{
27 | done: make(chan struct{}, 1),
28 | }
29 | s.listener = pq.NewListener(dsn, minReconn, maxReconn, s.onListenerEvent)
30 | return s
31 | }
32 |
33 | func (s *sqlSubscriber) SetCallback(callback domain.UpdateEventCallback) {
34 | s.callback = callback
35 | }
36 |
37 | func (s *sqlSubscriber) Start() {
38 | go s.waitForEvents()
39 | if err := s.listener.Listen(chTableUpdate); err != nil {
40 | panic(err)
41 | }
42 | }
43 |
44 | func (s *sqlSubscriber) Stop() {
45 | s.done <- struct{}{}
46 | }
47 |
48 | func (s *sqlSubscriber) onListenerEvent(ev pq.ListenerEventType, err error) {
49 | switch ev {
50 | case pq.ListenerEventConnected:
51 | if err := s.callback(domain.Connected); err != nil {
52 | log.Printf("subscriber connected: %s", err)
53 | }
54 | case pq.ListenerEventDisconnected:
55 | if err := s.callback(domain.Disconnected); err != nil {
56 | log.Printf("subscriber disconnected: %s", err)
57 | }
58 | case pq.ListenerEventReconnected:
59 | if err := s.callback(domain.Reconnected); err != nil {
60 | log.Printf("subscriber reconnected: %s", err)
61 | }
62 | case pq.ListenerEventConnectionAttemptFailed:
63 | log.Printf("subscriber connection attempt failed, %s", err)
64 | }
65 | }
66 |
67 | func (s *sqlSubscriber) waitForEvents() {
68 | log.Printf("subscriber started")
69 | var e domain.UpdateEvent
70 | loop:
71 | for {
72 | select {
73 | case n := <-s.listener.Notify:
74 | if n == nil {
75 | continue
76 | }
77 |
78 | switch n.Channel {
79 | case chTableUpdate:
80 | fields := strings.Fields(n.Extra)
81 | e.Operation = fields[0]
82 | e.ObjectType = fields[1]
83 | e.ObjectID = fields[2]
84 | if err := s.callback(&e); err != nil {
85 | log.Printf("subscriber waiting for events: %s", err)
86 | }
87 | }
88 | case <-time.After(1 * time.Minute):
89 | s.listener.Ping()
90 | case <-s.done:
91 | break loop
92 | }
93 | }
94 | s.close()
95 | log.Printf("subscriber stopped")
96 | }
97 |
98 | func (s *sqlSubscriber) close() error {
99 | s.listener.UnlistenAll()
100 | return s.listener.Close()
101 | }
102 |
--------------------------------------------------------------------------------
/ui/features/history/history-api.test.js:
--------------------------------------------------------------------------------
1 | import * as api from './history-api';
2 |
3 | describe('history api', () => {
4 | afterEach(() => {
5 | global.fetch.mockClear();
6 | delete global.fetch;
7 | });
8 |
9 | describe('job status', () => {
10 | it('retrieve', async () => {
11 | global.fetch = jest.fn().mockResolvedValue({
12 | status: 200,
13 | headers: {get: () => '"2hhaswzbz72p8"'},
14 | json: () => Promise.resolve({running: false}),
15 | });
16 |
17 | const d = await api.retrieveJobStatus('123');
18 |
19 | expect(d).toEqual({
20 | etag: '"2hhaswzbz72p8"',
21 | running: false,
22 | });
23 | expect(global.fetch).toHaveBeenCalledWith('/jobs/123/status', {
24 | method: 'GET',
25 | headers: {
26 | 'X-Requested-With': 'XMLHttpRequest',
27 | },
28 | });
29 | });
30 |
31 | it('patch', async () => {
32 | global.fetch = jest.fn().mockResolvedValue({
33 | status: 204,
34 | });
35 |
36 | await api.patchJobStatus('123', {
37 | id: '1234',
38 | etag: '"2hhaswzbz72p8"',
39 | updated: '2019-08-29T13:29:36.976Z',
40 | running: true,
41 | });
42 |
43 | expect(global.fetch).toHaveBeenCalledWith('/jobs/123/status', {
44 | method: 'PATCH',
45 | headers: {
46 | 'X-Requested-With': 'XMLHttpRequest',
47 | 'Content-Type': 'application/json',
48 | 'If-Match': '"2hhaswzbz72p8"',
49 | },
50 | body: '{"running":true}',
51 | });
52 | });
53 | });
54 |
55 | describe('job history', () => {
56 | it('list job history', async () => {
57 | global.fetch = jest.fn().mockResolvedValue({
58 | status: 200,
59 | headers: {get: () => '"2hhaswzbz72p8"'},
60 | json: () => Promise.resolve({items: []}),
61 | });
62 |
63 | const d = await api.listJobHistory('123');
64 |
65 | expect(d).toEqual({
66 | etag: '"2hhaswzbz72p8"',
67 | items: [],
68 | });
69 | expect(global.fetch).toHaveBeenCalledWith('/jobs/123/history', {
70 | method: 'GET',
71 | headers: {
72 | 'X-Requested-With': 'XMLHttpRequest',
73 | },
74 | });
75 | });
76 |
77 | it('delete job history', async () => {
78 | global.fetch = jest.fn().mockResolvedValue({
79 | status: 204,
80 | });
81 |
82 | await api.deleteJobHistory('123', '"2hhaswzbz72p8"');
83 |
84 | expect(global.fetch).toHaveBeenCalledWith('/jobs/123/history', {
85 | method: 'DELETE',
86 | headers: {
87 | 'X-Requested-With': 'XMLHttpRequest',
88 | 'If-Match': '"2hhaswzbz72p8"',
89 | },
90 | });
91 | });
92 | });
93 | });
94 |
--------------------------------------------------------------------------------
/ui/features/variable/variable-components.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import {Form, Button, Col, Row} from 'react-bootstrap';
3 |
4 | import {FieldError, Tip} from '../../shared/components';
5 |
6 | export const VariableForm = ({
7 | item,
8 | collections,
9 | pending,
10 | errors,
11 | onChange,
12 | onSave,
13 | onDelete,
14 | }) => {
15 | const handleChange = ({target: {name, value}}) => onChange?.(name, value);
16 | return (
17 |
90 | );
91 | };
92 |
--------------------------------------------------------------------------------
/core/jobs.go:
--------------------------------------------------------------------------------
1 | package core
2 |
3 | import (
4 | "log"
5 |
6 | "github.com/akornatskyy/scheduler/domain"
7 | )
8 |
9 | func (s *Service) ListJobs(collectionId string, fields []string) ([]*domain.JobItem, error) {
10 | if collectionId != "" {
11 | if err := domain.ValidateID(collectionId); err != nil {
12 | return nil, err
13 | }
14 | }
15 | if err := domain.ValidateJobListFields(fields); err != nil {
16 | return nil, err
17 | }
18 | return s.Repository.ListJobs(collectionId, fields)
19 | }
20 |
21 | func (s *Service) CreateJob(job *domain.JobDefinition) error {
22 | if err := s.validateJobDefinition(job); err != nil {
23 | return err
24 | }
25 | if job.ID == "" {
26 | job.ID = domain.NewID()
27 | }
28 | return s.Repository.CreateJob(job)
29 | }
30 |
31 | func (s *Service) RetrieveJob(id string) (*domain.JobDefinition, error) {
32 | if err := domain.ValidateID(id); err != nil {
33 | return nil, err
34 | }
35 | return s.Repository.RetrieveJob(id)
36 | }
37 |
38 | func (s *Service) UpdateJob(job *domain.JobDefinition) error {
39 | if err := s.validateJobDefinition(job); err != nil {
40 | return err
41 | }
42 | return s.Repository.UpdateJob(job)
43 | }
44 |
45 | func (s *Service) DeleteJob(id string) error {
46 | if err := domain.ValidateID(id); err != nil {
47 | return err
48 | }
49 | return s.Repository.DeleteJob(id)
50 | }
51 |
52 | func (s *Service) RetrieveJobStatus(id string) (*domain.JobStatus, error) {
53 | if err := domain.ValidateID(id); err != nil {
54 | return nil, err
55 | }
56 | j, err := s.Repository.RetrieveJobStatus(id)
57 | if err != nil {
58 | return nil, err
59 | }
60 | j.NextRun = s.Scheduler.NextRun(id)
61 | return j, nil
62 | }
63 |
64 | func (s *Service) RunJob(id string) error {
65 | if err := domain.ValidateID(id); err != nil {
66 | return err
67 | }
68 | job, err := s.Repository.RetrieveJob(id)
69 | if err != nil {
70 | return err
71 | }
72 |
73 | go s.OnRunJob(job)
74 | return nil
75 | }
76 |
77 | func (s *Service) validateJobDefinition(job *domain.JobDefinition) error {
78 | if err := domain.ValidateJobDefinition(job); err != nil {
79 | return err
80 | }
81 | variables, err := s.mapVariables(job.CollectionID)
82 | if err != nil {
83 | return err
84 | }
85 | req, err := job.Action.Request.Transpose(variables)
86 | if err != nil {
87 | return err
88 | }
89 | if err := domain.ValidateURI(req.URI); err != nil {
90 | return err
91 | }
92 | return nil
93 | }
94 |
95 | func (s *Service) resetLeftOverJobs() {
96 | jobs, err := s.Repository.ListLeftOverJobs()
97 | if err != nil {
98 | log.Printf("WARN: failed to list left over jobs: %s", err)
99 | return
100 | }
101 | for _, id := range jobs {
102 | log.Printf("reset job status for: %s", id)
103 | s.Repository.ResetJobStatus(id)
104 | }
105 | }
106 |
--------------------------------------------------------------------------------
/domain/validation_test.go:
--------------------------------------------------------------------------------
1 | package domain
2 |
3 | import (
4 | "encoding/json"
5 | "reflect"
6 | "testing"
7 | "time"
8 |
9 | "github.com/akornatskyy/goext/errorstate"
10 | "github.com/akornatskyy/goext/iojson"
11 | )
12 |
13 | func TestParseBefore(t *testing.T) {
14 | expected := time.Now().Truncate(time.Second)
15 | sample := expected.Format(time.RFC3339)
16 |
17 | actual, _ := ParseBefore(sample)
18 |
19 | if actual.Format(time.RFC3339) != sample {
20 | t.Errorf("ParseBefore() got: %s, expected: %s", actual, expected)
21 | }
22 | }
23 |
24 | func TestParseBeforeFails(t *testing.T) {
25 | var testcases = []string{
26 | `x`, `123`,
27 | }
28 | for _, tt := range testcases {
29 | _, err := ParseBefore(tt)
30 | if err == nil {
31 | t.FailNow()
32 | }
33 | }
34 | }
35 |
36 | func TestValidateID(t *testing.T) {
37 | var testcases = []string{
38 | "", "Xx-", "X-X", "z_-", NewID(),
39 | }
40 | for _, tt := range testcases {
41 | err := ValidateID(tt)
42 | if err != nil {
43 | t.Fatalf("%s: %s", tt, err)
44 | }
45 | }
46 | }
47 |
48 | func TestValidateIDFails(t *testing.T) {
49 | var testcases = []string{
50 | "1234567890123456789012345678901234567", "12~22", "X",
51 | "<>+", "Я", "_A", "-x",
52 | }
53 | for _, tt := range testcases {
54 | err := ValidateID(tt)
55 | if err == nil {
56 | t.Fatalf("%s", tt)
57 | }
58 | }
59 | }
60 |
61 | func TestValidateCollection(t *testing.T) {
62 | var testcases = []string{
63 | `ok`, `invalid`,
64 | }
65 | for _, tt := range testcases {
66 | t.Run(tt, func(t *testing.T) {
67 | var in struct {
68 | C *Collection `json:"collection"`
69 | Err *errorstate.ErrorState `json:"err,omitempty"`
70 | }
71 | iojson.ReadFile("testdata/validation/collection/"+tt+".json", &in)
72 |
73 | err := ValidateCollection(in.C)
74 | if !sameError(err, in.Err) {
75 | t.FailNow()
76 | }
77 | })
78 | }
79 | }
80 |
81 | func TestValidateJobDefinition(t *testing.T) {
82 | var testcases = []string{
83 | `ok`, `invalid`, `request-null`, // `invalid-uri`, `uri-not-http`,
84 | }
85 | for _, tt := range testcases {
86 | t.Run(tt, func(t *testing.T) {
87 | var in struct {
88 | J *JobDefinition `json:"job"`
89 | Err *errorstate.ErrorState `json:"err,omitempty"`
90 | }
91 | iojson.ReadFile("testdata/validation/job/"+tt+".json", &in)
92 |
93 | err := ValidateJobDefinition(in.J)
94 | if !sameError(err, in.Err) {
95 | b, _ := json.Marshal(err)
96 | t.Errorf("ValidateJobDefinition() got err: %s", string(b))
97 | }
98 | })
99 | }
100 | }
101 |
102 | func sameError(actual error, expected *errorstate.ErrorState) bool {
103 | if actual == nil {
104 | return expected == nil
105 | }
106 | details := actual.(*errorstate.ErrorState).Errors
107 | return reflect.DeepEqual(details, expected.Errors)
108 | }
109 |
--------------------------------------------------------------------------------
/ui/features/variables/variables.test.js:
--------------------------------------------------------------------------------
1 | import {act, render, screen} from '@testing-library/react';
2 | import React from 'react';
3 | import {MemoryRouter as Router} from 'react-router-dom';
4 |
5 | import Variables from './variables';
6 | import * as api from './variables-api';
7 |
8 | jest.mock('./variables-api');
9 |
10 | describe('variables', () => {
11 | const props = {
12 | location: {},
13 | };
14 |
15 | beforeEach(() => {
16 | jest.clearAllMocks();
17 | });
18 |
19 | it('handles list collections error', async () => {
20 | const errors = {__ERROR__: 'The error text.'};
21 | api.listCollections.mockRejectedValue(errors);
22 | api.listVariables.mockResolvedValue({items: []});
23 |
24 | await act(async () => {
25 | render(
26 |
27 |
28 | ,
29 | );
30 | });
31 |
32 | expect(api.listCollections).toHaveBeenCalledTimes(1);
33 | expect(api.listCollections).toHaveBeenCalledWith();
34 | expect(api.listVariables).toHaveBeenCalledTimes(1);
35 | expect(api.listVariables).toHaveBeenCalledWith(null);
36 | expect(screen.getByText(errors.__ERROR__)).toBeVisible();
37 | });
38 |
39 | it('handles list variables error', async () => {
40 | const errors = {__ERROR__: 'The error text.'};
41 | api.listCollections.mockResolvedValue({items: []});
42 | api.listVariables.mockRejectedValue(errors);
43 |
44 | await act(async () => {
45 | render(
46 |
47 |
48 | ,
49 | );
50 | });
51 |
52 | expect(api.listCollections).toHaveBeenCalledTimes(1);
53 | expect(api.listCollections).toHaveBeenCalledWith();
54 | expect(api.listVariables).toHaveBeenCalledTimes(1);
55 | expect(api.listVariables).toHaveBeenCalledWith(null);
56 | expect(screen.getByText(errors.__ERROR__)).toBeVisible();
57 | });
58 |
59 | it('updates state with fetched items', async () => {
60 | api.listCollections.mockResolvedValue({
61 | items: [
62 | {
63 | id: '65ada2f9',
64 | name: 'My App',
65 | },
66 | ],
67 | });
68 | api.listVariables.mockResolvedValue({
69 | items: [
70 | {
71 | id: 'c23abe44',
72 | collectionId: '65ada2f9',
73 | name: 'My Var',
74 | },
75 | ],
76 | });
77 |
78 | await act(async () => {
79 | render(
80 |
81 |
82 | ,
83 | );
84 | });
85 |
86 | expect(api.listCollections).toHaveBeenCalledTimes(1);
87 | expect(api.listCollections).toHaveBeenCalledWith();
88 | expect(api.listVariables).toHaveBeenCalledTimes(1);
89 | expect(api.listVariables).toHaveBeenCalledWith('65ada2f9');
90 | expect(screen.getByText('My App')).toBeVisible();
91 | expect(screen.getByText('My Var')).toBeVisible();
92 | });
93 | });
94 |
--------------------------------------------------------------------------------