├── .editorconfig ├── .env.example ├── .gitignore ├── .nowignore ├── .prettierrc ├── .travis.yml ├── docs.md ├── media ├── cron-syntax.png ├── docs │ ├── create-new-job-2.png │ ├── create-new-job.png │ ├── dashboard.png │ ├── error-creating-job.png │ ├── job-detail.png │ ├── job-logs-detail.png │ └── job-logs.png ├── favicon.ico ├── icons │ ├── api.svg │ ├── cluster.svg │ ├── customizable.svg │ ├── global.svg │ ├── logs.svg │ ├── notifications.svg │ ├── open-source.svg │ ├── setup.svg │ └── stripe.svg └── logo.svg ├── notes.md ├── now.json ├── package.json ├── readme.md ├── saasify.json ├── src ├── billing.ts ├── bootstrap.ts ├── db.ts ├── format-date.ts ├── grpc-utils.ts ├── jobs.ts ├── logs.ts ├── monitoring.ts ├── notification-channels │ ├── email.ts │ └── slack.ts ├── notifications.ts ├── routes.ts ├── scheduler.ts ├── server.ts └── types.ts ├── tsconfig.json ├── tsoa.json ├── web ├── .gitignore ├── config-overrides.js ├── package.json ├── public │ ├── favicon.ico │ ├── iframeResizer.contentWindow.min.js │ ├── index.html │ └── robots.txt ├── readme.md ├── src │ ├── App.css │ ├── App.js │ ├── components │ │ ├── JobLogsTable │ │ │ ├── JobLogsTable.js │ │ │ └── styles.module.css │ │ ├── JobsTable │ │ │ ├── JobsTable.js │ │ │ └── styles.module.css │ │ ├── NewJobForm │ │ │ ├── NewJobForm.js │ │ │ └── styles.module.css │ │ ├── Paper │ │ │ ├── Paper.js │ │ │ └── styles.module.css │ │ ├── RemoveJobModal │ │ │ └── RemoveJobModal.js │ │ └── index.js │ ├── index.js │ ├── lib │ │ └── sdk.js │ └── styles │ │ ├── app.module.css │ │ └── global.css └── yarn.lock └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | indent_style = space 4 | indent_size = 2 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------------ 2 | # This is an example .env file. 3 | # 4 | # All of these environment vars must be defined either in your environment or in 5 | # a local .env file in order to run this app. 6 | # 7 | # @see https://github.com/rolodato/dotenv-safe 8 | # ------------------------------------------------------------------------------ 9 | 10 | GOOGLE_APPLICATION_CREDENTIALS= 11 | 12 | GOOGLE_PROJECT_ID= 13 | GOOGLE_PROJECT_LOCATION= 14 | 15 | # Optionally used for usage reporting to Saasify 16 | #SAASIFY_PROVIDER_TOKEN= 17 | 18 | # Optionally used for sending email notifications 19 | #MAILGUN_API_KEY= 20 | #MAILGUN_DOMAIN= 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | .dummy 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # Bower dependency directory (https://bower.io/) 28 | bower_components 29 | 30 | # node-waf configuration 31 | .lock-wscript 32 | 33 | # Compiled binary addons (http://nodejs.org/api/addons.html) 34 | build/Release 35 | 36 | # Dependency directories 37 | node_modules/ 38 | jspm_packages/ 39 | 40 | # Typescript v1 declaration files 41 | typings/ 42 | 43 | # Optional npm cache directory 44 | .npm 45 | 46 | # Optional eslint cache 47 | .eslintcache 48 | 49 | # Optional REPL history 50 | .node_repl_history 51 | 52 | # Output of 'npm pack' 53 | *.tgz 54 | 55 | # Yarn Integrity file 56 | .yarn-integrity 57 | 58 | # dotenv environment variables file 59 | .env 60 | 61 | .next/ 62 | 63 | dist/ 64 | build/ 65 | 66 | .now 67 | .google.json 68 | -------------------------------------------------------------------------------- /.nowignore: -------------------------------------------------------------------------------- 1 | web/ 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxSingleQuote": true, 4 | "semi": false, 5 | "tabWidth": 2, 6 | "bracketSpacing": true, 7 | "jsxBracketSameLine": false, 8 | "arrowParens": "always", 9 | "trailingComma": "none" 10 | } 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | cache: yarn 3 | node_js: 4 | - 12 5 | -------------------------------------------------------------------------------- /docs.md: -------------------------------------------------------------------------------- 1 | # Simple Cron 2 | 3 | Simple Cron is a dead simple cloud service to make HTTP calls on a regular schedule. It is named after the classic UNIX Cron program which allowed you to schedule jobs to be run locally. 4 | 5 | Some common use cases for cron jobs include: 6 | 7 | - Monitoring uptime of websites or services 8 | - Sending out reports or emails once a week 9 | - Kicking off a backup process once a day 10 | - Running workflows on a regular schedule 11 | - Powering bots to automate social media 12 | 13 | ## Dashboard 14 | 15 |

16 | Simple Cron Dashboard 17 |

18 | 19 | Once you sign in, the dashboard lists all of your currently enabled and paused cron jobs. 20 | 21 | For each job, the dashboard lists metadata, current status, the result of the job's last run, and gives you options to manage the job. 22 | 23 | You can pause, resume, and permanently delete jobs. You can also view detailed information about a job including the result of its last run and logs for all past runs. 24 | 25 | ## Creating New Jobs 26 | 27 |

28 | Create new job dialog 29 |

30 | 31 | From the dashboard, select `Add New Job` to add your first cloud cron job for free. 32 | 33 | You can customize a cron job with the following options: 34 | 35 | - `Name` - A short recognizable name describing this job. 36 | - `Schedule` - A standard cron syntax expression specifying the schedule for this job. (See below for more info on cron syntax) 37 | - `Timezone` - Timezone to run your job's schedule against. 38 | - `HTTP URL` - The URL for this job to target. 39 | - `HTTP Method` - The HTTP method to use when pinging the target `HTTP URL`. All HTTP methods are supported. Defaults to `GET`. 40 | 41 | The following notification channels are available for job failures: 42 | 43 | - `Email` - Email address for notifications related to this job. 44 | - `Slack URL` - Slack webhook URL for notifications related to this job. 45 | - Follow this [guide](https://api.slack.com/tutorials/slack-apps-hello-world) to create a new Slack App with an incoming webhook that will post to a specific workspace and channel. 46 | - Your incoming webhook URL should resemble `https://hooks.slack.com/services/XXX/YYY/ZZZZZZZZ`. 47 | 48 | Upon creating a new job, it will begin executing automatically according to its schedule. If you expect a job to have run, you can refresh the dashboard and view its logs to ensure everything it working properly. 49 | 50 | ## Upgrading 51 | 52 |

53 | Error creating job 54 |

55 | 56 | If you get an error when you try to create a new job, then you've run out of free jobs and will need to [upgrade your account](/pricing) to continue. 57 | 58 | ## Cron Syntax 59 | 60 |

61 | Cron expression syntax 62 |

63 | 64 | Simple Cron uses classic UNIX cron syntax to schedule cloud cron jobs to make HTTP calls on a regular schedule. 65 | 66 | Here are some common examples to get you started. 67 | 68 | | Cron Expression | Explanation | 69 | | --------------- | ---------------------------------- | 70 | | `* * * * *` | Every minute | 71 | | `0 0 * * *` | Daily at midnight | 72 | | `45 23 * * 6` | Every Saturday at 23:45 (11:45 PM) | 73 | | `0 9 * * 1` | Every Monday at 09:00 | 74 | 75 | ## Job Detail 76 | 77 |

78 | Cron job detail 79 |

80 | 81 | From the dashboard, you can click the `+` icon to the left of any job to view its full JSON metadata in detail. 82 | 83 | ## Job Logs 84 | 85 |

86 | Cron job logs 87 |

88 | 89 | From the dashboard, you can click `View Logs` on any job to view the full history of its call logs. 90 | 91 | ## Job Logs Detail 92 | 93 |

94 | Cron job logs detail 95 |

