| // 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); |