Skip to content

Commit

Permalink
Merge pull request #14 from adamwatters/incidents
Browse files Browse the repository at this point in the history
added incidents menu and notifications
  • Loading branch information
stefanjudis committed Mar 18, 2019
2 parents 2544628 + b5b123b commit 1d2c496
Show file tree
Hide file tree
Showing 12 changed files with 599 additions and 210 deletions.
2 changes: 1 addition & 1 deletion .travis.yml
@@ -1,5 +1,5 @@
language: node_js
node_js:
- '11'
- '10'
os: osx
script: npm run test:ci && npm run release
418 changes: 252 additions & 166 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion package.json
Expand Up @@ -43,7 +43,8 @@
"date-fns": "^1.30.1",
"electron-settings": "^3.2.0",
"electron-updater": "^4.0.6",
"node-fetch": "^2.3.0"
"node-fetch": "^2.3.0",
"rss-parser": "^3.6.2"
},
"devDependencies": {
"@types/auto-launch": "^5.0.0",
Expand Down
81 changes: 81 additions & 0 deletions src/incidentFeed.test.ts
@@ -0,0 +1,81 @@
const firstResponse = {
items: [
{ link: 'id=1', content: 'not updated' },
{ link: 'id=2', content: 'not updated' }
]
};
const secondResponse = {
items: [
{ link: 'id=1', content: 'not updated' },
{ link: 'id=2', content: 'not updated' },
{ link: 'id=3', content: 'not updated' }
]
};
const thirdResponse = {
items: [
{ link: 'id=1', content: 'updated' },
{ link: 'id=2', content: 'not updated' },
{ link: 'id=3', content: 'not updated' }
]
};
const fourthResponse = {
items: [
{ link: 'id=1', content: 'updated' },
{ link: 'id=2', content: 'not updated' },
{ link: 'id=3', content: 'not updated' }
]
};

jest.doMock(
'rss-parser',
() =>
// has to use function keyword to be called with new keyword (ie act as a constructor)
/* tslint:disable-line only-arrow-functions */ function() {
return {
parseURL: jest
.fn()
.mockReturnValueOnce(firstResponse)
.mockReturnValueOnce(secondResponse)
.mockReturnValueOnce(thirdResponse)
.mockReturnValueOnce(fourthResponse)
};
}
);

import IncidentFeed from './incidentFeed';
const incidentFeed = new IncidentFeed();

describe('IncidentFeed', () => {
test('before :update is called, :getFeed returns an empty array', () => {
expect(incidentFeed.getFeed()).toMatchObject([]);
expect(incidentFeed.getFeed()).not.toMatchObject(['some value']);
});
test('first update', async () => {
await incidentFeed.update();
expect(incidentFeed.getFeed()).toBe(firstResponse.items);
expect(incidentFeed.newIncidents()).toMatchObject([]);
expect(incidentFeed.updatedIncidents()).toMatchObject([]);
});
test('second update', async () => {
await incidentFeed.update();
expect(incidentFeed.getFeed()).toBe(secondResponse.items);
expect(incidentFeed.newIncidents()).toMatchObject([
{ link: 'id=3', content: 'not updated' }
]);
expect(incidentFeed.updatedIncidents()).toMatchObject([]);
});
test('third update', async () => {
await incidentFeed.update();
expect(incidentFeed.getFeed()).toBe(thirdResponse.items);
expect(incidentFeed.newIncidents()).toMatchObject([]);
expect(incidentFeed.updatedIncidents()).toMatchObject([
{ link: 'id=1', content: 'updated' }
]);
});
test('fourth update', async () => {
await incidentFeed.update();
expect(incidentFeed.getFeed()).toBe(fourthResponse.items);
expect(incidentFeed.newIncidents()).toMatchObject([]);
expect(incidentFeed.updatedIncidents()).toMatchObject([]);
});
});
59 changes: 59 additions & 0 deletions src/incidentFeed.ts
@@ -0,0 +1,59 @@
import Parser from 'rss-parser';

const FEED_URL = 'https://www.netlifystatus.com/history.rss';

export interface IFeedItem {
title: string;
pubDate: string;
content: string;
link: string;
}

export default class IncidentFeed {
private parser: { parseURL(feedUrl: string) };
private currentFeed: IFeedItem[];
private previousFeed: IFeedItem[];

constructor() {
this.parser = new Parser();
this.currentFeed = [];
this.previousFeed = [];
}

public async update(): Promise<any> {
const fetchedFeed: IFeedItem[] = await this.fetchAndParseFeed();
this.previousFeed = this.currentFeed;
this.currentFeed = fetchedFeed;
}

public newIncidents(): ReadonlyArray<IFeedItem> {
if (this.previousFeed.length === 0) {
return [];
}
return this.currentFeed.filter(currentItem => {
return !this.previousFeed.some(previousItem => {
return previousItem.link === currentItem.link;
});
});
}

public updatedIncidents(): ReadonlyArray<IFeedItem> {
return this.currentFeed.filter(currentItem => {
return this.previousFeed.find(previousItem => {
return (
previousItem.link === currentItem.link &&
previousItem.content !== currentItem.content
);
});
});
}

public getFeed(): ReadonlyArray<IFeedItem> {
return this.currentFeed as ReadonlyArray<IFeedItem>;
}

private async fetchAndParseFeed(): Promise<IFeedItem[]> {
const response = await this.parser.parseURL(FEED_URL);
return response.items;
}
}
5 changes: 4 additions & 1 deletion src/index.ts
Expand Up @@ -3,6 +3,7 @@ import { app, powerMonitor } from 'electron'; // tslint:disable-line no-implicit
import settings from 'electron-settings';
import { autoUpdater } from 'electron-updater';
import Connection from './connection';
import IncidentFeed from './incidentFeed';
import MenuUI from './menubar';
import Netlify from './netlify';

Expand Down Expand Up @@ -44,6 +45,7 @@ const configureAutoLauncher = (
*/
const onAppReady = async (): Promise<void> => {
const connection = await getOnlineConnection();
const incidentFeed = new IncidentFeed();
const apiClient = await getNetlifyClient(settings.get(
'accessToken'
) as string);
Expand All @@ -64,7 +66,8 @@ const onAppReady = async (): Promise<void> => {

const ui = new MenuUI({
apiClient,
connection
connection,
incidentFeed
});

// only hide dock icon when everything's running
Expand Down
106 changes: 76 additions & 30 deletions src/menubar.ts
@@ -1,18 +1,19 @@
import {
app,
Menu,
MenuItemConstructorOptions,
Notification,
shell,
Tray
} from 'electron'; // tslint:disable-line no-implicit-dependencies
import { isToday, isYesterday } from 'date-fns';
import { app, Menu, MenuItemConstructorOptions, shell, Tray } from 'electron'; // tslint:disable-line no-implicit-dependencies
import settings from 'electron-settings';
import { EventEmitter } from 'events';
import { POLL_DURATIONS } from './config';
import Connection from './connection';
import ICONS from './icons';
import { getCheckboxMenu, getDeploysMenu, getSitesMenu } from './menus';
import IncidentFeed from './incidentFeed';
import {
getCheckboxMenu,
getDeploysMenu,
getIncidentsMenu,
getSitesMenu
} from './menus';
import Netlify, { INetlifyDeploy, INetlifySite, INetlifyUser } from './netlify';
import notify from './notify';
import {
getFormattedDeploys,
getNotificationOptions,
Expand Down Expand Up @@ -60,6 +61,7 @@ const DEFAULT_SETTINGS: IAppSettings = {

export default class UI extends EventEmitter {
private apiClient: Netlify;
private incidentFeed: IncidentFeed;
private connection: Connection;
private state: IAppState;
private tray: Tray;
Expand All @@ -68,13 +70,16 @@ export default class UI extends EventEmitter {

public constructor({
apiClient,
connection
connection,
incidentFeed
}: {
apiClient: Netlify;
connection: Connection;
incidentFeed: IncidentFeed;
}) {
super();

this.incidentFeed = incidentFeed;
this.tray = new Tray(ICONS.loading);
this.apiClient = apiClient;
this.connection = connection;
Expand All @@ -95,11 +100,22 @@ export default class UI extends EventEmitter {
updateAvailable: false
};

// TODO: move this to a dedicated Scheduler
this.setup().then(() => {
// Scheduler should have some method like: doOnce()
let first = true;
const repeat = () => {
setTimeout(async () => {
if (this.connection.isOnline) {
await this.updateDeploys();
// every 10 seconds is probably too frequent to be checking the incidents rss
// incident feed should get its own polling interval when Scheduler is implemented
await this.incidentFeed.update();
if (first) {
first = false;
this.notifyForIncidentsPastTwoDays();
}
this.notifyForNewAndUpdatedIncidents();
} else {
this.tray.setImage(ICONS.offline);
await this.render();
Expand All @@ -108,7 +124,6 @@ export default class UI extends EventEmitter {
repeat();
}, this.settings.pollInterval);
};

repeat();
});
}
Expand Down Expand Up @@ -157,7 +172,6 @@ export default class UI extends EventEmitter {
private async fetchData(fn: () => void): Promise<void> {
if (this.connection.isOnline) {
this.tray.setImage(ICONS.loading);

// catch possible network hickups
try {
await fn();
Expand Down Expand Up @@ -187,6 +201,40 @@ export default class UI extends EventEmitter {
});
}

private notifyForIncidentsPastTwoDays(): void {
const recentIncidents = this.incidentFeed.getFeed().filter(item => {
const publicationDate = new Date(item.pubDate);
return isToday(publicationDate) || isYesterday(publicationDate);
});
if (recentIncidents.length) {
this.notifyIncident(recentIncidents[0], 'Recently reported incident');
}
}

private notifyForNewAndUpdatedIncidents(): void {
const newIncidents = this.incidentFeed.newIncidents();
const updatedIncidents = this.incidentFeed.updatedIncidents();
if (newIncidents.length) {
this.notifyIncident(newIncidents[0], 'New incident reported');
}
if (updatedIncidents.length) {
this.notifyIncident(updatedIncidents[0], 'Incident report updated');
}
}

private notifyIncident(
incident: { title: string; link: string },
title: string
): void {
notify({
body: incident.title,
onClick: () => {
shell.openExternal(incident.link);
},
title
});
}

private evaluateDeployState(): void {
const { deploys } = this.netlifyData;
const { previousDeploy, currentSite } = this.state;
Expand All @@ -205,32 +253,25 @@ export default class UI extends EventEmitter {
return;
}

if (this.settings.showNotifications && previousDeploy) {
if (previousDeploy) {
const notificationOptions = getNotificationOptions(
previousDeploy,
currentDeploy
);

if (notificationOptions) {
const notification = new Notification(notificationOptions);

notification.on('click', event => {
if (currentSite && currentDeploy) {
shell.openExternal(
`https://app.netlify.com/sites/${currentSite.name}/deploys/${
currentDeploy.id
}`
);
notify({
...notificationOptions,
onClick: () => {
if (currentSite && currentDeploy) {
shell.openExternal(
`https://app.netlify.com/sites/${currentSite.name}/deploys/${
currentDeploy.id
}`
);
}
}
});

// notifications with an attached click handler
// won't disappear by itself
// -> close it after certain timeframe automatically
notification.on('show', () =>
setTimeout(() => notification.close(), 4000)
);
notification.show();
}
}

Expand Down Expand Up @@ -277,6 +318,11 @@ export default class UI extends EventEmitter {
label: `Netlify Menubar ${app.getVersion()}`
},
{ type: 'separator' },
{
label: 'Reported Incidents',
submenu: getIncidentsMenu(this.incidentFeed)
},
{ type: 'separator' },
{
enabled: false,
label: user && user.email
Expand Down

0 comments on commit 1d2c496

Please sign in to comment.