blob: 5d5a9d6ce445062717d56747d5f06e738c873528 [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001// Copyright 2020 The Chromium Authors
Copybara854996b2021-09-07 19:36:02 +00002// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01005import { LitElement, html, css } from 'lit-element';
6import 'elements/framework/mr-comment-content/mr-comment-content.js';
7
8import { connectStore } from 'reducers/base.js';
9import * as projectV0 from 'reducers/projectV0.js';
10import * as userV0 from 'reducers/userV0.js';
Copybara854996b2021-09-07 19:36:02 +000011
12// URL where announcements are fetched from.
13const ANNOUNCEMENT_SERVICE =
14 'https://chopsdash.appspot.com/prpc/dashboard.ChopsAnnouncements/SearchAnnouncements';
15
16// Prefix prepended to responses for security reasons.
17export const XSSI_PREFIX = ')]}\'';
18
19const FETCH_HEADERS = Object.freeze({
20 'accept': 'application/json',
21 'content-type': 'application/json',
22});
23
24// How often to refresh announcements.
25export const REFRESH_TIME_MS = 5 * 60 * 1000;
26
27/**
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010028 * @type {Array<Announcement>} A list of hardcodded announcements for Monorail.
29 */
30export const HARDCODED_ANNOUNCEMENTS = [{
31 "messageContent": "The Chromium project will be migrating to Buganizer on " +
32 " February 5 (go/chrome-buganizer). Please test your workflows for this " +
33 "transition with these instructions: go/cob-buv-quick-start",
34 "projects": ["chromium"],
35 "groups": ["everyone@google.com", "googlers@chromium.org"],
36}];
37
38/**
Copybara854996b2021-09-07 19:36:02 +000039 * @typedef {Object} Announcement
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010040 * @property {string=} id
Copybara854996b2021-09-07 19:36:02 +000041 * @property {string} messageContent
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010042 * @property {Array<string>=} projects Monorail extension for hard-coded
43 * announcements. Specifies the names of projects the announcement will
44 * occur in.
45 * @property {Array<string>=} groups Monorail extension for hard-coded
46 * announcements. Specifies email groups the announces will show up in.
Copybara854996b2021-09-07 19:36:02 +000047 */
48
49/**
50 * @typedef {Object} AnnouncementResponse
51 * @property {Array<Announcement>} announcements
52 */
53
54/**
55 * `<chops-announcement>` displays a ChopsDash message when there's an outage
56 * or other important announcement.
57 *
58 * @customElement chops-announcement
59 */
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010060class _ChopsAnnouncement extends LitElement {
Copybara854996b2021-09-07 19:36:02 +000061 /** @override */
62 static get styles() {
63 return css`
64 :host {
65 display: block;
66 width: 100%;
67 }
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010068 mr-comment-content {
Copybara854996b2021-09-07 19:36:02 +000069 display: block;
70 color: #222;
71 font-size: 13px;
72 background: #FFCDD2; /* Material design red */
73 width: 100%;
74 text-align: center;
75 padding: 0.5em 16px;
76 box-sizing: border-box;
77 margin: 0;
78 /* Using a red-tinted grey border makes hues feel harmonious. */
79 border-bottom: 1px solid #D6B3B6;
80 }
81 `;
82 }
83 /** @override */
84 render() {
85 if (this._error) {
86 return html`<p><strong>Error: </strong>${this._error}</p>`;
87 }
88 return html`
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010089 ${this._processedAnnouncements().map(
90 ({ messageContent }) => html`
91 <mr-comment-content
92 .content=${messageContent}>
93 </mr-comment-content>`)}
Copybara854996b2021-09-07 19:36:02 +000094 `;
95 }
96
97 /** @override */
98 static get properties() {
99 return {
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100100 service: { type: String },
101 additionalAnnouncements: { type: Array },
102
103 // Properties from the currently logged in user, usually feched through
104 // Redux.
105 currentUserName: { type: String },
106 userGroups: { type: Array },
107 currentProject: { type: String },
108
109 // Private properties managing state from requests to Chops Dash.
110 _error: { type: String },
111 _announcements: { type: Array },
Copybara854996b2021-09-07 19:36:02 +0000112 };
113 }
114
115 /** @override */
116 constructor() {
117 super();
118
119 /** @type {string} */
120 this.service = undefined;
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100121 /** @type {Array<Announcement>} */
122 this.additionalAnnouncements = HARDCODED_ANNOUNCEMENTS;
123
124 this.currentUserName = '';
125 this.userGroups = [];
126 this.currentProject = '';
127
Copybara854996b2021-09-07 19:36:02 +0000128 /** @type {string} */
129 this._error = undefined;
130 /** @type {Array<Announcement>} */
131 this._announcements = [];
132
133 /** @type {number} Interval ID returned by window.setInterval. */
134 this._interval = undefined;
135 }
136
137 /** @override */
138 updated(changedProperties) {
139 if (changedProperties.has('service')) {
140 if (this.service) {
141 this.startRefresh();
142 } else {
143 this.stopRefresh();
144 }
145 }
146 }
147
148 /** @override */
149 disconnectedCallback() {
150 super.disconnectedCallback();
151
152 this.stopRefresh();
153 }
154
155 /**
156 * Set up autorefreshing logic or announcement information.
157 */
158 startRefresh() {
159 this.stopRefresh();
160 this.refresh();
161 this._interval = window.setInterval(() => this.refresh(), REFRESH_TIME_MS);
162 }
163
164 /**
165 * Logic for clearing refresh behavior.
166 */
167 stopRefresh() {
168 if (this._interval) {
169 window.clearInterval(this._interval);
170 }
171 }
172
173 /**
174 * Refresh the announcement banner.
175 */
176 async refresh() {
177 try {
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100178 const { announcements = [] } = await this.fetch(this.service);
Copybara854996b2021-09-07 19:36:02 +0000179 this._error = undefined;
180 this._announcements = announcements;
181 } catch (e) {
182 this._error = e.message;
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100183 this._announcements = HARDCODED_ANNOUNCEMENTS;
Copybara854996b2021-09-07 19:36:02 +0000184 }
185 }
186
187 /**
188 * Fetches the announcement for a given service.
189 * @param {string} service Name of the service to fetch from ChopsDash.
190 * ie: "monorail"
191 * @return {Promise<AnnouncementResponse>} ChopsDash response JSON.
192 * @throws {Error} If something went wrong while fetching.
193 */
194 async fetch(service) {
195 const message = {
196 retired: false,
197 platformName: service,
198 };
199
200 const response = await window.fetch(ANNOUNCEMENT_SERVICE, {
201 method: 'POST',
202 headers: FETCH_HEADERS,
203 body: JSON.stringify(message),
204 });
205
206 if (!response.ok) {
207 throw new Error('Something went wrong while fetching announcements');
208 }
209
210 // We can't use response.json() because of the XSSI prefix.
211 const text = await response.text();
212
213 if (!text.startsWith(XSSI_PREFIX)) {
214 throw new Error(`No XSSI prefix in announce response: ${XSSI_PREFIX}`);
215 }
216
217 return JSON.parse(text.substr(XSSI_PREFIX.length));
218 }
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100219
220 _processedAnnouncements() {
221 const announcements = [...this.additionalAnnouncements, ...this._announcements];
222
223 // Only show announcements relevant to the project the user is viewing and
224 // the group the user is part of, if applicable.
225 return announcements.filter(({ groups, projects }) => {
226 if (groups && groups.length && !this._isUserInGroups(groups,
227 this.userGroups, this.currentUserName)) {
228 return false;
229 }
230 if (projects && projects.length && !this._isViewingProject(projects,
231 this.currentProject)) {
232 return false;
233 }
234 return true;
235 });
236 }
237
238 /**
239 * Helper to check if the user is a member of the allowed groups.
240 * @param {Array<string>} allowedGroups
241 * @param {Array<{{userId: string, displayName: string}}>} userGroups
242 * @param {string} userEmail
243 */
244 _isUserInGroups(allowedGroups, userGroups, userEmail) {
245 const userGroupSet = new Set(userGroups.map(
246 ({ displayName }) => displayName.toLowerCase()));
247 return allowedGroups.find((group) => {
248 group = group.toLowerCase();
249
250 // Handle custom groups in Monorail like everyone@google.com
251 if (group.startsWith('everyone@')) {
252 let [_, suffix] = group.split('@');
253 suffix = '@' + suffix;
254 return userEmail.endsWith(suffix);
255 }
256
257 return userGroupSet.has(group);
258 });
259 }
260
261 _isViewingProject(projects, currentProject) {
262 return projects.find((project = "") => project.toLowerCase() === currentProject.toLowerCase());
263 }
Copybara854996b2021-09-07 19:36:02 +0000264}
265
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100266/** Redux-connected version of _ChopsAnnouncement. */
267export class ChopsAnnouncement extends connectStore(_ChopsAnnouncement) {
268 /** @override */
269 stateChanged(state) {
270 const { displayName, groups } = userV0.currentUser(state);
271 this.currentUserName = displayName;
272 this.userGroups = groups;
273
274 this.currentProject = projectV0.viewedProjectName(state);
275 }
276}
277
278customElements.define('chops-announcement-base', _ChopsAnnouncement);
Copybara854996b2021-09-07 19:36:02 +0000279customElements.define('chops-announcement', ChopsAnnouncement);