96 | 97 | From the Job Logs screen, you can click the `+` icon to the left of any call log to view its full JSON metadata in detail. 98 | 99 | ## REST API 100 | 101 | In addition to the dashboard UI, all of Simple Cron's functionality is easily accessible via a straightforward REST API. 102 | 103 | Pricing for the API is the same as using the dashboard: 104 | 105 | - You're billed based on the number of cron jobs you use. 106 | - Cron jobs have no limit to the number of executions they're allowed to run. 107 | - You're allowed one free cron job with an unlimited number of executions per month. 108 | 109 | ## Roadmap 110 | 111 | In the near future, we'll be adding support for: 112 | 113 | - Webhook notifications for job failures 114 | - Customizable HTTP bodies for PUT and POST requests 115 | - Customizable HTTP headers 116 | - Customizable retry logic 117 | - Customizable success / fail logic 118 | - More control over notifications 119 | - Job analytics over time 120 | 121 | Have a use case or feature request not listed here? Please don't hesitate to [email us](mailto:support@saasify.sh). 122 | -------------------------------------------------------------------------------- /media/cron-syntax.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saasify-sh/simple-cron/81c74d1497e5faae159c0982935c5fc37cfd14de/media/cron-syntax.png -------------------------------------------------------------------------------- /media/docs/create-new-job-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saasify-sh/simple-cron/81c74d1497e5faae159c0982935c5fc37cfd14de/media/docs/create-new-job-2.png -------------------------------------------------------------------------------- /media/docs/create-new-job.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saasify-sh/simple-cron/81c74d1497e5faae159c0982935c5fc37cfd14de/media/docs/create-new-job.png -------------------------------------------------------------------------------- /media/docs/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saasify-sh/simple-cron/81c74d1497e5faae159c0982935c5fc37cfd14de/media/docs/dashboard.png -------------------------------------------------------------------------------- /media/docs/error-creating-job.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saasify-sh/simple-cron/81c74d1497e5faae159c0982935c5fc37cfd14de/media/docs/error-creating-job.png -------------------------------------------------------------------------------- /media/docs/job-detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saasify-sh/simple-cron/81c74d1497e5faae159c0982935c5fc37cfd14de/media/docs/job-detail.png -------------------------------------------------------------------------------- /media/docs/job-logs-detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saasify-sh/simple-cron/81c74d1497e5faae159c0982935c5fc37cfd14de/media/docs/job-logs-detail.png -------------------------------------------------------------------------------- /media/docs/job-logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saasify-sh/simple-cron/81c74d1497e5faae159c0982935c5fc37cfd14de/media/docs/job-logs.png -------------------------------------------------------------------------------- /media/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saasify-sh/simple-cron/81c74d1497e5faae159c0982935c5fc37cfd14de/media/favicon.ico -------------------------------------------------------------------------------- /media/icons/api.svg: -------------------------------------------------------------------------------- 1 | code typing -------------------------------------------------------------------------------- /media/icons/cluster.svg: -------------------------------------------------------------------------------- 1 | server_cluster -------------------------------------------------------------------------------- /media/icons/customizable.svg: -------------------------------------------------------------------------------- 1 | control panel1 -------------------------------------------------------------------------------- /media/icons/notifications.svg: -------------------------------------------------------------------------------- 1 | message sent -------------------------------------------------------------------------------- /media/icons/open-source.svg: -------------------------------------------------------------------------------- 1 | open source -------------------------------------------------------------------------------- /media/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | logo 5 | Created with Sketch. 6 | 7 | 54 | 55 | -------------------------------------------------------------------------------- /notes.md: -------------------------------------------------------------------------------- 1 | # Development Notes 2 | 3 | ## TODO 4 | 5 | - [ ] Customize SaaS design 6 | - [ ] Customize SaaS pricing 7 | - [ ] Customize SaaS features 8 | - [ ] Customize SaaS readme 9 | - [ ] Simplify main readme to show usage 10 | - Move self-hosting info into a separate doc 11 | - [ ] Add use cases section 12 | - [ ] Create blog post 13 | 14 | ## Alternative Architecture 15 | 16 | - API 17 | - Creates and manages CronJobs 18 | - Hosted on ZEIT now or Google Cloud Functions 19 | - Scheduler 20 | - Creates CronJobRuns from CronJobs based on their schedules 21 | - Hosted anywhere? 22 | - How to scale scheduling horizontally? 23 | - Post-MVP; also may not be necessary given the Runner is doing the real work 24 | - Easiest solution would be static N servers and round-robin 25 | - Runner 26 | - Executes CronJobRuns taking them from pending => success or failure 27 | - Triggered by the creation of CronJobRuns in Cloud Firestore 28 | - Implemented via Google Cloud Functions 29 | - SaaS 30 | - Bundles this cron API into a SaaS product via [Saasify](https://saasify.sh) 31 | 32 | ## Naming Brainstorm 33 | 34 | cron 35 | serverless cron 36 | cloud cron 37 | easy cron 38 | simple-cron 39 | simple cron 40 | 41 | ## Related 42 | 43 | - https://github.com/kelektiv/node-cron - Cron for Node.js. 44 | - https://github.com/node-cron/node-cron - A simple cron-like job scheduler for Node.js. 45 | - https://github.com/node-schedule/node-schedule - A cron-like and not-cron-like job scheduler for Node.js. 46 | - https://github.com/bradymholt/cronstrue - Converts cron expressions into human readable descriptions. 47 | - https://dkron.io/ 48 | - https://github.com/shunfei/cronsun 49 | - https://github.com/Nextdoor/ndscheduler 50 | - http://airflow.apache.org/ 51 | 52 | AWS https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html 53 | https://github.com/capside/CloudCron 54 | https://docs.aws.amazon.com/AmazonECS/latest/developerguide/scheduled_tasks.html 55 | -------------------------------------------------------------------------------- /now.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 2, 3 | "env": { 4 | "GOOGLE_APPLICATION_CREDENTIALS": "@simple-cron-google-application-credentials", 5 | "GOOGLE_PROJECT_ID": "@simple-cron-google-project-id", 6 | "GOOGLE_PROJECT_LOCATION": "@simple-cron-google-project-location", 7 | "SAASIFY_PROVIDER_TOKEN": "@simple-cron-saasify-provider-token", 8 | "MAILGUN_API_KEY": "@simple-cron-mailgun-api-key", 9 | "MAILGUN_DOMAIN": "@simple-cron-mailgun-domain" 10 | }, 11 | "builds": [{ "src": "build/server.js", "use": "@now/node" }], 12 | "routes": [{ "src": ".*", "dest": "build/server.js" }] 13 | } 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-cron", 3 | "version": "0.1.0", 4 | "private": true, 5 | "description": "Dead simple cron service.", 6 | "repository": "saasify-sh/simple-cron", 7 | "author": "Saasify ", 8 | "license": "MIT", 9 | "engines": { 10 | "node": ">=10" 11 | }, 12 | "scripts": { 13 | "start": "node build/server.js", 14 | "dev": "cross-env NODE_ENV=development nodemon --exec ts-node src/server.ts", 15 | "predev": "run-s build:routes", 16 | "clean": "del build dist", 17 | "build": "run-s build:*", 18 | "prebuild": "run-s clean", 19 | "build:swagger": "tsoa swagger", 20 | "build:routes": "tsoa routes", 21 | "build:ts": "tsc", 22 | "test": "run-s build", 23 | "deploy": "now --prod", 24 | "predeploy": "run-s build", 25 | "postdeploy": "now rm --safe --yes simple-cron" 26 | }, 27 | "dependencies": { 28 | "@google-cloud/firestore": "^3.7.3", 29 | "@google-cloud/logging": "^7.3.0", 30 | "@google-cloud/monitoring": "^1.7.0", 31 | "@google-cloud/scheduler": "^1.6.0", 32 | "@koa/cors": "^3.0.0", 33 | "@slack/webhook": "^5.0.3", 34 | "date-fns": "^2.11.1", 35 | "dotenv": "^8.2.0", 36 | "grpc": "^1.24.2", 37 | "koa": "^2.11.0", 38 | "koa-bodyparser": "^4.3.0", 39 | "koa-compress": "^3.0.0", 40 | "koa-router": "^8.0.8", 41 | "lodash.pick": "^4.4.0", 42 | "mailgun-js": "^0.22.0", 43 | "p-map": "^4.0.0", 44 | "pify": "^5.0.0", 45 | "remark-html": "^11.0.1", 46 | "remark-parse": "^8.0.0", 47 | "saasify-provider-sdk": "^1.18.10", 48 | "tsoa": "^2.5.13", 49 | "unified": "^9.0.0" 50 | }, 51 | "devDependencies": { 52 | "@types/koa": "^2.11.3", 53 | "@types/koa-bodyparser": "^4.3.0", 54 | "@types/koa-compress": "^2.0.9", 55 | "@types/koa-router": "^7.4.0", 56 | "@types/koa__cors": "^3.0.1", 57 | "@types/lodash.pick": "^4.4.6", 58 | "@types/mailgun-js": "^0.22.4", 59 | "@types/node": "^13.11.1", 60 | "@types/p-map": "^2.0.0", 61 | "@types/pify": "^3.0.2", 62 | "cross-env": "^7.0.2", 63 | "del-cli": "^3.0.0", 64 | "nodemon": "^2.0.3", 65 | "npm-run-all": "^4.1.5", 66 | "ts-node": "^8.8.2", 67 | "typescript": "^3.8.3" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Simple Cron 2 | 3 | > Dead simple cron service for making HTTP calls on a regular schedule. ([link](https://simplecron.dev)) 4 | 5 | [![Build Status](https://travis-ci.com/saasify-sh/simple-cron.svg?branch=master)](https://travis-ci.com/saasify-sh/simple-cron) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 6 | 7 | ## Features 8 | 9 | - 💯 **Open source** 10 | - 🙈 [Hosted version](https://simplecron.dev) provided by [Saasify](https://saasify.sh) 11 | - 🙉 Self-hosted version is easy to set up 12 | - 🐳 Built on top of Google Cloud [Scheduler](https://cloud.google.com/scheduler) and [Firestore](https://cloud.google.com/firestore) 13 | - 💪 Scales "infinitely" via serverless magics 14 | - 👤 Users can only manage the jobs they own 15 | - 🤖 Includes an auto-generated OpenAPI spec 16 | - 👍 Super simple -- Google does all the hard work for us 17 | 18 | ## Install 19 | 20 | ```bash 21 | # Install dependencies 22 | yarn 23 | ``` 24 | 25 | ```bash 26 | # Configure your google service account and cloud scheduler 27 | # See the Setup section below for more details 28 | echo GOOGLE_APPLICATION_CREDENTIALS='/path/to/service-account.json' >> .env 29 | echo GOOGLE_PROJECT_ID='XXX' >> .env 30 | echo GOOGLE_PROJECT_LOCATION='XXX' >> .env 31 | 32 | # Run the development server 33 | yarn dev 34 | 35 | # Or run the production server 36 | yarn build 37 | yarn start 38 | ``` 39 | 40 | ## Usage 41 | 42 | Now let's set up some useful environment variables for the following examples. We'll be using the local dev server and a fake user named `nala` (my wonderful kitty 😻). 43 | 44 | ```bash 45 | export SIMPLE_CRON_URL=http://localhost:4000 46 | export SIMPLE_CRON_USER=nala 47 | ``` 48 | 49 | All of the following examples use [httpie](https://httpie.org), a modern alternative to `curl`. They're just normal HTTP REST calls, so feel free to replace them with your preferred REST language / client. 50 | 51 |

52 | Cron expression syntax 53 |

