blob: 5d5a9d6ce445062717d56747d5f06e738c873528 [file] [log] [blame]
// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import { LitElement, html, css } from 'lit-element';
import 'elements/framework/mr-comment-content/mr-comment-content.js';
import { connectStore } from 'reducers/base.js';
import * as projectV0 from 'reducers/projectV0.js';
import * as userV0 from 'reducers/userV0.js';
// URL where announcements are fetched from.
const ANNOUNCEMENT_SERVICE =
'https://chopsdash.appspot.com/prpc/dashboard.ChopsAnnouncements/SearchAnnouncements';
// Prefix prepended to responses for security reasons.
export const XSSI_PREFIX = ')]}\'';
const FETCH_HEADERS = Object.freeze({
'accept': 'application/json',
'content-type': 'application/json',
});
// How often to refresh announcements.
export const REFRESH_TIME_MS = 5 * 60 * 1000;
/**
* @type {Array<Announcement>} A list of hardcodded announcements for Monorail.
*/
export const HARDCODED_ANNOUNCEMENTS = [{
"messageContent": "The Chromium project will be migrating to Buganizer on " +
" February 5 (go/chrome-buganizer). Please test your workflows for this " +
"transition with these instructions: go/cob-buv-quick-start",
"projects": ["chromium"],
"groups": ["everyone@google.com", "googlers@chromium.org"],
}];
/**
* @typedef {Object} Announcement
* @property {string=} id
* @property {string} messageContent
* @property {Array<string>=} projects Monorail extension for hard-coded
* announcements. Specifies the names of projects the announcement will
* occur in.
* @property {Array<string>=} groups Monorail extension for hard-coded
* announcements. Specifies email groups the announces will show up in.
*/
/**
* @typedef {Object} AnnouncementResponse
* @property {Array<Announcement>} announcements
*/
/**
* `<chops-announcement>` displays a ChopsDash message when there's an outage
* or other important announcement.
*
* @customElement chops-announcement
*/
class _ChopsAnnouncement extends LitElement {
/** @override */
static get styles() {
return css`
:host {
display: block;
width: 100%;
}
mr-comment-content {
display: block;
color: #222;
font-size: 13px;
background: #FFCDD2; /* Material design red */
width: 100%;
text-align: center;
padding: 0.5em 16px;
box-sizing: border-box;
margin: 0;
/* Using a red-tinted grey border makes hues feel harmonious. */
border-bottom: 1px solid #D6B3B6;
}
`;
}
/** @override */
render() {
if (this._error) {
return html`<p><strong>Error: </strong>${this._error}</p>`;
}
return html`
${this._processedAnnouncements().map(
({ messageContent }) => html`
<mr-comment-content
.content=${messageContent}>
</mr-comment-content>`)}
`;
}
/** @override */
static get properties() {
return {
service: { type: String },
additionalAnnouncements: { type: Array },
// Properties from the currently logged in user, usually feched through
// Redux.
currentUserName: { type: String },
userGroups: { type: Array },
currentProject: { type: String },
// Private properties managing state from requests to Chops Dash.
_error: { type: String },
_announcements: { type: Array },
};
}
/** @override */
constructor() {
super();
/** @type {string} */
this.service = undefined;
/** @type {Array<Announcement>} */
this.additionalAnnouncements = HARDCODED_ANNOUNCEMENTS;
this.currentUserName = '';
this.userGroups = [];
this.currentProject = '';
/** @type {string} */
this._error = undefined;
/** @type {Array<Announcement>} */
this._announcements = [];
/** @type {number} Interval ID returned by window.setInterval. */
this._interval = undefined;
}
/** @override */
updated(changedProperties) {
if (changedProperties.has('service')) {
if (this.service) {
this.startRefresh();
} else {
this.stopRefresh();
}
}
}
/** @override */
disconnectedCallback() {
super.disconnectedCallback();
this.stopRefresh();
}
/**
* Set up autorefreshing logic or announcement information.
*/
startRefresh() {
this.stopRefresh();
this.refresh();
this._interval = window.setInterval(() => this.refresh(), REFRESH_TIME_MS);
}
/**
* Logic for clearing refresh behavior.
*/
stopRefresh() {
if (this._interval) {
window.clearInterval(this._interval);
}
}
/**
* Refresh the announcement banner.
*/
async refresh() {
try {
const { announcements = [] } = await this.fetch(this.service);
this._error = undefined;
this._announcements = announcements;
} catch (e) {
this._error = e.message;
this._announcements = HARDCODED_ANNOUNCEMENTS;
}
}
/**
* Fetches the announcement for a given service.
* @param {string} service Name of the service to fetch from ChopsDash.
* ie: "monorail"
* @return {Promise<AnnouncementResponse>} ChopsDash response JSON.
* @throws {Error} If something went wrong while fetching.
*/
async fetch(service) {
const message = {
retired: false,
platformName: service,
};
const response = await window.fetch(ANNOUNCEMENT_SERVICE, {
method: 'POST',
headers: FETCH_HEADERS,
body: JSON.stringify(message),
});
if (!response.ok) {
throw new Error('Something went wrong while fetching announcements');
}
// We can't use response.json() because of the XSSI prefix.
const text = await response.text();
if (!text.startsWith(XSSI_PREFIX)) {
throw new Error(`No XSSI prefix in announce response: ${XSSI_PREFIX}`);
}
return JSON.parse(text.substr(XSSI_PREFIX.length));
}
_processedAnnouncements() {
const announcements = [...this.additionalAnnouncements, ...this._announcements];
// Only show announcements relevant to the project the user is viewing and
// the group the user is part of, if applicable.
return announcements.filter(({ groups, projects }) => {
if (groups && groups.length && !this._isUserInGroups(groups,
this.userGroups, this.currentUserName)) {
return false;
}
if (projects && projects.length && !this._isViewingProject(projects,
this.currentProject)) {
return false;
}
return true;
});
}
/**
* Helper to check if the user is a member of the allowed groups.
* @param {Array<string>} allowedGroups
* @param {Array<{{userId: string, displayName: string}}>} userGroups
* @param {string} userEmail
*/
_isUserInGroups(allowedGroups, userGroups, userEmail) {
const userGroupSet = new Set(userGroups.map(
({ displayName }) => displayName.toLowerCase()));
return allowedGroups.find((group) => {
group = group.toLowerCase();
// Handle custom groups in Monorail like everyone@google.com
if (group.startsWith('everyone@')) {
let [_, suffix] = group.split('@');
suffix = '@' + suffix;
return userEmail.endsWith(suffix);
}
return userGroupSet.has(group);
});
}
_isViewingProject(projects, currentProject) {
return projects.find((project = "") => project.toLowerCase() === currentProject.toLowerCase());
}
}
/** Redux-connected version of _ChopsAnnouncement. */
export class ChopsAnnouncement extends connectStore(_ChopsAnnouncement) {
/** @override */
stateChanged(state) {
const { displayName, groups } = userV0.currentUser(state);
this.currentUserName = displayName;
this.userGroups = groups;
this.currentProject = projectV0.viewedProjectName(state);
}
}
customElements.define('chops-announcement-base', _ChopsAnnouncement);
customElements.define('chops-announcement', ChopsAnnouncement);