diff --git a/src/server_manager/web_app/app.spec.ts b/src/server_manager/web_app/app.spec.ts index 63d90e8b..9524da5b 100644 --- a/src/server_manager/web_app/app.spec.ts +++ b/src/server_manager/web_app/app.spec.ts @@ -22,6 +22,8 @@ import {Surveys} from '../model/survey'; import {App} from './app'; import {TokenManager} from './digitalocean_oauth'; import {DisplayServer, DisplayServerRepository, makeDisplayServer} from './display_server'; +import {AppRoot} from './ui_components/app-root.js'; +import {ServerView} from './ui_components/outline-server-view.js'; const TOKEN_WITH_NO_SERVERS = 'no-server-token'; const TOKEN_WITH_ONE_SERVER = 'one-server-token'; @@ -257,11 +259,12 @@ enum AppRootScreen { DIALOG } -class FakePolymerAppRoot implements polymer.Base { +class FakePolymerAppRoot extends AppRoot { events = new EventEmitter(); backgroundScreen = AppRootScreen.NONE; currentScreen = AppRootScreen.NONE; - serverView = {setServerTransferredData: () => {}, serverId: '', initHelpBubbles: () => {}}; + serverView = {setServerTransferredData: () => {}, serverId: '', initHelpBubbles: () => {}} as + unknown as ServerView; serverList: DisplayServer[] = []; is: 'fake-polymer-app-root'; @@ -290,9 +293,9 @@ class FakePolymerAppRoot implements polymer.Base { showModalDialog() { this.backgroundScreen = this.currentScreen; this.setScreen(AppRootScreen.DIALOG); - const promise = new Promise(() => {}); + const promise = new Promise(() => 0); // Supress Promise not handled warning. - promise.then(() => {}); + promise.then(v => v); return promise; } @@ -304,7 +307,7 @@ class FakePolymerAppRoot implements polymer.Base { this.backgroundScreen = AppRootScreen.NONE; } - getServerView() { + getServerView(serverId: string): ServerView { return this.serverView; } diff --git a/src/server_manager/web_app/app.ts b/src/server_manager/web_app/app.ts index df8fc538..e8d8b135 100644 --- a/src/server_manager/web_app/app.ts +++ b/src/server_manager/web_app/app.ts @@ -27,6 +27,9 @@ import * as digitalocean_server from './digitalocean_server'; import {DisplayServer, DisplayServerRepository, makeDisplayServer} from './display_server'; import {parseManualServerConfig} from './management_urls'; +import {AppRoot} from './ui_components/app-root.js'; +import {DisplayAccessKey, DisplayDataAmount, ServerView} from './ui_components/outline-server-view.js'; + // The Outline DigitalOcean team's referral code: // https://www.digitalocean.com/help/referral-program/ const UNUSED_DIGITALOCEAN_REFERRAL_CODE = '5ddb4219b716'; @@ -38,20 +41,6 @@ const CHANGE_HOSTNAME_VERSION = '1.2.0'; export const DATA_LIMITS_AVAILABILITY_DATE = new Date('2020-06-02'); const MAX_ACCESS_KEY_DATA_LIMIT_BYTES = 50 * (10 ** 9); // 50GB -interface UiAccessKey { - id: string; - placeholderName: string; - name: string; - accessUrl: string; - transferredBytes: number; - relativeTraffic: number; -} - -interface DisplayDataAmount { - unit: 'MB'|'GB'; - value: number; -} - function dataLimitToDisplayDataAmount(limit: server.DataLimit): DisplayDataAmount|null { if (!limit) { return null; @@ -99,7 +88,7 @@ async function computeDefaultAccessKeyDataLimit( } } -async function showHelpBubblesOnce(serverView: polymer.Base) { +async function showHelpBubblesOnce(serverView: ServerView) { if (!window.localStorage.getItem('addAccessKeyHelpBubble-dismissed')) { await serverView.showAddAccessKeyHelpBubble(); window.localStorage.setItem('addAccessKeyHelpBubble-dismissed', 'true'); @@ -137,7 +126,7 @@ export class App { private serverBeingCreated: server.ManagedServer; constructor( - private appRoot: polymer.Base, private readonly version: string, + private appRoot: AppRoot, private readonly version: string, private createDigitalOceanSession: DigitalOceanSessionFactory, private createDigitalOceanServerRepository: DigitalOceanServerRepositoryFactory, private manualServerRepository: server.ManualServerRepository, @@ -565,7 +554,7 @@ export class App { }); } else { // Display the unreachable server state within the server view. - const serverView = this.appRoot.getServerView(displayServer.id); + const serverView = this.appRoot.getServerView(displayServer.id) as ServerView; serverView.isServerReachable = false; serverView.isServerManaged = isManagedServer(server); serverView.serverName = displayServer.name; // Don't get the name from the remote server. @@ -892,7 +881,7 @@ export class App { this.showTransferStats(selectedServer, view); } - private showMetricsOptInWhenNeeded(selectedServer: server.Server, serverView: polymer.Base) { + private showMetricsOptInWhenNeeded(selectedServer: server.Server, serverView: ServerView) { const showMetricsOptInOnce = () => { // Sanity check to make sure the running server is still displayed, i.e. // it hasn't been deleted. @@ -922,7 +911,7 @@ export class App { } } - private async refreshTransferStats(selectedServer: server.Server, serverView: polymer.Base) { + private async refreshTransferStats(selectedServer: server.Server, serverView: ServerView) { try { const stats = await selectedServer.getDataUsage(); let totalBytes = 0; @@ -963,7 +952,7 @@ export class App { } } - private showTransferStats(selectedServer: server.Server, serverView: polymer.Base) { + private showTransferStats(selectedServer: server.Server, serverView: ServerView) { this.refreshTransferStats(selectedServer, serverView); // Get transfer stats once per minute for as long as server is selected. const statsRefreshRateMs = 60 * 1000; @@ -986,7 +975,7 @@ export class App { // Converts the access key from the remote service format to the // format used by outline-server-view. - private convertToUiAccessKey(remoteAccessKey: server.AccessKey): UiAccessKey { + private convertToUiAccessKey(remoteAccessKey: server.AccessKey): DisplayAccessKey { return { id: remoteAccessKey.id, placeholderName: `${this.appRoot.localize('key', 'keyId', remoteAccessKey.id)}`, @@ -1224,7 +1213,8 @@ export class App { this.appRoot.showError(this.appRoot.localize('error-server-rename')); const oldName = this.selectedServer.getName(); view.serverName = oldName; - view.$.serverSettings.serverName = oldName; + // tslint:disable-next-line:no-any + (view.$.serverSettings as any).serverName = oldName; } } diff --git a/src/server_manager/web_app/main.ts b/src/server_manager/web_app/main.ts index 18bc66ab..3a99a743 100644 --- a/src/server_manager/web_app/main.ts +++ b/src/server_manager/web_app/main.ts @@ -24,6 +24,7 @@ import * as digitalocean_server from './digitalocean_server'; import {DisplayServerRepository} from './display_server'; import {ManualServerRepository} from './manual_server'; import {DEFAULT_PROMPT_IMPRESSION_DELAY_MS, OutlineSurveys} from './survey'; +import {AppRoot} from './ui_components/app-root.js'; const SUPPORTED_LANGUAGES: {[key: string]: {id: string, dir: string}} = { 'am': {id: 'am', dir: 'ltr'}, @@ -101,7 +102,7 @@ document.addEventListener('WebComponentsReady', () => { document.documentElement.setAttribute('dir', languageDirection); // NOTE: this cast is safe and allows us to leverage Polymer typings since we haven't migrated to // Polymer 3, which adds typescript support. - const appRoot = document.getElementById('appRoot') as unknown as polymer.Base; + const appRoot = document.getElementById('appRoot') as unknown as AppRoot; appRoot.setLanguage(language.string(), languageDirection); new App( appRoot, version, digitalocean_api.createDigitalOceanSession, diff --git a/src/server_manager/web_app/ui_components/app-root.js b/src/server_manager/web_app/ui_components/app-root.js index a343134f..b8a79dc1 100644 --- a/src/server_manager/web_app/ui_components/app-root.js +++ b/src/server_manager/web_app/ui_components/app-root.js @@ -36,7 +36,6 @@ import './outline-manual-server-entry.js'; import './outline-modal-dialog.js'; import './outline-region-picker-step.js'; import './outline-server-progress-step.js'; -import './outline-server-view.js'; import './outline-tos-view.js'; import {AppLocalizeBehavior} from '@polymer/app-localize-behavior/app-localize-behavior.js'; @@ -44,9 +43,13 @@ import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js'; import {html} from '@polymer/polymer/lib/utils/html-tag.js'; import {PolymerElement} from '@polymer/polymer/polymer-element.js'; +import {DisplayServer} from '../display_server'; + +import {ServerView} from './outline-server-view.js'; + const TOS_ACK_LOCAL_STORAGE_KEY = 'tos-ack'; -class AppRoot extends mixinBehaviors +export class AppRoot extends mixinBehaviors ([AppLocalizeBehavior], PolymerElement) { static get template() { return html` @@ -467,30 +470,15 @@ class AppRoot extends mixinBehaviors static get properties() { return { // Properties language and useKeyIfMissing are used by Polymer.AppLocalizeBehavior. - language: { - type: String, - readonly: true, - }, - useKeyIfMissing: { - type: Boolean, - value: true, - }, - serverList: { - type: Array, - value: [], - }, - selectedServer: { - type: Object, // DisplayServer in display_server.ts - value: undefined, - }, + language: {type: String, readonly: true}, + useKeyIfMissing: {type: Boolean}, + serverList: {type: Array}, + selectedServer: {type: Object}, hasManualServers: { type: Boolean, computed: '_computeHasManualServers(serverList.*)', }, - adminEmail: { - type: String, - value: '', - }, + adminEmail: {type: String}, isSignedInToDigitalOcean: { type: Boolean, computed: '_computeIsSignedInToDigitalOcean(adminEmail)', @@ -505,14 +493,8 @@ class AppRoot extends mixinBehaviors type: Boolean, computed: '_computeHasAcceptedTermsOfService(userAcceptedTos)', }, - currentPage: { - type: String, - value: 'intro', - }, - shouldShowSideBar: { - type: Boolean, - value: false, - }, + currentPage: {type: String}, + shouldShowSideBar: {type: Boolean}, sideBarMarginClass: { type: String, computed: '_computeSideBarMarginClass(shouldShowSideBar)', @@ -522,6 +504,18 @@ class AppRoot extends mixinBehaviors constructor() { super(); + /** @type {DisplayServer} */ + this.selectedServer = undefined; + this.language = ''; + this.useKeyIfMissing = true; + /** @type {DisplayServer[]} */ + this.serverList = []; + this.adminEmail = ''; + this.outlineVersion = ''; + this.currentPage = 'intro'; + this.shouldShowSideBar = false; + + this.addEventListener('RegionSelected', this.handleRegionSelected); this.addEventListener( 'SetUpGenericCloudProviderRequested', this.handleSetUpGenericCloudProviderRequested); @@ -530,6 +524,11 @@ class AppRoot extends mixinBehaviors this.addEventListener('ManualServerEntryCancelled', this.handleManualCancelled); } + /** + * Sets the language and direction for the application + * @param {string} newLanguage + * @param {string} langDir + */ setLanguage(newLanguage, langDir) { const messagesUrl = `./messages/${newLanguage}.json`; this.loadResources(messagesUrl, newLanguage); @@ -573,6 +572,10 @@ class AppRoot extends mixinBehaviors return this.$.manualEntry; } + /** + * @param {string} serverName + * @param {boolean} showCancelButton + */ showProgress(serverName, showCancelButton) { this.currentPage = 'serverProgressStep'; this.$.serverProgressStep.serverName = serverName; @@ -585,6 +588,11 @@ class AppRoot extends mixinBehaviors this.currentPage = 'serverView'; } + /** + * Gets the ServerView for the server given by its id + * @param {string} displayServerId + * @returns {ServerView} + */ getServerView(displayServerId) { if (!displayServerId) { return null; @@ -593,7 +601,7 @@ class AppRoot extends mixinBehaviors return this.$.serverView.querySelector(`#serverView-${selectedServerId}`); } - handleRegionSelected(e) { + handleRegionSelected(/** @type {Event} */ e) { this.fire('SetUpServerRequested', { regionId: this.$.regionPicker.getSelectedRegionId(), }); @@ -611,7 +619,7 @@ class AppRoot extends mixinBehaviors this.handleManualServerSelected('gcp'); } - handleManualServerSelected(cloudProvider) { + handleManualServerSelected(/** @type {'generic'|'aws'|'gcp'} */ cloudProvider) { this.$.manualEntry.clear(); this.$.manualEntry.cloudProvider = cloudProvider; this.currentPage = 'manualEntry'; @@ -621,14 +629,19 @@ class AppRoot extends mixinBehaviors this.currentPage = 'intro'; } - showError(errorMsg) { + showError(/** @type {string} */ errorMsg) { this.showToast(errorMsg, Infinity); } - showNotification(message, durationMs = 3000) { + showNotification(/** @type {string} */ message, durationMs = 3000) { this.showToast(message, durationMs); } + /** + * Show a toast with a message + * @param {string} message + * @param {number} duration in seconds + */ showToast(message, duration) { const toast = this.$.toast; toast.close(); @@ -647,8 +660,11 @@ class AppRoot extends mixinBehaviors this.$.toast.close(); } - // cb is a function which accepts a single boolean which is true iff - // the user chose to retry the failing operation. + /** + * @param {(retry: boolean) => void} cb a function which accepts a single boolean which is true + * iff + * the user chose to retry the failing operation. + */ showConnectivityDialog(cb) { const dialogTitle = this.localize('error-connectivity-title'); const dialogText = this.localize('error-connectivity'); @@ -668,6 +684,10 @@ class AppRoot extends mixinBehaviors }); } + /** + * @param {string} errorTitle + * @param {string} errorText + */ showManualServerError(errorTitle, errorText) { this.showModalDialog(errorTitle, errorText, [this.localize('cancel'), this.localize('retry')]) .then(clickedButtonIndex => { @@ -730,7 +750,7 @@ class AppRoot extends mixinBehaviors this.fire('SignOutRequested'); } - openManualInstallFeedback(prepopulatedMessage) { + openManualInstallFeedback(/** @type {string} */ prepopulatedMessage) { this.$.feedbackDialog.open(prepopulatedMessage, true); } @@ -738,7 +758,7 @@ class AppRoot extends mixinBehaviors this.$.shareDialog.open(accessKey, s3Url); } - openGetConnectedDialog(inviteUrl) { + openGetConnectedDialog(/** @type {string} */ inviteUrl) { const dialog = this.$.getConnectedDialog; if (dialog.children.length > 1) { return; // The iframe is already loading. @@ -766,7 +786,12 @@ class AppRoot extends mixinBehaviors this.$.metricsDialog.showMetricsOptInDialog(); } - // Returns a Promise which fulfills with the index of the button clicked. + /** + * @param {string} title + * @param {string} text + * @param {string[]} buttons + * @returns {Promise} a Promise which fulfills with the index of the button clicked. + */ showModalDialog(title, text, buttons) { return this.$.modalDialog.open(title, text, buttons); } diff --git a/src/server_manager/web_app/ui_components/outline-server-view.js b/src/server_manager/web_app/ui_components/outline-server-view.js index c6fa895a..7369c446 100644 --- a/src/server_manager/web_app/ui_components/outline-server-view.js +++ b/src/server_manager/web_app/ui_components/outline-server-view.js @@ -55,6 +55,23 @@ function makePublicEvent(eventName, detail) { return new CustomEvent(eventName, params); } +/** + * An access key to be displayed + * @typedef {Object} DisplayAccessKey + * @prop {string} id + * @prop {string} placeholderName + * @prop {string} name + * @prop {string} accessUrl + * @prop {number} transferredBytes + * @prop {number} relativeTraffic + */ + +/** + * @typedef {Object} DisplayDataAmount + * @prop {'MB'|'GB'} unit + * @prop {number} value + */ + export class ServerView extends DirMixin(PolymerElement) { static get template() { return html` @@ -564,60 +581,35 @@ export class ServerView extends DirMixin(PolymerElement) { serverName: String, serverHostname: String, serverVersion: String, - isHostnameEditable: {type: Boolean, value: false}, + isHostnameEditable: {type: Boolean}, serverManagementApiUrl: String, serverPortForNewAccessKeys: Number, - isAccessKeyPortEditable: {type: Boolean, value: false}, + isAccessKeyPortEditable: {type: Boolean}, serverCreationDate: String, serverLocation: String, - accessKeyDataLimit: {type: Object, value: null}, + accessKeyDataLimit: {type: Object}, isAccessKeyDataLimitEnabled: {type: Boolean}, - supportsAccessKeyDataLimit: - {type: Boolean, value: false}, // Whether the server supports data limits. - dataLimitsAvailabilityDate: - {type: String}, // Date by which the feature stops being an experiment. + supportsAccessKeyDataLimit: {type: Boolean}, + dataLimitsAvailabilityDate: {type: String}, isServerManaged: Boolean, isServerReachable: Boolean, - // Callback for retrying to display an unreachable server. retryDisplayingServer: Function, - // myConnection has the same fields as each item in accessKeyRows. It may - // be unset in some old versions of Outline that allowed deleting this - // row. myConnection: Object, totalInboundBytes: Number, - accessKeyRows: {type: Array, value: []}, + accessKeyRows: {type: Array}, hasNonAdminAccessKeys: Boolean, metricsEnabled: Boolean, - // Initialize monthlyOutboundTransferBytes and monthlyCost to 0, so they can - // be bound to hidden attributes. Initializing to undefined does not - // cause hidden$=... expressions to be evaluated and so elements may be - // shown incorrectly. See: - // https://stackoverflow.com/questions/33700125/polymer-1-0-hidden-attribute-negate-operator - // https://www.polymer-project.org/1.0/docs/devguide/data-binding.html - monthlyOutboundTransferBytes: {type: Number, value: 0}, - monthlyCost: {type: Number, value: 0}, - selectedTab: { - type: String, - value: 'connections', - }, + monthlyOutboundTransferBytes: {type: Number}, + monthlyCost: {type: Number}, + selectedTab: {type: String}, managedServerUtilzationPercentage: { type: Number, computed: '_computeManagedServerUtilzationPercentage(totalInboundBytes, monthlyOutboundTransferBytes)', }, - accessKeySortBy: { - type: String, - value: 'name', - }, - accessKeySortDirection: { - // 1 == ascending, -1 == descending - type: Number, - value: 1, - }, - localize: { - type: Function, - readonly: true, - }, + accessKeySortBy: {type: String}, + accessKeySortDirection: {type: Number}, + localize: {type: Function, readonly: true}, }; } @@ -629,23 +621,72 @@ export class ServerView extends DirMixin(PolymerElement) { ]; } - // Parameter `accessKey` has the format { - // id: string, - // placeholderName: string, - // name: string, - // accessUrl: string, - // transferredBytes: number; - // relativeTraffic: number; - // } - addAccessKey(accessKey) { - // TODO(fortuna): Restore loading animation. - // TODO(fortuna): Restore highlighting. - this.push('accessKeyRows', accessKey); - // Force render the access key list so that the input is present in the DOM - this.$.accessKeysContainer.querySelector('dom-repeat').render(); - const input = this.shadowRoot.querySelector(`#access-key-${accessKey.id}`); - input.select(); - } + constructor() { + super(); + this.serverId = ''; + this.serverName = ''; + this.serverHostname = ''; + this.serverVersion = ''; + this.isHostnameEditable = false; + this.serverManagementApiUrl = ''; + /** @type {number} */ + this.serverPortForNewAccessKeys = null; + this.isAccessKeyPortEditable = false; + this.serverCreationDate = ''; + this.serverLocation = ''; + /** @type {DisplayDataAmount} */ + this.accessKeyDataLimit = null; + this.isAccessKeyDataLimitEnabled = false; + /** Whether the server supports data limits. */ + this.supportsAccessKeyDataLimit = false; + /** Date by which the feature stops being an experiment. */ + this.dataLimitsAvailabilityDate = ''; + this.isServerManaged = false; + this.isServerReachable = false; + /** + * Callback for retrying to display an unreachable server. + * @type {() => void)} + */ + this.retryDisplayingServer = null; + /** + * myConnection has the same fields as each item in accessKeyRows. It may + * be unset in some old versions of Outline that allowed deleting this row + * @type {DisplayAccessKey} + */ + this.myConnection = null; + this.totalInboundBytes = 0; + /** @type {DisplayAccessKey[]} */ + this.accessKeyRows = []; + this.hasNonAdminAccessKeys = false; + this.metricsEnabled = true; + // Initialize monthlyOutboundTransferBytes and monthlyCost to 0, so they can + // be bound to hidden attributes. Initializing to undefined does not + // cause hidden$=... expressions to be evaluated and so elements may be + // shown incorrectly. See: + // https://stackoverflow.com/questions/33700125/polymer-1-0-hidden-attribute-negate-operator + // https://www.polymer-project.org/1.0/docs/devguide/data-binding.html + this.monthlyOutboundTransferBytes = 0; + this.monthlyCost = 0; + this.selectedTab = 'connections'; + this.accessKeySortBy = 'name'; + /** The direction to sort: 1 == ascending, -1 == descending */ + this.accessKeySortDirection = 1; + /** @type {(msgId: string, ...params: string[]) => string} */ + this.localize = null; + } + + /** + * @param {DisplayAccessKey} accessKey + */ + addAccessKey(accessKey) { + // TODO(fortuna): Restore loading animation. + // TODO(fortuna): Restore highlighting. + this.push('accessKeyRows', accessKey); + // Force render the access key list so that the input is present in the DOM + this.$.accessKeysContainer.querySelector('dom-repeat').render(); + const input = this.shadowRoot.querySelector(`#access-key-${accessKey.id}`); + input.select(); + } removeAccessKey(accessKeyId) { for (let ui in this.accessKeyRows) { diff --git a/tsconfig.json b/tsconfig.json index 8ffbc06c..e5ec6a74 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "outDir": "build/server_manager/web_app/js", "sourceMap": true, "experimentalDecorators":true, + "allowJs": true, }, "include": [ "src/**/*.ts"