54 | 55 | ```bash 56 | # Create a new job that will send a GET request to example.com once every minute 57 | http POST ${SIMPLE_CRON_URL}/jobs schedule='* * * * *' url='https://example.com' x-saasify-user:${SIMPLE_CRON_USER} 58 | ``` 59 | 60 | There are two required parameters: `schedule` and `url`. 61 | 62 | - `schedule` - (**required** string) A standard [cron expression](https://crontab.guru) describing when your HTTP job should run. 63 | - `url` - (**required** string) The URL to visit each time your job runs. 64 | - `httpMethod` - (string, default `GET`) Optional HTTP method to use. (`GET`, `POST`, `PUT`, `HEAD`, `DELETE`, `PATCH`, `OPTIONS`) 65 | - `httpBody` - (object, default `{}`) Optional JSON body to use. 66 | - `httpHeaders` - (object, default `{}`) Optional HTTP headers to use. 67 | - `httpQuery` - (object, default `{}`) Optional HTTP query parameters to add to the base `url`. 68 | - `timezone` - (string, default `America/New_York`) Optional [time zone](https://cloud.google.com/dataprep/docs/html/Supported-Time-Zone-Values_66194188) to use. 69 | - `name` - (string) Optional metadata name. 70 | - `description` - (string) Optional metadata description . 71 | - `tags` - (string[]) Optional metadata tags . 72 | 73 | The output of your newly created job should look something like this: 74 | 75 | ```json 76 | { 77 | "id": "sEfyx6mm2d9smI0xltYI", 78 | "schedule": "* * * * *", 79 | "url": "https://saasify.sh", 80 | "userId": "nala", 81 | "timezone": "America/New_York", 82 | "httpMethod": "GET", 83 | "httpHeaders": {}, 84 | "description": "", 85 | "name": "Default", 86 | "tags": [], 87 | "createdAt": "2020-03-07T19:10:40.119Z", 88 | "updatedAt": "2020-03-07T19:10:40.119Z" 89 | } 90 | ``` 91 | 92 | Here are some of the ways you can manage your job: 93 | 94 | ```bash 95 | # Get the job via its id 96 | http GET ${SIMPLE_CRON_URL}/jobs/sEfyx6mm2d9smI0xltYI x-saasify-user:${SIMPLE_CRON_USER} 97 | 98 | # Pause the job 99 | http PUT ${SIMPLE_CRON_URL}/jobs/sEfyx6mm2d9smI0xltYI x-saasify-user:${SIMPLE_CRON_USER} state=paused 100 | 101 | # Resume the job 102 | http PUT ${SIMPLE_CRON_URL}/jobs/sEfyx6mm2d9smI0xltYI x-saasify-user:${SIMPLE_CRON_USER} state=enabled 103 | 104 | # Disable the job 105 | http PUT ${SIMPLE_CRON_URL}/jobs/sEfyx6mm2d9smI0xltYI x-saasify-user:${SIMPLE_CRON_USER} state=disabled 106 | 107 | # Delete the job 108 | http DELETE ${SIMPLE_CRON_URL}/jobs/sEfyx6mm2d9smI0xltYI x-saasify-user:${SIMPLE_CRON_USER} 109 | ``` 110 | 111 | ## Deploy 112 | 113 | Once you have the project working locally, you can deploy it anywhere you want: Heroku, AWS, GCP, etc. 114 | 115 | For demonstration purposes, we've included an example of deploying to [ZEIT now](https://zeit.co/now) as a serverless function. 116 | 117 | Assuming you have `now` set up locally, you will need to initialize the required environment variables first. 118 | 119 | ```bash 120 | now secret add -- simple-cron-google-project-id "XXX" 121 | now secret add -- simple-cron-google-project-location "XXX" 122 | now secret add -- simple-cron-google-application-credentials "`base64 /path/to/service-account.json`" 123 | ``` 124 | 125 | Note that the `base64` encoding is a [workaround](https://github.com/zeit/now/issues/749) because the serverless environment won't be able to access the filesystem. 126 | 127 | Once your `now` config is set up, you should be able to run: 128 | 129 | ```bash 130 | yarn deploy 131 | ``` 132 | 133 | You should be gucci now -- go forth and cron, my friend! ✌️ 134 | 135 | Be careful to never check your Google service account credentials into version control. 136 | 137 | ## Setup 138 | 139 | #### Google Cloud Firestore 140 | 141 | You need to set up a new Firebase project, following the prompts. Once you get to your Firestore database, make sure it's in native mode, not datastore mode so we can access it programatically. 142 | 143 | #### Google Cloud Scheduler 144 | 145 | You need to enable the Google Cloud Scheduler API in your cloud console and then figure out your project ID and location. For me, these were `saasify` and `us-central1` respectively. 146 | 147 | #### Google Service Account 148 | 149 | You need to create a new [Google service account](https://cloud.google.com/docs/authentication/getting-started), assign the correct roles, and then download the resulting `json` key file. 150 | 151 | The roles I used are `Cloud Scheduler Admin`, `Cloud Scheduler Service Agent`, `Firebase Admin`, `Firebase Develop Admin`, and `Firebase Admin SDK Administrator Service Agent`. You may need to add additional roles for optional functionality such as logging and monitoring. 152 | 153 | These roles are a bit of a black box and I'm sure it would work with less roles, but this worked for me. If you figure out the minimal set of roles needed, please open an issue to let me know. 154 | 155 | #### Misc 156 | 157 | If you get permission denied errors along the way, don't worry -- you're in good company 😂 158 | 159 | Just double check to make sure these APIs are enabled in your Google cloud console and that the service account you're using has the correct roles. If the errors still happen, try waiting a few minutes for the permissions to propagate which ended up working for me. 160 | 161 | ## Related 162 | 163 | - [EasyCron](https://www.easycron.com/) - Solid hosted cron service (not open source). 164 | - [Hosted cron services](https://www.cronjobservices.com) - Collection of hosted cron service providers. 165 | - [Saasify](https://saasify.sh) makes it easy to monetize these types of services. 166 | 167 | ## License 168 | 169 | MIT © [Saasify](https://saasify.sh) 170 | 171 | Support my OSS work by following me on twitter twitter 172 | -------------------------------------------------------------------------------- /saasify.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-cron", 3 | "alias": "simplecron.dev", 4 | "openapi": "./dist/swagger.json", 5 | "services": [ 6 | { 7 | "path": "/jobs", 8 | "httpMethod": "POST", 9 | "examples": [ 10 | { 11 | "name": "Create a Job", 12 | "description": "Creates a cron job to ping a URL every minute.", 13 | "input": { 14 | "schedule": "* * * * *", 15 | "url": "https://example.com", 16 | "httpMethod": "GET" 17 | }, 18 | "output": { 19 | "id": "sEfyx6mm2d9smI0xltYI", 20 | "schedule": "* * * * *", 21 | "url": "https://saasify.sh", 22 | "userId": "nala", 23 | "timezone": "America/New_York", 24 | "httpMethod": "GET", 25 | "httpHeaders": {}, 26 | "description": "", 27 | "name": "Default", 28 | "tags": [], 29 | "createdAt": "2020-03-07T19:10:40.119Z", 30 | "updatedAt": "2020-03-07T19:10:40.119Z" 31 | } 32 | } 33 | ] 34 | } 35 | ], 36 | "saas": { 37 | "name": "Simple Cron", 38 | "heading": "Cloud Cron Jobs", 39 | "subheading": "Dead simple cron service for making HTTP calls on a regular schedule. Open source, simple, and scalable.", 40 | "repo": "https://github.com/saasify-sh/simple-cron", 41 | "logo": "media/logo.svg", 42 | "favicon": "media/favicon.ico", 43 | "readme": "docs.md", 44 | "theme": { 45 | "name": "waves", 46 | "buttonStyle": "rounded", 47 | "color": "#ff6e6c", 48 | "codeBlockDark": true, 49 | "backgroundImage": "https://images.unsplash.com/photo-1524678714210-9917a6c619c2?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=2549&q=80" 50 | }, 51 | "sections": { 52 | "pricing": { 53 | "showMeteredBilling": false 54 | }, 55 | "hero": { 56 | "cta": "Get started for free" 57 | }, 58 | "demo": false 59 | }, 60 | "webapp": { 61 | "url": "https://simple-cron-webapp.now.sh", 62 | "devUrl": "http://localhost:7000" 63 | }, 64 | "features": [ 65 | { 66 | "name": "Simple setup", 67 | "desc": "Easy to get started with unlimited HTTP calls for one **free** cron job.", 68 | "icon": "media/icons/setup.svg" 69 | }, 70 | { 71 | "name": "Simple pricing", 72 | "desc": "Based on the number of cron jobs you create with **unlimited calls per job**.", 73 | "icon": "media/icons/stripe.svg" 74 | }, 75 | { 76 | "name": "Customizable", 77 | "desc": "You can customize HTTP method, headers, query params, and request bodies.", 78 | "icon": "media/icons/customizable.svg" 79 | }, 80 | { 81 | "name": "Notifications", 82 | "desc": "One click to enable **email** and **slack** notifications for job failures.", 83 | "icon": "media/icons/notifications.svg" 84 | }, 85 | { 86 | "name": "Detailed logs", 87 | "desc": "Logs for all jobs are stored and remain accessible forever.", 88 | "icon": "media/icons/logs.svg" 89 | }, 90 | { 91 | "name": "Reliable delivery", 92 | "desc": "Enterprise-grade reliability for cron jobs built on Google Cloud. Guaranteed at-least-once delivery to your job targets.", 93 | "icon": "media/icons/cluster.svg" 94 | }, 95 | { 96 | "name": "Rigorously tested", 97 | "desc": "Simple Cron currently handles over 100k calls per day to servers around the world.", 98 | "icon": "media/icons/global.svg" 99 | }, 100 | { 101 | "name": "Powerful API", 102 | "desc": "Create and manage cron jobs programatically via our [REST API](/docs#section/API).", 103 | "icon": "media/icons/api.svg" 104 | }, 105 | { 106 | "name": "Open Source", 107 | "desc": "Easy to set up your own self-hosted version via our [open source repo](https://github.com/saasify-sh/simple-cron).", 108 | "icon": "media/icons/open-source.svg" 109 | } 110 | ] 111 | }, 112 | "pricingPlans": [ 113 | { 114 | "name": "Free", 115 | "rateLimit": null, 116 | "metrics": [ 117 | { 118 | "slug": "jobs", 119 | "usageType": "licensed", 120 | "amount": 0 121 | } 122 | ], 123 | "features": ["1 free cron job per month", "Unlimited calls per cron job"] 124 | }, 125 | { 126 | "name": "Pro", 127 | "amount": 499, 128 | "rateLimit": null, 129 | "metrics": [ 130 | { 131 | "slug": "jobs", 132 | "usageType": "licensed", 133 | "billingScheme": "tiered", 134 | "tiersMode": "graduated", 135 | "tiers": [ 136 | { 137 | "upTo": 5, 138 | "flatAmount": 0 139 | }, 140 | { 141 | "upTo": "inf", 142 | "unitAmount": 99 143 | } 144 | ] 145 | } 146 | ], 147 | "features": [ 148 | "5 cron jobs per month", 149 | "Additional cron jobs are $0.99 per job", 150 | "Unlimited calls per cron job" 151 | ] 152 | }, 153 | { 154 | "name": "Business", 155 | "amount": 2999, 156 | "rateLimit": null, 157 | "metrics": [ 158 | { 159 | "slug": "jobs", 160 | "usageType": "licensed", 161 | "billingScheme": "tiered", 162 | "tiersMode": "graduated", 163 | "tiers": [ 164 | { 165 | "upTo": 50, 166 | "flatAmount": 0 167 | }, 168 | { 169 | "upTo": "inf", 170 | "unitAmount": 99 171 | } 172 | ] 173 | } 174 | ], 175 | "features": [ 176 | "50 cron jobs per month", 177 | "Additional cron jobs are $0.99 per job", 178 | "Unlimited calls per cron job" 179 | ] 180 | } 181 | ] 182 | } 183 | -------------------------------------------------------------------------------- /src/billing.ts: -------------------------------------------------------------------------------- 1 | import SaasifyProviderSDK = require('saasify-provider-sdk') 2 | 3 | import * as db from './db' 4 | 5 | const token = process.env.SAASIFY_PROVIDER_TOKEN 6 | 7 | const sdk = token ? new SaasifyProviderSDK({ token }) : null 8 | const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV 9 | 10 | export const updateUsage = async ({ userId, plan, delta }) => { 11 | if (!sdk) { 12 | console.warn( 13 | 'We recommend you configure "SAASIFY_PROVIDER_TOKEN" to report usage to Saasify.' 14 | ) 15 | 16 | return 17 | } 18 | 19 | console.time('updateUsage getUserJobDocs') 20 | const { size } = await db.getUserJobDocs({ userId }) 21 | console.timeEnd('updateUsage getUserJobDocs') 22 | console.log('updateUsage', { userId, plan, delta, size }) 23 | 24 | const quantity = Math.max(0, size + delta) 25 | 26 | if (plan === 'free') { 27 | if (quantity > 1 && delta >= 0) { 28 | if (isDev) { 29 | console.error('warning: disabling subscription limits') 30 | return 31 | } else { 32 | throw { 33 | message: 'Please upgrade your subscription to add more jobs.', 34 | status: 402 35 | } 36 | } 37 | } 38 | } else { 39 | return sdk.updateUsage({ 40 | metric: 'jobs', 41 | user: userId, 42 | quantity 43 | }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/bootstrap.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | 3 | require('dotenv').config() 4 | 5 | // hack for dealing with base64-encoded google service account on AWS lambda 6 | const credentials = process.env.GOOGLE_APPLICATION_CREDENTIALS 7 | const prodCredentialsPath = '/tmp/google.json' 8 | 9 | try { 10 | const json = JSON.parse(Buffer.from(credentials, 'base64').toString()) 11 | fs.writeFileSync(prodCredentialsPath, JSON.stringify(json)) 12 | process.env.GOOGLE_APPLICATION_CREDENTIALS = prodCredentialsPath 13 | } catch (err) { 14 | // If the credentials weren't base64-encoded, then we're likely running locally. 15 | // Google will throw an error if this isn't the case, so we ignore things here. 16 | } 17 | -------------------------------------------------------------------------------- /src/db.ts: -------------------------------------------------------------------------------- 1 | import grpc = require('grpc') 2 | import * as firestore from '@google-cloud/firestore' 3 | 4 | import * as types from './types' 5 | 6 | export const db = new firestore.Firestore({ grpc }) 7 | export const CronJobs = db.collection('cron-jobs') 8 | 9 | export async function get( 10 | doc: firestore.DocumentReference, 11 | userId?: string 12 | ): Promise { 13 | const snapshot = await doc.get() 14 | 15 | if (snapshot.exists) { 16 | const res = getSnapshot(snapshot) 17 | 18 | if (userId && res.userId && res.userId !== userId) { 19 | throw { 20 | message: 'Unauthorized', 21 | status: 403 22 | } 23 | } 24 | 25 | return res 26 | } 27 | 28 | throw { 29 | message: 'Not found', 30 | status: 404 31 | } 32 | } 33 | 34 | export function getSnapshot( 35 | snapshot: firestore.DocumentSnapshot 36 | ): T { 37 | const data = snapshot.data() 38 | 39 | return { 40 | ...data, 41 | id: snapshot.id, 42 | createdAt: snapshot.createTime.toDate(), 43 | updatedAt: snapshot.updateTime.toDate() 44 | } as T 45 | } 46 | 47 | export async function getUserJobDocs({ 48 | userId, 49 | offset = 0, 50 | limit = null, 51 | orderBy = null 52 | }) { 53 | let query = CronJobs.where('userId', '==', userId) 54 | 55 | if (offset) { 56 | query = query.offset(offset) 57 | } 58 | 59 | if (limit) { 60 | query = query.limit(limit) 61 | } 62 | 63 | if (orderBy) { 64 | query = query.orderBy(orderBy.key, orderBy.direction) 65 | } 66 | 67 | return query.get() 68 | } 69 | -------------------------------------------------------------------------------- /src/format-date.ts: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns' 2 | 3 | // TODO: use timezone set for job 4 | export const formatDate = (date) => 5 | date ? format(date, 'MM/dd/yyyy HH:mm:ss OOOO') : 'n/a' 6 | -------------------------------------------------------------------------------- /src/grpc-utils.ts: -------------------------------------------------------------------------------- 1 | import * as types from './types' 2 | 3 | export function convertCode(code?: number | null): types.HttpStatus | null { 4 | if (code !== null) { 5 | switch (code) { 6 | case 0: 7 | return { 8 | code: 200, 9 | message: 'OK' 10 | } 11 | case 1: 12 | return { 13 | code: 499, 14 | message: 'Cancelled' 15 | } 16 | case 2: 17 | return { 18 | code: 500, 19 | message: 'Internal Server Error - Unknown' 20 | } 21 | case 3: 22 | return { 23 | code: 400, 24 | message: 'Bad Request' 25 | } 26 | case 4: 27 | return { 28 | code: 504, 29 | message: 'Gateway Timeout' 30 | } 31 | case 5: 32 | return { 33 | code: 404, 34 | message: 'Not Found' 35 | } 36 | case 6: 37 | return { 38 | code: 409, 39 | message: 'Conflict' 40 | } 41 | case 7: 42 | return { 43 | code: 403, 44 | message: 'Forbidden' 45 | } 46 | case 8: 47 | return { 48 | code: 429, 49 | message: 'Too Many Requests' 50 | } 51 | case 9: 52 | return { 53 | code: 400, 54 | message: 'Bad Request' 55 | } 56 | case 10: 57 | return { 58 | code: 409, 59 | message: 'Aborted' 60 | } 61 | case 11: 62 | return { 63 | code: 400, 64 | message: 'Bad Request - Out Of Range' 65 | } 66 | case 12: 67 | return { 68 | code: 501, 69 | message: 'Not Implemented' 70 | } 71 | case 13: 72 | return { 73 | code: 500, 74 | message: 'Internal Server Error' 75 | } 76 | case 14: 77 | return { 78 | code: 503, 79 | message: 'Service Unavailable' 80 | } 81 | case 15: 82 | return { 83 | code: 500, 84 | message: 'Internal Server Error - Data Loss' 85 | } 86 | } 87 | } 88 | 89 | return null 90 | } 91 | -------------------------------------------------------------------------------- /src/jobs.ts: -------------------------------------------------------------------------------- 1 | import pMap = require('p-map') 2 | import { 3 | Body, 4 | Controller, 5 | Delete, 6 | Get, 7 | Post, 8 | Put, 9 | Query, 10 | Route, 11 | Header 12 | } from 'tsoa' 13 | import { 14 | CronJob, 15 | CronJobCreateRequest, 16 | CronJobUpdateRequest, 17 | LogEntry 18 | } from './types' 19 | 20 | import * as db from './db' 21 | import * as logs from './logs' 22 | import * as monitoring from './monitoring' 23 | import * as scheduler from './scheduler' 24 | import * as billing from './billing' 25 | 26 | @Route('/jobs') 27 | export class CronJobController extends Controller { 28 | @Post() 29 | public async createJob( 30 | @Body() body: CronJobCreateRequest, 31 | @Header('x-saasify-user') userId: string, 32 | @Header('x-saasify-plan') plan: string 33 | ): Promise { 34 | console.log('createJob', { body, userId }) 35 | 36 | const data = { 37 | timezone: 'America/New_York', 38 | httpMethod: 'GET', 39 | httpHeaders: {}, 40 | name: 'Default', 41 | description: '', 42 | tags: [], 43 | timestamp: Date.now(), 44 | ...body, 45 | userId, 46 | state: 'enabled' 47 | } 48 | 49 | await billing.updateUsage({ 50 | userId, 51 | plan, 52 | delta: 1 53 | }) 54 | 55 | const doc = await db.CronJobs.add(data) 56 | const job = await db.get(doc, userId) 57 | console.log({ job }) 58 | 59 | const schedulerJob = await scheduler.createJob(job) 60 | console.log({ schedulerJob }) 61 | 62 | const alertPolicy = await monitoring.createAlert(job) 63 | console.log({ alertPolicy }) 64 | await doc.update({ alertPolicy: alertPolicy.name }) 65 | 66 | return scheduler.enrichJob(job, schedulerJob) 67 | } 68 | 69 | @Get(`/{jobId}`) 70 | public async getJob( 71 | jobId: string, 72 | @Header('x-saasify-user') userId: string 73 | ): Promise { 74 | console.log('getJob', { jobId, userId }) 75 | 76 | const doc = db.CronJobs.doc(jobId) 77 | const job = await db.get(doc, userId) 78 | 79 | const schedulerJob = await scheduler.getJob(job) 80 | console.log({ schedulerJob }) 81 | 82 | return scheduler.enrichJob(job, schedulerJob) 83 | } 84 | 85 | @Delete(`/{jobId}`) 86 | public async removeJob( 87 | jobId: string, 88 | @Header('x-saasify-user') userId: string, 89 | @Header('x-saasify-plan') plan: string 90 | ): Promise { 91 | console.log('removeJob', { jobId, userId }) 92 | 93 | const doc = db.CronJobs.doc(jobId) 94 | const job = await db.get(doc, userId) 95 | 96 | await billing.updateUsage({ 97 | userId, 98 | plan, 99 | delta: -1 100 | }) 101 | 102 | await scheduler.deleteJob(job) 103 | await monitoring.deleteAlert(job) 104 | await doc.delete() 105 | } 106 | 107 | @Get() 108 | public async listJobs( 109 | @Header('x-saasify-user') userId: string, 110 | @Query() offset: number = 0, 111 | @Query() limit: number = 100 112 | ): Promise { 113 | console.log('listJobs', { offset, limit, userId }) 114 | 115 | console.time('listJobs db.CronJobs.where') 116 | const { docs } = await db.getUserJobDocs({ 117 | userId, 118 | offset, 119 | limit, 120 | orderBy: { 121 | key: 'timestamp', 122 | direction: 'desc' 123 | } 124 | }) 125 | console.timeEnd('listJobs db.CronJobs.where') 126 | 127 | console.log('results', docs.length) 128 | const jobs = docs.map((doc) => db.getSnapshot(doc)) 129 | 130 | return pMap( 131 | jobs, 132 | async (job) => { 133 | console.time(`scheduler.getJob(${job.id})`) 134 | const schedulerJob = await scheduler.getJob(job) 135 | console.timeEnd(`scheduler.getJob(${job.id})`) 136 | return scheduler.enrichJob(job, schedulerJob) 137 | }, 138 | { 139 | concurrency: 4 140 | } 141 | ) 142 | } 143 | 144 | @Put(`/{jobId}`) 145 | public async updateJob( 146 | jobId: string, 147 | @Body() body: CronJobUpdateRequest, 148 | @Header('x-saasify-user') userId: string 149 | ): Promise { 150 | console.log('updateJob', { jobId, body, userId }) 151 | 152 | const doc = db.CronJobs.doc(jobId) 153 | const snapshot = await doc.get() 154 | 155 | if (snapshot.exists) { 156 | const data = snapshot.data() 157 | 158 | if (data.userId === userId) { 159 | await doc.update(body) 160 | const job = await db.get(doc, userId) 161 | 162 | const schedulerJob = await scheduler.updateJob(job) 163 | return scheduler.enrichJob(job, schedulerJob) 164 | } 165 | } 166 | 167 | throw { 168 | message: 'Not found', 169 | status: 404 170 | } 171 | } 172 | 173 | @Get(`/{jobId}/logs`) 174 | public async listJobLogs( 175 | jobId: string, 176 | @Header('x-saasify-user') userId: string, 177 | @Query() limit: number = 25 178 | // @Query() pageToken?: string 179 | ): Promise { 180 | console.log('listJobLogs', { jobId, userId }) 181 | 182 | const doc = db.CronJobs.doc(jobId) 183 | const job = await db.get(doc, userId) 184 | console.log({ job }) 185 | 186 | return logs.getJobLogs(job, { limit }) 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/logs.ts: -------------------------------------------------------------------------------- 1 | import grpc = require('grpc') 2 | import { Logging, Entry } from '@google-cloud/logging' 3 | import pick = require('lodash.pick') 4 | 5 | import * as types from './types' 6 | 7 | const logEntryTypeAttemptFinished = 8 | 'type.googleapis.com/google.cloud.scheduler.logging.AttemptFinished' 9 | const logEntryTypeAttemptStarted = 10 | 'type.googleapis.com/google.cloud.scheduler.logging.AttemptStarted' 11 | 12 | const client = new Logging({ grpc: grpc as any }) 13 | 14 | export async function getJobLogs( 15 | job: types.CronJob, 16 | opts: types.LogOptions = {} 17 | ): Promise { 18 | const filter = `resource.type="cloud_scheduler_job" AND resource.labels.job_id="${job.id}"` 19 | 20 | console.time(`getJobLogs ${job.id}`) 21 | const [entries] = await client.getEntries({ 22 | filter, 23 | pageSize: opts.limit || 25 24 | }) 25 | console.timeEnd(`getJobLogs ${job.id}`) 26 | console.log(`getJobLogs ${job.id}`, entries.length, 'logs') 27 | 28 | return entries.map((entry) => encodeLogEntry(entry, job)) 29 | } 30 | 31 | function encodeLogEntry(entry: Entry, job: types.CronJob): types.LogEntry { 32 | const metadata = pick(entry.metadata, [ 33 | 'timestamp', 34 | 'severity', 35 | 'httpRequest' 36 | ]) 37 | 38 | const { url, statusMessage } = entry.data 39 | const type = entry.data['@type'] 40 | let status = null 41 | 42 | if (type == logEntryTypeAttemptFinished) { 43 | status = { 44 | message: statusMessage, 45 | code: metadata.httpRequest ? metadata.httpRequest.status : undefined 46 | } 47 | 48 | if (!status.message && status.code === 200) { 49 | status.message = 'OK' 50 | } 51 | } else if (type == logEntryTypeAttemptStarted) { 52 | status = { 53 | message: 'Attempt started' 54 | } 55 | } 56 | 57 | return { 58 | id: entry.metadata.insertId, 59 | jobId: job.id, 60 | userId: job.userId, 61 | httpMethod: job.httpMethod, 62 | url, 63 | status, 64 | ...metadata 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/monitoring.ts: -------------------------------------------------------------------------------- 1 | import grpc = require('grpc') 2 | import * as monitoring from '@google-cloud/monitoring' 3 | import * as monitoringTypes from '@google-cloud/monitoring/protos/protos' 4 | import * as types from './types' 5 | 6 | const alertClient = new monitoring.AlertPolicyServiceClient({ 7 | grpc: grpc as any 8 | }) 9 | 10 | const metricName = 'logging.googleapis.com/user/simple-cron-job-errors' 11 | const notificationChannelName = 12 | 'projects/saasify/notificationChannels/6071543916583820227' 13 | 14 | export async function createAlert( 15 | job: types.CronJob 16 | ): Promise { 17 | const projectId = await alertClient.getProjectId() 18 | const projectPath = alertClient.projectPath(projectId) 19 | 20 | const filter = ` 21 | metric.type = "${metricName}" AND 22 | metric.labels.job_id = "${job.id}" AND 23 | resource.type="cloud_scheduler_job" 24 | `.replace(/[ \r\n]+/g, ' ') 25 | 26 | console.log({ filter }) 27 | 28 | // TODO: this needs to be more customizable 29 | const alertPolicy = ( 30 | await alertClient.createAlertPolicy({ 31 | name: projectPath, 32 | alertPolicy: { 33 | displayName: `Cron job failure alert - ${job.name || job.id}`, 34 | notificationChannels: [notificationChannelName], 35 | combiner: 'OR', 36 | conditions: [ 37 | { 38 | displayName: `Cron job failure condition - ${job.name || job.id}`, 39 | conditionThreshold: { 40 | filter, 41 | comparison: 'COMPARISON_GT', 42 | duration: { 43 | seconds: 120, 44 | nanos: 0 45 | }, 46 | thresholdValue: 0 47 | // trigger: { 48 | // count: 1 49 | // }, 50 | // aggregations: [ 51 | // { 52 | // alignmentPeriod: { seconds: 60, nanos: 0 }, 53 | // perSeriesAligner: 'ALIGN_RATE' 54 | // } 55 | // ] 56 | } 57 | } 58 | ] 59 | } 60 | }) 61 | )[0] 62 | 63 | return alertPolicy 64 | } 65 | 66 | export async function deleteAlert(job: types.CronJob): Promise { 67 | if (!job.alertPolicy) { 68 | return 69 | } 70 | 71 | await alertClient.deleteAlertPolicy({ 72 | name: job.alertPolicy 73 | }) 74 | } 75 | -------------------------------------------------------------------------------- /src/notification-channels/email.ts: -------------------------------------------------------------------------------- 1 | import mailgun = require('mailgun-js') 2 | import pify = require('pify') 3 | import unified = require('unified') 4 | import markdown = require('remark-parse') 5 | import html = require('remark-html') 6 | 7 | import { formatDate } from '../format-date' 8 | import * as types from '../types' 9 | 10 | // TODO: add better formatting via a handlebars html template 11 | 12 | const apiKey = process.env.MAILGUN_API_KEY 13 | const domain = process.env.MAILGUN_DOMAIN 14 | 15 | const client = 16 | apiKey && domain 17 | ? mailgun({ 18 | apiKey, 19 | domain 20 | }) 21 | : null 22 | 23 | export async function sendNotification({ 24 | job, 25 | incident, 26 | stateLabel, 27 | emojiLabel 28 | }: { 29 | job: types.CronJob 30 | incident: any 31 | stateLabel: string 32 | emojiLabel: string 33 | }) { 34 | if (!job.email) { 35 | return 36 | } 37 | 38 | const subject = `Simple Cron Job Alert - ${job.name}` 39 | 40 | const message = ` 41 | ${emojiLabel}**Simple Cron Job Failure ${stateLabel}** 42 | 43 | 44 | Job Name: **${job.name}** 45 | 46 | Job ID: \`${job.id}\` 47 | 48 | 49 | HTTP status: **${job.status?.code}** 50 | 51 | HTTP message: **${job.status?.message}** 52 | 53 | HTTP url: ${job.url} 54 | 55 | HTTP method: ${job.httpMethod} 56 | 57 | Last attempt time: ${formatDate(job.lastAttemptTime)} 58 | 59 | Next attempt time: ${formatDate(job.nextAttemptTime)} 60 | 61 | 62 | Incident start time: ${ 63 | incident.started_at 64 | ? formatDate(new Date(incident.started_at * 1000)) 65 | : 'n/a' 66 | } 67 | 68 | Incident end time: ${ 69 | incident.ended_at ? formatDate(new Date(incident.ended_at * 1000)) : 'n/a' 70 | } 71 | 72 | 73 | [View Job](https://simplecron.dev/dashboard) 74 | ` 75 | 76 | // convert markdown to html 77 | const body = ( 78 | await unified().use(markdown).use(html).process(message) 79 | ).toString() 80 | 81 | return send({ html: body, subject, to: job.email }) 82 | } 83 | 84 | export async function send({ 85 | from = 'Simple Cron ', 86 | subject, 87 | html, 88 | to 89 | }) { 90 | if (!client) { 91 | console.warn( 92 | 'Warning: email is disabled. Please specify MAILGUN_API_KEY and MAILGUN_DOMAIN to enable email notifications.' 93 | ) 94 | return 95 | } 96 | 97 | const messages = client.messages() 98 | const send = pify(messages.send.bind(messages)) 99 | 100 | return send({ 101 | html, 102 | subject, 103 | from, 104 | to 105 | }) 106 | } 107 | -------------------------------------------------------------------------------- /src/notification-channels/slack.ts: -------------------------------------------------------------------------------- 1 | import { IncomingWebhook } from '@slack/webhook' 2 | import { formatDate } from '../format-date' 3 | import * as types from '../types' 4 | 5 | // TODO: look into slack block formatting 6 | // https://api.slack.com/tools/block-kit-builder?mode=message&blocks=%5B%7B%22type%22%3A%22section%22%2C%22text%22%3A%7B%22type%22%3A%22mrkdwn%22%2C%22text%22%3A%22You%20have%20a%20new%20request%3A%5Cn*%3CfakeLink.toEmployeeProfile.com%7CFred%20Enriquez%20-%20New%20device%20request%3E*%22%7D%7D%2C%7B%22type%22%3A%22section%22%2C%22fields%22%3A%5B%7B%22type%22%3A%22mrkdwn%22%2C%22text%22%3A%22*Type%3A*%5CnComputer%20(laptop)%22%7D%2C%7B%22type%22%3A%22mrkdwn%22%2C%22text%22%3A%22*When%3A*%5CnSubmitted%20Aut%2010%22%7D%2C%7B%22type%22%3A%22mrkdwn%22%2C%22text%22%3A%22*Last%20Update%3A*%5CnMar%2010%2C%202015%20(3%20years%2C%205%20months)%22%7D%2C%7B%22type%22%3A%22mrkdwn%22%2C%22text%22%3A%22*Reason%3A*%5CnAll%20vowel%20keys%20aren%27t%20working.%22%7D%2C%7B%22type%22%3A%22mrkdwn%22%2C%22text%22%3A%22*Specs%3A*%5Cn%5C%22Cheetah%20Pro%2015%5C%22%20-%20Fast%2C%20really%20fast%5C%22%22%7D%5D%7D%2C%7B%22type%22%3A%22actions%22%2C%22elements%22%3A%5B%7B%22type%22%3A%22button%22%2C%22text%22%3A%7B%22type%22%3A%22plain_text%22%2C%22emoji%22%3Atrue%2C%22text%22%3A%22Approve%22%7D%2C%22style%22%3A%22primary%22%2C%22value%22%3A%22click_me_123%22%7D%2C%7B%22type%22%3A%22button%22%2C%22text%22%3A%7B%22type%22%3A%22plain_text%22%2C%22emoji%22%3Atrue%2C%22text%22%3A%22Deny%22%7D%2C%22style%22%3A%22danger%22%2C%22value%22%3A%22click_me_123%22%7D%5D%7D%5D 7 | 8 | const iconEmojiLabels = { 9 | open: ':warning:', 10 | closed: ':white_check_mark:' 11 | } 12 | 13 | export async function sendNotification({ 14 | job, 15 | incident, 16 | stateLabel, 17 | emojiLabel 18 | }: { 19 | job: types.CronJob 20 | incident: any 21 | stateLabel: string 22 | emojiLabel: string 23 | }) { 24 | if (!job.slackWebhookUrl) { 25 | return 26 | } 27 | 28 | const text = ` 29 | ${emojiLabel}*Simple Cron Job Failure ${stateLabel}* 30 | 31 | Job Name: *${job.name}* 32 | Job ID: \`${job.id}\` 33 | 34 | 35 | HTTP status: *${job.status?.code}* 36 | HTTP message: *${job.status?.message}* 37 | HTTP url: ${job.url} 38 | HTTP method: ${job.httpMethod} 39 | Last attempt time: ${formatDate(job.lastAttemptTime)} 40 | Next attempt time: ${formatDate(job.nextAttemptTime)} 41 | 42 | Incident start time: ${ 43 | incident.started_at 44 | ? formatDate(new Date(incident.started_at * 1000)) 45 | : 'n/a' 46 | } 47 | Incident end time: ${ 48 | incident.ended_at ? formatDate(new Date(incident.ended_at * 1000)) : 'n/a' 49 | } 50 | 51 | https://simplecron.dev/dashboard 52 | ` 53 | 54 | return send({ 55 | text, 56 | webhookUrl: job.slackWebhookUrl, 57 | icon_emoji: iconEmojiLabels[incident.state] 58 | }) 59 | } 60 | 61 | export async function send({ text, webhookUrl, ...rest }) { 62 | const webhook = new IncomingWebhook(webhookUrl) 63 | 64 | return webhook.send({ 65 | unfurl_links: false, 66 | text, 67 | ...rest 68 | }) 69 | } 70 | -------------------------------------------------------------------------------- /src/notifications.ts: -------------------------------------------------------------------------------- 1 | import * as Koa from 'koa' 2 | 3 | import * as db from './db' 4 | import * as scheduler from './scheduler' 5 | import * as types from './types' 6 | 7 | import * as slack from './notification-channels/slack' 8 | import * as email from './notification-channels/email' 9 | 10 | const stateLabels = { 11 | open: 'Alert', 12 | closed: 'Resolved' 13 | } 14 | 15 | const emojiLabels = { 16 | open: '⚠️', 17 | closed: '✅' 18 | } 19 | 20 | export const handler = async (ctx: Koa.Context) => { 21 | const body = (ctx.request as any)?.body 22 | console.log('notification', body) 23 | 24 | const incident = body?.incident 25 | if (!incident) { 26 | ctx.throw(400, 'no incident') 27 | } 28 | 29 | const jobId = incident?.resource?.labels?.job_id 30 | if (!jobId) { 31 | ctx.throw(400, 'no job_id') 32 | } 33 | 34 | const state = incident.state 35 | const doc = db.CronJobs.doc(jobId) 36 | let job = await db.get(doc) 37 | console.log({ job }) 38 | 39 | if (job.state !== 'enabled') { 40 | console.log(`job state is ${job.state} - ignoring notification alert`) 41 | ctx.body = `ignored (${job.state})` 42 | return 43 | } 44 | 45 | const schedulerJob = await scheduler.getJob(job) 46 | console.log({ schedulerJob }) 47 | 48 | job = scheduler.enrichJob(job, schedulerJob) 49 | console.log('enriched', { job }) 50 | 51 | // useful for testing 52 | // const state = 'open' 53 | // const job: types.CronJob = { 54 | // name: 'test', 55 | // id: 'foo', 56 | // lastAttemptTime: new Date(), 57 | // nextAttemptTime: new Date(), 58 | // slackWebhookUrl: 'TODO', 59 | // email: 'travis@saasify.sh', 60 | // status: { 61 | // code: 500, 62 | // message: 'Internal Kitty Error' 63 | // }, 64 | // state: 'enabled' 65 | // } as any 66 | // const incident = {} 67 | 68 | const stateLabel = stateLabels[state] || state 69 | const emojiLabel = emojiLabels[state] ? emojiLabels[state] + ' ' : '' 70 | 71 | await Promise.all([ 72 | slack.sendNotification({ job, incident, stateLabel, emojiLabel }), 73 | email.sendNotification({ job, incident, stateLabel, emojiLabel }) 74 | ]) 75 | 76 | ctx.body = 'ok' 77 | } 78 | -------------------------------------------------------------------------------- /src/routes.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable */ 2 | /* eslint-disable */ 3 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 4 | import { Controller, ValidationService, FieldErrors, ValidateError, TsoaRoute } from 'tsoa'; 5 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 6 | import { CronJobController } from './jobs'; 7 | import * as KoaRouter from 'koa-router'; 8 | 9 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 10 | 11 | const models: TsoaRoute.Models = { 12 | "HttpHeaders": { 13 | "dataType": "refObject", 14 | "properties": { 15 | }, 16 | "additionalProperties": { "dataType": "string" }, 17 | }, 18 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 19 | "HttpBody": { 20 | "dataType": "refObject", 21 | "properties": { 22 | }, 23 | "additionalProperties": { "dataType": "any" }, 24 | }, 25 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 26 | "HttpQuery": { 27 | "dataType": "refObject", 28 | "properties": { 29 | }, 30 | "additionalProperties": { "dataType": "string" }, 31 | }, 32 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 33 | "HttpStatus": { 34 | "dataType": "refObject", 35 | "properties": { 36 | "code": { "dataType": "double", "required": true }, 37 | "message": { "dataType": "string", "required": true }, 38 | }, 39 | "additionalProperties": true, 40 | }, 41 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 42 | "CronJob": { 43 | "dataType": "refObject", 44 | "properties": { 45 | "id": { "dataType": "string", "required": true }, 46 | "userId": { "dataType": "string", "required": true }, 47 | "createdAt": { "dataType": "datetime", "required": true }, 48 | "updatedAt": { "dataType": "datetime", "required": true }, 49 | "schedule": { "dataType": "string", "required": true }, 50 | "timezone": { "dataType": "string", "required": true }, 51 | "url": { "dataType": "string", "required": true }, 52 | "httpMethod": { "dataType": "enum", "enums": ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"], "required": true }, 53 | "httpHeaders": { "ref": "HttpHeaders", "required": true }, 54 | "httpBody": { "ref": "HttpBody", "required": true }, 55 | "httpQuery": { "ref": "HttpQuery", "required": true }, 56 | "name": { "dataType": "string", "required": true }, 57 | "description": { "dataType": "string", "required": true }, 58 | "tags": { "dataType": "array", "array": { "dataType": "string" }, "required": true }, 59 | "state": { "dataType": "enum", "enums": ["enabled", "disabled", "paused"], "required": true }, 60 | "timestamp": { "dataType": "datetime" }, 61 | "lastAttemptTime": { "dataType": "datetime" }, 62 | "nextAttemptTime": { "dataType": "datetime" }, 63 | "status": { "ref": "HttpStatus" }, 64 | "email": { "dataType": "string" }, 65 | "slackWebhookUrl": { "dataType": "string" }, 66 | "alertPolicy": { "dataType": "string" }, 67 | }, 68 | "additionalProperties": true, 69 | }, 70 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 71 | "CronJobCreateRequest": { 72 | "dataType": "refObject", 73 | "properties": { 74 | "schedule": { "dataType": "string", "required": true }, 75 | "timezone": { "dataType": "string" }, 76 | "url": { "dataType": "string", "required": true }, 77 | "httpMethod": { "dataType": "enum", "enums": ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"] }, 78 | "httpHeaders": { "ref": "HttpHeaders" }, 79 | "httpBody": { "ref": "HttpBody" }, 80 | "httpQuery": { "ref": "HttpQuery" }, 81 | "name": { "dataType": "string" }, 82 | "description": { "dataType": "string" }, 83 | "tags": { "dataType": "array", "array": { "dataType": "string" } }, 84 | "email": { "dataType": "string" }, 85 | "slackWebhookUrl": { "dataType": "string" }, 86 | }, 87 | "additionalProperties": true, 88 | }, 89 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 90 | "CronJobUpdateRequest": { 91 | "dataType": "refObject", 92 | "properties": { 93 | "state": { "dataType": "enum", "enums": ["enabled", "disabled", "paused"], "required": true }, 94 | }, 95 | "additionalProperties": true, 96 | }, 97 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 98 | "LogEntry": { 99 | "dataType": "refObject", 100 | "properties": { 101 | }, 102 | "additionalProperties": { "dataType": "any" }, 103 | }, 104 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 105 | }; 106 | const validationService = new ValidationService(models); 107 | 108 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 109 | 110 | export function RegisterRoutes(router: KoaRouter) { 111 | // ########################################################################################################### 112 | // NOTE: If you do not see routes for all of your controllers in this file, then you might not have informed tsoa of where to look 113 | // Please look into the "controllerPathGlobs" config option described in the readme: https://github.com/lukeautry/tsoa 114 | // ########################################################################################################### 115 | router.post('/jobs', 116 | async (context: any, next: any) => { 117 | const args = { 118 | body: { "in": "body", "name": "body", "required": true, "ref": "CronJobCreateRequest" }, 119 | userId: { "in": "header", "name": "x-saasify-user", "required": true, "dataType": "string" }, 120 | plan: { "in": "header", "name": "x-saasify-plan", "required": true, "dataType": "string" }, 121 | }; 122 | 123 | let validatedArgs: any[] = []; 124 | try { 125 | validatedArgs = getValidatedArgs(args, context); 126 | } catch (error) { 127 | context.status = error.status; 128 | context.throw(error.status, JSON.stringify({ fields: error.fields })); 129 | } 130 | 131 | const controller = new CronJobController(); 132 | 133 | const promise = controller.createJob.apply(controller, validatedArgs as any); 134 | return promiseHandler(controller, promise, context, next); 135 | }); 136 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 137 | router.get('/jobs/:jobId', 138 | async (context: any, next: any) => { 139 | const args = { 140 | jobId: { "in": "path", "name": "jobId", "required": true, "dataType": "string" }, 141 | userId: { "in": "header", "name": "x-saasify-user", "required": true, "dataType": "string" }, 142 | }; 143 | 144 | let validatedArgs: any[] = []; 145 | try { 146 | validatedArgs = getValidatedArgs(args, context); 147 | } catch (error) { 148 | context.status = error.status; 149 | context.throw(error.status, JSON.stringify({ fields: error.fields })); 150 | } 151 | 152 | const controller = new CronJobController(); 153 | 154 | const promise = controller.getJob.apply(controller, validatedArgs as any); 155 | return promiseHandler(controller, promise, context, next); 156 | }); 157 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 158 | router.delete('/jobs/:jobId', 159 | async (context: any, next: any) => { 160 | const args = { 161 | jobId: { "in": "path", "name": "jobId", "required": true, "dataType": "string" }, 162 | userId: { "in": "header", "name": "x-saasify-user", "required": true, "dataType": "string" }, 163 | plan: { "in": "header", "name": "x-saasify-plan", "required": true, "dataType": "string" }, 164 | }; 165 | 166 | let validatedArgs: any[] = []; 167 | try { 168 | validatedArgs = getValidatedArgs(args, context); 169 | } catch (error) { 170 | context.status = error.status; 171 | context.throw(error.status, JSON.stringify({ fields: error.fields })); 172 | } 173 | 174 | const controller = new CronJobController(); 175 | 176 | const promise = controller.removeJob.apply(controller, validatedArgs as any); 177 | return promiseHandler(controller, promise, context, next); 178 | }); 179 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 180 | router.get('/jobs', 181 | async (context: any, next: any) => { 182 | const args = { 183 | userId: { "in": "header", "name": "x-saasify-user", "required": true, "dataType": "string" }, 184 | offset: { "default": 0, "in": "query", "name": "offset", "dataType": "double" }, 185 | limit: { "default": 100, "in": "query", "name": "limit", "dataType": "double" }, 186 | }; 187 | 188 | let validatedArgs: any[] = []; 189 | try { 190 | validatedArgs = getValidatedArgs(args, context); 191 | } catch (error) { 192 | context.status = error.status; 193 | context.throw(error.status, JSON.stringify({ fields: error.fields })); 194 | } 195 | 196 | const controller = new CronJobController(); 197 | 198 | const promise = controller.listJobs.apply(controller, validatedArgs as any); 199 | return promiseHandler(controller, promise, context, next); 200 | }); 201 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 202 | router.put('/jobs/:jobId', 203 | async (context: any, next: any) => { 204 | const args = { 205 | jobId: { "in": "path", "name": "jobId", "required": true, "dataType": "string" }, 206 | body: { "in": "body", "name": "body", "required": true, "ref": "CronJobUpdateRequest" }, 207 | userId: { "in": "header", "name": "x-saasify-user", "required": true, "dataType": "string" }, 208 | }; 209 | 210 | let validatedArgs: any[] = []; 211 | try { 212 | validatedArgs = getValidatedArgs(args, context); 213 | } catch (error) { 214 | context.status = error.status; 215 | context.throw(error.status, JSON.stringify({ fields: error.fields })); 216 | } 217 | 218 | const controller = new CronJobController(); 219 | 220 | const promise = controller.updateJob.apply(controller, validatedArgs as any); 221 | return promiseHandler(controller, promise, context, next); 222 | }); 223 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 224 | router.get('/jobs/:jobId/logs', 225 | async (context: any, next: any) => { 226 | const args = { 227 | jobId: { "in": "path", "name": "jobId", "required": true, "dataType": "string" }, 228 | userId: { "in": "header", "name": "x-saasify-user", "required": true, "dataType": "string" }, 229 | limit: { "default": 25, "in": "query", "name": "limit", "dataType": "double" }, 230 | }; 231 | 232 | let validatedArgs: any[] = []; 233 | try { 234 | validatedArgs = getValidatedArgs(args, context); 235 | } catch (error) { 236 | context.status = error.status; 237 | context.throw(error.status, JSON.stringify({ fields: error.fields })); 238 | } 239 | 240 | const controller = new CronJobController(); 241 | 242 | const promise = controller.listJobLogs.apply(controller, validatedArgs as any); 243 | return promiseHandler(controller, promise, context, next); 244 | }); 245 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 246 | 247 | 248 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 249 | 250 | function isController(object: any): object is Controller { 251 | return 'getHeaders' in object && 'getStatus' in object && 'setStatus' in object; 252 | } 253 | 254 | function promiseHandler(controllerObj: any, promise: Promise, context: any, next: () => Promise) { 255 | return Promise.resolve(promise) 256 | .then((data: any) => { 257 | if (data || data === false) { 258 | context.body = data; 259 | context.status = 200; 260 | } else { 261 | context.status = 204; 262 | } 263 | 264 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 265 | 266 | if (isController(controllerObj)) { 267 | const headers = controllerObj.getHeaders(); 268 | Object.keys(headers).forEach((name: string) => { 269 | context.set(name, headers[name]); 270 | }); 271 | 272 | const statusCode = controllerObj.getStatus(); 273 | if (statusCode) { 274 | context.status = statusCode; 275 | } 276 | } 277 | return next(); 278 | }) 279 | .catch((error: any) => { 280 | context.status = error.status || 500; 281 | context.throw(context.status, error.message, error); 282 | }); 283 | } 284 | 285 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 286 | 287 | function getValidatedArgs(args: any, context: any): any[] { 288 | const errorFields: FieldErrors = {}; 289 | const values = Object.keys(args).map(key => { 290 | const name = args[key].name; 291 | switch (args[key].in) { 292 | case 'request': 293 | return context.request; 294 | case 'query': 295 | return validationService.ValidateParam(args[key], context.request.query[name], name, errorFields, undefined, { "specVersion": 3 }); 296 | case 'path': 297 | return validationService.ValidateParam(args[key], context.params[name], name, errorFields, undefined, { "specVersion": 3 }); 298 | case 'header': 299 | return validationService.ValidateParam(args[key], context.request.headers[name], name, errorFields, undefined, { "specVersion": 3 }); 300 | case 'body': 301 | return validationService.ValidateParam(args[key], context.request.body, name, errorFields, name + '.', { "specVersion": 3 }); 302 | case 'body-prop': 303 | return validationService.ValidateParam(args[key], context.request.body[name], name, errorFields, 'body.', { "specVersion": 3 }); 304 | } 305 | }); 306 | if (Object.keys(errorFields).length > 0) { 307 | throw new ValidateError(errorFields, ''); 308 | } 309 | return values; 310 | } 311 | 312 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 313 | } 314 | 315 | // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa 316 | -------------------------------------------------------------------------------- /src/scheduler.ts: -------------------------------------------------------------------------------- 1 | import grpc = require('grpc') 2 | import { URL } from 'url' 3 | import * as scheduler from '@google-cloud/scheduler' 4 | 5 | import * as grpcUtils from './grpc-utils' 6 | import * as types from './types' 7 | 8 | import Scheduler = scheduler.protos.google.cloud.scheduler.v1 9 | 10 | const projectId = process.env.GOOGLE_PROJECT_ID 11 | const projectLocation = process.env.GOOGLE_PROJECT_LOCATION 12 | 13 | const client = new scheduler.CloudSchedulerClient({ grpc: grpc as any }) 14 | const parent = client.locationPath(projectId, projectLocation) 15 | 16 | export async function createJob(job: types.CronJob): Promise { 17 | const jobResult = await client.createJob({ 18 | parent, 19 | job: cronJobToSchedulerJob(job) 20 | }) 21 | 22 | return jobResult[0] 23 | } 24 | 25 | export async function getJob(job: types.CronJob): Promise { 26 | const name = getSchedulerJobName(job) 27 | 28 | const jobResult = await client.getJob({ name }) 29 | 30 | return jobResult[0] 31 | } 32 | 33 | export async function deleteJob(job: types.CronJob): Promise { 34 | const name = getSchedulerJobName(job) 35 | 36 | await client.deleteJob({ name }) 37 | } 38 | 39 | export async function updateJob(job: types.CronJob): Promise { 40 | const name = getSchedulerJobName(job) 41 | let jobResult 42 | 43 | if (job.state === 'paused') { 44 | jobResult = await client.pauseJob({ name }) 45 | } else if (job.state === 'enabled') { 46 | jobResult = await client.resumeJob({ name }) 47 | } else if (job.state === 'disabled') { 48 | await client.pauseJob({ name }) 49 | 50 | jobResult = await client.updateJob({ 51 | job: cronJobToSchedulerJob(job) 52 | })[0] 53 | } 54 | 55 | return jobResult 56 | } 57 | 58 | function getSchedulerJobName(job: types.CronJob): string { 59 | return `${parent}/jobs/${job.id}` 60 | } 61 | 62 | function cronJobToSchedulerJob(job: types.CronJob): Scheduler.IJob { 63 | const name = getSchedulerJobName(job) 64 | 65 | const url = new URL(job.url) 66 | if (job.httpQuery) { 67 | for (const key of Object.keys(job.httpQuery)) { 68 | url.searchParams.set(key, job.httpQuery[key]) 69 | } 70 | } 71 | 72 | let state: Scheduler.Job.State = Scheduler.Job.State.STATE_UNSPECIFIED 73 | 74 | switch (job.state) { 75 | case 'enabled': 76 | state = Scheduler.Job.State.ENABLED 77 | break 78 | case 'disabled': 79 | state = Scheduler.Job.State.DISABLED 80 | break 81 | case 'paused': 82 | state = Scheduler.Job.State.PAUSED 83 | break 84 | } 85 | 86 | const schedulerJob = { 87 | name, 88 | description: job.description, 89 | schedule: job.schedule, 90 | timeZone: job.timezone, 91 | state, 92 | httpTarget: { 93 | uri: url.toString(), 94 | httpMethod: job.httpMethod, 95 | headers: job.httpHeaders, 96 | body: job.httpBody ? Buffer.from(JSON.stringify(job.httpBody)) : undefined 97 | } 98 | } 99 | 100 | // console.log({ schedulerJob }) 101 | return schedulerJob 102 | } 103 | 104 | export function enrichJob( 105 | job: types.CronJob, 106 | schedulerJob: Scheduler.IJob 107 | ): types.CronJob { 108 | if (schedulerJob.lastAttemptTime) { 109 | job.lastAttemptTime = new Date( 110 | +schedulerJob.lastAttemptTime.seconds * 1000 + 111 | +schedulerJob.lastAttemptTime.nanos / 10000 112 | ) 113 | } 114 | 115 | if (schedulerJob.scheduleTime) { 116 | job.nextAttemptTime = new Date( 117 | +schedulerJob.scheduleTime.seconds * 1000 + 118 | +schedulerJob.scheduleTime.nanos / 10000 119 | ) 120 | } 121 | 122 | if (schedulerJob.status) { 123 | job.status = grpcUtils.convertCode(schedulerJob.status.code) 124 | } 125 | 126 | return job 127 | } 128 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import './bootstrap' 2 | import './jobs' 3 | 4 | import * as http from 'http' 5 | import * as Koa from 'koa' 6 | import * as KoaRouter from 'koa-router' 7 | import * as bodyParser from 'koa-bodyparser' 8 | import * as gzip from 'koa-compress' 9 | import * as cors from '@koa/cors' 10 | 11 | import { RegisterRoutes } from './routes' 12 | import * as notifications from './notifications' 13 | 14 | const port = process.env.PORT || 4000 15 | 16 | const app = new Koa() 17 | app.use(cors()) 18 | app.use(bodyParser()) 19 | app.use(gzip()) 20 | 21 | app.use(async (ctx, next) => { 22 | console.log(ctx.request.url, ctx.request.method) 23 | 24 | await next() 25 | }) 26 | 27 | // tsoa magic 28 | const router = new KoaRouter() 29 | RegisterRoutes(router) 30 | 31 | router.post('/notification', notifications.handler) 32 | 33 | app.use(router.routes()).use(router.allowedMethods()) 34 | 35 | process.on('uncaughtException', (err) => { 36 | console.error(err) 37 | process.exit(1) 38 | }) 39 | 40 | const createAndRunServer = (): http.Server => { 41 | return app.listen(port, () => { 42 | console.log(`Listening on http://localhost:${port}`) 43 | }) 44 | } 45 | 46 | createAndRunServer() 47 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface HttpBody { 2 | [key: string]: any 3 | } 4 | 5 | export interface HttpQuery { 6 | [key: string]: string 7 | } 8 | 9 | export interface HttpHeaders { 10 | [key: string]: string 11 | } 12 | 13 | export interface LogEntry { 14 | [key: string]: any 15 | } 16 | 17 | export type CronJobState = 'enabled' | 'disabled' | 'paused' 18 | export type CronJobRunStatus = 'pending' | 'success' | 'failure' 19 | export type HttpMethod = 20 | | 'GET' 21 | | 'POST' 22 | | 'PUT' 23 | | 'DELETE' 24 | | 'PATCH' 25 | | 'HEAD' 26 | | 'OPTIONS' 27 | 28 | export interface CronJobCreateRequest { 29 | schedule: string 30 | timezone?: string 31 | 32 | url: string 33 | httpMethod?: HttpMethod 34 | httpHeaders?: HttpHeaders 35 | httpBody?: HttpBody 36 | httpQuery?: HttpQuery 37 | 38 | // metadata 39 | name?: string 40 | description?: string 41 | tags?: string[] 42 | 43 | // notifications 44 | email?: string 45 | slackWebhookUrl?: string 46 | } 47 | 48 | export interface CronJobUpdateRequest { 49 | state: CronJobState 50 | } 51 | 52 | export interface Model { 53 | id: string 54 | userId: string 55 | 56 | createdAt: Date 57 | updatedAt: Date 58 | } 59 | 60 | export interface HttpStatus { 61 | code: number 62 | message: string 63 | } 64 | 65 | export interface CronJob extends Model { 66 | schedule: string 67 | timezone: string 68 | 69 | url: string 70 | httpMethod: HttpMethod 71 | httpHeaders: HttpHeaders 72 | httpBody: HttpBody 73 | httpQuery: HttpQuery 74 | 75 | // metadata 76 | name: string 77 | description: string 78 | tags: string[] 79 | 80 | state: CronJobState 81 | 82 | timestamp?: Date 83 | lastAttemptTime?: Date 84 | nextAttemptTime?: Date 85 | 86 | status?: HttpStatus 87 | 88 | email?: string 89 | slackWebhookUrl?: string 90 | 91 | alertPolicy?: string 92 | } 93 | 94 | // export interface CronJobRun { 95 | // id: string 96 | // cronJob: string 97 | // status: CronJobRunStatus 98 | // httpStatus: number 99 | // } 100 | 101 | export interface LogOptions { 102 | limit?: number 103 | } 104 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "moduleResolution": "node", 5 | "allowJs": true, 6 | "experimentalDecorators": true, 7 | "emitDecoratorMetadata": true, 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true, 10 | "lib": ["esnext"], 11 | "outDir": "build" 12 | }, 13 | "include": ["src"] 14 | } 15 | -------------------------------------------------------------------------------- /tsoa.json: -------------------------------------------------------------------------------- 1 | { 2 | "swagger": { 3 | "specVersion": 3, 4 | "entryFile": "./src/server.ts", 5 | "outputDirectory": "dist" 6 | }, 7 | "routes": { 8 | "entryFile": "./src/server.ts", 9 | "routesDir": "./src", 10 | "middleware": "koa" 11 | }, 12 | "ignore": ["**/node_modules/**"] 13 | } 14 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | .now -------------------------------------------------------------------------------- /web/config-overrides.js: -------------------------------------------------------------------------------- 1 | const AntdDayjsWebpackPlugin = require('antd-dayjs-webpack-plugin') 2 | const { 3 | override, 4 | addWebpackPlugin, 5 | fixBabelImports, 6 | addDecoratorsLegacy, 7 | disableEsLint 8 | } = require('customize-cra') 9 | 10 | module.exports = override( 11 | addDecoratorsLegacy(), 12 | disableEsLint(), 13 | fixBabelImports('import', { 14 | libraryName: 'antd', 15 | libraryDirectory: 'es', 16 | style: 'css' 17 | }), 18 | addWebpackPlugin(new AntdDayjsWebpackPlugin()) 19 | ) 20 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-cron-web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "react-app-rewired start", 7 | "build": "react-app-rewired build", 8 | "test": "react-app-rewired test", 9 | "eject": "react-app-rewired eject", 10 | "deploy": "now --prod", 11 | "postdeploy": "now rm --safe --yes simple-cron-webapp" 12 | }, 13 | "dependencies": { 14 | "antd": "^3.26.6", 15 | "classnames": "^2.2.6", 16 | "compact-timezone-list": "^1.0.6", 17 | "cron-validator": "^1.1.1", 18 | "cronstrue": "^1.92.0", 19 | "date-fns": "^2.11.0", 20 | "prop-types": "^15.7.2", 21 | "react": "^16.13.1", 22 | "react-dom": "^16.13.1", 23 | "react-inspector": "^5.0.1", 24 | "react-router-dom": "^5.1.2", 25 | "saasify-sdk": "^1.18.11" 26 | }, 27 | "devDependencies": { 28 | "@babel/plugin-proposal-decorators": "^7.8.3", 29 | "antd-dayjs-webpack-plugin": "^0.0.9", 30 | "babel-plugin-import": "^1.13.0", 31 | "customize-cra": "^0.9.1", 32 | "react-app-rewired": "^2.1.5", 33 | "react-scripts": "^3.3.0" 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/saasify-sh/simple-cron/81c74d1497e5faae159c0982935c5fc37cfd14de/web/public/favicon.ico -------------------------------------------------------------------------------- /web/public/iframeResizer.contentWindow.min.js: -------------------------------------------------------------------------------- 1 | /*! iFrame Resizer (iframeSizer.contentWindow.min.js) - v4.2.10 - 2020-02-04 2 | * Desc: Include this file in any page being loaded into an iframe 3 | * to force the iframe to resize to the content size. 4 | * Requires: iframeResizer.min.js on host page. 5 | * Copyright: (c) 2020 David J. Bradshaw - dave@bradshaw.net 6 | * License: MIT 7 | */ 8 | 9 | !function(d){if("undefined"!=typeof window){var n=!0,o=10,i="",r=0,a="",t=null,u="",c=!1,s={resize:1,click:1},l=128,f=!0,m=1,h="bodyOffset",g=h,p=!0,v="",y={},b=32,w=null,T=!1,E="[iFrameSizer]",O=E.length,S="",M={max:1,min:1,bodyScroll:1,documentElementScroll:1},I="child",N=!0,A=window.parent,C="*",z=0,k=!1,e=null,R=16,x=1,L="scroll",F=L,P=window,D=function(){re("onMessage function not defined")},j=function(){},q=function(){},H={height:function(){return re("Custom height calculation function not defined"),document.documentElement.offsetHeight},width:function(){return re("Custom width calculation function not defined"),document.body.scrollWidth}},W={},B=!1;try{var J=Object.create({},{passive:{get:function(){B=!0}}});window.addEventListener("test",ee,J),window.removeEventListener("test",ee,J)}catch(e){}var U,V,K,Q,X,Y,G=Date.now||function(){return(new Date).getTime()},Z={bodyOffset:function(){return document.body.offsetHeight+pe("marginTop")+pe("marginBottom")},offset:function(){return Z.bodyOffset()},bodyScroll:function(){return document.body.scrollHeight},custom:function(){return H.height()},documentElementOffset:function(){return document.documentElement.offsetHeight},documentElementScroll:function(){return document.documentElement.scrollHeight},max:function(){return Math.max.apply(null,ye(Z))},min:function(){return Math.min.apply(null,ye(Z))},grow:function(){return Z.max()},lowestElement:function(){return Math.max(Z.bodyOffset()||Z.documentElementOffset(),ve("bottom",we()))},taggedElement:function(){return be("bottom","data-iframe-height")}},$={bodyScroll:function(){return document.body.scrollWidth},bodyOffset:function(){return document.body.offsetWidth},custom:function(){return H.width()},documentElementScroll:function(){return document.documentElement.scrollWidth},documentElementOffset:function(){return document.documentElement.offsetWidth},scroll:function(){return Math.max($.bodyScroll(),$.documentElementScroll())},max:function(){return Math.max.apply(null,ye($))},min:function(){return Math.min.apply(null,ye($))},rightMostElement:function(){return ve("right",we())},taggedElement:function(){return be("right","data-iframe-width")}},_=(U=Te,X=null,Y=0,function(){var e=G(),t=R-(e-(Y=Y||e));return V=this,K=arguments,t<=0||R 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | Simple Cron 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /web/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /web/readme.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `yarn start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `yarn test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `yarn build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `yarn eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `yarn build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /web/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /web/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { 4 | BrowserRouter as Router, 5 | Route, 6 | Switch, 7 | NavLink, 8 | withRouter 9 | } from 'react-router-dom' 10 | import { Breadcrumb, Spin } from 'antd' 11 | 12 | import { sdk } from './lib/sdk' 13 | import { JobsTable, JobLogsTable, Paper } from './components' 14 | 15 | import styles from './styles/app.module.css' 16 | 17 | const Layout = withRouter(({ location, children }) => { 18 | const pathSnippets = location.pathname.split('/').filter(Boolean) 19 | const extraBreadcrumbItems = pathSnippets.map((_, index) => { 20 | const url = `/${pathSnippets.slice(0, index + 1).join('/')}` 21 | 22 | return ( 23 | 24 | 25 | Job Logs 26 | 27 | 28 | ) 29 | }) 30 | 31 | const breadcrumbItems = [ 32 | 33 | 34 | Home 35 | 36 | 37 | ].concat(extraBreadcrumbItems) 38 | 39 | return ( 40 | 41 | {breadcrumbItems} 42 | 43 | {children} 44 | 45 | ) 46 | }) 47 | 48 | export class App extends React.Component { 49 | state = { 50 | status: 'loading' 51 | } 52 | 53 | componentDidMount() { 54 | sdk.ready 55 | .then(() => { 56 | this.setState({ status: 'ready' }) 57 | }) 58 | .catch((err) => { 59 | console.error(err) 60 | this.setState({ status: 'error' }) 61 | }) 62 | } 63 | 64 | render() { 65 | const { status } = this.state 66 | 67 | return ( 68 |
69 | {status === 'loading' && } 70 | {status === 'error' && 'Error connecting to Saasify'} 71 | {status === 'ready' && ( 72 | 73 | 74 | 75 | 81 | 82 | 87 | 88 | 89 | 90 | )} 91 |
92 | ) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /web/src/components/JobLogsTable/JobLogsTable.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import cs from 'classnames' 3 | 4 | import { ObjectInspector } from 'react-inspector' 5 | import { Button, Table, Tag, Tooltip, notification } from 'antd' 6 | import { format } from 'date-fns' 7 | 8 | import { sdk } from '../../lib/sdk' 9 | 10 | import styles from './styles.module.css' 11 | 12 | export class JobLogsTable extends Component { 13 | columns = [ 14 | { 15 | title: 'Timestamp', 16 | dataIndex: 'timestamp', 17 | render: (timestamp) => 18 | format(new Date(timestamp), 'MM/dd/yyyy HH:mm:ss OOOO') 19 | }, 20 | { 21 | title: 'Method', 22 | dataIndex: 'httpMethod' 23 | }, 24 | { 25 | title: 'URL', 26 | dataIndex: 'url', 27 | render: (url) => ( 28 | 29 | {url} 30 | 31 | ) 32 | }, 33 | { 34 | title: 'Type', 35 | dataIndex: 'severity', 36 | render: (severity) => { 37 | switch (severity) { 38 | case 'INFO': 39 | return Info 40 | case 'ERROR': 41 | return Error 42 | case 'WARN': 43 | return Warn 44 | default: 45 | return null 46 | } 47 | } 48 | }, 49 | { 50 | title: 'Status', 51 | dataIndex: 'status', 52 | render: (status) => { 53 | if (status) { 54 | if (status.code) { 55 | return {status.code} 56 | } else { 57 | return status.message 58 | } 59 | } 60 | 61 | return null 62 | } 63 | } 64 | ] 65 | 66 | state = { 67 | data: [], 68 | pagination: { 69 | pageSize: 10 70 | }, 71 | loading: true 72 | } 73 | 74 | componentDidMount() { 75 | this._fetch() 76 | } 77 | 78 | render() { 79 | const { match, className } = this.props 80 | const { data, pagination, loading } = this.state 81 | 82 | return ( 83 |
84 |

Job Logs - {match.params.jobId}

85 | 86 |
87 | 95 |
96 | 97 | 106 | 107 | ) 108 | } 109 | 110 | _renderExpandedRow = (record) => { 111 | return 112 | } 113 | 114 | _handleTableChange = (pagination, filters, sorter) => { 115 | const pager = { ...this.state.pagination } 116 | pager.current = pagination.current 117 | this.setState({ pagination: pager }) 118 | 119 | this._fetch({ 120 | results: pagination.pageSize, 121 | page: pagination.current, 122 | sortField: sorter.field, 123 | sortOrder: sorter.order, 124 | ...filters 125 | }) 126 | } 127 | 128 | _fetch = async (params = {}) => { 129 | const { jobId } = this.props.match.params 130 | let { data, pagination } = this.state 131 | 132 | if (params.reset) { 133 | data = [] 134 | params.page = 0 135 | } 136 | 137 | const offset = (params.page || 0) * pagination.pageSize 138 | 139 | if (!params.page || offset >= data.length) { 140 | this.setState({ loading: true }) 141 | 142 | const params = { limit: 100, offset } 143 | 144 | try { 145 | const { body: items } = await sdk.api.get(`/jobs/${jobId}/logs`, { 146 | params 147 | }) 148 | 149 | const pagination = { ...this.state.pagination } 150 | 151 | if (!items.length) { 152 | pagination.total = data.length 153 | } else { 154 | data = data.concat(items) 155 | pagination.total = data.length 156 | } 157 | 158 | this.setState({ 159 | loading: false, 160 | data, 161 | pagination 162 | }) 163 | } catch (err) { 164 | console.error('error loading', err) 165 | this.setState({ loading: false }) 166 | 167 | notification.error({ 168 | message: 'Error loading logs', 169 | description: err?.response?.data?.error || err.message, 170 | duration: 10 171 | }) 172 | } 173 | } 174 | } 175 | _onRefresh = () => { 176 | this._fetch({ reset: true }) 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /web/src/components/JobLogsTable/styles.module.css: -------------------------------------------------------------------------------- 1 | .body { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | 6 | .actions { 7 | align-self: flex-end; 8 | margin-bottom: 1em; 9 | } 10 | 11 | .actions > * { 12 | margin-right: 1em; 13 | } 14 | 15 | .actions > *:last-child { 16 | margin-right: 0; 17 | } 18 | -------------------------------------------------------------------------------- /web/src/components/JobsTable/JobsTable.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import cs from 'classnames' 3 | import cronstrue from 'cronstrue' 4 | 5 | import { format } from 'date-fns' 6 | import { Link } from 'react-router-dom' 7 | import { ObjectInspector } from 'react-inspector' 8 | import { 9 | Button, 10 | Divider, 11 | Modal, 12 | Table, 13 | Tag, 14 | Tooltip, 15 | message, 16 | notification 17 | } from 'antd' 18 | 19 | import { NewJobForm } from '../NewJobForm/NewJobForm' 20 | import { RemoveJobModal } from '../RemoveJobModal/RemoveJobModal' 21 | import { sdk } from '../../lib/sdk' 22 | 23 | import styles from './styles.module.css' 24 | 25 | export class JobsTable extends Component { 26 | columns = [ 27 | { 28 | title: 'Name', 29 | dataIndex: 'name', 30 | render: (id) => {id} 31 | }, 32 | { 33 | title: 'Created', 34 | dataIndex: 'createdAt', 35 | render: (timestamp) => ( 36 | 39 | {format(new Date(timestamp), 'MM/dd/yyyy')} 40 | 41 | ) 42 | }, 43 | { 44 | title: 'Schedule', 45 | dataIndex: 'schedule', 46 | render: (schedule, job) => ( 47 | 50 | {schedule} 51 | 52 | ) 53 | }, 54 | { 55 | title: 'Method', 56 | dataIndex: 'httpMethod' 57 | }, 58 | { 59 | title: 'URL', 60 | dataIndex: 'url', 61 | ellipsis: true, 62 | width: '12em', 63 | render: (url) => ( 64 | 65 | 66 | {url} 67 | 68 | 69 | ) 70 | }, 71 | { 72 | title: 'Status', 73 | dataIndex: 'state', 74 | render: (state) => { 75 | switch (state) { 76 | case 'enabled': 77 | return Enabled 78 | case 'disabled': 79 | return Disabled 80 | case 'paused': 81 | return Paused 82 | default: 83 | return Unknown 84 | } 85 | } 86 | }, 87 | { 88 | title: 'Last Run', 89 | dataIndex: 'lastAttemptTime', 90 | render: (timestamp) => 91 | timestamp ? ( 92 | 95 | {format(new Date(timestamp), 'MM/dd/yyyy')} 96 | 97 | ) : null 98 | }, 99 | { 100 | title: 'Result', 101 | dataIndex: 'status', 102 | render: (status) => 103 | status ? ( 104 | 105 | {status.code !== 200 ? {status.code} : status.code} 106 | 107 | ) : null 108 | }, 109 | { 110 | title: 'Logs', 111 | render: (text, record) => View Logs 112 | }, 113 | { 114 | title: 'Actions', 115 | render: (_, job) => ( 116 | 117 | {job.state === 'enabled' && ( 118 | 120 | this._onUpdateJob(event, job, { state: 'paused' }) 121 | } 122 | > 123 | Pause 124 | 125 | )} 126 | 127 | {job.state === 'paused' && ( 128 | 130 | this._onUpdateJob(event, job, { state: 'enabled' }) 131 | } 132 | > 133 | Resume 134 | 135 | )} 136 | 137 | 138 | 139 | this._onOpenRemoveJobModal(event, job)}> 140 | Delete 141 | 142 | 143 | ) 144 | } 145 | ] 146 | 147 | state = { 148 | data: [], 149 | pagination: { 150 | pageSize: 10 151 | }, 152 | loading: true, 153 | isOpenAddNewJobModal: false, 154 | isOpenRemoveJobModal: false, 155 | selectedJob: null 156 | } 157 | 158 | componentDidMount() { 159 | this._fetch() 160 | } 161 | 162 | render() { 163 | const { className } = this.props 164 | const { 165 | data, 166 | pagination, 167 | loading, 168 | isOpenAddNewJobModal, 169 | isOpenRemoveJobModal, 170 | selectedJob 171 | } = this.state 172 | 173 | return ( 174 |
175 |

Scheduled Jobs

176 | 177 |
178 | 186 | 187 | 194 |
195 | 196 |
205 | 206 | 212 | {isOpenAddNewJobModal && ( 213 | 217 | )} 218 | 219 | 220 | {isOpenRemoveJobModal && ( 221 | 227 | )} 228 | 229 | ) 230 | } 231 | 232 | _renderExpandedRow = (record) => { 233 | return 234 | } 235 | 236 | _handleTableChange = (pagination, filters, sorter) => { 237 | const pager = { ...this.state.pagination } 238 | pager.current = pagination.current 239 | this.setState({ pagination: pager }) 240 | 241 | this._fetch({ 242 | page: pagination.current, 243 | sortField: sorter.field, 244 | sortOrder: sorter.order, 245 | ...filters 246 | }) 247 | } 248 | 249 | _fetch = async (params = {}) => { 250 | let { data, pagination } = this.state 251 | 252 | if (params.reset) { 253 | data = [] 254 | params.page = 0 255 | } 256 | 257 | const offset = (params.page || 0) * pagination.pageSize 258 | 259 | if (!params.page || offset >= data.length) { 260 | this.setState({ loading: true }) 261 | 262 | const params = { limit: 25, offset } 263 | 264 | try { 265 | const { body: items } = await sdk.api.get('/jobs', { 266 | params 267 | }) 268 | 269 | const pagination = { ...this.state.pagination } 270 | 271 | if (!items.length) { 272 | pagination.total = data.length 273 | } else { 274 | data = data.concat(items) 275 | pagination.total = data.length 276 | } 277 | 278 | this.setState({ 279 | loading: false, 280 | data, 281 | pagination 282 | }) 283 | } catch (err) { 284 | console.error('error loading', err) 285 | this.setState({ 286 | loading: false 287 | }) 288 | } 289 | } 290 | } 291 | 292 | _onRefresh = () => { 293 | this._fetch({ reset: true }) 294 | } 295 | 296 | _onOpenAddNewJobModal = () => { 297 | this.setState({ isOpenAddNewJobModal: true }) 298 | } 299 | 300 | _onDoneAddNewJobModal = () => { 301 | this._onRefresh() 302 | this._onCloseAddNewJobModal() 303 | } 304 | 305 | _onCloseAddNewJobModal = () => { 306 | this.setState({ isOpenAddNewJobModal: false }) 307 | } 308 | 309 | _onOpenRemoveJobModal = (event, job) => { 310 | event.stopPropagation() 311 | this.setState({ isOpenRemoveJobModal: true, selectedJob: job }) 312 | } 313 | 314 | _onDoneRemoveJobModal = () => { 315 | this._onRefresh() 316 | this._onCloseRemoveJobModal() 317 | } 318 | 319 | _onCloseRemoveJobModal = () => { 320 | this.setState({ isOpenRemoveJobModal: false }) 321 | } 322 | 323 | _onUpdateJob = (event, job, data) => { 324 | event.stopPropagation() 325 | this.setState({ loading: true }) 326 | 327 | sdk.api 328 | .put(`/jobs/${job.id}`, { data }) 329 | .then(() => { 330 | message.success('Job updated') 331 | this._onRefresh() 332 | }) 333 | .catch((err) => { 334 | console.error(err) 335 | this.setState({ loading: false }) 336 | 337 | notification.error({ 338 | message: 'Error updating job', 339 | description: err?.response?.data?.error || err.message, 340 | duration: 10 341 | }) 342 | }) 343 | } 344 | } 345 | -------------------------------------------------------------------------------- /web/src/components/JobsTable/styles.module.css: -------------------------------------------------------------------------------- 1 | .body { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | 6 | .actions { 7 | align-self: flex-end; 8 | margin-bottom: 1em; 9 | } 10 | 11 | .actions > * { 12 | margin-right: 1em; 13 | } 14 | 15 | .actions > *:last-child { 16 | margin-right: 0; 17 | } 18 | -------------------------------------------------------------------------------- /web/src/components/NewJobForm/NewJobForm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import cs from 'classnames' 4 | import { isValidCron } from 'cron-validator' 5 | import { minimalTimezoneSet } from 'compact-timezone-list' 6 | 7 | import { 8 | Button, 9 | Divider, 10 | Form, 11 | Icon, 12 | Input, 13 | Select, 14 | Tooltip, 15 | notification 16 | } from 'antd' 17 | 18 | import { sdk } from '../../lib/sdk' 19 | 20 | import styles from './styles.module.css' 21 | 22 | const formItemLayout = { 23 | labelCol: { 24 | xs: { span: 24 }, 25 | sm: { span: 6 } 26 | }, 27 | wrapperCol: { 28 | xs: { span: 24 }, 29 | sm: { span: 18 } 30 | } 31 | } 32 | 33 | @Form.create() 34 | export class NewJobForm extends Component { 35 | static propTypes = { 36 | form: PropTypes.object.isRequired, 37 | className: PropTypes.string, 38 | onCancel: PropTypes.func, 39 | onDone: PropTypes.func 40 | } 41 | 42 | state = { 43 | loading: false 44 | } 45 | 46 | render() { 47 | const { className, form } = this.props 48 | const { getFieldDecorator } = form 49 | const { loading } = this.state 50 | 51 | return ( 52 |
53 | 54 | {getFieldDecorator('name', { 55 | rules: [ 56 | { required: true, message: 'Please enter a name for this job.' } 57 | ] 58 | })()} 59 | 60 | 61 | {/* 62 | {getFieldDecorator('description')( 63 | 64 | )} 65 | */} 66 | 67 | 68 | {getFieldDecorator('schedule', { 69 | initialValue: '* * * * *', 70 | rules: [ 71 | { 72 | required: true, 73 | message: 'Please enter a cron schedule.' 74 | }, 75 | { 76 | validator: (rule, value, cb) => { 77 | if (!isValidCron(value)) { 78 | return cb('Please enter a valid cron expression.') 79 | } else { 80 | return cb() 81 | } 82 | } 83 | } 84 | ] 85 | })( 86 | 90 | 94 | 95 | } 96 | /> 97 | )} 98 | 99 | 100 | 101 | {getFieldDecorator('timezone', { 102 | initialValue: 'America/New_York' 103 | })( 104 | 111 | )} 112 | 113 | 114 | 115 | 116 | 117 | {getFieldDecorator('url', { 118 | rules: [ 119 | { 120 | required: true, 121 | message: 'Please enter a URL to target.' 122 | }, 123 | { 124 | type: 'url', 125 | message: 'Please enter a valid URL.' 126 | } 127 | ] 128 | })()} 129 | 130 | 131 | 132 | {getFieldDecorator('httpMethod', { 133 | initialValue: 'GET' 134 | })( 135 | 144 | )} 145 | 146 | 147 | {/* TODO: add httpHeaders, httpBody, and httpQuery */} 148 | 149 | 150 | 151 | {/*
*/} 157 | 158 | {getFieldDecorator('email', { 159 | rules: [ 160 | { 161 | type: 'email', 162 | message: 'Please enter a valid email.' 163 | } 164 | ] 165 | })()} 166 | 167 | 168 | 169 | {getFieldDecorator('slackWebhookUrl', { 170 | rules: [ 171 | { 172 | type: 'url', 173 | message: 'Please enter a valid URL.' 174 | }, 175 | { 176 | validator: (rule, value, cb) => { 177 | if ( 178 | value && 179 | !value.startsWith('https://hooks.slack.com/services/') 180 | ) { 181 | return cb( 182 | 'Please enter a valid Slack webhook URL: https://hooks.slack.com/services/...' 183 | ) 184 | } else { 185 | return cb() 186 | } 187 | } 188 | } 189 | ] 190 | })()} 191 | 192 | 193 |
194 | 195 | 196 | 205 |
206 | 207 | ) 208 | } 209 | 210 | _onSubmit = (e) => { 211 | e.preventDefault() 212 | 213 | this.props.form.validateFields((err, data) => { 214 | if (!err) { 215 | this.setState({ loading: true }) 216 | 217 | sdk.api 218 | .post('/jobs', { data }) 219 | .then(this.props.onDone) 220 | .catch((err) => { 221 | console.error(err) 222 | this.setState({ loading: false }) 223 | 224 | notification.error({ 225 | message: 'Error creating job', 226 | description: err?.response?.data?.error || err.message, 227 | duration: 10 228 | }) 229 | 230 | if (err?.response?.status === 402) { 231 | this.props.onCancel() 232 | } 233 | }) 234 | } 235 | }) 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /web/src/components/NewJobForm/styles.module.css: -------------------------------------------------------------------------------- 1 | .footer { 2 | display: flex; 3 | flex-direction: row; 4 | justify-content: space-between; 5 | } 6 | 7 | /* 8 | .submit { 9 | width: 100%; 10 | padding: 0.4em 1em; 11 | height: initial; 12 | } 13 | 14 | :global(.ant-form-item) { 15 | margin-bottom: 16px; 16 | } 17 | */ 18 | /* 19 | .collapsible { 20 | transition: max-height 150ms ease-out; 21 | max-height: 0; 22 | overflow: hidden; 23 | } 24 | 25 | .expanded { 26 | max-height: 500px; 27 | } */ 28 | -------------------------------------------------------------------------------- /web/src/components/Paper/Paper.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import cs from 'classnames' 3 | 4 | import styles from './styles.module.css' 5 | 6 | export class Paper extends Component { 7 | render() { 8 | const { className, ...rest } = this.props 9 | 10 | return
11 | } 12 | } 13 | -------------------------------------------------------------------------------- /web/src/components/Paper/styles.module.css: -------------------------------------------------------------------------------- 1 | .paper { 2 | position: relative; 3 | padding: 24px; 4 | 5 | background: #fff; 6 | border-radius: 4px; 7 | box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.2), 8 | 0px 1px 1px 0px rgba(0, 0, 0, 0.14), 0px 2px 1px -1px rgba(0, 0, 0, 0.12); 9 | } 10 | -------------------------------------------------------------------------------- /web/src/components/RemoveJobModal/RemoveJobModal.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | 3 | import { Modal, notification } from 'antd' 4 | 5 | import { sdk } from '../../lib/sdk' 6 | 7 | export class RemoveJobModal extends Component { 8 | state = { 9 | loading: false 10 | } 11 | 12 | render() { 13 | const { isOpen, job, onCancel } = this.props 14 | const { loading } = this.state 15 | 16 | return ( 17 | 26 | {job && ( 27 |
Are you sure you want to remove job "{job.name || job.id}"?
28 | )} 29 |
30 | ) 31 | } 32 | 33 | _onConfirm = () => { 34 | this.setState({ loading: true }) 35 | sdk.api 36 | .delete(`/jobs/${this.props.job.id}`) 37 | .then(this.props.onDone) 38 | .catch((err) => { 39 | console.error(err) 40 | this.setState({ loading: false }) 41 | 42 | notification.error({ 43 | message: 'Error removing job', 44 | description: err?.response?.data?.error || err.message, 45 | duration: 10 46 | }) 47 | }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /web/src/components/index.js: -------------------------------------------------------------------------------- 1 | export * from './JobsTable/JobsTable' 2 | export * from './JobLogsTable/JobLogsTable' 3 | export * from './Paper/Paper' 4 | -------------------------------------------------------------------------------- /web/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | import { App } from './App' 5 | import './styles/global.css' 6 | 7 | ReactDOM.render(, document.getElementById('root')) 8 | -------------------------------------------------------------------------------- /web/src/lib/sdk.js: -------------------------------------------------------------------------------- 1 | import SaasifySDK from 'saasify-sdk' 2 | 3 | export const sdk = new SaasifySDK({ 4 | projectId: 'dev/simple-cron', 5 | developmentToken: process.env.REACT_APP_SAASIFY_TOKEN, 6 | developmentTargetUrl: 'http://localhost:4000' 7 | }) 8 | 9 | // backendDevUrl vs backendUrl 10 | -------------------------------------------------------------------------------- /web/src/styles/app.module.css: -------------------------------------------------------------------------------- 1 | .body { 2 | width: 100%; 3 | 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: center; 7 | align-items: center; 8 | } 9 | 10 | .content { 11 | max-width: 1280px; 12 | width: 100%; 13 | } 14 | 15 | .breadcrumb { 16 | margin-bottom: 1em; 17 | } 18 | -------------------------------------------------------------------------------- /web/src/styles/global.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | background-color: transparent !important; 7 | margin: 0; 8 | padding: 24px 0; 9 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 10 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 11 | sans-serif; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | code { 17 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 18 | monospace; 19 | } 20 | 21 | .ant-form-item { 22 | margin-bottom: 1em !important; 23 | } 24 | --------------------------------------------------------------------------------