├── .eslintignore ├── src ├── index.ts ├── site.ts ├── base.ts ├── helpers │ ├── log-color.ts │ ├── secrets.ts │ ├── ping.ts │ ├── constants.ts │ ├── config.ts │ ├── overlap.ts │ ├── environment.ts │ ├── should-continue.ts │ ├── overlap.spec.ts │ ├── get-badge-color.ts │ ├── git.ts │ ├── notifications.ts │ ├── request.ts │ ├── log.ts │ ├── calculate-response-time.ts │ ├── calculate-uptime.ts │ ├── graphs.ts │ ├── incidents.ts │ ├── notifme.ts │ ├── update-env-file.ts │ └── notifme-sdk.d.ts ├── commands │ ├── docs.ts │ ├── status.ts │ ├── init.ts │ ├── incidents.ts │ ├── run.ts │ └── config.ts ├── help.ts ├── graphs.ts ├── interfaces.ts ├── summary.ts └── update.ts ├── bin ├── run.cmd └── run ├── .eslintrc ├── test ├── mocha.opts ├── tsconfig.json └── commands │ ├── docs.test.ts │ ├── init.test.ts │ ├── run.test.ts │ ├── config.test.ts │ ├── status.test.ts │ └── incidents.test.ts ├── .gitignore ├── .editorconfig ├── incidents.yml ├── .uclirc.yml ├── tsconfig.json ├── LICENSE ├── .github └── workflows │ └── cacert.yml ├── .circleci └── config.yml ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | /lib 2 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {run} from '@oclif/command' 2 | -------------------------------------------------------------------------------- /bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "oclif", 4 | "oclif-typescript" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require ts-node/register 2 | --watch-extensions ts 3 | --recursive 4 | --reporter spec 5 | --timeout 5000 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | /.nyc_output 3 | /dist 4 | /lib 5 | /package-lock.json 6 | /tmp 7 | node_modules 8 | .DS_Store 9 | .uclirc.yml 10 | history 11 | -------------------------------------------------------------------------------- /bin/run: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | require('@oclif/command').run() 4 | .then(require('@oclif/command/flush')) 5 | .catch(require('@oclif/errors/handle')) 6 | -------------------------------------------------------------------------------- /src/site.ts: -------------------------------------------------------------------------------- 1 | import {infoErrorLogger} from './helpers/log' 2 | 3 | export const generateSite = async () => { 4 | infoErrorLogger.info('Generate Site') 5 | } 6 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig", 3 | "compilerOptions": { 4 | "noEmit": true 5 | }, 6 | "references": [ 7 | {"path": ".."} 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /incidents.yml: -------------------------------------------------------------------------------- 1 | # example incidents 2 | incidents: 3 | - name: Github 4 | url: https://www.github.com 5 | timestamp: 2021-09-05T12:25:05.060Z 6 | - name: Wikipedia 7 | url: https://www.wikipedia.org 8 | timestamp: 2021-09-05T12:15:02.316Z -------------------------------------------------------------------------------- /.uclirc.yml: -------------------------------------------------------------------------------- 1 | sites: 2 | - name: Github 3 | url: https://www.github.com 4 | maxRedirects: 10 5 | - name: Wikipedia 6 | url: https://www.wikipedia.org 7 | - name: Google 8 | url: https://www.google.com 9 | - name: Yahoo 10 | url: https://in.yahoo.com -------------------------------------------------------------------------------- /src/base.ts: -------------------------------------------------------------------------------- 1 | import Command from '@oclif/command' 2 | import {createLoggers} from './helpers/log' 3 | import {config} from 'dotenv' 4 | 5 | export default abstract class extends Command { 6 | async init() { 7 | await createLoggers() 8 | config() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/helpers/log-color.ts: -------------------------------------------------------------------------------- 1 | import {getConfig} from './config' 2 | 3 | export let isLogColor: undefined | boolean 4 | export const getIsLogColor = async () => { 5 | if (isLogColor) return isLogColor 6 | const config = await getConfig() 7 | isLogColor = config.logs?.colors 8 | return isLogColor 9 | } 10 | -------------------------------------------------------------------------------- /src/commands/docs.ts: -------------------------------------------------------------------------------- 1 | import Command from '../base' 2 | import cli from 'cli-ux' 3 | export default class Docs extends Command { 4 | static description = 'redirects to Upptime docs' 5 | 6 | async run() { 7 | this.log('redirecting to https://upptime.js.org/') 8 | cli.open('https://upptime.js.org/') 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "importHelpers": true, 5 | "module": "commonjs", 6 | "outDir": "lib", 7 | "rootDir": "src", 8 | "strict": true, 9 | "target": "es2017", 10 | "esModuleInterop": true 11 | }, 12 | "include": [ 13 | "src/**/*" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/helpers/secrets.ts: -------------------------------------------------------------------------------- 1 | /** Get a secret from the context or an environment variable */ 2 | 3 | export const getSecret = (key: string) => { 4 | const SECRETS_CONTEXT = process.env.SECRETS_CONTEXT || '{}' 5 | const allSecrets: Record = JSON.parse(SECRETS_CONTEXT) 6 | if (allSecrets[key]) { 7 | return allSecrets[key] 8 | } 9 | return process.env[key] 10 | } 11 | -------------------------------------------------------------------------------- /src/help.ts: -------------------------------------------------------------------------------- 1 | import Help from '@oclif/plugin-help' 2 | import chalk from 'chalk' 3 | import figlet from 'figlet' 4 | 5 | export default class MyHelpClass extends Help { 6 | // the formatting responsible for the header 7 | // displayed for the root help 8 | formatRoot(): string { 9 | return `\n${chalk.green(figlet.textSync('Upptime', {font: 'ANSI Shadow'}))}\n${super.formatRoot()}` 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/helpers/ping.ts: -------------------------------------------------------------------------------- 1 | import {ping as tcpp} from 'tcp-ping' 2 | import type {Options, Result} from 'tcp-ping' 3 | 4 | /** 5 | * Promisified TCP pinging 6 | * @param options - tcpp.ping options 7 | */ 8 | 9 | export const ping = (options: Options) => 10 | new Promise((resolve, reject) => { 11 | tcpp(options, (error, data) => { 12 | if (error) return reject(error) 13 | resolve(data) 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /src/helpers/constants.ts: -------------------------------------------------------------------------------- 1 | export const GRAPHS_CI_SCHEDULE = '0 0 * * *' 2 | export const RESPONSE_TIME_CI_SCHEDULE = '0 23 * * *' 3 | export const STATIC_SITE_CI_SCHEDULE = '0 1 * * *' 4 | export const SUMMARY_CI_SCHEDULE = '0 0 * * *' 5 | export const UPDATE_TEMPLATE_CI_SCHEDULE = '0 0 * * *' 6 | export const UPDATES_CI_SCHEDULE = '0 3 * * *' 7 | export const UPTIME_CI_SCHEDULE = '*/5 * * * *' 8 | export const DEFAULT_RUNNER = 'ubuntu-18.04' 9 | -------------------------------------------------------------------------------- /test/commands/docs.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, test} from '@oclif/test' 2 | 3 | describe('docs', () => { 4 | test 5 | .stdout() 6 | .command(['docs']) 7 | .it('runs hello', ctx => { 8 | expect(ctx.stdout).to.contain('hello world') 9 | }) 10 | 11 | test 12 | .stdout() 13 | .command(['docs', '--name', 'jeff']) 14 | .it('runs hello --name jeff', ctx => { 15 | expect(ctx.stdout).to.contain('hello jeff') 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /test/commands/init.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, test} from '@oclif/test' 2 | 3 | describe('init', () => { 4 | test 5 | .stdout() 6 | .command(['init']) 7 | .it('runs hello', ctx => { 8 | expect(ctx.stdout).to.contain('hello world') 9 | }) 10 | 11 | test 12 | .stdout() 13 | .command(['init', '--name', 'jeff']) 14 | .it('runs hello --name jeff', ctx => { 15 | expect(ctx.stdout).to.contain('hello jeff') 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /test/commands/run.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, test} from '@oclif/test' 2 | 3 | describe('run', () => { 4 | test 5 | .stdout() 6 | .command(['run']) 7 | .it('runs hello', ctx => { 8 | expect(ctx.stdout).to.contain('hello world') 9 | }) 10 | 11 | test 12 | .stdout() 13 | .command(['run', '--name', 'jeff']) 14 | .it('runs hello --name jeff', ctx => { 15 | expect(ctx.stdout).to.contain('hello jeff') 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/helpers/config.ts: -------------------------------------------------------------------------------- 1 | import {load} from 'js-yaml' 2 | import {readFile} from 'fs-extra' 3 | import {join} from 'path' 4 | import {UppConfig} from '../interfaces' 5 | 6 | let __config: UppConfig | undefined 7 | 8 | export const getConfig = async (): Promise => { 9 | if (__config) return __config 10 | const config = load(await readFile(join('.', '.uclirc.yml'), 'utf8')) as UppConfig 11 | __config = config 12 | return config 13 | } 14 | -------------------------------------------------------------------------------- /src/helpers/overlap.ts: -------------------------------------------------------------------------------- 1 | interface Range { 2 | start: number; 3 | end: number; 4 | } 5 | 6 | /** 7 | * Get the overlap between two numbers 8 | */ 9 | 10 | export const checkOverlap = (a: Range, b: Range): number => { 11 | const min = a.start < b.start ? a : b 12 | const max = min.start === a.start && min.end === a.end ? b : a 13 | if (min.end < max.start) return 0 14 | return (min.end < max.end ? min.end : max.end) - max.start 15 | } 16 | -------------------------------------------------------------------------------- /test/commands/config.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, test} from '@oclif/test' 2 | 3 | describe('config', () => { 4 | test 5 | .stdout() 6 | .command(['config']) 7 | .it('runs hello', ctx => { 8 | expect(ctx.stdout).to.contain('hello world') 9 | }) 10 | 11 | test 12 | .stdout() 13 | .command(['config', '--name', 'jeff']) 14 | .it('runs hello --name jeff', ctx => { 15 | expect(ctx.stdout).to.contain('hello jeff') 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /test/commands/status.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, test} from '@oclif/test' 2 | 3 | describe('status', () => { 4 | test 5 | .stdout() 6 | .command(['status']) 7 | .it('runs hello', ctx => { 8 | expect(ctx.stdout).to.contain('hello world') 9 | }) 10 | 11 | test 12 | .stdout() 13 | .command(['status', '--name', 'jeff']) 14 | .it('runs hello --name jeff', ctx => { 15 | expect(ctx.stdout).to.contain('hello jeff') 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /test/commands/incidents.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, test} from '@oclif/test' 2 | 3 | describe('incidents', () => { 4 | test 5 | .stdout() 6 | .command(['incidents']) 7 | .it('runs hello', ctx => { 8 | expect(ctx.stdout).to.contain('hello world') 9 | }) 10 | 11 | test 12 | .stdout() 13 | .command(['incidents', '--name', 'jeff']) 14 | .it('runs hello --name jeff', ctx => { 15 | expect(ctx.stdout).to.contain('hello jeff') 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/helpers/environment.ts: -------------------------------------------------------------------------------- 1 | export const replaceEnvironmentVariables = (str: string) => { 2 | Object.keys(process.env).forEach(key => { 3 | str = str.replace(`$${key}`, process.env[key] || `$${key}`) 4 | }) 5 | const SECRETS_CONTEXT = process.env.SECRETS_CONTEXT || '{}' 6 | const allSecrets: Record = JSON.parse(SECRETS_CONTEXT) 7 | const secrets: Record = {...JSON.parse(JSON.stringify(process.env)), allSecrets} 8 | Object.keys(secrets).forEach(key => { 9 | str = str.replace(`$${key}`, secrets[key] || `$${key}`) 10 | }) 11 | return str 12 | } 13 | -------------------------------------------------------------------------------- /src/helpers/should-continue.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk' 2 | import {existsSync} from 'fs-extra' 3 | 4 | export class ShouldContinue { 5 | message: string | undefined 6 | 7 | continue: boolean 8 | 9 | constructor(shouldContinue: boolean, message?: string) { 10 | this.continue = shouldContinue 11 | this.message = message 12 | } 13 | } 14 | 15 | export async function shouldContinue() { 16 | if (!existsSync('.uclirc.yml')) { 17 | return new ShouldContinue(false, chalk.red('❌ Repository is not Upptime Initialized') + '\n' + chalk.blue('Try ') + chalk.yellow('upp run')) 18 | } 19 | return new ShouldContinue(true) 20 | } 21 | -------------------------------------------------------------------------------- /src/helpers/overlap.spec.ts: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai' 2 | import {checkOverlap} from './overlap' 3 | 4 | describe('checkOverlap', () => { 5 | it('partial overlap', () => { 6 | expect(checkOverlap({start: 14, end: 17}, {start: 16, end: 19})).equal(1) 7 | }) 8 | 9 | it('partial overlap (opposite)', () => { 10 | expect(checkOverlap({start: 16, end: 19}, {start: 14, end: 17})).equal(1) 11 | }) 12 | 13 | it('full overlap', () => { 14 | expect(checkOverlap({start: 14, end: 17}, {start: 13, end: 18})).equal(3) 15 | }) 16 | 17 | it('no overlap', () => { 18 | expect(checkOverlap({start: 14, end: 17}, {start: 19, end: 21})).equal(0) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /src/helpers/get-badge-color.ts: -------------------------------------------------------------------------------- 1 | export const getResponseTimeColor = (responseTime: number) => 2 | responseTime === 0 ? 3 | 'red' : 4 | responseTime < 200 ? 5 | 'brightgreen' : 6 | responseTime < 400 ? 7 | 'green' : 8 | responseTime < 600 ? 9 | 'yellowgreen' : 10 | responseTime < 800 ? 11 | 'yellow' : 12 | responseTime < 1000 ? 13 | 'orange' : 14 | 'red' 15 | 16 | export const getUptimeColor = (uptime: string | number) => { 17 | if (typeof (uptime) === 'string') 18 | uptime = Number(uptime.split('%')[0]) 19 | 20 | return uptime > 95 ? 21 | 'brightgreen' : 22 | uptime > 90 ? 23 | 'green' : 24 | uptime > 85 ? 25 | 'yellowgreen' : 26 | uptime > 80 ? 27 | 'yellow' : 28 | uptime > 75 ? 29 | 'orange' : 30 | 'red' 31 | } 32 | 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Anand Chowdhary 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/cacert.yml: -------------------------------------------------------------------------------- 1 | name: Update CACERT 2 | on: 3 | schedule: 4 | - cron: "0 0 */15 * *" 5 | repository_dispatch: 6 | types: [cacert] 7 | workflow_dispatch: 8 | jobs: 9 | update: 10 | name: Update CACERT 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2.3.3 15 | with: 16 | persist-credentials: false 17 | fetch-depth: 0 18 | - name: Get CACERT extracted by curl 19 | run: | 20 | curl --remote-name --time-cond cacert.pem https://curl.se/ca/cacert.pem 21 | - name: Stage and commit changes 22 | id: commit 23 | continue-on-error: true 24 | run: | 25 | git add . 26 | git config --local user.email "73812536+upptime-bot@users.noreply.github.com" 27 | git config --local user.name "Upptime Bot" 28 | git commit -m ":arrow_up: update CACERT" 29 | - name: Push changes 30 | if: steps.commit.outcome == 'success' 31 | uses: ad-m/github-push-action@master 32 | with: 33 | github_token: ${{ secrets.GH_PAT }} 34 | branch: ${{ github.ref }} -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | jobs: 4 | node-latest: &test 5 | docker: 6 | - image: node:latest 7 | working_directory: ~/cli 8 | steps: 9 | - checkout 10 | - restore_cache: &restore_cache 11 | keys: 12 | - v1-npm-{{checksum ".circleci/config.yml"}}-{{checksum "yarn.lock"}} 13 | - v1-npm-{{checksum ".circleci/config.yml"}} 14 | - run: 15 | name: Install dependencies 16 | command: yarn 17 | - run: ./bin/run --version 18 | - run: ./bin/run --help 19 | - run: 20 | name: Testing 21 | command: yarn test 22 | node-12: 23 | <<: *test 24 | docker: 25 | - image: node:12 26 | node-10: 27 | <<: *test 28 | docker: 29 | - image: node:10 30 | cache: 31 | <<: *test 32 | steps: 33 | - checkout 34 | - run: 35 | name: Install dependencies 36 | command: yarn 37 | - save_cache: 38 | key: v1-npm-{{checksum ".circleci/config.yml"}}-{{checksum "yarn.lock"}} 39 | paths: 40 | - ~/cli/node_modules 41 | - /usr/local/share/.cache/yarn 42 | - /usr/local/share/.config/yarn 43 | 44 | workflows: 45 | version: 2 46 | "upp": 47 | jobs: 48 | - node-latest 49 | - node-12 50 | - node-10 51 | - cache: 52 | filters: 53 | tags: 54 | only: /^v.*/ 55 | branches: 56 | ignore: /.*/ 57 | -------------------------------------------------------------------------------- /src/helpers/git.ts: -------------------------------------------------------------------------------- 1 | import {exec} from 'shelljs' 2 | import {infoErrorLogger} from './log' 3 | 4 | const _getNameAndEmail = () => { 5 | const currName = exec('git config --list', {silent: true}).grep('user.name=').stdout.split( 6 | 'user.name=' 7 | ).pop()?.trim() 8 | const currEmail = exec('git config --list', {silent: true}).grep('user.email=').stdout.split( 9 | 'user.email=' 10 | ).pop()?.trim() 11 | return {currName, currEmail} 12 | } 13 | 14 | const _setNameAndEmail = ( 15 | name = 'Upptime Bot', 16 | email = '73812536+upptime-bot@users.noreply.github.com' 17 | ) => { 18 | exec(`git config user.email "${email}"`) 19 | exec(`git config user.name "${name}"`) 20 | } 21 | 22 | export const commit = ( 23 | message: string, 24 | name: string | undefined, 25 | email: string | undefined, 26 | files?: string, 27 | ) => { 28 | const {currName: prevName, currEmail: prevEmail} = _getNameAndEmail() 29 | _setNameAndEmail(name, email) 30 | exec(`git add ${files ?? '.'}`) 31 | infoErrorLogger.info( 32 | exec(`git commit -m "${message.replace(/"/g, "''")}"`, {silent: true}) 33 | .stdout 34 | ) 35 | _setNameAndEmail(prevName, prevEmail) 36 | } 37 | 38 | export const push = () => { 39 | const {currName: prevName, currEmail: prevEmail} = _getNameAndEmail() 40 | _setNameAndEmail() 41 | const result = exec('git push') 42 | if (result.includes('error:')) throw new Error(result) 43 | _setNameAndEmail(prevName, prevEmail) 44 | } 45 | 46 | export const lastCommit = () => { 47 | return exec('git log --format="%H" -n 1').stdout 48 | } 49 | -------------------------------------------------------------------------------- /src/commands/status.ts: -------------------------------------------------------------------------------- 1 | import Command from '../base' 2 | import {load} from 'js-yaml' 3 | import {readFile} from 'fs-extra' 4 | import {join} from 'path' 5 | import {SiteStatus} from '../interfaces' 6 | import {getConfig} from '../helpers/config' 7 | import {cli} from 'cli-ux' 8 | import slugify from '@sindresorhus/slugify' 9 | import chalk from 'chalk' 10 | 11 | export default class Status extends Command { 12 | static description = 'updates about status of websites' 13 | 14 | async run() { 15 | const config = await getConfig() 16 | let i = 0 17 | const arr = [] 18 | for await (const site of config.sites) { 19 | const slug = site.slug || slugify(site.name) 20 | try { 21 | const _data = load( 22 | (await readFile(join('.', 'history', `${slug}.yml`), 'utf8'))) as SiteStatus 23 | i++ 24 | const data = Object.assign({}, _data, { 25 | idx: i, 26 | }) 27 | arr.push(data) 28 | } catch (error) { 29 | this.log(chalk.red.inverse('No Status available')) 30 | this.log(chalk.blue('Please run the upp run command first')) 31 | break 32 | } 33 | } 34 | cli.table(arr, { 35 | idx: { 36 | header: '', 37 | }, 38 | url: { 39 | header: 'Website', 40 | minWidth: 7, 41 | }, 42 | status: { 43 | header: 'Status', 44 | get: row => row.status === 'up' ? `${row.status} 🟩` : row.status === 'down' ? `${row.status} 🟥` : `${row.status} 🟨`, 45 | minWidth: 6, 46 | }, 47 | code: { 48 | header: 'CODE', 49 | minWidth: 4, 50 | }, 51 | responseTime: { 52 | header: 'Response Time', 53 | minWidth: 6, 54 | }, 55 | }, { 56 | printLine: this.log, 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/commands/init.ts: -------------------------------------------------------------------------------- 1 | import Command from '../base' 2 | import fs = require('fs') 3 | import {prompt} from 'enquirer' 4 | import chalk from 'chalk' 5 | import {exec} from 'shelljs' 6 | 7 | export default class Init extends Command { 8 | static description = 'initializes upptime'; 9 | 10 | async run() { 11 | // user inputs for configuration 12 | function testForGit(this: any) { 13 | try { 14 | return exec('git rev-parse --is-inside-work-tree', {silent: true, encoding: 'utf8'}).code 15 | } catch (error) { 16 | this.log(error) 17 | } 18 | } 19 | 20 | const gitignoreData = ` 21 | 22 | # upptime log files 23 | down.log 24 | error.log 25 | degraded.log 26 | status.log 27 | info.log` 28 | 29 | if (testForGit() !== 0) { 30 | this.log(chalk.bgRed('Directory is not git initialised')) 31 | } else if (fs.existsSync('.uclirc.yml')) { 32 | this.log(chalk.red('❌ Already Initialized')) 33 | } else { 34 | if (fs.existsSync('.gitignore')) { 35 | fs.appendFileSync('.gitignore', gitignoreData) 36 | } else { 37 | fs.writeFileSync('.gitignore', gitignoreData) 38 | } 39 | const response: {name: string; url: string} = await prompt([ 40 | { 41 | type: 'input', 42 | name: 'name', 43 | message: 'Enter site name?', 44 | }, 45 | { 46 | type: 'input', 47 | name: 'url', 48 | message: 'Enter site url?', 49 | }, 50 | ]) 51 | // .yml file data to put, taken from the user 52 | const fileData = `sites: 53 | - name: ${response.name} 54 | url: ${response.url}` 55 | fs.writeFileSync('.uclirc.yml', fileData) 56 | // generate a new .yml file or modify/append new data 57 | this.log(chalk.green.inverse('✅ initialized successfully')) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/helpers/notifications.ts: -------------------------------------------------------------------------------- 1 | import {UppConfig} from '../interfaces' 2 | import axios from 'axios' 3 | import nodemailer from 'nodemailer' 4 | import SMTPTransport from 'nodemailer/lib/smtp-transport' 5 | import {getSecret} from './secrets' 6 | import {infoErrorLogger} from './log' 7 | 8 | export const sendNotification = async (config: UppConfig, text: string) => { 9 | infoErrorLogger.info(`[debug] Sending notification ${text}`) 10 | infoErrorLogger.info(`[debug] Notification config has ${(config.notifications || []).length} keys`) 11 | for await (const notification of config.notifications || []) { 12 | if (notification.type === 'slack') { 13 | infoErrorLogger.info(`[debug] Sending Slack notification to channel ${notification.channel}`) 14 | const token = getSecret('SLACK_APP_ACCESS_TOKEN') 15 | if (token) { 16 | const {data} = await axios.post( 17 | 'https://slack.com/api/chat.postMessage', 18 | {channel: notification.channel, text}, 19 | {headers: {Authorization: `Bearer ${getSecret('SLACK_BOT_ACCESS_TOKEN')}`}} 20 | ) 21 | infoErrorLogger.info(`[debug] Slack response ${data}`) 22 | } 23 | infoErrorLogger.info(`[debug] Slack token found? ${Boolean(token)}`) 24 | } else if (notification.type === 'discord') { 25 | infoErrorLogger.info('[debug] Sending Discord notification') 26 | const webhookUrl = getSecret('DISCORD_WEBHOOK_URL') 27 | if (webhookUrl) await axios.post(webhookUrl, {content: text}) 28 | } else if (notification.type === 'email') { 29 | infoErrorLogger.info('[debug] Sending email notification') 30 | const transporter = nodemailer.createTransport({ 31 | host: getSecret('NOTIFICATION_SMTP_HOST'), 32 | port: getSecret('NOTIFICATION_SMTP_PORT') || 587, 33 | secure: Boolean(getSecret('NOTIFICATION_SMTP_SECURE')), 34 | auth: { 35 | user: getSecret('NOTIFICATION_SMTP_USER'), 36 | pass: getSecret('NOTIFICATION_SMTP_PASSWORD'), 37 | }, 38 | } as SMTPTransport.Options) 39 | await transporter.sendMail({ 40 | from: getSecret('NOTIFICATION_SMTP_USER'), 41 | to: getSecret('NOTIFICATION_EMAIL') || getSecret('NOTIFICATION_SMTP_USER'), 42 | subject: text, 43 | text: text, 44 | html: `

${text}

`, 45 | }) 46 | infoErrorLogger.info('[debug] Sent notification') 47 | } else { 48 | infoErrorLogger.info(`This notification type is not supported: ${notification.type}`) 49 | } 50 | } 51 | infoErrorLogger.info('[debug] Notifications are sent') 52 | } 53 | -------------------------------------------------------------------------------- /src/helpers/request.ts: -------------------------------------------------------------------------------- 1 | import {Curl, CurlFeature} from 'node-libcurl' 2 | import {UppConfig} from '../interfaces' 3 | import {join} from 'path' 4 | import {writeFileSync} from 'fs-extra' 5 | import {infoErrorLogger} from './log' 6 | const tls = require('tls') 7 | 8 | export const curl = (site: UppConfig['sites'][0]): 9 | Promise<{httpCode: number; totalTime: number; data: string}> => new Promise(resolve => { 10 | // const url = replaceEnvironmentVariables(site.url); 11 | const method = site.method || 'GET' 12 | const curl = new Curl() 13 | curl.enable(CurlFeature.Raw) 14 | curl.setOpt('URL', site.url) // IMP! Change to url 15 | // if (site.headers) 16 | // curl.setOpt(Curl.option.HTTPHEADER, site.headers.map(replaceEnvironmentVariables)) 17 | // if (site.body) curl.setOpt('POSTFIELDS', replaceEnvironmentVariables(site.body)) 18 | 19 | // As per https://github.com/JCMais/node-libcurl/blob/develop/COMMON_ISSUES.md 20 | const certFilePath = join('../../', 'cacert-2021-07-05.pem') 21 | const tlsData = tls.rootCertificates.join('\n') 22 | writeFileSync(certFilePath, tlsData) 23 | curl.setOpt(Curl.option.CAINFO, certFilePath) 24 | 25 | if (site.__dangerous__insecure || site.__dangerous__disable_verify_peer) 26 | curl.setOpt('SSL_VERIFYPEER', false) 27 | if (site.__dangerous__insecure || site.__dangerous__disable_verify_host) 28 | curl.setOpt('SSL_VERIFYHOST', false) 29 | curl.setOpt('FOLLOWLOCATION', 1) 30 | curl.setOpt('MAXREDIRS', Number.isInteger(site.maxRedirects) ? Number(site.maxRedirects) : 3) 31 | curl.setOpt('USERAGENT', 'Pabio Bot') 32 | curl.setOpt('CONNECTTIMEOUT', 10) 33 | curl.setOpt('TIMEOUT', 30) 34 | curl.setOpt('HEADER', 1) 35 | curl.setOpt('VERBOSE', false) 36 | curl.setOpt('CUSTOMREQUEST', method) 37 | curl.on('error', (error: any) => { 38 | curl.close() 39 | infoErrorLogger.error('Got an error (on error)', error) 40 | return resolve({httpCode: 0, totalTime: 0, data: ''}) 41 | }) 42 | curl.on('end', (_: any, data: any) => { 43 | if (typeof data !== 'string') data = data.toString() 44 | let httpCode = 0 45 | let totalTime = 0 46 | try { 47 | httpCode = Number(curl.getInfo('RESPONSE_CODE')) 48 | totalTime = Number(curl.getInfo('TOTAL_TIME')) 49 | } catch (error) { 50 | curl.close() 51 | infoErrorLogger.error('Got an error (on end)', error) 52 | return resolve({httpCode, totalTime, data}) 53 | } 54 | if (httpCode === 0 || totalTime === 0) infoErrorLogger.error('Didn\'t get an error but got 0s') 55 | return resolve({httpCode, totalTime, data}) 56 | }) 57 | curl.perform() 58 | }) 59 | -------------------------------------------------------------------------------- /src/helpers/log.ts: -------------------------------------------------------------------------------- 1 | import {Logger} from 'winston' 2 | import {stat} from 'fs-extra' 3 | import {getIsLogColor, isLogColor} from './log-color' 4 | const winston = require('winston') 5 | const {format} = winston 6 | const {label, combine, timestamp, printf, colorize} = format 7 | 8 | const customLevels = { 9 | levels: { 10 | down: 0, 11 | error: 1, 12 | info: 2, 13 | degraded: 3, 14 | up: 4, 15 | }, 16 | colors: { 17 | down: 'magenta', 18 | error: 'red', 19 | info: 'blue', 20 | degraded: 'yellow', 21 | up: 'green', 22 | }, 23 | } 24 | winston.addColors(customLevels.colors) 25 | 26 | export const printFormat = printf((info: {timestamp: string; level: string; message: string}) => { 27 | return `${info.timestamp} ${info.level}: ${info.message}` 28 | }) 29 | 30 | export let statusLogger: { up: (arg0: string) => void; degraded: (arg0: string) => void; down: (arg0: string) => void } 31 | export let infoErrorLogger: Logger 32 | export const createLoggers = (async () => { 33 | // Check if .uclirc.yml exists, if it doesn't then return 34 | try { 35 | await stat('.uclirc.yml') 36 | } catch (_) { 37 | return 38 | } 39 | await getIsLogColor() 40 | infoErrorLogger = winston.loggers.add('infoError', { 41 | exitOnError: false, 42 | levels: customLevels.levels, 43 | format: combine( 44 | label({label: 'infoError'}), 45 | timestamp(), 46 | isLogColor ? colorize() : printFormat, 47 | printFormat 48 | ), 49 | transports: [ 50 | new winston.transports.Console({level: 'error', format: colorize({all: true})}), 51 | new winston.transports.File({level: 'error', filename: 'error.log', colorize: false}), 52 | new winston.transports.File({level: 'info', filename: 'info.log', colorize: false}), 53 | ], 54 | }) 55 | statusLogger = winston.loggers.add('status', { 56 | exitOnError: false, 57 | levels: customLevels.levels, 58 | format: combine( 59 | label({label: 'status'}), 60 | timestamp(), 61 | isLogColor ? colorize() : printFormat, 62 | printFormat 63 | ), 64 | transports: [ 65 | new winston.transports.Console({level: 'down', format: colorize({all: true})}), 66 | new winston.transports.File({level: 'error', filename: 'error.log'}), 67 | new winston.transports.File({level: 'info', filename: 'info.log'}), 68 | new winston.transports.File({level: 'down', filename: 'down.log'}), 69 | new winston.transports.File({level: 'degraded', filename: 'degraded.log'}), 70 | new winston.transports.File({level: 'up', filename: 'status.log'}), 71 | ], 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /src/commands/incidents.ts: -------------------------------------------------------------------------------- 1 | import Command from '../base' 2 | import {getIncidents} from '../helpers/incidents' 3 | import {cli} from 'cli-ux' 4 | import chalk from 'chalk' 5 | import {flags} from '@oclif/command' 6 | 7 | export default class Incidents extends Command { 8 | static description = 'reports all the incidents/downtimes' 9 | 10 | static flags = { 11 | help: flags.help({char: 'h', description: 'Show help for run cmd'}), 12 | edit: flags.integer({char: 'e', name: 'edit', description: 'Edit an Issue'}), 13 | columns: flags.string({exclusive: ['additional'], description: 'only show provided columns (comma-separated)'}), 14 | sort: flags.string({description: 'property to sort by (prepend \'-\' for descending)'}), 15 | filter: flags.string({description: 'filter property by partial string matching, ex: name=foo'}), 16 | csv: flags.boolean({exclusive: ['no-truncate'], description: 'output is csv format'}), 17 | extended: flags.boolean({char: 'x', description: 'show extra columns'}), 18 | 'no-truncate': flags.boolean({exclusive: ['csv'], description: 'do not truncate output to fit screen'}), 19 | 'no-header': flags.boolean({exclusive: ['csv'], description: 'hide table header from output'}), 20 | } 21 | 22 | async run() { 23 | const {flags} = this.parse(Incidents) 24 | try { 25 | const _data = (await getIncidents()).incidents 26 | const arr: any[] = [] 27 | Object.keys(_data).forEach(key => { 28 | arr.push({id: key, ..._data[Number(key)]}) 29 | }) 30 | 31 | const options = { 32 | printLine: this.log, 33 | columns: flags.columns, 34 | sort: flags.sort, 35 | filter: flags.filter, 36 | csv: flags.csv, 37 | extended: flags.extended, 38 | 'no-truncate': flags['no-truncate'], 39 | 'no-header': flags['no-header'], 40 | } 41 | 42 | cli.table(arr, { 43 | id: { 44 | header: 'ID', 45 | minWidth: 7, 46 | }, 47 | url: { 48 | header: 'Issue URL', 49 | minWidth: 10, 50 | extended: true, 51 | get: row => row.url ?? '-', 52 | }, 53 | status: { 54 | header: 'Status', 55 | minWidth: 7, 56 | get: row => row.status ?? '-', 57 | }, 58 | createdAt: { 59 | header: 'Created At', 60 | minWidth: 10, 61 | get: row => new Date(row.createdAt).toLocaleString(), 62 | }, 63 | closedAt: { 64 | header: 'Closed At', 65 | minWidth: 10, 66 | get: row => row.closedAt ?? '-', 67 | }, 68 | }, options) 69 | } catch (error) { 70 | this.log(chalk.bgYellow('No incidents as of now.')) 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/graphs.ts: -------------------------------------------------------------------------------- 1 | import slugify from '@sindresorhus/slugify' 2 | import {mkdirp, ensureFile, writeFile} from 'fs-extra' 3 | import {join} from 'path' 4 | import {getConfig} from './helpers/config' 5 | import {infoErrorLogger} from './helpers/log' 6 | import {getHistoryItems} from './helpers/calculate-response-time' 7 | import {cli} from 'cli-ux' 8 | import chalk from 'chalk' 9 | import dayjs from 'dayjs' 10 | 11 | export const generateGraphs = async () => { 12 | cli.action.start('Running graphs workflow') 13 | infoErrorLogger.info('Generate Graphs') 14 | const config = await getConfig() 15 | await mkdirp(join('.', 'history', 'response-data')) 16 | try { 17 | for await (const site of config.sites) { 18 | const slug = slugify(site.name) 19 | if (!slug) continue 20 | // console.log(slug) 21 | const items = await getHistoryItems(slug) 22 | // console.log(items) 23 | const responseTimes: [string, number][] = items 24 | .filter( 25 | item => 26 | item.commit.message.includes(' in ') && 27 | Number(item.commit.message.split(' in ')[1].split('ms')[0].trim()) !== 0 && 28 | !isNaN(Number(item.commit.message.split(' in ')[1].split('ms')[0].trim())) 29 | ) 30 | /** 31 | * Parse the commit message 32 | * @example "🟥 Broken Site is down (500 in 321 ms) [skip ci] [upptime]" 33 | * @returns [Date, 321] where Date is the commit date 34 | */ 35 | .map( 36 | item => 37 | [ 38 | item.commit.author.date, 39 | parseInt(item.commit.message.split(' in ')[1].split('ms')[0].trim(), 10), 40 | ] as [string, number] 41 | ) 42 | .filter(item => item[1] && !isNaN(item[1])) 43 | 44 | // creating separate files 45 | const tDay = responseTimes.filter(i => dayjs(i[0]).isAfter(dayjs().subtract(1, 'day'))) 46 | const tWeek = responseTimes.filter(i => dayjs(i[0]).isAfter(dayjs().subtract(1, 'week'))) 47 | const tMonth = responseTimes.filter(i => dayjs(i[0]).isAfter(dayjs().subtract(1, 'month'))) 48 | const tYear = responseTimes.filter(i => dayjs(i[0]).isAfter(dayjs().subtract(1, 'year'))) 49 | const dataItems: [string, [string, number][]][] = [ 50 | ['response-time-day.yml', tDay], 51 | ['response-time-week.yml', tWeek], 52 | ['response-time-month.yml', tMonth], 53 | ['response-time-year.yml', tYear], 54 | ] 55 | 56 | for await (const dataItem of dataItems) { 57 | await ensureFile(join('.', 'history', 'response-data', `${slug}`, dataItem[0])) 58 | await writeFile( 59 | join('.', 'history', 'response-data', `${slug}`, dataItem[0]), 60 | [1, ...dataItem[1].map(item => item[1]).reverse()].toString().split(',').join('\n') 61 | ) 62 | } 63 | } 64 | } catch (error) { 65 | // console.log(error) 66 | infoErrorLogger.error(error) 67 | cli.action.stop(chalk.red('error')) 68 | } 69 | cli.action.stop(chalk.green('done')) 70 | } 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "upp", 3 | "description": "Uptime monitor and status page powered by GitHub Actions, Issues, and Pages", 4 | "version": "0.1.0-beta", 5 | "author": "Nirmitjatana @Nirmitjatana", 6 | "bin": { 7 | "upp": "./bin/run" 8 | }, 9 | "bugs": "https://github.com/upptime/cli/issues", 10 | "dependencies": { 11 | "@oclif/command": "^1", 12 | "@oclif/config": "^1", 13 | "@oclif/plugin-help": "^3", 14 | "@sindresorhus/slugify": "^1.1.1", 15 | "@types/figlet": "^1.5.4", 16 | "axios": "^0.21.4", 17 | "chalk": "^4.1.2", 18 | "chart.js": "^3.5.1", 19 | "chartjs-node-canvas": "^3.2.0", 20 | "cjs": "0.0.11", 21 | "cli-ux": "^5.6.3", 22 | "dayjs": "^1.10.6", 23 | "dotenv": "^10.0.0", 24 | "enquirer": "^2.3.6", 25 | "figlet": "^1.5.2", 26 | "fs-extra": "^10.0.0", 27 | "inquirer": "^8.1.5", 28 | "js-yaml": "^4.1.0", 29 | "loader.js": "^4.7.0", 30 | "node-cron": "^3.0.0", 31 | "node-libcurl": "^2.3.3", 32 | "nodemailer": "^6.6.3", 33 | "notifme-sdk": "^1.11.0", 34 | "p-queue": "^6.6.2", 35 | "prettier": "^2.3.2", 36 | "shelljs": "^0.8.4", 37 | "tcp-ping": "^0.1.1", 38 | "tslib": "^1", 39 | "unique-names-generator": "^4.6.0", 40 | "whatwg-url": "^9.1.0", 41 | "winston": "^3.3.3" 42 | }, 43 | "devDependencies": { 44 | "@oclif/dev-cli": "^1", 45 | "@oclif/test": "^1", 46 | "@types/chai": "^4", 47 | "@types/chart.js": "^2.9.34", 48 | "@types/fs-extra": "^9.0.12", 49 | "@types/inquirer": "^8.1.3", 50 | "@types/js-yaml": "^4.0.2", 51 | "@types/mocha": "^5", 52 | "@types/node": "^16.7.1", 53 | "@types/node-cron": "^2.0.4", 54 | "@types/nodemailer": "^6.4.4", 55 | "@types/prettier": "^2.3.2", 56 | "@types/shelljs": "^0.8.9", 57 | "@types/tcp-ping": "^0.1.3", 58 | "@types/whatwg-url": "^8.2.1", 59 | "chai": "^4", 60 | "eslint": "^5.13", 61 | "eslint-config-oclif": "^3.1", 62 | "eslint-config-oclif-typescript": "^0.1", 63 | "globby": "^10", 64 | "mocha": "^5", 65 | "nyc": "^14", 66 | "rimraf": "^3.0.2", 67 | "ts-node": "^8", 68 | "typescript": "^3.3" 69 | }, 70 | "engines": { 71 | "node": ">=8.0.0" 72 | }, 73 | "files": [ 74 | "/bin", 75 | "/lib", 76 | "/npm-shrinkwrap.json", 77 | "/oclif.manifest.json" 78 | ], 79 | "homepage": "https://github.com/upptime/cli", 80 | "keywords": [ 81 | "oclif" 82 | ], 83 | "license": "MIT", 84 | "main": "lib/index.js", 85 | "oclif": { 86 | "commands": "./lib/commands", 87 | "bin": "upp", 88 | "helpClass": "./lib/help", 89 | "plugins": [ 90 | "@oclif/plugin-help" 91 | ] 92 | }, 93 | "repository": "upptime/cli", 94 | "scripts": { 95 | "postpack": "rimraf oclif.manifest.json", 96 | "posttest": "eslint . --ext .ts --config .eslintrc", 97 | "prepack": "rimraf lib && tsc -b && oclif-dev manifest && oclif-dev readme", 98 | "test": "nyc --extension .ts mocha --forbid-only \"test/**/*.test.ts\"", 99 | "version": "oclif-dev readme && git add README.md" 100 | }, 101 | "types": "lib/index.d.ts" 102 | } 103 | -------------------------------------------------------------------------------- /src/helpers/calculate-response-time.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import {exec} from 'shelljs' 3 | import {Downtimes} from '../interfaces' 4 | import {getConfig} from './config' 5 | import {infoErrorLogger} from './log' 6 | 7 | /** Calculate the average of some numbers */ 8 | 9 | const avg = (array: number[]) => (array.length > 0 ? array.reduce((a, b) => a + b) / array.length : 0) 10 | 11 | /** Get commits for a history file */ 12 | 13 | export const getHistoryItems = async ( 14 | slug: string, 15 | ) => { 16 | const results = exec(`git log --pretty=format:"%h%x09%ad%x09%s" --date=default --all --first-parent --author-date-order -- ./history/${slug}.yml`, {silent: true}).stdout.split('\n') 17 | const data = results.map(item => { 18 | const info = item.split('\t') 19 | return { 20 | commit: { 21 | message: info[2], 22 | author: { 23 | date: info[1], 24 | }, 25 | }, 26 | } 27 | }) 28 | if (!data[0]) return [] 29 | return data 30 | } 31 | 32 | export const getResponseTimeForSite = async ( 33 | slug: string 34 | ): Promise> => { 35 | const config = await getConfig() 36 | const data = await getHistoryItems(slug) 37 | const responseTimes: [string, number][] = data 38 | .filter( 39 | item => 40 | item.commit.message.includes(' in ') && 41 | Number(item.commit.message.split(' in ')[1].split('ms')[0].trim()) !== 0 && 42 | !isNaN(Number(item.commit.message.split(' in ')[1].split('ms')[0].trim())) 43 | ) 44 | /** 45 | * Parse the commit message 46 | * @example "🟥 Broken Site is down (500 in 321 ms) [skip ci] [upptime]" 47 | * @returns [Date, 321] where Date is the commit date 48 | */ 49 | .map( 50 | item => 51 | [ 52 | (item.commit.author || {}).date, 53 | parseInt(item.commit.message.split(' in ')[1].split('ms')[0].trim(), 10), 54 | ] as [string, number] 55 | ) 56 | .filter(item => item[1] && !isNaN(item[1])) 57 | 58 | const daySum: number[] = responseTimes 59 | .filter(i => dayjs(i[0]).isAfter(dayjs().subtract(1, 'day'))) 60 | .map(i => i[1]) 61 | const weekSum: number[] = responseTimes 62 | .filter(i => dayjs(i[0]).isAfter(dayjs().subtract(1, 'week'))) 63 | .map(i => i[1]) 64 | const monthSum: number[] = responseTimes 65 | .filter(i => dayjs(i[0]).isAfter(dayjs().subtract(1, 'month'))) 66 | .map(i => i[1]) 67 | const yearSum: number[] = responseTimes 68 | .filter(i => dayjs(i[0]).isAfter(dayjs().subtract(1, 'year'))) 69 | .map(i => i[1]) 70 | const allSum: number[] = responseTimes.map(i => i[1]) 71 | infoErrorLogger.info(`weekSum, ${weekSum}, ${avg(weekSum)}`) 72 | 73 | // Current status is "up", "down", or "degraded" based on the emoji prefix of the commit message 74 | const currentStatus: 'up' | 'down' | 'degraded' = data[0] ? 75 | data[0].commit.message.split(' ')[0].includes(config.commitPrefixStatusUp || '🟩') ? 76 | 'up' : 77 | data[0].commit.message.split(' ')[0].includes(config.commitPrefixStatusDegraded || '🟨') ? 78 | 'degraded' : 79 | 'down' : 80 | 'up' 81 | 82 | return { 83 | day: Math.round(avg(daySum) || 0), 84 | week: Math.round(avg(weekSum) || 0), 85 | month: Math.round(avg(monthSum) || 0), 86 | year: Math.round(avg(yearSum) || 0), 87 | all: Math.round(avg(allSum) || 0), 88 | currentStatus, 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Upptime CLI 2 | === 3 | 4 | Uptime monitor and status page powered by GitHub Actions, Issues, and Pages 5 | 6 | [![oclif](https://img.shields.io/badge/cli-oclif-brightgreen.svg)](https://oclif.io) 7 | [![Version](https://img.shields.io/npm/v/upp.svg)](https://npmjs.org/package/upp) 8 | [![CircleCI](https://circleci.com/gh/upptime/cli/tree/master.svg?style=shield)](https://circleci.com/gh/upptime/cli/tree/master) 9 | [![Downloads/week](https://img.shields.io/npm/dw/upp.svg)](https://npmjs.org/package/upp) 10 | [![License](https://img.shields.io/npm/l/upp.svg)](https://github.com/upptime/cli/blob/master/package.json) 11 | 12 | 13 | * [Usage](#usage) 14 | * [Commands](#commands) 15 | 16 | # Usage 17 | 18 | ```sh-session 19 | $ npm install -g upp 20 | $ upp COMMAND 21 | running command... 22 | $ upp (-v|--version|version) 23 | upp/0.0.0 darwin-x64 node-v14.17.6 24 | $ upp --help [COMMAND] 25 | USAGE 26 | $ upp COMMAND 27 | ... 28 | ``` 29 | 30 | # Commands 31 | 32 | * [`upp config`](#upp-config) 33 | * [`upp docs`](#upp-docs) 34 | * [`upp help [COMMAND]`](#upp-help-command) 35 | * [`upp init`](#upp-init) 36 | * [`upp run [ITERATIONS]`](#upp-run-iterations) 37 | * [`upp status`](#upp-status) 38 | * [`upp incidents`](#upp-incidents) 39 | 40 | ## `upp config` 41 | 42 | configures uclirc.yml 43 | 44 | ``` 45 | USAGE 46 | $ upp config 47 | 48 | OPTIONS 49 | -f, --force 50 | -h, --help show CLI help 51 | -n, --name=name name to print 52 | ``` 53 | 54 | _See code: [src/commands/config.ts](https://github.com/upptime/cli/blob/v0.0.0/src/commands/config.ts)_ 55 | 56 | ## `upp docs` 57 | 58 | redirects to Upptime docs 59 | 60 | ``` 61 | USAGE 62 | $ upp docs 63 | ``` 64 | 65 | _See code: [src/commands/docs.ts](https://github.com/upptime/cli/blob/v0.0.0/src/commands/docs.ts)_ 66 | 67 | ## `upp help [COMMAND]` 68 | 69 | display help for upp 70 | 71 | ``` 72 | USAGE 73 | $ upp help [COMMAND] 74 | 75 | ARGUMENTS 76 | COMMAND command to show help for 77 | 78 | OPTIONS 79 | --all see all commands in CLI 80 | ``` 81 | 82 | _See code: [@oclif/plugin-help](https://github.com/oclif/plugin-help/blob/v3.2.3/src/commands/help.ts)_ 83 | 84 | ## `upp init` 85 | 86 | initializes upptime 87 | 88 | ``` 89 | USAGE 90 | $ upp init 91 | ``` 92 | 93 | _See code: [src/commands/init.ts](https://github.com/upptime/cli/blob/v0.0.0/src/commands/init.ts)_ 94 | 95 | ## `upp run [ITERATIONS]` 96 | 97 | Run workflows 98 | 99 | ``` 100 | USAGE 101 | $ upp run [ITERATIONS] 102 | 103 | OPTIONS 104 | -g, --graphs 105 | -h, --help show CLI help 106 | -i, --iterations=iterations number of iterations 107 | -p, --staticSite 108 | -s, --summary 109 | -u, --uptime 110 | ``` 111 | 112 | * **upp run** : *runs all workflows according to user/default schedule* 113 | * **upp run -i 5** : *runs all workflows 5 times in order* 114 | * **upp run -r** : *runs only response time workflow according to user/default schedule* 115 | * **upp run -ri** : *runs only response time workflow 5 times* 116 | 117 | 118 | _See code: [src/commands/run.ts](https://github.com/upptime/cli/blob/v0.0.0/src/commands/run.ts)_ 119 | 120 | ## `upp status` 121 | 122 | updates about status of websites 123 | 124 | ``` 125 | USAGE 126 | $ upp status 127 | ``` 128 | 129 | _See code: [src/commands/status.ts](https://github.com/upptime/cli/blob/v0.0.0/src/commands/status.ts)_ 130 | 131 | ## `upp incidents` 132 | 133 | reports all the incidents/downtimes 134 | 135 | ``` 136 | USAGE 137 | $ upp incidents 138 | ``` 139 | 140 | _See code: [src/commands/status.ts](https://github.com/upptime/cli/blob/v0.0.0/src/commands/status.ts)_ 141 | 142 | -------------------------------------------------------------------------------- /src/commands/run.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable complexity */ 2 | import {flags} from '@oclif/command' 3 | import Command from '../base' 4 | import {schedule} from 'node-cron' 5 | import PQueue from 'p-queue' 6 | import {getConfig} from '../helpers/config' 7 | import {GRAPHS_CI_SCHEDULE, RESPONSE_TIME_CI_SCHEDULE, STATIC_SITE_CI_SCHEDULE, SUMMARY_CI_SCHEDULE, UPTIME_CI_SCHEDULE} from '../helpers/constants' 8 | import {UppConfig} from '../interfaces' 9 | import {generateSite} from '../site' 10 | import {generateSummary} from '../summary' 11 | import {update} from '../update' 12 | import {generateGraphs} from '../graphs' 13 | 14 | export default class Run extends Command { 15 | static description = 'Run workflows' 16 | 17 | static flags = { 18 | help: flags.help({char: 'h', description: 'Show help for run cmd'}), 19 | iterations: flags.integer({char: 'i', description: 'Number of iterations'}), 20 | quiet: flags.boolean({char: 'q', description: 'Quiet'}), 21 | uptime: flags.boolean({char: 'u', description: 'Check change in status'}), 22 | summary: flags.boolean({char: 's', description: 'Generate README.md'}), 23 | staticSite: flags.boolean({char: 'p', description: 'Generate and build static site'}), 24 | graphs: flags.boolean({char: 'g', description: 'Generate graphs'}), 25 | responseTime: flags.boolean({char: 'r', description: 'Commit response time'}), 26 | } 27 | 28 | static args = [{name: 'iterations'}] 29 | 30 | async run() { 31 | // console.log("test run") 32 | const {flags} = this.parse(Run) 33 | const queue = new PQueue({concurrency: 1}) 34 | const config: UppConfig = await getConfig() 35 | 36 | const returnWorkflows = (func: (() => Promise), cronSchedule: string) => { 37 | schedule(cronSchedule, async () => queue.add(func)) 38 | } 39 | 40 | const noWorkflowFlags = !flags.responseTime && !flags.uptime && !flags.summary && !flags.staticSite && !flags.graphs 41 | 42 | // It would be desirable to execute each iteration of CI in a cycle 43 | if (flags.iterations) { 44 | for (let i = 0; i < flags.iterations; i++) { 45 | this.log('running for ' + i + 1 + 'th iteration') 46 | if (flags.uptime) 47 | queue.add(update) 48 | if (flags.responseTime) 49 | queue.add(() => update(true)) 50 | if (flags.graphs) 51 | // method to call data point function 52 | // queue.add(() => Dayvalues('Github')) 53 | // queue.add(() => Weekvalues('Google')) 54 | // queue.add(() => Monthvalues('Wikipedia')) 55 | // queue.add(() => Yearvalues('Yahoo')) 56 | queue.add(generateGraphs) 57 | if (flags.summary) 58 | queue.add(generateSummary) 59 | if (flags.staticSite) 60 | queue.add(generateSite) 61 | /* If no workflow related flag is passed, run all workflows 62 | update(false) is omitted because we already are checking status while update(true) 63 | */ 64 | if (noWorkflowFlags) { 65 | queue.add(() => update(true)) 66 | queue.add(generateGraphs) 67 | queue.add(generateSummary) 68 | queue.add(generateSite) 69 | } 70 | } 71 | } else { 72 | this.log('Setting up workflows') 73 | // cli.action.stop('Workflows set-up complete') 74 | if (flags.uptime) 75 | returnWorkflows(update, config.workflowSchedule?.uptime ?? UPTIME_CI_SCHEDULE) 76 | if (flags.responseTime) 77 | returnWorkflows(() => update(true), config.workflowSchedule?.responseTime ?? RESPONSE_TIME_CI_SCHEDULE) 78 | if (flags.graphs) 79 | returnWorkflows(generateGraphs, config.workflowSchedule?.graphs ?? GRAPHS_CI_SCHEDULE) 80 | if (flags.summary) 81 | returnWorkflows(generateSummary, config.workflowSchedule?.summary ?? SUMMARY_CI_SCHEDULE) 82 | if (flags.staticSite) 83 | returnWorkflows(generateSite, config.workflowSchedule?.staticSite ?? STATIC_SITE_CI_SCHEDULE) 84 | // If no workflow related flags passed, run all workflows as per defined schedule 85 | if (noWorkflowFlags) { 86 | returnWorkflows(update, config.workflowSchedule?.uptime ?? UPTIME_CI_SCHEDULE) 87 | returnWorkflows(() => update(true), config.workflowSchedule?.responseTime ?? RESPONSE_TIME_CI_SCHEDULE) 88 | returnWorkflows(generateGraphs, config.workflowSchedule?.graphs ?? GRAPHS_CI_SCHEDULE) 89 | returnWorkflows(generateSummary, config.workflowSchedule?.summary ?? SUMMARY_CI_SCHEDULE) 90 | returnWorkflows(generateSite, config.workflowSchedule?.staticSite ?? STATIC_SITE_CI_SCHEDULE) 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/helpers/calculate-uptime.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs' 2 | import {readFile} from 'fs-extra' 3 | import {load} from 'js-yaml' 4 | import {join} from 'path' 5 | import {DownPecentages, Downtimes, SiteHistory} from '../interfaces' 6 | import {getIncidents, getIndexes} from './incidents' 7 | import {checkOverlap} from './overlap' 8 | 9 | /** 10 | * Get the number of seconds a website has been down 11 | * @param slug - Slug of the site 12 | */ 13 | 14 | const getDowntimeSecondsForSite = async (slug: string): Promise => { 15 | let day = 0 16 | let week = 0 17 | let month = 0 18 | let year = 0 19 | let all = 0 20 | const dailyMinutesDown: Record = {} 21 | 22 | // Get all the issues for this website 23 | const incidents = await getIncidents() 24 | const indexes = await getIndexes(slug) 25 | 26 | // If this issue has been closed already, calculate the difference 27 | // between when it was closed and when it was opened 28 | // If this issue is still open, calculate the time since it was opened 29 | indexes.forEach(id => { 30 | const issue = incidents.incidents[id] 31 | const issueDowntime = 32 | new Date(issue.closedAt || new Date()).getTime() - new Date(issue.createdAt).getTime() 33 | all += issueDowntime 34 | const issueOverlap = { 35 | start: new Date(issue.createdAt).getTime(), 36 | end: new Date(issue.closedAt || new Date()).getTime(), 37 | }; 38 | 39 | [...(new Array(365).keys())].forEach(day => { 40 | const date = dayjs().subtract(day, 'day') 41 | const overlap = checkOverlap(issueOverlap, { 42 | start: date.startOf('day').toDate().getTime(), 43 | end: date.endOf('day').toDate().getTime(), 44 | }) 45 | if (overlap) { 46 | dailyMinutesDown[date.format('YYYY-MM-DD')] = 47 | dailyMinutesDown[date.format('YYYY-MM-DD')] || 0 48 | dailyMinutesDown[date.format('YYYY-MM-DD')] += Math.round(overlap / 60000) 49 | } 50 | }) 51 | 52 | const end = dayjs().toDate().getTime() 53 | day += checkOverlap(issueOverlap, { 54 | start: dayjs().subtract(1, 'day').toDate().getTime(), 55 | end, 56 | }) 57 | week += checkOverlap(issueOverlap, { 58 | start: dayjs().subtract(1, 'week').toDate().getTime(), 59 | end, 60 | }) 61 | month += checkOverlap(issueOverlap, { 62 | start: dayjs().subtract(1, 'month').toDate().getTime(), 63 | end, 64 | }) 65 | year += checkOverlap(issueOverlap, { 66 | start: dayjs().subtract(1, 'year').toDate().getTime(), 67 | end, 68 | }) 69 | }) 70 | 71 | return { 72 | day: Math.round(day / 1000), 73 | week: Math.round(week / 1000), 74 | month: Math.round(month / 1000), 75 | year: Math.round(year / 1000), 76 | all: Math.round(all / 1000), 77 | dailyMinutesDown, 78 | } 79 | } 80 | 81 | /** 82 | * Get the uptime percentage for a website 83 | * @returns Percent string, e.g., 94.43% 84 | * @param slug - Slug of the site 85 | */ 86 | 87 | export const getUptimePercentForSite = async (slug: string): Promise => { 88 | const site = load( 89 | (await readFile(join('.', 'history', `${slug}.yml`), 'utf8')) 90 | .split('\n') 91 | .map(line => (line.startsWith('- ') ? line.replace('- ', '') : line)) 92 | .join('\n') 93 | ) as SiteHistory 94 | // Time when we started tracking this website's downtime 95 | const startDate = new Date(site.startTime || new Date()) 96 | 97 | // Number of seconds we have been tracking this site 98 | const totalSeconds = (new Date().getTime() - startDate.getTime()) / 1000 99 | 100 | // Number of seconds the site has been down 101 | const downtimeSeconds = await getDowntimeSecondsForSite(slug) 102 | 103 | // Return a percentage string 104 | return { 105 | day: `${Math.max(0, 100 - ((downtimeSeconds.day / Math.min(86400, totalSeconds)) * 100)).toFixed( 106 | 2 107 | )}%`, 108 | week: `${Math.max( 109 | 0, 110 | 100 - ((downtimeSeconds.week / Math.min(604800, totalSeconds)) * 100) 111 | ).toFixed(2)}%`, 112 | month: `${Math.max( 113 | 0, 114 | 100 - ((downtimeSeconds.month / Math.min(2628288, totalSeconds)) * 100) 115 | ).toFixed(2)}%`, 116 | year: `${Math.max( 117 | 0, 118 | 100 - ((downtimeSeconds.year / Math.min(31536000, totalSeconds)) * 100) 119 | ).toFixed(2)}%`, 120 | all: `${Math.max(0, 100 - ((downtimeSeconds.all / totalSeconds) * 100)).toFixed(2)}%`, 121 | dailyMinutesDown: downtimeSeconds.dailyMinutesDown, 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface UppConfig { 2 | assignees: string[]; 3 | sites: { 4 | check?: 'http' | 'tcp-ping'; 5 | urlSecretText?: string; 6 | method?: string; 7 | name: string; 8 | url: string; 9 | port?: number; 10 | expectedStatusCodes?: number[]; 11 | assignees?: string[]; 12 | headers?: string[]; 13 | slug?: string; 14 | body?: string; 15 | icon?: string; 16 | maxResponseTime?: number; 17 | maxRedirects?: number; 18 | __dangerous__insecure?: boolean; 19 | __dangerous__disable_verify_peer?: boolean; 20 | __dangerous__disable_verify_host?: boolean; 21 | __dangerous__body_down?: string; 22 | __dangerous__body_down_if_text_missing?: string; 23 | __dangerous__body_degraded?: string; 24 | __dangerous__body_degraded_if_text_missing?: string; 25 | }[]; 26 | workflowSchedule?: { 27 | graphs?: string; 28 | staticSite?: string; 29 | summary?: string; 30 | uptime?: string; 31 | responseTime?: string; 32 | }; 33 | commitMessages?: { 34 | readmeContent?: string; 35 | summaryJson?: string; 36 | statusChange?: string; 37 | graphsUpdate?: string; 38 | commitAuthorName?: string; 39 | commitAuthorEmail?: string; 40 | }; 41 | commitPrefixStatusUp?: string; 42 | commitPrefixStatusDown?: string; 43 | commitPrefixStatusDegraded?: string; 44 | commits?: { 45 | provider?: ''; 46 | }; 47 | pages?: { 48 | provider?: ''; 49 | }; 50 | logs?: { 51 | colors?: boolean; 52 | }; 53 | notifications?: { type: string; [index: string]: string }[]; 54 | skipGeneratingWebsite: boolean; 55 | customStatusWebsitePackage: string; 56 | incidentCommitPrefixOpen?: string; 57 | incidentCommitPrefixClose?: string; 58 | incidentCommentPrefix?: string; 59 | skipDeleteIssues?: boolean; 60 | skipDescriptionUpdate?: boolean; 61 | skipTopicsUpdate?: boolean; 62 | skipPoweredByReadme?: boolean; 63 | summaryStartHtmlComment?: string; 64 | summaryEndHtmlComment?: string; 65 | liveStatusHtmlComment?: string; 66 | i18n?: { 67 | up?: string; 68 | down?: string; 69 | degraded?: string; 70 | url?: string; 71 | status?: string; 72 | history?: string; 73 | ms?: string; 74 | responseTime?: string; 75 | responseTimeDay?: string; 76 | responseTimeWeek?: string; 77 | responseTimeMonth?: string; 78 | responseTimeYear?: string; 79 | uptime?: string; 80 | uptimeDay?: string; 81 | uptimeWeek?: string; 82 | uptimeMonth?: string; 83 | uptimeYear?: string; 84 | responseTimeGraphAlt?: string; 85 | liveStatus?: string; 86 | allSystemsOperational?: string; 87 | degradedPerformance?: string; 88 | completeOutage?: string; 89 | partialOutage?: string; 90 | } & Record; 91 | 'status-website'?: { 92 | cname?: string; 93 | logoUrl?: string; 94 | name?: string; 95 | introTitle?: string; 96 | introMessage?: string; 97 | navbar?: { title: string; url: string }[]; 98 | publish?: boolean; 99 | }; 100 | } 101 | export interface SiteHistory { 102 | url: string; 103 | status: 'up' | 'down' | 'degraded'; 104 | code: number; 105 | responseTime: number; 106 | lastUpdated?: string; 107 | startTime?: string; 108 | generator: 'Upptime '; 109 | } 110 | export interface SiteStatus { 111 | /** Name of site */ 112 | name: string; 113 | /** Short slug of the site */ 114 | slug: string; 115 | /** Full URL of the site */ 116 | url: string; 117 | /** Favicon URL of the site */ 118 | icon: string; 119 | /** Current status, up or down */ 120 | status: 'up' | 'down' | 'degraded'; 121 | /** Current response time (ms) */ 122 | time: number; 123 | timeDay: number; 124 | timeWeek: number; 125 | timeMonth: number; 126 | timeYear: number; 127 | /** Total uptime percentage */ 128 | uptime: string; 129 | uptimeDay: string; 130 | uptimeWeek: string; 131 | uptimeMonth: string; 132 | uptimeYear: string; 133 | /** Summary for downtimes */ 134 | dailyMinutesDown: Record; 135 | } 136 | export interface Downtimes { 137 | day: number; 138 | week: number; 139 | month: number; 140 | year: number; 141 | all: number; 142 | dailyMinutesDown: Record; 143 | } 144 | export interface DownPecentages { 145 | day: string; 146 | week: string; 147 | month: string; 148 | year: string; 149 | all: string; 150 | dailyMinutesDown: Record; 151 | } 152 | 153 | export interface Incidents { 154 | useID: number; 155 | incidents: { 156 | [id: number]: { 157 | slug: string; 158 | labels: string[] | undefined; 159 | title: string; 160 | createdAt: number; 161 | closedAt?: number; 162 | willCloseAt?: number; 163 | siteURL?: string; 164 | /** URL(if any) of where the issue is on the Internet */ 165 | url?: string; 166 | status: 'open' | 'closed'; 167 | }; 168 | }; 169 | } 170 | 171 | export interface MemoizedIncidents { 172 | useID: number; 173 | incidents: Incidents['incidents']; 174 | indexes: { 175 | [slug: string]: number[]; 176 | }; 177 | } 178 | -------------------------------------------------------------------------------- /src/helpers/graphs.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-depth */ 2 | import slugify from '@sindresorhus/slugify' 3 | import {getConfig} from './config' 4 | import {readFile} from 'fs-extra' 5 | import {join} from 'path' 6 | import cli from 'cli-ux' 7 | import chalk from 'chalk' 8 | import {infoErrorLogger} from './log' 9 | 10 | // Error.stackTraceLimit = Infinity; 11 | export const Dayvalues = async (slug = '') => { 12 | const values = [] 13 | try { 14 | const config = await getConfig() 15 | const exists = config.sites.map(ob => Boolean(ob.slug === `${slug}` || slugify(ob.name) === `${slug}`)) 16 | if (exists.includes(true)) { 17 | // change structure before push 18 | const daysArray = (await readFile(join('.', 'history', 'response-data', `${slug.toLowerCase()}`, 'response-time-day.yml'), 'utf8')).split('\n') 19 | const num = daysArray.length 20 | 21 | if (num < 5 + 1) { 22 | for (let i = 1; i < num; i++) { 23 | const element = Number(daysArray[i]) 24 | values.push(element) 25 | } 26 | } else { 27 | // var testArray = ['100','200','300','700','150','220','350','780','190','210','360','600'] 28 | // num = testArray.length 29 | let count = 0 30 | const interval = Math.floor(num / 5) 31 | let sum = 0 32 | for (let i = 1; i < num + 1; i++) { 33 | if (count >= interval) { 34 | values.push(Math.floor(sum / interval)) 35 | sum = 0 36 | count = 0 37 | } 38 | const element = Number(daysArray[i]) 39 | sum += element 40 | count++ 41 | } 42 | } 43 | } else { 44 | throw Error 45 | } 46 | } catch (error) { 47 | infoErrorLogger.error(error) 48 | // output message 49 | cli.action.stop(chalk.red('Some issue fetching response time data')) 50 | } 51 | // return array 52 | infoErrorLogger.info(`day ,${values}`) 53 | return values 54 | } 55 | 56 | export const Weekvalues = async (slug = '') => { 57 | const values = [] 58 | try { 59 | const config = await getConfig() 60 | const exists = config.sites.map(ob => ob.name === `${slug}`) 61 | if (exists.includes(true)) { 62 | // change structure before push 63 | const daysArray = (await readFile(join('.', 'history', 'response-data', `${slug.toLowerCase()}`, 'response-time-week.yml'), 'utf8')).split('\n') 64 | const num = daysArray.length 65 | 66 | if (num < 7 + 1) { 67 | for (let i = 1; i < num; i++) { 68 | const element = Number(daysArray[i]) 69 | values.push(element) 70 | } 71 | } else { 72 | let count = 0 73 | const interval = Math.floor(num / 7) 74 | let sum = 0 75 | for (let i = 1; i < num + 1; i++) { 76 | if (count >= interval) { 77 | values.push(Math.floor(sum / interval)) 78 | sum = 0 79 | count = 0 80 | } 81 | const element = Number(daysArray[i]) 82 | sum += element 83 | count++ 84 | } 85 | } 86 | } else { 87 | throw Error 88 | } 89 | } catch (error) { 90 | infoErrorLogger.error(error) 91 | // output message 92 | cli.action.stop(chalk.red('Some issue fetching response time data')) 93 | } 94 | // return array 95 | infoErrorLogger.info(`week , ${values}`) 96 | return values 97 | } 98 | 99 | export const Monthvalues = async (slug = '') => { 100 | const values = [] 101 | try { 102 | const config = await getConfig() 103 | 104 | const exists = config.sites.map(ob => ob.name.toLowerCase() === `${slug.toLowerCase()}`) 105 | if (exists.includes(true)) { 106 | // change structure before push 107 | const daysArray = (await readFile(join('.', 'history', 'response-data', `${slug.toLowerCase()}`, 'response-time-month.yml'), 'utf8')).split('\n') 108 | 109 | const num = daysArray.length 110 | 111 | if (num < 10 + 1) { 112 | for (let i = 1; i < num; i++) { 113 | const element = Number(daysArray[i]) 114 | values.push(element) 115 | } 116 | } else { 117 | let count = 0 118 | const interval = Math.floor(num / 10) 119 | let sum = 0 120 | for (let i = 1; i < num + 1; i++) { 121 | if (count >= interval) { 122 | values.push(Math.floor(sum / interval)) 123 | sum = 0 124 | count = 0 125 | } 126 | const element = Number(daysArray[i]) 127 | sum += element 128 | count++ 129 | } 130 | } 131 | } else { 132 | throw Error 133 | } 134 | } catch (error) { 135 | infoErrorLogger.error(error) 136 | // output message 137 | cli.action.stop(chalk.red('Some issue fetching response time data')) 138 | } 139 | // return array 140 | infoErrorLogger.info(`month , ${values}`) 141 | return values 142 | } 143 | 144 | export const Yearvalues = async (slug = '') => { 145 | const values = [] 146 | try { 147 | const config = await getConfig() 148 | const exists = config.sites.map(ob => ob.name === `${slug}`) 149 | if (exists.includes(true)) { 150 | // change structure before push 151 | const daysArray = (await readFile(join('.', 'history', 'response-data', `${slug.toLowerCase()}`, 'response-time-year.yml'), 'utf8')).split('\n') 152 | const num = daysArray.length 153 | 154 | if (num < 12 + 1) { 155 | for (let i = 1; i < num; i++) { 156 | const element = Number(daysArray[i]) 157 | values.push(element) 158 | } 159 | } else { 160 | let count = 0 161 | const interval = Math.floor(num / 12) 162 | let sum = 0 163 | for (let i = 1; i < num + 1; i++) { 164 | if (count >= interval) { 165 | values.push(Math.floor(sum / interval)) 166 | sum = 0 167 | count = 0 168 | } 169 | const element = Number(daysArray[i]) 170 | sum += element 171 | count++ 172 | } 173 | } 174 | } else { 175 | throw Error 176 | } 177 | } catch (error) { 178 | infoErrorLogger.error(error) 179 | // output message 180 | cli.action.stop(chalk.red('Some issue fetching response time data')) 181 | } 182 | // return array 183 | infoErrorLogger.info('year ', values) 184 | return values 185 | } 186 | 187 | -------------------------------------------------------------------------------- /src/helpers/incidents.ts: -------------------------------------------------------------------------------- 1 | import {dump, load} from 'js-yaml' 2 | import {readFile, ensureFile, appendFile, writeFile} from 'fs-extra' 3 | import path from 'path' 4 | import {Incidents, MemoizedIncidents, UppConfig} from '../interfaces' 5 | import slugify from '@sindresorhus/slugify' 6 | import {commit} from './git' 7 | import {getConfig} from './config' 8 | import {infoErrorLogger} from './log' 9 | 10 | let __memoizedIncidents: MemoizedIncidents | undefined 11 | 12 | const initMemoizedIncidents = () => { 13 | if (__memoizedIncidents) return 14 | __memoizedIncidents = {} as MemoizedIncidents 15 | } 16 | 17 | export const getIncidents = async (): Promise => { 18 | initMemoizedIncidents() 19 | if (__memoizedIncidents?.incidents) return __memoizedIncidents 20 | await ensureFile('.incidents.yml') 21 | let incidents = load(await readFile('.incidents.yml', 'utf8')) as Incidents 22 | if (!incidents) { 23 | incidents = { 24 | useID: 1, 25 | incidents: {}, 26 | } as Incidents 27 | await writeFile('.incidents.yml', dump(incidents)) 28 | 29 | const config = await getConfig() 30 | commit('$PREFIX Create incidents.yml' 31 | .replace('$PREFIX', config.incidentCommitPrefixOpen || '⭕'), 32 | (config.commitMessages || {}).commitAuthorName, 33 | (config.commitMessages || {}).commitAuthorEmail, 34 | '.incidents.yml') 35 | } 36 | /** __memoizedIncidents is already initialized */ 37 | __memoizedIncidents = {...incidents, indexes: {}} 38 | return __memoizedIncidents! 39 | } 40 | 41 | export const getIndexes = async (label: string): Promise => { 42 | await getIncidents() 43 | if (__memoizedIncidents?.indexes[label]) return __memoizedIncidents?.indexes[label] 44 | const indexes: number[] = [] 45 | Object.keys(__memoizedIncidents!.incidents).forEach(id => { 46 | if (__memoizedIncidents!.incidents[Number(id)].labels?.includes(label)) 47 | indexes.push(Number(id)) 48 | }) 49 | __memoizedIncidents!.indexes[label] = indexes 50 | return indexes 51 | } 52 | 53 | export const createIncident = async (site: UppConfig['sites'][0], meta: {willCloseAt?: number; slug?: string; author: string; assignees: string[]; labels: string[]}, title: string, headline:string, desc: string): Promise => { 54 | const slug = meta.slug ?? site.slug ?? slugify(site.name) 55 | const incidents = await getIncidents() 56 | const id = incidents.useID 57 | const now = Date.now() 58 | // write to .incidents.yml 59 | incidents.incidents[id] = { 60 | siteURL: site.urlSecretText || site.url, 61 | slug: slug, 62 | createdAt: now, 63 | willCloseAt: meta.willCloseAt, 64 | status: 'open', 65 | title, 66 | labels: meta.labels, 67 | } 68 | 69 | incidents.useID = id + 1 70 | meta.labels.forEach(label => { 71 | if (incidents.indexes[label]) 72 | incidents.indexes[label].push(id) 73 | }) 74 | 75 | __memoizedIncidents = incidents 76 | await writeFile('.incidents.yml', dump({ 77 | useID: incidents.useID, 78 | incidents: incidents.incidents, 79 | })) 80 | 81 | // write to incidents/slugified-site-folder/$id-$title.md 82 | const mdPath = path.join('incidents', `${title}.md`) 83 | await ensureFile(mdPath) 84 | const content = `--- 85 | id: ${id} 86 | assignees: ${meta.assignees?.join(', ')} 87 | labels: ${meta.labels.join(', ')} 88 | --- 89 | # ${headline} 90 | 91 | 92 | ${desc} 93 | 94 | --- 95 | ` 96 | await writeFile(mdPath, content) 97 | const config = await getConfig() 98 | infoErrorLogger.info(`.incidents.yml ${mdPath}`) 99 | commit('$PREFIX Create Issue #$ID' 100 | .replace('$PREFIX', config.incidentCommitPrefixOpen || '⭕') 101 | .replace('$ID', id.toString(10)), 102 | (config.commitMessages || {}).commitAuthorName, 103 | (config.commitMessages || {}).commitAuthorEmail, 104 | `.incidents.yml "${mdPath}"`) 105 | } 106 | 107 | export const closeMaintenanceIncidents = async () => { 108 | // Slug is not needed as a parameter, since newly added site will not have any issue 109 | // if it does, it must already be in .incidents.yml 110 | await getIncidents() 111 | const now = Date.now() 112 | const ongoingMaintenanceEvents: {incident: Incidents['incidents'][0]; id: number}[] = [] 113 | const indexes = await getIndexes('maintenance') 114 | let hasDelta = false 115 | indexes.forEach(id => { 116 | const status = __memoizedIncidents!.incidents[id].status 117 | if (status === 'open') { 118 | const willCloseAt = __memoizedIncidents!.incidents[id].willCloseAt 119 | if (willCloseAt && willCloseAt < now) { 120 | __memoizedIncidents!.incidents[id].closedAt = now 121 | __memoizedIncidents!.incidents[id].status = 'closed' 122 | hasDelta = true 123 | } 124 | ongoingMaintenanceEvents.push({ 125 | id: id, 126 | incident: __memoizedIncidents!.incidents[id], 127 | }) 128 | } 129 | }) 130 | if (hasDelta) { 131 | await writeFile('.incidents.yml', dump({ 132 | useID: __memoizedIncidents?.useID, 133 | incidents: __memoizedIncidents?.incidents, 134 | })) 135 | // Commit changes 136 | const config = await getConfig() 137 | commit('$PREFIX Close maintenance issues'.replace('$PREFIX', config.incidentCommitPrefixClose || '📛'), 138 | (config.commitMessages || {}).commitAuthorName, 139 | (config.commitMessages || {}).commitAuthorEmail, '.incidents.yml') 140 | } 141 | 142 | return ongoingMaintenanceEvents 143 | } 144 | 145 | export const closeIncident = async (id: number) => { 146 | await getIncidents() 147 | __memoizedIncidents!.incidents[id].closedAt = Date.now() 148 | __memoizedIncidents!.incidents[id].status = 'closed' 149 | await writeFile('.incidents.yml', dump({ 150 | useID: __memoizedIncidents?.useID, 151 | incidents: __memoizedIncidents?.incidents, 152 | })) 153 | const config = await getConfig() 154 | commit('$PREFIX Close #$ID' 155 | .replace('$PREFIX', config.incidentCommitPrefixClose || '📛') 156 | .replace('$ID', id.toString(10)), 157 | (config.commitMessages || {}).commitAuthorName, 158 | (config.commitMessages || {}).commitAuthorEmail, '.incidents.yml') 159 | } 160 | 161 | export const createComment = async (meta: {slug: string; id: number; title: string; author: string}, comment: string) => { 162 | const filePath = path.join('incidents', `${meta.title}.md`) 163 | await appendFile(filePath, ` 164 | 165 | ${comment} 166 | 167 | 168 | --- 169 | `) 170 | const config = await getConfig() 171 | commit('$PREFIX Comment in #$ID by $AUTHOR' 172 | .replace('$PREFIX', config.incidentCommentPrefix || '💬') 173 | .replace('$AUTHOR', meta.author) 174 | .replace('$ID', meta.id.toString(10)), 175 | (config.commitMessages || {}).commitAuthorName, 176 | (config.commitMessages || {}).commitAuthorEmail, 177 | `"${filePath}"`) 178 | } 179 | -------------------------------------------------------------------------------- /src/commands/config.ts: -------------------------------------------------------------------------------- 1 | import Command from '../base' 2 | import {shouldContinue} from '../helpers/should-continue' 3 | import chalk from 'chalk' 4 | import child_process from 'child_process' 5 | import {prompt} from 'enquirer' 6 | import {flags} from '@oclif/command' 7 | import {addToEnv, getFromEnv, notificationsProviderGroup, possibleEnvVariables, ProviderTypes} from '../helpers/update-env-file' 8 | import {getSecret} from '../helpers/secrets' 9 | // import {getConfig} from '../helpers/config' 10 | 11 | const {AutoComplete, Select} = require('enquirer') 12 | 13 | enum ConfigOptions { 14 | ADD_ENVIRONMENT_VARIABLE='ADD_ENVIRONMENT_VARIABLE', ADD_NOTIFICATION_PROVIDER='ADD_NOTIFICATION_PROVIDER', ADD_SITE='ADD_SITE', OPEN_EDITOR='OPEN_EDITOR', OPEN_TEMPLATE='OPEN_TEMPLATE' 15 | } 16 | 17 | enum Code { 18 | SUCCESS=0, USER_ABORT=-1 19 | } 20 | 21 | export default class Config extends Command { 22 | static description = 'configures uclirc.yml'; 23 | 24 | static flags = { 25 | help: flags.help({char: 'h', description: 'Show help for config cmd'}), 26 | // 'add-site': flags.string({char: 's', description: 'Add site url to monitor'}), 27 | 'add-env-variable': flags.boolean({char: 'e', description: 'Add/edit environment variable'}), 28 | 'add-notification-provider': flags.boolean({char: 'n', description: 'Add/edit environment variables particular to a notification provider'}), 29 | 'open-editor': flags.boolean({char: 'o', description: 'Open in editor'}), 30 | // 'open-template': flags.boolean({char: 't', description: 'Open template to edit'}), 31 | } 32 | 33 | async run() { 34 | const {flags} = this.parse(Config) 35 | 36 | const shouldContinueObj = await shouldContinue() 37 | if (!shouldContinueObj.continue) { 38 | this.log(shouldContinueObj.message) 39 | return 40 | } 41 | 42 | let response: any 43 | 44 | if (Object.keys(flags).length === 0) { 45 | try { 46 | const question = new Select({ 47 | name: 'action', 48 | message: 'Select config option:', 49 | choices: [{ 50 | name: 'Add/edit environment variable', value: ConfigOptions.ADD_ENVIRONMENT_VARIABLE, 51 | }, { 52 | name: 'Add/edit notification provider', value: ConfigOptions.ADD_NOTIFICATION_PROVIDER, 53 | }, { 54 | // name: 'Add site url to monitor', value: ConfigOptions.ADD_SITE, 55 | // }, { 56 | name: 'Other configurations (Open in editor)', value: ConfigOptions.OPEN_EDITOR, 57 | // }, { 58 | // name: 'Other configurations (Open template)', value: ConfigOptions.OPEN_TEMPLATE, 59 | }], 60 | result(names: any) { 61 | return this.map(names) 62 | }, 63 | }) 64 | response = Object.values(await question.run())[0] 65 | } catch (error) { 66 | this.exitMessage(Code.USER_ABORT) 67 | return 68 | } 69 | } else if (flags['add-env-variable']) response = ConfigOptions.ADD_ENVIRONMENT_VARIABLE 70 | else if (flags['add-notification-provider']) response = ConfigOptions.ADD_NOTIFICATION_PROVIDER 71 | else if (flags['open-editor']) response = ConfigOptions.OPEN_EDITOR 72 | // else if (flags['open-template']) response = ConfigOptions.OPEN_TEMPLATE 73 | // else if (flags['add-site']) response = ConfigOptions.ADD_SITE 74 | 75 | switch (response) { 76 | // case ConfigOptions.ADD_SITE: 77 | // // Code to add a website 78 | // break 79 | case ConfigOptions.ADD_NOTIFICATION_PROVIDER: 80 | await this.addNotificationProvider() 81 | break 82 | case ConfigOptions.ADD_ENVIRONMENT_VARIABLE: 83 | await this.addEnvironmentVariable() 84 | break 85 | case ConfigOptions.OPEN_EDITOR: 86 | this.spawnEditor() 87 | break 88 | // case ConfigOptions.OPEN_TEMPLATE: 89 | // this.configTemplate() 90 | // break 91 | } 92 | } 93 | 94 | async addNotificationProvider() { 95 | const providerTypes = Object.values(ProviderTypes).map(val => { 96 | return {name: val} 97 | }) 98 | const questionType = new Select({ 99 | name: 'providerType', 100 | message: 'Select notification provider type:', 101 | choices: providerTypes, 102 | }) 103 | const providerType = await questionType.run() 104 | 105 | const providers = Object.keys(notificationsProviderGroup) 106 | .filter(key => notificationsProviderGroup[key].type === providerType) 107 | .map(key => { 108 | return { 109 | name: notificationsProviderGroup[key].name, 110 | value: key, 111 | } 112 | }) 113 | 114 | const questionProvider = new Select({ 115 | name: 'provider', 116 | message: 'Select notification provider:', 117 | choices: providers, 118 | result(names: any) { 119 | return this.map(names) 120 | }, 121 | }) 122 | const provider = Object.values(await questionProvider.run())[0] as string 123 | const providerObj = notificationsProviderGroup[provider] 124 | 125 | /* Add dependsOn only if previously not set, it becomes tedious if user has to input 126 | multiple providers of same type */ 127 | let listOfVariables: string[] = [] 128 | if (providerObj.dependsOn && !providerObj.dependsOn.every(key => getSecret(key))) 129 | listOfVariables = listOfVariables.concat(providerObj.dependsOn) 130 | listOfVariables = listOfVariables.concat(providerObj.variables) 131 | 132 | for await (const key of listOfVariables) { 133 | const originalValue = getFromEnv(key) 134 | const value: {env_variable_value: string} = await prompt({ 135 | name: 'env_variable_value', 136 | type: 'input', 137 | message: originalValue ? `${key} (${originalValue}):` : `${key}:`, 138 | }) 139 | if (value.env_variable_value) addToEnv(key, value.env_variable_value) 140 | } 141 | } 142 | 143 | async addEnvironmentVariable() { 144 | const question = new AutoComplete({ 145 | name: 'env_variable', 146 | message: 'Select environment variable?', 147 | choices: possibleEnvVariables, 148 | limit: 7, 149 | }) 150 | const key = await question.run() 151 | const originalValue = getFromEnv(key) 152 | const value: {env_variable_value: string} = await prompt({ 153 | name: 'env_variable_value', 154 | type: 'input', 155 | message: originalValue ? `Value (${originalValue}):` : 'Value:', 156 | }) 157 | if (value.env_variable_value) addToEnv(key, value.env_variable_value) 158 | } 159 | 160 | // platform aware 161 | getPlatformDefaultEditor() { 162 | let editor = null 163 | switch (process.platform) { 164 | case 'win32': // though Wordpad is the default, I feel generally users prefer notepad 165 | editor = 'notepad' 166 | break 167 | case 'aix': 168 | case 'linux': 169 | case 'darwin': 170 | case 'freebsd': 171 | case 'sunos': 172 | case 'openbsd': 173 | editor = 'vi' 174 | break 175 | } 176 | return editor 177 | } 178 | 179 | exitMessage(code: number | null) { 180 | if (code === 0) 181 | this.log(chalk.green.inverse('Your upptime configured successfully!')) 182 | else if (code === Code.USER_ABORT) 183 | this.log(chalk.bgRed.white('Aborted! User generated interrupt')) 184 | else 185 | this.log(chalk.red.inverse('Your upptime did not configure!')) 186 | } 187 | 188 | spawnEditor() { 189 | const editor = process.env.EDITOR || this.getPlatformDefaultEditor() 190 | if (editor === null) { 191 | this.log('Set "EDITOR" env variable to open your favorite editor') 192 | return 193 | } 194 | const child = child_process.spawn(editor, ['.uclirc.yml'], { 195 | stdio: 'inherit', 196 | }) 197 | child.on('exit', (_code, _signal) => { 198 | this.exitMessage(_code) 199 | }) 200 | } 201 | 202 | // async configTemplate() { 203 | // const config = await getConfig() 204 | // const snippet = new Snippet({ 205 | // name: 'config', 206 | // message: 'Fill out the fields in uclirc.yaml', 207 | // template: config.toString(), 208 | // }) 209 | 210 | // snippet.run() 211 | // .then((answer: { result: any }) => this.log('Answer:', answer.result)) 212 | // .catch(this.error) 213 | // } 214 | } 215 | -------------------------------------------------------------------------------- /src/helpers/notifme.ts: -------------------------------------------------------------------------------- 1 | import NotifmeSdk, {EmailProvider, SlackProvider, SmsProvider} from 'notifme-sdk' 2 | import axios from 'axios' 3 | import type {Channel} from 'notifme-sdk' 4 | import {replaceEnvironmentVariables} from './environment' 5 | import {getSecret} from './secrets' 6 | import {infoErrorLogger} from './log' 7 | 8 | const channels: { 9 | email?: Channel; 10 | sms?: Channel; 11 | slack?: Channel; 12 | } = {} 13 | 14 | if ( 15 | getSecret('NOTIFICATION_EMAIL_SENDGRID') || 16 | getSecret('NOTIFICATION_EMAIL_SES') || 17 | getSecret('NOTIFICATION_EMAIL_SPARKPOST') || 18 | getSecret('NOTIFICATION_EMAIL_MAILGUN') || 19 | getSecret('NOTIFICATION_EMAIL_SMTP') 20 | ) { 21 | channels.email = { 22 | providers: [], 23 | multiProviderStrategy: 24 | (getSecret('NOTIFICATION_EMAIL_STRATEGY') as 'fallback' | 'roundrobin' | 'no-fallback') || 25 | 'roundrobin', 26 | } 27 | 28 | if (getSecret('NOTIFICATION_EMAIL_SENDGRID')) { 29 | channels.email.providers.push({ 30 | type: 'sendgrid', 31 | apiKey: getSecret('NOTIFICATION_EMAIL_SENDGRID_API_KEY') as string, 32 | }) 33 | } 34 | if (getSecret('NOTIFICATION_EMAIL_SES')) { 35 | channels.email.providers.push({ 36 | type: 'ses', 37 | region: getSecret('NOTIFICATION_EMAIL_SES_REGION') as string, 38 | accessKeyId: getSecret('NOTIFICATION_EMAIL_SES_ACCESS_KEY_ID') as string, 39 | secretAccessKey: getSecret('NOTIFICATION_EMAIL_SES_SECRET_ACCESS_KEY') as string, 40 | sessionToken: getSecret('NOTIFICATION_EMAIL_SES_SESSION_TOKEN') as string, 41 | }) 42 | } 43 | if (getSecret('NOTIFICATION_EMAIL_SPARKPOST')) { 44 | channels.email.providers.push({ 45 | type: 'sparkpost', 46 | apiKey: getSecret('NOTIFICATION_EMAIL_SPARKPOST_API_KEY') as string, 47 | }) 48 | } 49 | if (getSecret('NOTIFICATION_EMAIL_MAILGUN')) { 50 | channels.email.providers.push({ 51 | type: 'mailgun', 52 | apiKey: getSecret('NOTIFICATION_EMAIL_MAILGUN_API_KEY') as string, 53 | domainName: getSecret('NOTIFICATION_EMAIL_MAILGUN_DOMAIN_NAME') as string, 54 | }) 55 | } 56 | if (getSecret('NOTIFICATION_EMAIL_SMTP')) { 57 | channels.email.providers.push({ 58 | type: 'smtp', 59 | port: (getSecret('NOTIFICATION_EMAIL_SMTP_PORT') ? 60 | parseInt(getSecret('NOTIFICATION_EMAIL_SMTP_PORT') || '', 10) : 61 | 587) as 587 | 25 | 465, 62 | host: getSecret('NOTIFICATION_EMAIL_SMTP_HOST') as string, 63 | auth: { 64 | user: getSecret('NOTIFICATION_EMAIL_SMTP_USERNAME') as string, 65 | pass: getSecret('NOTIFICATION_EMAIL_SMTP_PASSWORD') as string, 66 | }, 67 | }) 68 | } 69 | } 70 | 71 | if ( 72 | getSecret('NOTIFICATION_SMS_46ELKS') || 73 | getSecret('NOTIFICATION_SMS_CALLR') || 74 | getSecret('NOTIFICATION_SMS_CLICKATELL') || 75 | getSecret('NOTIFICATION_SMS_INFOBIP') || 76 | getSecret('NOTIFICATION_SMS_NEXMO') || 77 | getSecret('NOTIFICATION_SMS_OVH') || 78 | getSecret('NOTIFICATION_SMS_PLIVO') || 79 | getSecret('NOTIFICATION_SMS_TWILIO') 80 | ) { 81 | channels.sms = { 82 | providers: [], 83 | multiProviderStrategy: 84 | (getSecret('NOTIFICATION_SMS_STRATEGY') as 'fallback' | 'roundrobin' | 'no-fallback') || 85 | 'roundrobin', 86 | } 87 | if (getSecret('NOTIFICATION_SMS_46ELKS')) { 88 | channels.sms.providers.push({ 89 | type: '46elks', 90 | apiUsername: getSecret('NOTIFICATION_SMS_46ELKS_API_USERNAME') as string, 91 | apiPassword: getSecret('NOTIFICATION_SMS_46ELKS_API_PASSWORD') as string, 92 | }) 93 | } 94 | if (getSecret('NOTIFICATION_SMS_CALLR')) { 95 | channels.sms.providers.push({ 96 | type: 'callr', 97 | login: getSecret('NOTIFICATION_SMS_CALLR_LOGIN') as string, 98 | password: getSecret('NOTIFICATION_SMS_CALLR_PASSWORD') as string, 99 | }) 100 | } 101 | if (getSecret('NOTIFICATION_SMS_CLICKATELL')) { 102 | channels.sms.providers.push({ 103 | type: 'clickatell', 104 | apiKey: getSecret('NOTIFICATION_SMS_CLICKATELL_API_KEY') as string, 105 | }) 106 | } 107 | if (getSecret('NOTIFICATION_SMS_INFOBIP')) { 108 | channels.sms.providers.push({ 109 | type: 'infobip', 110 | username: getSecret('NOTIFICATION_SMS_INFOBIP_USERNAME') as string, 111 | password: getSecret('NOTIFICATION_SMS_INFOBIP_PASSWORD') as string, 112 | }) 113 | } 114 | if (getSecret('NOTIFICATION_SMS_NEXMO')) { 115 | channels.sms.providers.push({ 116 | type: 'nexmo', 117 | apiKey: getSecret('NOTIFICATION_SMS_NEXMO_API_KEY') as string, 118 | apiSecret: getSecret('NOTIFICATION_SMS_NEXMO_API_SECRET') as string, 119 | }) 120 | } 121 | if (getSecret('NOTIFICATION_SMS_OVH')) { 122 | channels.sms.providers.push({ 123 | type: 'ovh', 124 | appKey: getSecret('NOTIFICATION_SMS_OVH_APP_KEY') as string, 125 | appSecret: getSecret('NOTIFICATION_SMS_OVH_APP_SECRET') as string, 126 | consumerKey: getSecret('NOTIFICATION_SMS_OVH_CONSUMER_KEY') as string, 127 | account: getSecret('NOTIFICATION_SMS_OVH_ACCOUNT') as string, 128 | host: getSecret('NOTIFICATION_SMS_OVH_HOST') as string, 129 | }) 130 | } 131 | if (getSecret('NOTIFICATION_SMS_PLIVO')) { 132 | channels.sms.providers.push({ 133 | type: 'plivo', 134 | authId: getSecret('NOTIFICATION_SMS_PLIVO_AUTH_ID') as string, 135 | authToken: getSecret('NOTIFICATION_SMS_PLIVO_AUTH_TOKEN') as string, 136 | }) 137 | } 138 | if (getSecret('NOTIFICATION_SMS_TWILIO')) { 139 | channels.sms.providers.push({ 140 | type: 'twilio', 141 | accountSid: getSecret('NOTIFICATION_SMS_TWILIO_ACCOUNT_SID') as string, 142 | authToken: getSecret('NOTIFICATION_SMS_TWILIO_AUTH_TOKEN') as string, 143 | }) 144 | } 145 | } 146 | 147 | if (getSecret('NOTIFICATION_SLACK')) { 148 | channels.slack = { 149 | providers: [], 150 | multiProviderStrategy: 151 | (getSecret('NOTIFICATION_SLACK_STRATEGY') as 'fallback' | 'roundrobin' | 'no-fallback') || 152 | 'roundrobin', 153 | } 154 | 155 | if (getSecret('NOTIFICATION_SLACK_WEBHOOK')) { 156 | channels.slack.providers.push({ 157 | type: 'webhook', 158 | webhookUrl: getSecret('NOTIFICATION_SLACK_WEBHOOK_URL') as string, 159 | }) 160 | } 161 | } 162 | 163 | const notifier = new NotifmeSdk({ 164 | channels, 165 | }) 166 | 167 | export const sendNotification = async (message: string) => { 168 | infoErrorLogger.info(`Sending notification ${message}`) 169 | message = replaceEnvironmentVariables(message) 170 | 171 | if (channels.email) { 172 | infoErrorLogger.info('Sending email') 173 | try { 174 | await notifier.send({ 175 | email: { 176 | from: (getSecret('NOTIFICATION_EMAIL_FROM') || getSecret('NOTIFICATION_EMAIL')) as string, 177 | to: (getSecret('NOTIFICATION_EMAIL_TO') || getSecret('NOTIFICATION_EMAIL')) as string, 178 | subject: message, 179 | html: message, 180 | }, 181 | }) 182 | infoErrorLogger.info('Success email') 183 | } catch (error) { 184 | infoErrorLogger.error(`Got an error, email: ${error}`) 185 | } 186 | infoErrorLogger.info('Finished sending email') 187 | } 188 | if (channels.sms) { 189 | infoErrorLogger.info('Sending SMS') 190 | try { 191 | await notifier.send({ 192 | sms: { 193 | from: getSecret('NOTIFICATION_SMS_FROM') as string, 194 | to: getSecret('NOTIFICATION_SMS_TO') as string, 195 | text: message, 196 | }, 197 | }) 198 | infoErrorLogger.info('Success SMS') 199 | } catch (error) { 200 | infoErrorLogger.info(`Got an error, sms: ${error}`) 201 | } 202 | infoErrorLogger.info('Finished sending SMS') 203 | } 204 | if (channels.slack) { 205 | infoErrorLogger.info('Sending Slack') 206 | try { 207 | await notifier.send({ 208 | slack: { 209 | text: message, 210 | }, 211 | }) 212 | infoErrorLogger.info('Success Slack') 213 | } catch (error) { 214 | infoErrorLogger.info(`Got an error, slack: ${error}`) 215 | } 216 | infoErrorLogger.info('Finished sending Slack') 217 | } 218 | if (getSecret('NOTIFICATION_DISCORD_WEBHOOK_URL')) { 219 | infoErrorLogger.info('Sending Discord') 220 | try { 221 | await axios.post(getSecret('NOTIFICATION_DISCORD_WEBHOOK_URL') as string, { 222 | content: message, 223 | }) 224 | infoErrorLogger.info('Success Discord') 225 | } catch (error) { 226 | infoErrorLogger.info(`Got an error, discord: ${error}`) 227 | } 228 | infoErrorLogger.info('Finished sending Discord') 229 | } 230 | if (getSecret('NOTIFICATION_TELEGRAM') && getSecret('NOTIFICATION_TELEGRAM_BOT_KEY')) { 231 | infoErrorLogger.info('Sending Telegram') 232 | try { 233 | await axios.post( 234 | `https://api.telegram.org/bot${getSecret('NOTIFICATION_TELEGRAM_BOT_KEY')}/sendMessage`, 235 | { 236 | parse_mode: 'Markdown', 237 | disable_web_page_preview: true, 238 | chat_id: getSecret('NOTIFICATION_TELEGRAM_CHAT_ID'), 239 | text: message.replace(/_/g, '\\_'), 240 | } 241 | ) 242 | infoErrorLogger.info('Success Telegram') 243 | } catch (error) { 244 | infoErrorLogger.info(`Got an error, telegram: ${error}`) 245 | } 246 | infoErrorLogger.info('Finished sending Telegram') 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/helpers/update-env-file.ts: -------------------------------------------------------------------------------- 1 | // helper functions to edit .env file 2 | import {appendFile, createFile, existsSync, readFile, writeFile} from 'fs-extra' 3 | import {getSecret} from './secrets' 4 | 5 | export async function addToEnv(key: string, value: string) { 6 | if (!existsSync('.env')) { 7 | await createFile('.env') 8 | } 9 | const appendString = `${key}=${value}\n` 10 | 11 | // Update current env, future env will be updated in subsequent config() calls of init() 12 | // could call config(), but only one variable changes, so why try update all 13 | readFile('.env', 'utf-8', function (err, data) { 14 | if (err) throw err 15 | if (data.includes(`${key}=`)) { 16 | const newData = data.replace(new RegExp(`${key}=.*\n`), appendString) 17 | writeFile('.env', newData, 'utf-8', function (err) { 18 | if (err) throw err 19 | }) 20 | } else appendFile('.env', appendString) 21 | }) 22 | process.env[key] = value 23 | } 24 | 25 | export async function deleteFromEnv(key: string) { 26 | if (existsSync('.env')) { 27 | // Delete current env, future env will be updated in subsequent config() calls of init() 28 | // could call config(), but only one variable changes, so why try update all 29 | readFile('.env', 'utf-8', function (err, data) { 30 | if (err) throw err 31 | if (data.includes(`${key}=`)) { 32 | const newData = data.replace(new RegExp(`${key}=.*\n`), '') 33 | writeFile('.env', newData, 'utf-8', function (err) { 34 | if (err) throw err 35 | }) 36 | } 37 | }) 38 | delete process.env[key] 39 | } 40 | } 41 | 42 | export function getFromEnv(key: string) { 43 | return getSecret(key) 44 | } 45 | 46 | /* Data to provide suggestions */ 47 | export enum ProviderTypes{ 48 | MAIL='Mail', SMS='SMS', SOCIAL='Social' 49 | } 50 | const providerTypeMailDependency = [ 51 | 'NOTIFICATION_EMAIL', 52 | 'NOTIFICATION_EMAIL_FROM', 53 | 'NOTIFICATION_EMAIL_TO', 54 | ] 55 | const providerTypeSMSDependency = [ 56 | 'NOTIFICATION_SMS_FROM', 57 | 'NOTIFICATION_SMS_TO', 58 | ] 59 | 60 | interface Provider { 61 | name: string; 62 | type: string; 63 | dependsOn?: string[]; 64 | variables: string[]; 65 | } 66 | // Sorted lexicographically 67 | export const notificationsProviderGroup: {[key: string]: Provider} = { 68 | elks: { 69 | name: '46ELKS', 70 | type: ProviderTypes.SMS, 71 | dependsOn: providerTypeSMSDependency, 72 | variables: [ 73 | 'NOTIFICATION_SMS_46ELKS', 74 | 'NOTIFICATION_SMS_46ELKS_API_PASSWORD', 75 | 'NOTIFICATION_SMS_46ELKS_API_USERNAME', 76 | ], 77 | }, 78 | callr: { 79 | name: 'Callr', 80 | type: ProviderTypes.SMS, 81 | dependsOn: providerTypeSMSDependency, 82 | variables: [ 83 | 'NOTIFICATION_SMS_CALLR', 84 | 'NOTIFICATION_SMS_CALLR_LOGIN', 85 | 'NOTIFICATION_SMS_CALLR_PASSWORD', 86 | ], 87 | }, 88 | clickatell: { 89 | name: 'Clickatell', 90 | type: ProviderTypes.SMS, 91 | dependsOn: providerTypeSMSDependency, 92 | variables: [ 93 | 'NOTIFICATION_SMS_CLICKATELL', 94 | 'NOTIFICATION_SMS_CLICKATELL_API_KEY', 95 | ], 96 | }, 97 | discord: { 98 | name: 'Discord', 99 | type: ProviderTypes.SOCIAL, 100 | variables: [ 101 | 'NOTIFICATION_DISCORD', 102 | 'NOTIFICATION_DISCORD_WEBHOOK', 103 | 'NOTIFICATION_DISCORD_WEBHOOK_URL', 104 | ], 105 | }, 106 | infobip: { 107 | name: 'Infobip', 108 | type: ProviderTypes.SMS, 109 | dependsOn: providerTypeSMSDependency, 110 | variables: [ 111 | 'NOTIFICATION_SMS_INFOBIP', 112 | 'NOTIFICATION_SMS_INFOBIP_PASSWORD', 113 | 'NOTIFICATION_SMS_INFOBIP_USERNAME', 114 | ], 115 | }, 116 | mailgun: { 117 | name: 'Mailgun', 118 | type: ProviderTypes.MAIL, 119 | dependsOn: providerTypeMailDependency, 120 | variables: [ 121 | 'NOTIFICATION_EMAIL_MAILGUN', 122 | 'NOTIFICATION_EMAIL_MAILGUN_API_KEY', 123 | 'NOTIFICATION_EMAIL_MAILGUN_DOMAIN_NAME', 124 | ], 125 | }, 126 | nexmo: { 127 | name: 'Nexmo', 128 | type: ProviderTypes.SMS, 129 | dependsOn: providerTypeSMSDependency, 130 | variables: [ 131 | 'NOTIFICATION_SMS_NEXMO', 132 | 'NOTIFICATION_SMS_NEXMO_API_KEY', 133 | 'NOTIFICATION_SMS_NEXMO_API_SECRET', 134 | ], 135 | }, 136 | ovh: { 137 | name: 'OVH', 138 | type: ProviderTypes.SMS, 139 | dependsOn: providerTypeSMSDependency, 140 | variables: [ 141 | 'NOTIFICATION_SMS_OVH', 142 | 'NOTIFICATION_SMS_OVH_ACCOUNT', 143 | 'NOTIFICATION_SMS_OVH_APP_KEY', 144 | 'NOTIFICATION_SMS_OVH_APP_SECRET', 145 | 'NOTIFICATION_SMS_OVH_CONSUMER_KEY', 146 | 'NOTIFICATION_SMS_OVH_HOST', 147 | ], 148 | }, 149 | plivo: { 150 | name: 'Plivo', 151 | type: ProviderTypes.SMS, 152 | dependsOn: providerTypeSMSDependency, 153 | variables: [ 154 | 'NOTIFICATION_SMS_PLIVO', 155 | 'NOTIFICATION_SMS_PLIVO_AUTH_ID', 156 | 'NOTIFICATION_SMS_PLIVO_AUTH_TOKEN', 157 | ], 158 | }, 159 | sendgrid: { 160 | name: 'SendGrid', 161 | type: ProviderTypes.MAIL, 162 | dependsOn: providerTypeMailDependency, 163 | variables: [ 164 | 'NOTIFICATION_EMAIL_SENDGRID', 165 | 'NOTIFICATION_EMAIL_SENDGRID_API_KEY', 166 | ], 167 | }, 168 | ses: { 169 | name: 'SES', 170 | type: ProviderTypes.MAIL, 171 | dependsOn: providerTypeMailDependency, 172 | variables: [ 173 | 'NOTIFICATION_EMAIL_SES', 174 | 'NOTIFICATION_EMAIL_SES_ACCESS_KEY_ID', 175 | 'NOTIFICATION_EMAIL_SES_REGION', 176 | 'NOTIFICATION_EMAIL_SES_SECRET_ACCESS_KEY', 177 | 'NOTIFICATION_EMAIL_SES_SESSION_TOKEN', 178 | ], 179 | }, 180 | slack: { 181 | name: 'Slack', 182 | type: ProviderTypes.SOCIAL, 183 | variables: [ 184 | 'NOTIFICATION_SLACK', 185 | 'NOTIFICATION_SLACK_WEBHOOK', 186 | 'NOTIFICATION_SLACK_WEBHOOK_URL', 187 | ], 188 | }, 189 | smtp: { 190 | name: 'SMTP', 191 | type: ProviderTypes.MAIL, 192 | dependsOn: providerTypeMailDependency, 193 | variables: [ 194 | 'NOTIFICATION_EMAIL_SMTP', 195 | 'NOTIFICATION_EMAIL_SMTP_HOST', 196 | 'NOTIFICATION_EMAIL_SMTP_PASSWORD', 197 | 'NOTIFICATION_EMAIL_SMTP_PORT', 198 | 'NOTIFICATION_EMAIL_SMTP_USERNAME', 199 | ], 200 | }, 201 | sparkhost: { 202 | name: 'SparkHost', 203 | type: ProviderTypes.MAIL, 204 | dependsOn: providerTypeMailDependency, 205 | variables: [ 206 | 'NOTIFICATION_EMAIL_SPARKPOST', 207 | 'NOTIFICATION_EMAIL_SPARKPOST_API_KEY', 208 | ], 209 | }, 210 | telegram: { 211 | name: 'Telegram', 212 | type: ProviderTypes.SOCIAL, 213 | variables: [ 214 | 'NOTIFICATION_TELEGRAM', 215 | 'NOTIFICATION_TELEGRAM_BOT_KEY', 216 | 'NOTIFICATION_TELEGRAM_CHAT_ID', 217 | ], 218 | }, 219 | twilio: { 220 | name: 'Twilio', 221 | type: ProviderTypes.SMS, 222 | dependsOn: providerTypeSMSDependency, 223 | variables: [ 224 | 'NOTIFICATION_SMS_TWILIO', 225 | 'NOTIFICATION_SMS_TWILIO_ACCOUNT_SID', 226 | 'NOTIFICATION_SMS_TWILIO_AUTH_TOKEN', 227 | ], 228 | }, 229 | } 230 | 231 | // Sorted lexicographically 232 | export const possibleEnvVariables = [ 233 | 'NOTIFICATION_DISCORD', 234 | 'NOTIFICATION_DISCORD_WEBHOOK', 235 | 'NOTIFICATION_DISCORD_WEBHOOK_URL', 236 | 'NOTIFICATION_EMAIL', 237 | 'NOTIFICATION_EMAIL_FROM', 238 | 'NOTIFICATION_EMAIL_MAILGUN', 239 | 'NOTIFICATION_EMAIL_MAILGUN_API_KEY', 240 | 'NOTIFICATION_EMAIL_MAILGUN_DOMAIN_NAME', 241 | 'NOTIFICATION_EMAIL_SENDGRID', 242 | 'NOTIFICATION_EMAIL_SENDGRID_API_KEY', 243 | 'NOTIFICATION_EMAIL_SES', 244 | 'NOTIFICATION_EMAIL_SES_ACCESS_KEY_ID', 245 | 'NOTIFICATION_EMAIL_SES_REGION', 246 | 'NOTIFICATION_EMAIL_SES_SECRET_ACCESS_KEY', 247 | 'NOTIFICATION_EMAIL_SES_SESSION_TOKEN', 248 | 'NOTIFICATION_EMAIL_SMTP', 249 | 'NOTIFICATION_EMAIL_SMTP_HOST', 250 | 'NOTIFICATION_EMAIL_SMTP_PASSWORD', 251 | 'NOTIFICATION_EMAIL_SMTP_PORT', 252 | 'NOTIFICATION_EMAIL_SMTP_USERNAME', 253 | 'NOTIFICATION_EMAIL_SPARKPOST', 254 | 'NOTIFICATION_EMAIL_SPARKPOST_API_KEY', 255 | 'NOTIFICATION_EMAIL_TO', 256 | 'NOTIFICATION_SLACK', 257 | 'NOTIFICATION_SLACK_WEBHOOK', 258 | 'NOTIFICATION_SLACK_WEBHOOK_URL', 259 | 'NOTIFICATION_SMS_46ELKS', 260 | 'NOTIFICATION_SMS_46ELKS_API_PASSWORD', 261 | 'NOTIFICATION_SMS_46ELKS_API_USERNAME', 262 | 'NOTIFICATION_SMS_CALLR', 263 | 'NOTIFICATION_SMS_CALLR_LOGIN', 264 | 'NOTIFICATION_SMS_CALLR_PASSWORD', 265 | 'NOTIFICATION_SMS_CLICKATELL', 266 | 'NOTIFICATION_SMS_CLICKATELL_API_KEY', 267 | 'NOTIFICATION_SMS_FROM', 268 | 'NOTIFICATION_SMS_INFOBIP', 269 | 'NOTIFICATION_SMS_INFOBIP_PASSWORD', 270 | 'NOTIFICATION_SMS_INFOBIP_USERNAME', 271 | 'NOTIFICATION_SMS_NEXMO', 272 | 'NOTIFICATION_SMS_NEXMO_API_KEY', 273 | 'NOTIFICATION_SMS_NEXMO_API_SECRET', 274 | 'NOTIFICATION_SMS_OVH', 275 | 'NOTIFICATION_SMS_OVH_ACCOUNT', 276 | 'NOTIFICATION_SMS_OVH_APP_KEY', 277 | 'NOTIFICATION_SMS_OVH_APP_SECRET', 278 | 'NOTIFICATION_SMS_OVH_CONSUMER_KEY', 279 | 'NOTIFICATION_SMS_OVH_HOST', 280 | 'NOTIFICATION_SMS_PLIVO', 281 | 'NOTIFICATION_SMS_PLIVO_AUTH_ID', 282 | 'NOTIFICATION_SMS_PLIVO_AUTH_TOKEN', 283 | 'NOTIFICATION_SMS_TO', 284 | 'NOTIFICATION_SMS_TWILIO', 285 | 'NOTIFICATION_SMS_TWILIO_ACCOUNT_SID', 286 | 'NOTIFICATION_SMS_TWILIO_AUTH_TOKEN', 287 | 'NOTIFICATION_TELEGRAM', 288 | 'NOTIFICATION_TELEGRAM_BOT_KEY', 289 | 'NOTIFICATION_TELEGRAM_CHAT_ID', 290 | ] 291 | -------------------------------------------------------------------------------- /src/summary.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable complexity */ 2 | import slugify from '@sindresorhus/slugify' 3 | import {mkdirp, readFile, writeFile, writeFileSync} from 'fs-extra' 4 | import {join} from 'path' 5 | import {format} from 'prettier' 6 | import {getResponseTimeForSite} from './helpers/calculate-response-time' 7 | import {getUptimePercentForSite} from './helpers/calculate-uptime' 8 | import {getConfig} from './helpers/config' 9 | import {commit, push} from './helpers/git' 10 | import {SiteStatus} from './interfaces' 11 | import {parseURL} from 'whatwg-url' 12 | import {getResponseTimeColor, getUptimeColor} from './helpers/get-badge-color' 13 | import {existsSync} from 'fs-extra' 14 | import {infoErrorLogger} from './helpers/log' 15 | import {cli} from 'cli-ux' 16 | import chalk from 'chalk' 17 | 18 | export const generateSummary = async () => { 19 | cli.action.start('Running summary workflow') 20 | await mkdirp('history') 21 | const config = await getConfig() 22 | const i18n = config.i18n || {} 23 | 24 | if (!existsSync('README.md')) { 25 | writeFileSync('README.md', `# 📈 ${i18n.liveStatus || 'Live Status'}: ${ 26 | i18n.liveStatusHtmlComment || '' 27 | } **${i18n.allSystemsOperational || '🟩 All systems operational'}**\n\n`) 28 | } 29 | let readmeContent = await readFile(join('.', 'README.md'), 'utf8') 30 | 31 | const startText = readmeContent.split( 32 | config.summaryStartHtmlComment || '' 33 | )[0] 34 | const endText = readmeContent.split( 35 | config.summaryEndHtmlComment || '' 36 | )[1] 37 | 38 | // This object will track the summary data of all sites 39 | const pageStatuses: Array = [] 40 | 41 | // We'll keep incrementing this if there are down/degraded sites 42 | // This is used to show the overall status later 43 | let numberOfDown = 0 44 | let numberOfDegraded = 0 45 | 46 | // Loop through each site and add compute the current status 47 | for await (const site of config.sites) { 48 | const slug = site.slug || slugify(site.name) 49 | 50 | const uptimes = await getUptimePercentForSite(slug) 51 | infoErrorLogger.info('Uptimes', uptimes) 52 | 53 | const responseTimes = await getResponseTimeForSite(slug) 54 | infoErrorLogger.info('Response times', responseTimes) 55 | 56 | let fallbackIcon = '' 57 | try { 58 | fallbackIcon = `https://www.google.com/s2/favicons?domain=${parseURL(site.url)?.host}` 59 | } catch (error) {} 60 | 61 | pageStatuses.push({ 62 | name: site.name, 63 | url: site.urlSecretText || site.url, 64 | icon: site.icon || fallbackIcon, 65 | slug, 66 | status: responseTimes.currentStatus, 67 | uptime: uptimes.all, 68 | uptimeDay: uptimes.day, 69 | uptimeWeek: uptimes.week, 70 | uptimeMonth: uptimes.month, 71 | uptimeYear: uptimes.year, 72 | time: Math.floor(responseTimes.all), 73 | timeDay: responseTimes.day, 74 | timeWeek: responseTimes.week, 75 | timeMonth: responseTimes.month, 76 | timeYear: responseTimes.year, 77 | dailyMinutesDown: uptimes.dailyMinutesDown, 78 | }) 79 | if (responseTimes.currentStatus === 'down') numberOfDown++ 80 | if (responseTimes.currentStatus === 'degraded') numberOfDegraded++ 81 | } 82 | 83 | if (readmeContent.includes(config.summaryStartHtmlComment || '')) { 84 | readmeContent = `${startText}${config.summaryStartHtmlComment || ''} 85 | 86 | 87 | 88 | | ${i18n.url || 'URL'} | ${i18n.status || 'Status'} | ${i18n.history || 'History'} | ${ 89 | i18n.responseTime || 'Response Time' 90 | } | ${i18n.uptime || 'Uptime'} | 91 | | --- | ------ | ------- | ------------- | ------ | 92 | ${pageStatuses 93 | .map( 94 | page => 95 | `| ${ 96 | page.url.includes('$') ? page.name : `[${page.name}](${page.url})` 97 | } | ${ 98 | page.status === 'up' ? 99 | i18n.up || '🟩 Up' : 100 | page.status === 'degraded' ? 101 | i18n.degraded || '🟨 Degraded' : 102 | i18n.down || '🟥 Down' 103 | } | ${page.slug}.yml |
${page.timeWeek}${ 104 | i18n.ms || 'ms' 105 | }
${
106 |         i18n.responseTime || 'Response time'
107 |       } ${
108 |         page.time
109 |       }
${
116 |         i18n.responseTimeDay || '24-hour response time'
117 |       } ${
118 |         page.timeDay
119 |       }
${
126 |         i18n.responseTimeWeek || '7-day response time'
127 |       } ${
128 |         page.timeWeek
129 |       }
${
136 |         i18n.responseTimeMonth || '30-day response time'
137 |       } ${
138 |         page.timeMonth
139 |       }
${
146 |         i18n.responseTimeYear || '1-year response time'
147 |       } ${
148 |         page.timeYear
149 |       }
|
${page.uptimeWeek}${
156 |         i18n.uptime || 'All-time uptime'
157 |       } ${
158 |         page.uptime
159 |       }
${
166 |         i18n.uptimeDay || '24-hour uptime'
167 |       } ${
168 |         page.uptimeDay
169 |       }
${
176 |         i18n.uptimeWeek || '7-day uptime'
177 |       } ${
178 |         page.uptimeWeek
179 |       }
${
186 |         i18n.uptimeMonth || '30-day uptime'
187 |       } ${
188 |         page.uptimeMonth
189 |       }
${
196 |         i18n.uptimeYear || '1-year uptime'
197 |       } ${
198 |         page.uptimeYear
199 |       }
` 206 | ) 207 | .join('\n')} 208 | ${config.summaryEndHtmlComment || ''}${endText}` 209 | } 210 | 211 | readmeContent = readmeContent 212 | .split('\n') 213 | .map((line, _) => { 214 | if ( 215 | line.includes('') 216 | ) 217 | return '\nWith [Upptime](https://upptime.js.org), you can get your own unlimited and free uptime monitor and status page. We use Issues as incident reports, Cron Jobs as uptime monitors, and Pages for the status page.' 218 | return line 219 | }) 220 | .filter(line => !line.startsWith(`## 📈 ${i18n.liveStatus || 'Live Status'}`)) 221 | .join('\n') 222 | 223 | // Add live status line 224 | readmeContent = readmeContent 225 | .split('\n') 226 | .map(line => { 227 | if (line.includes('')) { 228 | line = `${line.split('')[0]} **${ 229 | numberOfDown === 0 ? 230 | numberOfDegraded === 0 ? 231 | i18n.allSystemsOperational || '🟩 All systems operational' : 232 | i18n.degradedPerformance || '🟨 Degraded performance' : 233 | numberOfDown === config.sites.length ? 234 | i18n.completeOutage || '🟥 Complete outage' : 235 | i18n.partialOutage || '🟧 Partial outage' 236 | }**` 237 | } 238 | return line 239 | }) 240 | .join('\n') 241 | 242 | await writeFile(join('.', 'README.md'), format(readmeContent, {parser: 'markdown'})) 243 | commit( 244 | (config.commitMessages || {}).readmeContent || 245 | ':pencil: Update summary in README [skip ci] [upptime]', 246 | (config.commitMessages || {}).commitAuthorName, 247 | (config.commitMessages || {}).commitAuthorEmail 248 | ) 249 | 250 | await writeFile(join('.', 'history', 'summary.json'), JSON.stringify(pageStatuses, null, 2)) 251 | commit( 252 | (config.commitMessages || {}).summaryJson || 253 | ':card_file_box: Update status summary [skip ci] [upptime]', 254 | (config.commitMessages || {}).commitAuthorName, 255 | (config.commitMessages || {}).commitAuthorEmail 256 | ) 257 | if (config.commits?.provider && config.commits?.provider === 'GitHub') 258 | push() 259 | cli.action.stop(chalk.green('done')) 260 | } 261 | -------------------------------------------------------------------------------- /src/update.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-depth */ 2 | /* eslint-disable complexity */ 3 | 4 | import slugify from '@sindresorhus/slugify' 5 | import {mkdirp, readFile, writeFile} from 'fs-extra' 6 | import {load} from 'js-yaml' 7 | import {join} from 'path' 8 | import {getConfig} from './helpers/config' 9 | // import {replaceEnvironmentVariables} from './helpers/environment' 10 | import {commit, lastCommit, push} from './helpers/git' 11 | import {infoErrorLogger, statusLogger} from './helpers/log' 12 | import {ping} from './helpers/ping' 13 | import {curl} from './helpers/request' 14 | import {SiteHistory} from './interfaces' 15 | import {generateSummary} from './summary' 16 | import cli from 'cli-ux' 17 | import chalk from 'chalk' 18 | import {closeIncident, closeMaintenanceIncidents, createComment, createIncident, getIncidents, getIndexes} from './helpers/incidents' 19 | import {sendNotification} from './helpers/notifme' 20 | import { uniqueNamesGenerator, Config, adjectives, colors, animals } from 'unique-names-generator'; 21 | 22 | 23 | const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) 24 | export const update = async (shouldCommit = false) => { 25 | // !! DIFF:: not checking if the .yml is valid, missing shouldContinue() 26 | cli.action.start(`Running ${shouldCommit ? 'response-time' : 'update'} workflow`) 27 | await mkdirp('history') 28 | 29 | const config = await getConfig() 30 | let hasDelta = false 31 | 32 | // close maintenance issues 33 | const ongoingMaintenanceEvents = await closeMaintenanceIncidents() 34 | 35 | for await (const site of config.sites) { 36 | infoErrorLogger.info(`Checking ${site.urlSecretText || site.url}`) 37 | const slug = site.slug || slugify(site.name) 38 | let currentStatus = 'unknown' 39 | let startTime = new Date() 40 | try { 41 | const siteHistory = load( 42 | (await readFile(join('.', 'history', `${slug}.yml`), 'utf8')) 43 | .split('\n') 44 | .map(line => (line.startsWith('- ') ? line.replace('- ', '') : line)) 45 | .join('\n') 46 | ) as SiteHistory 47 | currentStatus = siteHistory.status || 'unknown' 48 | startTime = new Date(siteHistory.startTime || new Date()) 49 | } catch (error) {} 50 | infoErrorLogger.info(`Current status ${site.slug} ${currentStatus} ${startTime}`) 51 | 52 | /** 53 | * Check whether the site is online 54 | */ 55 | const performTestOnce = async (): Promise<{ 56 | result: { 57 | httpCode: number; 58 | }; 59 | responseTime: string; 60 | status: 'up' | 'down' | 'degraded'; 61 | }> => { 62 | if (site.check === 'tcp-ping') { 63 | infoErrorLogger.info('Using tcp-ping instead of curl') 64 | try { 65 | let status: 'up' | 'down' | 'degraded' = 'up' 66 | const tcpResult = await ping({ 67 | // address: replaceEnvironmentVariables(site.url), 68 | // port: Number(replaceEnvironmentVariables(site.port ? String(site.port) : '')), 69 | address: site.url, 70 | attempts: 5, 71 | port: Number(site.port), 72 | }) 73 | if (tcpResult.avg > (site.maxResponseTime || 60000)) status = 'degraded' 74 | infoErrorLogger.info(`Got result ${tcpResult}`) 75 | return { 76 | result: {httpCode: 200}, 77 | responseTime: (tcpResult.avg || 0).toFixed(0), 78 | status, 79 | } 80 | } catch (error) { 81 | infoErrorLogger.info(`Got pinging error ${error}`) 82 | return {result: {httpCode: 0}, responseTime: (0).toFixed(0), status: 'down'} 83 | } 84 | } else { 85 | const result = await curl(site) 86 | infoErrorLogger.info(`Result from test ${result.httpCode} ${result.totalTime}`) 87 | const responseTime = (result.totalTime * 1000).toFixed(0) 88 | const expectedStatusCodes = ( 89 | site.expectedStatusCodes || [ 90 | 200, 91 | 201, 92 | 202, 93 | 203, 94 | 200, 95 | 204, 96 | 205, 97 | 206, 98 | 207, 99 | 208, 100 | 226, 101 | 300, 102 | 301, 103 | 302, 104 | 303, 105 | 304, 106 | 305, 107 | 306, 108 | 307, 109 | 308, 110 | ] 111 | ).map(Number) 112 | let status: 'up' | 'down' | 'degraded' = expectedStatusCodes.includes( 113 | Number(result.httpCode) 114 | ) ? 115 | 'up' : 116 | 'down' 117 | if (parseInt(responseTime, 10) > (site.maxResponseTime || 60000)) status = 'degraded' 118 | if (status === 'up' && typeof result.data === 'string') { 119 | if (site.__dangerous__body_down && result.data.includes(site.__dangerous__body_down)) 120 | status = 'down' 121 | if ( 122 | site.__dangerous__body_degraded && 123 | result.data.includes(site.__dangerous__body_degraded) 124 | ) 125 | status = 'degraded' 126 | } 127 | if ( 128 | site.__dangerous__body_degraded_if_text_missing && 129 | !result.data.includes(site.__dangerous__body_degraded_if_text_missing) 130 | ) 131 | status = 'degraded' 132 | if ( 133 | site.__dangerous__body_down_if_text_missing && 134 | !result.data.includes(site.__dangerous__body_down_if_text_missing) 135 | ) 136 | status = 'down' 137 | return {result, responseTime, status} 138 | } 139 | } 140 | 141 | let {result, responseTime, status} = await performTestOnce() 142 | /** 143 | * If the site is down, we perform the test 2 more times to make 144 | * sure that it's not a false alarm 145 | */ 146 | if (status === 'down' || status === 'degraded') { 147 | wait(1000) 148 | const secondTry = await performTestOnce() 149 | if (secondTry.status === 'up') { 150 | result = secondTry.result 151 | responseTime = secondTry.responseTime 152 | status = secondTry.status 153 | } else { 154 | wait(10000) 155 | const thirdTry = await performTestOnce() 156 | if (thirdTry.status === 'up') { 157 | result = thirdTry.result 158 | responseTime = thirdTry.responseTime 159 | status = thirdTry.status 160 | } 161 | } 162 | } 163 | 164 | try { 165 | if (shouldCommit || currentStatus !== status) { 166 | await writeFile( 167 | join('.', 'history', `${slug}.yml`), 168 | `url: ${site.urlSecretText || site.url} 169 | status: ${status} 170 | code: ${result.httpCode} 171 | responseTime: ${responseTime} 172 | lastUpdated: ${new Date().toISOString()} 173 | startTime: ${startTime} 174 | generator: Upptime 175 | ` 176 | ) 177 | const commitMsg = ( 178 | (config.commitMessages || {}).statusChange || 179 | '$PREFIX $SITE_NAME is $STATUS ($RESPONSE_CODE in $RESPONSE_TIME ms) [skip ci] [upptime]' 180 | ) 181 | .replace( 182 | '$PREFIX', 183 | status === 'up' ? 184 | config.commitPrefixStatusUp || '🟩' : 185 | status === 'degraded' ? 186 | config.commitPrefixStatusDegraded || '🟨' : 187 | config.commitPrefixStatusDown || '🟥' 188 | ) 189 | .replace('$SITE_NAME', site.name) 190 | .replace('$SITE_URL', site.urlSecretText || site.url) 191 | .replace('$SITE_METHOD', site.method || 'GET') 192 | .replace('$STATUS', status) 193 | .replace('$RESPONSE_CODE', result.httpCode.toString()) 194 | .replace('$RESPONSE_TIME', responseTime) 195 | 196 | if (status === 'up') 197 | statusLogger.up(commitMsg) 198 | else if (status === 'degraded') 199 | statusLogger.degraded(commitMsg) 200 | else 201 | statusLogger.down(commitMsg) 202 | 203 | commit( 204 | commitMsg, 205 | (config.commitMessages || {}).commitAuthorName, 206 | (config.commitMessages || {}).commitAuthorEmail 207 | ) 208 | if (currentStatus === status) { 209 | infoErrorLogger.info(`Status is the same ${currentStatus} ${status}`) 210 | } else { 211 | infoErrorLogger.info(`Status is different ${currentStatus} to ${status}`) 212 | hasDelta = true 213 | const lastCommitSha = lastCommit() 214 | const maintenanceIssueExists = ongoingMaintenanceEvents.find(i => i.incident.slug) 215 | // Don't create an issue if it's expected that the site is down or degraded 216 | let expected = false 217 | if ( 218 | (status === 'down' && maintenanceIssueExists) || 219 | (status === 'degraded' && maintenanceIssueExists) 220 | ) 221 | expected = true 222 | const incidents = await getIncidents() 223 | const issueAlreadyExistsIndex = (await getIndexes(slug)).find(id => incidents.incidents[id].status === 'open') 224 | 225 | // If the site was just recorded as down or degraded, open an issue 226 | if ((status === 'down' || status === 'degraded') && !expected) { 227 | if (issueAlreadyExistsIndex === undefined) { 228 | const cute_file_name: string = uniqueNamesGenerator({ 229 | dictionaries: [adjectives, colors, animals] 230 | }); 231 | createIncident(site, { 232 | assignees: [...(config.assignees || []), ...(site.assignees || [])], 233 | author: 'Upptime Bot', 234 | labels: ['status', slug], 235 | },cute_file_name, status === 'down' ? 236 | `🛑 ${site.name} is down` : 237 | `⚠️ ${site.name} has degraded performance` 238 | , `In \`${lastCommitSha.substr( 239 | 0, 240 | 7 241 | )}\`, ${site.name} (${ 242 | site.urlSecretText || site.url 243 | }) ${status === 'down' ? 'was **down**' : 'experienced **degraded performance**'}: 244 | - HTTP code: ${result.httpCode} 245 | - Response time: ${responseTime} ms 246 | `) 247 | infoErrorLogger.info('Opened and locked a new issue') 248 | try { 249 | await sendNotification( 250 | status === 'down' ? 251 | `🟥 ${site.name} (${site.urlSecretText || site.url}) is **down**` : 252 | `🟨 ${site.name} (${site.urlSecretText || site.url}) is experiencing **degraded performance**` 253 | ) 254 | } catch (error) { 255 | infoErrorLogger.error(error) 256 | } 257 | } else { 258 | infoErrorLogger.info('An issue is already open for this') 259 | } 260 | } else if (issueAlreadyExistsIndex) { 261 | // If the site just came back up 262 | const incident = incidents.incidents[issueAlreadyExistsIndex] 263 | const title = incident.title 264 | await createComment( 265 | { 266 | author: 'Upptime Bot', 267 | id: issueAlreadyExistsIndex, 268 | slug, 269 | title, 270 | }, 271 | `**Resolved:** ${site.name} ${ 272 | title.includes('degraded') ? 273 | 'performance has improved' : 274 | 'is back up' 275 | } in \`${lastCommitSha.substr( 276 | 0, 277 | 7 278 | )}\`.` 279 | ) 280 | infoErrorLogger.info('Created comment in issue') 281 | await closeIncident(issueAlreadyExistsIndex) 282 | infoErrorLogger.info('Closed issue') 283 | try { 284 | await sendNotification( 285 | `🟩 ${site.name} (${site.urlSecretText || site.url}) ${ 286 | title.includes('degraded') ? 287 | 'performance has improved' : 288 | 'is back up' 289 | }.` 290 | ) 291 | } catch (error) { 292 | infoErrorLogger.error(error) 293 | } 294 | } else { 295 | infoErrorLogger.info('Could not find a relevant issue') 296 | } 297 | } 298 | } else { 299 | infoErrorLogger.info(`Skipping commit, status is ${status}`) 300 | } 301 | } catch (error) { 302 | infoErrorLogger.error(`${error}`) 303 | } 304 | } 305 | if (config.commits?.provider && config.commits?.provider === 'GitHub') 306 | push() 307 | cli.action.stop(chalk.green('done')) 308 | if (hasDelta) generateSummary() 309 | } 310 | -------------------------------------------------------------------------------- /src/helpers/notifme-sdk.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @source https://gist.github.com/epiphone/34c04970680b6f4472cd7bb6153443c1 3 | */ 4 | declare module "notifme-sdk" { 5 | export type ChannelType = "email" | "sms" | "push" | "webpush" | "slack"; 6 | export type ProviderStrategyType = "fallback" | "roundrobin" | "no-fallback"; 7 | 8 | export default class NotifMeSdk { 9 | constructor(options: Options); 10 | 11 | send(notification: Notification): Promise; 12 | } 13 | 14 | export type Options = { 15 | channels: { 16 | email?: Channel; 17 | sms?: Channel; 18 | push?: any; // TODO 19 | webpush?: any; // TODO 20 | slack?: Channel; 21 | }; 22 | useNotificationCatcher?: boolean; 23 | }; 24 | 25 | export type Notification = { 26 | metadata?: { 27 | id?: string; 28 | userId?: string; 29 | }; 30 | email?: EmailRequest; 31 | push?: PushRequest; 32 | sms?: SmsRequest; 33 | webpush?: WebpushRequest; 34 | slack?: SlackRequest; 35 | }; 36 | 37 | export type NotificationStatus = { 38 | status: "success" | "error"; 39 | channels?: { 40 | [channel in ChannelType]: { 41 | id: string; 42 | providerId?: string; 43 | }; 44 | }; 45 | errors?: { [channel in ChannelType]: Error }; 46 | info?: Object; 47 | }; 48 | 49 | export type Channel = { 50 | multiProviderStrategy?: MultiProviderStrategy | ProviderStrategyType; 51 | providers: T[]; 52 | }; 53 | 54 | export type MultiProviderStrategy = (providers: Provider[]) => any; 55 | 56 | //region PROVIDERS 57 | 58 | export type EmailProvider = 59 | | { 60 | type: "logger"; 61 | } 62 | | { 63 | type: "custom"; 64 | id: string; 65 | send: (request: EmailRequest) => Promise; 66 | } 67 | | { 68 | // Doc: https://nodemailer.com/transports/sendmail/ 69 | type: "sendmail"; 70 | sendmail: true; 71 | path: string; // Defaults to 'sendmail' 72 | newline: "windows" | "unix"; // Defaults to 'unix' 73 | args?: string[]; 74 | attachDataUrls?: boolean; 75 | disableFileAccess?: boolean; 76 | disableUrlAccess?: boolean; 77 | } 78 | | { 79 | // General options (Doc: https://nodemailer.com/smtp/) 80 | type: "smtp"; 81 | port?: 25 | 465 | 587; // Defaults to 587 82 | host?: string; // Defaults to 'localhost' 83 | auth: 84 | | { 85 | type?: "login"; 86 | user: string; 87 | pass: string; 88 | } 89 | | { 90 | type: "oauth2"; // Doc: https://nodemailer.com/smtp/oauth2/#oauth-3lo 91 | user: string; 92 | clientId?: string; 93 | clientSecret?: string; 94 | refreshToken?: string; 95 | accessToken?: string; 96 | expires?: string; 97 | accessUrl?: string; 98 | } 99 | | { 100 | type: "oauth2"; // Doc: https://nodemailer.com/smtp/oauth2/#oauth-2lo 101 | user: string; 102 | serviceClient: string; 103 | privateKey?: string; 104 | }; 105 | authMethod?: string; // Defaults to 'PLAIN' 106 | // TLS options (Doc: https://nodemailer.com/smtp/#tls-options) 107 | secure?: boolean; 108 | tls?: Object; // Doc: https://nodejs.org/api/tls.html#tls_class_tls_tlssocket 109 | ignoreTLS?: boolean; 110 | requireTLS?: boolean; 111 | // Connection options (Doc: https://nodemailer.com/smtp/#connection-options) 112 | name?: string; 113 | localAddress?: string; 114 | connectionTimeout?: number; 115 | greetingTimeout?: number; 116 | socketTimeout?: number; 117 | // Debug options (Doc: https://nodemailer.com/smtp/#debug-options) 118 | logger?: boolean; 119 | debug?: boolean; 120 | // Security options (Doc: https://nodemailer.com/smtp/#security-options) 121 | disableFileAccess?: boolean; 122 | disableUrlAccess?: boolean; 123 | // Pooling options (Doc: https://nodemailer.com/smtp/pooled/) 124 | pool?: boolean; 125 | maxConnections?: number; 126 | maxMessages?: number; 127 | rateDelta?: number; 128 | rateLimit?: number; 129 | // Proxy options (Doc: https://nodemailer.com/smtp/proxies/) 130 | proxy?: string; 131 | } 132 | | { 133 | type: "mailgun"; 134 | apiKey: string; 135 | domainName: string; 136 | } 137 | | { 138 | type: "sendgrid"; 139 | apiKey: string; 140 | } 141 | | { 142 | type: "ses"; 143 | region: string; 144 | accessKeyId: string; 145 | secretAccessKey: string; 146 | sessionToken?: string; 147 | } 148 | | { 149 | type: "sparkpost"; 150 | apiKey: string; 151 | }; 152 | 153 | export type SmsProvider = 154 | | { 155 | type: "logger"; 156 | } 157 | | { 158 | type: "custom"; 159 | id: string; 160 | send: (request: SmsRequest) => Promise; 161 | } 162 | | { 163 | type: "46elks"; 164 | apiUsername: string; 165 | apiPassword: string; 166 | } 167 | | { 168 | type: "callr"; 169 | login: string; 170 | password: string; 171 | } 172 | | { 173 | type: "clickatell"; 174 | apiKey: string; // One-way integration API key 175 | } 176 | | { 177 | type: "infobip"; 178 | username: string; 179 | password: string; 180 | } 181 | | { 182 | type: "nexmo"; 183 | apiKey: string; 184 | apiSecret: string; 185 | } 186 | | { 187 | type: "ovh"; 188 | appKey: string; 189 | appSecret: string; 190 | consumerKey: string; 191 | account: string; 192 | host: string; // https://github.com/ovh/node-ovh/blob/master/lib/endpoints.js 193 | } 194 | | { 195 | type: "plivo"; 196 | authId: string; 197 | authToken: string; 198 | } 199 | | { 200 | type: "twilio"; 201 | accountSid: string; 202 | authToken: string; 203 | }; 204 | 205 | export type PushProvider = 206 | | { 207 | type: "logger"; 208 | } 209 | | { 210 | type: "custom"; 211 | id: string; 212 | send: (request: PushRequest) => Promise; 213 | } 214 | | { 215 | // Doc: https://github.com/node-apn/node-apn/blob/master/doc/provider.markdown 216 | type: "apn"; // Apple Push Notification 217 | token?: { 218 | key: string; 219 | keyId: string; 220 | teamId: string; 221 | }; 222 | cert?: string; 223 | key?: string; 224 | ca?: string[]; 225 | pfx?: string; 226 | passphrase?: string; 227 | production?: boolean; 228 | rejectUnauthorized?: boolean; 229 | connectionRetryLimit?: number; 230 | } 231 | | { 232 | // Doc: https://github.com/ToothlessGear/node-gcm 233 | type: "fcm"; // Firebase Cloud Messaging (previously called GCM, Google Cloud Messaging) 234 | id: string; 235 | phonegap?: boolean; 236 | } 237 | | { 238 | // Doc: https://github.com/tjanczuk/wns 239 | type: "wns"; // Windows Push Notification 240 | clientId: string; 241 | clientSecret: string; 242 | notificationMethod: string; // sendTileSquareBlock, sendTileSquareImage... 243 | } 244 | | { 245 | // Doc: https://github.com/umano/node-adm 246 | type: "adm"; // Amazon Device Messaging 247 | clientId: string; 248 | clientSecret: string; 249 | }; 250 | 251 | // TODO?: onesignal, urbanairship, goroost, sendpulse, wonderpush, appboy... 252 | export type WebpushProvider = 253 | | { 254 | type: "logger"; 255 | } 256 | | { 257 | type: "custom"; 258 | id: string; 259 | send: (request: WebpushRequest) => Promise; 260 | } 261 | | { 262 | type: "gcm"; 263 | gcmAPIKey?: string; 264 | vapidDetails?: { 265 | subject: string; 266 | publicKey: string; 267 | privateKey: string; 268 | }; 269 | ttl?: number; 270 | headers?: { [key: string]: string | number | boolean }; 271 | }; 272 | 273 | export type SlackProvider = 274 | | { 275 | type: "logger"; 276 | } 277 | | { 278 | type: "custom"; 279 | id: string; 280 | send: (request: SlackRequest) => Promise; 281 | } 282 | | { 283 | type: "webhook"; 284 | webhookUrl: string; 285 | }; 286 | 287 | export type Provider = 288 | | EmailProvider 289 | | SmsProvider 290 | | PushProvider 291 | | WebpushProvider 292 | | SlackProvider; 293 | 294 | //endregion PROVIDERS 295 | 296 | //region REQUEST TYPES: 297 | 298 | type RequestMetadata = { 299 | id?: string; 300 | userId?: string; 301 | }; 302 | 303 | export type EmailRequest = RequestMetadata & { 304 | from: string; 305 | to: string; 306 | subject: string; 307 | cc?: string[]; 308 | bcc?: string[]; 309 | replyTo?: string; 310 | text?: string; 311 | html?: string; 312 | attachments?: { 313 | contentType: string; // text/plain... 314 | filename: string; 315 | content: string | Buffer; 316 | // path?: string, 317 | // href?: string, 318 | // contentDisposition?: string, 319 | // contentTransferEncoding?: string, 320 | // cid?: string, 321 | // raw?: string, 322 | // encoding?: string, 323 | // headers?: {[string]: string | number | boolean} 324 | }[]; 325 | headers?: { [key: string]: string | number | boolean }; 326 | }; 327 | 328 | export type PushRequest = RequestMetadata & { 329 | registrationToken: string; 330 | title: string; 331 | body: string; 332 | custom?: Object; 333 | priority?: "high" | "normal"; // gcm, apn. Will be translated to 10 and 5 for apn. Defaults to 'high' 334 | collapseKey?: string; // gcm for android, used as collapseId in apn 335 | contentAvailable?: boolean; // gcm for android 336 | delayWhileIdle?: boolean; // gcm for android 337 | restrictedPackageName?: string; // gcm for android 338 | dryRun?: boolean; // gcm for android 339 | icon?: string; // gcm for android 340 | tag?: string; // gcm for android 341 | color?: string; // gcm for android 342 | clickAction?: string; // gcm for android. In ios, category will be used if not supplied 343 | locKey?: string; // gcm, apn 344 | bodyLocArgs?: string; // gcm, apn 345 | titleLocKey?: string; // gcm, apn 346 | titleLocArgs?: string; // gcm, apn 347 | retries?: number; // gcm, apn 348 | encoding?: string; // apn 349 | badge?: number; // gcm for ios, apn 350 | sound?: string; // gcm, apn 351 | alert?: string | Object; // apn, will take precedence over title and body 352 | launchImage?: string; // apn and gcm for ios 353 | action?: string; // apn and gcm for ios 354 | topic?: string; // apn and gcm for ios 355 | category?: string; // apn and gcm for ios 356 | mdm?: string; // apn and gcm for ios 357 | urlArgs?: string; // apn and gcm for ios 358 | truncateAtWordEnd?: boolean; // apn and gcm for ios 359 | mutableContent?: number; // apn 360 | expiry?: number; // seconds 361 | timeToLive?: number; // if both expiry and timeToLive are given, expiry will take precedency 362 | headers?: { [key: string]: string | number | boolean }; // wns 363 | launch?: string; // wns 364 | duration?: string; // wns 365 | consolidationKey?: string; // ADM 366 | }; 367 | 368 | export type SmsRequest = RequestMetadata & { 369 | from: string; 370 | to: string; 371 | text: string; 372 | type?: "text" | "unicode"; // Defaults to 'text' 373 | nature?: "marketing" | "transactional"; 374 | ttl?: number; 375 | messageClass?: 0 | 1 | 2 | 3; // 0 for Flash SMS, 1 - ME-specific, 2 - SIM / USIM specific, 3 - TE-specific 376 | // } & ( 377 | // {type?: 'text', text: string} 378 | // | {type: 'unicode', text: string} 379 | // | {type: 'binary', body: string, udh: string, protocolId: string} 380 | // | {type: 'wappush', title: string, url: string, validity?: number} 381 | // | {type: 'vcal', vcal: string} 382 | // | {type: 'vcard', vcard: string} 383 | // ) 384 | }; 385 | 386 | export type WebpushRequest = RequestMetadata & { 387 | subscription: { 388 | endpoint: string; 389 | keys: { 390 | auth: string; 391 | p256dh: string; 392 | }; 393 | }; 394 | title: string; // C22 F22 S6 395 | body: string; // C22 F22 S6 396 | actions?: { 397 | action: string; 398 | title: string; 399 | icon?: string; 400 | }[]; // C53 401 | badge?: string; // C53 402 | dir?: "auto" | "rtl" | "ltr"; // C22 F22 S6 403 | icon?: string; // C22 F22 404 | image?: string; // C55 F22 405 | redirects?: { [key: string]: string }; // added for local tests 406 | requireInteraction?: boolean; // C22 F52 407 | }; 408 | 409 | export type SlackRequest = RequestMetadata & { 410 | text: string; 411 | }; 412 | 413 | export type Request = EmailRequest | PushRequest | SmsRequest | WebpushRequest | SlackRequest; 414 | } 415 | --------------------------------------------------------------------------------