├── .all-contributorsrc ├── .github └── issue_template.md ├── .gitignore ├── .nvmrc ├── .prettierrc ├── .travis.yml ├── CODE-OF-CONDUCT.md ├── LICENSE ├── README.md ├── build └── icon.png ├── index.js ├── jest.config.js ├── media ├── buildingTemplate.png ├── buildingTemplate@2x.png ├── buildingTemplate@4x.png ├── errorTemplate.png ├── errorTemplate@2x.png ├── errorTemplate@4x.png ├── loadingTemplate.png ├── loadingTemplate@2x.png ├── loadingTemplate@4x.png ├── offlineTemplate.png ├── offlineTemplate@2x.png ├── offlineTemplate@4x.png ├── readyTemplate.png ├── readyTemplate@2x.png └── readyTemplate@4x.png ├── package-lock.json ├── package.json ├── screenshot.jpg ├── src ├── __tests__ │ ├── __snapshots__ │ │ └── menus.test.ts.snap │ ├── incidentFeed.test.ts │ ├── menus.test.ts │ ├── netlify.test.ts │ ├── notify.test.ts │ └── scheduler.test.ts ├── config.ts ├── connection.ts ├── icons.ts ├── incidentFeed.ts ├── index.ts ├── menubar.ts ├── menus.ts ├── netlify.ts ├── notify.ts ├── scheduler.ts ├── util.test.ts └── util.ts ├── tsconfig.json └── tslint.json /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "netlify-menubar", 3 | "projectOwner": "stefanjudis", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "contributors": [ 12 | { 13 | "login": "johnnyxbell", 14 | "name": "Johnny Bell", 15 | "avatar_url": "https://avatars2.githubusercontent.com/u/4260265?v=4", 16 | "profile": "http://johnnybell.io", 17 | "contributions": [ 18 | "code" 19 | ] 20 | }, 21 | { 22 | "login": "viviangb", 23 | "name": "Vivian Guillen", 24 | "avatar_url": "https://avatars3.githubusercontent.com/u/7389358?v=4", 25 | "profile": "http://codequeen.io", 26 | "contributions": [ 27 | "code", 28 | "design" 29 | ] 30 | }, 31 | { 32 | "login": "ukmadlz", 33 | "name": "Mike Elsmore", 34 | "avatar_url": "https://avatars2.githubusercontent.com/u/804683?v=4", 35 | "profile": "http://elsmore.me", 36 | "contributions": [ 37 | "code" 38 | ] 39 | }, 40 | { 41 | "login": "adamwatters", 42 | "name": "Adam Watters", 43 | "avatar_url": "https://avatars2.githubusercontent.com/u/7673611?v=4", 44 | "profile": "http://adamwatters.nyc", 45 | "contributions": [ 46 | "code" 47 | ] 48 | } 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | # Found a problem or want to give feedback 2 | 3 | I'd love to hear from you if Netlify Menubar is useful for you and if you run into any troubles. 😊 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | # typescript output 64 | dist 65 | 66 | # packed app 67 | release 68 | 69 | # local certs 70 | .certificates.p12 71 | 72 | # codecov 73 | coverage 74 | 75 | # random stuff 76 | .DS_STORE 77 | .vscode -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v12.4.0 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '12' 4 | os: osx 5 | script: npm run test:ci && npm run release 6 | -------------------------------------------------------------------------------- /CODE-OF-CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at stefanjudis@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [https://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: https://contributor-covenant.org 74 | [version]: https://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Stefan Judis 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Netlify Menubar 2 | 3 | [![Build Status](https://travis-ci.org/stefanjudis/netlify-menubar.svg?branch=master)](https://travis-ci.org/stefanjudis/netlify-menubar) [![Recent release](https://img.shields.io/github/release/stefanjudis/netlify-menubar.svg)](https://github.com/stefanjudis/netlify-menubar/releases) [![All Contributors](https://img.shields.io/badge/all_contributors-4-orange.svg?style=flat-square)](#contributors) [![codecov](https://codecov.io/gh/stefanjudis/netlify-menubar/branch/master/graph/badge.svg)](https://codecov.io/gh/stefanjudis/netlify-menubar) 4 | 5 | > See, monitor and controls builds from within your top menubar 6 | 7 | ![Netlify Menubar UI opened showing the recent builds](./screenshot.jpg) 8 | 9 | **🙈 It's my first project in TypeScript – don't judge. 🙈** 10 | 11 | ## Features 12 | 13 | - monitor deploys of a certain site 14 | - get notified when a new deploy is ready 15 | - trigger deploys 16 | - automatic app updates (since Netlify Menubar `1.2.1`) 17 | 18 | ## Installation 19 | 20 | Head over to the [releases section](https://github.com/stefanjudis/netlify-menubar/releases) and download the latest `dmg`. The app supports auto-updates so you only have to install it once and then it's rolling. 21 | 22 | _Install via `brew cask` is in progress._ :) 23 | 24 | ## Contribute 25 | 26 | You can run Netlify menubar right from your machine. There should be no additional setup required. 27 | 28 | ``` 29 | $ git clone git@github.com:stefanjudis/netlify-menubar.git 30 | $ npm run dev 31 | ``` 32 | 33 | ## Contributors 34 | 35 | Thanks goes to these wonderful people ([emoji key](https://github.com/all-contributors/all-contributors#emoji-key)): 36 | 37 | 38 | 39 | | [Johnny Bell
Johnny Bell](http://johnnybell.io)
[💻](https://github.com/stefanjudis/netlify-menubar/commits?author=johnnyxbell "Code") | [Vivian Guillen
Vivian Guillen](http://codequeen.io)
[💻](https://github.com/stefanjudis/netlify-menubar/commits?author=viviangb "Code") [🎨](#design-viviangb "Design") | [Mike Elsmore
Mike Elsmore](http://elsmore.me)
[💻](https://github.com/stefanjudis/netlify-menubar/commits?author=ukmadlz "Code") | [Adam Watters
Adam Watters](http://adamwatters.nyc)
[💻](https://github.com/stefanjudis/netlify-menubar/commits?author=adamwatters "Code") | 40 | | :---: | :---: | :---: | :---: | 41 | 42 | 43 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 44 | -------------------------------------------------------------------------------- /build/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/build/icon.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const app = require('./dist/index'); 2 | 3 | // entry function for electron-builder to go around 4 | // file-path and configuration hassle 5 | app.start(); 6 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | roots: ['/src'], 5 | collectCoverage: true, 6 | collectCoverageFrom: ['src/*.ts', '!**/node_modules/**'], 7 | coverageDirectory: './coverage/' 8 | }; 9 | -------------------------------------------------------------------------------- /media/buildingTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/buildingTemplate.png -------------------------------------------------------------------------------- /media/buildingTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/buildingTemplate@2x.png -------------------------------------------------------------------------------- /media/buildingTemplate@4x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/buildingTemplate@4x.png -------------------------------------------------------------------------------- /media/errorTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/errorTemplate.png -------------------------------------------------------------------------------- /media/errorTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/errorTemplate@2x.png -------------------------------------------------------------------------------- /media/errorTemplate@4x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/errorTemplate@4x.png -------------------------------------------------------------------------------- /media/loadingTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/loadingTemplate.png -------------------------------------------------------------------------------- /media/loadingTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/loadingTemplate@2x.png -------------------------------------------------------------------------------- /media/loadingTemplate@4x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/loadingTemplate@4x.png -------------------------------------------------------------------------------- /media/offlineTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/offlineTemplate.png -------------------------------------------------------------------------------- /media/offlineTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/offlineTemplate@2x.png -------------------------------------------------------------------------------- /media/offlineTemplate@4x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/offlineTemplate@4x.png -------------------------------------------------------------------------------- /media/readyTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/readyTemplate.png -------------------------------------------------------------------------------- /media/readyTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/readyTemplate@2x.png -------------------------------------------------------------------------------- /media/readyTemplate@4x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/media/readyTemplate@4x.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "netlify-menubar", 3 | "version": "1.9.3", 4 | "description": "A menubar app to keep track of Netlify builds", 5 | "author": "stefan judis ", 6 | "main": "index.js", 7 | "scripts": { 8 | "dev": "tsc && electron index.js", 9 | "postinstall": "electron-builder install-app-deps", 10 | "test": "tslint src/* && jest", 11 | "test:ci": "npm run test && codecov", 12 | "pack": "tsc && electron-builder --dir -m", 13 | "dist": "tsc && electron-builder -m", 14 | "release": "tsc && electron-builder", 15 | "contributors:add": "all-contributors add", 16 | "contributors:generate": "all-contributors generate" 17 | }, 18 | "husky": { 19 | "hooks": { 20 | "pre-commit": "npm test" 21 | } 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/stefanjudis/netlify-menubar-status.git" 26 | }, 27 | "build": { 28 | "appId": "netlify.menubar.electron.app", 29 | "productName": "Netlify Menubar", 30 | "copyright": "Copyright © 2019 Stefan Judis", 31 | "directories": { 32 | "output": "release" 33 | } 34 | }, 35 | "keywords": [], 36 | "license": "MIT", 37 | "bugs": { 38 | "url": "https://github.com/stefanjudis/netlify-menubar-status/issues" 39 | }, 40 | "homepage": "https://github.com/stefanjudis/netlify-menubar-status#readme", 41 | "dependencies": { 42 | "auto-launch": "^5.0.5", 43 | "date-fns": "^1.30.1", 44 | "electron-is-dev": "^1.1.0", 45 | "electron-settings": "^3.2.0", 46 | "electron-updater": "^4.2.5", 47 | "node-fetch": "^2.6.0", 48 | "rss-parser": "^3.7.1" 49 | }, 50 | "devDependencies": { 51 | "@types/auto-launch": "^5.0.1", 52 | "@types/electron-settings": "^3.1.1", 53 | "@types/jest": "^24.0.14", 54 | "@types/node": "^12.12.35", 55 | "@types/node-fetch": "^2.3.6", 56 | "all-contributors-cli": "^6.7.0", 57 | "codecov": "^3.5.0", 58 | "electron": "^8.2.1", 59 | "electron-builder": "^22.4.1", 60 | "husky": "^2.4.1", 61 | "jest": "^24.8.0", 62 | "ts-jest": "^24.0.2", 63 | "tslint": "^5.17.0", 64 | "tslint-config-prettier": "^1.18.0", 65 | "typescript": "^3.5.1" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stefanjudis/netlify-menubar/e5540cb54085381d0870964097fc675260926209/screenshot.jpg -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/menus.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`menu helper functions getCheckboxMenu should render a correct checkbox settings menu 1`] = ` 4 | Array [ 5 | Object { 6 | "checked": true, 7 | "click": [Function], 8 | "label": "Launch at start label", 9 | "type": "checkbox", 10 | }, 11 | Object { 12 | "checked": false, 13 | "click": [Function], 14 | "label": "Show notifications label", 15 | "type": "checkbox", 16 | }, 17 | ] 18 | `; 19 | 20 | exports[`menu helper functions getDeploysMenu should render a correct deploy menu 1`] = ` 21 | Array [ 22 | Object { 23 | "click": [Function], 24 | "label": "Overview", 25 | }, 26 | Object { 27 | "type": "separator", 28 | }, 29 | Object { 30 | "click": [Function], 31 | "label": "production: building → master (less than a minute ago in 123s)", 32 | }, 33 | Object { 34 | "enabled": false, 35 | "label": "—", 36 | }, 37 | Object { 38 | "click": [Function], 39 | "label": "production: ready → master (1 day ago in 126s)", 40 | }, 41 | ] 42 | `; 43 | 44 | exports[`menu helper functions getSitesMenu should render a correct sites menu 1`] = ` 45 | Array [ 46 | Object { 47 | "checked": false, 48 | "click": [Function], 49 | "label": "foo.com", 50 | "type": "radio", 51 | }, 52 | Object { 53 | "checked": true, 54 | "click": [Function], 55 | "label": "current.com", 56 | "type": "radio", 57 | }, 58 | Object { 59 | "checked": false, 60 | "click": [Function], 61 | "label": "bar.com", 62 | "type": "radio", 63 | }, 64 | ] 65 | `; 66 | 67 | exports[`menu helper functions getSitesMenu should render a correct sites menu 2`] = ` 68 | Array [ 69 | Object { 70 | "click": [Function], 71 | "label": "History", 72 | }, 73 | Object { 74 | "type": "separator", 75 | }, 76 | Object { 77 | "enabled": false, 78 | "label": "no recent incidents", 79 | }, 80 | ] 81 | `; 82 | -------------------------------------------------------------------------------- /src/__tests__/incidentFeed.test.ts: -------------------------------------------------------------------------------- 1 | const firstResponse = { 2 | items: [ 3 | { link: 'id=1', content: 'not updated' }, 4 | { link: 'id=2', content: 'not updated' } 5 | ] 6 | }; 7 | const secondResponse = { 8 | items: [ 9 | { link: 'id=1', content: 'not updated' }, 10 | { link: 'id=2', content: 'not updated' }, 11 | { link: 'id=3', content: 'not updated' } 12 | ] 13 | }; 14 | const thirdResponse = { 15 | items: [ 16 | { link: 'id=1', content: 'updated' }, 17 | { link: 'id=2', content: 'not updated' }, 18 | { link: 'id=3', content: 'not updated' } 19 | ] 20 | }; 21 | const fourthResponse = { 22 | items: [ 23 | { link: 'id=1', content: 'updated' }, 24 | { link: 'id=2', content: 'not updated' }, 25 | { link: 'id=3', content: 'not updated' } 26 | ] 27 | }; 28 | 29 | // TODO place this in global config somehwere 30 | // tslint:disable-next-line 31 | console.log = () => {}; 32 | 33 | jest.doMock( 34 | 'rss-parser', 35 | () => 36 | // has to use function keyword to be called with new keyword (ie act as a constructor) 37 | /* tslint:disable-line only-arrow-functions */ function() { 38 | return { 39 | parseURL: jest 40 | .fn() 41 | .mockReturnValueOnce(firstResponse) 42 | .mockReturnValueOnce(secondResponse) 43 | .mockReturnValueOnce(thirdResponse) 44 | .mockReturnValueOnce(fourthResponse) 45 | }; 46 | } 47 | ); 48 | 49 | import IncidentFeed from '../incidentFeed'; 50 | const incidentFeed = new IncidentFeed(); 51 | 52 | describe('IncidentFeed', () => { 53 | test('before :update is called, :getFeed returns an empty array', () => { 54 | expect(incidentFeed.getFeed()).toMatchObject([]); 55 | expect(incidentFeed.getFeed()).not.toMatchObject(['some value']); 56 | }); 57 | test('first update', async () => { 58 | await incidentFeed.update(); 59 | expect(incidentFeed.getFeed()).toBe(firstResponse.items); 60 | expect(incidentFeed.newIncidents()).toMatchObject([]); 61 | expect(incidentFeed.updatedIncidents()).toMatchObject([]); 62 | }); 63 | test('second update', async () => { 64 | await incidentFeed.update(); 65 | expect(incidentFeed.getFeed()).toBe(secondResponse.items); 66 | expect(incidentFeed.newIncidents()).toMatchObject([ 67 | { link: 'id=3', content: 'not updated' } 68 | ]); 69 | expect(incidentFeed.updatedIncidents()).toMatchObject([]); 70 | }); 71 | test('third update', async () => { 72 | await incidentFeed.update(); 73 | expect(incidentFeed.getFeed()).toBe(thirdResponse.items); 74 | expect(incidentFeed.newIncidents()).toMatchObject([]); 75 | expect(incidentFeed.updatedIncidents()).toMatchObject([ 76 | { link: 'id=1', content: 'updated' } 77 | ]); 78 | }); 79 | test('fourth update', async () => { 80 | await incidentFeed.update(); 81 | expect(incidentFeed.getFeed()).toBe(fourthResponse.items); 82 | expect(incidentFeed.newIncidents()).toMatchObject([]); 83 | expect(incidentFeed.updatedIncidents()).toMatchObject([]); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/__tests__/menus.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getCheckboxMenu, 3 | getDeploysMenu, 4 | getIncidentsMenu, 5 | getSitesMenu 6 | } from '../menus'; 7 | import IncidentFeed from '../incidentFeed'; 8 | 9 | describe('menu helper functions', () => { 10 | describe('getCheckboxMenu', () => { 11 | test('should render a correct checkbox settings menu', () => { 12 | const result = getCheckboxMenu({ 13 | items: [ 14 | { label: 'Launch at start label', key: 'launchAtStart' }, 15 | { label: 'Show notifications label', key: 'showNotifications' } 16 | ], 17 | settings: { 18 | updateAutomatically: true, 19 | currentSiteId: 'jooooooo', 20 | launchAtStart: true, 21 | pollInterval: 10000, 22 | showNotifications: false 23 | }, 24 | 25 | // tslint:disable-next-line 26 | onItemClick: id => {} 27 | }); 28 | 29 | expect(result).toMatchSnapshot(); 30 | }); 31 | }); 32 | 33 | describe('getDeploysMenu', () => { 34 | test('should render a correct deploy menu', () => { 35 | const getToday = () => new Date(); 36 | const getYday = () => { 37 | const d = new Date(); 38 | d.setDate(d.getDate() - 1); 39 | return d; 40 | }; 41 | 42 | const result = getDeploysMenu({ 43 | currentSite: { 44 | admin_url: 'https://foo-admin.com', 45 | id: 'foo', 46 | name: 'Foo', 47 | url: 'https://foo.com' 48 | }, 49 | deploys: { 50 | pending: [ 51 | { 52 | branch: 'master', 53 | context: 'production', 54 | created_at: getToday().toISOString(), 55 | deploy_time: '123', 56 | error_message: '', 57 | id: '2', 58 | state: 'building' 59 | } 60 | ], 61 | ready: [ 62 | { 63 | branch: 'master', 64 | context: 'production', 65 | created_at: getYday().toISOString(), 66 | deploy_time: '126', 67 | error_message: '', 68 | id: '1', 69 | state: 'ready' 70 | } 71 | ] 72 | }, 73 | // tslint:disable-next-line 74 | onItemClick: id => {} 75 | }); 76 | 77 | expect(result).toMatchSnapshot(); 78 | }); 79 | }); 80 | 81 | describe('getSitesMenu', () => { 82 | test('should render a correct sites menu', () => { 83 | const result = getSitesMenu({ 84 | currentSite: { 85 | admin_url: 'https://current-admin.com', 86 | id: 'current-id', 87 | name: 'current-name', 88 | url: 'https://current.com' 89 | }, 90 | sites: [ 91 | { 92 | admin_url: 'https://foo-admin.com', 93 | id: 'foo-id', 94 | name: 'foo-name', 95 | url: 'https://foo.com' 96 | }, 97 | { 98 | admin_url: 'https://current-admin.com', 99 | id: 'current-id', 100 | name: 'current-name', 101 | url: 'https://current.com' 102 | }, 103 | { 104 | admin_url: 'https://bar-admin.com', 105 | id: 'bar-id', 106 | name: 'bar-name', 107 | url: 'https://bar.com' 108 | } 109 | ], 110 | // tslint:disable-next-line 111 | onItemClick: id => {} 112 | }); 113 | 114 | expect(result).toMatchSnapshot(); 115 | }); 116 | }); 117 | 118 | describe('getSitesMenu', () => { 119 | test('should render a correct sites menu', () => { 120 | const result = getIncidentsMenu(({ 121 | getFeed: () => [ 122 | { 123 | link: 'https://example.com', 124 | title: 'incident 1', 125 | pubDate: '2019-11-02', 126 | content: '' 127 | } 128 | ] 129 | // // tslint:disable-next-line 130 | // onItemClick: id => {} 131 | } as unknown) as IncidentFeed); 132 | 133 | expect(result).toMatchSnapshot(); 134 | }); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /src/__tests__/netlify.test.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'node-fetch'; 2 | import Netlify, { API_URL } from '../netlify'; 3 | 4 | // TODO place this in global config somehwere 5 | // tslint:disable-next-line 6 | console.log = () => {}; 7 | 8 | jest.mock('node-fetch'); 9 | jest.mock('electron', () => ({ 10 | dialog: { 11 | showMessageBox: jest.fn(options => Promise.resolve({ response: 0 })) 12 | }, 13 | shell: { 14 | openExternal: jest.fn() 15 | } 16 | })); 17 | 18 | interface IFetchResponse { 19 | response?: number; 20 | json: () => {}; 21 | } 22 | 23 | const getFetchPromise = async ( 24 | json: {} = {}, 25 | response: {} = {} 26 | ): Promise => { 27 | const result = await { 28 | ...(response && response), 29 | json: () => new Promise(res => res(json)) 30 | }; 31 | return result; 32 | }; 33 | 34 | describe('netlify api client', () => { 35 | const apiToken = 'awesomeToken'; 36 | let apiClient: Netlify; 37 | const mFetch = (fetch as unknown) as jest.Mock>; 38 | 39 | beforeEach(() => { 40 | apiClient = new Netlify(apiToken); 41 | mFetch.mockReset(); 42 | }); 43 | 44 | describe(':fetch', () => { 45 | test('stores accessToken in api client', async () => { 46 | expect(apiClient.accessToken).toBe(apiToken); 47 | }); 48 | 49 | test('sets authorization header properly', async () => { 50 | mFetch.mockResolvedValue(getFetchPromise()); 51 | await apiClient.fetch('/foo'); 52 | expect(mFetch.mock.calls[0][0].endsWith('/foo')).toBeTruthy(); 53 | expect(mFetch.mock.calls[0][1].headers.authorization).toBe( 54 | `Bearer ${apiToken}` 55 | ); 56 | }); 57 | 58 | test("rejects 'NOT_AUTHORIZED' error in case of 401", async () => { 59 | mFetch.mockResolvedValue( 60 | getFetchPromise( 61 | {}, 62 | { 63 | status: 401 64 | } 65 | ) 66 | ); 67 | await expect(apiClient.fetch('/foo')).rejects.toThrow('NOT_AUTHORIZED'); 68 | }); 69 | 70 | test('returns the correct response', async () => { 71 | const response = { foo: 'bar' }; 72 | mFetch.mockResolvedValue(getFetchPromise(response)); 73 | 74 | const result = await apiClient.fetch('/foo'); 75 | expect(result).toBe(response); 76 | }); 77 | }); 78 | 79 | describe(':getCurrentUser', () => { 80 | test('calls the correct URL', async () => { 81 | mFetch.mockResolvedValue(getFetchPromise({})); 82 | await apiClient.getCurrentUser(); 83 | expect(mFetch.mock.calls[0][0]).toBe(`${API_URL}/user`); 84 | }); 85 | }); 86 | 87 | describe(':getSites', () => { 88 | test('calls the correct URL', async () => { 89 | mFetch.mockResolvedValue(getFetchPromise({})); 90 | await apiClient.getSites(); 91 | expect(mFetch.mock.calls[0][0]).toBe(`${API_URL}/sites`); 92 | }); 93 | }); 94 | 95 | describe(':getSiteDeploys', () => { 96 | test('calls the correct URL', async () => { 97 | const siteId = '123456789'; 98 | mFetch.mockResolvedValue(getFetchPromise({})); 99 | await apiClient.getSiteDeploys(siteId); 100 | expect(mFetch.mock.calls[0][0]).toBe( 101 | `${API_URL}/sites/${siteId}/deploys?page=1&per_page=15` 102 | ); 103 | }); 104 | }); 105 | 106 | describe(':createSiteBuild', () => { 107 | test('calls the correct URL with the correct HTTP method', async () => { 108 | const siteId = '123456789'; 109 | mFetch.mockResolvedValue(getFetchPromise({})); 110 | await apiClient.createSiteBuild(siteId); 111 | expect(mFetch.mock.calls[0][0]).toBe(`${API_URL}/sites/${siteId}/builds`); 112 | expect(mFetch.mock.calls[0][1].method).toBe('POST'); 113 | }); 114 | }); 115 | 116 | describe(':authorize', () => { 117 | test('calls the API check validity of the token and return client', async () => { 118 | mFetch.mockResolvedValue(getFetchPromise({})); 119 | const client = await apiClient.authorize('clientId'); 120 | expect(mFetch.mock.calls.length).toBe(1); 121 | expect(client).toBe(apiClient); 122 | }); 123 | 124 | test('invalid token', async () => { 125 | const newToken = 'yeah-awesome-token'; 126 | 127 | mFetch.mockResolvedValueOnce(getFetchPromise({}, { status: 401 })); 128 | mFetch.mockResolvedValueOnce(getFetchPromise({ id: 'ticketId' })); 129 | mFetch.mockResolvedValueOnce(getFetchPromise({ authorized: false })); 130 | mFetch.mockResolvedValueOnce(getFetchPromise({ authorized: true })); 131 | mFetch.mockResolvedValueOnce(getFetchPromise({ access_token: newToken })); 132 | 133 | const client = await apiClient.authorize('clientId2'); 134 | 135 | expect(client.accessToken).toBe(newToken); 136 | expect(mFetch.mock.calls.length).toBe(5); 137 | }); 138 | }); 139 | }); 140 | -------------------------------------------------------------------------------- /src/__tests__/notify.test.ts: -------------------------------------------------------------------------------- 1 | const mockGetSettings = jest.fn(); 2 | const mockOn = jest.fn(); 3 | const mockShow = jest.fn(); 4 | const inMockConstructor = jest.fn(); 5 | class Notification { 6 | public on = mockOn; 7 | public show = mockShow; 8 | constructor() { 9 | inMockConstructor(); 10 | } 11 | } 12 | /* doMock instead of mock to prevent hoisting above class and const declarations*/ 13 | jest.doMock('electron', () => ({ 14 | Notification 15 | })); 16 | jest.doMock('electron-settings', () => ({ 17 | get: mockGetSettings 18 | })); 19 | 20 | // this import must come after jest.doMock(... 21 | import notify from '../notify'; 22 | 23 | describe('notify function', () => { 24 | test('if showNotifications setting is false, does not create a Notification ', () => { 25 | mockGetSettings.mockImplementation(() => false); // getting any setting will return false 26 | notify({ title: 'test title', body: 'test body', onClick: jest.fn() }); 27 | expect(mockGetSettings).toHaveBeenCalledWith('showNotifications'); 28 | expect(mockGetSettings.mock.results[0].value).toEqual(false); 29 | expect(inMockConstructor).not.toHaveBeenCalled(); 30 | }); 31 | test('if showNotifications setting is true, it creates a Notification, registers callbacks, calls notifation.show()', () => { 32 | mockGetSettings.mockImplementation(() => true); 33 | notify({ title: 'test title', body: 'test body', onClick: jest.fn() }); 34 | expect(inMockConstructor).toHaveBeenCalled(); 35 | expect(mockOn).toHaveBeenCalledTimes(2); 36 | expect(mockShow).toHaveBeenCalled(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/__tests__/scheduler.test.ts: -------------------------------------------------------------------------------- 1 | import scheduler from '../scheduler'; 2 | 3 | function flushPromises() { 4 | return new Promise(resolve => setImmediate(resolve)); 5 | } 6 | 7 | describe('scheduler', () => { 8 | describe(':repeat', () => { 9 | test('repeats function call in set interval and accepts stop/resume', async () => { 10 | jest.useFakeTimers(); 11 | const mFn = jest.fn(); 12 | mFn.mockImplementation(() => Promise.resolve()); 13 | 14 | scheduler.repeat([ 15 | { 16 | fn: mFn, 17 | interval: 1000 18 | } 19 | ]); 20 | 21 | expect(mFn).toHaveBeenLastCalledWith({ isFirstRun: true }); 22 | expect(mFn.mock.calls.length).toBe(1); 23 | 24 | await flushPromises(); 25 | jest.advanceTimersByTime(1000); 26 | expect(mFn).toHaveBeenLastCalledWith({ isFirstRun: false }); 27 | expect(mFn.mock.calls.length).toBe(2); 28 | 29 | await flushPromises(); 30 | scheduler.stop(); 31 | jest.advanceTimersByTime(10000); 32 | expect(mFn.mock.calls.length).toBe(2); 33 | 34 | scheduler.resume(); 35 | expect(mFn.mock.calls.length).toBe(3); 36 | 37 | await flushPromises(); 38 | jest.advanceTimersByTime(1000); 39 | expect(mFn.mock.calls.length).toBe(4); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export const POLL_DURATIONS = [ 2 | { value: 10000, label: '10sec' }, 3 | { value: 30000, label: '30sec' }, 4 | { value: 60000, label: '1min' }, 5 | { value: 180000, label: '3min' }, 6 | { value: 300000, label: '5min' } 7 | ]; 8 | -------------------------------------------------------------------------------- /src/connection.ts: -------------------------------------------------------------------------------- 1 | import { BrowserWindow, ipcMain } from 'electron'; // tslint:disable-line no-implicit-dependencies 2 | import { EventEmitter } from 'events'; 3 | 4 | export default class Connection extends EventEmitter { 5 | private status: string; 6 | private statusWindow: BrowserWindow; 7 | 8 | public constructor() { 9 | super(); 10 | 11 | this.status = 'PENDING'; 12 | this.statusWindow = new BrowserWindow({ 13 | height: 0, 14 | show: false, 15 | webPreferences: { 16 | nodeIntegration: true 17 | }, 18 | width: 0 19 | }); 20 | this.statusWindow.loadURL(`data:text/html;charset=utf-8, 21 | 22 | 23 | 30 | 31 | `); 32 | 33 | ipcMain.on('status-changed', (event, status) => { 34 | this.status = status ? 'ONLINE' : 'OFFLINE'; 35 | this.emit('status-changed', this); 36 | }); 37 | } 38 | 39 | public get isOnline(): boolean { 40 | return this.status === 'ONLINE'; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/icons.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | const getIconPath = (fileName: string): string => 4 | path.join(__dirname, '..', 'media', fileName); 5 | 6 | export default { 7 | building: getIconPath('buildingTemplate.png'), 8 | enqueued: getIconPath('buildingTemplate.png'), 9 | error: getIconPath('errorTemplate.png'), 10 | loading: getIconPath('loadingTemplate.png'), 11 | new: getIconPath('buildingTemplate.png'), 12 | offline: getIconPath('offlineTemplate.png'), 13 | processing: getIconPath('buildingTemplate.png'), 14 | ready: getIconPath('readyTemplate.png'), 15 | uploaded: getIconPath('buildingTemplate.png'), 16 | uploading: getIconPath('buildingTemplate.png') 17 | }; 18 | -------------------------------------------------------------------------------- /src/incidentFeed.ts: -------------------------------------------------------------------------------- 1 | import Parser from 'rss-parser'; 2 | 3 | const FEED_URL = 'https://www.netlifystatus.com/history.rss'; 4 | 5 | export interface IFeedItem { 6 | title: string; 7 | pubDate: string; 8 | content: string; 9 | link: string; 10 | } 11 | 12 | export default class IncidentFeed { 13 | private parser: { parseURL(feedUrl: string) }; 14 | private currentFeed: IFeedItem[]; 15 | private previousFeed: IFeedItem[]; 16 | 17 | constructor() { 18 | this.parser = new Parser(); 19 | this.currentFeed = []; 20 | this.previousFeed = []; 21 | } 22 | 23 | public async update(): Promise { 24 | console.log('FEED CALL'); // tslint:disable-line no-console 25 | const fetchedFeed: IFeedItem[] = await this.fetchAndParseFeed(); 26 | console.log('FEED CALL DONE'); // tslint:disable-line no-console 27 | this.previousFeed = this.currentFeed; 28 | this.currentFeed = fetchedFeed; 29 | } 30 | 31 | public newIncidents(): ReadonlyArray { 32 | if (this.previousFeed.length === 0) { 33 | return []; 34 | } 35 | return this.currentFeed.filter(currentItem => { 36 | return !this.previousFeed.some(previousItem => { 37 | return previousItem.link === currentItem.link; 38 | }); 39 | }); 40 | } 41 | 42 | public updatedIncidents(): ReadonlyArray { 43 | return this.currentFeed.filter(currentItem => { 44 | return this.previousFeed.find(previousItem => { 45 | return ( 46 | previousItem.link === currentItem.link && 47 | previousItem.content !== currentItem.content 48 | ); 49 | }); 50 | }); 51 | } 52 | 53 | public getFeed(): ReadonlyArray { 54 | return this.currentFeed as ReadonlyArray; 55 | } 56 | 57 | private async fetchAndParseFeed(): Promise { 58 | const response = await this.parser.parseURL(FEED_URL); 59 | return response.items; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import AutoLaunch from 'auto-launch'; 2 | import { app, powerMonitor } from 'electron'; // tslint:disable-line no-implicit-dependencies 3 | import isDev from 'electron-is-dev'; 4 | import settings from 'electron-settings'; 5 | import { autoUpdater } from 'electron-updater'; 6 | import Connection from './connection'; 7 | import IncidentFeed from './incidentFeed'; 8 | import MenuUI from './menubar'; 9 | import Netlify from './netlify'; 10 | 11 | const OAUTH_CLIENT_ID = 12 | '38a923fc70a44fce96c3ad5abd41c732a3c33d3cdb134c0d32c577181671e077'; 13 | 14 | const getNetlifyClient = async (accessToken: string): Promise => { 15 | const apiClient = new Netlify(accessToken); 16 | return apiClient.authorize(OAUTH_CLIENT_ID); 17 | }; 18 | 19 | const getOnlineConnection = (): Promise => { 20 | return new Promise((resolve) => { 21 | const connection = new Connection(); 22 | 23 | connection.on('status-changed', (conn) => { 24 | if (conn.isOnline) { 25 | resolve(connection); 26 | } 27 | }); 28 | }); 29 | }; 30 | 31 | const configureAutoLauncher = ( 32 | autoLauncher: AutoLaunch, 33 | { shouldAutoLaunch } 34 | ): void => { 35 | if (shouldAutoLaunch) { 36 | autoLauncher.enable(); 37 | } else { 38 | autoLauncher.disable(); 39 | } 40 | }; 41 | 42 | /** 43 | * 44 | * 45 | * @returns {Promise} 46 | */ 47 | const onAppReady = async (): Promise => { 48 | const connection = await getOnlineConnection(); 49 | const incidentFeed = new IncidentFeed(); 50 | const apiClient = await getNetlifyClient( 51 | settings.get('accessToken') as string 52 | ); 53 | 54 | settings.set('accessToken', apiClient.accessToken); 55 | 56 | if (!isDev) { 57 | const autoLauncher = new AutoLaunch({ 58 | name: 'Netlify Menubar', 59 | path: '/Applications/Netlify Menubar.app', 60 | }); 61 | 62 | configureAutoLauncher(autoLauncher, { 63 | shouldAutoLaunch: settings.get('launchAtStart'), 64 | }); 65 | 66 | settings.watch('launchAtStart', (launchAtStart) => { 67 | configureAutoLauncher(autoLauncher, { shouldAutoLaunch: launchAtStart }); 68 | }); 69 | } 70 | 71 | const ui = new MenuUI({ 72 | apiClient, 73 | connection, 74 | incidentFeed, 75 | }); 76 | 77 | // only hide dock icon when everything's running 78 | // otherwise the auth prompt disappears in MacOS 79 | app.dock.hide(); 80 | 81 | if ( 82 | settings.get('updateAutomatically') || 83 | // it defaults to true but is not stored initially 84 | settings.get('updateAutomatically') === undefined 85 | ) { 86 | autoUpdater.checkForUpdatesAndNotify(); 87 | autoUpdater.on('update-downloaded', () => { 88 | ui.on('ready-to-update', () => autoUpdater.quitAndInstall()); 89 | ui.setState({ updateAvailable: true }); 90 | }); 91 | powerMonitor.on('unlock-screen', () => 92 | autoUpdater.checkForUpdatesAndNotify() 93 | ); 94 | } 95 | }; 96 | 97 | export const start = () => { 98 | app.on('ready', onAppReady); 99 | }; 100 | -------------------------------------------------------------------------------- /src/menubar.ts: -------------------------------------------------------------------------------- 1 | import { isToday, isYesterday } from 'date-fns'; 2 | import { app, Menu, MenuItemConstructorOptions, shell, Tray } from 'electron'; // tslint:disable-line no-implicit-dependencies 3 | import settings from 'electron-settings'; 4 | import { EventEmitter } from 'events'; 5 | import { POLL_DURATIONS } from './config'; 6 | import Connection from './connection'; 7 | import ICONS from './icons'; 8 | import IncidentFeed from './incidentFeed'; 9 | import { 10 | getCheckboxMenu, 11 | getDeploysMenu, 12 | getIncidentsMenu, 13 | getSitesMenu 14 | } from './menus'; 15 | import Netlify, { INetlifyDeploy, INetlifySite, INetlifyUser } from './netlify'; 16 | import notify from './notify'; 17 | import scheduler from './scheduler'; 18 | import { 19 | getFormattedDeploys, 20 | getNotificationOptions, 21 | getSuspendedDeployCount 22 | } from './util'; 23 | 24 | interface IJsonObject { 25 | [x: string]: JsonValue; 26 | } 27 | 28 | interface IJsonArray extends Array {} // tslint:disable-line no-empty-interface 29 | type JsonValue = string | number | boolean | null | IJsonArray | IJsonObject; 30 | 31 | export interface IAppSettings { 32 | updateAutomatically: boolean; 33 | launchAtStart: boolean; 34 | pollInterval: number; 35 | showNotifications: boolean; 36 | currentSiteId: string | null; 37 | } 38 | 39 | interface IAppState { 40 | currentSite?: INetlifySite; 41 | menuIsOpen: boolean; 42 | previousDeploy: INetlifyDeploy | null; 43 | updateAvailable: boolean; 44 | } 45 | 46 | export interface IAppDeploys { 47 | pending: INetlifyDeploy[]; 48 | ready: INetlifyDeploy[]; 49 | } 50 | 51 | interface IAppNetlifyData { 52 | deploys: IAppDeploys; 53 | sites: INetlifySite[]; 54 | user?: INetlifyUser; 55 | } 56 | 57 | const DEFAULT_SETTINGS: IAppSettings = { 58 | currentSiteId: null, 59 | launchAtStart: false, 60 | pollInterval: 10000, 61 | showNotifications: false, 62 | updateAutomatically: true 63 | }; 64 | 65 | export default class UI extends EventEmitter { 66 | private apiClient: Netlify; 67 | private incidentFeed: IncidentFeed; 68 | private connection: Connection; 69 | private state: IAppState; 70 | private tray: Tray; 71 | private settings: IAppSettings; 72 | private netlifyData: IAppNetlifyData; 73 | 74 | public constructor({ 75 | apiClient, 76 | connection, 77 | incidentFeed 78 | }: { 79 | apiClient: Netlify; 80 | connection: Connection; 81 | incidentFeed: IncidentFeed; 82 | }) { 83 | super(); 84 | 85 | this.incidentFeed = incidentFeed; 86 | this.tray = new Tray(ICONS.loading); 87 | this.apiClient = apiClient; 88 | this.connection = connection; 89 | 90 | this.settings = { 91 | ...DEFAULT_SETTINGS, 92 | ...(settings.getAll() as {}) 93 | }; 94 | 95 | this.netlifyData = { 96 | deploys: { pending: [], ready: [] }, 97 | sites: [] 98 | }; 99 | 100 | this.state = { 101 | menuIsOpen: false, 102 | previousDeploy: null, 103 | updateAvailable: false 104 | }; 105 | 106 | this.setup().then(() => this.setupScheduler()); 107 | } 108 | 109 | public setState(state: Partial) { 110 | this.state = { ...this.state, ...state }; 111 | this.render(); 112 | } 113 | 114 | private async setup(): Promise { 115 | await this.fetchData(async () => { 116 | if (!this.settings.currentSiteId) { 117 | this.settings.currentSiteId = await this.getFallbackSiteId(); 118 | } 119 | 120 | const [currentUser, sites, deploys] = await Promise.all([ 121 | this.apiClient.getCurrentUser(), 122 | this.apiClient.getSites(), 123 | this.apiClient.getSiteDeploys(this.settings.currentSiteId) 124 | ]); 125 | 126 | this.netlifyData = { 127 | deploys: getFormattedDeploys(deploys), 128 | sites, 129 | user: { 130 | email: currentUser.email 131 | } 132 | }; 133 | 134 | this.state.currentSite = this.getSite(this.settings.currentSiteId); 135 | }); 136 | } 137 | 138 | private async setupScheduler(): Promise { 139 | scheduler.repeat([ 140 | { 141 | fn: async () => { 142 | await this.updateDeploys(); 143 | }, 144 | interval: this.settings.pollInterval 145 | }, 146 | { 147 | fn: async ({ isFirstRun }) => { 148 | await this.updateFeed(); 149 | if (isFirstRun) { 150 | this.notifyForIncidentsPastTwoDays(); 151 | } else { 152 | this.notifyForNewAndUpdatedIncidents(); 153 | } 154 | }, 155 | // going with a minute for now 156 | interval: 60000 157 | } 158 | ]); 159 | 160 | this.connection.on('status-changed', connection => { 161 | if (connection.isOnline) { 162 | scheduler.resume(); 163 | } else { 164 | scheduler.stop(); 165 | console.error('Currently offline, unable to get updates...'); // tslint:disable-line no-console 166 | this.tray.setImage(ICONS.offline); 167 | } 168 | }); 169 | } 170 | 171 | private getSite(siteId: string): INetlifySite { 172 | return ( 173 | this.netlifyData.sites.find(({ id }) => id === siteId) || 174 | this.netlifyData.sites[0] 175 | ); 176 | } 177 | 178 | private async getFallbackSiteId(): Promise { 179 | const sites = await this.apiClient.getSites(); 180 | return sites[0].id; 181 | } 182 | 183 | private async fetchData(fn: () => void): Promise { 184 | if (this.connection.isOnline) { 185 | this.tray.setImage(ICONS.loading); 186 | // catch possible network hickups 187 | try { 188 | await fn(); 189 | this.evaluateDeployState(); 190 | if (this.state.previousDeploy) { 191 | this.tray.setImage(ICONS[this.state.previousDeploy.state]); 192 | } 193 | } catch (e) { 194 | console.error(e); // tslint:disable-line no-console 195 | this.tray.setImage(ICONS.offline); 196 | } 197 | } 198 | 199 | this.render(); 200 | } 201 | 202 | private updateFeed(): Promise { 203 | return this.fetchData(async () => { 204 | await this.incidentFeed.update(); 205 | }); 206 | } 207 | 208 | private updateDeploys(): Promise { 209 | return this.fetchData(async () => { 210 | if (this.settings.currentSiteId) { 211 | const deploys = await this.apiClient.getSiteDeploys( 212 | this.settings.currentSiteId 213 | ); 214 | 215 | this.netlifyData.deploys = getFormattedDeploys(deploys); 216 | } 217 | }); 218 | } 219 | 220 | private notifyForIncidentsPastTwoDays(): void { 221 | const recentIncidents = this.incidentFeed.getFeed().filter(item => { 222 | const publicationDate = new Date(item.pubDate); 223 | return isToday(publicationDate) || isYesterday(publicationDate); 224 | }); 225 | if (recentIncidents.length) { 226 | this.notifyIncident(recentIncidents[0], 'Recently reported incident'); 227 | } 228 | } 229 | 230 | private notifyForNewAndUpdatedIncidents(): void { 231 | const newIncidents = this.incidentFeed.newIncidents(); 232 | const updatedIncidents = this.incidentFeed.updatedIncidents(); 233 | if (newIncidents.length) { 234 | this.notifyIncident(newIncidents[0], 'New incident reported'); 235 | } 236 | if (updatedIncidents.length) { 237 | this.notifyIncident(updatedIncidents[0], 'Incident report updated'); 238 | } 239 | } 240 | 241 | private notifyIncident( 242 | incident: { title: string; link: string }, 243 | title: string 244 | ): void { 245 | notify({ 246 | body: incident.title, 247 | onClick: () => { 248 | shell.openExternal(incident.link); 249 | }, 250 | title 251 | }); 252 | } 253 | 254 | private evaluateDeployState(): void { 255 | const { deploys } = this.netlifyData; 256 | const { previousDeploy, currentSite } = this.state; 257 | 258 | let currentDeploy: INetlifyDeploy | null = null; 259 | 260 | if (deploys.pending.length) { 261 | currentDeploy = deploys.pending[deploys.pending.length - 1]; 262 | } else if (deploys.ready.length) { 263 | currentDeploy = deploys.ready[0]; 264 | } 265 | 266 | // cover edge case for new users 267 | // who don't have any deploys yet 268 | if (currentDeploy === null) { 269 | return; 270 | } 271 | 272 | if (previousDeploy) { 273 | const notificationOptions = getNotificationOptions( 274 | previousDeploy, 275 | currentDeploy 276 | ); 277 | 278 | if (notificationOptions) { 279 | notify({ 280 | ...notificationOptions, 281 | onClick: () => { 282 | if (currentSite && currentDeploy) { 283 | shell.openExternal( 284 | `https://app.netlify.com/sites/${currentSite.name}/deploys/${ 285 | currentDeploy.id 286 | }` 287 | ); 288 | } 289 | } 290 | }); 291 | } 292 | } 293 | 294 | this.state.previousDeploy = currentDeploy; 295 | } 296 | 297 | private saveSetting(key: string, value: JsonValue): void { 298 | settings.set(key, value); 299 | this.settings[key] = value; 300 | this.render(); 301 | } 302 | 303 | private async render(): Promise { 304 | if (!this.state.currentSite) { 305 | console.error('No current site found'); // tslint:disable-line no-console 306 | return; 307 | } 308 | 309 | this.tray.setTitle( 310 | getSuspendedDeployCount(this.netlifyData.deploys.pending.length) 311 | ); 312 | 313 | this.renderMenu(this.state.currentSite); 314 | } 315 | 316 | private async renderMenu(currentSite: INetlifySite): Promise { 317 | if (!this.connection.isOnline) { 318 | return this.tray.setContextMenu( 319 | Menu.buildFromTemplate([ 320 | { 321 | enabled: false, 322 | label: "Looks like you're offline..." 323 | } 324 | ]) 325 | ); 326 | } 327 | 328 | const { sites, deploys, user } = this.netlifyData; 329 | const { pollInterval } = this.settings; 330 | 331 | const menu = Menu.buildFromTemplate([ 332 | { 333 | enabled: false, 334 | label: `Netlify Menubar ${app.getVersion()}` 335 | }, 336 | { type: 'separator' }, 337 | { 338 | label: 'Reported Incidents', 339 | submenu: getIncidentsMenu(this.incidentFeed) 340 | }, 341 | { type: 'separator' }, 342 | { 343 | enabled: false, 344 | label: user && user.email 345 | }, 346 | { type: 'separator' }, 347 | { 348 | label: 'Choose site:', 349 | submenu: getSitesMenu({ 350 | currentSite, 351 | onItemClick: siteId => { 352 | this.saveSetting('currentSiteId', siteId); 353 | this.state.previousDeploy = null; 354 | this.state.currentSite = this.getSite(siteId); 355 | this.updateDeploys(); 356 | }, 357 | sites 358 | }) 359 | }, 360 | { type: 'separator' }, 361 | { 362 | enabled: false, 363 | label: `${currentSite.url.replace(/https?:\/\//, '')}` 364 | }, 365 | { 366 | click: () => shell.openExternal(currentSite.url), 367 | label: 'Go to Site' 368 | }, 369 | { 370 | click: () => shell.openExternal(currentSite.admin_url), 371 | label: 'Go to Admin' 372 | }, 373 | { 374 | enabled: false, 375 | label: '—' 376 | }, 377 | { 378 | label: 'Deploys', 379 | submenu: getDeploysMenu({ 380 | currentSite, 381 | deploys, 382 | onItemClick: deployId => 383 | shell.openExternal( 384 | `https://app.netlify.com/sites/${ 385 | currentSite.name 386 | }/deploys/${deployId}` 387 | ) 388 | }) 389 | }, 390 | { 391 | click: async () => { 392 | this.fetchData(async () => { 393 | await this.apiClient.createSiteBuild(currentSite.id); 394 | this.updateDeploys(); 395 | }); 396 | }, 397 | label: 'Trigger new deploy' 398 | }, 399 | { type: 'separator' }, 400 | { 401 | label: 'Settings', 402 | submenu: [ 403 | ...getCheckboxMenu({ 404 | items: [ 405 | { 406 | key: 'updateAutomatically', 407 | label: 'Receive automatic updates' 408 | }, 409 | { key: 'launchAtStart', label: 'Launch at Start' }, 410 | { key: 'showNotifications', label: 'Show notifications' } 411 | ], 412 | onItemClick: (key, value) => this.saveSetting(key, !value), 413 | settings: this.settings 414 | }), 415 | { 416 | label: 'Poll interval', 417 | submenu: POLL_DURATIONS.map( 418 | ({ label, value }): MenuItemConstructorOptions => ({ 419 | checked: pollInterval === value, 420 | click: () => this.saveSetting('pollInterval', value), 421 | label, 422 | type: 'radio' 423 | }) 424 | ) 425 | } 426 | ] 427 | }, 428 | { type: 'separator' }, 429 | { 430 | click: () => 431 | shell.openExternal( 432 | `https://github.com/stefanjudis/netlify-menubar/releases/tag/v${app.getVersion()}` 433 | ), 434 | label: 'Changelog' 435 | }, 436 | { 437 | click: () => 438 | shell.openExternal( 439 | 'https://github.com/stefanjudis/netlify-menubar/issues/new' 440 | ), 441 | label: 'Give feedback' 442 | }, 443 | { type: 'separator' }, 444 | { 445 | click: () => { 446 | settings.deleteAll(); 447 | app.exit(); 448 | }, 449 | label: 'Logout' 450 | }, 451 | { type: 'separator' }, 452 | ...(this.state.updateAvailable 453 | ? [ 454 | { 455 | click: () => this.emit('ready-to-update'), 456 | label: 'Restart and update...' 457 | } 458 | ] 459 | : []), 460 | { label: 'Quit Netlify Menubar', role: 'quit' } 461 | ]); 462 | 463 | menu.on('menu-will-show', () => (this.state.menuIsOpen = true)); 464 | menu.on('menu-will-close', () => { 465 | this.state.menuIsOpen = false; 466 | // queue it behind other event handlers because otherwise 467 | // the menu-rerender will cancel ongoing click handlers 468 | setImmediate(() => this.render()); 469 | }); 470 | 471 | // avoid the menu to close in case the user has it open 472 | if (!this.state.menuIsOpen) { 473 | // tslint:disable-next-line 474 | console.log('UI: rerending menu'); 475 | this.tray.setContextMenu(menu); 476 | } 477 | } 478 | } 479 | -------------------------------------------------------------------------------- /src/menus.ts: -------------------------------------------------------------------------------- 1 | import { distanceInWords, isWithinRange, subMonths } from 'date-fns'; 2 | import { MenuItemConstructorOptions, shell } from 'electron'; // tslint:disable-line no-implicit-dependencies 3 | import IncidentFeed, { IFeedItem } from './incidentFeed'; 4 | import { IAppDeploys, IAppSettings } from './menubar'; 5 | import { INetlifySite } from './netlify'; 6 | import { shortenString } from './util'; 7 | 8 | interface IDeployMenuOptions { 9 | currentSite: INetlifySite; 10 | deploys: IAppDeploys; 11 | onItemClick: (deployId: string) => void; 12 | } 13 | 14 | const isOlderThanAMonth = (incident: IFeedItem): boolean => { 15 | const pubDate = new Date(incident.pubDate); 16 | const today = new Date(); 17 | const aMonthAgo = subMonths(today, 1); 18 | return isWithinRange(pubDate, aMonthAgo, today); 19 | }; 20 | 21 | const SEPARATOR: MenuItemConstructorOptions = { type: 'separator' }; 22 | 23 | export const getIncidentsMenu = ( 24 | incidentFeed: IncidentFeed 25 | ): MenuItemConstructorOptions[] => { 26 | const recentIncidents = incidentFeed 27 | .getFeed() 28 | .filter(isOlderThanAMonth) 29 | // create menu option objects from incidents 30 | .map(incident => { 31 | return { 32 | click: () => shell.openExternal(incident.link), 33 | label: `${shortenString(incident.title, 60)} (${distanceInWords( 34 | new Date(incident.pubDate), 35 | new Date() 36 | )} ago)` 37 | }; 38 | }); 39 | // if there are no recent incidents, replace with message 40 | const renderedItems = recentIncidents.length 41 | ? recentIncidents 42 | : [ 43 | { 44 | enabled: false, 45 | label: 'no recent incidents' 46 | } 47 | ]; 48 | return [ 49 | { 50 | click: () => shell.openExternal('https://www.netlifystatus.com/history'), 51 | label: 'History' 52 | }, 53 | SEPARATOR, 54 | ...renderedItems 55 | ]; 56 | }; 57 | 58 | export const getDeploysMenu = ({ 59 | currentSite, 60 | deploys, 61 | onItemClick 62 | }: IDeployMenuOptions): MenuItemConstructorOptions[] => { 63 | const { pending: pendingDeploys, ready: doneDeploys } = deploys; 64 | const mapDeployToMenuItem = ({ 65 | context, 66 | created_at, 67 | state, 68 | branch, 69 | deploy_time, 70 | id 71 | }) => { 72 | return { 73 | click: () => onItemClick(id), 74 | label: `${context}: ${state} → ${branch} (${distanceInWords( 75 | new Date(created_at), 76 | new Date() 77 | )} ago ${deploy_time ? `in ${deploy_time}s` : ''})` 78 | }; 79 | }; 80 | 81 | return [ 82 | { 83 | click: () => shell.openExternal(`${currentSite.admin_url}/deploys`), 84 | label: 'Overview' 85 | }, 86 | SEPARATOR, 87 | ...pendingDeploys.map(mapDeployToMenuItem), 88 | ...(pendingDeploys.length ? [{ label: '—', enabled: false }] : []), 89 | ...doneDeploys.map(mapDeployToMenuItem) 90 | ].slice(0, 20); 91 | }; 92 | 93 | interface ISiteMenuOptions { 94 | sites: INetlifySite[]; 95 | currentSite: INetlifySite; 96 | onItemClick: (siteId: string) => void; 97 | } 98 | 99 | export const getSitesMenu = ({ 100 | sites, 101 | currentSite, 102 | onItemClick 103 | }: ISiteMenuOptions): MenuItemConstructorOptions[] => { 104 | return sites.map( 105 | ({ id, url }): MenuItemConstructorOptions => ({ 106 | checked: currentSite.id === id, 107 | click: () => onItemClick(id), 108 | label: `${url.replace(/https?:\/\//, '')}`, 109 | type: 'radio' 110 | }) 111 | ); 112 | }; 113 | 114 | interface ICheckboxMenuOptions { 115 | items: Array<{ key: string; label: string }>; 116 | settings: IAppSettings; 117 | onItemClick: (key: string, value: boolean) => void; 118 | } 119 | 120 | export const getCheckboxMenu = ({ 121 | items, 122 | settings, 123 | onItemClick 124 | }: ICheckboxMenuOptions): MenuItemConstructorOptions[] => { 125 | return items.map( 126 | ({ label, key }): MenuItemConstructorOptions => ({ 127 | checked: settings[key], 128 | click: () => onItemClick(key, settings[key]), 129 | label, 130 | type: 'checkbox' 131 | }) 132 | ); 133 | }; 134 | -------------------------------------------------------------------------------- /src/netlify.ts: -------------------------------------------------------------------------------- 1 | import { dialog, MessageBoxReturnValue, shell } from 'electron'; // tslint:disable-line no-implicit-dependencies 2 | import fetch from 'node-fetch'; 3 | 4 | const showMessageBox = (options: any): Promise => 5 | dialog.showMessageBox(options); 6 | 7 | interface INetlifyTicket { 8 | id: string; 9 | client_id: string; 10 | authorized: boolean; 11 | } 12 | 13 | interface INetlifyBuild { 14 | id: string; 15 | deploy_id: string; 16 | sha: string; 17 | done: boolean; 18 | error: string; 19 | } 20 | 21 | export interface INetlifyDeploy { 22 | context: string; 23 | created_at: string; 24 | state: string; 25 | branch: string; 26 | deploy_time: string; 27 | error_message: string; 28 | id: string; 29 | } 30 | 31 | export interface INetlifySite { 32 | id: string; 33 | name: string; 34 | url: string; 35 | admin_url: string; 36 | } 37 | 38 | export interface INetlifyUser { 39 | email: string; 40 | } 41 | 42 | interface INetlifyAccessToken { 43 | id: string; 44 | access_token: string; 45 | user_id: string; 46 | user_email: string; 47 | } 48 | 49 | export const API_URL = 'https://api.netlify.com/api/v1'; 50 | export const UI_URL = 'https://app.netlify.com'; 51 | 52 | class Netlify { 53 | public accessToken: string | null; 54 | private API_URL: string; 55 | private UI_URL: string; 56 | 57 | constructor(accessToken: string) { 58 | this.accessToken = accessToken; 59 | this.API_URL = API_URL; 60 | this.UI_URL = UI_URL; 61 | } 62 | 63 | public async authorize(clientId: string): Promise { 64 | if (this.accessToken) { 65 | try { 66 | await this.getCurrentUser(); 67 | return this; 68 | } catch (e) { 69 | // fetching the current user failed 70 | // meaning that the access token is not valid 71 | // -> clear access token and issue a new one 72 | this.accessToken = null; 73 | return this.authorize(clientId); 74 | } 75 | } 76 | 77 | const messageBoxReturn = await showMessageBox({ 78 | buttons: ['Open Netlify', 'Cancel'], 79 | cancelId: 1, 80 | defaultId: 0, 81 | message: 82 | "In order to access your build information Netlify Menubar has to be authorized in the Netlify UI.\n\n This is a mandatory step and without it the App won't work...", 83 | title: 'Authorize Netlify Menubar', 84 | type: 'question' 85 | }); 86 | 87 | if (messageBoxReturn.response !== 0) { 88 | return process.exit(); 89 | } 90 | 91 | const ticket = await this.fetch( 92 | `/oauth/tickets?client_id=${clientId}`, 93 | 'POST' 94 | ); 95 | 96 | shell.openExternal( 97 | `${this.UI_URL}/authorize?response_type=ticket&ticket=${ticket.id}` 98 | ); 99 | 100 | this.accessToken = await this.getAccessToken(ticket.id); 101 | 102 | return this; 103 | } 104 | 105 | /** 106 | * @param {string} siteId 107 | * @returns {Promise} 108 | * @memberof Netlify 109 | * @tested 110 | */ 111 | public createSiteBuild(siteId: string): Promise { 112 | return this.fetch(`/sites/${siteId}/builds`, 'POST'); 113 | } 114 | 115 | /** 116 | * @returns {Promise} 117 | * @memberof Netlify 118 | * @tested 119 | */ 120 | public getCurrentUser(): Promise { 121 | return this.fetch('/user'); 122 | } 123 | 124 | /** 125 | * @returns {Promise} 126 | * @memberof Netlify 127 | * @tested 128 | */ 129 | public getSites(): Promise { 130 | return this.fetch('/sites'); 131 | } 132 | 133 | /** 134 | * @param {string} siteId 135 | * @returns {Promise} 136 | * @memberof Netlify 137 | * @tested 138 | */ 139 | public getSiteDeploys(siteId: string): Promise { 140 | // paginate the deploys to not generate too much load on netlify's side 141 | // https://github.com/stefanjudis/netlify-menubar/issues/20 142 | return this.fetch( 143 | `/sites/${siteId}/deploys?page=1&per_page=15` 144 | ); 145 | } 146 | 147 | /** 148 | * @template T 149 | * @param {string} path 150 | * @param {string} [method='GET'] 151 | * @returns {Promise} 152 | * @memberof Netlify 153 | * @tested 154 | */ 155 | public async fetch(path: string, method: string = 'GET'): Promise { 156 | // tslint:disable-next-line 157 | console.log('NETLIFY CALL:', path, method); 158 | const response = await fetch(`${this.API_URL}${path}`, { 159 | headers: { 160 | authorization: `Bearer ${this.accessToken}` 161 | }, 162 | method 163 | }); 164 | 165 | if (response.status === 401) { 166 | throw new Error('NOT_AUTHORIZED'); 167 | } 168 | 169 | // tslint:disable-next-line 170 | console.log('NETLIFY CALL DONE:', path, method); 171 | return response.json(); 172 | } 173 | 174 | private async getAccessToken(ticketId: string): Promise { 175 | const waitFor = (delay: number): Promise => 176 | new Promise(resolve => setTimeout(resolve, delay)); 177 | 178 | const checkTicket = async (): Promise => { 179 | return this.fetch(`/oauth/tickets/${ticketId}`); 180 | }; 181 | 182 | let authorizedTicket: INetlifyTicket | null = null; 183 | 184 | while (!authorizedTicket) { 185 | const ticket = await checkTicket(); 186 | if (ticket.authorized) { 187 | authorizedTicket = ticket; 188 | } 189 | await waitFor(1000); 190 | } 191 | 192 | const response = await this.fetch( 193 | `/oauth/tickets/${ticketId}/exchange`, 194 | 'POST' 195 | ); 196 | return response.access_token; 197 | } 198 | } 199 | 200 | export default Netlify; 201 | -------------------------------------------------------------------------------- /src/notify.ts: -------------------------------------------------------------------------------- 1 | import { Notification, NotificationConstructorOptions } from 'electron'; // tslint:disable-line no-implicit-dependencies 2 | import settings from 'electron-settings'; 3 | 4 | interface INotificationOptions extends NotificationConstructorOptions { 5 | onClick: () => void; 6 | } 7 | 8 | const notify = (options: INotificationOptions): void => { 9 | if (settings.get('showNotifications')) { 10 | const notification = new Notification(options); 11 | notification.on('click', options.onClick); 12 | // notifications with an attached click handler 13 | // won't disappear by itself 14 | // -> close it after certain timeframe automatically 15 | notification.on('show', () => setTimeout(() => notification.close(), 4000)); 16 | notification.show(); 17 | } 18 | }; 19 | 20 | export default notify; 21 | -------------------------------------------------------------------------------- /src/scheduler.ts: -------------------------------------------------------------------------------- 1 | interface IRun { 2 | fn: (options: any) => Promise; 3 | interval: number; 4 | timeout?: NodeJS.Timeout | null; 5 | } 6 | 7 | let queue: IRun[] = []; 8 | const errors: Error[] = []; 9 | 10 | const repeat = (fns: IRun[]): void => { 11 | fns.forEach(async run => { 12 | queue.push(run); 13 | const { fn, interval } = run; 14 | await fn({ isFirstRun: true }); 15 | 16 | const repeatFn = () => { 17 | run.timeout = setTimeout(async () => { 18 | try { 19 | await fn({ isFirstRun: false }); 20 | } catch (e) { 21 | errors.push(e); 22 | } finally { 23 | repeatFn(); 24 | } 25 | }, interval); 26 | }; 27 | 28 | repeatFn(); 29 | }); 30 | }; 31 | 32 | const stop = (): void => { 33 | queue.forEach(run => { 34 | if (run.timeout) { 35 | clearTimeout(run.timeout); 36 | run.timeout = null; 37 | } 38 | }); 39 | }; 40 | 41 | const resume = (): void => { 42 | const tmpQueue = [...queue]; 43 | queue = []; 44 | repeat(tmpQueue); 45 | }; 46 | 47 | export default { 48 | repeat, 49 | resume, 50 | stop 51 | }; 52 | -------------------------------------------------------------------------------- /src/util.test.ts: -------------------------------------------------------------------------------- 1 | import { INetlifyDeploy } from './netlify'; 2 | import { 3 | getFormattedDeploys, 4 | getNotificationOptions, 5 | getSuspendedDeployCount 6 | } from './util'; 7 | 8 | const getDeploy = (deploy: any): INetlifyDeploy => ({ 9 | ...{ 10 | branch: 'master', 11 | context: '...', 12 | created_at: 'createdAt', 13 | deploy_time: 'deploytime', 14 | error_message: '', 15 | id: '123', 16 | state: 'pending' 17 | }, 18 | ...deploy 19 | }); 20 | 21 | describe('utils', () => { 22 | describe(':getNotificationOptions', () => { 23 | test('returns correct notification for different deploys', () => { 24 | expect.assertions(2); 25 | const previousDeploy = getDeploy({ id: '123' }); 26 | const currentDeploy = getDeploy({ id: '234', state: 'skipped' }); 27 | 28 | const notificationOptions = getNotificationOptions( 29 | previousDeploy, 30 | currentDeploy 31 | ); 32 | 33 | if (notificationOptions) { 34 | expect(notificationOptions.title).toBe('New deploy started'); 35 | expect(notificationOptions.body).toBe('New deploy state: skipped'); 36 | } 37 | }); 38 | 39 | test('returns correct notification for same deploys but with different state', () => { 40 | expect.assertions(2); 41 | const previousDeploy = getDeploy({ id: '123', state: 'pending' }); 42 | const currentDeploy = getDeploy({ id: '1123', state: 'ready' }); 43 | 44 | const notificationOptions = getNotificationOptions( 45 | previousDeploy, 46 | currentDeploy 47 | ); 48 | 49 | if (notificationOptions) { 50 | expect(notificationOptions.title).toBe('New deploy started'); 51 | expect(notificationOptions.body).toBe('New deploy state: ready'); 52 | } 53 | }); 54 | 55 | test('returns correct notification for different deploys', () => { 56 | const previousDeploy = getDeploy({ id: '123', state: 'same' }); 57 | const currentDeploy = getDeploy({ id: '123', state: 'same' }); 58 | 59 | const notificationOptions = getNotificationOptions( 60 | previousDeploy, 61 | currentDeploy 62 | ); 63 | 64 | expect(notificationOptions).toBeNull(); 65 | }); 66 | }); 67 | describe(':getFormattedDeploys', () => { 68 | test('groups deploys correctly', () => { 69 | const deploys = [ 70 | getDeploy({ state: 'new', error_message: null, id: '1' }), 71 | getDeploy({ state: 'building', error_message: null, id: '2' }), 72 | getDeploy({ state: 'error', error_message: '', id: '3' }), 73 | getDeploy({ state: 'error', error_message: 'Skipped', id: '4' }), 74 | getDeploy({ state: 'ready', error_message: null, id: '5' }), 75 | getDeploy({ state: 'ready', error_message: null, id: '6' }) 76 | ]; 77 | 78 | const { 79 | pending: pendingDeploys, 80 | ready: readyDeploys 81 | } = getFormattedDeploys(deploys); 82 | expect(pendingDeploys.length).toBe(2); 83 | expect(readyDeploys.length).toBe(4); 84 | }); 85 | }); 86 | 87 | describe(':getSuspendedDeployCount', () => { 88 | test('returns correct string if in range', () => { 89 | const suspendedCount = getSuspendedDeployCount(5); 90 | 91 | expect(suspendedCount).toBe('₅'); 92 | }); 93 | 94 | test('returns correct string if out of range', () => { 95 | const suspendedCount = getSuspendedDeployCount(11); 96 | 97 | expect(suspendedCount).toBe('₉₊'); 98 | }); 99 | 100 | test('returns empty string if number of builds is 0', () => { 101 | const suspendedCount = getSuspendedDeployCount(0); 102 | 103 | expect(suspendedCount).toBe(''); 104 | }); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { NotificationConstructorOptions } from 'electron'; // tslint:disable-line no-implicit-dependencies 2 | import { IAppDeploys } from './menubar'; 3 | import { INetlifyDeploy } from './netlify'; 4 | 5 | interface IDeploysReduceAcc { 6 | pending: INetlifyDeploy[]; 7 | ready: INetlifyDeploy[]; 8 | foundReadyDeploy: boolean; 9 | } 10 | 11 | const isReady = (deploy: INetlifyDeploy) => deploy.state === 'ready'; 12 | const isError = (deploy: INetlifyDeploy) => deploy.state === 'error'; 13 | const isSkipped = (deploy: INetlifyDeploy) => 14 | deploy.state === 'error' && deploy.error_message === 'Skipped'; 15 | const isDifferentDeploy = (prev: INetlifyDeploy, current: INetlifyDeploy) => 16 | prev.id !== current.id; 17 | const isDifferentDeployState = ( 18 | prev: INetlifyDeploy, 19 | current: INetlifyDeploy 20 | ) => prev.state !== current.state; 21 | 22 | /** 23 | * 24 | * @param previous {INetlifyDeploy} 25 | * @param current {INetlifyDeploy} 26 | * @returns INotification | null 27 | * @tested 28 | */ 29 | export const getNotificationOptions = ( 30 | previous: INetlifyDeploy, 31 | current: INetlifyDeploy 32 | ): NotificationConstructorOptions | null => { 33 | if (isDifferentDeploy(previous, current)) { 34 | return { 35 | body: `New deploy state: ${current.state}`, 36 | title: 'New deploy started' 37 | }; 38 | } else if (isDifferentDeployState(previous, current)) { 39 | return { 40 | body: `Deploy state: ${current.state}`, 41 | title: 'Deploy progressed' 42 | }; 43 | } 44 | 45 | return null; 46 | }; 47 | 48 | /** 49 | * 50 | * @param deploys {INetlifyDeploy[]} 51 | * @returns IAppDeploys 52 | * @tested 53 | */ 54 | export const getFormattedDeploys = (deploys: INetlifyDeploy[]): IAppDeploys => { 55 | const formattedDeploys = deploys.reduce( 56 | (acc: IDeploysReduceAcc, deploy: INetlifyDeploy): IDeploysReduceAcc => { 57 | if ( 58 | !acc.foundReadyDeploy && 59 | (isReady(deploy) || isError(deploy) || isSkipped(deploy)) 60 | ) { 61 | acc.foundReadyDeploy = true; 62 | } 63 | 64 | if (acc.foundReadyDeploy) { 65 | acc.ready.push(deploy); 66 | } else { 67 | acc.pending.push(deploy); 68 | } 69 | 70 | if (deploy.state === 'error' && deploy.error_message === 'Skipped') { 71 | deploy.state = 'skipped'; 72 | } 73 | 74 | return acc; 75 | }, 76 | { pending: [], ready: [], foundReadyDeploy: false } 77 | ); 78 | 79 | return { 80 | pending: formattedDeploys.pending, 81 | ready: formattedDeploys.ready 82 | }; 83 | }; 84 | 85 | /** 86 | * 87 | * @param deployCount 88 | * @tested 89 | */ 90 | export const getSuspendedDeployCount = (deployCount: number): string => { 91 | if (deployCount > 0) { 92 | return `${ 93 | deployCount > 9 94 | ? String.fromCharCode(8329) + '₊' 95 | : String.fromCharCode(8320 + deployCount) 96 | }`; 97 | } else { 98 | return ''; 99 | } 100 | }; 101 | 102 | // shorten strings without splitting the last word 103 | export const shortenString = (str: string, maxLen: number) => { 104 | if (str.length <= maxLen) { 105 | return str; 106 | } 107 | return str.substr(0, str.lastIndexOf(' ', maxLen)); 108 | }; 109 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ES2018" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */, 5 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 6 | // "lib": [], /* Specify library files to be included in the compilation. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | "outDir": "./dist" /* Redirect output structure to the directory. */, 15 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 16 | // "composite": true, /* Enable project compilation */ 17 | // "removeComments": true, /* Do not emit comments to output. */ 18 | // "noEmit": true, /* Do not emit outputs. */ 19 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 20 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 22 | 23 | /* Strict Type-Checking Options */ 24 | "strict": true /* Enable all strict type-checking options. */, 25 | "noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */, 26 | // "strictNullChecks": true, /* Enable strict null checks. */ 27 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 28 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 29 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 30 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 31 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 32 | 33 | /* Additional Checks */ 34 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 35 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 36 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 37 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 38 | 39 | /* Module Resolution Options */ 40 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 41 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 42 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 43 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 44 | "typeRoots": [ 45 | "./node_modules/@types" 46 | ] /* List of folders to include type definitions from. */, 47 | // "types": [], /* Type declaration files to be included in compilation. */ 48 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 49 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 50 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 51 | 52 | /* Source Map Options */ 53 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 54 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 55 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 56 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 57 | 58 | /* Experimental Options */ 59 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 60 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 61 | }, 62 | "exclude": ["node_modules", "typings"] 63 | } 64 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:latest", "tslint-config-prettier"], 4 | "jsRules": {}, 5 | "rules": { 6 | "interface-name": [true, "always-prefix"] 7 | }, 8 | "rulesDirectory": [] 9 | } 10 | --------------------------------------------------------------------------------